mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add support for per-project sandbox overlays (#294)
* feat: Add support for per-project sandbox overlays * chore: Maintain consistency with TUI experience * fix: Code review fixes
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/safedep/dry/usefulerror"
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// allowFactory bundles the dependencies needed by `pmg sandbox allow`. Tests
|
||||
// inject stubs. Production wiring uses defaultAllowFactory.
|
||||
type allowFactory struct {
|
||||
overlayDir func() string
|
||||
repoRoot func() (string, error)
|
||||
cache func() *pmgsandbox.ViolationCache
|
||||
locked func() bool
|
||||
}
|
||||
|
||||
func defaultAllowFactory() allowFactory {
|
||||
return allowFactory{
|
||||
overlayDir: func() string { return config.Get().SandboxOverlayDir() },
|
||||
repoRoot: resolveCurrentRepoRoot,
|
||||
cache: func() *pmgsandbox.ViolationCache {
|
||||
return pmgsandbox.NewViolationCache(config.Get().SandboxViolationCacheDir())
|
||||
},
|
||||
locked: func() bool { return config.Get().IsLocked() },
|
||||
}
|
||||
}
|
||||
|
||||
// NewAllowCommand returns the `pmg sandbox allow` command.
|
||||
func NewAllowCommand() *cobra.Command {
|
||||
return newAllowCommand(defaultAllowFactory())
|
||||
}
|
||||
|
||||
type allowOptions struct {
|
||||
last bool
|
||||
all bool
|
||||
force bool
|
||||
}
|
||||
|
||||
func newAllowCommand(factory allowFactory) *cobra.Command {
|
||||
opts := &allowOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "allow [type=value …]",
|
||||
Short: "Persist sandbox allowances for the current repository",
|
||||
Long: "Save allowances into the current repo's sandbox project overlay so future PMG runs in this repo apply them automatically.\n\n" +
|
||||
"Use --last to promote the primary violation from the most recent cached report,\n" +
|
||||
"or --last --all to promote every safe FS/exec violation from that report.\n" +
|
||||
"Manual entries (type=value …) accept any allow type and persist as-is.",
|
||||
Example: " pmg sandbox allow write=./.astro net-bind=localhost:4321\n" +
|
||||
" pmg sandbox allow --last --all",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
SilenceErrors: false,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := runAllow(cmd.OutOrStdout(), args, opts, factory); err != nil {
|
||||
return sandboxErrorExit(cmd, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.last, "last", false, "Promote allowances from the most recent cached violation report")
|
||||
cmd.Flags().BoolVar(&opts.all, "all", false, "With --last: promote every safe FS/exec violation (default: primary only)")
|
||||
cmd.Flags().BoolVar(&opts.force, "force", false, "Allow saving entries that touch sensitive paths (.env, .npmrc, .ssh, ...)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runAllow(out io.Writer, args []string, opts *allowOptions, factory allowFactory) error {
|
||||
// ApplySandbox ignores overlays under global_lockdown, so refuse up-front
|
||||
// instead of letting the user think their allowances took effect.
|
||||
if factory.locked != nil && factory.locked() {
|
||||
return usefulerror.NewUsefulError().
|
||||
WithCode(errcodes.PermissionDenied).
|
||||
WithHumanError("sandbox overlays are disabled while global_lockdown is in force").
|
||||
WithHelp("This machine's PMG configuration is locked. Contact your administrator to change sandbox policy.").
|
||||
Wrap(errors.New("sandbox overlay refused under global_lockdown"))
|
||||
}
|
||||
|
||||
if !opts.last && len(args) == 0 {
|
||||
return invalidArgumentError(
|
||||
"nothing to save: pass type=value arguments or --last",
|
||||
"Example: `pmg sandbox allow write=./.astro` or `pmg sandbox allow --last --all`.",
|
||||
)
|
||||
}
|
||||
if opts.all && !opts.last {
|
||||
return invalidArgumentError(
|
||||
"--all requires --last",
|
||||
"Use `pmg sandbox allow --last --all` to promote every FS/exec violation from the most recent cached report.",
|
||||
)
|
||||
}
|
||||
|
||||
repoRoot, err := factory.repoRoot()
|
||||
if err != nil {
|
||||
return wrapUseful(fmt.Errorf("resolve repo root: %w", err),
|
||||
errcodes.Unknown,
|
||||
"Could not determine the current repository root. Ensure the working directory is accessible.")
|
||||
}
|
||||
if repoRoot == "" {
|
||||
return invalidArgumentError(
|
||||
"could not determine current repository root",
|
||||
"Change to a directory inside the repository, then retry.",
|
||||
)
|
||||
}
|
||||
|
||||
overlayDir := factory.overlayDir()
|
||||
if overlayDir == "" {
|
||||
return invalidArgumentError(
|
||||
"sandbox overlay directory is not configured",
|
||||
"Ensure the PMG config directory is writable, then retry.",
|
||||
)
|
||||
}
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(overlayDir, repoRoot)
|
||||
if err != nil {
|
||||
return wrapUseful(err, errcodes.Unknown,
|
||||
"Could not read the existing project overlay. Check the file under SandboxOverlayDir().")
|
||||
}
|
||||
if overlay == nil {
|
||||
overlay = &pmgsandbox.Overlay{}
|
||||
}
|
||||
|
||||
pending, err := collectAllowEntries(args, opts, factory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return invalidArgumentError(
|
||||
"no eligible allowances to save",
|
||||
"Run a sandboxed command first to populate the violation cache, or pass explicit type=value arguments.",
|
||||
)
|
||||
}
|
||||
|
||||
if err := guardSensitiveEntries(pending, opts.force); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addedEntries := make([]pmgsandbox.OverlayAllow, 0, len(pending))
|
||||
for _, entry := range pending {
|
||||
if overlay.Add(entry) {
|
||||
addedEntries = append(addedEntries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if len(addedEntries) == 0 {
|
||||
_, err := fmt.Fprintf(out, "%s\n", ui.Colors.Dim(fmt.Sprintf("No new allowances (%d already present).", len(pending))))
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := pmgsandbox.SaveOverlay(overlayDir, repoRoot, overlay)
|
||||
if err != nil {
|
||||
return wrapUseful(err, errcodes.Unknown,
|
||||
"Could not write the project overlay file. Check filesystem permissions for the overlay directory.")
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(out, "%s Saved %d allowance(s) for %s\n", ui.Colors.Green("✓"), len(addedEntries), repoRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range addedEntries {
|
||||
if _, err := fmt.Fprintf(out, " %s %s=%s\n", ui.Colors.Dim("•"), e.Type, e.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = fmt.Fprintf(out, " %s %s\n", ui.Colors.Dim("overlay:"), ui.Colors.Dim(path))
|
||||
return err
|
||||
}
|
||||
|
||||
func collectAllowEntries(args []string, opts *allowOptions, factory allowFactory) ([]pmgsandbox.OverlayAllow, error) {
|
||||
out := make([]pmgsandbox.OverlayAllow, 0, len(args)+1)
|
||||
|
||||
for _, raw := range args {
|
||||
override, err := config.ParseSingleOverride(raw)
|
||||
if err != nil {
|
||||
return nil, invalidArgumentError(
|
||||
err.Error(),
|
||||
"Each positional argument must be `type=value` (read, write, exec, net-connect, net-bind).",
|
||||
)
|
||||
}
|
||||
out = append(out, pmgsandbox.OverlayAllow{Type: override.Type, Value: override.Value})
|
||||
}
|
||||
|
||||
if !opts.last {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
suggestions, err := suggestionsFromCache(factory.cache(), opts.all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sugg := range suggestions {
|
||||
typ := overrideTypeForKind(sugg.Kind)
|
||||
if typ == "" {
|
||||
continue
|
||||
}
|
||||
// Normalize through the manual-entry validator so stored values match
|
||||
// how applyRuntimeOverrides resolves them against the policy. Skip on
|
||||
// rejection so one bad target does not block the rest of the report.
|
||||
normalized, err := config.ParseSingleOverride(fmt.Sprintf("%s=%s", typ, sugg.Target))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, pmgsandbox.OverlayAllow{Type: normalized.Type, Value: normalized.Value})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// suggestionsFromCache loads the latest cached violation report and returns
|
||||
// the override suggestions to promote. When all is true, every safe FS/exec
|
||||
// suggestion is returned, otherwise just the primary one (if any).
|
||||
func suggestionsFromCache(cache *pmgsandbox.ViolationCache, all bool) ([]pmgsandbox.OverrideSuggestion, error) {
|
||||
entry, err := cache.Latest()
|
||||
if err != nil {
|
||||
return nil, wrapUseful(err, errcodes.Unknown,
|
||||
"Could not read the sandbox violation cache. Check the cache directory and retry.")
|
||||
}
|
||||
if entry == nil || entry.Record.Report == nil {
|
||||
return nil, notFoundError(
|
||||
"no cached violation report",
|
||||
"Run a sandboxed command that hits a denial first, then retry `pmg sandbox allow --last`.",
|
||||
)
|
||||
}
|
||||
|
||||
if all {
|
||||
return pmgsandbox.BuildAllOverrides(entry.Record.Report), nil
|
||||
}
|
||||
if override := pmgsandbox.BuildExplanation(entry.Record.Report).Override; override != nil {
|
||||
return []pmgsandbox.OverrideSuggestion{*override}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// overrideTypeForKind maps a ViolationKind to the matching SandboxAllowType.
|
||||
// Only FS + exec are handled; network kinds are not classified by drivers and
|
||||
// will never reach this function via BuildAllOverrides.
|
||||
func overrideTypeForKind(kind pmgsandbox.ViolationKind) config.SandboxAllowType {
|
||||
switch kind {
|
||||
case pmgsandbox.ViolationKindFSRead:
|
||||
return config.SandboxAllowRead
|
||||
case pmgsandbox.ViolationKindFSWrite, pmgsandbox.ViolationKindFSDeleteOrRename:
|
||||
return config.SandboxAllowWrite
|
||||
case pmgsandbox.ViolationKindExec:
|
||||
return config.SandboxAllowExec
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func guardSensitiveEntries(entries []pmgsandbox.OverlayAllow, force bool) error {
|
||||
if force {
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if pmgsandbox.IsSensitiveProjectTarget(e.Value) {
|
||||
return usefulerror.NewUsefulError().
|
||||
WithCode(errcodes.PermissionDenied).
|
||||
WithHumanError(fmt.Sprintf("refusing to allow sensitive target: %s", e.Value)).
|
||||
WithHelp("Re-run with --force to allow saving this entry, after verifying the path is intentional.").
|
||||
Wrap(errors.New("sensitive target"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type allowDeps struct {
|
||||
overlayDir string
|
||||
repoRoot string
|
||||
cache *pmgsandbox.ViolationCache
|
||||
}
|
||||
|
||||
func newAllowDeps(t *testing.T) *allowDeps {
|
||||
t.Helper()
|
||||
return &allowDeps{
|
||||
overlayDir: t.TempDir(),
|
||||
repoRoot: filepath.Clean(t.TempDir()),
|
||||
cache: pmgsandbox.NewViolationCache(t.TempDir()),
|
||||
}
|
||||
}
|
||||
|
||||
func runAllowCmd(t *testing.T, deps *allowDeps, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
cmd := newAllowCommand(allowFactory{
|
||||
overlayDir: func() string { return deps.overlayDir },
|
||||
repoRoot: func() (string, error) { return deps.repoRoot, nil },
|
||||
cache: func() *pmgsandbox.ViolationCache { return deps.cache },
|
||||
})
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func TestAllow_PositionalSaved(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
stdout, stderr, err := runAllowCmd(t, deps, "net-bind=localhost:4321")
|
||||
require.NoError(t, err, "stderr: %s", stderr)
|
||||
assert.Contains(t, stdout, "Saved 1 allowance")
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, overlay)
|
||||
require.Len(t, overlay.Allow, 1)
|
||||
assert.Equal(t, config.SandboxAllowNetBind, overlay.Allow[0].Type)
|
||||
assert.Equal(t, "localhost:4321", overlay.Allow[0].Value)
|
||||
}
|
||||
|
||||
func TestAllow_LastAllPromotesFromCache(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
writeTarget := filepath.Join(deps.repoRoot, ".astro")
|
||||
execTarget := "/usr/bin/sh"
|
||||
report := &pmgsandbox.ViolationReport{
|
||||
SandboxName: pmgsandbox.DriverSeatbelt,
|
||||
Violations: []pmgsandbox.Violation{
|
||||
{Kind: pmgsandbox.ViolationKindFSWrite, Target: writeTarget},
|
||||
{Kind: pmgsandbox.ViolationKindExec, Target: execTarget},
|
||||
},
|
||||
}
|
||||
_, err := deps.cache.Write(report)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, stderr, err := runAllowCmd(t, deps, "--last", "--all")
|
||||
require.NoError(t, err, "stderr: %s", stderr)
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, overlay)
|
||||
assert.ElementsMatch(t, []pmgsandbox.OverlayAllow{
|
||||
{Type: config.SandboxAllowWrite, Value: writeTarget},
|
||||
{Type: config.SandboxAllowExec, Value: execTarget},
|
||||
}, overlay.Allow)
|
||||
}
|
||||
|
||||
func TestAllow_LastPromotesPrimaryOnly(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
writeTarget := filepath.Join(deps.repoRoot, ".astro")
|
||||
execTarget := "/usr/bin/sh"
|
||||
report := &pmgsandbox.ViolationReport{
|
||||
SandboxName: pmgsandbox.DriverSeatbelt,
|
||||
Violations: []pmgsandbox.Violation{
|
||||
{Kind: pmgsandbox.ViolationKindFSWrite, Target: writeTarget},
|
||||
{Kind: pmgsandbox.ViolationKindExec, Target: execTarget},
|
||||
},
|
||||
}
|
||||
_, err := deps.cache.Write(report)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = runAllowCmd(t, deps, "--last")
|
||||
require.NoError(t, err)
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, overlay)
|
||||
require.Len(t, overlay.Allow, 1)
|
||||
}
|
||||
|
||||
func TestAllow_LastNoCacheErrors(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
_, _, err := runAllowCmd(t, deps, "--last")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAllow_RefusesSensitiveTargetWithoutForce(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
secret := filepath.Join(deps.repoRoot, ".env")
|
||||
_, _, err := runAllowCmd(t, deps, "read="+secret)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "sensitive target")
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, overlay)
|
||||
}
|
||||
|
||||
func TestAllow_SensitiveTargetWithForceSaves(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
secret := filepath.Join(deps.repoRoot, ".env")
|
||||
_, _, err := runAllowCmd(t, deps, "--force", "read="+secret)
|
||||
require.NoError(t, err)
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, overlay)
|
||||
assert.Len(t, overlay.Allow, 1)
|
||||
}
|
||||
|
||||
func TestAllow_Dedupes(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
_, _, err := runAllowCmd(t, deps, "exec=/usr/bin/sh")
|
||||
require.NoError(t, err)
|
||||
stdout, _, err := runAllowCmd(t, deps, "exec=/usr/bin/sh")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stdout, "No new allowances")
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, overlay)
|
||||
assert.Len(t, overlay.Allow, 1)
|
||||
}
|
||||
|
||||
func TestAllow_NothingToSaveErrors(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
_, _, err := runAllowCmd(t, deps)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAllow_AllWithoutLastErrors(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
_, _, err := runAllowCmd(t, deps, "--all", "exec=/usr/bin/sh")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAllow_InvalidPositionalRejected(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
_, _, err := runAllowCmd(t, deps, "garbage")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAllow_RefusedUnderLockdown(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
cmd := newAllowCommand(allowFactory{
|
||||
overlayDir: func() string { return deps.overlayDir },
|
||||
repoRoot: func() (string, error) { return deps.repoRoot, nil },
|
||||
cache: func() *pmgsandbox.ViolationCache { return deps.cache },
|
||||
locked: func() bool { return true },
|
||||
})
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{"exec=/usr/bin/sh"})
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "global_lockdown")
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, overlay)
|
||||
}
|
||||
|
||||
func TestAllow_LastNormalizesRelativeTargets(t *testing.T) {
|
||||
deps := newAllowDeps(t)
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
relTarget := "./.astro"
|
||||
wantAbs := filepath.Join(cwd, ".astro")
|
||||
|
||||
report := &pmgsandbox.ViolationReport{
|
||||
SandboxName: pmgsandbox.DriverSeatbelt,
|
||||
Violations: []pmgsandbox.Violation{
|
||||
{Kind: pmgsandbox.ViolationKindFSWrite, Target: relTarget},
|
||||
},
|
||||
}
|
||||
_, err = deps.cache.Write(report)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = runAllowCmd(t, deps, "--last")
|
||||
require.NoError(t, err)
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, overlay)
|
||||
require.Len(t, overlay.Allow, 1)
|
||||
assert.Equal(t, wantAbs, overlay.Allow[0].Value, "cache-derived target should be normalized to absolute")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// projectDeps is the dependency surface for `pmg sandbox project` subcommands.
|
||||
type projectDeps struct {
|
||||
overlayDir func() string
|
||||
repoRoot func() (string, error)
|
||||
}
|
||||
|
||||
func defaultProjectDeps() projectDeps {
|
||||
return projectDeps{
|
||||
overlayDir: func() string { return config.Get().SandboxOverlayDir() },
|
||||
repoRoot: resolveCurrentRepoRoot,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCurrentRepoRoot returns the git toplevel for the current working
|
||||
// directory, falling back to the cwd itself when not inside a git work tree.
|
||||
func resolveCurrentRepoRoot() (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pmgsandbox.ResolveRepoRoot(cwd)
|
||||
}
|
||||
|
||||
// renderProjectSection prints the standard PMG section header
|
||||
// (blank line, cyan title, dashed separator).
|
||||
func renderProjectSection(out io.Writer, title string) error {
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, ui.Colors.Cyan(title)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(out, ui.Colors.Normal("--------------------"))
|
||||
return err
|
||||
}
|
||||
|
||||
// renderKeyValueBlock prints aligned "Key: value" pairs with bold keys. Empty
|
||||
// values are skipped so optional fields do not produce empty lines.
|
||||
func renderKeyValueBlock(out io.Writer, entries [][2]string) error {
|
||||
width := 0
|
||||
for _, e := range entries {
|
||||
if e[1] != "" && len(e[0]) > width {
|
||||
width = len(e[0])
|
||||
}
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e[1] == "" {
|
||||
continue
|
||||
}
|
||||
label := fmt.Sprintf("%-*s", width+1, e[0]+":")
|
||||
if _, err := fmt.Fprintf(out, "%s %s\n", ui.Colors.Bold(label), e[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewProjectCommand returns the `pmg sandbox project` parent command.
|
||||
func NewProjectCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "project",
|
||||
Short: "Inspect and manage the current repository's sandbox overlay",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
deps := defaultProjectDeps()
|
||||
cmd.AddCommand(newProjectShowCommand(deps))
|
||||
cmd.AddCommand(newProjectResetCommand(deps))
|
||||
cmd.AddCommand(newProjectListCommand(deps))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type projectListOptions struct {
|
||||
jsonOut bool
|
||||
}
|
||||
|
||||
func newProjectListCommand(deps projectDeps) *cobra.Command {
|
||||
opts := &projectListOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all known sandbox project overlays",
|
||||
Args: cobra.NoArgs,
|
||||
SilenceErrors: false,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := runProjectList(cmd.OutOrStdout(), cmd.ErrOrStderr(), opts, deps); err != nil {
|
||||
return sandboxErrorExit(cmd, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit entries as JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type projectListJSONEntry struct {
|
||||
RepoRoot string `json:"repo_root"`
|
||||
Entries int `json:"entries"`
|
||||
Path string `json:"path"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type projectListJSONOutput struct {
|
||||
Entries []projectListJSONEntry `json:"entries"`
|
||||
}
|
||||
|
||||
func runProjectList(out, _ io.Writer, opts *projectListOptions, deps projectDeps) error {
|
||||
overlays, err := pmgsandbox.ListOverlays(deps.overlayDir())
|
||||
if err != nil {
|
||||
return wrapUseful(err, errcodes.Unknown, "Could not list project overlays.")
|
||||
}
|
||||
|
||||
if opts.jsonOut {
|
||||
payload := projectListJSONOutput{Entries: make([]projectListJSONEntry, 0, len(overlays))}
|
||||
for _, e := range overlays {
|
||||
item := projectListJSONEntry{
|
||||
RepoRoot: e.Overlay.RepoRoot,
|
||||
Entries: len(e.Overlay.Allow),
|
||||
Path: e.Path,
|
||||
}
|
||||
if !e.Overlay.UpdatedAt.IsZero() {
|
||||
item.UpdatedAt = e.Overlay.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
payload.Entries = append(payload.Entries, item)
|
||||
}
|
||||
return writeJSONIndent(out, payload)
|
||||
}
|
||||
|
||||
if err := renderProjectSection(out, "Project Overlays"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(overlays) == 0 {
|
||||
_, err := fmt.Fprintln(out, ui.Colors.Dim("No project overlays."))
|
||||
return err
|
||||
}
|
||||
|
||||
rows := [][]string{{
|
||||
ui.Colors.Bold("REPO"),
|
||||
ui.Colors.Bold("ENTRIES"),
|
||||
ui.Colors.Bold("UPDATED"),
|
||||
}}
|
||||
dash := ui.Colors.Dim("—")
|
||||
for _, e := range overlays {
|
||||
updated := dash
|
||||
if !e.Overlay.UpdatedAt.IsZero() {
|
||||
updated = e.Overlay.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
rows = append(rows, []string{e.Overlay.RepoRoot, fmt.Sprintf("%d", len(e.Overlay.Allow)), updated})
|
||||
}
|
||||
return renderTable(out, rows, nil)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func execProjectList(t *testing.T, overlayDir string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
cmd := newProjectListCommand(projectDeps{
|
||||
overlayDir: func() string { return overlayDir },
|
||||
})
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func TestProjectList_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stdout, _, err := execProjectList(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stdout, "No project overlays")
|
||||
}
|
||||
|
||||
func TestProjectList_Human(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, repo := range []string{"/repo/a", "/repo/b"} {
|
||||
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
stdout, _, err := execProjectList(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stdout, "/repo/a")
|
||||
assert.Contains(t, stdout, "/repo/b")
|
||||
}
|
||||
|
||||
func TestProjectList_JSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, repo := range []string{"/repo/a", "/repo/b"} {
|
||||
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
stdout, _, err := execProjectList(t, dir, "--json")
|
||||
require.NoError(t, err)
|
||||
var payload struct {
|
||||
Entries []struct {
|
||||
RepoRoot string `json:"repo_root"`
|
||||
Entries int `json:"entries"`
|
||||
Path string `json:"path"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
|
||||
require.Len(t, payload.Entries, 2)
|
||||
assert.Equal(t, "/repo/a", payload.Entries[0].RepoRoot)
|
||||
assert.Equal(t, 1, payload.Entries[0].Entries)
|
||||
}
|
||||
|
||||
func TestProjectList_JSONEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stdout, _, err := execProjectList(t, dir, "--json")
|
||||
require.NoError(t, err)
|
||||
var payload struct {
|
||||
Entries []any `json:"entries"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
|
||||
assert.NotNil(t, payload.Entries)
|
||||
assert.Empty(t, payload.Entries)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type projectResetOptions struct {
|
||||
yes bool
|
||||
}
|
||||
|
||||
func newProjectResetCommand(deps projectDeps) *cobra.Command {
|
||||
opts := &projectResetOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "Delete the sandbox overlay for the current repository",
|
||||
Args: cobra.NoArgs,
|
||||
SilenceErrors: false,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := runProjectReset(cmd.OutOrStdout(), opts, deps); err != nil {
|
||||
return sandboxErrorExit(cmd, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.yes, "yes", false, "Confirm deletion without an interactive prompt")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runProjectReset(out io.Writer, opts *projectResetOptions, deps projectDeps) error {
|
||||
if !opts.yes {
|
||||
return invalidArgumentError(
|
||||
"refusing to reset without --yes",
|
||||
"Re-run with `pmg sandbox project reset --yes` to delete the overlay.",
|
||||
)
|
||||
}
|
||||
|
||||
repoRoot, err := deps.repoRoot()
|
||||
if err != nil {
|
||||
return wrapUseful(err, errcodes.Unknown, "Could not determine the current repository root.")
|
||||
}
|
||||
overlay, path, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir(), repoRoot)
|
||||
if err != nil {
|
||||
return wrapUseful(err, errcodes.Unknown, "Could not read the project overlay file.")
|
||||
}
|
||||
if overlay == nil {
|
||||
_, err := fmt.Fprintf(out, "%s\n", ui.Colors.Dim(fmt.Sprintf("No project overlay for %s", repoRoot)))
|
||||
return err
|
||||
}
|
||||
if err := pmgsandbox.DeleteOverlayForRepo(deps.overlayDir(), repoRoot); err != nil {
|
||||
return wrapUseful(err, errcodes.Unknown, "Could not delete the project overlay file.")
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "%s Deleted overlay for %s\n", ui.Colors.Green("✓"), repoRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(out, " %s\n", ui.Colors.Dim(path))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func execProjectReset(t *testing.T, overlayDir, repoRoot string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
cmd := newProjectResetCommand(projectDeps{
|
||||
overlayDir: func() string { return overlayDir },
|
||||
repoRoot: func() (string, error) { return repoRoot, nil },
|
||||
})
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func TestProjectReset_RequiresYes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo := filepath.Clean(t.TempDir())
|
||||
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/x"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = execProjectReset(t, dir, repo)
|
||||
require.Error(t, err)
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(dir, repo)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, overlay)
|
||||
}
|
||||
|
||||
func TestProjectReset_WithYesDeletes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo := filepath.Clean(t.TempDir())
|
||||
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/x"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
stdout, _, err := execProjectReset(t, dir, repo, "--yes")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stdout, repo)
|
||||
assert.Contains(t, stdout, "Deleted")
|
||||
|
||||
overlay, _, err := pmgsandbox.LoadOverlayForRepo(dir, repo)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, overlay)
|
||||
}
|
||||
|
||||
func TestProjectReset_MissingIsNoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo := filepath.Clean(t.TempDir())
|
||||
stdout, _, err := execProjectReset(t, dir, repo, "--yes")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stdout, "No project overlay")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type projectShowOptions struct {
|
||||
jsonOut bool
|
||||
}
|
||||
|
||||
func newProjectShowCommand(deps projectDeps) *cobra.Command {
|
||||
opts := &projectShowOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show the saved sandbox overlay for the current repository",
|
||||
SilenceErrors: false,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := runProjectShow(cmd.OutOrStdout(), opts, deps); err != nil {
|
||||
return sandboxErrorExit(cmd, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit overlay as JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type projectShowJSONAllow struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type projectShowJSONPayload struct {
|
||||
RepoRoot string `json:"repo_root"`
|
||||
Path string `json:"path,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Allow []projectShowJSONAllow `json:"allow"`
|
||||
}
|
||||
|
||||
func runProjectShow(out io.Writer, opts *projectShowOptions, deps projectDeps) error {
|
||||
repoRoot, err := deps.repoRoot()
|
||||
if err != nil {
|
||||
return wrapUseful(err, errcodes.Unknown, "Could not determine the current repository root.")
|
||||
}
|
||||
overlay, path, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir(), repoRoot)
|
||||
if err != nil {
|
||||
return wrapUseful(err, errcodes.Unknown, "Could not read the project overlay file.")
|
||||
}
|
||||
|
||||
if opts.jsonOut {
|
||||
payload := projectShowJSONPayload{RepoRoot: repoRoot, Path: path, Allow: []projectShowJSONAllow{}}
|
||||
if overlay != nil {
|
||||
for _, a := range overlay.Allow {
|
||||
payload.Allow = append(payload.Allow, projectShowJSONAllow{Type: string(a.Type), Value: a.Value})
|
||||
}
|
||||
if !overlay.UpdatedAt.IsZero() {
|
||||
payload.UpdatedAt = overlay.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return writeJSONIndent(out, payload)
|
||||
}
|
||||
|
||||
if err := renderProjectSection(out, "Project Overlay"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if overlay == nil || len(overlay.Allow) == 0 {
|
||||
_, err := fmt.Fprintf(out, "%s\n", ui.Colors.Dim(fmt.Sprintf("No project overlay for %s", repoRoot)))
|
||||
return err
|
||||
}
|
||||
|
||||
updated := ""
|
||||
if !overlay.UpdatedAt.IsZero() {
|
||||
updated = overlay.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if err := renderKeyValueBlock(out, [][2]string{
|
||||
{"Repo", repoRoot},
|
||||
{"Overlay", path},
|
||||
{"Updated", updated},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(overlay.Allow)+1)
|
||||
rows = append(rows, []string{
|
||||
ui.Colors.Bold("TYPE"),
|
||||
ui.Colors.Bold("VALUE"),
|
||||
})
|
||||
for _, a := range overlay.Allow {
|
||||
rows = append(rows, []string{string(a.Type), a.Value})
|
||||
}
|
||||
return renderTable(out, rows, nil)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func execProjectShow(t *testing.T, overlayDir, repoRoot string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
cmd := newProjectShowCommand(projectDeps{
|
||||
overlayDir: func() string { return overlayDir },
|
||||
repoRoot: func() (string, error) { return repoRoot, nil },
|
||||
})
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func TestProjectShow_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo := filepath.Clean(t.TempDir())
|
||||
stdout, _, err := execProjectShow(t, dir, repo)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stdout, repo)
|
||||
assert.Contains(t, stdout, "No project overlay")
|
||||
}
|
||||
|
||||
func TestProjectShow_WithEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo := filepath.Clean(t.TempDir())
|
||||
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowWrite, Value: "/repo/.astro"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
stdout, _, err := execProjectShow(t, dir, repo)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stdout, repo)
|
||||
assert.Contains(t, stdout, "write")
|
||||
assert.Contains(t, stdout, "/repo/.astro")
|
||||
}
|
||||
|
||||
func TestProjectShow_JSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo := filepath.Clean(t.TempDir())
|
||||
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/usr/bin/sh"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
stdout, _, err := execProjectShow(t, dir, repo, "--json")
|
||||
require.NoError(t, err)
|
||||
var payload struct {
|
||||
RepoRoot string `json:"repo_root"`
|
||||
Path string `json:"path"`
|
||||
Allow []struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
} `json:"allow"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
|
||||
assert.Equal(t, repo, payload.RepoRoot)
|
||||
assert.NotEmpty(t, payload.Path)
|
||||
require.Len(t, payload.Allow, 1)
|
||||
assert.Equal(t, "exec", payload.Allow[0].Type)
|
||||
assert.Equal(t, "/usr/bin/sh", payload.Allow[0].Value)
|
||||
}
|
||||
|
||||
func TestProjectShow_JSONEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo := filepath.Clean(t.TempDir())
|
||||
stdout, _, err := execProjectShow(t, dir, repo, "--json")
|
||||
require.NoError(t, err)
|
||||
var payload struct {
|
||||
Allow []any `json:"allow"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
|
||||
assert.NotNil(t, payload.Allow)
|
||||
assert.Empty(t, payload.Allow)
|
||||
}
|
||||
@@ -22,5 +22,7 @@ func NewCommand() *cobra.Command {
|
||||
cmd.AddCommand(NewProfileCommand())
|
||||
cmd.AddCommand(NewExplainCommand())
|
||||
cmd.AddCommand(NewViolationsCommand())
|
||||
cmd.AddCommand(NewAllowCommand())
|
||||
cmd.AddCommand(NewProjectCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user