mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* fix(sandbox): classify helper-tool errors with usefulerror Sandbox helper commands (profile lint/diff/show/init/list) used to bubble up plain fmt.Errorf chains from the registry layer, which the TUI then classified as Unknown and decorated with a bug-report link. Wrap each error path at the cmd/sandbox boundary so the TUI prints NotFound, InvalidArgument, or PermissionDenied with actionable hints instead. Closes #269 * refactor(sandbox): classify registry errors via sentinel wrapping Replace the fragile substring match in profileLoadError with errors.Is against new sandbox.ErrProfileNotFound / sandbox.ErrProfileInvalid sentinels. Every fmt.Errorf in registry.go that previously communicated "missing" or "malformed" by message text now wraps the corresponding sentinel, so the cmd layer can classify without inspecting strings. * fix(sandbox): detect IO error class when wrapping helper errors Replace static ErrCodeUnknown / ErrCodePermissionDenied wrappings with ioErrorCode, which inspects the error chain for fs.ErrPermission and fs.ErrNotExist before falling back. Applied to runProfileList (where an unreadable user profile directory now classifies as PermissionDenied), registryInitError, and the stat/MkdirAll/WriteFile paths in profile init. Also drop redundant doc comments on helpers whose names are self-evident. --------- Co-authored-by: Claude <noreply@anthropic.com>
418 lines
11 KiB
Go
418 lines
11 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
//go:embed profiles/*.yml
|
|
var profilesFS embed.FS
|
|
|
|
type defaultProfileRegistry struct {
|
|
mu sync.RWMutex
|
|
profiles map[string]*SandboxPolicy
|
|
builtins map[string]struct{}
|
|
builtinYAML map[string][]byte
|
|
userProfileDir string
|
|
}
|
|
|
|
func newDefaultProfileRegistry(opts ...RegistryOption) (*defaultProfileRegistry, error) {
|
|
options := ®istryOptions{}
|
|
for _, opt := range opts {
|
|
opt(options)
|
|
}
|
|
|
|
registry := &defaultProfileRegistry{
|
|
profiles: make(map[string]*SandboxPolicy),
|
|
builtins: make(map[string]struct{}),
|
|
builtinYAML: make(map[string][]byte),
|
|
userProfileDir: options.userProfileDir,
|
|
}
|
|
|
|
if err := registry.loadBuiltinProfiles(); err != nil {
|
|
return nil, fmt.Errorf("failed to load built-in sandbox profiles: %w", err)
|
|
}
|
|
|
|
return registry, nil
|
|
}
|
|
|
|
// loadBuiltinProfiles loads all built-in YAML profiles from the embedded filesystem.
|
|
// Inheritance is resolved in a second pass after all profiles are loaded.
|
|
func (r *defaultProfileRegistry) loadBuiltinProfiles() error {
|
|
entries, err := profilesFS.ReadDir("profiles")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read profiles directory: %w", err)
|
|
}
|
|
|
|
// First pass: load all profiles without resolving inheritance
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yml") {
|
|
continue
|
|
}
|
|
|
|
profilePath := filepath.Join("profiles", entry.Name())
|
|
data, err := profilesFS.ReadFile(profilePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read profile %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
policy, err := parsePolicy(data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse profile %s: %w: %w", entry.Name(), ErrProfileInvalid, err)
|
|
}
|
|
|
|
// Basic validation (without inheritance resolution)
|
|
if err := policy.Validate(); err != nil {
|
|
return fmt.Errorf("invalid profile %s: %w: %w", entry.Name(), ErrProfileInvalid, err)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
r.profiles[policy.Name] = policy
|
|
r.builtins[policy.Name] = struct{}{}
|
|
r.builtinYAML[policy.Name] = data
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// Second pass: resolve inheritance and validate
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
for name, policy := range r.profiles {
|
|
if policy.Inherits != "" {
|
|
if err := r.resolveInheritance(policy); err != nil {
|
|
return fmt.Errorf("failed to resolve inheritance for profile %s: %w", name, err)
|
|
}
|
|
|
|
// Validate after inheritance resolution
|
|
if err := policy.ValidateResolved(); err != nil {
|
|
return fmt.Errorf("invalid profile %s after inheritance: %w: %w", name, ErrProfileInvalid, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveInheritance resolves the inheritance chain for a policy.
|
|
// This function is called during registry initialization and modifies the policy in place.
|
|
// Assumes registry mutex is already held.
|
|
func (r *defaultProfileRegistry) resolveInheritance(child *SandboxPolicy) error {
|
|
if child.Inherits == "" {
|
|
return nil
|
|
}
|
|
|
|
// Look up parent profile (must be a built-in profile)
|
|
parent, exists := r.profiles[child.Inherits]
|
|
if !exists {
|
|
return fmt.Errorf("%w: parent profile '%s' (only built-in profiles can be inherited)", ErrProfileNotFound, child.Inherits)
|
|
}
|
|
|
|
// Prevent inheritance chains (parent must not itself inherit)
|
|
if parent.Inherits != "" {
|
|
return fmt.Errorf("%w: inheritance chains not allowed: parent profile '%s' inherits from '%s'", ErrProfileInvalid, parent.Name, parent.Inherits)
|
|
}
|
|
|
|
// Merge parent into child
|
|
child.MergeWithParent(parent)
|
|
|
|
// Clear the inherits field after resolution to indicate it's been processed
|
|
child.Inherits = ""
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetProfile retrieves a policy by name.
|
|
// Resolution order: built-in profiles first, then user profile directory
|
|
// (by bare name, looking up <name>.yml or <name>.yaml), then a literal file path.
|
|
func (r *defaultProfileRegistry) GetProfile(name string) (*SandboxPolicy, error) {
|
|
r.mu.RLock()
|
|
if _, isBuiltin := r.builtins[name]; isBuiltin {
|
|
policy := r.profiles[name]
|
|
r.mu.RUnlock()
|
|
return policy, nil
|
|
}
|
|
r.mu.RUnlock()
|
|
|
|
path, found, err := r.findUserProfileByName(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if found {
|
|
return r.LoadCustomProfile(path)
|
|
}
|
|
|
|
if fileExists(name) {
|
|
return r.LoadCustomProfile(name)
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: %s (not a built-in profile, no matching user profile, and file does not exist)", ErrProfileNotFound, name)
|
|
}
|
|
|
|
// findUserProfileByName looks for `<name>.yml` then `<name>.yaml` under
|
|
// the user profile directory. Returns the absolute path if found.
|
|
func (r *defaultProfileRegistry) findUserProfileByName(name string) (string, bool, error) {
|
|
if r.userProfileDir == "" || !isBareProfileName(name) {
|
|
return "", false, nil
|
|
}
|
|
|
|
files, err := r.userProfileFiles()
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("failed to read user profile directory %s: %w", r.userProfileDir, err)
|
|
}
|
|
for _, file := range files {
|
|
if file.name == name {
|
|
return file.path, true, nil
|
|
}
|
|
}
|
|
|
|
return "", false, nil
|
|
}
|
|
|
|
func isBareProfileName(name string) bool {
|
|
return name != "" && name == filepath.Base(name) && name != "." && name != ".."
|
|
}
|
|
|
|
// UserProfileDir returns the directory scanned for user profiles.
|
|
func (r *defaultProfileRegistry) UserProfileDir() string {
|
|
return r.userProfileDir
|
|
}
|
|
|
|
// ListUserProfiles enumerates *.yml / *.yaml files under UserProfileDir().
|
|
// A missing directory returns an empty slice with no error. Profiles whose
|
|
// name collides with a built-in are marked as Shadowed.
|
|
func (r *defaultProfileRegistry) ListUserProfiles() ([]ProfileInfo, error) {
|
|
files, err := r.userProfileFiles()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read user profile directory %s: %w", r.userProfileDir, err)
|
|
}
|
|
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
profiles := make([]ProfileInfo, 0, len(files))
|
|
for _, file := range files {
|
|
_, shadowed := r.builtins[file.name]
|
|
|
|
profiles = append(profiles, ProfileInfo{
|
|
Name: file.name,
|
|
Path: file.path,
|
|
Shadowed: shadowed,
|
|
})
|
|
}
|
|
|
|
return profiles, nil
|
|
}
|
|
|
|
// LoadCustomProfile loads a policy from a custom YAML file path.
|
|
// Inheritance is resolved if the profile inherits from a built-in profile.
|
|
func (r *defaultProfileRegistry) LoadCustomProfile(path string) (*SandboxPolicy, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read custom profile %s: %w", path, err)
|
|
}
|
|
|
|
policy, err := parsePolicy(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse custom profile %s: %w: %w", path, ErrProfileInvalid, err)
|
|
}
|
|
|
|
// Basic validation
|
|
if err := policy.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid custom profile %s: %w: %w", path, ErrProfileInvalid, err)
|
|
}
|
|
|
|
// Resolve inheritance if present
|
|
if policy.Inherits != "" {
|
|
r.mu.RLock()
|
|
parent, exists := r.profiles[policy.Inherits]
|
|
r.mu.RUnlock()
|
|
|
|
if !exists {
|
|
return nil, fmt.Errorf("%w: custom profile %s inherits from unknown profile '%s' (only built-in profiles can be inherited)", ErrProfileNotFound, path, policy.Inherits)
|
|
}
|
|
|
|
// Prevent inheritance chains
|
|
if parent.Inherits != "" {
|
|
return nil, fmt.Errorf("%w: custom profile %s parent '%s' inherits from '%s' (chains not allowed)", ErrProfileInvalid, path, parent.Name, parent.Inherits)
|
|
}
|
|
|
|
// Merge parent into child
|
|
policy.MergeWithParent(parent)
|
|
policy.Inherits = ""
|
|
}
|
|
|
|
// Validate after inheritance resolution
|
|
if err := policy.ValidateResolved(); err != nil {
|
|
return nil, fmt.Errorf("invalid custom profile %s after inheritance: %w: %w", path, ErrProfileInvalid, err)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
r.profiles[path] = policy
|
|
r.mu.Unlock()
|
|
|
|
return policy, nil
|
|
}
|
|
|
|
// ListProfiles returns all discoverable profiles: built-ins first, then user
|
|
// profiles (including shadowed entries so the cmd layer can warn the user).
|
|
func (r *defaultProfileRegistry) ListProfiles() ([]ProfileSummary, error) {
|
|
r.mu.RLock()
|
|
builtinNames := make([]string, 0, len(r.builtins))
|
|
for name := range r.builtins {
|
|
builtinNames = append(builtinNames, name)
|
|
}
|
|
sort.Strings(builtinNames)
|
|
|
|
summaries := make([]ProfileSummary, 0, len(builtinNames))
|
|
for _, name := range builtinNames {
|
|
p := r.profiles[name]
|
|
summaries = append(summaries, ProfileSummary{
|
|
Name: name,
|
|
Source: ProfileSourceBuiltin,
|
|
Inherits: p.Inherits,
|
|
PackageManagers: append([]string(nil), p.PackageManagers...),
|
|
Description: p.Description,
|
|
})
|
|
}
|
|
r.mu.RUnlock()
|
|
|
|
files, err := r.userProfileFiles()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read user profile directory %s: %w", r.userProfileDir, err)
|
|
}
|
|
|
|
userEntries := make([]ProfileSummary, 0, len(files))
|
|
for _, file := range files {
|
|
r.mu.RLock()
|
|
_, shadowed := r.builtins[file.name]
|
|
r.mu.RUnlock()
|
|
|
|
summary := ProfileSummary{
|
|
Name: file.name,
|
|
Source: ProfileSourceUser,
|
|
Path: file.path,
|
|
Shadowed: shadowed,
|
|
}
|
|
|
|
data, err := os.ReadFile(file.path)
|
|
if err != nil {
|
|
log.Warnf("failed to read user profile %s: %v", file.path, err)
|
|
userEntries = append(userEntries, summary)
|
|
continue
|
|
}
|
|
|
|
var parsed SandboxPolicy
|
|
if err := yaml.Unmarshal(data, &parsed); err != nil {
|
|
log.Warnf("failed to parse user profile %s: %v", file.path, err)
|
|
userEntries = append(userEntries, summary)
|
|
continue
|
|
}
|
|
|
|
summary.Inherits = parsed.Inherits
|
|
summary.PackageManagers = parsed.PackageManagers
|
|
summary.Description = parsed.Description
|
|
userEntries = append(userEntries, summary)
|
|
}
|
|
|
|
sort.Slice(userEntries, func(i, j int) bool {
|
|
return userEntries[i].Name < userEntries[j].Name
|
|
})
|
|
|
|
return append(summaries, userEntries...), nil
|
|
}
|
|
|
|
type userProfileFile struct {
|
|
name string
|
|
path string
|
|
}
|
|
|
|
func (r *defaultProfileRegistry) userProfileFiles() ([]userProfileFile, error) {
|
|
if r.userProfileDir == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(r.userProfileDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
byName := make(map[string]userProfileFile, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
ext := filepath.Ext(entry.Name())
|
|
if ext != ".yml" && ext != ".yaml" {
|
|
continue
|
|
}
|
|
|
|
name := strings.TrimSuffix(entry.Name(), ext)
|
|
if !isBareProfileName(name) {
|
|
continue
|
|
}
|
|
|
|
file := userProfileFile{
|
|
name: name,
|
|
path: filepath.Join(r.userProfileDir, entry.Name()),
|
|
}
|
|
|
|
if existing, ok := byName[name]; ok && filepath.Ext(existing.path) == ".yml" {
|
|
continue
|
|
}
|
|
byName[name] = file
|
|
}
|
|
|
|
files := make([]userProfileFile, 0, len(byName))
|
|
for _, file := range byName {
|
|
files = append(files, file)
|
|
}
|
|
sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
|
|
return files, nil
|
|
}
|
|
|
|
// BuiltinProfileYAML returns the embedded YAML for a built-in profile.
|
|
func (r *defaultProfileRegistry) BuiltinProfileYAML(name string) ([]byte, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
data, ok := r.builtinYAML[name]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
out := make([]byte, len(data))
|
|
copy(out, data)
|
|
return out, true
|
|
}
|
|
|
|
func parsePolicy(data []byte) (*SandboxPolicy, error) {
|
|
var policy SandboxPolicy
|
|
|
|
if err := yaml.Unmarshal(data, &policy); err != nil {
|
|
return nil, fmt.Errorf("failed to parse policy from YAML: %w", err)
|
|
}
|
|
|
|
return &policy, nil
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return !info.IsDir()
|
|
}
|