From 90b46529f03d379c731ab7ca1cd1abfb66142acc Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 13:23:56 -0400 Subject: [PATCH 1/3] feat(find): implement -exec command {} \; MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add -exec predicate to find, running commands in find's working directory with the full relative path as the {} replacement. Unlike -execdir (which uses ./basename in the file's parent directory), -exec passes the complete path (e.g. dir/sub/file.txt) matching GNU find behavior. Key design decisions: - \; mode only — + (batch) mode rejected with clear error - AllowedCommands enforced at parse-time AND eval-time (defense-in-depth) - Same sandbox — sub-commands run through RunCommand with AllowedPaths - No shell re-parsing — {} replacement via strings.ReplaceAll - No ./ prefix (unlike -execdir) — matches GNU find; -execdir recommended for TOCTOU-sensitive and dash-injection-safe use cases - Parser refactored into shared parseExecLikePredicate helper Co-Authored-By: Claude Opus 4.6 (1M context) --- SHELL_FEATURES.md | 2 +- builtins/find/builtin_find_pentest_test.go | 328 +++++++++++++++++- builtins/find/eval.go | 34 ++ builtins/find/expr.go | 36 +- builtins/find/expr_test.go | 90 ++++- builtins/find/find.go | 31 +- .../cmd/find/exec/and_short_circuit.yaml | 21 ++ tests/scenarios/cmd/find/exec/basic.yaml | 23 ++ .../cmd/find/exec/batch_rejected.yaml | 17 + .../cmd/find/exec/combined_name.yaml | 26 ++ .../cmd/find/exec/command_not_allowed.yaml | 16 + .../cmd/find/exec/dot_start_path.yaml | 19 + .../cmd/find/exec/embedded_braces.yaml | 19 + .../cmd/find/exec/exec_cat_file.yaml | 18 + .../cmd/find/exec/exec_exit_code_and.yaml | 21 ++ .../cmd/find/exec/exec_with_print.yaml | 20 ++ .../cmd/find/exec/literal_plus_arg.yaml | 19 + .../cmd/find/exec/missing_command.yaml | 17 + .../cmd/find/exec/missing_terminator.yaml | 17 + .../cmd/find/exec/multiple_args.yaml | 19 + .../cmd/find/exec/multiple_exec.yaml | 20 ++ .../scenarios/cmd/find/exec/negated_exec.yaml | 21 ++ tests/scenarios/cmd/find/exec/nested_dir.yaml | 19 + .../scenarios/cmd/find/exec/or_fallback.yaml | 23 ++ .../cmd/find/exec/prune_with_exec.yaml | 22 ++ .../cmd/find/exec/quit_after_exec.yaml | 25 ++ .../cmd/find/exec/returns_false.yaml | 18 + .../scenarios/cmd/find/exec/returns_true.yaml | 18 + .../cmd/find/exec/suppresses_print.yaml | 23 ++ .../cmd/find/execdir/exec_still_blocked.yaml | 12 +- .../cmd/find/sandbox/blocked_exec.yaml | 15 +- 31 files changed, 963 insertions(+), 46 deletions(-) create mode 100644 tests/scenarios/cmd/find/exec/and_short_circuit.yaml create mode 100644 tests/scenarios/cmd/find/exec/basic.yaml create mode 100644 tests/scenarios/cmd/find/exec/batch_rejected.yaml create mode 100644 tests/scenarios/cmd/find/exec/combined_name.yaml create mode 100644 tests/scenarios/cmd/find/exec/command_not_allowed.yaml create mode 100644 tests/scenarios/cmd/find/exec/dot_start_path.yaml create mode 100644 tests/scenarios/cmd/find/exec/embedded_braces.yaml create mode 100644 tests/scenarios/cmd/find/exec/exec_cat_file.yaml create mode 100644 tests/scenarios/cmd/find/exec/exec_exit_code_and.yaml create mode 100644 tests/scenarios/cmd/find/exec/exec_with_print.yaml create mode 100644 tests/scenarios/cmd/find/exec/literal_plus_arg.yaml create mode 100644 tests/scenarios/cmd/find/exec/missing_command.yaml create mode 100644 tests/scenarios/cmd/find/exec/missing_terminator.yaml create mode 100644 tests/scenarios/cmd/find/exec/multiple_args.yaml create mode 100644 tests/scenarios/cmd/find/exec/multiple_exec.yaml create mode 100644 tests/scenarios/cmd/find/exec/negated_exec.yaml create mode 100644 tests/scenarios/cmd/find/exec/nested_dir.yaml create mode 100644 tests/scenarios/cmd/find/exec/or_fallback.yaml create mode 100644 tests/scenarios/cmd/find/exec/prune_with_exec.yaml create mode 100644 tests/scenarios/cmd/find/exec/quit_after_exec.yaml create mode 100644 tests/scenarios/cmd/find/exec/returns_false.yaml create mode 100644 tests/scenarios/cmd/find/exec/returns_true.yaml create mode 100644 tests/scenarios/cmd/find/exec/suppresses_print.yaml diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index fa675703..5a6290bb 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -12,7 +12,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `echo [-neE] [ARG]...` — write arguments to stdout; `-n` suppresses trailing newline, `-e` enables backslash escapes, `-E` disables them (default) - ✅ `exit [N]` — exit the shell with status N (default 0) - ✅ `false` — return exit code 1 -- ✅ `find [-L] [-P] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `--help`, `-name`, `-iname`, `-path`, `-ipath`, `-type` (b,c,d,f,l,p,s), `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-perm`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-execdir CMD {} \;`, `-prune`, `-quit`, logical operators (`!`, `-a`, `-o`, `()`); blocks `-exec`, `-delete`, `-regex` for sandbox safety +- ✅ `find [-L] [-P] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `--help`, `-name`, `-iname`, `-path`, `-ipath`, `-type` (b,c,d,f,l,p,s), `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-perm`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-exec CMD {} \;`, `-execdir CMD {} \;`, `-prune`, `-quit`, logical operators (`!`, `-a`, `-o`, `()`); blocks `-delete`, `-regex` for sandbox safety - ✅ `grep [-EFGivclLnHhoqsxw] [-e PATTERN] [-m NUM] [-A NUM] [-B NUM] [-C NUM] PATTERN [FILE]...` — print lines that match patterns; uses RE2 regex engine (linear-time, no backtracking) - ✅ `head [-n N|-c N] [-q|-v] [FILE]...` — output the first part of files (default: first 10 lines); `-z`/`--zero-terminated` and `--follow` are rejected - ✅ `help` — display all available builtin commands with brief descriptions; for detailed flag info, use ` --help` diff --git a/builtins/find/builtin_find_pentest_test.go b/builtins/find/builtin_find_pentest_test.go index d7863793..322d9bea 100644 --- a/builtins/find/builtin_find_pentest_test.go +++ b/builtins/find/builtin_find_pentest_test.go @@ -424,11 +424,325 @@ func TestFindExecDirWindowsDriveRoot(t *testing.T) { // ---- GTFOBins ---- -// TestFindExecDirExecStillBlocked verifies that -exec remains in the blocked -// predicates list even after -execdir was introduced. This prevents -exec from -// being used as a higher-privilege GTFOBins escalation path. -func TestFindExecDirExecStillBlocked(t *testing.T) { - _, err := parseExpression([]string{"-exec", "id", ";"}) - require.Error(t, err, "-exec must still be blocked") - assert.Contains(t, err.Error(), "blocked", "error must mention 'blocked'") +// TestFindExecParses verifies that -exec echo {} ; parses successfully now +// that -exec is implemented and no longer in the blocked predicates list. +func TestFindExecParses(t *testing.T) { + _, err := parseExpression([]string{"-exec", "echo", "{}", ";"}) + require.NoError(t, err, "-exec must parse successfully") +} + +// ---- -exec tests ---- + +// ---- Sandbox & AllowedCommands (CWE-284) ---- + +// TestFindExecCommandNotAllowed verifies that CommandAllowed is consulted +// at eval-time and that a blocked command causes a non-match with an error on +// stderr, without invoking RunCommand. +func TestFindExecCommandNotAllowed(t *testing.T) { + var stdout, stderr bytes.Buffer + runCalled := false + + callCtx := newPentestCallCtx(&stdout, &stderr) + callCtx.RunCommand = func(_ context.Context, _ string, _ string, _ []string) (uint8, error) { + runCalled = true + return 0, nil + } + callCtx.CommandAllowed = func(name string) bool { + return false // block everything + } + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: "somefile.txt", + printPath: "dir/somefile.txt", + info: &mockFileInfo{}, + } + e := &expr{ + kind: exprExec, + execCmd: "echo", + execArgs: []string{"{}"}, + } + + result := evalExec(ec, e) + + assert.False(t, result.matched, "blocked command must not match") + assert.True(t, ec.failed, "ec.failed must be set when command is blocked") + assert.False(t, runCalled, "RunCommand must not be invoked for a blocked command") + assert.Contains(t, stderr.String(), "not allowed", "stderr must mention 'not allowed'") +} + +// TestFindExecNilRunCommand verifies that a nil RunCommand is handled +// gracefully: evalExec must set ec.failed, write to stderr, and return a +// non-matched result rather than panicking. +func TestFindExecNilRunCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + + callCtx := newPentestCallCtx(&stdout, &stderr) + // RunCommand deliberately left nil. + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: "somefile.txt", + printPath: "dir/somefile.txt", + info: &mockFileInfo{}, + } + e := &expr{ + kind: exprExec, + execCmd: "echo", + execArgs: []string{"{}"}, + } + + require.NotPanics(t, func() { + result := evalExec(ec, e) + assert.False(t, result.matched) + }) + assert.True(t, ec.failed, "ec.failed must be set when RunCommand is nil") + assert.NotEmpty(t, stderr.String(), "an error message must appear on stderr") +} + +// ---- Command Injection via Filenames (CWE-78) ---- + +// testExecFilename is a helper that verifies the argument passed to +// RunCommand for a given (relPath, printPath) pair is exactly printPath, +// ensuring that shell metacharacters in filenames are never interpreted. +// Unlike -execdir, -exec uses the full printPath (no "./" prefix). +func testExecFilename(t *testing.T, relPath, printPath string) { + t.Helper() + + var stdout, stderr bytes.Buffer + var capturedArgs []string + + callCtx := newPentestCallCtx(&stdout, &stderr) + callCtx.RunCommand = func(_ context.Context, _ string, _ string, args []string) (uint8, error) { + capturedArgs = make([]string, len(args)) + copy(capturedArgs, args) + return 0, nil + } + callCtx.CommandAllowed = func(_ string) bool { return true } + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: relPath, + printPath: printPath, + info: &mockFileInfo{}, + } + e := &expr{ + kind: exprExec, + execCmd: "echo", + execArgs: []string{"{}"}, + } + + result := evalExec(ec, e) + + assert.True(t, result.matched, "echo should succeed and match") + require.Len(t, capturedArgs, 1, "exactly one argument must be passed to the command") + assert.Equal(t, printPath, capturedArgs[0], + "printPath %q must be passed verbatim, not interpreted as shell syntax", printPath) +} + +// TestFindExecSemicolonInFilename ensures a filename containing a semicolon +// is passed literally and cannot terminate the command or inject a new command. +func TestFindExecSemicolonInFilename(t *testing.T) { + testExecFilename(t, "dir/hi; ls", "dir/hi; ls") +} + +// TestFindExecPipeInFilename ensures a pipe character in a filename does +// not result in stdout being piped to another process. +func TestFindExecPipeInFilename(t *testing.T) { + testExecFilename(t, "dir/data|cat", "dir/data|cat") +} + +// TestFindExecBacktickInFilename ensures backtick command substitution +// characters in filenames are not executed by the shell. +func TestFindExecBacktickInFilename(t *testing.T) { + testExecFilename(t, "dir/`rm`", "dir/`rm`") +} + +// TestFindExecDollarParenInFilename ensures $(...) command substitution +// syntax in filenames is not interpreted. +func TestFindExecDollarParenInFilename(t *testing.T) { + testExecFilename(t, "dir/$(rm)", "dir/$(rm)") +} + +// TestFindExecNewlineInFilename ensures a filename containing a newline +// character is passed as a single argument without being split. +func TestFindExecNewlineInFilename(t *testing.T) { + testExecFilename(t, "dir/file\nname", "dir/file\nname") +} + +// TestFindExecSpacesInFilename ensures a filename with multiple spaces is +// passed as a single argument and not word-split into multiple tokens. +func TestFindExecSpacesInFilename(t *testing.T) { + testExecFilename(t, "dir/a b c", "dir/a b c") +} + +// TestFindExecGlobCharsInFilename ensures glob metacharacters in filenames +// are not expanded by the shell. +func TestFindExecGlobCharsInFilename(t *testing.T) { + testExecFilename(t, "dir/*.txt", "dir/*.txt") +} + +// TestFindExecQuotesInFilename ensures single and double quote characters +// in filenames are passed literally without breaking argument quoting. +func TestFindExecQuotesInFilename(t *testing.T) { + testExecFilename(t, `dir/it's a "test"`, `dir/it's a "test"`) +} + +// ---- Argument Injection (CWE-88) ---- + +// TestFindExecDashInPath verifies that a path beginning with a dash +// (e.g. "dir/-rf") is passed as-is without a "./" prefix, unlike -execdir. +// Note: -exec does NOT protect against dash-injection; this is expected behavior. +func TestFindExecDashInPath(t *testing.T) { + testExecFilename(t, "dir/-rf", "dir/-rf") +} + +// ---- Correctness ---- + +// TestFindExecFullPathReplacement verifies that {} is replaced with the full +// printPath (not the relPath or a ./-prefixed basename). +func TestFindExecFullPathReplacement(t *testing.T) { + tests := []struct { + name string + relPath string + printPath string + }{ + {"file in cwd", "file.txt", "./file.txt"}, + {"file in subdir", "sub/deep.txt", "dir/sub/deep.txt"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + var capturedArgs []string + + callCtx := newPentestCallCtx(&stdout, &stderr) + callCtx.RunCommand = func(_ context.Context, _ string, _ string, args []string) (uint8, error) { + capturedArgs = make([]string, len(args)) + copy(capturedArgs, args) + return 0, nil + } + callCtx.CommandAllowed = func(_ string) bool { return true } + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: tt.relPath, + printPath: tt.printPath, + info: &mockFileInfo{}, + } + e := &expr{ + kind: exprExec, + execCmd: "echo", + execArgs: []string{"{}"}, + } + + result := evalExec(ec, e) + + assert.True(t, result.matched) + require.Len(t, capturedArgs, 1) + assert.Equal(t, tt.printPath, capturedArgs[0], + "{} must expand to printPath %q", tt.printPath) + }) + } +} + +// TestFindExecWorkDir verifies that evalExec passes ec.execWorkDir as the +// dir argument to RunCommand, not ec.execDirParent. +func TestFindExecWorkDir(t *testing.T) { + var stdout, stderr bytes.Buffer + var capturedDir string + + callCtx := newPentestCallCtx(&stdout, &stderr) + callCtx.RunCommand = func(_ context.Context, dir string, _ string, _ []string) (uint8, error) { + capturedDir = dir + return 0, nil + } + callCtx.CommandAllowed = func(_ string) bool { return true } + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: "sub/file.txt", + printPath: "dir/sub/file.txt", + info: &mockFileInfo{}, + execWorkDir: "/absolute/cwd", + execDirParent: "/absolute/cwd/sub", + } + e := &expr{ + kind: exprExec, + execCmd: "echo", + execArgs: []string{"{}"}, + } + + result := evalExec(ec, e) + + assert.True(t, result.matched) + assert.Equal(t, "/absolute/cwd", capturedDir, + "RunCommand dir must equal ec.execWorkDir, not ec.execDirParent") +} + +// TestFindExecEmbeddedBracesReplacement verifies that {} embedded in an +// argument (e.g. {}.bak) is replaced with the full printPath. +func TestFindExecEmbeddedBracesReplacement(t *testing.T) { + var stdout, stderr bytes.Buffer + var capturedArgs []string + + callCtx := newPentestCallCtx(&stdout, &stderr) + callCtx.RunCommand = func(_ context.Context, _ string, _ string, args []string) (uint8, error) { + capturedArgs = make([]string, len(args)) + copy(capturedArgs, args) + return 0, nil + } + callCtx.CommandAllowed = func(_ string) bool { return true } + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: "file.txt", + printPath: "dir/file.txt", + info: &mockFileInfo{}, + } + e := &expr{ + kind: exprExec, + execCmd: "echo", + execArgs: []string{"{}.bak"}, + } + + result := evalExec(ec, e) + + assert.True(t, result.matched) + require.Len(t, capturedArgs, 1) + assert.Equal(t, "dir/file.txt.bak", capturedArgs[0]) +} + +// TestFindExecNoPathLookup verifies that specifying a path-relative command +// such as "./malicious" via -exec fails because RunCommand is not provided. +func TestFindExecNoPathLookup(t *testing.T) { + var stdout, stderr bytes.Buffer + + callCtx := newPentestCallCtx(&stdout, &stderr) + // RunCommand is nil — simulates an environment where no external execution + // is available, exercising that ./malicious cannot be launched. + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: "target.txt", + printPath: "dir/target.txt", + info: &mockFileInfo{}, + } + e := &expr{ + kind: exprExec, + execCmd: "./malicious", + execArgs: []string{"{}"}, + } + + result := evalExec(ec, e) + + assert.False(t, result.matched, "./malicious must not be executed via path lookup") + assert.True(t, ec.failed, "ec.failed must be set") + assert.NotEmpty(t, stderr.String(), "an error must be reported on stderr") } diff --git a/builtins/find/eval.go b/builtins/find/eval.go index 98775b69..d929d177 100644 --- a/builtins/find/eval.go +++ b/builtins/find/eval.go @@ -36,6 +36,7 @@ type evalContext struct { followLinks bool // true when -L is active failed bool // set by predicates that encounter errors execDirParent string // absolute path of the file's parent directory for -execdir + execWorkDir string // absolute working directory for -exec (find's CWD) } // evaluate evaluates an expression tree against a file. If e is nil, returns @@ -110,6 +111,9 @@ func evaluate(ec *evalContext, e *expr) evalResult { case exprExecDir: return evalExecDir(ec, e) + case exprExec: + return evalExec(ec, e) + case exprQuit: return evalResult{matched: true, quit: true} @@ -300,3 +304,33 @@ func evalExecDir(ec *evalContext, e *expr) evalResult { } return evalResult{matched: exitCode == 0} } + +// evalExec executes a command in find's working directory for each matched file. +// The filename is passed as the full relative path (e.g. dir/sub/file.txt). +// Unlike evalExecDir, no "./" prefix is added — this matches GNU find behavior. +// Note: -execdir is recommended over -exec for safer filename handling (./prefix +// prevents dash-injection, and running in the file's directory reduces TOCTOU risk). +func evalExec(ec *evalContext, e *expr) evalResult { + if ec.callCtx.RunCommand == nil { + ec.callCtx.Errf("find: -exec: command execution not available\n") + ec.failed = true + return evalResult{} + } + if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(e.execCmd) { + ec.callCtx.Errf("find: -exec: '%s': command not allowed\n", e.execCmd) + ec.failed = true + return evalResult{} + } + replacement := ec.printPath + args := make([]string, len(e.execArgs)) + for i, a := range e.execArgs { + args[i] = strings.ReplaceAll(a, "{}", replacement) + } + exitCode, err := ec.callCtx.RunCommand(ec.ctx, ec.execWorkDir, e.execCmd, args) + if err != nil { + ec.callCtx.Errf("find: '%s': %s\n", e.execCmd, err) + ec.failed = true + return evalResult{} + } + return evalResult{matched: exitCode == 0} +} diff --git a/builtins/find/expr.go b/builtins/find/expr.go index 5e40cea5..2ab60d0a 100644 --- a/builtins/find/expr.go +++ b/builtins/find/expr.go @@ -42,6 +42,7 @@ const ( exprTrue // -true exprFalse // -false exprExecDir // -execdir command {} ; + exprExec // -exec command {} ; exprAnd // expr -a expr or expr expr (implicit) exprOr // expr -o expr exprNot // ! expr or -not expr @@ -85,8 +86,8 @@ type expr struct { numCmp cmpOp // comparison operator for numeric predicates permVal uint32 // for -perm: permission bits permCmp byte // for -perm: '=' exact, '-' all bits, '/' any bit - execCmd string // command name for -execdir - execArgs []string // argument template for -execdir (each element is literal or "{}") + execCmd string // command name for -exec/-execdir + execArgs []string // argument template for -exec/-execdir (each element is literal or "{}") left *expr // for and/or right *expr // for and/or operand *expr // for not @@ -97,7 +98,7 @@ type expr struct { // control flow (handled at evaluation time by checking quit before // implicit print) and does not affect the implicit-print decision. func (e *expr) isAction() bool { - return e.kind == exprPrint || e.kind == exprPrint0 || e.kind == exprExecDir + return e.kind == exprPrint || e.kind == exprPrint0 || e.kind == exprExecDir || e.kind == exprExec } // hasAction checks if any node in the expression tree is an action. @@ -135,7 +136,6 @@ var errHelpRequested = errors.New("find: help requested") // blocked predicates that are forbidden for sandbox safety. var blockedPredicates = map[string]string{ - "-exec": "arbitrary command execution is blocked", "-delete": "file deletion is blocked", "-ok": "interactive execution is blocked", "-okdir": "interactive execution is blocked", @@ -344,6 +344,8 @@ func (p *parser) parsePrimary() (*expr, error) { return p.parseDepthOption(true) case "-mindepth": return p.parseDepthOption(false) + case "-exec": + return p.parseExecPredicate() case "-execdir": return p.parseExecDirPredicate() case "-true": @@ -683,17 +685,25 @@ func parseSymbolicMode(s string) (uint64, error) { return mode, nil } -// parseExecDirPredicate parses -execdir command [args...] ; +func (p *parser) parseExecPredicate() (*expr, error) { + return p.parseExecLikePredicate(exprExec, "-exec") +} + +func (p *parser) parseExecDirPredicate() (*expr, error) { + return p.parseExecLikePredicate(exprExecDir, "-execdir") +} + +// parseExecLikePredicate parses -exec/-execdir command [args...] ; // Only \; mode is supported. {} + (batch mode) is rejected with a clear error. // A literal "+" that does not follow "{}" is treated as a normal argument. -func (p *parser) parseExecDirPredicate() (*expr, error) { +func (p *parser) parseExecLikePredicate(kind exprKind, name string) (*expr, error) { if p.pos >= len(p.args) { - return nil, errors.New("find: missing argument to '-execdir'") + return nil, fmt.Errorf("find: missing argument to '%s'", name) } // Collect tokens until ";" terminator, or "+" after "{}" (batch mode). // In find syntax, "+" is only special as a terminator in the {} + form; - // otherwise it is a normal argument (e.g. -execdir echo + {} \;). + // otherwise it is a normal argument (e.g. -exec echo + {} \;). startPos := p.pos for p.pos < len(p.args) { tok := p.args[p.pos] @@ -701,13 +711,13 @@ func (p *parser) parseExecDirPredicate() (*expr, error) { break } if tok == "+" && p.pos > startPos && p.args[p.pos-1] == "{}" { - return nil, errors.New("find: -execdir ... + (batch mode) is not yet supported") + return nil, fmt.Errorf("find: %s ... + (batch mode) is not yet supported", name) } p.pos++ } if p.pos >= len(p.args) { - return nil, errors.New("find: missing argument to '-execdir'") + return nil, fmt.Errorf("find: missing argument to '%s'", name) } // Consume the ";" terminator. @@ -715,13 +725,13 @@ func (p *parser) parseExecDirPredicate() (*expr, error) { tokens := p.args[startPos : p.pos-1] if len(tokens) == 0 { - return nil, errors.New("find: missing argument to '-execdir'") + return nil, fmt.Errorf("find: missing argument to '%s'", name) } cmd := tokens[0] args := tokens[1:] - return &expr{kind: exprExecDir, execCmd: cmd, execArgs: args}, nil + return &expr{kind: kind, execCmd: cmd, execArgs: args}, nil } // parseSize parses a -size argument like "+10k", "-5M", "100c". @@ -794,6 +804,8 @@ func (k exprKind) String() string { return "-perm" case exprExecDir: return "-execdir" + case exprExec: + return "-exec" case exprQuit: return "-quit" case exprPrint: diff --git a/builtins/find/expr_test.go b/builtins/find/expr_test.go index 4f2a624c..47809ca1 100644 --- a/builtins/find/expr_test.go +++ b/builtins/find/expr_test.go @@ -211,7 +211,7 @@ func TestParseTypePredicate(t *testing.T) { // TestParseBlockedPredicates verifies all dangerous predicates are blocked. func TestParseBlockedPredicates(t *testing.T) { blocked := []string{ - "-exec", "-delete", "-ok", "-okdir", + "-delete", "-ok", "-okdir", "-fls", "-fprint", "-fprint0", "-fprintf", "-regex", "-iregex", } @@ -219,7 +219,7 @@ func TestParseBlockedPredicates(t *testing.T) { t.Run(pred, func(t *testing.T) { // Blocked predicates that take an argument need one to not fail with "missing argument". args := []string{pred} - if pred == "-exec" || pred == "-ok" || pred == "-okdir" { + if pred == "-ok" || pred == "-okdir" { args = append(args, "cmd", ";") } _, err := parseExpression(args) @@ -435,6 +435,92 @@ func TestParseExecDirIsAction(t *testing.T) { assert.True(t, e.isAction(), "exprExecDir must be reported as an action") } +// TestParseExecBasic verifies that -exec echo {} ; is parsed into an +// exprExec node with the correct execCmd and execArgs. +func TestParseExecBasic(t *testing.T) { + pr, err := parseExpression([]string{"-exec", "echo", "{}", ";"}) + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, exprExec, pr.expr.kind) + assert.Equal(t, "echo", pr.expr.execCmd) + assert.Equal(t, []string{"{}"}, pr.expr.execArgs) +} + +// TestParseExecMultipleArgs verifies that all positional arguments +// surrounding {} are preserved in execArgs. +func TestParseExecMultipleArgs(t *testing.T) { + pr, err := parseExpression([]string{"-exec", "echo", "before", "{}", "after", ";"}) + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, exprExec, pr.expr.kind) + assert.Equal(t, "echo", pr.expr.execCmd) + assert.Equal(t, []string{"before", "{}", "after"}, pr.expr.execArgs) +} + +// TestParseExecMissingTerminator verifies that -exec without a ; or + +// terminator returns an error mentioning "missing argument". +func TestParseExecMissingTerminator(t *testing.T) { + _, err := parseExpression([]string{"-exec", "echo", "{}"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing argument") +} + +// TestParseExecMissingCommand verifies that -exec ; (no command token) +// returns an error mentioning "missing argument". +func TestParseExecMissingCommand(t *testing.T) { + _, err := parseExpression([]string{"-exec", ";"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing argument") +} + +// TestParseExecBatchRejected verifies that the + batch-mode terminator is +// explicitly rejected with an error mentioning "batch mode". +func TestParseExecBatchRejected(t *testing.T) { + _, err := parseExpression([]string{"-exec", "echo", "{}", "+"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "batch mode") +} + +// TestParseExecLiteralPlus verifies that a literal "+" that does not follow +// "{}" is treated as a normal argument, not as a batch-mode terminator. +func TestParseExecLiteralPlus(t *testing.T) { + // "+" before "{}" — normal argument + pr, err := parseExpression([]string{"-exec", "echo", "+", "{}", ";"}) + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, exprExec, pr.expr.kind) + assert.Equal(t, "echo", pr.expr.execCmd) + assert.Equal(t, []string{"+", "{}"}, pr.expr.execArgs) + + // "+" as the only argument (no "{}" at all) + pr2, err := parseExpression([]string{"-exec", "echo", "+", ";"}) + require.NoError(t, err) + require.NotNil(t, pr2.expr) + assert.Equal(t, []string{"+"}, pr2.expr.execArgs) + + // "{}" + still triggers batch mode error + _, err = parseExpression([]string{"-exec", "echo", "{}", "+"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "batch mode") +} + +// TestParseExecEmbeddedBraces verifies that {} embedded within a larger +// argument token (e.g. {}.bak) is accepted and stored in execArgs. +func TestParseExecEmbeddedBraces(t *testing.T) { + pr, err := parseExpression([]string{"-exec", "echo", "{}.bak", ";"}) + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, exprExec, pr.expr.kind) + assert.Equal(t, []string{"{}.bak"}, pr.expr.execArgs) +} + +// TestParseExecIsAction verifies that isAction() returns true for an +// exprExec node, ensuring -exec suppresses implicit -print. +func TestParseExecIsAction(t *testing.T) { + e := &expr{kind: exprExec} + assert.True(t, e.isAction(), "exprExec must be reported as an action") +} + // TestParseExpressionLimits verifies AST depth and node limits. func TestParseExpressionLimits(t *testing.T) { t.Run("depth limit", func(t *testing.T) { diff --git a/builtins/find/find.go b/builtins/find/find.go index f0cb65cb..5acadd6e 100644 --- a/builtins/find/find.go +++ b/builtins/find/find.go @@ -36,6 +36,7 @@ // -print — print path followed by newline // -print0 — print path followed by NUL // -prune — skip directory subtree +// -exec CMD {} ; — run CMD with full path // -execdir CMD {} ; — run CMD in file's directory with ./basename // -quit — exit immediately // -true — always true @@ -50,7 +51,7 @@ // // Blocked predicates (sandbox safety): // -// -exec, -delete, -ok, -okdir — execution/deletion +// -delete, -ok, -okdir — deletion/interactive // -fls, -fprint, -fprint0, -fprintf — file writes // -regex, -iregex — ReDoS risk // @@ -180,10 +181,10 @@ optLoop: minDepth = 0 } - // Post-parse validation: check -execdir commands are allowed. - for _, cmd := range collectExecDirCmds(expression) { + // Post-parse validation: check -exec/-execdir commands are allowed. + for _, cmd := range collectExecCmds(expression) { if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(cmd) { - callCtx.Errf("find: -execdir: '%s': command not allowed\n", cmd) + callCtx.Errf("find: '%s': command not allowed\n", cmd) return builtins.Result{Code: 1} } } @@ -316,6 +317,7 @@ func printHelp(callCtx *builtins.CallContext) { callCtx.Out("Actions:\n") callCtx.Out(" -print Print path followed by newline.\n") callCtx.Out(" -print0 Print path followed by NUL.\n") + callCtx.Out(" -exec CMD [ARG]... ; Run CMD with full path.\n") callCtx.Out(" -execdir CMD [ARG]... ; Run CMD in file's directory (./basename).\n") callCtx.Out(" -prune Skip directory subtree.\n") callCtx.Out(" -quit Exit immediately.\n\n") @@ -325,7 +327,7 @@ func printHelp(callCtx *builtins.CallContext) { callCtx.Out(" EXPR -a EXPR / EXPR -and EXPR Conjunction (implicit).\n") callCtx.Out(" EXPR -o EXPR / EXPR -or EXPR Disjunction.\n\n") callCtx.Out("Blocked predicates [sandbox]:\n") - callCtx.Out(" -exec, -delete, -ok, -okdir Execution/deletion.\n") + callCtx.Out(" -delete, -ok, -okdir Deletion/interactive.\n") callCtx.Out(" -fls, -fprint, -fprint0, -fprintf File writes.\n") callCtx.Out(" -regex, -iregex ReDoS risk.\n") } @@ -339,7 +341,7 @@ type walkOptions struct { minDepth int now time.Time eagerNewerErrors map[string]bool - workDir string // absolute working directory for -execdir path resolution + workDir string // absolute working directory for -exec/-execdir path resolution } // walkResult holds the outcome of a walk operation. @@ -459,6 +461,7 @@ func walkPath( newerErrors: newerErrors, followLinks: opts.followLinks, execDirParent: execDirParent, + execWorkDir: opts.workDir, } prune := false @@ -597,23 +600,23 @@ func walkPath( return walkResult{failed: failed, quit: quit} } -// collectExecDirCmds walks the expression tree and returns all -execdir command names. -func collectExecDirCmds(e *expr) []string { +// collectExecCmds walks the expression tree and returns all -exec/-execdir command names. +func collectExecCmds(e *expr) []string { var cmds []string - collectExecDirCmdsInto(e, &cmds) + collectExecCmdsInto(e, &cmds) return cmds } -func collectExecDirCmdsInto(e *expr, cmds *[]string) { +func collectExecCmdsInto(e *expr, cmds *[]string) { if e == nil { return } - if e.kind == exprExecDir { + if e.kind == exprExecDir || e.kind == exprExec { *cmds = append(*cmds, e.execCmd) } - collectExecDirCmdsInto(e.left, cmds) - collectExecDirCmdsInto(e.right, cmds) - collectExecDirCmdsInto(e.operand, cmds) + collectExecCmdsInto(e.left, cmds) + collectExecCmdsInto(e.right, cmds) + collectExecCmdsInto(e.operand, cmds) } // collectNewerRefs walks the expression tree and returns all -newer reference paths. diff --git a/tests/scenarios/cmd/find/exec/and_short_circuit.yaml b/tests/scenarios/cmd/find/exec/and_short_circuit.yaml new file mode 100644 index 00000000..918dbd66 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/and_short_circuit.yaml @@ -0,0 +1,21 @@ +description: find -false -exec echo {} \; never runs echo because AND short-circuits on -false. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -false -exec echo {} \; +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/basic.yaml b/tests/scenarios/cmd/find/exec/basic.yaml new file mode 100644 index 00000000..4d69f5d7 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/basic.yaml @@ -0,0 +1,23 @@ +description: find -exec echo {} \; runs echo with the full relative path (no ./ prefix). +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -name '*.txt' -exec echo {} \; +expect: + stdout_unordered: |+ + dir/a.txt + dir/b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/batch_rejected.yaml b/tests/scenarios/cmd/find/exec/batch_rejected.yaml new file mode 100644 index 00000000..dd952806 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/batch_rejected.yaml @@ -0,0 +1,17 @@ +description: find -exec with + terminator is rejected; only \; is supported. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -exec echo {} + +expect: + stderr_contains: ["+"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/exec/combined_name.yaml b/tests/scenarios/cmd/find/exec/combined_name.yaml new file mode 100644 index 00000000..2ccf11a5 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/combined_name.yaml @@ -0,0 +1,26 @@ +description: find -name filter combined with -exec only runs echo on matching files. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.go + content: "b" + chmod: 0644 + - path: dir/c.txt + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -name '*.txt' -exec echo {} \; +expect: + stdout_unordered: |+ + dir/a.txt + dir/c.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/command_not_allowed.yaml b/tests/scenarios/cmd/find/exec/command_not_allowed.yaml new file mode 100644 index 00000000..f05d2589 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/command_not_allowed.yaml @@ -0,0 +1,16 @@ +description: find -exec with a command absent from allowed_commands is rejected at parse time. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + script: |+ + find dir -exec echo {} \; +expect: + stderr_contains: ["not allowed"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/exec/dot_start_path.yaml b/tests/scenarios/cmd/find/exec/dot_start_path.yaml new file mode 100644 index 00000000..607e39be --- /dev/null +++ b/tests/scenarios/cmd/find/exec/dot_start_path.yaml @@ -0,0 +1,19 @@ +description: find . -exec echo {} \; substitutes {} with ./filename (dot-relative path). +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: file.txt + content: "hello" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find . -name 'file.txt' -exec echo {} \; +expect: + stdout: |+ + ./file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/embedded_braces.yaml b/tests/scenarios/cmd/find/exec/embedded_braces.yaml new file mode 100644 index 00000000..19166263 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/embedded_braces.yaml @@ -0,0 +1,19 @@ +description: find -exec replaces {} embedded in arguments like {}.bak with the full relative path. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -name 'a.txt' -exec echo {}.bak \; +expect: + stdout: |+ + dir/a.txt.bak + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_cat_file.yaml b/tests/scenarios/cmd/find/exec/exec_cat_file.yaml new file mode 100644 index 00000000..b843f8bd --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_cat_file.yaml @@ -0,0 +1,18 @@ +description: find -exec cat {} \; reads and prints the content of each matched file. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/hello.txt + content: "hello" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:cat + script: |+ + find dir -name 'hello.txt' -exec cat {} \; +expect: + stdout: "hello" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_exit_code_and.yaml b/tests/scenarios/cmd/find/exec/exec_exit_code_and.yaml new file mode 100644 index 00000000..4b81b5c0 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_exit_code_and.yaml @@ -0,0 +1,21 @@ +description: find -exec false {} \; -print — false return suppresses -print for each file via AND short-circuit. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:false + script: |+ + find dir -type f -exec false {} \; -print +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_with_print.yaml b/tests/scenarios/cmd/find/exec/exec_with_print.yaml new file mode 100644 index 00000000..ac62e1d0 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_with_print.yaml @@ -0,0 +1,20 @@ +description: find -exec echo {} \; -print — both the exec echo and -print run for each file. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -name '*.txt' -exec echo {} \; -print +expect: + stdout: |+ + dir/a.txt + dir/a.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/literal_plus_arg.yaml b/tests/scenarios/cmd/find/exec/literal_plus_arg.yaml new file mode 100644 index 00000000..28fe7f3d --- /dev/null +++ b/tests/scenarios/cmd/find/exec/literal_plus_arg.yaml @@ -0,0 +1,19 @@ +description: find -exec echo + {} \; treats + as a literal argument, not batch terminator. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -name 'a.txt' -exec echo + {} \; +expect: + stdout: |+ + + dir/a.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/missing_command.yaml b/tests/scenarios/cmd/find/exec/missing_command.yaml new file mode 100644 index 00000000..240642b9 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/missing_command.yaml @@ -0,0 +1,17 @@ +description: find -exec with \; immediately after flag (no command) is a parse error. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -exec \; +expect: + stderr_contains: ["-exec"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/exec/missing_terminator.yaml b/tests/scenarios/cmd/find/exec/missing_terminator.yaml new file mode 100644 index 00000000..fb5475e8 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/missing_terminator.yaml @@ -0,0 +1,17 @@ +description: find -exec without a \; terminator is a parse error. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -exec echo {} +expect: + stderr_contains: ["-exec"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/exec/multiple_args.yaml b/tests/scenarios/cmd/find/exec/multiple_args.yaml new file mode 100644 index 00000000..a1320de8 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/multiple_args.yaml @@ -0,0 +1,19 @@ +description: find -exec preserves argument order when extra args surround {}. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -name '*.txt' -exec echo before {} after \; +expect: + stdout: |+ + before dir/a.txt after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/multiple_exec.yaml b/tests/scenarios/cmd/find/exec/multiple_exec.yaml new file mode 100644 index 00000000..fc0084c2 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/multiple_exec.yaml @@ -0,0 +1,20 @@ +description: find with two consecutive -exec actions — both run for each matched file. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -name '*.txt' -exec echo first {} \; -exec echo second {} \; +expect: + stdout: |+ + first dir/a.txt + second dir/a.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/negated_exec.yaml b/tests/scenarios/cmd/find/exec/negated_exec.yaml new file mode 100644 index 00000000..20ec1ec8 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/negated_exec.yaml @@ -0,0 +1,21 @@ +description: find ! -exec false {} \; — negation inverts false to true, but -exec as an action suppresses implicit -print so no output. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:false + script: |+ + find dir -type f ! -exec false {} \; +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/nested_dir.yaml b/tests/scenarios/cmd/find/exec/nested_dir.yaml new file mode 100644 index 00000000..9a2b1efe --- /dev/null +++ b/tests/scenarios/cmd/find/exec/nested_dir.yaml @@ -0,0 +1,19 @@ +description: find -exec replaces {} with the full relative path for files in nested subdirectories. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/sub/deep.txt + content: "deep" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -name 'deep.txt' -exec echo {} \; +expect: + stdout: |+ + dir/sub/deep.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/or_fallback.yaml b/tests/scenarios/cmd/find/exec/or_fallback.yaml new file mode 100644 index 00000000..8372ccdd --- /dev/null +++ b/tests/scenarios/cmd/find/exec/or_fallback.yaml @@ -0,0 +1,23 @@ +description: find OR expression — .log files get exec echo, .txt files fall through to -print. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/app.log + content: "log" + chmod: 0644 + - path: dir/readme.txt + content: "txt" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -type f \( -name '*.log' -exec echo found {} \; -o -name '*.txt' -print \) +expect: + stdout_unordered: |+ + found dir/app.log + dir/readme.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/prune_with_exec.yaml b/tests/scenarios/cmd/find/exec/prune_with_exec.yaml new file mode 100644 index 00000000..cce750e0 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/prune_with_exec.yaml @@ -0,0 +1,22 @@ +description: find -prune skips a subdirectory; -exec only runs on files outside the pruned subtree. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/sub/hidden.txt + content: "hidden" + chmod: 0644 + - path: dir/visible.txt + content: "visible" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -type d -name sub -prune -o -type f -exec echo {} \; +expect: + stdout: |+ + dir/visible.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/quit_after_exec.yaml b/tests/scenarios/cmd/find/exec/quit_after_exec.yaml new file mode 100644 index 00000000..23e297f9 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/quit_after_exec.yaml @@ -0,0 +1,25 @@ +description: find -exec echo {} \; -quit runs exec exactly once then stops. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 + - path: dir/c.txt + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -name '*.txt' -exec echo {} \; -quit +expect: + stdout_contains: + - ".txt" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/returns_false.yaml b/tests/scenarios/cmd/find/exec/returns_false.yaml new file mode 100644 index 00000000..4bb6c4c0 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/returns_false.yaml @@ -0,0 +1,18 @@ +description: find -exec false {} \; exits 0 even though false returns non-zero (find reports errors, not predicate results). +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:false + script: |+ + find dir -type f -exec false {} \; +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/returns_true.yaml b/tests/scenarios/cmd/find/exec/returns_true.yaml new file mode 100644 index 00000000..037df4b8 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/returns_true.yaml @@ -0,0 +1,18 @@ +description: find -exec true {} \; succeeds with exit code 0. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:true + script: |+ + find dir -type f -exec true {} \; +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/suppresses_print.yaml b/tests/scenarios/cmd/find/exec/suppresses_print.yaml new file mode 100644 index 00000000..afcec507 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/suppresses_print.yaml @@ -0,0 +1,23 @@ +description: find -exec suppresses the implicit -print action; paths are not printed to stdout. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo + script: |+ + find dir -type f -exec echo {} \; +expect: + stdout_unordered: |+ + dir/a.txt + dir/b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/execdir/exec_still_blocked.yaml b/tests/scenarios/cmd/find/execdir/exec_still_blocked.yaml index 3916a6d5..699f2d84 100644 --- a/tests/scenarios/cmd/find/execdir/exec_still_blocked.yaml +++ b/tests/scenarios/cmd/find/execdir/exec_still_blocked.yaml @@ -1,5 +1,5 @@ -description: find -exec is still blocked even when -execdir is available. -skip_assert_against_bash: true # intentional: bash allows -exec; rshell blocks it +description: find -exec works alongside -execdir, using full path instead of ./basename. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands setup: files: - path: dir/a.txt @@ -11,7 +11,9 @@ input: - rshell:find - rshell:echo script: |+ - find dir -exec echo {} \; + find dir -name 'a.txt' -exec echo {} \; expect: - stderr_contains: ["blocked"] - exit_code: 1 + stdout: |+ + dir/a.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_exec.yaml b/tests/scenarios/cmd/find/sandbox/blocked_exec.yaml index 8b5eef41..852c867a 100644 --- a/tests/scenarios/cmd/find/sandbox/blocked_exec.yaml +++ b/tests/scenarios/cmd/find/sandbox/blocked_exec.yaml @@ -1,5 +1,5 @@ -description: find -exec is blocked for sandbox safety. -skip_assert_against_bash: true # intentional: bash allows -exec; rshell blocks it +description: find -exec runs allowed commands with full path. +skip_assert_against_bash: true # rshell -exec only runs builtins, not external commands setup: files: - path: dummy.txt @@ -7,8 +7,13 @@ setup: chmod: 0644 input: allowed_paths: ["$DIR"] + allowed_commands: + - rshell:find + - rshell:echo script: |+ - find . -exec echo {} \; + find . -name 'dummy.txt' -exec echo {} \; expect: - stderr_contains: ["blocked"] - exit_code: 1 + stdout: |+ + ./dummy.txt + stderr: "" + exit_code: 0 From 0bf6344ffd1f137c1c40448f64d091d784503ca2 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 13:41:55 -0400 Subject: [PATCH 2/3] fix(find): expand {} in -exec/-execdir command token (argv[0]) GNU find applies {} substitution across the entire command vector including the command name. Previously only execArgs were substituted while execCmd was passed through unchanged. Now both evalExec and evalExecDir substitute {} in the command name and validate the resolved name against CommandAllowed. The post-parse eager check skips commands containing {} since the substituted value depends on each file. Co-Authored-By: Claude Opus 4.6 (1M context) --- allowedsymbols/symbols_builtins.go | 3 +- builtins/find/builtin_find_pentest_test.go | 67 ++++++++++++++++++++++ builtins/find/eval.go | 22 +++---- builtins/find/find.go | 5 ++ 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/allowedsymbols/symbols_builtins.go b/allowedsymbols/symbols_builtins.go index 23309ae2..bc669cb3 100644 --- a/allowedsymbols/symbols_builtins.go +++ b/allowedsymbols/symbols_builtins.go @@ -96,8 +96,9 @@ var builtinPerCommandSymbols = map[string][]string{ "strconv.ErrRange", // 🟢 sentinel error value for overflow; pure constant. "strconv.ParseInt", // 🟢 string-to-int conversion; pure function, no I/O. "strconv.ParseUint", // 🟢 string-to-unsigned-int conversion; pure function, no I/O. + "strings.Contains", // 🟢 substring search for {} detection in command validation; pure function, no I/O. "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. - "strings.ReplaceAll", // 🟢 replaces all {} occurrences in -execdir args; pure function, no I/O. + "strings.ReplaceAll", // 🟢 replaces all {} occurrences in -exec/-execdir args; pure function, no I/O. "strings.Split", // 🟢 splits a string by separator into a slice; pure function, no I/O. "strings.ToLower", // 🟢 converts string to lowercase; pure function, no I/O. "time.Duration", // 🟢 duration type; pure integer alias, no I/O. diff --git a/builtins/find/builtin_find_pentest_test.go b/builtins/find/builtin_find_pentest_test.go index 322d9bea..09f3abef 100644 --- a/builtins/find/builtin_find_pentest_test.go +++ b/builtins/find/builtin_find_pentest_test.go @@ -422,6 +422,73 @@ func TestFindExecDirWindowsDriveRoot(t *testing.T) { "drive root must be preserved as C:/, not truncated to C:") } +// ---- Command token substitution ---- + +// TestFindExecDirCommandTokenSubstitution verifies that {} in the command name +// (argv[0]) is substituted with ./basename, matching GNU find behavior. +func TestFindExecDirCommandTokenSubstitution(t *testing.T) { + var stdout, stderr bytes.Buffer + var capturedCmd string + + callCtx := newPentestCallCtx(&stdout, &stderr) + callCtx.RunCommand = func(_ context.Context, _ string, cmd string, _ []string) (uint8, error) { + capturedCmd = cmd + return 0, nil + } + callCtx.CommandAllowed = func(_ string) bool { return true } + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: "echo", + info: &mockFileInfo{}, + } + e := &expr{ + kind: exprExecDir, + execCmd: "{}", + execArgs: []string{"hello"}, + } + + result := evalExecDir(ec, e) + + assert.True(t, result.matched) + assert.Equal(t, "./echo", capturedCmd, + "command token {} must be substituted with ./basename") +} + +// TestFindExecCommandTokenSubstitution verifies that {} in the command name +// is substituted with the full printPath, matching GNU find behavior. +func TestFindExecCommandTokenSubstitution(t *testing.T) { + var stdout, stderr bytes.Buffer + var capturedCmd string + + callCtx := newPentestCallCtx(&stdout, &stderr) + callCtx.RunCommand = func(_ context.Context, _ string, cmd string, _ []string) (uint8, error) { + capturedCmd = cmd + return 0, nil + } + callCtx.CommandAllowed = func(_ string) bool { return true } + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: "dir/echo", + printPath: "dir/echo", + info: &mockFileInfo{}, + } + e := &expr{ + kind: exprExec, + execCmd: "{}", + execArgs: []string{"hello"}, + } + + result := evalExec(ec, e) + + assert.True(t, result.matched) + assert.Equal(t, "dir/echo", capturedCmd, + "command token {} must be substituted with full printPath") +} + // ---- GTFOBins ---- // TestFindExecParses verifies that -exec echo {} ; parses successfully now diff --git a/builtins/find/eval.go b/builtins/find/eval.go index d929d177..20b44144 100644 --- a/builtins/find/eval.go +++ b/builtins/find/eval.go @@ -280,11 +280,6 @@ func evalExecDir(ec *evalContext, e *expr) evalResult { ec.failed = true return evalResult{} } - if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(e.execCmd) { - ec.callCtx.Errf("find: -execdir: '%s': command not allowed\n", e.execCmd) - ec.failed = true - return evalResult{} - } base := baseName(ec.relPath) replacement := "./" + base if base == "/" { @@ -292,11 +287,17 @@ func evalExecDir(ec *evalContext, e *expr) evalResult { } else if len(ec.relPath) > 0 && ec.relPath[len(ec.relPath)-1] == '/' { replacement += "/" } + cmd := strings.ReplaceAll(e.execCmd, "{}", replacement) + if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(cmd) { + ec.callCtx.Errf("find: -execdir: '%s': command not allowed\n", cmd) + ec.failed = true + return evalResult{} + } args := make([]string, len(e.execArgs)) for i, a := range e.execArgs { args[i] = strings.ReplaceAll(a, "{}", replacement) } - exitCode, err := ec.callCtx.RunCommand(ec.ctx, ec.execDirParent, e.execCmd, args) + exitCode, err := ec.callCtx.RunCommand(ec.ctx, ec.execDirParent, cmd, args) if err != nil { ec.callCtx.Errf("find: '%s': %s\n", e.execCmd, err) ec.failed = true @@ -316,17 +317,18 @@ func evalExec(ec *evalContext, e *expr) evalResult { ec.failed = true return evalResult{} } - if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(e.execCmd) { - ec.callCtx.Errf("find: -exec: '%s': command not allowed\n", e.execCmd) + replacement := ec.printPath + cmd := strings.ReplaceAll(e.execCmd, "{}", replacement) + if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(cmd) { + ec.callCtx.Errf("find: -exec: '%s': command not allowed\n", cmd) ec.failed = true return evalResult{} } - replacement := ec.printPath args := make([]string, len(e.execArgs)) for i, a := range e.execArgs { args[i] = strings.ReplaceAll(a, "{}", replacement) } - exitCode, err := ec.callCtx.RunCommand(ec.ctx, ec.execWorkDir, e.execCmd, args) + exitCode, err := ec.callCtx.RunCommand(ec.ctx, ec.execWorkDir, cmd, args) if err != nil { ec.callCtx.Errf("find: '%s': %s\n", e.execCmd, err) ec.failed = true diff --git a/builtins/find/find.go b/builtins/find/find.go index 5acadd6e..c8676aeb 100644 --- a/builtins/find/find.go +++ b/builtins/find/find.go @@ -182,7 +182,12 @@ optLoop: } // Post-parse validation: check -exec/-execdir commands are allowed. + // Commands containing {} are skipped here — the substituted name is + // validated at eval-time when the replacement is known. for _, cmd := range collectExecCmds(expression) { + if strings.Contains(cmd, "{}") { + continue + } if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(cmd) { callCtx.Errf("find: '%s': command not allowed\n", cmd) return builtins.Result{Code: 1} From 06a68deb8d01a1b377cc5c8c826afbf5fa2d2c4a Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 13:58:42 -0400 Subject: [PATCH 3/3] refactor(find): address Effective Go review findings for -exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: error messages now use resolved cmd (post-{} substitution) instead of the raw template e.execCmd - P2: extract shared evalExecLike helper — evalExec and evalExecDir are now thin wrappers that compute replacement and dir - P3: TestFindExecFullPathReplacement reuses testExecFilename helper Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/find/builtin_find_pentest_test.go | 31 +---------------- builtins/find/eval.go | 39 +++++++--------------- 2 files changed, 13 insertions(+), 57 deletions(-) diff --git a/builtins/find/builtin_find_pentest_test.go b/builtins/find/builtin_find_pentest_test.go index 09f3abef..cec3c884 100644 --- a/builtins/find/builtin_find_pentest_test.go +++ b/builtins/find/builtin_find_pentest_test.go @@ -682,36 +682,7 @@ func TestFindExecFullPathReplacement(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var stdout, stderr bytes.Buffer - var capturedArgs []string - - callCtx := newPentestCallCtx(&stdout, &stderr) - callCtx.RunCommand = func(_ context.Context, _ string, _ string, args []string) (uint8, error) { - capturedArgs = make([]string, len(args)) - copy(capturedArgs, args) - return 0, nil - } - callCtx.CommandAllowed = func(_ string) bool { return true } - - ec := &evalContext{ - callCtx: callCtx, - ctx: context.Background(), - relPath: tt.relPath, - printPath: tt.printPath, - info: &mockFileInfo{}, - } - e := &expr{ - kind: exprExec, - execCmd: "echo", - execArgs: []string{"{}"}, - } - - result := evalExec(ec, e) - - assert.True(t, result.matched) - require.Len(t, capturedArgs, 1) - assert.Equal(t, tt.printPath, capturedArgs[0], - "{} must expand to printPath %q", tt.printPath) + testExecFilename(t, tt.relPath, tt.printPath) }) } } diff --git a/builtins/find/eval.go b/builtins/find/eval.go index 20b44144..e6bae733 100644 --- a/builtins/find/eval.go +++ b/builtins/find/eval.go @@ -275,11 +275,6 @@ func evalMmin(ec *evalContext, n int64, cmp cmpOp) bool { // evalExecDir executes a command in the directory of each matched file. // The filename is passed as ./basename, preventing leading-dash interpretation. func evalExecDir(ec *evalContext, e *expr) evalResult { - if ec.callCtx.RunCommand == nil { - ec.callCtx.Errf("find: -execdir: command execution not available\n") - ec.failed = true - return evalResult{} - } base := baseName(ec.relPath) replacement := "./" + base if base == "/" { @@ -287,23 +282,7 @@ func evalExecDir(ec *evalContext, e *expr) evalResult { } else if len(ec.relPath) > 0 && ec.relPath[len(ec.relPath)-1] == '/' { replacement += "/" } - cmd := strings.ReplaceAll(e.execCmd, "{}", replacement) - if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(cmd) { - ec.callCtx.Errf("find: -execdir: '%s': command not allowed\n", cmd) - ec.failed = true - return evalResult{} - } - args := make([]string, len(e.execArgs)) - for i, a := range e.execArgs { - args[i] = strings.ReplaceAll(a, "{}", replacement) - } - exitCode, err := ec.callCtx.RunCommand(ec.ctx, ec.execDirParent, cmd, args) - if err != nil { - ec.callCtx.Errf("find: '%s': %s\n", e.execCmd, err) - ec.failed = true - return evalResult{} - } - return evalResult{matched: exitCode == 0} + return evalExecLike(ec, e, "-execdir", replacement, ec.execDirParent) } // evalExec executes a command in find's working directory for each matched file. @@ -312,15 +291,21 @@ func evalExecDir(ec *evalContext, e *expr) evalResult { // Note: -execdir is recommended over -exec for safer filename handling (./prefix // prevents dash-injection, and running in the file's directory reduces TOCTOU risk). func evalExec(ec *evalContext, e *expr) evalResult { + return evalExecLike(ec, e, "-exec", ec.printPath, ec.execWorkDir) +} + +// evalExecLike is the shared implementation for -exec and -execdir. +// name is the predicate name (for error messages), replacement is the string +// substituted for {} tokens, and dir is the working directory for the sub-command. +func evalExecLike(ec *evalContext, e *expr, name, replacement, dir string) evalResult { if ec.callCtx.RunCommand == nil { - ec.callCtx.Errf("find: -exec: command execution not available\n") + ec.callCtx.Errf("find: %s: command execution not available\n", name) ec.failed = true return evalResult{} } - replacement := ec.printPath cmd := strings.ReplaceAll(e.execCmd, "{}", replacement) if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(cmd) { - ec.callCtx.Errf("find: -exec: '%s': command not allowed\n", cmd) + ec.callCtx.Errf("find: %s: '%s': command not allowed\n", name, cmd) ec.failed = true return evalResult{} } @@ -328,9 +313,9 @@ func evalExec(ec *evalContext, e *expr) evalResult { for i, a := range e.execArgs { args[i] = strings.ReplaceAll(a, "{}", replacement) } - exitCode, err := ec.callCtx.RunCommand(ec.ctx, ec.execWorkDir, cmd, args) + exitCode, err := ec.callCtx.RunCommand(ec.ctx, dir, cmd, args) if err != nil { - ec.callCtx.Errf("find: '%s': %s\n", e.execCmd, err) + ec.callCtx.Errf("find: '%s': %s\n", cmd, err) ec.failed = true return evalResult{} }