Files
pmg/cmd/sandbox/profile_lint.go
T
b59c3358e8 fix(sandbox): classify helper-tool errors with usefulerror (#272)
* 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>
2026-05-21 19:34:29 +05:30

217 lines
5.5 KiB
Go

package sandbox
import (
"fmt"
"io"
"os"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/internal/ui"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/spf13/cobra"
)
const ExitCodeLintFail = 2
type profileLintOptions struct {
strict bool
verbose bool
jsonOut bool
}
func newProfileLintCommand(factory registryFactory) *cobra.Command {
opts := &profileLintOptions{}
cmd := &cobra.Command{
Use: "lint <path|name>",
Short: "Lint a sandbox profile for schema issues, overly broad rules, and conflicts",
Example: " pmg sandbox profile lint npm-restrictive\n pmg sandbox profile lint ./my-profile.yml --strict",
Args: cobra.ExactArgs(1),
SilenceErrors: false,
RunE: func(cmd *cobra.Command, args []string) error {
err := runProfileLint(cmd.OutOrStdout(), args[0], opts, factory)
if err != nil {
if _, isFail := err.(*lintFailError); isFail {
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return err
}
return sandboxErrorExit(cmd, err)
}
return err
},
}
cmd.Flags().BoolVar(&opts.strict, "strict", false, "Treat warnings as errors (non-zero exit)")
cmd.Flags().BoolVar(&opts.verbose, "verbose", false, "Include info-level issues")
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit lint results as JSON")
return cmd
}
type lintFailError struct{}
func (e *lintFailError) Error() string { return "" }
func (e *lintFailError) ExitCode() int { return ExitCodeLintFail }
func runProfileLint(out io.Writer, name string, opts *profileLintOptions, factory registryFactory) error {
registry, err := factory()
if err != nil {
return registryInitError(err)
}
policy, resolvedName, err := resolveProfileForLint(name, registry)
if err != nil {
return profileLoadError(err)
}
issues := pmgsandbox.LintProfile(policy)
if !opts.verbose {
issues = filterInfo(issues)
}
if opts.jsonOut {
if err := writeLintJSON(out, resolvedName, issues); err != nil {
return err
}
} else {
if err := renderLintHuman(out, resolvedName, issues); err != nil {
return err
}
}
if shouldFailLint(issues, opts.strict) {
return &lintFailError{}
}
return nil
}
func resolveProfileForLint(name string, registry pmgsandbox.ProfileRegistry) (*pmgsandbox.SandboxPolicy, string, error) {
if _, ok := registry.BuiltinProfileYAML(name); ok {
policy, err := registry.GetProfile(name)
if err != nil {
return nil, "", profileLoadError(err)
}
return policy, name, nil
}
summaries, err := registry.ListProfiles()
if err != nil {
log.Warnf("sandbox: failed to enumerate user profiles: %v", err)
}
for _, s := range summaries {
if s.Source == pmgsandbox.ProfileSourceUser && s.Name == name {
policy, loadErr := registry.LoadCustomProfile(s.Path)
if loadErr != nil {
return nil, "", profileLoadError(loadErr)
}
return policy, s.Path, nil
}
}
if _, statErr := os.Stat(name); statErr == nil {
policy, loadErr := registry.LoadCustomProfile(name)
if loadErr != nil {
return nil, "", profileLoadError(loadErr)
}
return policy, name, nil
}
return nil, "", notFoundError(
fmt.Sprintf("sandbox profile not found: %s", name),
"Use `pmg sandbox profile list` to see available profiles, or pass an existing profile YAML path.",
)
}
func filterInfo(issues []pmgsandbox.LintIssue) []pmgsandbox.LintIssue {
out := make([]pmgsandbox.LintIssue, 0, len(issues))
for _, i := range issues {
if i.Level == pmgsandbox.LintLevelInfo {
continue
}
out = append(out, i)
}
return out
}
func shouldFailLint(issues []pmgsandbox.LintIssue, strict bool) bool {
for _, i := range issues {
if i.Level == pmgsandbox.LintLevelError {
return true
}
if strict && i.Level == pmgsandbox.LintLevelWarn {
return true
}
}
return false
}
type jsonLintReport struct {
Profile string `json:"profile"`
Issues []pmgsandbox.LintIssue `json:"issues"`
}
func writeLintJSON(out io.Writer, profile string, issues []pmgsandbox.LintIssue) error {
if issues == nil {
issues = []pmgsandbox.LintIssue{}
}
return writeJSONIndent(out, jsonLintReport{Profile: profile, Issues: issues})
}
func renderLintHuman(out io.Writer, profile string, issues []pmgsandbox.LintIssue) error {
if _, err := fmt.Fprintln(out); err != nil {
return err
}
if _, err := fmt.Fprintln(out, ui.Colors.Cyan("Profile Lint: ")+ui.Colors.Bold(profile)); err != nil {
return err
}
if _, err := fmt.Fprintln(out, ui.Colors.Normal("---------------")); err != nil {
return err
}
if len(issues) == 0 {
_, err := fmt.Fprintln(out, ui.Colors.Green("OK")+" no issues found")
return err
}
rows := make([][]string, 0, len(issues)+1)
rows = append(rows, []string{
ui.Colors.Bold("LEVEL"),
ui.Colors.Bold("CODE"),
ui.Colors.Bold("FIELD"),
ui.Colors.Bold("MESSAGE"),
})
for _, i := range issues {
rows = append(rows, []string{
lintLevelBadge(i.Level),
i.Code,
i.Field,
i.Message,
})
}
indent := firstColumnIndent(rows)
return renderTable(out, rows, func(dataIdx int) error {
if dataIdx < 0 {
return nil
}
issue := issues[dataIdx]
if issue.Rule == "" {
return nil
}
_, err := fmt.Fprintf(out, "%s%s %s\n", indent, ui.Colors.Dim("rule:"), ui.Colors.Dim(issue.Rule))
return err
})
}
func lintLevelBadge(level pmgsandbox.LintLevel) string {
switch level {
case pmgsandbox.LintLevelError:
return ui.Colors.Red("ERROR")
case pmgsandbox.LintLevelWarn:
return ui.Colors.Yellow("WARN")
case pmgsandbox.LintLevelInfo:
return ui.Colors.Dim("INFO")
}
return string(level)
}