2026-01-13 14:52:02 +05:30
package sandbox
import (
"embed"
"fmt"
"os"
"path/filepath"
2026-05-19 14:40:54 +05:30
"sort"
2026-01-13 14:52:02 +05:30
"strings"
"sync"
2026-05-19 14:40:54 +05:30
"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 {
2026-05-19 14:40:54 +05:30
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
}
2026-05-19 14:40:54 +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 {
2026-05-19 14:40:54 +05:30
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.
2026-01-14 10:50:39 +05:30
// 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 )
}
2026-01-14 10:50:39 +05:30
// 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 {
2026-05-21 19:34:29 +05:30
return fmt . Errorf ( "failed to parse profile %s: %w: %w" , entry . Name (), ErrProfileInvalid , err )
2026-01-13 14:52:02 +05:30
}
2026-01-14 10:50:39 +05:30
// Basic validation (without inheritance resolution)
2026-01-13 14:52:02 +05:30
if err := policy . Validate (); err != nil {
2026-05-21 19:34:29 +05:30
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
2026-05-19 14:40:54 +05:30
r . builtins [ policy . Name ] = struct {}{}
r . builtinYAML [ policy . Name ] = data
2026-01-13 14:52:02 +05:30
r . mu . Unlock ()
}
2026-01-14 10:50:39 +05:30
// 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 {
2026-05-21 19:34:29 +05:30
return fmt . Errorf ( "invalid profile %s after inheritance: %w: %w" , name , ErrProfileInvalid , err )
2026-01-14 10:50:39 +05:30
}
}
}
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 {
2026-05-21 19:34:29 +05:30
return fmt . Errorf ( "%w: parent profile '%s' (only built-in profiles can be inherited)" , ErrProfileNotFound , child . Inherits )
2026-01-14 10:50:39 +05:30
}
// Prevent inheritance chains (parent must not itself inherit)
if parent . Inherits != "" {
2026-05-21 19:34:29 +05:30
return fmt . Errorf ( "%w: inheritance chains not allowed: parent profile '%s' inherits from '%s'" , ErrProfileInvalid , parent . Name , parent . Inherits )
2026-01-14 10:50:39 +05:30
}
// 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.
2026-05-19 14:40:54 +05:30
// 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 ()
2026-05-19 14:40:54 +05:30
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 ()
2026-05-19 14:40:54 +05:30
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 )
}
2026-05-21 19:34:29 +05:30
return nil , fmt . Errorf ( "%w: %s (not a built-in profile, no matching user profile, and file does not exist)" , ErrProfileNotFound , name )
2026-05-19 14:40:54 +05:30
}
// 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.
2026-01-14 10:50:39 +05:30
// 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 {
2026-05-21 19:34:29 +05:30
return nil , fmt . Errorf ( "failed to parse custom profile %s: %w: %w" , path , ErrProfileInvalid , err )
2026-01-13 14:52:02 +05:30
}
2026-01-14 10:50:39 +05:30
// Basic validation
2026-01-13 14:52:02 +05:30
if err := policy . Validate (); err != nil {
2026-05-21 19:34:29 +05:30
return nil , fmt . Errorf ( "invalid custom profile %s: %w: %w" , path , ErrProfileInvalid , err )
2026-01-13 14:52:02 +05:30
}
2026-01-14 10:50:39 +05:30
// Resolve inheritance if present
if policy . Inherits != "" {
r . mu . RLock ()
parent , exists := r . profiles [ policy . Inherits ]
r . mu . RUnlock ()
if ! exists {
2026-05-21 19:34:29 +05:30
return nil , fmt . Errorf ( "%w: custom profile %s inherits from unknown profile '%s' (only built-in profiles can be inherited)" , ErrProfileNotFound , path , policy . Inherits )
2026-01-14 10:50:39 +05:30
}
// Prevent inheritance chains
if parent . Inherits != "" {
2026-05-21 19:34:29 +05:30
return nil , fmt . Errorf ( "%w: custom profile %s parent '%s' inherits from '%s' (chains not allowed)" , ErrProfileInvalid , path , parent . Name , parent . Inherits )
2026-01-14 10:50:39 +05:30
}
// Merge parent into child
policy . MergeWithParent ( parent )
policy . Inherits = ""
}
// Validate after inheritance resolution
if err := policy . ValidateResolved (); err != nil {
2026-05-21 19:34:29 +05:30
return nil , fmt . Errorf ( "invalid custom profile %s after inheritance: %w: %w" , path , ErrProfileInvalid , err )
2026-01-14 10:50:39 +05:30
}
2026-01-13 14:52:02 +05:30
r . mu . Lock ()
r . profiles [ path ] = policy
r . mu . Unlock ()
return policy , nil
}
2026-05-19 14:40:54 +05:30
// 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 ()
2026-05-19 14:40:54 +05:30
data , ok := r . builtinYAML [ name ]
if ! ok {
return nil , false
2026-01-13 14:52:02 +05:30
}
2026-05-19 14:40:54 +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 ()
}