diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index e50b49b4..c0273412 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] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `-name`, `-iname`, `-path`, `-ipath`, `-type`, `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-prune`, logical operators (`!`, `-a`, `-o`, `()`); blocks `-exec`, `-delete`, `-regex` for sandbox safety +- ✅ `find [-L] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `-name`, `-iname`, `-path`, `-ipath`, `-type`, `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-prune`, `-exec cmd {} \;`, `-exec cmd {} +`, `-execdir cmd {} \;`, `-execdir cmd {} +`, logical operators (`!`, `-a`, `-o`, `()`); `-exec`/`-execdir` execute only shell builtins (not external binaries); 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 - ✅ `sort [-rnubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec) diff --git a/allowedsymbols/symbols_builtins.go b/allowedsymbols/symbols_builtins.go index f108da61..5ebe6ae4 100644 --- a/allowedsymbols/symbols_builtins.go +++ b/allowedsymbols/symbols_builtins.go @@ -72,6 +72,7 @@ var builtinPerCommandSymbols = map[string][]string{ "errors.New", // creates a simple error value; pure function, no I/O. "fmt.Errorf", // error formatting; pure function, no I/O. "io.EOF", // sentinel error value; pure constant. + "io.Writer", // interface type for writing; no side effects by itself. "io/fs.FileInfo", // interface type for file information; no side effects. "io/fs.ModeDir", // file mode bit constant for directories; pure constant. "io/fs.ModeNamedPipe", // file mode bit constant for named pipes; pure constant. @@ -83,11 +84,16 @@ var builtinPerCommandSymbols = map[string][]string{ "math.MaxInt64", // integer constant; no side effects. "os.IsNotExist", // checks if error is "not exist"; pure function, no I/O. "os.PathError", // error type for path operations; pure type. + "path.Base", // extracts last element of path (always uses /); pure function, no I/O. + "path.Clean", // cleans a path (removes trailing slashes, double slashes); pure function, no I/O. + "path.Dir", // extracts directory from path (always uses /); pure function, no I/O. "path/filepath.ToSlash", // converts OS path separators to forward slashes; pure function, no I/O. "strconv.Atoi", // string-to-int conversion; pure function, no I/O. "strconv.ErrRange", // sentinel error value for overflow; pure constant. "strconv.ParseInt", // string-to-int conversion; pure function, no I/O. + "strings.Contains", // checks if a substring is present; pure function, no I/O. "strings.HasPrefix", // pure function for prefix matching; no I/O. + "strings.ReplaceAll", // replaces all occurrences of a substring; 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. "time.Hour", // constant representing one hour; no side effects. @@ -336,6 +342,9 @@ var builtinAllowedSymbols = []string{ "os.IsNotExist", // checks if error is "not exist"; pure function, no I/O. "os.O_RDONLY", // read-only file flag constant; cannot open files by itself. "os.PathError", // error type for filesystem path errors; pure type, no I/O. + "path.Base", // extracts last element of a path (always uses /); pure function, no I/O. + "path.Clean", // cleans a path (removes trailing slashes, double slashes); pure function, no I/O. + "path.Dir", // extracts directory part of a path (always uses /); pure function, no I/O. "path/filepath.ToSlash", // converts OS path separators to forward slashes; pure function, no I/O. "regexp.Compile", // compiles a regular expression; pure function, no I/O. Uses RE2 engine (linear-time, no backtracking). "regexp.QuoteMeta", // escapes all special regex characters in a string; pure function, no I/O. @@ -355,6 +364,7 @@ var builtinAllowedSymbols = []string{ "strconv.ParseInt", // string-to-int conversion with base/bit-size; pure function, no I/O. "strconv.ParseUint", // string-to-unsigned-int conversion; pure function, no I/O. "strings.Builder", // efficient string concatenation; pure in-memory buffer, no I/O. + "strings.Contains", // checks if a substring is present; pure function, no I/O. "strings.ContainsRune", // checks if a rune is in a string; pure function, no I/O. "strings.HasPrefix", // pure function for prefix matching; no I/O. "strings.IndexByte", // finds byte in string; pure function, no I/O. diff --git a/builtins/builtins.go b/builtins/builtins.go index b845f9d8..0e438f8b 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -128,6 +128,13 @@ type CallContext struct { // via GetFileInformationByHandle. The path parameter is needed on Windows // where FileInfo.Sys() lacks identity fields; Unix ignores it. FileIdentity func(path string, info fs.FileInfo) (FileID, bool) + + // ExecCommand executes a builtin command within the shell interpreter. + // Used by find -exec/-execdir to invoke other builtins. The command + // runs with the same sandbox restrictions as the calling builtin. + // dir overrides the working directory for the command (empty = inherit). + // Returns the command's exit code. + ExecCommand func(ctx context.Context, args []string, dir string, stdout, stderr io.Writer) (uint8, error) } // Out writes a string to stdout. diff --git a/builtins/find/eval.go b/builtins/find/eval.go index 66259b83..7feca322 100644 --- a/builtins/find/eval.go +++ b/builtins/find/eval.go @@ -7,8 +7,11 @@ package find import ( "context" + "io" iofs "io/fs" "math" + "path" + "strings" "time" "github.com/DataDog/rshell/builtins" @@ -20,19 +23,24 @@ type evalResult struct { prune bool // skip descending into this directory } +// execCommandFunc is the signature for executing a builtin command. +type execCommandFunc func(ctx context.Context, args []string, dir string, stdout, stderr io.Writer) (uint8, error) + // evalContext holds state needed during expression evaluation. type evalContext struct { callCtx *builtins.CallContext ctx context.Context now time.Time - relPath string // path relative to starting point - info iofs.FileInfo // file info (lstat or stat depending on -L) - depth int // current depth - printPath string // path to print (includes starting point prefix) - newerCache map[string]time.Time // cached -newer reference file modtimes - newerErrors map[string]bool // tracks which -newer reference files failed to stat - followLinks bool // true when -L is active - failed bool // set by predicates that encounter errors + relPath string // path relative to starting point + info iofs.FileInfo // file info (lstat or stat depending on -L) + depth int // current depth + printPath string // path to print (includes starting point prefix) + newerCache map[string]time.Time // cached -newer reference file modtimes + newerErrors map[string]bool // tracks which -newer reference files failed to stat + followLinks bool // true when -L is active + failed bool // set by predicates that encounter errors + execCommand execCommandFunc // callback for -exec/-execdir + batchAccum map[*expr][]batchEntry // accumulated paths for batch exec (+) } // evaluate evaluates an expression tree against a file. If e is nil, returns @@ -105,6 +113,12 @@ func evaluate(ec *evalContext, e *expr) evalResult { case exprPrune: return evalResult{matched: true, prune: true} + case exprExec: + return evalExec(ec, e, false) + + case exprExecDir: + return evalExec(ec, e, true) + case exprTrue: return evalResult{matched: true} @@ -248,3 +262,85 @@ func evalMmin(ec *evalContext, n int64, cmp cmpOp) bool { return mins == n } } + +// evalExec evaluates a -exec or -execdir predicate. +// For `;` mode: executes the command immediately, returns matched=true if exit 0. +// For `+` mode: accumulates the path for later batch execution, returns matched=true. +func evalExec(ec *evalContext, e *expr, isExecDir bool) evalResult { + if ec.execCommand == nil { + ec.callCtx.Errf("find: -exec/-execdir: command execution not available\n") + ec.failed = true + return evalResult{matched: false} + } + + var filePath string + var dir string + if isExecDir { + clean := path.Clean(ec.printPath) + if clean == "." { + // Start directory itself: GNU find outputs "." not "./.". + filePath = "." + } else { + dir = path.Dir(clean) + if dir == "." { + dir = "" + } + filePath = "./" + path.Base(clean) + } + } else { + filePath = ec.printPath + } + + // Batch mode: accumulate path for later execution. + // Paths are collected without limit here; executeBatch chunks them into + // groups of maxExecArgs to match GNU find behaviour (process all matches + // in multiple invocations rather than silently dropping entries). + if e.execBatch { + if ec.batchAccum != nil { + entries := ec.batchAccum[e] + // Pre-allocate on first entry to reduce slice growth overhead. + if entries == nil { + const initialBatchCap = 64 + entries = make([]batchEntry, 0, initialBatchCap) + } + ec.batchAccum[e] = append(entries, batchEntry{filePath: filePath, dir: dir}) + } + return evalResult{matched: true} + } + + // Single mode (;): execute immediately. + // GNU find treats per-file -exec failures as a false predicate result + // (continuing traversal) rather than a global fatal error. Only batch + // mode (+) propagates errors to the global exit code. + args := buildExecArgs(e.execArgs, filePath) + code, err := ec.execCommand(ec.ctx, args, dir, ec.callCtx.Stdout, ec.callCtx.Stderr) + if err != nil { + ec.callCtx.Errf("find: %s: %v\n", args[0], err) + return evalResult{matched: false} + } + return evalResult{matched: code == 0} +} + +// buildExecArgs replaces {} with filePath in exec arguments. +func buildExecArgs(template []string, filePath string) []string { + args := make([]string, len(template)) + for i, arg := range template { + args[i] = strings.ReplaceAll(arg, "{}", filePath) + } + return args +} + +// collectExecExprs finds all -exec/-execdir batch mode expressions in the tree. +func collectExecExprs(e *expr) []*expr { + if e == nil { + return nil + } + var result []*expr + if (e.kind == exprExec || e.kind == exprExecDir) && e.execBatch { + result = append(result, e) + } + result = append(result, collectExecExprs(e.left)...) + result = append(result, collectExecExprs(e.right)...) + result = append(result, collectExecExprs(e.operand)...) + return result +} diff --git a/builtins/find/expr.go b/builtins/find/expr.go index 7e25394c..4e94bda5 100644 --- a/builtins/find/expr.go +++ b/builtins/find/expr.go @@ -24,24 +24,26 @@ const ( type exprKind int const ( - exprName exprKind = iota // -name pattern - exprIName // -iname pattern - exprPath // -path pattern - exprIPath // -ipath pattern - exprType // -type c - exprSize // -size n[cwbkMG] - exprEmpty // -empty - exprNewer // -newer file - exprMtime // -mtime n - exprMmin // -mmin n - exprPrint // -print - exprPrint0 // -print0 - exprPrune // -prune - exprTrue // -true - exprFalse // -false - exprAnd // expr -a expr or expr expr (implicit) - exprOr // expr -o expr - exprNot // ! expr or -not expr + exprName exprKind = iota // -name pattern + exprIName // -iname pattern + exprPath // -path pattern + exprIPath // -ipath pattern + exprType // -type c + exprSize // -size n[cwbkMG] + exprEmpty // -empty + exprNewer // -newer file + exprMtime // -mtime n + exprMmin // -mmin n + exprPrint // -print + exprPrint0 // -print0 + exprPrune // -prune + exprExec // -exec command {} ; + exprExecDir // -execdir command {} ; + exprTrue // -true + exprFalse // -false + exprAnd // expr -a expr or expr expr (implicit) + exprOr // expr -o expr + exprNot // ! expr or -not expr ) // cmpOp represents a comparison operator for numeric predicates. @@ -73,21 +75,28 @@ type sizeUnit struct { unit byte // one of: c w b k M G (default 'b' if omitted) } +// maxExecArgs limits the number of arguments that can be accumulated in +// -exec/-execdir batch mode (+) to prevent memory exhaustion. +const maxExecArgs = 10000 + // expr is a node in the find expression AST. type expr struct { - kind exprKind - strVal string // pattern for name/iname/path/ipath, type char, file path for newer - sizeVal sizeUnit // for -size - numVal int64 // for -mtime, -mmin - numCmp cmpOp // comparison operator for numeric predicates - left *expr // for and/or - right *expr // for and/or - operand *expr // for not + kind exprKind + strVal string // pattern for name/iname/path/ipath, type char, file path for newer + sizeVal sizeUnit // for -size + numVal int64 // for -mtime, -mmin + numCmp cmpOp // comparison operator for numeric predicates + left *expr // for and/or + right *expr // for and/or + operand *expr // for not + execArgs []string // for -exec/-execdir: command and arguments (with {} placeholder) + execBatch bool // for -exec/-execdir: true if terminated by + (batch mode) } // isAction returns true if this expression is an output action. func (e *expr) isAction() bool { - return e.kind == exprPrint || e.kind == exprPrint0 + return e.kind == exprPrint || e.kind == exprPrint0 || + e.kind == exprExec || e.kind == exprExecDir } // hasAction checks if any node in the expression tree is an action. @@ -120,8 +129,6 @@ type parseResult struct { // blocked predicates that are forbidden for sandbox safety. var blockedPredicates = map[string]string{ - "-exec": "arbitrary command execution is blocked", - "-execdir": "arbitrary command execution is blocked", "-delete": "file deletion is blocked", "-ok": "interactive execution is blocked", "-okdir": "interactive execution is blocked", @@ -311,6 +318,10 @@ func (p *parser) parsePrimary() (*expr, error) { return p.parseNumericPredicate(exprMtime) case "-mmin": return p.parseNumericPredicate(exprMmin) + case "-exec": + return p.parseExecPredicate(exprExec) + case "-execdir": + return p.parseExecPredicate(exprExecDir) case "-print": return &expr{kind: exprPrint}, nil case "-print0": @@ -459,6 +470,60 @@ func (p *parser) parseDepthOption(isMax bool) (*expr, error) { return &expr{kind: exprTrue}, nil } +// parseExecPredicate parses -exec/-execdir arguments. +// Syntax: -exec command [args...] ; +// +// -exec command [args...] {} + +// +// The `;` terminator must be a separate argument (the shell handles `\;`). +// The `+` terminator enables batch mode (multiple files per invocation); +// in batch mode `{}` must be the last argument before `+`. +// `{}` is optional in `;` mode — when absent, the command runs without +// the matched path in its arguments (matching GNU find behaviour). +func (p *parser) parseExecPredicate(kind exprKind) (*expr, error) { + name := "-exec" + if kind == exprExecDir { + name = "-execdir" + } + if p.pos >= len(p.args) { + return nil, fmt.Errorf("find: %s: missing command", name) + } + + // maxExecCmdArgs limits the number of fixed arguments in an -exec/-execdir + // command template to prevent pathological parser inputs. + const maxExecCmdArgs = 1024 + + var cmdArgs []string + placeholderCount := 0 + for p.pos < len(p.args) { + tok := p.args[p.pos] + p.pos++ + + if tok == ";" { + if len(cmdArgs) == 0 { + return nil, fmt.Errorf("find: %s: missing command", name) + } + return &expr{kind: kind, execArgs: cmdArgs, execBatch: false}, nil + } + if tok == "+" && placeholderCount > 0 && len(cmdArgs) > 0 && cmdArgs[len(cmdArgs)-1] == "{}" { + // Batch mode: {} must be the last arg before +. + // GNU find rejects multiple {} in batch mode. + if placeholderCount > 1 { + return nil, fmt.Errorf("find: %s: only one instance of '{}' is supported with -exec ... +", name) + } + return &expr{kind: kind, execArgs: cmdArgs, execBatch: true}, nil + } + if strings.Contains(tok, "{}") { + placeholderCount++ + } + cmdArgs = append(cmdArgs, tok) + if len(cmdArgs) > maxExecCmdArgs { + return nil, fmt.Errorf("find: %s: too many arguments (limit %d)", name, maxExecCmdArgs) + } + } + return nil, fmt.Errorf("find: missing terminator for %s (expected ';' or '+')", name) +} + // parseSize parses a -size argument like "+10k", "-5M", "100c". func parseSize(s string) (sizeUnit, error) { if len(s) == 0 { @@ -541,6 +606,10 @@ func (k exprKind) String() string { return "-or" case exprNot: return "-not" + case exprExec: + return "-exec" + case exprExecDir: + return "-execdir" default: return "unknown" } diff --git a/builtins/find/expr_test.go b/builtins/find/expr_test.go index 459ba5cc..91ef20a1 100644 --- a/builtins/find/expr_test.go +++ b/builtins/find/expr_test.go @@ -181,7 +181,7 @@ func TestParsePathPredicateUsesParsePathPredicate(t *testing.T) { // TestParseBlockedPredicates verifies all dangerous predicates are blocked. func TestParseBlockedPredicates(t *testing.T) { blocked := []string{ - "-exec", "-execdir", "-delete", "-ok", "-okdir", + "-delete", "-ok", "-okdir", "-fls", "-fprint", "-fprint0", "-fprintf", "-regex", "-iregex", } @@ -189,7 +189,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 == "-execdir" || pred == "-ok" || pred == "-okdir" { + if pred == "-ok" || pred == "-okdir" { args = append(args, "cmd", ";") } _, err := parseExpression(args) @@ -199,6 +199,45 @@ func TestParseBlockedPredicates(t *testing.T) { } } +// TestParseExec verifies -exec/-execdir parsing. +func TestParseExec(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + errContains string + wantBatch bool + }{ + {"exec single", []string{"-exec", "echo", "{}", ";"}, false, "", false}, + {"exec batch", []string{"-exec", "echo", "{}", "+"}, false, "", true}, + {"execdir single", []string{"-execdir", "echo", "{}", ";"}, false, "", false}, + {"execdir batch", []string{"-execdir", "echo", "{}", "+"}, false, "", true}, + {"exec missing command", []string{"-exec"}, true, "missing command", false}, + {"exec missing terminator", []string{"-exec", "echo", "{}"}, true, "missing terminator", false}, + {"exec without placeholder", []string{"-exec", "echo", ";"}, false, "", false}, + {"exec empty command", []string{"-exec", ";"}, true, "missing command", false}, + {"exec with extra args", []string{"-exec", "grep", "-l", "{}", ";"}, false, "", false}, + {"exec batch multiple placeholders", []string{"-exec", "echo", "{}", "x", "{}", "+"}, true, "only one instance", false}, + {"exec batch embedded placeholder rejected", []string{"-exec", "echo", "foo{}", "{}", "+"}, true, "only one instance", false}, + {"exec batch only embedded placeholder rejected", []string{"-exec", "echo", "foo{}", "+"}, true, "missing terminator", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pr, err := parseExpression(tt.args) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, tt.wantBatch, pr.expr.execBatch) + } + }) + } +} + // 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 38bfffb4..ff90ebe0 100644 --- a/builtins/find/find.go +++ b/builtins/find/find.go @@ -35,6 +35,10 @@ // -print — print path followed by newline // -print0 — print path followed by NUL // -prune — skip directory subtree +// -exec cmd {} ; — execute cmd for each matched file (builtins only) +// -exec cmd {} + — like -exec but batches files into fewer invocations +// -execdir cmd {} ; — like -exec but runs from the file's parent directory +// -execdir cmd {} + — batched version of -execdir // -true — always true // -false — always false // @@ -47,7 +51,7 @@ // // Blocked predicates (sandbox safety): // -// -exec, -execdir, -delete, -ok, -okdir — execution/deletion +// -delete, -ok, -okdir — deletion/interactive execution // -fls, -fprint, -fprint0, -fprintf — file writes // -regex, -iregex — ReDoS risk // @@ -147,7 +151,7 @@ optLoop: exprArgs := args[i:] pr, err := parseExpression(exprArgs) if err != nil { - callCtx.Errf("%s\n", err.Error()) + callCtx.Errf("%v\n", err) return builtins.Result{Code: 1} } expression := pr.expr @@ -211,6 +215,13 @@ optLoop: // consistent reference across all root paths (matches GNU find). now := callCtx.Now() + // Initialize batch accumulators for -exec/-execdir with + terminator. + batchExprs := collectExecExprs(expression) + var batchAccum map[*expr][]batchEntry + if len(batchExprs) > 0 { + batchAccum = make(map[*expr][]batchEntry, len(batchExprs)) + } + // GNU find treats a missing -newer reference as a fatal argument error // and produces no result set, so skip the walk entirely. if !failed { @@ -234,12 +245,34 @@ optLoop: minDepth: minDepth, now: now, eagerNewerErrors: eagerNewerErrors, + execCommand: callCtx.ExecCommand, + batchAccum: batchAccum, }) { failed = true } } } + // Execute accumulated batch commands (-exec ... {} + / -execdir ... {} +). + // Run batches if no errors occurred, or if entries were accumulated despite + // per-file errors — GNU find always runs pending batches regardless of + // individual file evaluation failures. + if !failed || len(batchAccum) > 0 { + for _, e := range batchExprs { + entries := batchAccum[e] + if len(entries) == 0 { + continue + } + if ctx.Err() != nil { + failed = true + break + } + if executeBatch(ctx, callCtx, e, entries) { + failed = true + } + } + } + if failed { return builtins.Result{Code: 1} } @@ -256,6 +289,12 @@ func isExpressionStart(arg string) bool { return strings.HasPrefix(arg, "-") && len(arg) > 1 } +// batchEntry holds a file path accumulated for -exec/-execdir batch mode. +type batchEntry struct { + filePath string // the path (printPath for -exec, ./basename for -execdir) + dir string // parent directory (used by -execdir, empty for -exec) +} + // walkOptions holds configuration for a single walkPath invocation. type walkOptions struct { expression *expr @@ -265,6 +304,8 @@ type walkOptions struct { minDepth int now time.Time eagerNewerErrors map[string]bool + execCommand execCommandFunc + batchAccum map[*expr][]batchEntry // accumulated paths for batch exec } // walkPath walks the directory tree rooted at startPath, evaluating the @@ -370,6 +411,8 @@ func walkPath( newerCache: newerCache, newerErrors: newerErrors, followLinks: opts.followLinks, + execCommand: opts.execCommand, + batchAccum: opts.batchAccum, } prune := false @@ -508,6 +551,87 @@ func collectNewerRefs(e *expr) []string { return refs } +// executeBatch runs a batch -exec/-execdir command with all accumulated paths. +// When a group exceeds maxExecArgs paths, it is chunked into multiple +// invocations of at most maxExecArgs paths each, matching GNU find behaviour. +// Returns true if any error occurred. +func executeBatch(ctx context.Context, callCtx *builtins.CallContext, e *expr, entries []batchEntry) bool { + if callCtx.ExecCommand == nil { + callCtx.Errf("find: -exec/-execdir: command execution not available\n") + return true + } + + // Group entries by directory for -execdir (each directory gets its own invocation). + // For -exec, all entries share the same (empty) dir. + type group struct { + dir string + paths []string + } + var groups []group + if e.kind == exprExecDir { + // Group by directory. + dirMap := make(map[string]int) + for _, entry := range entries { + idx, ok := dirMap[entry.dir] + if !ok { + idx = len(groups) + dirMap[entry.dir] = idx + groups = append(groups, group{dir: entry.dir}) + } + groups[idx].paths = append(groups[idx].paths, entry.filePath) + } + } else { + // All in one group. + paths := make([]string, len(entries)) + for i, entry := range entries { + paths[i] = entry.filePath + } + groups = append(groups, group{paths: paths}) + } + + failed := false + for _, g := range groups { + if ctx.Err() != nil { + return true + } + // Build args: command [fixed-args] file1 file2 ... + // In batch mode, only standalone {} is expanded (replaced with accumulated + // paths). This differs from `;` mode where {} is replaced even inside + // larger strings via strings.ReplaceAll — matching GNU find behaviour + // where batch mode only expands the terminal {} placeholder. + + // Chunk the paths into batches of maxExecArgs to avoid excessively + // long argument lists while still processing all matched files. + for start := 0; start < len(g.paths); start += maxExecArgs { + if ctx.Err() != nil { + return true + } + end := start + maxExecArgs + if end > len(g.paths) { + end = len(g.paths) + } + chunk := g.paths[start:end] + + var args []string + for _, arg := range e.execArgs { + if arg == "{}" { + args = append(args, chunk...) + } else { + args = append(args, arg) + } + } + code, err := callCtx.ExecCommand(ctx, args, g.dir, callCtx.Stdout, callCtx.Stderr) + if err != nil { + callCtx.Errf("find: %s: %v\n", args[0], err) + failed = true + } else if code != 0 { + failed = true + } + } + } + return failed +} + // joinPath joins a directory and a name with a forward slash. // The shell normalises all paths to forward slashes on all platforms, // so hardcoding '/' is correct even on Windows. diff --git a/interp/runner_exec.go b/interp/runner_exec.go index b589c86e..cda22848 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -307,6 +307,81 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } return builtins.FileID{Dev: dev, Ino: ino}, true }, + ExecCommand: func(ctx context.Context, cmdArgs []string, dir string, stdout, stderr io.Writer) (uint8, error) { + if len(cmdArgs) == 0 { + return 1, fmt.Errorf("exec: empty command") + } + cmdName := cmdArgs[0] + // Enforce the same command allowlist as the shell's call() path. + if !r.allowAllCommands && !r.allowedCommands[cmdName] { + return 127, fmt.Errorf("exec: command not allowed: %s", cmdName) + } + handler, ok := builtins.Lookup(cmdName) + if !ok { + return 127, fmt.Errorf("exec: command not found: %s", cmdName) + } + // Resolve the effective working directory once up front rather + // than recomputing filepath.Join in every sandbox callback. + resolvedDir := r.Dir + if dir != "" { + if filepath.IsAbs(dir) { + resolvedDir = dir + } else { + resolvedDir = filepath.Join(r.Dir, dir) + } + } + // NOTE: subcall intentionally does not set ExecCommand. This prevents + // nested find -exec from spawning further -exec subprocesses, avoiding + // unbounded recursion (e.g. find . -exec find {} -exec echo {} \; \;). + subcall := &builtins.CallContext{ + Stdout: stdout, + Stderr: stderr, + Stdin: r.stdin, + OpenFile: func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) { + f, err := r.sandbox.Open(path, resolvedDir, flags, mode) + if err != nil { + return nil, allowedpaths.PortablePathError(err) + } + return f, nil + }, + ReadDir: func(ctx context.Context, path string) ([]fs.DirEntry, error) { + return r.sandbox.ReadDir(path, resolvedDir) + }, + OpenDir: func(ctx context.Context, path string) (fs.ReadDirFile, error) { + return r.sandbox.OpenDir(path, resolvedDir) + }, + IsDirEmpty: func(ctx context.Context, path string) (bool, error) { + return r.sandbox.IsDirEmpty(path, resolvedDir) + }, + ReadDirLimited: func(ctx context.Context, path string, offset, maxRead int) ([]fs.DirEntry, bool, error) { + return r.sandbox.ReadDirLimited(path, resolvedDir, offset, maxRead) + }, + StatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { + return r.sandbox.Stat(path, resolvedDir) + }, + LstatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { + return r.sandbox.Lstat(path, resolvedDir) + }, + AccessFile: func(ctx context.Context, path string, mode uint32) error { + return r.sandbox.Access(path, resolvedDir, mode) + }, + PortableErr: allowedpaths.PortableErrMsg, + Now: time.Now, + FileIdentity: func(path string, info fs.FileInfo) (builtins.FileID, bool) { + absPath := path + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(resolvedDir, absPath) + } + dev, ino, ok := allowedpaths.FileIdentity(absPath, info, r.sandbox) + if !ok { + return builtins.FileID{}, false + } + return builtins.FileID{Dev: dev, Ino: ino}, true + }, + } + res := handler(ctx, subcall, cmdArgs[1:]) + return res.Code, nil + }, } if r.stdin != nil { // do not assign a typed nil into the io.Reader interface call.Stdin = r.stdin diff --git a/tests/scenarios/cmd/find/exec/exec_as_filter.yaml b/tests/scenarios/cmd/find/exec/exec_as_filter.yaml new file mode 100644 index 00000000..fca46471 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_as_filter.yaml @@ -0,0 +1,15 @@ +# Test -exec as a boolean filter (false suppresses, true passes) +description: find -exec acts as boolean filter where command exit code determines match. +setup: + files: + - path: a.txt + content: "hello" + - path: b.txt + content: "world" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -exec grep -q hello {} \; -print | sort +expect: + stdout: "./a.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_basic.yaml b/tests/scenarios/cmd/find/exec/exec_basic.yaml new file mode 100644 index 00000000..8732cd94 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_basic.yaml @@ -0,0 +1,15 @@ +# Test basic -exec with echo +description: find -exec runs a command for each matched file. +setup: + files: + - path: a.txt + content: "alpha" + - path: b.txt + content: "beta" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -exec echo {} \; | sort +expect: + stdout: "./a.txt\n./b.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_batch.yaml b/tests/scenarios/cmd/find/exec/exec_batch.yaml new file mode 100644 index 00000000..e094e8fc --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_batch.yaml @@ -0,0 +1,15 @@ +# Test -exec with batch mode (+) +description: find -exec with + batches files into a single command invocation. +setup: + files: + - path: a.txt + content: "alpha" + - path: b.txt + content: "beta" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -exec echo {} + | tr ' ' '\n' | sort +expect: + stdout: "./a.txt\n./b.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_batch_embedded_placeholder.yaml b/tests/scenarios/cmd/find/exec/exec_batch_embedded_placeholder.yaml new file mode 100644 index 00000000..a5dd1bd8 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_batch_embedded_placeholder.yaml @@ -0,0 +1,14 @@ +# Test that embedded {} in batch mode is rejected +description: find -exec with embedded {} in batch mode reports an error. +skip_assert_against_bash: true # error message format differs +setup: + files: + - path: a.txt + content: "alpha" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -exec echo foo{} {} + +expect: + stderr_contains: ["only one instance"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/exec/exec_batch_many_files.yaml b/tests/scenarios/cmd/find/exec/exec_batch_many_files.yaml new file mode 100644 index 00000000..5b1955f2 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_batch_many_files.yaml @@ -0,0 +1,21 @@ +# Test batch mode with many files +description: find -exec with + batches many files into a single invocation. +setup: + files: + - path: a.txt + content: "a" + - path: b.txt + content: "b" + - path: c.txt + content: "c" + - path: d.txt + content: "d" + - path: e.txt + content: "e" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -exec echo {} + | tr ' ' '\n' | sort +expect: + stdout: "./a.txt\n./b.txt\n./c.txt\n./d.txt\n./e.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_cat.yaml b/tests/scenarios/cmd/find/exec/exec_cat.yaml new file mode 100644 index 00000000..bc411fbd --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_cat.yaml @@ -0,0 +1,13 @@ +# Test -exec with cat to read file contents +description: find -exec cat reads matched file contents. +setup: + files: + - path: hello.txt + content: "hello world\n" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "hello.txt" -exec cat {} \; +expect: + stdout: "hello world\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_exit_code.yaml b/tests/scenarios/cmd/find/exec/exec_exit_code.yaml new file mode 100644 index 00000000..acfe8306 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_exit_code.yaml @@ -0,0 +1,14 @@ +# Test -exec returns false when command fails, suppressing implicit print +description: find -exec returns false when the executed command exits non-zero. +setup: + files: + - path: a.txt + content: "alpha" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "a.txt" -exec false \; + echo "exit: $?" +expect: + stdout: "exit: 0\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_grep.yaml b/tests/scenarios/cmd/find/exec/exec_grep.yaml new file mode 100644 index 00000000..871b866e --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_grep.yaml @@ -0,0 +1,15 @@ +# Test -exec with grep to search file contents +description: find -exec grep searches within matched files. +setup: + files: + - path: a.txt + content: "hello world\n" + - path: b.txt + content: "goodbye world\n" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -exec grep -l hello {} \; | sort +expect: + stdout: "./a.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_missing_terminator.yaml b/tests/scenarios/cmd/find/exec/exec_missing_terminator.yaml new file mode 100644 index 00000000..ac9809de --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_missing_terminator.yaml @@ -0,0 +1,14 @@ +# Test -exec without terminator +description: find -exec without ; or + reports parse error. +skip_assert_against_bash: true # different error messages +setup: + files: + - path: a.txt + content: "alpha" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -exec echo {} +expect: + stderr_contains: ["missing terminator"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/exec/exec_multiple.yaml b/tests/scenarios/cmd/find/exec/exec_multiple.yaml new file mode 100644 index 00000000..f58dc008 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_multiple.yaml @@ -0,0 +1,13 @@ +# Test chaining multiple -exec predicates +description: find with multiple -exec predicates runs each command for matched files. +setup: + files: + - path: a.txt + content: "alpha" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "a.txt" -exec echo first {} \; -exec echo second {} \; +expect: + stdout: "first ./a.txt\nsecond ./a.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_multiple_matches.yaml b/tests/scenarios/cmd/find/exec/exec_multiple_matches.yaml new file mode 100644 index 00000000..297d7e66 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_multiple_matches.yaml @@ -0,0 +1,17 @@ +# Test -exec with multiple matching files +description: find -exec runs the command once per matched file with correct paths. +setup: + files: + - path: dir1/a.txt + content: "alpha" + - path: dir2/b.txt + content: "beta" + - path: dir2/c.txt + content: "gamma" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -exec echo {} \; | sort +expect: + stdout: "./dir1/a.txt\n./dir2/b.txt\n./dir2/c.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_multiple_placeholders.yaml b/tests/scenarios/cmd/find/exec/exec_multiple_placeholders.yaml new file mode 100644 index 00000000..70699468 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_multiple_placeholders.yaml @@ -0,0 +1,13 @@ +# Test multiple {} placeholders in ; mode +description: find -exec replaces all {} occurrences in ; mode. +setup: + files: + - path: a.txt + content: "alpha" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "a.txt" -exec echo {} and {} \; +expect: + stdout: "./a.txt and ./a.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_nested_find.yaml b/tests/scenarios/cmd/find/exec/exec_nested_find.yaml new file mode 100644 index 00000000..967fee3c --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_nested_find.yaml @@ -0,0 +1,15 @@ +# Nested find -exec find without inner -exec works, but inner -exec is unavailable. +# The subcall intentionally omits ExecCommand to prevent unbounded recursion. +description: nested find -exec find works but inner -exec is not available +skip_assert_against_bash: true +setup: + files: + - path: dir/file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -exec find {} -print \; +expect: + exit_code: 0 + stdout: "./dir/file.txt\n" diff --git a/tests/scenarios/cmd/find/exec/exec_no_match.yaml b/tests/scenarios/cmd/find/exec/exec_no_match.yaml new file mode 100644 index 00000000..34fbe309 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_no_match.yaml @@ -0,0 +1,13 @@ +# Test -exec with no matching files +description: find -exec with no matches produces no output. +setup: + files: + - path: a.txt + content: "alpha" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.xyz" -exec echo {} \; +expect: + stdout: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_no_placeholder.yaml b/tests/scenarios/cmd/find/exec/exec_no_placeholder.yaml new file mode 100644 index 00000000..86724c5d --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_no_placeholder.yaml @@ -0,0 +1,15 @@ +# Test -exec without {} placeholder +description: find -exec without {} runs the command once per match without substitution. +setup: + files: + - path: a.txt + content: "alpha" + - path: b.txt + content: "beta" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -exec echo hello \; | sort +expect: + stdout: "hello\nhello\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_parentheses.yaml b/tests/scenarios/cmd/find/exec/exec_parentheses.yaml new file mode 100644 index 00000000..7890228f --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_parentheses.yaml @@ -0,0 +1,17 @@ +# Test -exec within parentheses with OR logic +description: find -exec within parenthesized groups with -o works correctly. +setup: + files: + - path: a.txt + content: "alpha" + - path: b.log + content: "beta" + - path: c.dat + content: "gamma" +input: + allowed_paths: ["$DIR"] + script: |+ + find . \( -name "*.txt" -exec echo txt {} \; \) -o \( -name "*.log" -exec echo log {} \; \) | sort +expect: + stdout: "log ./b.log\ntxt ./a.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_special_chars.yaml b/tests/scenarios/cmd/find/exec/exec_special_chars.yaml new file mode 100644 index 00000000..fb75e463 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_special_chars.yaml @@ -0,0 +1,13 @@ +# Test -exec with filenames containing spaces +description: find -exec handles filenames with spaces correctly. +setup: + files: + - path: "hello world.txt" + content: "greeting" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "hello world.txt" -exec echo found {} \; +expect: + stdout: "found ./hello world.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_type_filter.yaml b/tests/scenarios/cmd/find/exec/exec_type_filter.yaml new file mode 100644 index 00000000..b727c61e --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_type_filter.yaml @@ -0,0 +1,13 @@ +# Test -exec with -type f to only match files +description: find -type f -exec only processes files, not directories. +setup: + files: + - path: sub/a.txt + content: "alpha" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -type f -exec echo {} \; +expect: + stdout: "./sub/a.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_unknown_command.yaml b/tests/scenarios/cmd/find/exec/exec_unknown_command.yaml new file mode 100644 index 00000000..7c54f828 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_unknown_command.yaml @@ -0,0 +1,14 @@ +# Test -exec with unknown command +description: find -exec with unknown command reports error. +skip_assert_against_bash: true # rshell has different error messages +setup: + files: + - path: a.txt + content: "alpha" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "a.txt" -exec nonexistent {} \; +expect: + stderr_contains: ["command not found"] + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_with_not.yaml b/tests/scenarios/cmd/find/exec/exec_with_not.yaml new file mode 100644 index 00000000..82587124 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_with_not.yaml @@ -0,0 +1,15 @@ +# Test -exec combined with -not +description: find -not with -exec only executes for non-matching files. +setup: + files: + - path: a.txt + content: "alpha" + - path: b.log + content: "beta" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -maxdepth 1 -type f -not -name "*.txt" -exec echo {} \; +expect: + stdout: "./b.log\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_with_or.yaml b/tests/scenarios/cmd/find/exec/exec_with_or.yaml new file mode 100644 index 00000000..f2bc3154 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_with_or.yaml @@ -0,0 +1,15 @@ +# Test -exec combined with -o (OR operator) +description: find -exec with -o evaluates correctly with short-circuit logic. +setup: + files: + - path: a.txt + content: "alpha" + - path: b.log + content: "beta" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -exec echo txt {} \; -o -name "*.log" -exec echo log {} \; | sort +expect: + stdout: "log ./b.log\ntxt ./a.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_with_other_predicates.yaml b/tests/scenarios/cmd/find/exec/exec_with_other_predicates.yaml new file mode 100644 index 00000000..61e97850 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_with_other_predicates.yaml @@ -0,0 +1,17 @@ +# Test -exec combined with other predicates +description: find -exec works when combined with -name and -type predicates. +setup: + files: + - path: dir1/a.txt + content: "alpha" + - path: dir1/b.log + content: "beta" + - path: dir2/c.txt + content: "gamma" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -type f -name "*.txt" -exec echo found {} \; | sort +expect: + stdout: "found ./dir1/a.txt\nfound ./dir2/c.txt\n" + 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..b85c110b --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_with_print.yaml @@ -0,0 +1,13 @@ +# Test -exec combined with -print +description: find -exec followed by -print outputs both exec output and path. +setup: + files: + - path: a.txt + content: "alpha" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "a.txt" -exec echo found {} \; -print +expect: + stdout: "found ./a.txt\n./a.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/exec_with_prune.yaml b/tests/scenarios/cmd/find/exec/exec_with_prune.yaml new file mode 100644 index 00000000..cab646f2 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/exec_with_prune.yaml @@ -0,0 +1,15 @@ +# -exec combined with -prune skips subdirectories +description: find -exec combined with -prune to skip directories +setup: + files: + - path: skip/sub/a.txt + content: "hidden" + - path: keep/b.txt + content: "visible" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name skip -prune -o -name "*.txt" -exec echo {} \; | sort +expect: + exit_code: 0 + stdout: "./keep/b.txt\n" diff --git a/tests/scenarios/cmd/find/exec/execdir_basename_format.yaml b/tests/scenarios/cmd/find/exec/execdir_basename_format.yaml new file mode 100644 index 00000000..4221a627 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/execdir_basename_format.yaml @@ -0,0 +1,13 @@ +# Test -execdir uses ./basename format not full path +description: find -execdir passes ./basename to the command, not the full path. +setup: + files: + - path: sub/deep/file.txt + content: "data" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "file.txt" -execdir echo {} \; +expect: + stdout: "./file.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/execdir_basic.yaml b/tests/scenarios/cmd/find/exec/execdir_basic.yaml new file mode 100644 index 00000000..2bd757af --- /dev/null +++ b/tests/scenarios/cmd/find/exec/execdir_basic.yaml @@ -0,0 +1,13 @@ +# Test basic -execdir +description: find -execdir runs command from the file's parent directory with ./basename. +setup: + files: + - path: sub/file.txt + content: "content" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "file.txt" -execdir echo {} \; +expect: + stdout: "./file.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/execdir_batch.yaml b/tests/scenarios/cmd/find/exec/execdir_batch.yaml new file mode 100644 index 00000000..0a8f24a0 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/execdir_batch.yaml @@ -0,0 +1,15 @@ +# Test -execdir with batch mode (+) +description: find -execdir with + batches files per directory. +setup: + files: + - path: sub/a.txt + content: "alpha" + - path: sub/b.txt + content: "beta" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -execdir echo {} + | tr ' ' '\n' | sort +expect: + stdout: "./a.txt\n./b.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/execdir_batch_multidir.yaml b/tests/scenarios/cmd/find/exec/execdir_batch_multidir.yaml new file mode 100644 index 00000000..49e4226d --- /dev/null +++ b/tests/scenarios/cmd/find/exec/execdir_batch_multidir.yaml @@ -0,0 +1,17 @@ +# Test -execdir batch mode with files in multiple directories +description: find -execdir with + groups files by parent directory. +setup: + files: + - path: dir1/a.txt + content: "a" + - path: dir2/b.txt + content: "b" + - path: dir3/c.txt + content: "c" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "*.txt" -execdir echo {} + | sort +expect: + stdout: "./a.txt\n./b.txt\n./c.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/execdir_cat.yaml b/tests/scenarios/cmd/find/exec/execdir_cat.yaml new file mode 100644 index 00000000..52372741 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/execdir_cat.yaml @@ -0,0 +1,13 @@ +# Test -execdir with file I/O command (cat) +description: find -execdir cat reads file from the file's parent directory. +setup: + files: + - path: sub/hello.txt + content: "hello world\n" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "hello.txt" -execdir cat {} \; +expect: + stdout: "hello world\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/execdir_current_dir.yaml b/tests/scenarios/cmd/find/exec/execdir_current_dir.yaml new file mode 100644 index 00000000..43544fa2 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/execdir_current_dir.yaml @@ -0,0 +1,15 @@ +# Test -execdir with files only in current directory +description: find -execdir works correctly when files are in the start directory. +setup: + files: + - path: a.txt + content: "alpha" + - path: b.txt + content: "beta" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -maxdepth 1 -name "*.txt" -execdir echo {} \; | sort +expect: + stdout: "./a.txt\n./b.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/execdir_nested.yaml b/tests/scenarios/cmd/find/exec/execdir_nested.yaml new file mode 100644 index 00000000..b11b0440 --- /dev/null +++ b/tests/scenarios/cmd/find/exec/execdir_nested.yaml @@ -0,0 +1,13 @@ +# Test -execdir with deeply nested paths +description: find -execdir passes ./basename even for deeply nested files. +setup: + files: + - path: a/b/c/deep.txt + content: "deep" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name "deep.txt" -execdir echo {} \; +expect: + stdout: "./deep.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/exec/execdir_start_dir.yaml b/tests/scenarios/cmd/find/exec/execdir_start_dir.yaml new file mode 100644 index 00000000..ffca042d --- /dev/null +++ b/tests/scenarios/cmd/find/exec/execdir_start_dir.yaml @@ -0,0 +1,13 @@ +# -execdir on start directory itself produces "." not "./." +description: find -execdir on start directory outputs correct path +setup: + files: + - path: dummy.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + find . -maxdepth 0 -execdir echo {} \; +expect: + exit_code: 0 + stdout: ".\n" diff --git a/tests/scenarios/cmd/find/exec/execdir_trailing_slash.yaml b/tests/scenarios/cmd/find/exec/execdir_trailing_slash.yaml new file mode 100644 index 00000000..df79d82f --- /dev/null +++ b/tests/scenarios/cmd/find/exec/execdir_trailing_slash.yaml @@ -0,0 +1,13 @@ +# Test -execdir with trailing slash in start path +description: find sub/ -execdir correctly computes cwd when start path has trailing slash. +setup: + files: + - path: sub/hello.txt + content: "hello world\n" +input: + allowed_paths: ["$DIR"] + script: |+ + find sub/ -name "hello.txt" -execdir echo {} \; +expect: + stdout: "./hello.txt\n" + 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..a2d9d9dc 100644 --- a/tests/scenarios/cmd/find/sandbox/blocked_exec.yaml +++ b/tests/scenarios/cmd/find/sandbox/blocked_exec.yaml @@ -1,14 +1,13 @@ -description: find -exec is blocked for sandbox safety. -skip_assert_against_bash: true # intentional: bash allows -exec; rshell blocks it +description: find -exec executes commands for matched files. setup: files: - - path: dummy.txt - content: "x" + - path: hello.txt + content: "hello world" chmod: 0644 input: allowed_paths: ["$DIR"] script: |+ - find . -exec echo {} \; + find . -name "hello.txt" -exec echo found {} \; expect: - stderr_contains: ["blocked"] - exit_code: 1 + stdout: "found ./hello.txt\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_execdir.yaml b/tests/scenarios/cmd/find/sandbox/blocked_execdir.yaml index e3ea2fdc..4a111d3c 100644 --- a/tests/scenarios/cmd/find/sandbox/blocked_execdir.yaml +++ b/tests/scenarios/cmd/find/sandbox/blocked_execdir.yaml @@ -1,5 +1,4 @@ -description: find -execdir is blocked for sandbox safety. -skip_assert_against_bash: true # intentional: bash allows -execdir; rshell blocks it +description: find -execdir executes commands from the file's parent directory. setup: files: - path: dummy.txt @@ -8,7 +7,7 @@ setup: input: allowed_paths: ["$DIR"] script: |+ - find . -execdir echo {} \; + find . -name "dummy.txt" -execdir echo found {} \; expect: - stderr_contains: ["blocked"] - exit_code: 1 + stdout: "found ./dummy.txt\n" + exit_code: 0