Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
100 commits
Select commit Hold shift + click to select a range
ffd5112
Implement find builtin command
matt-dz Mar 10, 2026
fdfcda6
Address code review findings for find builtin
matt-dz Mar 10, 2026
45331b5
Fix misplaced skip_assert_against_bash in nonexistent.yaml
matt-dz Mar 10, 2026
b3d04ee
Document ReadDir sorted-order design choice and its bash divergence
matt-dz Mar 10, 2026
6188545
Address remaining PR review comments
matt-dz Mar 11, 2026
10e4148
Use fmt.Errorf directly instead of errors.New(fmt.Sprintf())
matt-dz Mar 11, 2026
682a62b
Add 52 comprehensive test scenarios for find builtin
matt-dz Mar 11, 2026
2691ffb
Fix -newer cache bug and address PR review comments
matt-dz Mar 11, 2026
7335e90
Integrate -maxdepth/-mindepth into the expression parser
matt-dz Mar 11, 2026
27b3d5e
Detect symlink loops by file identity (dev+inode) instead of path str…
matt-dz Mar 11, 2026
139d228
Address PR #36 review comments (round 2)
matt-dz Mar 11, 2026
27c52f1
Merge branch 'main' of github.com:DataDog/rshell into matt-dz/impleme…
matt-dz Mar 11, 2026
1698078
Address PR #36 review comments (round 3)
matt-dz Mar 11, 2026
e823302
Address PR #36 review comments (round 4)
matt-dz Mar 11, 2026
c95fb6c
Address PR #36 review comments (round 5)
matt-dz Mar 11, 2026
cd0786f
Address PR #36 review comments (round 6)
matt-dz Mar 11, 2026
4fc005f
Address PR #36 review comments (round 7)
matt-dz Mar 11, 2026
5d1151a
Address PR #36 review comments (round 8)
matt-dz Mar 11, 2026
4afc9fb
Address PR #36 review comments (round 9)
matt-dz Mar 11, 2026
b187f82
Address PR #36 review comments (round 10)
matt-dz Mar 11, 2026
f87d171
Address PR #36 review comments (round 11)
matt-dz Mar 11, 2026
793f6da
Address PR #36 review comments (round 12)
matt-dz Mar 11, 2026
a081c95
Address PR #36 review comments (round 13)
matt-dz Mar 11, 2026
92d809d
Add comprehensive unit tests for find builtin regression prevention
matt-dz Mar 11, 2026
289e635
Address PR #36 review comments (round 14)
matt-dz Mar 12, 2026
c75c0d9
Address PR #36 review comments (round 14)
matt-dz Mar 12, 2026
935ffd1
Address PR #36 review comments (round 15)
matt-dz Mar 12, 2026
ccea45b
Address PR #36 review comments (round 16)
matt-dz Mar 12, 2026
89730d0
Add regression tests for round 16 fixes
matt-dz Mar 12, 2026
ee3c48a
Merge branch 'main' into matt-dz/implement-find-builtin
matt-dz Mar 12, 2026
00825cc
Fix loop_detection_with_L test panic on Windows
matt-dz Mar 12, 2026
96351e6
Address PR #36 review comments (round 18)
matt-dz Mar 12, 2026
b6e4e11
Address PR #36 review comments (round 19)
matt-dz Mar 12, 2026
7386574
Fix gofmt and Windows ls sandbox test
matt-dz Mar 12, 2026
e9e8085
Address Effective Go review findings on find builtin
matt-dz Mar 12, 2026
fcbda04
Add time.Duration and time.Minute to builtin allowed symbols
matt-dz Mar 12, 2026
2e3d805
Address Effective Go review findings (round 2)
matt-dz Mar 12, 2026
a939740
Fix TestAllowedPathsExecViaPathLookup bypassing sandbox
matt-dz Mar 12, 2026
43b8b56
Merge branch 'main' of github.com:DataDog/rshell into matt-dz/impleme…
matt-dz Mar 12, 2026
c1a2380
Revert "Fix TestAllowedPathsExecViaPathLookup bypassing sandbox"
matt-dz Mar 12, 2026
3d540c5
Fix TestAllowedPathsExecViaPathLookup: sed is now a builtin
matt-dz Mar 12, 2026
d6da039
Address PR review comments
matt-dz Mar 12, 2026
e2e511e
Address PR review comments (round 3)
matt-dz Mar 13, 2026
b29a889
Add -mmin overflow tests and fix parser for int64-exceeding values
matt-dz Mar 13, 2026
3665f7c
Add scenario tests for ')' treated as path operand
matt-dz Mar 13, 2026
cdb1ad8
Add isExpressionStart unit test and path/expression boundary scenarios
matt-dz Mar 13, 2026
b961c4f
Add missing test coverage for find builtin
matt-dz Mar 13, 2026
00e872e
Merge branch 'main' into matt-dz/implement-find-builtin
matt-dz Mar 13, 2026
fbe0aaf
format files
matt-dz Mar 13, 2026
1ecb071
Merge branch 'matt-dz/implement-find-builtin' of github.com:DataDog/r…
matt-dz Mar 13, 2026
894fcd4
Fix bash comparison failures due to find output ordering
matt-dz Mar 13, 2026
6bc9c94
Use unsorted ReadDir in find to match GNU find ordering
matt-dz Mar 13, 2026
e21613f
Merge branch 'main' into matt-dz/implement-find-builtin
matt-dz Mar 13, 2026
1fd9265
Fix CI failures from unsorted find output on Linux
matt-dz Mar 13, 2026
5859439
Streaming DFS walker and short-circuit evalEmpty for find
matt-dz Mar 13, 2026
9203b69
Merge branch 'main' into matt-dz/implement-find-builtin
matt-dz Mar 13, 2026
49d858a
Fix CI: add os.File to builtin allowlist, remove stale RuneCount entry
matt-dz Mar 13, 2026
11bf6c0
fix: address find review comments (empty paths, empty -newer, malform…
matt-dz Mar 13, 2026
7f957a1
fix: treat empty path operands as per-root errors, not fatal parse
matt-dz Mar 13, 2026
af78619
test: add scenario tests for -mtime -0/0/+0 edge cases
matt-dz Mar 13, 2026
ff78c00
fix: make -type f and -type d scenarios order-independent
matt-dz Mar 13, 2026
f96ebbf
fix: stabilize -newer scenario with explicit mod_time values
matt-dz Mar 13, 2026
ba66935
revert: drop unrelated ls sandbox test change from find PR
matt-dz Mar 13, 2026
7375410
fix: align -mtime +N/-N with GNU find's raw-second comparison
matt-dz Mar 13, 2026
257315f
fix CI: add time.Hour and time.Second to builtin allowlist
matt-dz Mar 13, 2026
90dd433
refactor: replace *os.File with fs.ReadDirFile in OpenDir
matt-dz Mar 13, 2026
a9fbd9a
fix: propagate context cancellation + fix future-dated file mtime mat…
matt-dz Mar 13, 2026
2396dab
fix: stabilize Windows CI tests
matt-dz Mar 13, 2026
f262c4d
Merge remote-tracking branch 'origin/main' into matt-dz/implement-fin…
matt-dz Mar 13, 2026
c3b4bfa
fix: correct wc stdin/no_filename test expectation to match bash
matt-dz Mar 13, 2026
cced11b
fix: capture invocation time once for consistent -mtime/-mmin evaluation
matt-dz Mar 13, 2026
4a43391
test: verify Now() is called once per find invocation
matt-dz Mar 13, 2026
f4b613a
fix: fall back to lstat for dangling -newer refs under -L
matt-dz Mar 13, 2026
2cd4a86
fix: stabilize newer_dangling_symlink_L with explicit mod_time
matt-dz Mar 13, 2026
9f64956
Merge branch 'main' of github.com:DataDog/rshell into matt-dz/impleme…
matt-dz Mar 13, 2026
f45ea57
fix: use stderr_contains_windows for ls sandbox test
matt-dz Mar 13, 2026
32c6ee0
fix: stabilize mmin_exact test with explicit mod_time
matt-dz Mar 13, 2026
e4e0849
Merge remote-tracking branch 'origin/main' into matt-dz/implement-fin…
matt-dz Mar 13, 2026
5e11ab8
empty
AlexandreYang Mar 14, 2026
c33f0c0
feat: implement find -exec and -execdir support
AlexandreYang Mar 14, 2026
fb62074
[iter 1] Fix review findings: use path.Dir/Base, add test coverage
AlexandreYang Mar 14, 2026
3f4af8d
[iter 2] Fix Codex review: execdir OpenFile dir, reject multiple {} i…
AlexandreYang Mar 14, 2026
3e9f677
update skills
AlexandreYang Mar 14, 2026
db40685
[iter 3] Fix execdir sandbox path, remove skip_assert_against_bash, a…
AlexandreYang Mar 14, 2026
14ffe75
[iter 4] Remove unnecessary skip_assert_against_bash from sandbox exe…
AlexandreYang Mar 14, 2026
62bcc0c
[iter 4] Fix -exec ; mode: don't promote per-file errors to global fa…
AlexandreYang Mar 14, 2026
7131084
[iter 4] Use tagged switch in parseSize per lint diagnostic
AlexandreYang Mar 14, 2026
697f917
[iter 5] Fix -execdir wrong cwd when printPath has trailing slash
AlexandreYang Mar 14, 2026
d7d3787
[iter 7] Fix Codex review: reject embedded {} in -exec ... + batch va…
AlexandreYang Mar 14, 2026
2374b34
[iter 7] Modernize for loops in expr_test.go per lint
AlexandreYang Mar 14, 2026
3d656cd
Add comprehensive scenario tests for find -exec implementation
AlexandreYang Mar 15, 2026
bfa5228
[iter 11] Add clarifying comment on batch execution condition
AlexandreYang Mar 15, 2026
988ef6e
[iter 12] Address review nits: use %v for error formatting, exact std…
AlexandreYang Mar 15, 2026
936ed41
[iter 12] Add path.Clean and strings.Contains to find builtin allowlist
AlexandreYang Mar 15, 2026
12f4464
[iter 13] Inherit parent stdin, cache execDir, pre-allocate batch slice
AlexandreYang Mar 15, 2026
5e08194
[iter 13] Remove strings.NewReader from interp allowlist
AlexandreYang Mar 15, 2026
265b822
[iter 14] Chunk batch -exec into multiple invocations instead of drop…
AlexandreYang Mar 15, 2026
5a34e27
Merge remote-tracking branch 'origin/main' into alex/find_exec2
AlexandreYang Mar 16, 2026
7a29efb
[iter 15] Address review comments: allowedCommands check, execdir dot…
AlexandreYang Mar 16, 2026
5719bce
Merge branch 'main' into alex/find_exec2
AlexandreYang Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions allowedsymbols/symbols_builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
112 changes: 104 additions & 8 deletions builtins/find/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ package find

import (
"context"
"io"
iofs "io/fs"
"math"
"path"
"strings"
"time"

"github.com/DataDog/rshell/builtins"
Expand All @@ -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
Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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
}
127 changes: 98 additions & 29 deletions builtins/find/expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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.
Comment on lines +508 to +510

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject embedded '{}' before '+' in -exec templates

This + terminator check only triggers when the previous token is exactly {}, so inputs like find ... -exec echo x{} + \; are parsed as a valid ;-terminated command (x{} +) instead of being rejected. GNU find documents batch syntax as -exec COMMAND {} + (find --help) and, for this exact shape, errors with In '-exec ... {} +' the '{}' must appear by itself; accepting it here silently executes commands that GNU would fail, which can change script behavior and mask invalid expressions.

Useful? React with 👍 / 👎.

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 {
Expand Down Expand Up @@ -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"
}
Expand Down
Loading
Loading