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>
207 lines
5.0 KiB
Go
207 lines
5.0 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/safedep/pmg/internal/ui"
|
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
|
"github.com/safedep/pmg/usefulerror"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
const ExitCodeProbeFailure = 2
|
|
|
|
var sandboxErrorExit = func(_ *cobra.Command, err error) error {
|
|
type exitCoder interface{ ExitCode() int }
|
|
if ec, ok := err.(exitCoder); ok {
|
|
ui.ErrorExitWithCode(err, ec.ExitCode())
|
|
return nil
|
|
}
|
|
|
|
ui.ErrorExit(err)
|
|
return nil
|
|
}
|
|
|
|
var validDrivers = map[pmgsandbox.DriverName]struct{}{
|
|
pmgsandbox.DriverSeatbelt: {},
|
|
pmgsandbox.DriverBubblewrap: {},
|
|
pmgsandbox.DriverLandlock: {},
|
|
}
|
|
|
|
func validateDriver(name string) error {
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
if _, ok := validDrivers[pmgsandbox.DriverName(name)]; !ok {
|
|
return invalidArgumentError(
|
|
fmt.Sprintf("unknown driver %q", name),
|
|
"Use one of: seatbelt, bubblewrap, landlock",
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func invalidArgumentError(message, help string) error {
|
|
return usefulerror.Useful().
|
|
WithCode(usefulerror.ErrCodeInvalidArgument).
|
|
WithHumanError(message).
|
|
WithHelp(help).
|
|
Wrap(errors.New(message))
|
|
}
|
|
|
|
func notFoundError(message, help string) error {
|
|
return usefulerror.Useful().
|
|
WithCode(usefulerror.ErrCodeNotFound).
|
|
WithHumanError(message).
|
|
WithHelp(help).
|
|
Wrap(errors.New(message))
|
|
}
|
|
|
|
// Idempotent: returns err unchanged when nil or already useful, so call
|
|
// sites can apply it without losing more precise pre-classified errors.
|
|
func wrapUseful(err error, code, help string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if _, ok := usefulerror.AsUsefulError(err); ok {
|
|
return err
|
|
}
|
|
return usefulerror.Useful().
|
|
WithCode(code).
|
|
WithHumanError(err.Error()).
|
|
WithHelp(help).
|
|
Wrap(err)
|
|
}
|
|
|
|
func profileLoadError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if _, ok := usefulerror.AsUsefulError(err); ok {
|
|
return err
|
|
}
|
|
switch {
|
|
case errors.Is(err, pmgsandbox.ErrProfileNotFound):
|
|
return wrapUseful(err, usefulerror.ErrCodeNotFound,
|
|
"Use `pmg sandbox profile list` to see available profiles, or pass an existing profile YAML path.")
|
|
case errors.Is(err, pmgsandbox.ErrProfileInvalid):
|
|
return wrapUseful(err, usefulerror.ErrCodeInvalidArgument,
|
|
"Check the profile YAML for syntax/schema issues and verify any 'inherits:' parent name.")
|
|
}
|
|
return wrapUseful(err, usefulerror.ErrCodeUnknown,
|
|
"Failed to load the sandbox profile. Run with --verbose for the underlying cause.")
|
|
}
|
|
|
|
func registryInitError(err error) error {
|
|
return wrapUseful(err, ioErrorCode(err, usefulerror.ErrCodeUnknown),
|
|
"Failed to initialise the sandbox profile registry. Run with --verbose for details.")
|
|
}
|
|
|
|
func ioErrorCode(err error, fallback string) string {
|
|
switch {
|
|
case errors.Is(err, fs.ErrPermission):
|
|
return usefulerror.ErrCodePermissionDenied
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
return usefulerror.ErrCodeNotFound
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func writeJSONIndent(out io.Writer, v any) error {
|
|
enc := json.NewEncoder(out)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(v)
|
|
}
|
|
|
|
// renderTable prints rows with ANSI-aware column alignment. The first row is
|
|
// treated as a header. After each data row, optional continuation lines may be
|
|
// emitted via the after callback (passed the data row index; -1 for header).
|
|
func renderTable(out io.Writer, rows [][]string, after func(rowIdx int) error) error {
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
cols := len(rows[0])
|
|
widths := make([]int, cols)
|
|
for _, row := range rows {
|
|
for i, cell := range row {
|
|
if w := visibleWidth(cell); w > widths[i] {
|
|
widths[i] = w
|
|
}
|
|
}
|
|
}
|
|
for rIdx, row := range rows {
|
|
for i, cell := range row {
|
|
if i == cols-1 {
|
|
if _, err := fmt.Fprint(out, cell); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
pad := widths[i] - visibleWidth(cell)
|
|
if _, err := fmt.Fprint(out, cell, strings.Repeat(" ", pad+2)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := fmt.Fprintln(out); err != nil {
|
|
return err
|
|
}
|
|
if after != nil {
|
|
dataIdx := rIdx - 1
|
|
if err := after(dataIdx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// firstColumnIndent returns blanks the width of the first column plus the
|
|
// two-space padding renderTable uses, for continuation-line alignment.
|
|
func firstColumnIndent(rows [][]string) string {
|
|
if len(rows) == 0 {
|
|
return ""
|
|
}
|
|
w := 0
|
|
for _, row := range rows {
|
|
if v := visibleWidth(row[0]); v > w {
|
|
w = v
|
|
}
|
|
}
|
|
return strings.Repeat(" ", w+2)
|
|
}
|
|
|
|
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
|
|
|
|
// visibleWidth returns the printable width of s with ANSI escape sequences
|
|
// stripped — text/tabwriter counts escape bytes as visible chars, misaligning
|
|
// colored cells.
|
|
func visibleWidth(s string) int {
|
|
return len(ansiEscapeRe.ReplaceAllString(s, ""))
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
if n <= 3 {
|
|
return s[:n]
|
|
}
|
|
return s[:n-3] + "..."
|
|
}
|
|
|
|
func truncateLeft(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
if n <= 3 {
|
|
return s[len(s)-n:]
|
|
}
|
|
return "..." + s[len(s)-(n-3):]
|
|
}
|