Files

418 lines
11 KiB
Go
Raw Permalink Normal View History

2026-01-13 14:52:02 +05:30
package sandbox
import (
"embed"
"fmt"
"os"
"path/filepath"
"sort"
2026-01-13 14:52:02 +05:30
"strings"
"sync"
"github.com/safedep/dry/log"
2026-01-13 14:52:02 +05:30
"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
2026-01-13 14:52:02 +05:30
}
func newDefaultProfileRegistry(opts ...RegistryOption) (*defaultProfileRegistry, error) {
options := &registryOptions{}
for _, opt := range opts {
opt(options)
}
2026-01-13 14:52:02 +05:30
registry := &defaultProfileRegistry{
profiles: make(map[string]*SandboxPolicy),
builtins: make(map[string]struct{}),
builtinYAML: make(map[string][]byte),
userProfileDir: options.userProfileDir,
2026-01-13 14:52:02 +05:30
}
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.
2026-01-13 14:52:02 +05:30
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
2026-01-13 14:52:02 +05:30
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)
2026-01-13 14:52:02 +05:30
}
// Basic validation (without inheritance resolution)
2026-01-13 14:52:02 +05:30
if err := policy.Validate(); err != nil {
return fmt.Errorf("invalid profile %s: %w: %w", entry.Name(), ErrProfileInvalid, err)
2026-01-13 14:52:02 +05:30
}
r.mu.Lock()
r.profiles[policy.Name] = policy
r.builtins[policy.Name] = struct{}{}
r.builtinYAML[policy.Name] = data
2026-01-13 14:52:02 +05:30
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 = ""
2026-01-13 14:52:02 +05:30
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.
2026-01-13 14:52:02 +05:30
func (r *defaultProfileRegistry) GetProfile(name string) (*SandboxPolicy, error) {
r.mu.RLock()
if _, isBuiltin := r.builtins[name]; isBuiltin {
policy := r.profiles[name]
2026-01-13 14:52:02 +05:30
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)
}
2026-01-13 14:52:02 +05:30
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
2026-01-13 14:52:02 +05:30
}
// LoadCustomProfile loads a policy from a custom YAML file path.
// Inheritance is resolved if the profile inherits from a built-in profile.
2026-01-13 14:52:02 +05:30
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)
2026-01-13 14:52:02 +05:30
}
// Basic validation
2026-01-13 14:52:02 +05:30
if err := policy.Validate(); err != nil {
return nil, fmt.Errorf("invalid custom profile %s: %w: %w", path, ErrProfileInvalid, err)
2026-01-13 14:52:02 +05:30
}
// 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)
}
2026-01-13 14:52:02 +05:30
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) {
2026-01-13 14:52:02 +05:30
r.mu.RLock()
defer r.mu.RUnlock()
data, ok := r.builtinYAML[name]
if !ok {
return nil, false
2026-01-13 14:52:02 +05:30
}
out := make([]byte, len(data))
copy(out, data)
return out, true
2026-01-13 14:52:02 +05:30
}
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()
}