mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add Sandbox Inspection and Debugging Commands (#261)
* feat: add sandbox DX commands * fix: Linter errors * fix: Sandbox deny log parsing * fix: Sandbox docs * refactor: Maintain SSOT across pkg dependencies * fix: Linter errors
This commit is contained in:
+216
-12
@@ -5,9 +5,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -15,13 +17,24 @@ import (
|
||||
var profilesFS embed.FS
|
||||
|
||||
type defaultProfileRegistry struct {
|
||||
mu sync.RWMutex
|
||||
profiles map[string]*SandboxPolicy
|
||||
mu sync.RWMutex
|
||||
profiles map[string]*SandboxPolicy
|
||||
builtins map[string]struct{}
|
||||
builtinYAML map[string][]byte
|
||||
userProfileDir string
|
||||
}
|
||||
|
||||
func newDefaultProfileRegistry() (*defaultProfileRegistry, error) {
|
||||
func newDefaultProfileRegistry(opts ...RegistryOption) (*defaultProfileRegistry, error) {
|
||||
options := ®istryOptions{}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
registry := &defaultProfileRegistry{
|
||||
profiles: make(map[string]*SandboxPolicy),
|
||||
profiles: make(map[string]*SandboxPolicy),
|
||||
builtins: make(map[string]struct{}),
|
||||
builtinYAML: make(map[string][]byte),
|
||||
userProfileDir: options.userProfileDir,
|
||||
}
|
||||
|
||||
if err := registry.loadBuiltinProfiles(); err != nil {
|
||||
@@ -63,6 +76,8 @@ func (r *defaultProfileRegistry) loadBuiltinProfiles() error {
|
||||
|
||||
r.mu.Lock()
|
||||
r.profiles[policy.Name] = policy
|
||||
r.builtins[policy.Name] = struct{}{}
|
||||
r.builtinYAML[policy.Name] = data
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -115,19 +130,85 @@ func (r *defaultProfileRegistry) resolveInheritance(child *SandboxPolicy) error
|
||||
}
|
||||
|
||||
// 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 policy, exists := r.profiles[name]; exists {
|
||||
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("sandbox profile not found: %s (not a built-in profile and file does not exist)", name)
|
||||
return nil, fmt.Errorf("sandbox profile not found: %s (not a built-in profile, no matching user profile, and file does not exist)", 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.
|
||||
@@ -180,17 +261,140 @@ func (r *defaultProfileRegistry) LoadCustomProfile(path string) (*SandboxPolicy,
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// ListProfiles returns the names of all built-in profiles.
|
||||
func (r *defaultProfileRegistry) ListProfiles() []string {
|
||||
// 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()
|
||||
|
||||
profiles := make([]string, 0, len(r.profiles))
|
||||
for name := range r.profiles {
|
||||
profiles = append(profiles, name)
|
||||
data, ok := r.builtinYAML[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return profiles
|
||||
out := make([]byte, len(data))
|
||||
copy(out, data)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func parsePolicy(data []byte) (*SandboxPolicy, error) {
|
||||
|
||||
Reference in New Issue
Block a user