diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index f63ae606..21a0e6ca 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -12,6 +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 - ✅ `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/allowedpaths/portable_unix.go b/allowedpaths/portable_unix.go index 0bf84494..e507007d 100644 --- a/allowedpaths/portable_unix.go +++ b/allowedpaths/portable_unix.go @@ -19,6 +19,16 @@ func IsErrIsDirectory(err error) bool { return errors.Is(err, syscall.EISDIR) } +// FileIdentity extracts canonical file identity (dev+inode) from FileInfo. +// On Unix, this is extracted directly from Stat_t via info.Sys(). +func FileIdentity(_ string, info fs.FileInfo, _ *Sandbox) (uint64, uint64, bool) { + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, false + } + return uint64(st.Dev), uint64(st.Ino), true +} + // effectiveHasPerm checks whether the current process has the requested // permission (writeMask or execMask, each a 3-bit pattern like 0222 or 0111) // by inspecting the file's owner/group/other permission class that applies to diff --git a/allowedpaths/portable_windows.go b/allowedpaths/portable_windows.go index 6e2350db..62de2413 100644 --- a/allowedpaths/portable_windows.go +++ b/allowedpaths/portable_windows.go @@ -8,6 +8,7 @@ package allowedpaths import ( "errors" "io/fs" + "os" "syscall" ) @@ -21,6 +22,28 @@ func IsErrIsDirectory(err error) bool { return false } +// FileIdentity extracts canonical file identity on Windows using +// GetFileInformationByHandle (volume serial + file index). +// The path and sandbox are needed to open the file through the sandbox. +func FileIdentity(absPath string, _ fs.FileInfo, sandbox *Sandbox) (uint64, uint64, bool) { + root, relPath, ok := sandbox.resolve(absPath) + if !ok { + return 0, 0, false + } + f, err := root.OpenFile(relPath, os.O_RDONLY, 0) + if err != nil { + return 0, 0, false + } + defer f.Close() + + h := syscall.Handle(f.Fd()) + var d syscall.ByHandleFileInformation + if err := syscall.GetFileInformationByHandle(h, &d); err != nil { + return 0, 0, false + } + return uint64(d.VolumeSerialNumber), uint64(d.FileIndexHigh)<<32 | uint64(d.FileIndexLow), true +} + // effectiveHasPerm checks whether the current process has the requested // permission on Windows. Windows does not use Unix UID/GID permission classes, // so we fall back to checking any-class bits (0222 / 0111) as before. diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 03e869c1..40c74293 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -242,6 +242,47 @@ func (s *Sandbox) readDirN(path string, cwd string, maxEntries int) ([]fs.DirEnt return entries, nil } +// OpenDir opens a directory within the sandbox for incremental reading +// via ReadDir(n). The caller must close the returned handle when done. +// Returns fs.ReadDirFile to expose only read-only directory methods. +func (s *Sandbox) OpenDir(path string, cwd string) (fs.ReadDirFile, error) { + absPath := toAbs(path, cwd) + + root, relPath, ok := s.resolve(absPath) + if !ok { + return nil, &os.PathError{Op: "opendir", Path: path, Err: os.ErrPermission} + } + + f, err := root.Open(relPath) + if err != nil { + return nil, PortablePathError(err) + } + return f, nil +} + +// IsDirEmpty checks whether a directory is empty by reading at most one +// entry. More efficient than reading all entries when only emptiness +// needs to be determined. +func (s *Sandbox) IsDirEmpty(path string, cwd string) (bool, error) { + absPath := toAbs(path, cwd) + + root, relPath, ok := s.resolve(absPath) + if !ok { + return false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } + + f, err := root.Open(relPath) + if err != nil { + return false, PortablePathError(err) + } + defer f.Close() + entries, err := f.ReadDir(1) + if err != nil && err != io.EOF { + return false, PortablePathError(err) + } + return len(entries) == 0, nil +} + // ReadDirLimited reads directory entries, skipping the first offset entries // and returning up to maxRead entries sorted by name within the read window. // Returns (entries, truncated, error). When truncated is true, the directory diff --git a/allowedsymbols/symbols_allowedpaths.go b/allowedsymbols/symbols_allowedpaths.go index 7cca5539..bbb2ce12 100644 --- a/allowedsymbols/symbols_allowedpaths.go +++ b/allowedsymbols/symbols_allowedpaths.go @@ -17,39 +17,43 @@ package allowedsymbols // // The permanently banned packages (reflect, unsafe) apply here too. var allowedpathsAllowedSymbols = []string{ - "errors.As", // error type assertion; pure function, no I/O. - "errors.Is", // error comparison; pure function, no I/O. - "errors.New", // creates a simple error value; pure function, no I/O. - "fmt.Errorf", // formatted error creation; pure function, no I/O. - "io.EOF", // sentinel error value; pure constant. - "io.ReadWriteCloser", // combined interface type; no side effects. - "io/fs.DirEntry", // interface type for directory entries; no side effects. - "io/fs.ErrExist", // sentinel error for "already exists"; pure constant. - "io/fs.ErrNotExist", // sentinel error for "does not exist"; pure constant. - "io/fs.ErrPermission", // sentinel error for permission denied; pure constant. - "io/fs.FileInfo", // interface type for file metadata; no side effects. - "io/fs.FileMode", // file permission bits type; pure type. - "os.DevNull", // platform null device path constant; pure constant. - "os.ErrPermission", // sentinel error for permission denied; pure constant. - "os.FileMode", // file permission bits type; pure type. - "os.Getgid", // returns the numeric group id of the caller; read-only syscall. - "os.Getgroups", // returns supplementary group ids; read-only syscall. - "os.Getuid", // returns the numeric user id of the caller; read-only syscall. - "os.O_RDONLY", // read-only file flag constant; pure constant. - "os.OpenRoot", // opens a directory as a root for sandboxed file access; needed for sandbox. - "os.PathError", // error type wrapping path and operation; pure type. - "os.Root", // sandboxed directory root type; core of the filesystem sandbox. - "os.Stat", // returns file info for a path; needed for sandbox path validation. - "path/filepath.Abs", // returns absolute path; pure path computation. - "path/filepath.IsAbs", // checks if path is absolute; pure function, no I/O. - "path/filepath.Join", // joins path elements; pure function, no I/O. - "path/filepath.Rel", // returns relative path; pure path computation. - "path/filepath.Separator", // OS path separator constant; pure constant. - "slices.SortFunc", // sorts a slice with a comparison function; pure function, no I/O. - "strings.Compare", // compares two strings lexicographically; pure function, no I/O. - "strings.EqualFold", // case-insensitive string comparison; pure function, no I/O. - "strings.HasPrefix", // pure function for prefix matching; no I/O. - "syscall.EISDIR", // "is a directory" errno constant; pure constant. - "syscall.Errno", // system call error number type; pure type. - "syscall.Stat_t", // file stat structure type; pure type for Unix file metadata. + "errors.As", // error type assertion; pure function, no I/O. + "errors.Is", // error comparison; pure function, no I/O. + "errors.New", // creates a simple error value; pure function, no I/O. + "fmt.Errorf", // formatted error creation; pure function, no I/O. + "io.EOF", // sentinel error value; pure constant. + "io.ReadWriteCloser", // combined interface type; no side effects. + "io/fs.DirEntry", // interface type for directory entries; no side effects. + "io/fs.ErrExist", // sentinel error for "already exists"; pure constant. + "io/fs.ErrNotExist", // sentinel error for "does not exist"; pure constant. + "io/fs.ErrPermission", // sentinel error for permission denied; pure constant. + "io/fs.FileInfo", // interface type for file metadata; no side effects. + "io/fs.FileMode", // file permission bits type; pure type. + "io/fs.ReadDirFile", // read-only directory handle interface; no write capability. + "os.DevNull", // platform null device path constant; pure constant. + "os.ErrPermission", // sentinel error for permission denied; pure constant. + "os.FileMode", // file permission bits type; pure type. + "os.Getgid", // returns the numeric group id of the caller; read-only syscall. + "os.Getgroups", // returns supplementary group ids; read-only syscall. + "os.Getuid", // returns the numeric user id of the caller; read-only syscall. + "os.O_RDONLY", // read-only file flag constant; pure constant. + "os.OpenRoot", // opens a directory as a root for sandboxed file access; needed for sandbox. + "os.PathError", // error type wrapping path and operation; pure type. + "os.Root", // sandboxed directory root type; core of the filesystem sandbox. + "os.Stat", // returns file info for a path; needed for sandbox path validation. + "path/filepath.Abs", // returns absolute path; pure path computation. + "path/filepath.IsAbs", // checks if path is absolute; pure function, no I/O. + "path/filepath.Join", // joins path elements; pure function, no I/O. + "path/filepath.Rel", // returns relative path; pure path computation. + "path/filepath.Separator", // OS path separator constant; pure constant. + "slices.SortFunc", // sorts a slice with a comparison function; pure function, no I/O. + "strings.Compare", // compares two strings lexicographically; pure function, no I/O. + "strings.EqualFold", // case-insensitive string comparison; pure function, no I/O. + "strings.HasPrefix", // pure function for prefix matching; no I/O. + "syscall.ByHandleFileInformation", // Windows file identity structure; pure type for file metadata. + "syscall.EISDIR", // "is a directory" errno constant; pure constant. + "syscall.Errno", // system call error number type; pure type. + "syscall.GetFileInformationByHandle", // Windows API for file identity (vol serial + file index); read-only syscall. + "syscall.Handle", // Windows file handle type; pure type alias. + "syscall.Stat_t", // file stat structure type; pure type for Unix file metadata. } diff --git a/allowedsymbols/symbols_builtins.go b/allowedsymbols/symbols_builtins.go index 279dc763..f108da61 100644 --- a/allowedsymbols/symbols_builtins.go +++ b/allowedsymbols/symbols_builtins.go @@ -65,6 +65,37 @@ var builtinPerCommandSymbols = map[string][]string{ "false": { "context.Context", // deadline/cancellation plumbing; pure interface, no side effects. }, + "find": { + "context.Context", // deadline/cancellation plumbing; pure interface, no side effects. + "errors.As", // error type assertion; pure function, no I/O. + "errors.Is", // error comparison; pure function, no I/O. + "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/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. + "io/fs.ModeSocket", // file mode bit constant for sockets; pure constant. + "io/fs.ModeSymlink", // file mode bit constant for symlinks; pure constant. + "io/fs.ReadDirFile", // read-only directory handle interface; no write capability. + "math.Ceil", // pure arithmetic; no side effects. + "math.Floor", // pure arithmetic; no side effects. + "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/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.HasPrefix", // pure function for prefix matching; 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. + "time.Minute", // constant representing one minute; no side effects. + "time.Second", // constant representing one second; no side effects. + "time.Time", // time value type; pure data, no side effects. + "unicode/utf8.DecodeRuneInString", // decodes first UTF-8 rune from a string; pure function, no I/O. + }, "grep": { "bufio.NewScanner", // line-by-line input reading (e.g. head, cat); no write or exec capability. "bytes.IndexByte", // finds a byte in a byte slice; pure function, no I/O. @@ -263,86 +294,96 @@ var builtinPerCommandSymbols = map[string][]string{ } var builtinAllowedSymbols = []string{ - "bufio.NewScanner", // line-by-line input reading (e.g. head, cat); no write or exec capability. - "bufio.Scanner", // scanner type for buffered input reading; no write or exec capability. - "bufio.SplitFunc", // type for custom scanner split functions; pure type, no I/O. - "bytes.Equal", // compares two byte slices for equality; pure function, no I/O. - "bytes.IndexByte", // finds a byte in a byte slice; pure function, no I/O. - "bytes.NewReader", // wraps a byte slice as an io.Reader; pure in-memory, no I/O. - "context.Context", // deadline/cancellation plumbing; pure interface, no side effects. - "errors.As", // error type assertion; pure function, no I/O. - "errors.Is", // error comparison; pure function, no I/O. - "errors.New", // creates a simple error value; pure function, no I/O. - "fmt.Errorf", // error formatting; pure function, no I/O. - "fmt.Sprintf", // string formatting; pure function, no I/O. - "io.EOF", // sentinel error value; pure constant. - "io.MultiReader", // combines multiple Readers into one sequential Reader; no I/O side effects. - "io.NopCloser", // wraps a Reader with a no-op Close; no side effects. - "io.ReadCloser", // interface type; no side effects. - "io.ReadSeeker", // interface type combining Reader and Seeker; no side effects. - "io.Reader", // interface type; no side effects. - "io.SeekCurrent", // whence constant for Seek(offset, SeekCurrent); pure constant. - "io.WriteString", // writes a string to a writer; no filesystem access, delegates to Write. - "io.Writer", // interface type for writing; no side effects. - "io/fs.DirEntry", // interface type for directory entries; no side effects. - "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. - "io/fs.ModeSetgid", // file mode bit constant for setgid; pure constant. - "io/fs.ModeSetuid", // file mode bit constant for setuid; pure constant. - "io/fs.ModeSocket", // file mode bit constant for sockets; pure constant. - "io/fs.ModeSticky", // file mode bit constant for sticky bit; pure constant. - "io/fs.ModeSymlink", // file mode bit constant for symlinks; pure constant. - "math.Inf", // returns positive or negative infinity; pure function, no I/O. - "math.MaxInt32", // integer constant; no side effects. - "math.MaxInt64", // integer constant; no side effects. - "math.MaxUint64", // integer constant; no side effects. - "math.NaN", // returns IEEE 754 NaN value; pure function, no I/O. - "os.FileInfo", // file metadata interface returned by Stat; no I/O side effects. - "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. - "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. - "regexp.Regexp", // compiled regular expression type; no I/O side effects. All matching methods are linear-time (RE2). - "runtime.GOOS", // current OS name constant; pure constant, no I/O. - "slices.Reverse", // reverses a slice in-place; pure function, no I/O. - "slices.SortFunc", // sorts a slice with a comparison function; pure function, no I/O. - "slices.SortStableFunc", // stable sort with a comparison function; 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.FormatInt", // int-to-string conversion; pure function, no I/O. - "strconv.IntSize", // platform int size constant (32 or 64); pure constant, no I/O. - "strconv.Itoa", // int-to-string conversion; pure function, no I/O. - "strconv.NumError", // error type for numeric conversion failures; pure type. - "strconv.ParseBool", // string-to-bool conversion; pure function, no I/O. - "strconv.ParseFloat", // string-to-float conversion; pure function, no I/O. - "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.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. - "strings.Join", // concatenates a slice of strings with a separator; pure function, no I/O. - "strings.ReplaceAll", // replaces all occurrences of a substring; 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. - "strings.TrimSpace", // removes leading/trailing whitespace; pure function. - "syscall.EISDIR", // error number constant for "is a directory"; pure constant, no I/O. - "syscall.Errno", // error type for system call error numbers; pure type, no I/O. - "time.Time", // time value type; pure data, no side effects. - "unicode.Cc", // control character category range table; pure data, no I/O. - "unicode.Cf", // format character category range table; pure data, no I/O. - "unicode.Co", // private-use character category range table; pure data, no I/O. - "unicode.Is", // checks if rune belongs to a range table; pure function, no I/O. - "unicode.IsGraphic", // reports whether rune is defined as a graphic character; pure function, no I/O. - "unicode.Me", // enclosing mark category range table; pure data, no I/O. - "unicode.Mn", // nonspacing mark category range table; pure data, no I/O. - "unicode.Range16", // struct type for 16-bit Unicode ranges; pure data. - "unicode.Range32", // struct type for 32-bit Unicode ranges; pure data. - "unicode.RangeTable", // struct type for Unicode range tables; pure data. - "unicode.Zs", // Unicode space separator category range table; pure data, no I/O. - "unicode/utf8.DecodeRune", // decodes first UTF-8 rune from a byte slice; pure function, no I/O. - "unicode/utf8.RuneError", // replacement character returned for invalid UTF-8; constant, no I/O. - "unicode/utf8.UTFMax", // maximum number of bytes in a UTF-8 encoding; constant, no I/O. - "unicode/utf8.Valid", // checks if a byte slice is valid UTF-8; pure function, no I/O. + "bufio.NewScanner", // line-by-line input reading (e.g. head, cat); no write or exec capability. + "bufio.Scanner", // scanner type for buffered input reading; no write or exec capability. + "bufio.SplitFunc", // type for custom scanner split functions; pure type, no I/O. + "bytes.Equal", // compares two byte slices for equality; pure function, no I/O. + "bytes.IndexByte", // finds a byte in a byte slice; pure function, no I/O. + "bytes.NewReader", // wraps a byte slice as an io.Reader; pure in-memory, no I/O. + "context.Context", // deadline/cancellation plumbing; pure interface, no side effects. + "errors.As", // error type assertion; pure function, no I/O. + "errors.Is", // error comparison; pure function, no I/O. + "errors.New", // creates a simple error value; pure function, no I/O. + "fmt.Errorf", // error formatting; pure function, no I/O. + "fmt.Sprintf", // string formatting; pure function, no I/O. + "io.EOF", // sentinel error value; pure constant. + "io.MultiReader", // combines multiple Readers into one sequential Reader; no I/O side effects. + "io.NopCloser", // wraps a Reader with a no-op Close; no side effects. + "io.ReadCloser", // interface type; no side effects. + "io.ReadSeeker", // interface type combining Reader and Seeker; no side effects. + "io.Reader", // interface type; no side effects. + "io.SeekCurrent", // whence constant for Seek(offset, SeekCurrent); pure constant. + "io.WriteString", // writes a string to a writer; no filesystem access, delegates to Write. + "io.Writer", // interface type for writing; no side effects. + "io/fs.DirEntry", // interface type for directory entries; no side effects. + "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. + "io/fs.ModeSetgid", // file mode bit constant for setgid; pure constant. + "io/fs.ModeSetuid", // file mode bit constant for setuid; pure constant. + "io/fs.ModeSocket", // file mode bit constant for sockets; pure constant. + "io/fs.ModeSticky", // file mode bit constant for sticky bit; pure constant. + "io/fs.ModeSymlink", // file mode bit constant for symlinks; pure constant. + "io/fs.ReadDirFile", // read-only directory handle interface; no write capability. + "math.Ceil", // pure arithmetic; no side effects. + "math.Floor", // pure arithmetic; no side effects. + "math.Inf", // returns positive or negative infinity; pure function, no I/O. + "math.MaxInt32", // integer constant; no side effects. + "math.MaxInt64", // integer constant; no side effects. + "math.MaxUint64", // integer constant; no side effects. + "math.NaN", // returns IEEE 754 NaN value; pure function, no I/O. + "os.FileInfo", // file metadata interface returned by Stat; no I/O side effects. + "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/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. + "regexp.Regexp", // compiled regular expression type; no I/O side effects. All matching methods are linear-time (RE2). + "runtime.GOOS", // current OS name constant; pure constant, no I/O. + "slices.Reverse", // reverses a slice in-place; pure function, no I/O. + "slices.SortFunc", // sorts a slice with a comparison function; pure function, no I/O. + "slices.SortStableFunc", // stable sort with a comparison function; 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.FormatInt", // int-to-string conversion; pure function, no I/O. + "strconv.IntSize", // platform int size constant (32 or 64); pure constant, no I/O. + "strconv.Itoa", // int-to-string conversion; pure function, no I/O. + "strconv.NumError", // error type for numeric conversion failures; pure type. + "strconv.ParseBool", // string-to-bool conversion; pure function, no I/O. + "strconv.ParseFloat", // string-to-float conversion; pure function, no I/O. + "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.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. + "strings.Join", // concatenates a slice of strings with a separator; pure function, no I/O. + "strings.ReplaceAll", // replaces all occurrences of a substring; 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. + "strings.TrimSpace", // removes leading/trailing whitespace; pure function. + "syscall.EISDIR", // error number constant for "is a directory"; pure constant, no I/O. + "syscall.Errno", // error type for system call error numbers; pure type, no I/O. + "time.Duration", // duration type; pure integer alias, no I/O. + "time.Hour", // constant representing one hour; no side effects. + "time.Minute", // constant representing one minute; no side effects. + "time.Second", // constant representing one second; no side effects. + "time.Time", // time value type; pure data, no side effects. + "unicode.Cc", // control character category range table; pure data, no I/O. + "unicode.Cf", // format character category range table; pure data, no I/O. + "unicode.Co", // private-use character category range table; pure data, no I/O. + "unicode.Is", // checks if rune belongs to a range table; pure function, no I/O. + "unicode.IsGraphic", // reports whether rune is defined as a graphic character; pure function, no I/O. + "unicode.Me", // enclosing mark category range table; pure data, no I/O. + "unicode.Mn", // nonspacing mark category range table; pure data, no I/O. + "unicode.Range16", // struct type for 16-bit Unicode ranges; pure data. + "unicode.Range32", // struct type for 32-bit Unicode ranges; pure data. + "unicode.RangeTable", // struct type for Unicode range tables; pure data. + "unicode.Zs", // Unicode space separator category range table; pure data, no I/O. + "unicode/utf8.DecodeRune", // decodes first UTF-8 rune from a byte slice; pure function, no I/O. + "unicode/utf8.DecodeRuneInString", // decodes first UTF-8 rune from a string; pure function, no I/O. + "unicode/utf8.RuneError", // replacement character returned for invalid UTF-8; constant, no I/O. + "unicode/utf8.UTFMax", // maximum number of bytes in a UTF-8 encoding; constant, no I/O. + "unicode/utf8.Valid", // checks if a byte slice is valid UTF-8; pure function, no I/O. } diff --git a/allowedsymbols/symbols_interp.go b/allowedsymbols/symbols_interp.go index b726ce7e..5e10207f 100644 --- a/allowedsymbols/symbols_interp.go +++ b/allowedsymbols/symbols_interp.go @@ -33,6 +33,7 @@ var interpAllowedSymbols = []string{ "io.Writer", // interface type for writing; no side effects. "io/fs.DirEntry", // interface type for directory entries; no side effects. "io/fs.FileInfo", // interface type for file metadata; no side effects. + "io/fs.ReadDirFile", // read-only directory handle interface; no write capability. "maps.Insert", // inserts all key-value pairs from one map into another; pure function. "os.DirEntry", // type alias for fs.DirEntry; no side effects. "os.File", // file handle type; interpreter needs file I/O for redirects and pipes. @@ -41,6 +42,8 @@ var interpAllowedSymbols = []string{ "os.O_RDONLY", // read-only file flag constant; pure constant. "os.PathError", // error type wrapping path and operation; pure type. "os.Pipe", // creates an OS pipe pair; needed for shell pipelines. + "path/filepath.IsAbs", // checks if path is absolute; pure function, no I/O. + "path/filepath.Join", // joins path elements; pure function, no I/O. "runtime.GOOS", // current OS name constant; pure constant, no I/O. "strconv.Itoa", // int-to-string conversion; pure function, no I/O. "strings.Builder", // efficient string concatenation; pure in-memory buffer, no I/O. diff --git a/builtins/builtins.go b/builtins/builtins.go index 9789fd18..b845f9d8 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -87,9 +87,18 @@ type CallContext struct { OpenFile func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) // ReadDir reads a directory within the shell's path restrictions. - // Entries are returned sorted by name. + // Entries are returned sorted by name. Used by builtins like ls + // that need deterministic sorted output. ReadDir func(ctx context.Context, path string) ([]fs.DirEntry, error) + // OpenDir opens a directory within the shell's path restrictions for + // incremental reading via ReadDir(n). Caller must close the handle. + OpenDir func(ctx context.Context, path string) (fs.ReadDirFile, error) + + // IsDirEmpty checks whether a directory is empty by reading at most + // one entry. More efficient than reading all entries. + IsDirEmpty func(ctx context.Context, path string) (bool, error) + // ReadDirLimited reads directory entries, skipping the first offset entries // and returning up to maxRead entries sorted by name within the read window. // Returns (entries, truncated, error). When truncated is true, the directory @@ -113,6 +122,12 @@ type CallContext struct { // calling time.Now() directly, so the time source is consistent and // testable. Now func() time.Time + + // FileIdentity extracts canonical file identity from FileInfo. + // On Unix: dev+inode from Stat_t. On Windows: volume serial + file index + // 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) } // Out writes a string to stdout. @@ -130,6 +145,14 @@ func (c *CallContext) Errf(format string, a ...any) { fmt.Fprintf(c.Stderr, format, a...) } +// FileID is a comparable file identity for cycle detection. +// On Unix: device + inode. On Windows: volume serial + file index. +// Used as map key for visited-set tracking. +type FileID struct { + Dev uint64 + Ino uint64 +} + // Result captures the outcome of executing a builtin command. type Result struct { // Code is the exit status code. diff --git a/builtins/find/eval.go b/builtins/find/eval.go new file mode 100644 index 00000000..66259b83 --- /dev/null +++ b/builtins/find/eval.go @@ -0,0 +1,250 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package find + +import ( + "context" + iofs "io/fs" + "math" + "time" + + "github.com/DataDog/rshell/builtins" +) + +// evalResult captures the outcome of evaluating an expression on a file. +type evalResult struct { + matched bool + prune bool // skip descending into this directory +} + +// 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 +} + +// evaluate evaluates an expression tree against a file. If e is nil, returns +// matched=true (match everything). +func evaluate(ec *evalContext, e *expr) evalResult { + if e == nil { + return evalResult{matched: true} + } + switch e.kind { + case exprAnd: + left := evaluate(ec, e.left) + if !left.matched { + return evalResult{prune: left.prune} + } + right := evaluate(ec, e.right) + return evalResult{matched: right.matched, prune: left.prune || right.prune} + + case exprOr: + left := evaluate(ec, e.left) + if left.matched { + return evalResult{matched: true, prune: left.prune} + } + right := evaluate(ec, e.right) + return evalResult{matched: right.matched, prune: left.prune || right.prune} + + case exprNot: + r := evaluate(ec, e.operand) + return evalResult{matched: !r.matched, prune: r.prune} + + case exprName: + name := baseName(ec.relPath) + return evalResult{matched: matchGlob(e.strVal, name)} + + case exprIName: + name := baseName(ec.relPath) + return evalResult{matched: matchGlobFold(e.strVal, name)} + + case exprPath: + return evalResult{matched: matchPathGlob(e.strVal, ec.printPath)} + + case exprIPath: + return evalResult{matched: matchPathGlobFold(e.strVal, ec.printPath)} + + case exprType: + return evalResult{matched: matchType(ec.info, e.strVal)} + + case exprSize: + return evalResult{matched: compareSize(ec.info.Size(), e.sizeVal)} + + case exprEmpty: + return evalResult{matched: evalEmpty(ec)} + + case exprNewer: + return evalResult{matched: evalNewer(ec, e.strVal)} + + case exprMtime: + return evalResult{matched: evalMtime(ec, e.numVal, e.numCmp)} + + case exprMmin: + return evalResult{matched: evalMmin(ec, e.numVal, e.numCmp)} + + case exprPrint: + ec.callCtx.Outf("%s\n", ec.printPath) + return evalResult{matched: true} + + case exprPrint0: + ec.callCtx.Outf("%s\x00", ec.printPath) + return evalResult{matched: true} + + case exprPrune: + return evalResult{matched: true, prune: true} + + case exprTrue: + return evalResult{matched: true} + + case exprFalse: + return evalResult{matched: false} + + default: + return evalResult{matched: false} + } +} + +// evalEmpty returns true if the file is an empty regular file or empty directory. +// For directories, uses IsDirEmpty which reads at most one entry rather than +// materializing the full listing. If the check fails, the error is reported +// to stderr and ec.failed is set so that find exits non-zero, matching GNU +// find behaviour. +func evalEmpty(ec *evalContext) bool { + if ec.info.IsDir() { + empty, err := ec.callCtx.IsDirEmpty(ec.ctx, ec.printPath) + if err != nil { + ec.callCtx.Errf("find: '%s': %s\n", ec.printPath, ec.callCtx.PortableErr(err)) + ec.failed = true + return false + } + return empty + } + if ec.info.Mode().IsRegular() { + return ec.info.Size() == 0 + } + return false +} + +// evalNewer returns true if the file is newer than the reference file. +// The reference file's modtime is resolved once and cached in newerCache +// to avoid redundant stat calls for every entry in the tree. Errors are +// tracked in newerErrors (shared across all entries) so a failed stat +// consistently returns false for all subsequent entries rather than +// matching against a zero-time sentinel. +func evalNewer(ec *evalContext, refPath string) bool { + // Check if this reference path previously failed to stat. + if ec.newerErrors[refPath] { + return false + } + refTime, ok := ec.newerCache[refPath] + if !ok { + statRef := ec.callCtx.LstatFile + if ec.followLinks { + statRef = ec.callCtx.StatFile + } + refInfo, err := statRef(ec.ctx, refPath) + if err != nil { + // With -L, stat fails on dangling symlinks — fall back to + // lstat so the symlink's own mtime can be used. Only fall + // back for "not found" errors; permission/sandbox errors + // must be reported. + if ec.followLinks && isNotExist(err) { + refInfo, err = ec.callCtx.LstatFile(ec.ctx, refPath) + } + if err != nil { + ec.callCtx.Errf("find: '%s': %s\n", refPath, ec.callCtx.PortableErr(err)) + ec.newerErrors[refPath] = true + return false + } + } + refTime = refInfo.ModTime() + ec.newerCache[refPath] = refTime + } + return ec.info.ModTime().After(refTime) +} + +// evalMtime checks modification time in days. +// GNU find uses different comparison strategies for -mtime: +// - Exact (N): day-bucketed comparison — N*86400 <= delta < (N+1)*86400. +// - +N: raw second comparison — delta > (N+1)*86400. +// - -N: raw second comparison — delta < N*86400. +// +// GNU find captures 'now' via time() (second precision) but gets file mtime +// from stat() (nanosecond precision). This means for very fresh files, +// delta can be slightly negative, causing -mtime -0 to match files created +// within the same second. We replicate this by truncating now to seconds +// for +N/-N comparisons. +// +// maxMtimeN is the largest N for which (N+1)*24*time.Hour does not overflow. +const maxMtimeN = int64(math.MaxInt64/(int64(24*time.Hour))) - 1 + +func evalMtime(ec *evalContext, n int64, cmp cmpOp) bool { + modTime := ec.info.ModTime() + switch cmp { + case cmpMore: // +N: strictly older than (N+1) days + if n > maxMtimeN { + return false // threshold beyond representable duration + } + // Truncate now to second precision to match GNU find's time(). + diff := ec.now.Truncate(time.Second).Sub(modTime) + return diff >= time.Duration(n+1)*24*time.Hour + case cmpLess: // -N: strictly newer than N days + if n > maxMtimeN { + return true // threshold beyond representable duration + } + // Truncate now to second precision to match GNU find's time(). + diff := ec.now.Truncate(time.Second).Sub(modTime) + return diff < time.Duration(n)*24*time.Hour + default: // N: day-bucketed exact match + // Do not clamp negative diff — future-dated files must produce + // negative day buckets so they never match non-negative N, + // matching GNU find behavior. + diff := ec.now.Sub(modTime) + days := int64(math.Floor(diff.Hours() / 24)) + return days == n + } +} + +// evalMmin checks modification time in minutes. +// GNU find uses different comparison strategies: +// - Exact (N): ceiling-bucketed comparison — a 5s-old file is in bucket 1. +// - +N: raw second comparison — delta_seconds > N*60. +// - -N: raw second comparison — delta_seconds < N*60. +// +// This matches GNU findutils behavior where +N/-N compare against raw +// seconds while exact N uses a window check. +// maxMminN is the largest N for which time.Duration(N)*time.Minute +// does not overflow int64 nanoseconds. +const maxMminN = int64(math.MaxInt64 / int64(time.Minute)) + +func evalMmin(ec *evalContext, n int64, cmp cmpOp) bool { + modTime := ec.info.ModTime() + diff := ec.now.Sub(modTime) + switch cmp { + case cmpMore: // +N: strictly older than N minutes + if n > maxMminN { + return false // threshold is beyond representable duration; nothing qualifies + } + return diff > time.Duration(n)*time.Minute + case cmpLess: // -N: strictly newer than N minutes + if n > maxMminN { + return true // threshold is beyond representable duration; everything qualifies + } + return diff < time.Duration(n)*time.Minute + default: // N: ceiling-bucketed exact match + mins := int64(math.Ceil(diff.Minutes())) + return mins == n + } +} diff --git a/builtins/find/eval_test.go b/builtins/find/eval_test.go new file mode 100644 index 00000000..5978376e --- /dev/null +++ b/builtins/find/eval_test.go @@ -0,0 +1,367 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package find + +import ( + "context" + "io" + iofs "io/fs" + "math" + "strings" + "testing" + "time" + + "github.com/DataDog/rshell/builtins" + "github.com/stretchr/testify/assert" +) + +// TestEvalMminCeiling verifies that -mmin uses ceiling rounding. +// GNU find rounds up fractional minutes: a file 5 seconds old is in +// minute bucket 1 (not 0). This prevents regression to math.Floor. +func TestEvalMminCeiling(t *testing.T) { + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + age time.Duration // how old the file is + n int64 + cmp cmpOp + matched bool + }{ + // Exact match uses ceiling bucketing: ceil(delta_sec / 60) + // +N/-N use raw second comparison: delta_sec > N*60 / delta_sec < N*60 + + // 0 seconds old → ceil(0) = 0 → bucket 0 + {"0s exact 0", 0, 0, cmpExact, true}, + {"0s gt 0", 0, 0, cmpMore, false}, // 0 > 0 = false + {"0s lt 1", 0, 1, cmpLess, true}, // 0 < 60 = true + + // 1 second old → ceil(1/60) = 1 → bucket 1 + {"1s exact 0", 1 * time.Second, 0, cmpExact, false}, + {"1s exact 1", 1 * time.Second, 1, cmpExact, true}, + {"1s gt 0", 1 * time.Second, 0, cmpMore, true}, // 1 > 0 = true + {"1s lt 1", 1 * time.Second, 1, cmpLess, true}, // 1 < 60 = true (GNU find matches) + + // 5 seconds old → ceil(5/60) = 1 → bucket 1 + {"5s exact 0", 5 * time.Second, 0, cmpExact, false}, + {"5s exact 1", 5 * time.Second, 1, cmpExact, true}, + {"5s gt 0", 5 * time.Second, 0, cmpMore, true}, // 5 > 0 = true + {"5s lt 1", 5 * time.Second, 1, cmpLess, true}, // 5 < 60 = true (key regression test) + + // 30 seconds old — the specific case from codex P1 + {"30s lt 1", 30 * time.Second, 1, cmpLess, true}, // 30 < 60 = true + + // 59 seconds old → ceil(59/60) = 1 → bucket 1 + {"59s exact 1", 59 * time.Second, 1, cmpExact, true}, + {"59s exact 0", 59 * time.Second, 0, cmpExact, false}, + {"59s lt 1", 59 * time.Second, 1, cmpLess, true}, // 59 < 60 = true + + // 60 seconds old → ceil(60/60) = 1 → bucket 1 + {"60s exact 1", 60 * time.Second, 1, cmpExact, true}, + {"60s exact 2", 60 * time.Second, 2, cmpExact, false}, + {"60s gt 1", 60 * time.Second, 1, cmpMore, false}, // 60 > 60 = false + {"60s lt 1", 60 * time.Second, 1, cmpLess, false}, // 60 < 60 = false + + // 61 seconds old → ceil(61/60) = 2 → bucket 2 + {"61s exact 1", 61 * time.Second, 1, cmpExact, false}, + {"61s exact 2", 61 * time.Second, 2, cmpExact, true}, + {"61s gt 1", 61 * time.Second, 1, cmpMore, true}, // 61 > 60 = true + {"61s lt 2", 61 * time.Second, 2, cmpLess, true}, // 61 < 120 = true + + // 5 minutes old → ceil(300/60) = 5 → bucket 5 + {"5m exact 5", 5 * time.Minute, 5, cmpExact, true}, + {"5m gt 4", 5 * time.Minute, 4, cmpMore, true}, // 300 > 240 = true + {"5m lt 6", 5 * time.Minute, 6, cmpLess, true}, // 300 < 360 = true + + // 5 minutes 1 second old → ceil(301/60) = 6 → bucket 6 + {"5m1s exact 6", 5*time.Minute + 1*time.Second, 6, cmpExact, true}, + {"5m1s exact 5", 5*time.Minute + 1*time.Second, 5, cmpExact, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modTime := now.Add(-tt.age) + ec := &evalContext{ + now: now, + info: &fakeFileInfo{modTime: modTime}, + } + got := evalMmin(ec, tt.n, tt.cmp) + assert.Equal(t, tt.matched, got, "evalMmin(age=%v, n=%d, cmp=%s)", tt.age, tt.n, tt.cmp) + }) + } +} + +// TestEvalMminOverflow verifies that evalMmin handles values exceeding +// maxMminN without integer overflow. For +N (cmpMore), overflow values +// should return false (nothing qualifies). For -N (cmpLess), overflow +// values should return true (everything qualifies). +func TestEvalMminOverflow(t *testing.T) { + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + // File is 1 hour old — a normal age for testing overflow thresholds. + modTime := now.Add(-1 * time.Hour) + ec := &evalContext{ + now: now, + info: &fakeFileInfo{modTime: modTime}, + } + + tests := []struct { + name string + n int64 + cmp cmpOp + matched bool + }{ + // At the overflow boundary: maxMminN is the largest safe value. + {"maxMminN +N", maxMminN, cmpMore, false}, // threshold is ~292K years; 1h file is newer + {"maxMminN -N", maxMminN, cmpLess, true}, // 1h < ~292K years + {"maxMminN exact", maxMminN, cmpExact, false}, // exact match impossible + + // Just past the boundary: these would overflow without the guard. + {"maxMminN+1 +N", maxMminN + 1, cmpMore, false}, // overflow guard → false + {"maxMminN+1 -N", maxMminN + 1, cmpLess, true}, // overflow guard → true + + // Very large values that would definitely overflow. + {"huge +N", math.MaxInt64 / 2, cmpMore, false}, + {"huge -N", math.MaxInt64 / 2, cmpLess, true}, + {"maxint64 +N", math.MaxInt64, cmpMore, false}, + {"maxint64 -N", math.MaxInt64, cmpLess, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := evalMmin(ec, tt.n, tt.cmp) + assert.Equal(t, tt.matched, got, "evalMmin(n=%d, cmp=%s)", tt.n, tt.cmp) + }) + } +} + +// TestEvalMtimeFloor verifies that -mtime uses floor rounding (NOT ceiling). +// A file 5 hours old should be in day bucket 0 (not 1). +func TestEvalMtimeFloor(t *testing.T) { + now := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + age time.Duration + n int64 + cmp cmpOp + matched bool + }{ + // 0 hours → floor(0/24) = 0 + {"0h exact 0", 0, 0, cmpExact, true}, + {"0h gt 0", 0, 0, cmpMore, false}, + + // 5 hours → floor(5/24) = 0 + {"5h exact 0", 5 * time.Hour, 0, cmpExact, true}, + {"5h exact 1", 5 * time.Hour, 1, cmpExact, false}, + + // 23 hours → floor(23/24) = 0 + {"23h exact 0", 23 * time.Hour, 0, cmpExact, true}, + + // 24 hours → floor(24/24) = 1 + {"24h exact 1", 24 * time.Hour, 1, cmpExact, true}, + {"24h exact 0", 24 * time.Hour, 0, cmpExact, false}, + + // 25 hours → floor(25/24) = 1 + {"25h exact 1", 25 * time.Hour, 1, cmpExact, true}, + + // 48 hours → floor(48/24) = 2 + {"48h exact 2", 48 * time.Hour, 2, cmpExact, true}, + {"48h gt 1", 48 * time.Hour, 1, cmpMore, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modTime := now.Add(-tt.age) + ec := &evalContext{ + now: now, + info: &fakeFileInfo{modTime: modTime}, + } + got := evalMtime(ec, tt.n, tt.cmp) + assert.Equal(t, tt.matched, got, "evalMtime(age=%v, n=%d, cmp=%s)", tt.age, tt.n, tt.cmp) + }) + } +} + +// TestCompareSizeOverflow verifies overflow-safe ceiling division. +func TestCompareSizeOverflow(t *testing.T) { + tests := []struct { + name string + fileSize int64 + su sizeUnit + matched bool + }{ + // Normal cases + {"0 bytes exact 0c", 0, sizeUnit{n: 0, cmp: cmpExact, unit: 'c'}, true}, + {"1 byte exact 1c", 1, sizeUnit{n: 1, cmp: cmpExact, unit: 'c'}, true}, + {"512 bytes exact 1b", 512, sizeUnit{n: 1, cmp: cmpExact, unit: 'b'}, true}, + {"1 byte rounds up to 1 block", 1, sizeUnit{n: 1, cmp: cmpExact, unit: 'b'}, true}, + {"513 bytes rounds up to 2 blocks", 513, sizeUnit{n: 2, cmp: cmpExact, unit: 'b'}, true}, + + // Edge: zero-byte file + {"0 bytes +0c", 0, sizeUnit{n: 0, cmp: cmpMore, unit: 'c'}, false}, + {"0 bytes -1c", 0, sizeUnit{n: 1, cmp: cmpLess, unit: 'c'}, true}, + + // Large files near MaxInt64 (overflow protection) + {"MaxInt64 bytes +0c", 1<<63 - 1, sizeUnit{n: 0, cmp: cmpMore, unit: 'c'}, true}, + {"MaxInt64 bytes exact in blocks", 1<<63 - 1, sizeUnit{n: (1<<63 - 1) / 512, cmp: cmpMore, unit: 'b'}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := compareSize(tt.fileSize, tt.su) + assert.Equal(t, tt.matched, got) + }) + } +} + +// TestEvalEmpty verifies the -empty predicate for directories, regular files, +// and other file types. Scenario tests cannot create empty dirs (setup.files +// requires a file), so directory emptiness must be tested here. +func TestEvalEmpty(t *testing.T) { + t.Run("empty directory matches", func(t *testing.T) { + called := false + ec := &evalContext{ + ctx: context.Background(), + info: &fakeFileInfo{isDir: true}, + printPath: "emptydir", + callCtx: &builtins.CallContext{ + Stderr: io.Discard, + IsDirEmpty: func(_ context.Context, _ string) (bool, error) { + called = true + return true, nil + }, + }, + } + assert.True(t, evalEmpty(ec), "empty directory should match -empty") + assert.True(t, called, "IsDirEmpty must be called for directories") + }) + + t.Run("non-empty directory does not match", func(t *testing.T) { + ec := &evalContext{ + ctx: context.Background(), + info: &fakeFileInfo{isDir: true}, + printPath: "nonemptydir", + callCtx: &builtins.CallContext{ + Stderr: io.Discard, + IsDirEmpty: func(_ context.Context, _ string) (bool, error) { + return false, nil + }, + }, + } + assert.False(t, evalEmpty(ec), "non-empty directory should not match -empty") + }) + + t.Run("IsDirEmpty receives correct path", func(t *testing.T) { + var gotPath string + ec := &evalContext{ + ctx: context.Background(), + info: &fakeFileInfo{isDir: true}, + printPath: "some/nested/dir", + callCtx: &builtins.CallContext{ + Stderr: io.Discard, + IsDirEmpty: func(_ context.Context, path string) (bool, error) { + gotPath = path + return true, nil + }, + }, + } + evalEmpty(ec) + assert.Equal(t, "some/nested/dir", gotPath, "IsDirEmpty should receive printPath") + }) + + t.Run("IsDirEmpty error sets failed and returns false", func(t *testing.T) { + var stderr strings.Builder + ec := &evalContext{ + ctx: context.Background(), + info: &fakeFileInfo{isDir: true}, + printPath: "baddir", + callCtx: &builtins.CallContext{ + Stderr: &stderr, + IsDirEmpty: func(_ context.Context, _ string) (bool, error) { + return false, &iofs.PathError{Op: "readdir", Path: "baddir", Err: iofs.ErrPermission} + }, + PortableErr: func(err error) string { return err.Error() }, + }, + } + assert.False(t, evalEmpty(ec), "error should return false") + assert.True(t, ec.failed, "error should set failed flag") + assert.Contains(t, stderr.String(), "baddir", "error should mention the path on stderr") + }) + + t.Run("empty regular file matches", func(t *testing.T) { + ec := &evalContext{ + ctx: context.Background(), + info: &fakeFileInfo{size: 0, isDir: false}, + } + assert.True(t, evalEmpty(ec), "zero-byte regular file should match -empty") + }) + + t.Run("non-empty regular file does not match", func(t *testing.T) { + ec := &evalContext{ + ctx: context.Background(), + info: &fakeFileInfo{size: 42, isDir: false}, + } + assert.False(t, evalEmpty(ec), "non-empty regular file should not match -empty") + }) + + t.Run("symlink does not match", func(t *testing.T) { + ec := &evalContext{ + ctx: context.Background(), + info: &fakeFileInfo{mode: iofs.ModeSymlink}, + } + assert.False(t, evalEmpty(ec), "symlink should not match -empty") + }) + + t.Run("IsDirEmpty not called for regular files", func(t *testing.T) { + called := false + ec := &evalContext{ + ctx: context.Background(), + info: &fakeFileInfo{size: 0, isDir: false}, + callCtx: &builtins.CallContext{ + IsDirEmpty: func(_ context.Context, _ string) (bool, error) { + called = true + return true, nil + }, + }, + } + evalEmpty(ec) + assert.False(t, called, "IsDirEmpty should not be called for regular files") + }) +} + +// fakeDirEntry implements a minimal fs.DirEntry for testing. +type fakeDirEntry struct{} + +func (fakeDirEntry) Name() string { return "file.txt" } +func (fakeDirEntry) IsDir() bool { return false } +func (fakeDirEntry) Type() iofs.FileMode { return 0 } +func (fakeDirEntry) Info() (iofs.FileInfo, error) { return nil, nil } + +// fakeFileInfo implements the minimal fs.FileInfo interface for testing. +type fakeFileInfo struct { + modTime time.Time + size int64 + isDir bool + mode iofs.FileMode // when set, Mode() returns this directly +} + +func (f *fakeFileInfo) Name() string { return "fake" } +func (f *fakeFileInfo) Size() int64 { return f.size } +func (f *fakeFileInfo) ModTime() time.Time { return f.modTime } +func (f *fakeFileInfo) IsDir() bool { return f.isDir } +func (f *fakeFileInfo) Sys() any { return nil } + +// Mode returns a basic file mode for testing. If mode is explicitly set, +// it is returned directly; otherwise a default is derived from isDir. +func (f *fakeFileInfo) Mode() iofs.FileMode { + if f.mode != 0 { + return f.mode + } + if f.isDir { + return iofs.ModeDir | 0o755 + } + return 0o644 +} diff --git a/builtins/find/expr.go b/builtins/find/expr.go new file mode 100644 index 00000000..7e25394c --- /dev/null +++ b/builtins/find/expr.go @@ -0,0 +1,547 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package find + +import ( + "errors" + "fmt" + "math" + "path/filepath" + "strconv" + "strings" +) + +// AST limits to prevent resource exhaustion. +const ( + maxExprDepth = 64 + maxExprNodes = 256 +) + +// exprKind identifies the type of expression node. +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 +) + +// cmpOp represents a comparison operator for numeric predicates. +type cmpOp int + +const ( + cmpLess cmpOp = -1 + cmpExact cmpOp = 0 + cmpMore cmpOp = 1 +) + +func (c cmpOp) String() string { + switch c { + case cmpLess: + return "-N" + case cmpExact: + return "N" + case cmpMore: + return "+N" + default: + return "unknown" + } +} + +// sizeUnit holds a parsed -size predicate value. +type sizeUnit struct { + n int64 // magnitude (always positive) + cmp cmpOp // comparison operator + unit byte // one of: c w b k M G (default 'b' if omitted) +} + +// 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 +} + +// isAction returns true if this expression is an output action. +func (e *expr) isAction() bool { + return e.kind == exprPrint || e.kind == exprPrint0 +} + +// hasAction checks if any node in the expression tree is an action. +func hasAction(e *expr) bool { + if e == nil { + return false + } + if e.isAction() { + return true + } + return hasAction(e.left) || hasAction(e.right) || hasAction(e.operand) +} + +// parser is a recursive-descent parser for find expressions. +type parser struct { + args []string + pos int + depth int + nodes int + maxDepth int // -1 = not specified + minDepth int // -1 = not specified +} + +// parseResult holds the output of parseExpression. +type parseResult struct { + expr *expr + maxDepth int // -1 = not specified + minDepth int // -1 = not specified +} + +// 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", + "-fls": "file writes are blocked", + "-fprint": "file writes are blocked", + "-fprint0": "file writes are blocked", + "-fprintf": "file writes are blocked", + "-regex": "regular expressions are blocked (ReDoS risk)", + "-iregex": "regular expressions are blocked (ReDoS risk)", +} + +// parseExpression parses the find expression from args, including +// -maxdepth/-mindepth which are integrated into the recursive-descent parser. +// This avoids the argument-stealing problem: each predicate's own argument +// consumption naturally prevents depth options from capturing tokens that +// belong to other predicates (e.g. "find . -name -maxdepth" correctly treats +// "-maxdepth" as the -name pattern, not as a depth option). +func parseExpression(args []string) (parseResult, error) { + if len(args) == 0 { + return parseResult{maxDepth: -1, minDepth: -1}, nil + } + + p := &parser{args: args, maxDepth: -1, minDepth: -1} + e, err := p.parseOr() + if err != nil { + return parseResult{}, err + } + if p.pos < len(p.args) { + return parseResult{}, fmt.Errorf("find: unexpected argument '%s'", p.args[p.pos]) + } + return parseResult{expr: e, maxDepth: p.maxDepth, minDepth: p.minDepth}, nil +} + +func (p *parser) peek() string { + if p.pos >= len(p.args) { + return "" + } + return p.args[p.pos] +} + +func (p *parser) advance() string { + s := p.args[p.pos] + p.pos++ + return s +} + +func (p *parser) expect(s string) error { + if p.pos >= len(p.args) { + return fmt.Errorf("find: expected '%s'", s) + } + if p.args[p.pos] != s { + return fmt.Errorf("find: expected '%s', got '%s'", s, p.args[p.pos]) + } + p.pos++ + return nil +} + +func (p *parser) addNode() error { + p.nodes++ + if p.nodes > maxExprNodes { + return errors.New("find: expression too complex (too many nodes)") + } + return nil +} + +// parseOr handles: expr -o expr +func (p *parser) parseOr() (*expr, error) { + left, err := p.parseAnd() + if err != nil { + return nil, err + } + for p.peek() == "-o" || p.peek() == "-or" { + p.advance() + if err := p.addNode(); err != nil { + return nil, err + } + right, err := p.parseAnd() + if err != nil { + return nil, err + } + left = &expr{kind: exprOr, left: left, right: right} + } + return left, nil +} + +// parseAnd handles: expr -a expr or expr expr (implicit AND) +func (p *parser) parseAnd() (*expr, error) { + left, err := p.parseUnary() + if err != nil { + return nil, err + } + for { + tok := p.peek() + if tok == "-a" || tok == "-and" { + p.advance() + } else if tok == "" || tok == "-o" || tok == "-or" || tok == ")" { + break + } + if err := p.addNode(); err != nil { + return nil, err + } + right, err := p.parseUnary() + if err != nil { + return nil, err + } + left = &expr{kind: exprAnd, left: left, right: right} + } + return left, nil +} + +// parseUnary handles: ! expr or -not expr or ( expr ) or primary +func (p *parser) parseUnary() (*expr, error) { + tok := p.peek() + if tok == "!" || tok == "-not" { + p.advance() + p.depth++ + if p.depth > maxExprDepth { + return nil, errors.New("find: expression too deeply nested") + } + if err := p.addNode(); err != nil { + return nil, err + } + operand, err := p.parseUnary() + if err != nil { + return nil, err + } + p.depth-- + return &expr{kind: exprNot, operand: operand}, nil + } + if tok == "(" { + p.advance() + if p.peek() == ")" { + return nil, errors.New("find: invalid expression; empty parentheses are not allowed.") + } + p.depth++ + if p.depth > maxExprDepth { + return nil, errors.New("find: expression too deeply nested") + } + e, err := p.parseOr() + if err != nil { + return nil, err + } + p.depth-- + if err := p.expect(")"); err != nil { + return nil, err + } + return e, nil + } + return p.parsePrimary() +} + +// parsePrimary handles leaf predicates. +func (p *parser) parsePrimary() (*expr, error) { + if p.pos >= len(p.args) { + return nil, errors.New("find: expected expression") + } + + if err := p.addNode(); err != nil { + return nil, err + } + + tok := p.advance() + + // Check blocked predicates. + if reason, blocked := blockedPredicates[tok]; blocked { + return nil, fmt.Errorf("find: %s: %s", tok, reason) + } + + switch tok { + case "-name": + return p.parseStringPredicate(exprName) + case "-iname": + return p.parseStringPredicate(exprIName) + case "-path", "-wholename": + return p.parsePathPredicate(exprPath) + case "-ipath", "-iwholename": + return p.parsePathPredicate(exprIPath) + case "-type": + return p.parseTypePredicate() + case "-size": + return p.parseSizePredicate() + case "-empty": + return &expr{kind: exprEmpty}, nil + case "-newer": + return p.parsePathPredicate(exprNewer) + case "-mtime": + return p.parseNumericPredicate(exprMtime) + case "-mmin": + return p.parseNumericPredicate(exprMmin) + case "-print": + return &expr{kind: exprPrint}, nil + case "-print0": + return &expr{kind: exprPrint0}, nil + case "-prune": + return &expr{kind: exprPrune}, nil + case "-maxdepth": + return p.parseDepthOption(true) + case "-mindepth": + return p.parseDepthOption(false) + case "-true": + return &expr{kind: exprTrue}, nil + case "-false": + return &expr{kind: exprFalse}, nil + default: + return nil, fmt.Errorf("find: unknown predicate '%s'", tok) + } +} + +func (p *parser) parseStringPredicate(kind exprKind) (*expr, error) { + if p.pos >= len(p.args) { + return nil, fmt.Errorf("find: missing argument for %s", kind.String()) + } + val := p.advance() + return &expr{kind: kind, strVal: val}, nil +} + +// parsePathPredicate is like parseStringPredicate but normalizes the value +// with filepath.ToSlash so that backslash path separators on Windows are +// converted to forward slashes, matching the internal path representation. +// Used for -path, -ipath, and -newer (all of which take filesystem paths +// or path-glob patterns as arguments). +func (p *parser) parsePathPredicate(kind exprKind) (*expr, error) { + if p.pos >= len(p.args) { + return nil, fmt.Errorf("find: missing argument for %s", kind.String()) + } + val := filepath.ToSlash(p.advance()) + return &expr{kind: kind, strVal: val}, nil +} + +func (p *parser) parseTypePredicate() (*expr, error) { + if p.pos >= len(p.args) { + return nil, errors.New("find: missing argument for -type") + } + val := p.advance() + if len(val) == 0 { + return nil, errors.New("find: Unknown argument to -type: ") + } + // Validate type character(s). GNU find allows comma-separated types + // like "f,d" but rejects malformed lists like ",", "f,", ",d", or "fd". + expectType := true + for i := 0; i < len(val); i++ { + c := val[i] + if c == ',' { + if expectType { + // Leading or consecutive comma. + return nil, fmt.Errorf("find: Unknown argument to -type: %s", val) + } + expectType = true + continue + } + switch c { + case 'f', 'd', 'l', 'p', 's': + if !expectType { + // Adjacent type chars without comma (e.g. "fd"). + return nil, fmt.Errorf("find: Unknown argument to -type: %s", val) + } + expectType = false + default: + return nil, fmt.Errorf("find: Unknown argument to -type: %s", val) + } + } + if expectType { + // Trailing comma. + return nil, fmt.Errorf("find: Unknown argument to -type: %s", val) + } + return &expr{kind: exprType, strVal: val}, nil +} + +func (p *parser) parseSizePredicate() (*expr, error) { + if p.pos >= len(p.args) { + return nil, errors.New("find: missing argument for -size") + } + val := p.advance() + su, err := parseSize(val) + if err != nil { + return nil, err + } + return &expr{kind: exprSize, sizeVal: su}, nil +} + +func (p *parser) parseNumericPredicate(kind exprKind) (*expr, error) { + if p.pos >= len(p.args) { + return nil, fmt.Errorf("find: missing argument for %s", kind.String()) + } + val := p.advance() + cmp := cmpExact + numStr := val + if strings.HasPrefix(numStr, "+") { + cmp = cmpMore + numStr = numStr[1:] + } else if strings.HasPrefix(numStr, "-") { + cmp = cmpLess + numStr = numStr[1:] + } + n, err := strconv.ParseInt(numStr, 10, 64) + if err != nil { + // If the number overflows int64 but is otherwise valid, clamp to + // MaxInt64. The evaluation functions handle huge values correctly: + // +huge → nothing matches, -huge → everything matches, exact → no + // match. This matches GNU find behavior for very large arguments. + if errors.Is(err, strconv.ErrRange) { + n = math.MaxInt64 + err = nil + } + if err != nil { + return nil, fmt.Errorf("find: invalid argument '%s' to %s", val, kind.String()) + } + } + return &expr{kind: kind, numVal: n, numCmp: cmp}, nil +} + +func (p *parser) parseDepthOption(isMax bool) (*expr, error) { + name := "-mindepth" + if isMax { + name = "-maxdepth" + } + if p.pos >= len(p.args) { + return nil, fmt.Errorf("find: missing argument to '%s'", name) + } + val := p.advance() + // Reject non-decimal forms like "+1" or "-1" that strconv.Atoi accepts. + // GNU find requires a positive decimal integer. + if len(val) > 0 && (val[0] == '+' || val[0] == '-') { + return nil, fmt.Errorf("find: invalid argument '%s' to %s", val, name) + } + n, err := strconv.Atoi(val) + if err != nil || n < 0 { + return nil, fmt.Errorf("find: invalid argument '%s' to %s", val, name) + } + if isMax { + p.maxDepth = n + } else { + p.minDepth = n + } + return &expr{kind: exprTrue}, nil +} + +// parseSize parses a -size argument like "+10k", "-5M", "100c". +func parseSize(s string) (sizeUnit, error) { + if len(s) == 0 { + return sizeUnit{}, errors.New("find: invalid argument '' to -size") + } + var su sizeUnit + + numStr := s + if s[0] == '+' { + su.cmp = cmpMore + numStr = s[1:] + } else if s[0] == '-' { + su.cmp = cmpLess + numStr = s[1:] + } + + if len(numStr) == 0 { + return sizeUnit{}, fmt.Errorf("find: invalid argument '%s' to -size", s) + } + + // Check for unit suffix. + su.unit = 'b' // default: 512-byte blocks + last := numStr[len(numStr)-1] + switch last { + case 'c', 'w', 'b', 'k', 'M', 'G': + su.unit = last + numStr = numStr[:len(numStr)-1] + } + + if len(numStr) == 0 { + return sizeUnit{}, fmt.Errorf("find: invalid argument '%s' to -size", s) + } + + n, err := strconv.ParseInt(numStr, 10, 64) + if err != nil { + return sizeUnit{}, fmt.Errorf("find: invalid argument '%s' to -size", s) + } + if n < 0 { + return sizeUnit{}, fmt.Errorf("find: invalid argument '%s' to -size", s) + } + su.n = n + return su, nil +} + +func (k exprKind) String() string { + switch k { + case exprName: + return "-name" + case exprIName: + return "-iname" + case exprPath: + return "-path" + case exprIPath: + return "-ipath" + case exprType: + return "-type" + case exprSize: + return "-size" + case exprEmpty: + return "-empty" + case exprNewer: + return "-newer" + case exprMtime: + return "-mtime" + case exprMmin: + return "-mmin" + case exprPrint: + return "-print" + case exprPrint0: + return "-print0" + case exprPrune: + return "-prune" + case exprTrue: + return "-true" + case exprFalse: + return "-false" + case exprAnd: + return "-and" + case exprOr: + return "-or" + case exprNot: + return "-not" + default: + return "unknown" + } +} diff --git a/builtins/find/expr_test.go b/builtins/find/expr_test.go new file mode 100644 index 00000000..459ba5cc --- /dev/null +++ b/builtins/find/expr_test.go @@ -0,0 +1,232 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package find + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestParseDepthRejectsSignedValues verifies that -maxdepth/-mindepth reject +// +N and -N forms, matching GNU find's "positive decimal integer" requirement. +func TestParseDepthRejectsSignedValues(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + {"maxdepth 0", []string{"-maxdepth", "0"}, false}, + {"maxdepth 1", []string{"-maxdepth", "1"}, false}, + {"maxdepth 10", []string{"-maxdepth", "10"}, false}, + {"maxdepth +1 rejected", []string{"-maxdepth", "+1"}, true}, + {"maxdepth -1 rejected", []string{"-maxdepth", "-1"}, true}, + {"maxdepth +0 rejected", []string{"-maxdepth", "+0"}, true}, + {"mindepth 0", []string{"-mindepth", "0"}, false}, + {"mindepth +1 rejected", []string{"-mindepth", "+1"}, true}, + {"mindepth -1 rejected", []string{"-mindepth", "-1"}, true}, + {"maxdepth empty rejected", []string{"-maxdepth", ""}, true}, + {"maxdepth abc rejected", []string{"-maxdepth", "abc"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseExpression(tt.args) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestParseEmptyParens verifies that empty parentheses are rejected. +func TestParseEmptyParens(t *testing.T) { + _, err := parseExpression([]string{"(", ")"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty parentheses") +} + +// TestParseParensWithContent verifies that non-empty parentheses are accepted. +func TestParseParensWithContent(t *testing.T) { + pr, err := parseExpression([]string{"(", "-true", ")"}) + require.NoError(t, err) + assert.NotNil(t, pr.expr) +} + +// TestParseSizeEdgeCases covers size parsing edge cases. +func TestParseSizeEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + n int64 + cmp cmpOp + unit byte + }{ + {"simple bytes", "10c", false, 10, cmpExact, 'c'}, + {"plus kilobytes", "+5k", false, 5, cmpMore, 'k'}, + {"minus megabytes", "-3M", false, 3, cmpLess, 'M'}, + {"default 512-byte blocks", "100", false, 100, cmpExact, 'b'}, + {"zero bytes", "0c", false, 0, cmpExact, 'c'}, + {"gigabytes", "1G", false, 1, cmpExact, 'G'}, + {"word units", "10w", false, 10, cmpExact, 'w'}, + {"empty string", "", true, 0, 0, 0}, + {"just plus", "+", true, 0, 0, 0}, + {"just minus", "-", true, 0, 0, 0}, + {"just unit", "c", true, 0, 0, 0}, + {"invalid chars", "abc", true, 0, 0, 0}, + {"negative number", "-5c", false, 5, cmpLess, 'c'}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + su, err := parseSize(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.n, su.n) + assert.Equal(t, tt.cmp, su.cmp) + assert.Equal(t, tt.unit, su.unit) + } + }) + } +} + +// TestParsePathPredicateUsesParsePathPredicate verifies that -path, -ipath, +// -newer, -wholename, and -iwholename are routed through parsePathPredicate +// (which applies filepath.ToSlash). On Unix filepath.ToSlash is a no-op so +// we can only verify correct parsing here; actual backslash→slash conversion +// is exercised on Windows CI. +func TestParsePathPredicateUsesParsePathPredicate(t *testing.T) { + tests := []struct { + name string + args []string + kind exprKind + want string + }{ + {"path", []string{"-path", "dir/file"}, exprPath, "dir/file"}, + {"ipath", []string{"-ipath", "dir/file"}, exprIPath, "dir/file"}, + {"newer", []string{"-newer", "dir/ref.txt"}, exprNewer, "dir/ref.txt"}, + {"wholename alias", []string{"-wholename", "dir/file"}, exprPath, "dir/file"}, + {"iwholename alias", []string{"-iwholename", "dir/file"}, exprIPath, "dir/file"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pr, err := parseExpression(tt.args) + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, tt.kind, pr.expr.kind) + assert.Equal(t, tt.want, pr.expr.strVal) + }) + } + + // Verify -name and -iname do NOT go through parsePathPredicate + // (they match basenames only, no path separators to normalize). + t.Run("name is not path-normalized", func(t *testing.T) { + pr, err := parseExpression([]string{"-name", "*.txt"}) + require.NoError(t, err) + assert.Equal(t, exprName, pr.expr.kind) + assert.Equal(t, "*.txt", pr.expr.strVal) + }) + + // On Windows, filepath.ToSlash converts '\' to '/'. Verify that + // parsePathPredicate actually normalizes backslashes. This subtest + // is skipped on Unix where '\' is a valid filename character and + // filepath.ToSlash is a no-op. + if runtime.GOOS == "windows" { + windowsTests := []struct { + name string + args []string + kind exprKind + want string + }{ + {"path backslash", []string{"-path", `dir\sub\*.go`}, exprPath, "dir/sub/*.go"}, + {"ipath backslash", []string{"-ipath", `Dir\Sub\*.Go`}, exprIPath, "Dir/Sub/*.Go"}, + {"newer backslash", []string{"-newer", `dir\ref.txt`}, exprNewer, "dir/ref.txt"}, + {"wholename backslash", []string{"-wholename", `src\main.go`}, exprPath, "src/main.go"}, + {"iwholename backslash", []string{"-iwholename", `Src\Main.go`}, exprIPath, "Src/Main.go"}, + {"mixed separators", []string{"-path", `dir/sub\file.go`}, exprPath, "dir/sub/file.go"}, + {"multiple backslashes", []string{"-path", `a\b\c\d`}, exprPath, "a/b/c/d"}, + } + + for _, tt := range windowsTests { + t.Run("windows/"+tt.name, func(t *testing.T) { + pr, err := parseExpression(tt.args) + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, tt.kind, pr.expr.kind) + assert.Equal(t, tt.want, pr.expr.strVal) + }) + } + + // -name should NOT normalize backslashes even on Windows + // (basenames never contain path separators). + t.Run("windows/name not normalized", func(t *testing.T) { + pr, err := parseExpression([]string{"-name", `file\name`}) + require.NoError(t, err) + assert.Equal(t, `file\name`, pr.expr.strVal) + }) + } +} + +// TestParseBlockedPredicates verifies all dangerous predicates are blocked. +func TestParseBlockedPredicates(t *testing.T) { + blocked := []string{ + "-exec", "-execdir", "-delete", "-ok", "-okdir", + "-fls", "-fprint", "-fprint0", "-fprintf", + "-regex", "-iregex", + } + for _, pred := range blocked { + 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" { + args = append(args, "cmd", ";") + } + _, err := parseExpression(args) + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked") + }) + } +} + +// TestParseExpressionLimits verifies AST depth and node limits. +func TestParseExpressionLimits(t *testing.T) { + t.Run("depth limit", func(t *testing.T) { + // Build a deeply nested expression: ! ! ! ! ... -true + args := make([]string, 0, maxExprDepth+2) + for i := 0; i < maxExprDepth+1; i++ { + args = append(args, "!") + } + args = append(args, "-true") + _, err := parseExpression(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), "too deeply nested") + }) + + t.Run("node limit", func(t *testing.T) { + // Build a wide flat expression: -true -o -true -o -true ... + // Each "-true -o" pair adds nodes without increasing depth. + // We need maxExprNodes+1 leaf nodes to exceed the limit. + count := maxExprNodes + 1 + args := make([]string, 0, count*2) + for i := 0; i < count; i++ { + if i > 0 { + args = append(args, "-o") + } + args = append(args, "-true") + } + _, err := parseExpression(args) + require.Error(t, err) + assert.Contains(t, err.Error(), "too many nodes") + }) +} diff --git a/builtins/find/find.go b/builtins/find/find.go new file mode 100644 index 00000000..38bfffb4 --- /dev/null +++ b/builtins/find/find.go @@ -0,0 +1,522 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package find implements the find builtin command. +// +// find — search for files in a directory hierarchy +// +// Usage: find [-L] [PATH...] [EXPRESSION] +// +// Search the directory tree rooted at each PATH, evaluating the given +// EXPRESSION for each file found. If no PATH is given, the current +// directory (.) is used. If no EXPRESSION is given, -print is implied. +// +// Global options: +// +// -L Follow symbolic links. +// +// Supported predicates: +// +// -name PATTERN — basename matches shell glob PATTERN +// -iname PATTERN — like -name but case-insensitive +// -path PATTERN — full path matches shell glob PATTERN +// -ipath PATTERN — like -path but case-insensitive +// -type TYPE — file type: f (regular), d (directory), l (symlink), +// p (named pipe), s (socket). Comma-separated for OR. +// -size N[cwbkMG] — file size. +N = greater, -N = less, N = exact. +// -empty — empty regular file or directory +// -newer FILE — modified more recently than FILE +// -mtime N — modified N days ago (+N = more, -N = less) +// -mmin N — modified N minutes ago (+N = more, -N = less) +// -maxdepth N — descend at most N levels +// -mindepth N — apply tests only at depth >= N +// -print — print path followed by newline +// -print0 — print path followed by NUL +// -prune — skip directory subtree +// -true — always true +// -false — always false +// +// Operators: +// +// ( EXPR ) — grouping +// ! EXPR, -not EXPR — negation +// EXPR -a EXPR, EXPR -and EXPR, EXPR EXPR — conjunction (implicit) +// EXPR -o EXPR, EXPR -or EXPR — disjunction +// +// Blocked predicates (sandbox safety): +// +// -exec, -execdir, -delete, -ok, -okdir — execution/deletion +// -fls, -fprint, -fprint0, -fprintf — file writes +// -regex, -iregex — ReDoS risk +// +// Exit codes: +// +// 0 All paths searched successfully. +// 1 At least one error occurred. +package find + +import ( + "context" + "errors" + "io" + iofs "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/DataDog/rshell/builtins" +) + +// isNotExist checks whether an error represents a "not found" condition. +// The sandbox's PortablePathError wraps errors with errors.New(), stripping +// the fs.ErrNotExist sentinel, so we check both errors.Is and the string. +func isNotExist(err error) bool { + if os.IsNotExist(err) { + return true + } + // PortablePathError rewrites the inner error as a plain string; + // check for the canonical portable message. + var pe *os.PathError + if errors.As(err, &pe) { + return pe.Err.Error() == "no such file or directory" + } + return false +} + +// maxTraversalDepth limits directory recursion depth to prevent resource +// exhaustion. This is an intentional safety divergence from GNU find (which +// has no depth limit): the shell is designed for AI agent use where safety +// is the primary goal. When the user provides -maxdepth exceeding this +// limit, a warning is emitted and the value is clamped. Without -maxdepth, +// this cap applies silently as a defense-in-depth measure. +const maxTraversalDepth = 256 + +// Cmd is the find builtin command descriptor. +var Cmd = builtins.Command{Name: "find", MakeFlags: builtins.NoFlags(run)} + +func run(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + // Parse global options (-L) and separate paths from expression. + followLinks := false + i := 0 + + // Parse leading global options. +optLoop: + for i < len(args) { + switch args[i] { + case "-L": + followLinks = true + i++ + case "-P": + // -P overrides any earlier -L (last option wins). + followLinks = false + i++ + case "-H": + callCtx.Errf("find: -H is not supported\n") + return builtins.Result{Code: 1} + case "--": + i++ // consume --; stop option parsing + break optLoop + default: + break optLoop + } + } + + // Separate paths from expression. Paths are args before the first + // expression token (anything starting with - or ! or ( or )). + var paths []string + for i < len(args) { + arg := args[i] + if isExpressionStart(arg) { + break + } + paths = append(paths, filepath.ToSlash(arg)) + i++ + } + + if len(paths) == 0 { + paths = []string{"."} + } + + // Parse expression (includes -maxdepth/-mindepth as parser-recognized + // options). The recursive-descent parser naturally handles token ownership, + // so depth options can appear in any position without stealing arguments + // from other predicates. + exprArgs := args[i:] + pr, err := parseExpression(exprArgs) + if err != nil { + callCtx.Errf("%s\n", err.Error()) + return builtins.Result{Code: 1} + } + expression := pr.expr + + maxDepth := pr.maxDepth + if maxDepth < 0 { + maxDepth = maxTraversalDepth + } + if maxDepth > maxTraversalDepth { + callCtx.Errf("find: warning: -maxdepth %d exceeds safety limit %d; clamped to %d\n", maxDepth, maxTraversalDepth, maxTraversalDepth) + maxDepth = maxTraversalDepth + } + minDepth := pr.minDepth + if minDepth < 0 { + minDepth = 0 + } + + // If no explicit action, add implicit -print. + implicitPrint := expression == nil || !hasAction(expression) + + // Eagerly validate -newer reference paths before walking. + // GNU find reports missing reference files even if short-circuiting + // or -mindepth prevents the predicate from being evaluated. + // With -L, stat the reference (following symlinks) to get the target + // mtime; fall back to lstat for dangling symlinks. + failed := false + eagerNewerErrors := map[string]bool{} + seen := map[string]bool{} + for _, ref := range collectNewerRefs(expression) { + if seen[ref] { + continue + } + seen[ref] = true + if ref == "" { + callCtx.Errf("find: '': No such file or directory\n") + eagerNewerErrors[ref] = true + failed = true + continue + } + statRef := callCtx.LstatFile + if followLinks { + statRef = callCtx.StatFile + } + if _, err := statRef(ctx, ref); err != nil { + // With -L, stat fails on dangling symlinks — fall back to + // lstat so the symlink's own mtime can be used. Only fall + // back for "not found" errors; permission/sandbox errors + // must be reported. + if followLinks && isNotExist(err) { + if _, lerr := callCtx.LstatFile(ctx, ref); lerr == nil { + continue + } + } + callCtx.Errf("find: '%s': %s\n", ref, callCtx.PortableErr(err)) + eagerNewerErrors[ref] = true + failed = true + } + } + + // Capture invocation time once so -mtime/-mmin predicates use a + // consistent reference across all root paths (matches GNU find). + now := callCtx.Now() + + // GNU find treats a missing -newer reference as a fatal argument error + // and produces no result set, so skip the walk entirely. + if !failed { + for _, startPath := range paths { + if ctx.Err() != nil { + failed = true + break + } + // Reject empty path operands — GNU find treats "" as a + // non-existent path but continues walking remaining paths. + if startPath == "" { + callCtx.Errf("find: '': No such file or directory\n") + failed = true + continue + } + if walkPath(ctx, callCtx, startPath, walkOptions{ + expression: expression, + implicitPrint: implicitPrint, + followLinks: followLinks, + maxDepth: maxDepth, + minDepth: minDepth, + now: now, + eagerNewerErrors: eagerNewerErrors, + }) { + failed = true + } + } + } + + if failed { + return builtins.Result{Code: 1} + } + return builtins.Result{} +} + +// isExpressionStart returns true if the argument starts a find expression. +// GNU find treats any dash-prefixed token with length > 1 as an expression +// token (not a path), so `-1` is an unknown predicate, not a path argument. +func isExpressionStart(arg string) bool { + if arg == "!" || arg == "(" { + return true + } + return strings.HasPrefix(arg, "-") && len(arg) > 1 +} + +// walkOptions holds configuration for a single walkPath invocation. +type walkOptions struct { + expression *expr + implicitPrint bool + followLinks bool + maxDepth int + minDepth int + now time.Time + eagerNewerErrors map[string]bool +} + +// walkPath walks the directory tree rooted at startPath, evaluating the +// expression for each entry. Returns true if any error occurred. +func walkPath( + ctx context.Context, + callCtx *builtins.CallContext, + startPath string, + opts walkOptions, +) bool { + now := opts.now + failed := false + newerCache := map[string]time.Time{} + newerErrors := map[string]bool{} + for k, v := range opts.eagerNewerErrors { + newerErrors[k] = v + } + + // Stat the starting path. + var startInfo iofs.FileInfo + var err error + if opts.followLinks { + startInfo, err = callCtx.StatFile(ctx, startPath) + if err != nil && isNotExist(err) { + // Dangling symlink root: fall back to lstat like child entries. + // Only for "not found" — permission/sandbox errors are real. + startInfo, err = callCtx.LstatFile(ctx, startPath) + } + } else { + startInfo, err = callCtx.LstatFile(ctx, startPath) + } + if err != nil { + callCtx.Errf("find: '%s': %s\n", startPath, callCtx.PortableErr(err)) + return true + } + + // Cycle detection for -L mode: track ancestor directory identities + // (dev+inode on Unix, volume serial+file index on Windows) along the + // path from root to the current node. This correctly allows multiple + // symlinks to the same target (no ancestor cycle) while detecting + // actual loops. File identity is attempted per-entry; if it fails for + // a specific directory, we fall back to path-based ancestor tracking + // for that subtree. The maxTraversalDepth=256 cap remains as an + // ultimate safety bound. + + // dirIterator streams directory entries one at a time via ReadDir(1), + // keeping memory usage proportional to tree depth, not directory width. + type dirIterator struct { + dir iofs.ReadDirFile + parentPath string + depth int + ancestorIDs map[builtins.FileID]string + ancestorPaths map[string]bool + done bool + } + + // processEntry evaluates the expression for a single file entry. + // Returns (prune, isLoop). + processEntry := func(path string, info iofs.FileInfo, depth int, ancestorIDs map[builtins.FileID]string, ancestorPaths map[string]bool) (bool, bool, map[builtins.FileID]string, map[string]bool) { + // With -L, detect symlink loops BEFORE evaluating predicates. + var childAncestorIDs map[builtins.FileID]string + var childAncestorPaths map[string]bool + if info.IsDir() && opts.followLinks { + idOK := false + if callCtx.FileIdentity != nil { + if id, ok := callCtx.FileIdentity(path, info); ok { + idOK = true + if firstPath, seen := ancestorIDs[id]; seen { + callCtx.Errf("find: File system loop detected; '%s' is part of the same file system loop as '%s'.\n", + path, firstPath) + failed = true + return false, true, nil, nil + } + childAncestorIDs = make(map[builtins.FileID]string, len(ancestorIDs)+1) + for k, v := range ancestorIDs { + childAncestorIDs[k] = v + } + childAncestorIDs[id] = path + } + } + if !idOK { + if ancestorPaths[path] { + callCtx.Errf("find: File system loop detected; '%s' has already been visited.\n", path) + failed = true + return false, true, nil, nil + } + childAncestorPaths = make(map[string]bool, len(ancestorPaths)+1) + for k := range ancestorPaths { + childAncestorPaths[k] = true + } + childAncestorPaths[path] = true + } + } + + ec := &evalContext{ + callCtx: callCtx, + ctx: ctx, + now: now, + relPath: path, + info: info, + depth: depth, + printPath: path, + newerCache: newerCache, + newerErrors: newerErrors, + followLinks: opts.followLinks, + } + + prune := false + if depth >= opts.minDepth { + result := evaluate(ec, opts.expression) + prune = result.prune + if len(newerErrors) > 0 || ec.failed { + failed = true + } + if result.matched && opts.implicitPrint { + callCtx.Outf("%s\n", path) + } + } + + return prune, false, childAncestorIDs, childAncestorPaths + } + + // Process the starting path. + prune, isLoop, childAncIDs, childAncPaths := processEntry(startPath, startInfo, 0, nil, nil) + + // Set up the iterator stack. Each open directory keeps a file handle + // that reads one entry at a time, so memory is O(depth) not O(width). + var iterStack []*dirIterator + + if !isLoop && !prune && startInfo.IsDir() && 0 < opts.maxDepth { + dir, openErr := callCtx.OpenDir(ctx, startPath) + if openErr != nil { + callCtx.Errf("find: '%s': %s\n", startPath, callCtx.PortableErr(openErr)) + return true + } + iterStack = append(iterStack, &dirIterator{ + dir: dir, + parentPath: startPath, + depth: 1, + ancestorIDs: childAncIDs, + ancestorPaths: childAncPaths, + }) + } + + for len(iterStack) > 0 { + if ctx.Err() != nil { + failed = true + break + } + + top := iterStack[len(iterStack)-1] + if top.done { + top.dir.Close() + iterStack = iterStack[:len(iterStack)-1] + continue + } + + // Read one entry at a time from the directory. + dirEntries, readErr := top.dir.ReadDir(1) + if readErr != nil { + if readErr != io.EOF { + callCtx.Errf("find: '%s': %s\n", top.parentPath, callCtx.PortableErr(readErr)) + failed = true + } + top.done = true + continue + } + if len(dirEntries) == 0 { + top.done = true + continue + } + + child := dirEntries[0] + childPath := joinPath(top.parentPath, child.Name()) + + var childInfo iofs.FileInfo + if opts.followLinks { + childInfo, err = callCtx.StatFile(ctx, childPath) + if err != nil && isNotExist(err) { + // Dangling symlink: stat fails but lstat succeeds. + // Only for "not found" — permission/sandbox errors are real. + childInfo, err = callCtx.LstatFile(ctx, childPath) + } + if err != nil { + callCtx.Errf("find: '%s': %s\n", childPath, callCtx.PortableErr(err)) + failed = true + continue + } + } else { + childInfo, err = callCtx.LstatFile(ctx, childPath) + if err != nil { + callCtx.Errf("find: '%s': %s\n", childPath, callCtx.PortableErr(err)) + failed = true + continue + } + } + + prune, isLoop, cAncIDs, cAncPaths := processEntry(childPath, childInfo, top.depth, top.ancestorIDs, top.ancestorPaths) + if isLoop { + continue + } + + // Descend into child directories unless pruned or beyond maxdepth. + if childInfo.IsDir() && !prune && top.depth < opts.maxDepth { + dir, openErr := callCtx.OpenDir(ctx, childPath) + if openErr != nil { + callCtx.Errf("find: '%s': %s\n", childPath, callCtx.PortableErr(openErr)) + failed = true + continue + } + iterStack = append(iterStack, &dirIterator{ + dir: dir, + parentPath: childPath, + depth: top.depth + 1, + ancestorIDs: cAncIDs, + ancestorPaths: cAncPaths, + }) + } + } + + // Close any remaining open directory handles (e.g. on context cancellation). + for _, it := range iterStack { + it.dir.Close() + } + + return failed +} + +// collectNewerRefs walks the expression tree and returns all -newer reference paths. +func collectNewerRefs(e *expr) []string { + if e == nil { + return nil + } + if e.kind == exprNewer { + return []string{e.strVal} + } + var refs []string + refs = append(refs, collectNewerRefs(e.left)...) + refs = append(refs, collectNewerRefs(e.right)...) + refs = append(refs, collectNewerRefs(e.operand)...) + return refs +} + +// 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. +func joinPath(dir, name string) string { + if len(dir) == 0 { + return name + } + if dir[len(dir)-1] == '/' { + return dir + name + } + return dir + "/" + name +} diff --git a/builtins/find/find_test.go b/builtins/find/find_test.go new file mode 100644 index 00000000..c3a0e96d --- /dev/null +++ b/builtins/find/find_test.go @@ -0,0 +1,50 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package find + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestIsExpressionStart verifies the boundary between path operands and +// expression tokens. GNU find treats !, (, and any dash-prefixed token +// with length > 1 as expression starters. Everything else (including +// ")", "-", and plain words) is a path operand. +func TestIsExpressionStart(t *testing.T) { + tests := []struct { + arg string + want bool + }{ + // Expression starters + {"!", true}, + {"(", true}, + {"-name", true}, + {"-type", true}, + {"-maxdepth", true}, + {"-1", true}, // unknown predicate, but still expression + {"-a", true}, // short flag-like token + {"--", true}, // double dash, length > 1 and starts with - + + // Path operands (NOT expression starters) + {")", false}, // closing paren is a path, not expression + {"-", false}, // single dash is a path (length 1) + {".", false}, // current dir + {"..", false}, // parent dir + {"foo", false}, // plain word + {"/tmp", false}, // absolute path + {"dir/sub", false}, // relative path + {"", false}, // empty string + } + + for _, tt := range tests { + t.Run(tt.arg, func(t *testing.T) { + got := isExpressionStart(tt.arg) + assert.Equal(t, tt.want, got, "isExpressionStart(%q)", tt.arg) + }) + } +} diff --git a/builtins/find/match.go b/builtins/find/match.go new file mode 100644 index 00000000..c37bb95a --- /dev/null +++ b/builtins/find/match.go @@ -0,0 +1,300 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package find + +import ( + iofs "io/fs" + "math" + "strings" + "unicode/utf8" +) + +// matchGlob matches a name against a glob pattern. +// Uses pathGlobMatch which correctly handles [!...] negated character classes +// and treats malformed brackets (e.g. unclosed '[') as literal characters (or non-matching for incomplete ranges), +// matching GNU find's fnmatch() behaviour. +func matchGlob(pattern, name string) bool { + return pathGlobMatch(pattern, name) +} + +// matchGlobFold matches a name against a glob pattern case-insensitively. +func matchGlobFold(pattern, name string) bool { + return pathGlobMatch(strings.ToLower(pattern), strings.ToLower(name)) +} + +// matchType checks if a file's type matches the -type argument. +// typeArg may contain comma-separated types (GNU extension). +func matchType(info iofs.FileInfo, typeArg string) bool { + fileType := fileTypeChar(info) + + // Handle comma-separated types. + for _, c := range typeArg { + if c != ',' && byte(c) == fileType { + return true + } + } + return false +} + +// fileTypeChar returns the find type character for a file's mode. +// Accepts FileInfo (not FileMode) to avoid adding io/fs.FileMode to the +// import allowlist — matches the pattern used by ls.go. +func fileTypeChar(info iofs.FileInfo) byte { + mode := info.Mode() + switch { + case mode.IsRegular(): + return 'f' + case mode&iofs.ModeDir != 0: + return 'd' + case mode&iofs.ModeSymlink != 0: + return 'l' + case mode&iofs.ModeNamedPipe != 0: + return 'p' + case mode&iofs.ModeSocket != 0: + return 's' + default: + return '?' + } +} + +// sizeBlockSize returns the block size for rounding up in exact comparisons. +func sizeBlockSize(unit byte) int64 { + switch unit { + case 'c': + return 1 + case 'w': + return 2 + case 'b': + return 512 + case 'k': + return 1024 + case 'M': + return 1024 * 1024 + case 'G': + return 1024 * 1024 * 1024 + default: + return 512 + } +} + +// compareSize checks if fileSize matches the size predicate. +// GNU find rounds up to units for exact match: a 1-byte file is +0c, 1c, -2c. +func compareSize(fileSize int64, su sizeUnit) bool { + blockSz := sizeBlockSize(su.unit) + // Round file size up to the next block (ceiling division). + // Guard against overflow: (fileSize + blockSz - 1) can exceed MaxInt64 + // when fileSize is close to MaxInt64. + var fileBlocks int64 + if fileSize > 0 { + if blockSz == 1 { + fileBlocks = fileSize + } else if fileSize <= math.MaxInt64-blockSz+1 { + fileBlocks = (fileSize + blockSz - 1) / blockSz + } else { + // Overflow-safe ceiling division for very large file sizes. + fileBlocks = fileSize/blockSz + 1 + } + } + + switch su.cmp { + case cmpMore: // +n: strictly greater than n units + return fileBlocks > su.n + case cmpLess: // -n: strictly less than n units + return fileBlocks < su.n + default: // exactly n units + return fileBlocks == su.n + } +} + +// compareNumeric compares a value with the cmp operator. +func compareNumeric(actual, target int64, cmp cmpOp) bool { + switch cmp { + case cmpMore: // +n: strictly greater + return actual > target + case cmpLess: // -n: strictly less + return actual < target + default: // exactly n + return actual == target + } +} + +// baseName returns the last element of a path. +// Trailing slashes are stripped first so that "dir/" returns "dir", +// matching GNU find's behavior for -name/-iname matching. +// Only '/' is checked because the find builtin normalizes all input +// paths and predicate arguments to forward slashes via filepath.ToSlash +// at parse time, and joinPath produces '/'-separated child paths. +func baseName(p string) string { + // Strip trailing slashes (but keep at least one char for root "/"). + for len(p) > 1 && p[len(p)-1] == '/' { + p = p[:len(p)-1] + } + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '/' { + tail := p[i+1:] + if len(tail) == 0 { + // Root path "/" — return "/" as the basename. + return "/" + } + return tail + } + } + return p +} + +// matchPathGlob matches a full path against a glob pattern where '*' crosses +// '/' (FNM_PATHNAME-free). This matches GNU find's -path behaviour. +func matchPathGlob(pattern, name string) bool { + return pathGlobMatch(pattern, name) +} + +// matchPathGlobFold is like matchPathGlob but case-insensitive. +func matchPathGlobFold(pattern, name string) bool { + return pathGlobMatch(strings.ToLower(pattern), strings.ToLower(name)) +} + +// pathGlobMatch implements glob matching where '*' matches any character +// including '/', '?' matches exactly one rune including '/', and +// '[...]' character classes match runes as in path.Match. +func pathGlobMatch(pattern, name string) bool { + px, nx := 0, 0 + // nextPx/nextNx track the position to retry when a '*' fails to match. + nextPx, nextNx := 0, 0 + starActive := false + + for px < len(pattern) || nx < len(name) { + if px < len(pattern) { + switch pattern[px] { + case '*': + // '*' matches zero or more of any character (including '/'). + // Record restart point and try matching zero chars first. + starActive = true + nextPx = px + nextNx = nx + 1 + px++ + continue + case '?': + // '?' matches exactly one rune (including '/'). + if nx < len(name) { + _, w := utf8.DecodeRuneInString(name[nx:]) + px++ + nx += w + continue + } + case '[': + // Character class — delegate to matchClass for the class portion. + if nx < len(name) { + r, w := utf8.DecodeRuneInString(name[nx:]) + matched, patWidth := matchClass(pattern[px:], r) + if matched { + px += patWidth + nx += w + continue + } + // Malformed class (patWidth==0): fall back to literal or fail. + if patWidth == 0 && pattern[px] == name[nx] { + px++ + nx++ + continue + } + // Fatally malformed (patWidth==-1): pattern cannot match. + if patWidth == -1 { + return false + } + } + case '\\': + // Escape: next character is literal. + px++ + if px >= len(pattern) { + // Trailing backslash with no character to escape + // (dangling escape). GNU find's fnmatch treats this + // as non-matching, so fall through to backtrack/fail. + } else if nx < len(name) && pattern[px] == name[nx] { + px++ + nx++ + continue + } + default: + if nx < len(name) && pattern[px] == name[nx] { + px++ + nx++ + continue + } + } + } + // Current characters don't match. Backtrack to last '*' if possible. + if starActive && nextNx <= len(name) { + px = nextPx + 1 + nx = nextNx + nextNx++ + continue + } + return false + } + return true +} + +// matchClass tries to match a single rune against a bracket expression +// starting at pattern[0] == '['. Returns (matched, width) where width is +// the number of bytes consumed from pattern (including the closing ']'). +// On malformed classes returns (false, 0) for benign unclosed brackets +// (caller falls back to literal '[') or (false, -1) for incomplete ranges +// like "[a-" where the dash has no following character (caller treats as +// non-matching, per GNU fnmatch behavior). +func matchClass(pattern string, ch rune) (bool, int) { + if len(pattern) < 2 || pattern[0] != '[' { + return false, 0 + } + i := 1 + negate := false + if i < len(pattern) && pattern[i] == '^' { + negate = true + i++ + } else if i < len(pattern) && pattern[i] == '!' { + negate = true + i++ + } + matched := false + first := true + for i < len(pattern) { + if pattern[i] == ']' && !first { + i++ // consume ']' + if negate { + return !matched, i + } + return matched, i + } + first = false + // Handle backslash escaping inside bracket classes: + // \] matches literal ], \\ matches literal \, etc. + lo, loW := utf8.DecodeRuneInString(pattern[i:]) + if lo == '\\' && i+loW < len(pattern) { + lo, loW = utf8.DecodeRuneInString(pattern[i+loW:]) + i++ // skip the 1-byte backslash + } + i += loW + hi := lo + if i+1 < len(pattern) && pattern[i] == '-' && pattern[i+1] != ']' { + var hiW int + hi, hiW = utf8.DecodeRuneInString(pattern[i+1:]) + if hi == '\\' && i+1+hiW < len(pattern) { + hi, hiW = utf8.DecodeRuneInString(pattern[i+1+hiW:]) + i++ // skip the 1-byte backslash + } + i += 1 + hiW + } else if i < len(pattern) && pattern[i] == '-' && i+1 >= len(pattern) { + // Incomplete range: dash at end of pattern with no range-end + // character. GNU fnmatch treats this as non-matching rather + // than falling back to literal '['. + return false, -1 + } + if lo <= ch && ch <= hi { + matched = true + } + } + // Unclosed bracket — malformed. + return false, 0 +} diff --git a/builtins/find/match_test.go b/builtins/find/match_test.go new file mode 100644 index 00000000..efaabf80 --- /dev/null +++ b/builtins/find/match_test.go @@ -0,0 +1,191 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package find + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPathGlobMatchTrailingBackslash(t *testing.T) { + // A trailing backslash is a dangling escape (no character to escape). + // GNU find's fnmatch treats this as non-matching for any input, + // including a literal backslash character. + assert.False(t, pathGlobMatch(`abc\`, `abc\`)) + assert.False(t, pathGlobMatch(`abc\`, `abcd`)) + assert.False(t, pathGlobMatch(`abc\`, `abc`)) + assert.False(t, pathGlobMatch(`\`, `\`)) + assert.False(t, pathGlobMatch(`*\`, `abc\`)) + + // Properly escaped backslash (\\) DOES match a literal backslash. + assert.True(t, pathGlobMatch(`abc\\`, `abc\`)) + assert.True(t, pathGlobMatch(`\\`, `\`)) + assert.True(t, pathGlobMatch(`*\\`, `abc\`)) +} + +func TestMatchGlobMalformedBracket(t *testing.T) { + // Unclosed bracket patterns fall back to literal comparison. + assert.True(t, matchGlob("[", "[")) + assert.False(t, matchGlob("[", "a")) + assert.True(t, matchGlob("[abc", "[abc")) + assert.False(t, matchGlob("[abc", "a")) + + // Incomplete range (trailing dash) — non-matching per GNU fnmatch. + assert.False(t, matchGlob("[a-", "[a-")) + assert.False(t, matchGlob("[a-", "a")) + assert.False(t, matchGlob("[ab-", "[ab-")) +} + +func TestMatchGlobFoldMalformedBracket(t *testing.T) { + assert.True(t, matchGlobFold("[", "[")) + assert.False(t, matchGlobFold("[", "a")) + + // Incomplete range — non-matching. + assert.False(t, matchGlobFold("[a-", "[a-")) +} + +func TestBaseNameEdgeCases(t *testing.T) { + assert.Equal(t, "dir", baseName("dir")) + assert.Equal(t, "dir", baseName("dir/")) + assert.Equal(t, "dir", baseName("dir//")) + assert.Equal(t, "dir", baseName("/path/to/dir")) + assert.Equal(t, "dir", baseName("/path/to/dir/")) + assert.Equal(t, "/", baseName("/")) + assert.Equal(t, "/", baseName("///")) + assert.Equal(t, "file", baseName("file")) + assert.Equal(t, ".", baseName(".")) + assert.Equal(t, ".", baseName("./")) + assert.Equal(t, "b", baseName("a/b")) + assert.Equal(t, "b", baseName("a/b/")) +} + +func TestMatchClassEdgeCases(t *testing.T) { + // Valid class + matched, width := matchClass("[abc]", 'a') + assert.True(t, matched) + assert.Equal(t, 5, width) + + // Non-matching valid class + matched, width = matchClass("[abc]", 'z') + assert.False(t, matched) + assert.Equal(t, 5, width) + + // Negated class + matched, width = matchClass("[!abc]", 'z') + assert.True(t, matched) + assert.Equal(t, 6, width) + + matched, width = matchClass("[^abc]", 'a') + assert.False(t, matched) + assert.Equal(t, 6, width) + + // Range + matched, width = matchClass("[a-z]", 'm') + assert.True(t, matched) + assert.Equal(t, 5, width) + + matched, width = matchClass("[a-z]", 'A') + assert.False(t, matched) + assert.Equal(t, 5, width) + + // Malformed (unclosed) + matched, width = matchClass("[abc", 'a') + assert.False(t, matched) + assert.Equal(t, 0, width) + + // Single char "[" — too short + matched, width = matchClass("[", 'a') + assert.False(t, matched) + assert.Equal(t, 0, width) + + // "]" as first char in class (literal, not closing) + matched, width = matchClass("[]abc]", ']') + assert.True(t, matched) + assert.Equal(t, 6, width) + + // Backslash escape inside class: [\]] matches literal ] + matched, width = matchClass("[\\]]", ']') + assert.True(t, matched) + assert.Equal(t, 4, width) + + matched, width = matchClass("[\\]]", 'a') + assert.False(t, matched) + assert.Equal(t, 4, width) + + // Backslash escape: [a\]] matches a or ] + matched, width = matchClass("[a\\]]", ']') + assert.True(t, matched) + assert.Equal(t, 5, width) + + matched, width = matchClass("[a\\]]", 'a') + assert.True(t, matched) + assert.Equal(t, 5, width) + + // Backslash escape: [\\a] matches \ or a + matched, width = matchClass("[\\\\a]", '\\') + assert.True(t, matched) + assert.Equal(t, 5, width) + + matched, width = matchClass("[\\\\a]", 'a') + assert.True(t, matched) + assert.Equal(t, 5, width) + + matched, width = matchClass("[\\\\a]", 'z') + assert.False(t, matched) + assert.Equal(t, 5, width) + + // Escaped multi-byte character inside class: [\é] matches é + matched, width = matchClass(`[\é]`, 'é') + assert.True(t, matched) + assert.Equal(t, 5, width) // [ + \ + é(2 bytes) + ] = 5 + + matched, width = matchClass(`[\é]`, 'a') + assert.False(t, matched) + assert.Equal(t, 5, width) + + // Escaped multi-byte range endpoints: [\é-\ü] + matched, width = matchClass(`[\é-\ü]`, 'ö') // ö is between é and ü + assert.True(t, matched) + + matched, _ = matchClass(`[\é-\ü]`, 'a') + assert.False(t, matched) +} + +func TestCompareNumeric(t *testing.T) { + // Exact match + assert.True(t, compareNumeric(5, 5, cmpExact)) + assert.False(t, compareNumeric(5, 6, cmpExact)) + + // Greater than + assert.True(t, compareNumeric(6, 5, cmpMore)) + assert.False(t, compareNumeric(5, 5, cmpMore)) + assert.False(t, compareNumeric(4, 5, cmpMore)) + + // Less than + assert.True(t, compareNumeric(4, 5, cmpLess)) + assert.False(t, compareNumeric(5, 5, cmpLess)) + assert.False(t, compareNumeric(6, 5, cmpLess)) +} + +func TestPathGlobMatchMalformedBracket(t *testing.T) { + // Unclosed bracket patterns fall back to literal comparison. + assert.True(t, pathGlobMatch("[", "[")) + assert.False(t, pathGlobMatch("[", "a")) + assert.True(t, pathGlobMatch("dir/[sub/file", "dir/[sub/file")) + assert.False(t, pathGlobMatch("dir/[sub/file", "dir/asub/file")) + // Star followed by malformed bracket (backtracking interaction). + assert.True(t, pathGlobMatch("*/[", "dir/[")) + assert.False(t, pathGlobMatch("*/[", "dir/a")) + + // Incomplete range (trailing dash) — non-matching per GNU fnmatch. + assert.False(t, pathGlobMatch("[a-", "[a-")) + assert.False(t, pathGlobMatch("dir/[a-", "dir/[a-")) + + // Escaped multi-byte character in bracket class. + assert.True(t, pathGlobMatch(`[\é]`, "é")) + assert.False(t, pathGlobMatch(`[\é]`, "a")) +} diff --git a/builtins/find/now_test.go b/builtins/find/now_test.go new file mode 100644 index 00000000..a1bdedf0 --- /dev/null +++ b/builtins/find/now_test.go @@ -0,0 +1,78 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package find + +import ( + "bytes" + "context" + "io/fs" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins" +) + +// TestNowCalledOnce verifies that find captures the invocation timestamp +// once in run(), not per root path. GNU find evaluates -mtime/-mmin +// relative to a single invocation time, so multi-path invocations must +// use a consistent reference. +func TestNowCalledOnce(t *testing.T) { + // Create two directories with one file each. + tmp := t.TempDir() + dir1 := filepath.Join(tmp, "a") + dir2 := filepath.Join(tmp, "b") + require.NoError(t, os.MkdirAll(dir1, 0755)) + require.NoError(t, os.MkdirAll(dir2, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir1, "f1.txt"), []byte("x"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir2, "f2.txt"), []byte("y"), 0644)) + + var nowCalls atomic.Int32 + fixedNow := time.Now() + + var stdout, stderr bytes.Buffer + callCtx := &builtins.CallContext{ + Stdout: &stdout, + Stderr: &stderr, + Now: func() time.Time { + nowCalls.Add(1) + return fixedNow + }, + LstatFile: func(_ context.Context, path string) (fs.FileInfo, error) { + return os.Lstat(filepath.Join(tmp, path)) + }, + StatFile: func(_ context.Context, path string) (fs.FileInfo, error) { + return os.Stat(filepath.Join(tmp, path)) + }, + OpenDir: func(_ context.Context, path string) (fs.ReadDirFile, error) { + return os.Open(filepath.Join(tmp, path)) + }, + IsDirEmpty: func(_ context.Context, path string) (bool, error) { + entries, err := os.ReadDir(filepath.Join(tmp, path)) + if err != nil { + return false, err + } + return len(entries) == 0, nil + }, + PortableErr: func(err error) string { + return err.Error() + }, + } + + // Run find with two root paths and a time predicate. + result := run(context.Background(), callCtx, []string{"a", "b", "-mmin", "-60"}) + + assert.Equal(t, uint8(0), result.Code, "find should succeed") + assert.Equal(t, int32(1), nowCalls.Load(), + "Now() should be called exactly once per find invocation, not per root path") + assert.Contains(t, stdout.String(), "f1.txt") + assert.Contains(t, stdout.String(), "f2.txt") +} diff --git a/interp/allowed_paths_internal_test.go b/interp/allowed_paths_internal_test.go index 59a7ee61..bbdcf103 100644 --- a/interp/allowed_paths_internal_test.go +++ b/interp/allowed_paths_internal_test.go @@ -97,12 +97,36 @@ func TestAllowedPathsExecNonexistent(t *testing.T) { func TestAllowedPathsExecViaPathLookup(t *testing.T) { dir := t.TempDir() - // "find" is resolved via PATH (not absolute), but /bin and /usr are not allowed - _, stderr, exitCode := runScriptInternal(t, `find`, dir, + // "date" exists on PATH but /bin and /usr are not in AllowedPaths. + // The default noExecHandler must reject it. We avoid runScriptInternal + // because it overrides execHandler with a real exec.Command, bypassing + // the sandbox. We also cannot use a builtin name (find, grep, sed, etc.) + // because builtins are resolved before the exec handler is consulted. + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader("date"), "") + require.NoError(t, err) + + var outBuf, errBuf bytes.Buffer + runner, err := New( + StdIO(nil, &outBuf, &errBuf), AllowedPaths([]string{dir}), ) + require.NoError(t, err) + defer runner.Close() + runner.Dir = dir + + err = runner.Run(context.Background(), prog) + exitCode := 0 + if err != nil { + var es ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected error: %v", err) + } + } assert.Equal(t, 127, exitCode) - assert.Contains(t, stderr, "command not found") + assert.Contains(t, errBuf.String(), "command not found") } func TestAllowedPathsExecSymlinkEscape(t *testing.T) { diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 7882d054..3c266c25 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -16,6 +16,7 @@ import ( "github.com/DataDog/rshell/builtins/echo" "github.com/DataDog/rshell/builtins/exit" falsecmd "github.com/DataDog/rshell/builtins/false" + "github.com/DataDog/rshell/builtins/find" "github.com/DataDog/rshell/builtins/grep" "github.com/DataDog/rshell/builtins/head" "github.com/DataDog/rshell/builtins/ls" @@ -43,6 +44,7 @@ func registerBuiltins() { echo.Cmd, exit.Cmd, falsecmd.Cmd, + find.Cmd, grep.Cmd, head.Cmd, ls.Cmd, diff --git a/interp/runner_exec.go b/interp/runner_exec.go index e99f7bce..a4538970 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -11,6 +11,7 @@ import ( "io" "io/fs" "os" + "path/filepath" "sync" "time" @@ -262,6 +263,12 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { ReadDir: func(ctx context.Context, path string) ([]fs.DirEntry, error) { return r.sandbox.ReadDir(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) }, + OpenDir: func(ctx context.Context, path string) (fs.ReadDirFile, error) { + return r.sandbox.OpenDir(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) + }, + IsDirEmpty: func(ctx context.Context, path string) (bool, error) { + return r.sandbox.IsDirEmpty(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) + }, ReadDirLimited: func(ctx context.Context, path string, offset, maxRead int) ([]fs.DirEntry, bool, error) { return r.sandbox.ReadDirLimited(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir, offset, maxRead) }, @@ -276,6 +283,17 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { }, 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(r.Dir, absPath) + } + dev, ino, ok := allowedpaths.FileIdentity(absPath, info, r.sandbox) + if !ok { + return builtins.FileID{}, false + } + return builtins.FileID{Dev: dev, Ino: ino}, true + }, } 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/basic/dash_as_path.yaml b/tests/scenarios/cmd/find/basic/dash_as_path.yaml new file mode 100644 index 00000000..4330e117 --- /dev/null +++ b/tests/scenarios/cmd/find/basic/dash_as_path.yaml @@ -0,0 +1,16 @@ +description: find treats a single '-' as a path operand, not an expression token. +setup: + files: + - path: "-/file.txt" + content: "inside dash dir" +input: + allowed_paths: ["$DIR"] + script: |+ + find "-" -maxdepth 0 + find "-" -type f +expect: + stdout: |+ + - + -/file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/double_dash.yaml b/tests/scenarios/cmd/find/basic/double_dash.yaml new file mode 100644 index 00000000..f373acd4 --- /dev/null +++ b/tests/scenarios/cmd/find/basic/double_dash.yaml @@ -0,0 +1,14 @@ +description: find -- terminates global options, remaining args are paths. +setup: + files: + - path: dir/file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + find -- dir -type f +expect: + stdout_unordered: |+ + dir/file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/explicit_path.yaml b/tests/scenarios/cmd/find/basic/explicit_path.yaml new file mode 100644 index 00000000..db40271b --- /dev/null +++ b/tests/scenarios/cmd/find/basic/explicit_path.yaml @@ -0,0 +1,20 @@ +description: find with an explicit path lists the tree rooted at that path. +setup: + files: + - path: mydir/file1.txt + content: "a" + chmod: 0644 + - path: mydir/file2.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find mydir +expect: + stdout_unordered: |+ + mydir + mydir/file1.txt + mydir/file2.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/multiple_paths.yaml b/tests/scenarios/cmd/find/basic/multiple_paths.yaml new file mode 100644 index 00000000..14554364 --- /dev/null +++ b/tests/scenarios/cmd/find/basic/multiple_paths.yaml @@ -0,0 +1,19 @@ +description: find with multiple starting paths. +setup: + files: + - path: dir1/a.txt + content: "a" + chmod: 0644 + - path: dir2/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir1 dir2 -type f +expect: + stdout: |+ + dir1/a.txt + dir2/b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/nested_dirs.yaml b/tests/scenarios/cmd/find/basic/nested_dirs.yaml new file mode 100644 index 00000000..376f5402 --- /dev/null +++ b/tests/scenarios/cmd/find/basic/nested_dirs.yaml @@ -0,0 +1,21 @@ +description: find recurses into nested directories. +setup: + files: + - path: a/b/c.txt + content: "deep" + chmod: 0644 + - path: a/d.txt + content: "shallow" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a +expect: + stdout_unordered: |+ + a + a/b + a/b/c.txt + a/d.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/no_args.yaml b/tests/scenarios/cmd/find/basic/no_args.yaml new file mode 100644 index 00000000..289188cc --- /dev/null +++ b/tests/scenarios/cmd/find/basic/no_args.yaml @@ -0,0 +1,20 @@ +description: find with no args searches current directory. +setup: + files: + - path: a.txt + content: "hello" + chmod: 0644 + - path: b.txt + content: "world" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find +expect: + stdout_unordered: |+ + . + ./a.txt + ./b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/paren_as_path.yaml b/tests/scenarios/cmd/find/basic/paren_as_path.yaml new file mode 100644 index 00000000..81e5c1f1 --- /dev/null +++ b/tests/scenarios/cmd/find/basic/paren_as_path.yaml @@ -0,0 +1,19 @@ +description: find treats ')' as a path operand, not an expression token. +setup: + files: + - path: ")/file.txt" + content: "inside paren dir" +input: + allowed_paths: ["$DIR"] + script: |+ + # ')' in path position should be treated as a directory name + find ")" -maxdepth 0 + + # Also works with expressions after the path + find ")" -type f +expect: + stdout: |+ + ) + )/file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/path_with_spaces.yaml b/tests/scenarios/cmd/find/basic/path_with_spaces.yaml new file mode 100644 index 00000000..6e544274 --- /dev/null +++ b/tests/scenarios/cmd/find/basic/path_with_spaces.yaml @@ -0,0 +1,18 @@ +description: find handles paths with spaces correctly. +skip_assert_against_bash: true +setup: + files: + - path: "my dir/sub dir/file.txt" + content: "data" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find 'my dir' +expect: + stdout_unordered: |+ + my dir + my dir/sub dir + my dir/sub dir/file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/single_file_path.yaml b/tests/scenarios/cmd/find/basic/single_file_path.yaml new file mode 100644 index 00000000..325279dc --- /dev/null +++ b/tests/scenarios/cmd/find/basic/single_file_path.yaml @@ -0,0 +1,16 @@ +description: find with a file as starting path lists just that file. +setup: + files: + - path: dir/file.txt + content: "data" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir/file.txt +expect: + stdout: |+ + dir/file.txt + stderr: "" + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/find/basic/stress_wide_deep.yaml b/tests/scenarios/cmd/find/basic/stress_wide_deep.yaml new file mode 100644 index 00000000..e17b4516 --- /dev/null +++ b/tests/scenarios/cmd/find/basic/stress_wide_deep.yaml @@ -0,0 +1,1731 @@ +description: stress test find with a wide and deep directory tree (10 dirs x 43 files = 430 files) +setup: + files: + - path: root/d00/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/f02.txt + content: "x" + chmod: 0644 + - path: root/d00/f03.txt + content: "x" + chmod: 0644 + - path: root/d00/f04.txt + content: "x" + chmod: 0644 + - path: root/d00/f05.txt + content: "x" + chmod: 0644 + - path: root/d00/f06.txt + content: "x" + chmod: 0644 + - path: root/d00/f07.txt + content: "x" + chmod: 0644 + - path: root/d00/f08.txt + content: "x" + chmod: 0644 + - path: root/d00/f09.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d00/s2/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/f03.txt + content: "x" + chmod: 0644 + - path: root/d01/f04.txt + content: "x" + chmod: 0644 + - path: root/d01/f05.txt + content: "x" + chmod: 0644 + - path: root/d01/f06.txt + content: "x" + chmod: 0644 + - path: root/d01/f07.txt + content: "x" + chmod: 0644 + - path: root/d01/f08.txt + content: "x" + chmod: 0644 + - path: root/d01/f09.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d01/s2/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/f03.txt + content: "x" + chmod: 0644 + - path: root/d02/f04.txt + content: "x" + chmod: 0644 + - path: root/d02/f05.txt + content: "x" + chmod: 0644 + - path: root/d02/f06.txt + content: "x" + chmod: 0644 + - path: root/d02/f07.txt + content: "x" + chmod: 0644 + - path: root/d02/f08.txt + content: "x" + chmod: 0644 + - path: root/d02/f09.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d02/s2/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/f03.txt + content: "x" + chmod: 0644 + - path: root/d03/f04.txt + content: "x" + chmod: 0644 + - path: root/d03/f05.txt + content: "x" + chmod: 0644 + - path: root/d03/f06.txt + content: "x" + chmod: 0644 + - path: root/d03/f07.txt + content: "x" + chmod: 0644 + - path: root/d03/f08.txt + content: "x" + chmod: 0644 + - path: root/d03/f09.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d03/s2/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/f03.txt + content: "x" + chmod: 0644 + - path: root/d04/f04.txt + content: "x" + chmod: 0644 + - path: root/d04/f05.txt + content: "x" + chmod: 0644 + - path: root/d04/f06.txt + content: "x" + chmod: 0644 + - path: root/d04/f07.txt + content: "x" + chmod: 0644 + - path: root/d04/f08.txt + content: "x" + chmod: 0644 + - path: root/d04/f09.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d04/s2/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/f03.txt + content: "x" + chmod: 0644 + - path: root/d05/f04.txt + content: "x" + chmod: 0644 + - path: root/d05/f05.txt + content: "x" + chmod: 0644 + - path: root/d05/f06.txt + content: "x" + chmod: 0644 + - path: root/d05/f07.txt + content: "x" + chmod: 0644 + - path: root/d05/f08.txt + content: "x" + chmod: 0644 + - path: root/d05/f09.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d05/s2/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/f03.txt + content: "x" + chmod: 0644 + - path: root/d06/f04.txt + content: "x" + chmod: 0644 + - path: root/d06/f05.txt + content: "x" + chmod: 0644 + - path: root/d06/f06.txt + content: "x" + chmod: 0644 + - path: root/d06/f07.txt + content: "x" + chmod: 0644 + - path: root/d06/f08.txt + content: "x" + chmod: 0644 + - path: root/d06/f09.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d06/s2/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/f03.txt + content: "x" + chmod: 0644 + - path: root/d07/f04.txt + content: "x" + chmod: 0644 + - path: root/d07/f05.txt + content: "x" + chmod: 0644 + - path: root/d07/f06.txt + content: "x" + chmod: 0644 + - path: root/d07/f07.txt + content: "x" + chmod: 0644 + - path: root/d07/f08.txt + content: "x" + chmod: 0644 + - path: root/d07/f09.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d07/s2/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/f03.txt + content: "x" + chmod: 0644 + - path: root/d08/f04.txt + content: "x" + chmod: 0644 + - path: root/d08/f05.txt + content: "x" + chmod: 0644 + - path: root/d08/f06.txt + content: "x" + chmod: 0644 + - path: root/d08/f07.txt + content: "x" + chmod: 0644 + - path: root/d08/f08.txt + content: "x" + chmod: 0644 + - path: root/d08/f09.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d08/s2/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/f03.txt + content: "x" + chmod: 0644 + - path: root/d09/f04.txt + content: "x" + chmod: 0644 + - path: root/d09/f05.txt + content: "x" + chmod: 0644 + - path: root/d09/f06.txt + content: "x" + chmod: 0644 + - path: root/d09/f07.txt + content: "x" + chmod: 0644 + - path: root/d09/f08.txt + content: "x" + chmod: 0644 + - path: root/d09/f09.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/f03.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/f04.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/s0/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/f03.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/f04.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/s1/t1/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/f03.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/f04.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/t0/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/t0/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/t0/f02.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/t1/f00.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/t1/f01.txt + content: "x" + chmod: 0644 + - path: root/d09/s2/t1/f02.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find root -type f +expect: + stdout_unordered: |+ + root/d00/f00.txt + root/d00/f01.txt + root/d00/f02.txt + root/d00/f03.txt + root/d00/f04.txt + root/d00/f05.txt + root/d00/f06.txt + root/d00/f07.txt + root/d00/f08.txt + root/d00/f09.txt + root/d00/s0/f00.txt + root/d00/s0/f01.txt + root/d00/s0/f02.txt + root/d00/s0/f03.txt + root/d00/s0/f04.txt + root/d00/s0/t0/f00.txt + root/d00/s0/t0/f01.txt + root/d00/s0/t0/f02.txt + root/d00/s0/t1/f00.txt + root/d00/s0/t1/f01.txt + root/d00/s0/t1/f02.txt + root/d00/s1/f00.txt + root/d00/s1/f01.txt + root/d00/s1/f02.txt + root/d00/s1/f03.txt + root/d00/s1/f04.txt + root/d00/s1/t0/f00.txt + root/d00/s1/t0/f01.txt + root/d00/s1/t0/f02.txt + root/d00/s1/t1/f00.txt + root/d00/s1/t1/f01.txt + root/d00/s1/t1/f02.txt + root/d00/s2/f00.txt + root/d00/s2/f01.txt + root/d00/s2/f02.txt + root/d00/s2/f03.txt + root/d00/s2/f04.txt + root/d00/s2/t0/f00.txt + root/d00/s2/t0/f01.txt + root/d00/s2/t0/f02.txt + root/d00/s2/t1/f00.txt + root/d00/s2/t1/f01.txt + root/d00/s2/t1/f02.txt + root/d01/f00.txt + root/d01/f01.txt + root/d01/f02.txt + root/d01/f03.txt + root/d01/f04.txt + root/d01/f05.txt + root/d01/f06.txt + root/d01/f07.txt + root/d01/f08.txt + root/d01/f09.txt + root/d01/s0/f00.txt + root/d01/s0/f01.txt + root/d01/s0/f02.txt + root/d01/s0/f03.txt + root/d01/s0/f04.txt + root/d01/s0/t0/f00.txt + root/d01/s0/t0/f01.txt + root/d01/s0/t0/f02.txt + root/d01/s0/t1/f00.txt + root/d01/s0/t1/f01.txt + root/d01/s0/t1/f02.txt + root/d01/s1/f00.txt + root/d01/s1/f01.txt + root/d01/s1/f02.txt + root/d01/s1/f03.txt + root/d01/s1/f04.txt + root/d01/s1/t0/f00.txt + root/d01/s1/t0/f01.txt + root/d01/s1/t0/f02.txt + root/d01/s1/t1/f00.txt + root/d01/s1/t1/f01.txt + root/d01/s1/t1/f02.txt + root/d01/s2/f00.txt + root/d01/s2/f01.txt + root/d01/s2/f02.txt + root/d01/s2/f03.txt + root/d01/s2/f04.txt + root/d01/s2/t0/f00.txt + root/d01/s2/t0/f01.txt + root/d01/s2/t0/f02.txt + root/d01/s2/t1/f00.txt + root/d01/s2/t1/f01.txt + root/d01/s2/t1/f02.txt + root/d02/f00.txt + root/d02/f01.txt + root/d02/f02.txt + root/d02/f03.txt + root/d02/f04.txt + root/d02/f05.txt + root/d02/f06.txt + root/d02/f07.txt + root/d02/f08.txt + root/d02/f09.txt + root/d02/s0/f00.txt + root/d02/s0/f01.txt + root/d02/s0/f02.txt + root/d02/s0/f03.txt + root/d02/s0/f04.txt + root/d02/s0/t0/f00.txt + root/d02/s0/t0/f01.txt + root/d02/s0/t0/f02.txt + root/d02/s0/t1/f00.txt + root/d02/s0/t1/f01.txt + root/d02/s0/t1/f02.txt + root/d02/s1/f00.txt + root/d02/s1/f01.txt + root/d02/s1/f02.txt + root/d02/s1/f03.txt + root/d02/s1/f04.txt + root/d02/s1/t0/f00.txt + root/d02/s1/t0/f01.txt + root/d02/s1/t0/f02.txt + root/d02/s1/t1/f00.txt + root/d02/s1/t1/f01.txt + root/d02/s1/t1/f02.txt + root/d02/s2/f00.txt + root/d02/s2/f01.txt + root/d02/s2/f02.txt + root/d02/s2/f03.txt + root/d02/s2/f04.txt + root/d02/s2/t0/f00.txt + root/d02/s2/t0/f01.txt + root/d02/s2/t0/f02.txt + root/d02/s2/t1/f00.txt + root/d02/s2/t1/f01.txt + root/d02/s2/t1/f02.txt + root/d03/f00.txt + root/d03/f01.txt + root/d03/f02.txt + root/d03/f03.txt + root/d03/f04.txt + root/d03/f05.txt + root/d03/f06.txt + root/d03/f07.txt + root/d03/f08.txt + root/d03/f09.txt + root/d03/s0/f00.txt + root/d03/s0/f01.txt + root/d03/s0/f02.txt + root/d03/s0/f03.txt + root/d03/s0/f04.txt + root/d03/s0/t0/f00.txt + root/d03/s0/t0/f01.txt + root/d03/s0/t0/f02.txt + root/d03/s0/t1/f00.txt + root/d03/s0/t1/f01.txt + root/d03/s0/t1/f02.txt + root/d03/s1/f00.txt + root/d03/s1/f01.txt + root/d03/s1/f02.txt + root/d03/s1/f03.txt + root/d03/s1/f04.txt + root/d03/s1/t0/f00.txt + root/d03/s1/t0/f01.txt + root/d03/s1/t0/f02.txt + root/d03/s1/t1/f00.txt + root/d03/s1/t1/f01.txt + root/d03/s1/t1/f02.txt + root/d03/s2/f00.txt + root/d03/s2/f01.txt + root/d03/s2/f02.txt + root/d03/s2/f03.txt + root/d03/s2/f04.txt + root/d03/s2/t0/f00.txt + root/d03/s2/t0/f01.txt + root/d03/s2/t0/f02.txt + root/d03/s2/t1/f00.txt + root/d03/s2/t1/f01.txt + root/d03/s2/t1/f02.txt + root/d04/f00.txt + root/d04/f01.txt + root/d04/f02.txt + root/d04/f03.txt + root/d04/f04.txt + root/d04/f05.txt + root/d04/f06.txt + root/d04/f07.txt + root/d04/f08.txt + root/d04/f09.txt + root/d04/s0/f00.txt + root/d04/s0/f01.txt + root/d04/s0/f02.txt + root/d04/s0/f03.txt + root/d04/s0/f04.txt + root/d04/s0/t0/f00.txt + root/d04/s0/t0/f01.txt + root/d04/s0/t0/f02.txt + root/d04/s0/t1/f00.txt + root/d04/s0/t1/f01.txt + root/d04/s0/t1/f02.txt + root/d04/s1/f00.txt + root/d04/s1/f01.txt + root/d04/s1/f02.txt + root/d04/s1/f03.txt + root/d04/s1/f04.txt + root/d04/s1/t0/f00.txt + root/d04/s1/t0/f01.txt + root/d04/s1/t0/f02.txt + root/d04/s1/t1/f00.txt + root/d04/s1/t1/f01.txt + root/d04/s1/t1/f02.txt + root/d04/s2/f00.txt + root/d04/s2/f01.txt + root/d04/s2/f02.txt + root/d04/s2/f03.txt + root/d04/s2/f04.txt + root/d04/s2/t0/f00.txt + root/d04/s2/t0/f01.txt + root/d04/s2/t0/f02.txt + root/d04/s2/t1/f00.txt + root/d04/s2/t1/f01.txt + root/d04/s2/t1/f02.txt + root/d05/f00.txt + root/d05/f01.txt + root/d05/f02.txt + root/d05/f03.txt + root/d05/f04.txt + root/d05/f05.txt + root/d05/f06.txt + root/d05/f07.txt + root/d05/f08.txt + root/d05/f09.txt + root/d05/s0/f00.txt + root/d05/s0/f01.txt + root/d05/s0/f02.txt + root/d05/s0/f03.txt + root/d05/s0/f04.txt + root/d05/s0/t0/f00.txt + root/d05/s0/t0/f01.txt + root/d05/s0/t0/f02.txt + root/d05/s0/t1/f00.txt + root/d05/s0/t1/f01.txt + root/d05/s0/t1/f02.txt + root/d05/s1/f00.txt + root/d05/s1/f01.txt + root/d05/s1/f02.txt + root/d05/s1/f03.txt + root/d05/s1/f04.txt + root/d05/s1/t0/f00.txt + root/d05/s1/t0/f01.txt + root/d05/s1/t0/f02.txt + root/d05/s1/t1/f00.txt + root/d05/s1/t1/f01.txt + root/d05/s1/t1/f02.txt + root/d05/s2/f00.txt + root/d05/s2/f01.txt + root/d05/s2/f02.txt + root/d05/s2/f03.txt + root/d05/s2/f04.txt + root/d05/s2/t0/f00.txt + root/d05/s2/t0/f01.txt + root/d05/s2/t0/f02.txt + root/d05/s2/t1/f00.txt + root/d05/s2/t1/f01.txt + root/d05/s2/t1/f02.txt + root/d06/f00.txt + root/d06/f01.txt + root/d06/f02.txt + root/d06/f03.txt + root/d06/f04.txt + root/d06/f05.txt + root/d06/f06.txt + root/d06/f07.txt + root/d06/f08.txt + root/d06/f09.txt + root/d06/s0/f00.txt + root/d06/s0/f01.txt + root/d06/s0/f02.txt + root/d06/s0/f03.txt + root/d06/s0/f04.txt + root/d06/s0/t0/f00.txt + root/d06/s0/t0/f01.txt + root/d06/s0/t0/f02.txt + root/d06/s0/t1/f00.txt + root/d06/s0/t1/f01.txt + root/d06/s0/t1/f02.txt + root/d06/s1/f00.txt + root/d06/s1/f01.txt + root/d06/s1/f02.txt + root/d06/s1/f03.txt + root/d06/s1/f04.txt + root/d06/s1/t0/f00.txt + root/d06/s1/t0/f01.txt + root/d06/s1/t0/f02.txt + root/d06/s1/t1/f00.txt + root/d06/s1/t1/f01.txt + root/d06/s1/t1/f02.txt + root/d06/s2/f00.txt + root/d06/s2/f01.txt + root/d06/s2/f02.txt + root/d06/s2/f03.txt + root/d06/s2/f04.txt + root/d06/s2/t0/f00.txt + root/d06/s2/t0/f01.txt + root/d06/s2/t0/f02.txt + root/d06/s2/t1/f00.txt + root/d06/s2/t1/f01.txt + root/d06/s2/t1/f02.txt + root/d07/f00.txt + root/d07/f01.txt + root/d07/f02.txt + root/d07/f03.txt + root/d07/f04.txt + root/d07/f05.txt + root/d07/f06.txt + root/d07/f07.txt + root/d07/f08.txt + root/d07/f09.txt + root/d07/s0/f00.txt + root/d07/s0/f01.txt + root/d07/s0/f02.txt + root/d07/s0/f03.txt + root/d07/s0/f04.txt + root/d07/s0/t0/f00.txt + root/d07/s0/t0/f01.txt + root/d07/s0/t0/f02.txt + root/d07/s0/t1/f00.txt + root/d07/s0/t1/f01.txt + root/d07/s0/t1/f02.txt + root/d07/s1/f00.txt + root/d07/s1/f01.txt + root/d07/s1/f02.txt + root/d07/s1/f03.txt + root/d07/s1/f04.txt + root/d07/s1/t0/f00.txt + root/d07/s1/t0/f01.txt + root/d07/s1/t0/f02.txt + root/d07/s1/t1/f00.txt + root/d07/s1/t1/f01.txt + root/d07/s1/t1/f02.txt + root/d07/s2/f00.txt + root/d07/s2/f01.txt + root/d07/s2/f02.txt + root/d07/s2/f03.txt + root/d07/s2/f04.txt + root/d07/s2/t0/f00.txt + root/d07/s2/t0/f01.txt + root/d07/s2/t0/f02.txt + root/d07/s2/t1/f00.txt + root/d07/s2/t1/f01.txt + root/d07/s2/t1/f02.txt + root/d08/f00.txt + root/d08/f01.txt + root/d08/f02.txt + root/d08/f03.txt + root/d08/f04.txt + root/d08/f05.txt + root/d08/f06.txt + root/d08/f07.txt + root/d08/f08.txt + root/d08/f09.txt + root/d08/s0/f00.txt + root/d08/s0/f01.txt + root/d08/s0/f02.txt + root/d08/s0/f03.txt + root/d08/s0/f04.txt + root/d08/s0/t0/f00.txt + root/d08/s0/t0/f01.txt + root/d08/s0/t0/f02.txt + root/d08/s0/t1/f00.txt + root/d08/s0/t1/f01.txt + root/d08/s0/t1/f02.txt + root/d08/s1/f00.txt + root/d08/s1/f01.txt + root/d08/s1/f02.txt + root/d08/s1/f03.txt + root/d08/s1/f04.txt + root/d08/s1/t0/f00.txt + root/d08/s1/t0/f01.txt + root/d08/s1/t0/f02.txt + root/d08/s1/t1/f00.txt + root/d08/s1/t1/f01.txt + root/d08/s1/t1/f02.txt + root/d08/s2/f00.txt + root/d08/s2/f01.txt + root/d08/s2/f02.txt + root/d08/s2/f03.txt + root/d08/s2/f04.txt + root/d08/s2/t0/f00.txt + root/d08/s2/t0/f01.txt + root/d08/s2/t0/f02.txt + root/d08/s2/t1/f00.txt + root/d08/s2/t1/f01.txt + root/d08/s2/t1/f02.txt + root/d09/f00.txt + root/d09/f01.txt + root/d09/f02.txt + root/d09/f03.txt + root/d09/f04.txt + root/d09/f05.txt + root/d09/f06.txt + root/d09/f07.txt + root/d09/f08.txt + root/d09/f09.txt + root/d09/s0/f00.txt + root/d09/s0/f01.txt + root/d09/s0/f02.txt + root/d09/s0/f03.txt + root/d09/s0/f04.txt + root/d09/s0/t0/f00.txt + root/d09/s0/t0/f01.txt + root/d09/s0/t0/f02.txt + root/d09/s0/t1/f00.txt + root/d09/s0/t1/f01.txt + root/d09/s0/t1/f02.txt + root/d09/s1/f00.txt + root/d09/s1/f01.txt + root/d09/s1/f02.txt + root/d09/s1/f03.txt + root/d09/s1/f04.txt + root/d09/s1/t0/f00.txt + root/d09/s1/t0/f01.txt + root/d09/s1/t0/f02.txt + root/d09/s1/t1/f00.txt + root/d09/s1/t1/f01.txt + root/d09/s1/t1/f02.txt + root/d09/s2/f00.txt + root/d09/s2/f01.txt + root/d09/s2/f02.txt + root/d09/s2/f03.txt + root/d09/s2/f04.txt + root/d09/s2/t0/f00.txt + root/d09/s2/t0/f01.txt + root/d09/s2/t0/f02.txt + root/d09/s2/t1/f00.txt + root/d09/s2/t1/f01.txt + root/d09/s2/t1/f02.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/trailing_slash.yaml b/tests/scenarios/cmd/find/basic/trailing_slash.yaml new file mode 100644 index 00000000..e69fa70e --- /dev/null +++ b/tests/scenarios/cmd/find/basic/trailing_slash.yaml @@ -0,0 +1,14 @@ +description: trailing slash on path does not break -name matching. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir/ -maxdepth 0 -name dir +expect: + stdout: |+ + dir/ + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/combined_mindepth_maxdepth.yaml b/tests/scenarios/cmd/find/depth/combined_mindepth_maxdepth.yaml new file mode 100644 index 00000000..02b9d853 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/combined_mindepth_maxdepth.yaml @@ -0,0 +1,22 @@ +description: "-mindepth and -maxdepth combined after predicates select an exact depth band." +skip_assert_against_bash: true +setup: + files: + - path: a/b/c/deep.txt + content: "deep" + chmod: 0644 + - path: a/b/mid.txt + content: "mid" + chmod: 0644 + - path: a/top.txt + content: "top" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -type f -mindepth 2 -maxdepth 2 +expect: + stdout: |+ + a/b/mid.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/maxdepth.yaml b/tests/scenarios/cmd/find/depth/maxdepth.yaml new file mode 100644 index 00000000..8d6cea67 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth.yaml @@ -0,0 +1,20 @@ +description: find -maxdepth limits traversal depth. +setup: + files: + - path: a/b/c/deep.txt + content: "deep" + chmod: 0644 + - path: a/top.txt + content: "top" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -maxdepth 1 +expect: + stdout_unordered: |+ + a + a/b + a/top.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/maxdepth_after_predicate.yaml b/tests/scenarios/cmd/find/depth/maxdepth_after_predicate.yaml new file mode 100644 index 00000000..73292450 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth_after_predicate.yaml @@ -0,0 +1,19 @@ +description: find -maxdepth works after other predicates. +skip_assert_against_bash: true +setup: + files: + - path: a/b/c/deep.txt + content: "deep" + chmod: 0644 + - path: a/top.txt + content: "top" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -type f -maxdepth 1 +expect: + stdout: |+ + a/top.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/maxdepth_between_predicates.yaml b/tests/scenarios/cmd/find/depth/maxdepth_between_predicates.yaml new file mode 100644 index 00000000..bc65b6ba --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth_between_predicates.yaml @@ -0,0 +1,22 @@ +description: "-maxdepth works between two predicates." +setup: + files: + - path: a/b/c/deep.txt + content: "deep" + chmod: 0644 + - path: a/b/mid.txt + content: "mid" + chmod: 0644 + - path: a/top.txt + content: "top" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -type f -maxdepth 2 -name '*.txt' +expect: + stdout_unordered: |+ + a/b/mid.txt + a/top.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/maxdepth_invalid.yaml b/tests/scenarios/cmd/find/depth/maxdepth_invalid.yaml new file mode 100644 index 00000000..6f19993e --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth_invalid.yaml @@ -0,0 +1,14 @@ +description: find -maxdepth with non-numeric value produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -maxdepth abc +expect: + stderr_contains: ["invalid argument 'abc' to -maxdepth"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/depth/maxdepth_last_wins.yaml b/tests/scenarios/cmd/find/depth/maxdepth_last_wins.yaml new file mode 100644 index 00000000..1be6c04c --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth_last_wins.yaml @@ -0,0 +1,26 @@ +description: "When -maxdepth is specified multiple times, the last value wins." +setup: + files: + - path: a/b/c/deep.txt + content: "deep" + chmod: 0644 + - path: a/b/mid.txt + content: "mid" + chmod: 0644 + - path: a/top.txt + content: "top" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -maxdepth 1 -maxdepth 3 +expect: + stdout_unordered: |+ + a + a/b + a/b/c + a/b/c/deep.txt + a/b/mid.txt + a/top.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/maxdepth_missing_arg.yaml b/tests/scenarios/cmd/find/depth/maxdepth_missing_arg.yaml new file mode 100644 index 00000000..c52e153d --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth_missing_arg.yaml @@ -0,0 +1,14 @@ +description: find -maxdepth with no value produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -maxdepth +expect: + stderr_contains: ["missing argument to '-maxdepth'"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/depth/maxdepth_negative.yaml b/tests/scenarios/cmd/find/depth/maxdepth_negative.yaml new file mode 100644 index 00000000..e0d0ee44 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth_negative.yaml @@ -0,0 +1,14 @@ +description: find -maxdepth with negative value produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -maxdepth -1 +expect: + stderr_contains: ["invalid argument '-1' to -maxdepth"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/depth/maxdepth_plus_sign.yaml b/tests/scenarios/cmd/find/depth/maxdepth_plus_sign.yaml new file mode 100644 index 00000000..a1520eb7 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth_plus_sign.yaml @@ -0,0 +1,15 @@ +description: -maxdepth rejects +N form like GNU find. +skip_assert_against_bash: true +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -maxdepth +1 +expect: + stdout: "" + stderr_contains: ["invalid argument"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/depth/maxdepth_zero.yaml b/tests/scenarios/cmd/find/depth/maxdepth_zero.yaml new file mode 100644 index 00000000..bd80b011 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth_zero.yaml @@ -0,0 +1,15 @@ +description: find -maxdepth 0 only processes the starting point. +setup: + files: + - path: dir/file.txt + content: "data" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -maxdepth 0 +expect: + stdout: |+ + dir + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/maxdepth_zero_after_predicate.yaml b/tests/scenarios/cmd/find/depth/maxdepth_zero_after_predicate.yaml new file mode 100644 index 00000000..cd7371c4 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/maxdepth_zero_after_predicate.yaml @@ -0,0 +1,16 @@ +description: "-maxdepth 0 after a predicate only processes the starting point." +skip_assert_against_bash: true +setup: + files: + - path: a/b/file.txt + content: "data" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -type d -maxdepth 0 +expect: + stdout: |+ + a + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/mindepth.yaml b/tests/scenarios/cmd/find/depth/mindepth.yaml new file mode 100644 index 00000000..1bfc1002 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/mindepth.yaml @@ -0,0 +1,18 @@ +description: find -mindepth skips shallow entries. +setup: + files: + - path: a/b/deep.txt + content: "deep" + chmod: 0644 + - path: a/top.txt + content: "top" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -mindepth 2 +expect: + stdout: |+ + a/b/deep.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/mindepth_after_predicate.yaml b/tests/scenarios/cmd/find/depth/mindepth_after_predicate.yaml new file mode 100644 index 00000000..05a0b63f --- /dev/null +++ b/tests/scenarios/cmd/find/depth/mindepth_after_predicate.yaml @@ -0,0 +1,19 @@ +description: find -mindepth works after other predicates. +skip_assert_against_bash: true +setup: + files: + - path: a/b/deep.txt + content: "deep" + chmod: 0644 + - path: a/top.txt + content: "top" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -type f -mindepth 2 +expect: + stdout: |+ + a/b/deep.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/mindepth_exceeds_maxdepth.yaml b/tests/scenarios/cmd/find/depth/mindepth_exceeds_maxdepth.yaml new file mode 100644 index 00000000..182f916f --- /dev/null +++ b/tests/scenarios/cmd/find/depth/mindepth_exceeds_maxdepth.yaml @@ -0,0 +1,18 @@ +description: "When -mindepth exceeds -maxdepth, no entries are printed." +skip_assert_against_bash: true +setup: + files: + - path: a/b/c/deep.txt + content: "deep" + chmod: 0644 + - path: a/top.txt + content: "top" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -maxdepth 1 -mindepth 3 +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/mindepth_invalid.yaml b/tests/scenarios/cmd/find/depth/mindepth_invalid.yaml new file mode 100644 index 00000000..f9d6d150 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/mindepth_invalid.yaml @@ -0,0 +1,14 @@ +description: find -mindepth with non-numeric value produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -mindepth xyz +expect: + stderr_contains: ["invalid argument 'xyz' to -mindepth"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/depth/mindepth_missing_arg.yaml b/tests/scenarios/cmd/find/depth/mindepth_missing_arg.yaml new file mode 100644 index 00000000..56cf039f --- /dev/null +++ b/tests/scenarios/cmd/find/depth/mindepth_missing_arg.yaml @@ -0,0 +1,14 @@ +description: find -mindepth with no value produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -mindepth +expect: + stderr_contains: ["missing argument to '-mindepth'"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/depth/name_consumes_maxdepth.yaml b/tests/scenarios/cmd/find/depth/name_consumes_maxdepth.yaml new file mode 100644 index 00000000..31d711a2 --- /dev/null +++ b/tests/scenarios/cmd/find/depth/name_consumes_maxdepth.yaml @@ -0,0 +1,19 @@ +description: "-name consumes -maxdepth as its pattern argument (no argument stealing)." +skip_assert_against_bash: true +setup: + files: + - path: a/-maxdepth + content: "trick" + chmod: 0644 + - path: a/other.txt + content: "other" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find a -name -maxdepth +expect: + stdout: |+ + a/-maxdepth + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/depth/newer_consumes_maxdepth.yaml b/tests/scenarios/cmd/find/depth/newer_consumes_maxdepth.yaml new file mode 100644 index 00000000..0120f3bf --- /dev/null +++ b/tests/scenarios/cmd/find/depth/newer_consumes_maxdepth.yaml @@ -0,0 +1,14 @@ +description: "-newer consumes -maxdepth as its ref file, leaving '3' as unknown predicate." +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -newer -maxdepth 3 -type f +expect: + stderr_contains: ["find: unknown predicate '3'"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/dash_number_is_expression.yaml b/tests/scenarios/cmd/find/errors/dash_number_is_expression.yaml new file mode 100644 index 00000000..811182be --- /dev/null +++ b/tests/scenarios/cmd/find/errors/dash_number_is_expression.yaml @@ -0,0 +1,12 @@ +description: find treats '-1' as an expression token (unknown predicate), not a path. +setup: + files: + - path: dummy.txt + content: "x" +input: + allowed_paths: ["$DIR"] + script: |+ + find "-1" -maxdepth 0 +expect: + stderr_contains: ["find:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/empty_newer_ref.yaml b/tests/scenarios/cmd/find/errors/empty_newer_ref.yaml new file mode 100644 index 00000000..3c5eaf2f --- /dev/null +++ b/tests/scenarios/cmd/find/errors/empty_newer_ref.yaml @@ -0,0 +1,14 @@ +description: find rejects empty string as -newer reference. +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -newer "" +expect: + stderr: |+ + find: '': No such file or directory + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/empty_parens.yaml b/tests/scenarios/cmd/find/errors/empty_parens.yaml new file mode 100644 index 00000000..c046af02 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/empty_parens.yaml @@ -0,0 +1,14 @@ +description: empty parentheses are rejected with an error. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir "(" ")" +expect: + stdout: "" + stderr_contains: ["empty parentheses are not allowed"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/empty_path.yaml b/tests/scenarios/cmd/find/errors/empty_path.yaml new file mode 100644 index 00000000..9c2afa48 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/empty_path.yaml @@ -0,0 +1,14 @@ +description: find rejects empty string path operand. +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find "" -maxdepth 0 +expect: + stderr: |+ + find: '': No such file or directory + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/empty_path_mixed.yaml b/tests/scenarios/cmd/find/errors/empty_path_mixed.yaml new file mode 100644 index 00000000..82213ca1 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/empty_path_mixed.yaml @@ -0,0 +1,16 @@ +description: find reports error for empty path but still walks valid paths. +setup: + files: + - path: dir/file.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find "" dir -maxdepth 0 -print +expect: + stdout: |+ + dir + stderr: |+ + find: '': No such file or directory + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/empty_type.yaml b/tests/scenarios/cmd/find/errors/empty_type.yaml new file mode 100644 index 00000000..9f7d3012 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/empty_type.yaml @@ -0,0 +1,14 @@ +description: find -type with empty string produces an error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -type "" +expect: + stderr_contains: ["Unknown argument to -type"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/mtime_invalid.yaml b/tests/scenarios/cmd/find/errors/mtime_invalid.yaml new file mode 100644 index 00000000..4299cf29 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/mtime_invalid.yaml @@ -0,0 +1,14 @@ +description: find -mtime with non-numeric value produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -mtime foo +expect: + stderr_contains: ["invalid argument 'foo' to -mtime"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/name_missing_arg.yaml b/tests/scenarios/cmd/find/errors/name_missing_arg.yaml new file mode 100644 index 00000000..8b9dd56d --- /dev/null +++ b/tests/scenarios/cmd/find/errors/name_missing_arg.yaml @@ -0,0 +1,14 @@ +description: find -name with no pattern produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -name +expect: + stderr_contains: ["missing argument for -name"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/nonexistent.yaml b/tests/scenarios/cmd/find/errors/nonexistent.yaml new file mode 100644 index 00000000..8f1d40ec --- /dev/null +++ b/tests/scenarios/cmd/find/errors/nonexistent.yaml @@ -0,0 +1,13 @@ +description: find reports error for nonexistent starting path. +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find nonexistent +expect: + stderr_contains: ["find:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/numeric_predicate.yaml b/tests/scenarios/cmd/find/errors/numeric_predicate.yaml new file mode 100644 index 00000000..a1730b90 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/numeric_predicate.yaml @@ -0,0 +1,15 @@ +description: numeric-looking tokens like -1 are rejected as unknown predicates. +skip_assert_against_bash: true +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -1 +expect: + stdout: "" + stderr_contains: ["unknown predicate"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/paren_nonexistent.yaml b/tests/scenarios/cmd/find/errors/paren_nonexistent.yaml new file mode 100644 index 00000000..7c0d734d --- /dev/null +++ b/tests/scenarios/cmd/find/errors/paren_nonexistent.yaml @@ -0,0 +1,12 @@ +description: find treats ')' as a nonexistent path, not an expression error. +setup: + files: + - path: dummy.txt + content: "x" +input: + allowed_paths: ["$DIR"] + script: |+ + find ")" -maxdepth 0 +expect: + stderr_contains: ["find:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/size_invalid.yaml b/tests/scenarios/cmd/find/errors/size_invalid.yaml new file mode 100644 index 00000000..c5174d1f --- /dev/null +++ b/tests/scenarios/cmd/find/errors/size_invalid.yaml @@ -0,0 +1,14 @@ +description: find -size with invalid value produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -size abc +expect: + stderr_contains: ["invalid argument 'abc' to -size"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/size_missing_arg.yaml b/tests/scenarios/cmd/find/errors/size_missing_arg.yaml new file mode 100644 index 00000000..8db3403a --- /dev/null +++ b/tests/scenarios/cmd/find/errors/size_missing_arg.yaml @@ -0,0 +1,14 @@ +description: find -size with no value produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -size +expect: + stderr_contains: ["missing argument for -size"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/type_invalid_char.yaml b/tests/scenarios/cmd/find/errors/type_invalid_char.yaml new file mode 100644 index 00000000..db1edb99 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/type_invalid_char.yaml @@ -0,0 +1,14 @@ +description: find -type with invalid character produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -type x +expect: + stderr_contains: ["Unknown argument to -type: x"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/type_missing_arg.yaml b/tests/scenarios/cmd/find/errors/type_missing_arg.yaml new file mode 100644 index 00000000..f1799f21 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/type_missing_arg.yaml @@ -0,0 +1,14 @@ +description: find -type with no value produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -type +expect: + stderr_contains: ["missing argument for -type"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/type_trailing_comma.yaml b/tests/scenarios/cmd/find/errors/type_trailing_comma.yaml new file mode 100644 index 00000000..4a805a61 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/type_trailing_comma.yaml @@ -0,0 +1,14 @@ +description: find -type with trailing comma produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -type 'f,' +expect: + stderr_contains: ["Unknown argument to -type: f,"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/unknown_predicate.yaml b/tests/scenarios/cmd/find/errors/unknown_predicate.yaml new file mode 100644 index 00000000..4a3d2d43 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/unknown_predicate.yaml @@ -0,0 +1,14 @@ +description: find reports error for unknown predicate. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -bogus +expect: + stderr_contains: ["find: unknown predicate"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/unmatched_paren.yaml b/tests/scenarios/cmd/find/errors/unmatched_paren.yaml new file mode 100644 index 00000000..9cf7278b --- /dev/null +++ b/tests/scenarios/cmd/find/errors/unmatched_paren.yaml @@ -0,0 +1,14 @@ +description: find with unmatched opening parenthesis produces error. +skip_assert_against_bash: true +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . '(' -name '*.txt' +expect: + stderr_contains: ["expected ')'"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/unsupported_H.yaml b/tests/scenarios/cmd/find/errors/unsupported_H.yaml new file mode 100644 index 00000000..bc88ba29 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/unsupported_H.yaml @@ -0,0 +1,15 @@ +description: -H flag is rejected as unsupported. +skip_assert_against_bash: true +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find -H dir +expect: + stdout: "" + stderr_contains: ["-H is not supported"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/logic/complex_nested.yaml b/tests/scenarios/cmd/find/logic/complex_nested.yaml new file mode 100644 index 00000000..84012a94 --- /dev/null +++ b/tests/scenarios/cmd/find/logic/complex_nested.yaml @@ -0,0 +1,26 @@ +description: Complex expression with AND, OR, NOT, and parentheses. +skip_assert_against_bash: true +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.go + content: "b" + chmod: 0644 + - path: dir/c.md + content: "c" + chmod: 0644 + - path: dir/d.txt + content: "dddd" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f '(' -name '*.txt' -o -name '*.go' ')' -not -name 'a*' +expect: + stdout_unordered: |+ + dir/b.go + dir/d.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/logic/explicit_and.yaml b/tests/scenarios/cmd/find/logic/explicit_and.yaml new file mode 100644 index 00000000..4cf14f83 --- /dev/null +++ b/tests/scenarios/cmd/find/logic/explicit_and.yaml @@ -0,0 +1,23 @@ +description: find with explicit -a operator for conjunction. +skip_assert_against_bash: true +setup: + files: + - path: dir/hello.txt + content: "hi" + chmod: 0644 + - path: dir/hello.go + content: "go" + chmod: 0644 + - path: dir/world.txt + content: "world" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name 'hello*' -a -type f +expect: + stdout_unordered: |+ + dir/hello.go + dir/hello.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/logic/explicit_and_keyword.yaml b/tests/scenarios/cmd/find/logic/explicit_and_keyword.yaml new file mode 100644 index 00000000..0c7fbdb6 --- /dev/null +++ b/tests/scenarios/cmd/find/logic/explicit_and_keyword.yaml @@ -0,0 +1,23 @@ +description: find with explicit -and operator for conjunction. +skip_assert_against_bash: true +setup: + files: + - path: dir/hello.txt + content: "hi" + chmod: 0644 + - path: dir/hello.go + content: "go" + chmod: 0644 + - path: dir/world.txt + content: "world" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name 'hello*' -and -type f +expect: + stdout_unordered: |+ + dir/hello.go + dir/hello.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/logic/multiple_or_chain.yaml b/tests/scenarios/cmd/find/logic/multiple_or_chain.yaml new file mode 100644 index 00000000..43a62520 --- /dev/null +++ b/tests/scenarios/cmd/find/logic/multiple_or_chain.yaml @@ -0,0 +1,26 @@ +description: Chained OR with three alternatives. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.go + content: "b" + chmod: 0644 + - path: dir/c.md + content: "c" + chmod: 0644 + - path: dir/d.rs + content: "d" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f '(' -name '*.txt' -o -name '*.go' -o -name '*.md' ')' +expect: + stdout_unordered: |+ + dir/a.txt + dir/b.go + dir/c.md + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/logic/not.yaml b/tests/scenarios/cmd/find/logic/not.yaml new file mode 100644 index 00000000..8e4f1c8e --- /dev/null +++ b/tests/scenarios/cmd/find/logic/not.yaml @@ -0,0 +1,18 @@ +description: find with ! (NOT) negates the predicate. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.go + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f ! -name '*.txt' +expect: + stdout: |+ + dir/b.go + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/logic/not_keyword.yaml b/tests/scenarios/cmd/find/logic/not_keyword.yaml new file mode 100644 index 00000000..4251b139 --- /dev/null +++ b/tests/scenarios/cmd/find/logic/not_keyword.yaml @@ -0,0 +1,19 @@ +description: find -not keyword is equivalent to ! for negation. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.go + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -not -name '*.txt' +expect: + stdout: |+ + dir/b.go + stderr: "" + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/find/logic/or.yaml b/tests/scenarios/cmd/find/logic/or.yaml new file mode 100644 index 00000000..fdc34cd5 --- /dev/null +++ b/tests/scenarios/cmd/find/logic/or.yaml @@ -0,0 +1,22 @@ +description: find -name with -o (OR) matches either pattern. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.go + content: "b" + chmod: 0644 + - path: dir/c.md + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '*.txt' -o -name '*.go' +expect: + stdout_unordered: |+ + dir/a.txt + dir/b.go + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/logic/or_keyword.yaml b/tests/scenarios/cmd/find/logic/or_keyword.yaml new file mode 100644 index 00000000..b1276375 --- /dev/null +++ b/tests/scenarios/cmd/find/logic/or_keyword.yaml @@ -0,0 +1,22 @@ +description: find -or operator is an alias for -o. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.go + content: "b" + chmod: 0644 + - path: dir/c.md + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '*.txt' -or -name '*.go' +expect: + stdout_unordered: |+ + dir/a.txt + dir/b.go + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/logic/parens.yaml b/tests/scenarios/cmd/find/logic/parens.yaml new file mode 100644 index 00000000..d28b5462 --- /dev/null +++ b/tests/scenarios/cmd/find/logic/parens.yaml @@ -0,0 +1,22 @@ +description: find with parentheses for grouping. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.go + content: "b" + chmod: 0644 + - path: dir/c.md + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f '(' -name '*.txt' -o -name '*.go' ')' +expect: + stdout_unordered: |+ + dir/a.txt + dir/b.go + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/output/explicit_print.yaml b/tests/scenarios/cmd/find/output/explicit_print.yaml new file mode 100644 index 00000000..218bcf18 --- /dev/null +++ b/tests/scenarios/cmd/find/output/explicit_print.yaml @@ -0,0 +1,19 @@ +description: Explicit -print suppresses implicit print. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '*.txt' -print +expect: + stdout_unordered: |+ + dir/a.txt + dir/b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/output/print0.yaml b/tests/scenarios/cmd/find/output/print0.yaml new file mode 100644 index 00000000..b0e96f15 --- /dev/null +++ b/tests/scenarios/cmd/find/output/print0.yaml @@ -0,0 +1,19 @@ +description: find -print0 separates entries with NUL. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -print0 +expect: + stdout_contains: + - "dir/a.txt" + - "dir/b.txt" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/output/print0_suppresses_implicit.yaml b/tests/scenarios/cmd/find/output/print0_suppresses_implicit.yaml new file mode 100644 index 00000000..96d58e3c --- /dev/null +++ b/tests/scenarios/cmd/find/output/print0_suppresses_implicit.yaml @@ -0,0 +1,22 @@ +description: "-print0 in one OR branch suppresses implicit -print globally." +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.log + content: "b" + chmod: 0644 + - path: dir/c.txt + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '*.txt' -print0 -o -name '*.log' +expect: + stdout_contains: + - "dir/a.txt" + - "dir/c.txt" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/output/print_with_or.yaml b/tests/scenarios/cmd/find/output/print_with_or.yaml new file mode 100644 index 00000000..d1b02c66 --- /dev/null +++ b/tests/scenarios/cmd/find/output/print_with_or.yaml @@ -0,0 +1,22 @@ +description: Explicit -print inside OR branches prints only matching entries. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.go + content: "b" + chmod: 0644 + - path: dir/c.md + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '*.txt' -print -o -name '*.go' -print +expect: + stdout_unordered: |+ + dir/a.txt + dir/b.go + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/pipe/find_pipe_wc.yaml b/tests/scenarios/cmd/find/pipe/find_pipe_wc.yaml new file mode 100644 index 00000000..d5aeb849 --- /dev/null +++ b/tests/scenarios/cmd/find/pipe/find_pipe_wc.yaml @@ -0,0 +1,21 @@ +description: find piped to wc -l counts matching files. +skip_assert_against_bash: true +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 + - path: dir/c.go + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f | wc -l +expect: + stdout_contains: ["3"] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/empty_dir.yaml b/tests/scenarios/cmd/find/predicates/empty_dir.yaml new file mode 100644 index 00000000..5f1ec86b --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/empty_dir.yaml @@ -0,0 +1,22 @@ +description: find -empty matches empty files but not directories with contents. +skip_assert_against_bash: true +setup: + files: + - path: dir/empty.txt + content: "" + chmod: 0644 + - path: dir/notempty.txt + content: "stuff" + chmod: 0644 + - path: dir/sub/child.txt + content: "child" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -empty +expect: + stdout: |+ + dir/empty.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/empty_file.yaml b/tests/scenarios/cmd/find/predicates/empty_file.yaml new file mode 100644 index 00000000..266ffc88 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/empty_file.yaml @@ -0,0 +1,23 @@ +description: find -empty matches empty files and directories. +skip_assert_against_bash: true +setup: + files: + - path: dir/empty.txt + content: "" + chmod: 0644 + - path: dir/notempty.txt + content: "data" + chmod: 0644 + - path: dir/emptydir/.keep + content: "" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -empty -type f +expect: + stdout_unordered: |+ + dir/empty.txt + dir/emptydir/.keep + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/empty_nested_dirs.yaml b/tests/scenarios/cmd/find/predicates/empty_nested_dirs.yaml new file mode 100644 index 00000000..9cca0126 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/empty_nested_dirs.yaml @@ -0,0 +1,30 @@ +description: find -empty matches empty files at various depths in a nested tree. +skip_assert_against_bash: true +setup: + files: + - path: dir/full/file.txt + content: "stuff" + chmod: 0644 + - path: dir/empty1.txt + content: "" + chmod: 0644 + - path: dir/sub/empty2.txt + content: "" + chmod: 0644 + - path: dir/sub/deep/empty3.txt + content: "" + chmod: 0644 + - path: dir/sub/deep/notempty.txt + content: "data" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -empty +expect: + stdout_unordered: |+ + dir/empty1.txt + dir/sub/empty2.txt + dir/sub/deep/empty3.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/false.yaml b/tests/scenarios/cmd/find/predicates/false.yaml new file mode 100644 index 00000000..d7263953 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/false.yaml @@ -0,0 +1,17 @@ +description: find -false matches nothing. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -false +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/iname.yaml b/tests/scenarios/cmd/find/predicates/iname.yaml new file mode 100644 index 00000000..648cb092 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/iname.yaml @@ -0,0 +1,23 @@ +description: find -iname matches case-insensitively. +skip_assert_against_bash: true +setup: + files: + - path: dir/README.md + content: "readme" + chmod: 0644 + - path: dir/readme.txt + content: "also readme" + chmod: 0644 + - path: dir/other.go + content: "go" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -iname 'readme*' +expect: + stdout_unordered: |+ + dir/README.md + dir/readme.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/ipath.yaml b/tests/scenarios/cmd/find/predicates/ipath.yaml new file mode 100644 index 00000000..9a9beb24 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/ipath.yaml @@ -0,0 +1,19 @@ +description: find -ipath matches full path case-insensitively. +skip_assert_against_bash: true # intentional: case-insensitive filesystem handling may differ +setup: + files: + - path: SRC/Main.go + content: "package main" + chmod: 0644 + - path: doc/readme.md + content: "# Readme" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -ipath '*/src/*' -type f +expect: + stdout: |+ + ./SRC/Main.go + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/iwholename.yaml b/tests/scenarios/cmd/find/predicates/iwholename.yaml new file mode 100644 index 00000000..b3602fea --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/iwholename.yaml @@ -0,0 +1,19 @@ +description: find -iwholename is a case-insensitive alias for -path. +skip_assert_against_bash: true +setup: + files: + - path: DIR/Sub/File.TXT + content: "data" + chmod: 0644 + - path: other/readme.md + content: "md" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -iwholename '*/dir/sub/*' +expect: + stdout: |+ + ./DIR/Sub/File.TXT + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mmin.yaml b/tests/scenarios/cmd/find/predicates/mmin.yaml new file mode 100644 index 00000000..44d4eb57 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mmin.yaml @@ -0,0 +1,16 @@ +description: find -mmin matches files modified within specified minutes. +skip_assert_against_bash: true +setup: + files: + - path: dir/recent.txt + content: "just created" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -mmin -60 +expect: + stdout: |+ + dir/recent.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mmin_exact.yaml b/tests/scenarios/cmd/find/predicates/mmin_exact.yaml new file mode 100644 index 00000000..85090f85 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mmin_exact.yaml @@ -0,0 +1,16 @@ +description: find -mmin 0 does not match files that are even 1 second old (ceiling rounding). +skip_assert_against_bash: true +setup: + files: + - path: dir/recent.txt + content: "just created" + chmod: 0644 + mod_time: "2020-01-01T00:00:00Z" # explicit past time to ensure file is > 0 minutes old +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -mmin 0 +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mmin_int64_overflow.yaml b/tests/scenarios/cmd/find/predicates/mmin_int64_overflow.yaml new file mode 100644 index 00000000..e6f38c1e --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mmin_int64_overflow.yaml @@ -0,0 +1,28 @@ +description: -mmin with values exceeding int64 range behaves like GNU find. +skip_assert_against_bash: true # GNU find uses internal bignum; we clamp to MaxInt64 +setup: + files: + - path: dir/file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + # +N with a value far beyond int64 max: nothing should match + find dir -mmin +99999999999999999999999 -type f + echo "plus_exit: $?" + + # -N with a value far beyond int64 max: everything should match + find dir -mmin -99999999999999999999999 -type f + echo "minus_exit: $?" + + # Exact match with a value beyond int64: nothing should match + find dir -mmin 99999999999999999999999 -type f + echo "exact_exit: $?" +expect: + stdout: |+ + plus_exit: 0 + dir/file.txt + minus_exit: 0 + exact_exit: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mmin_large_int64.yaml b/tests/scenarios/cmd/find/predicates/mmin_large_int64.yaml new file mode 100644 index 00000000..5b7edee9 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mmin_large_int64.yaml @@ -0,0 +1,27 @@ +description: -mmin with values exceeding int32 but valid int64 behaves correctly. +skip_assert_against_bash: true # bash comparison tests cannot set mod_time +setup: + files: + - path: dir/old.txt + content: "ancient" + mod_time: "1800-01-01T00:00:00Z" + - path: dir/new.txt + content: "fresh" +input: + allowed_paths: ["$DIR"] + script: |+ + # 100000000 minutes (~190 years) exceeds int32 max (2147483647) in + # nanosecond representation. old.txt (year 1800) is >200 years old, + # so it should match +100000000. new.txt was just created, so it + # should not match. + find dir -mmin +100000000 -type f + + # -100000000: new.txt is newer than 190 years, so it matches. + # old.txt is older, so it does not match. + find dir -mmin -100000000 -type f +expect: + stdout_unordered: |+ + dir/old.txt + dir/new.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mmin_overflow.yaml b/tests/scenarios/cmd/find/predicates/mmin_overflow.yaml new file mode 100644 index 00000000..248d40d1 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mmin_overflow.yaml @@ -0,0 +1,14 @@ +description: -mmin with extremely large value does not overflow or match everything. +skip_assert_against_bash: true # GNU find may behave differently with overflow values +setup: + files: + - path: dir/file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -mmin +9999999999999999 -type f +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mmin_plus_zero.yaml b/tests/scenarios/cmd/find/predicates/mmin_plus_zero.yaml new file mode 100644 index 00000000..40e1606c --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mmin_plus_zero.yaml @@ -0,0 +1,17 @@ +description: find -mmin +0 matches files older than 0 minutes (any non-zero age). +skip_assert_against_bash: true # timing-sensitive — file age depends on test execution speed +setup: + files: + - path: dir/recent.txt + content: "just created" + chmod: 0644 + mod_time: "2020-01-01T00:00:00Z" # explicit past time to avoid timing flakes +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -mmin +0 +expect: + stdout: |+ + dir/recent.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mtime.yaml b/tests/scenarios/cmd/find/predicates/mtime.yaml new file mode 100644 index 00000000..cce80ce8 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mtime.yaml @@ -0,0 +1,16 @@ +description: find -mtime matches files modified within specified days. +skip_assert_against_bash: true +setup: + files: + - path: dir/recent.txt + content: "just created" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -mtime -1 +expect: + stdout: |+ + dir/recent.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mtime_exact.yaml b/tests/scenarios/cmd/find/predicates/mtime_exact.yaml new file mode 100644 index 00000000..cf865278 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mtime_exact.yaml @@ -0,0 +1,16 @@ +description: find -mtime 0 matches files modified within the last 24 hours. +skip_assert_against_bash: true +setup: + files: + - path: dir/recent.txt + content: "just created" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -mtime 0 +expect: + stdout: |+ + dir/recent.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mtime_int64_overflow.yaml b/tests/scenarios/cmd/find/predicates/mtime_int64_overflow.yaml new file mode 100644 index 00000000..034cfb74 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mtime_int64_overflow.yaml @@ -0,0 +1,16 @@ +description: "-mtime with int64-overflowing values does not panic or produce wrong results." +setup: + files: + - path: dir/file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -mtime +99999999999999999999999 -type f + find dir -mtime -99999999999999999999999 -type f + find dir -mtime 99999999999999999999999 -type f +expect: + stdout: |+ + dir/file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mtime_minus_zero.yaml b/tests/scenarios/cmd/find/predicates/mtime_minus_zero.yaml new file mode 100644 index 00000000..f3439554 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mtime_minus_zero.yaml @@ -0,0 +1,16 @@ +description: -mtime -0 matches very fresh files (GNU find compatibility). +skip_assert_against_bash: true # sub-second timing makes bash result non-deterministic +setup: + files: + - path: dir/file.txt + content: "x" + chmod: 0644 + mod_time: "2099-01-01T00:00:00Z" +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -maxdepth 1 -type f -mtime -0 +expect: + stdout: |+ + dir/file.txt + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mtime_plus_zero.yaml b/tests/scenarios/cmd/find/predicates/mtime_plus_zero.yaml new file mode 100644 index 00000000..40b3f9bb --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mtime_plus_zero.yaml @@ -0,0 +1,13 @@ +description: -mtime +0 matches nothing for a fresh file (days > 0 needs > 24h). +setup: + files: + - path: dir/file.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -maxdepth 1 -type f -mtime +0 +expect: + stdout: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/mtime_zero.yaml b/tests/scenarios/cmd/find/predicates/mtime_zero.yaml new file mode 100644 index 00000000..edd98ff9 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/mtime_zero.yaml @@ -0,0 +1,14 @@ +description: -mtime 0 matches fresh files (days == 0). +setup: + files: + - path: dir/file.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -maxdepth 1 -type f -mtime 0 +expect: + stdout: |+ + dir/file.txt + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name.yaml b/tests/scenarios/cmd/find/predicates/name.yaml new file mode 100644 index 00000000..4a61ca87 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name.yaml @@ -0,0 +1,22 @@ +description: find -name matches basename glob pattern. +setup: + files: + - path: dir/hello.txt + content: "hi" + chmod: 0644 + - path: dir/world.go + content: "go" + chmod: 0644 + - path: dir/sub/test.txt + content: "test" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '*.txt' +expect: + stdout_unordered: |+ + dir/hello.txt + dir/sub/test.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name_and_type.yaml b/tests/scenarios/cmd/find/predicates/name_and_type.yaml new file mode 100644 index 00000000..c30df264 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name_and_type.yaml @@ -0,0 +1,22 @@ +description: find -name combined with -type (implicit AND). +setup: + files: + - path: src/main.go + content: "package main" + chmod: 0644 + - path: src/util.go + content: "package util" + chmod: 0644 + - path: src/readme.md + content: "# Readme" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find src -name '*.go' -type f +expect: + stdout_unordered: |+ + src/main.go + src/util.go + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name_incomplete_range.yaml b/tests/scenarios/cmd/find/predicates/name_incomplete_range.yaml new file mode 100644 index 00000000..c3c31c4c --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name_incomplete_range.yaml @@ -0,0 +1,16 @@ +description: incomplete bracket range [a- matches nothing (GNU fnmatch behavior). +setup: + files: + - path: "dir/[a-" + content: "x" + chmod: 0644 + - path: dir/a.txt + content: "y" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '[a-' +expect: + stdout: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name_malformed_bracket.yaml b/tests/scenarios/cmd/find/predicates/name_malformed_bracket.yaml new file mode 100644 index 00000000..8f9efa05 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name_malformed_bracket.yaml @@ -0,0 +1,17 @@ +description: malformed bracket pattern in -name matches literal filename. +setup: + files: + - path: "dir/[" + content: "x" + chmod: 0644 + - path: dir/a.txt + content: "y" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '[' +expect: + stdout: |+ + dir/[ + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name_malformed_bracket_star.yaml b/tests/scenarios/cmd/find/predicates/name_malformed_bracket_star.yaml new file mode 100644 index 00000000..33254e55 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name_malformed_bracket_star.yaml @@ -0,0 +1,15 @@ +description: -name with malformed bracket treats [ as literal. +setup: + files: + - path: dir/normal.txt + content: "n" + - path: "dir/a[b.txt" + content: "x" +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '*[*' -type f +expect: + stdout: |+ + dir/a[b.txt + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name_negate_class_with_bang.yaml b/tests/scenarios/cmd/find/predicates/name_negate_class_with_bang.yaml new file mode 100644 index 00000000..919c4277 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name_negate_class_with_bang.yaml @@ -0,0 +1,22 @@ +description: "find -name with [^!...] negated character class treats ! as literal after ^" +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 + - path: dir/!.txt + content: "bang" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -name '[^!]*' +expect: + stdout_unordered: |+ + dir/a.txt + dir/b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name_negated_class.yaml b/tests/scenarios/cmd/find/predicates/name_negated_class.yaml new file mode 100644 index 00000000..cef1d8cc --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name_negated_class.yaml @@ -0,0 +1,18 @@ +description: -name with [!a]* negated bracket class excludes files starting with a. +setup: + files: + - path: dir/apple + content: "a" + - path: dir/banana + content: "b" + - path: dir/cherry + content: "c" +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '[!a]*' -type f +expect: + stdout_unordered: |+ + dir/banana + dir/cherry + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name_trailing_backslash.yaml b/tests/scenarios/cmd/find/predicates/name_trailing_backslash.yaml new file mode 100644 index 00000000..ee6ab5a7 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name_trailing_backslash.yaml @@ -0,0 +1,13 @@ +description: dangling trailing backslash in -name glob is non-matching per GNU fnmatch. +setup: + files: + - path: dir/a.txt + content: "y" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '\' +expect: + stdout: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name_utf8_class.yaml b/tests/scenarios/cmd/find/predicates/name_utf8_class.yaml new file mode 100644 index 00000000..146bb5e0 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name_utf8_class.yaml @@ -0,0 +1,20 @@ +description: -name character class matches multibyte UTF-8 characters. +skip_assert_against_bash: true # Docker bash cannot match é in character class +setup: + files: + - path: dir/a + content: "a" + - path: dir/é + content: "accent" + - path: dir/b + content: "b" +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '[aé]' -type f +expect: + stdout_unordered: |+ + dir/a + dir/é + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/name_utf8_question.yaml b/tests/scenarios/cmd/find/predicates/name_utf8_question.yaml new file mode 100644 index 00000000..36123463 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/name_utf8_question.yaml @@ -0,0 +1,17 @@ +description: -name '?' matches a single multibyte UTF-8 character. +skip_assert_against_bash: true # filesystem encoding may differ +setup: + files: + - path: dir/é + content: "accent" + - path: dir/ab + content: "two chars" +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name '?' -type f +expect: + stdout: |+ + dir/é + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/newer_basic.yaml b/tests/scenarios/cmd/find/predicates/newer_basic.yaml new file mode 100644 index 00000000..585fa950 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_basic.yaml @@ -0,0 +1,26 @@ +description: find -newer matches files newer than reference. +skip_assert_against_bash: false +setup: + files: + - path: dir/old.txt + content: "old" + chmod: 0644 + mod_time: "2024-01-01T00:00:00Z" + - path: dir/ref.txt + content: "reference" + chmod: 0644 + mod_time: "2024-01-02T00:00:00Z" + - path: dir/new.txt + content: "new" + chmod: 0644 + mod_time: "2024-01-03T00:00:00Z" +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -newer dir/old.txt -type f +expect: + stdout_unordered: |+ + dir/ref.txt + dir/new.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/newer_dangling_symlink_L.yaml b/tests/scenarios/cmd/find/predicates/newer_dangling_symlink_L.yaml new file mode 100644 index 00000000..0834eb56 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_dangling_symlink_L.yaml @@ -0,0 +1,23 @@ +description: -newer with dangling symlink ref succeeds under -L (falls back to lstat). +skip_assert_against_bash: false +setup: + files: + - path: dir/old.txt + content: "old" + chmod: 0644 + mod_time: "2020-01-01T00:00:00Z" + - path: dir/ref_link + symlink: nonexistent_target + - path: dir/new.txt + content: "new" + chmod: 0644 + mod_time: "2030-01-01T00:00:00Z" +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -newer dir/ref_link -type f +expect: + stdout: |+ + dir/new.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/newer_dedup.yaml b/tests/scenarios/cmd/find/predicates/newer_dedup.yaml new file mode 100644 index 00000000..5c43a2fb --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_dedup.yaml @@ -0,0 +1,15 @@ +description: duplicate -newer refs produce error and exit code 1. +skip_assert_against_bash: false +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -newer nonexist -o -newer nonexist +expect: + stdout: "" + stderr_contains: ["find: 'nonexist'"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/predicates/newer_eager_validation.yaml b/tests/scenarios/cmd/find/predicates/newer_eager_validation.yaml new file mode 100644 index 00000000..cb91f71e --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_eager_validation.yaml @@ -0,0 +1,15 @@ +description: find -newer with missing reference file reports error even with -mindepth preventing evaluation. +skip_assert_against_bash: false +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -mindepth 99 -newer nonexistent.txt +expect: + stdout: "" + stderr_contains: ["find:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/predicates/newer_missing_aborts_walk.yaml b/tests/scenarios/cmd/find/predicates/newer_missing_aborts_walk.yaml new file mode 100644 index 00000000..f6baf2a3 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_missing_aborts_walk.yaml @@ -0,0 +1,18 @@ +description: find -newer with missing reference aborts walk — no stdout even with -o -true fallback. +skip_assert_against_bash: false +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -newer nonexistent.txt -o -true +expect: + stdout: "" + stderr_contains: ["find: 'nonexistent.txt'"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/predicates/newer_nonexistent.yaml b/tests/scenarios/cmd/find/predicates/newer_nonexistent.yaml new file mode 100644 index 00000000..68752f69 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_nonexistent.yaml @@ -0,0 +1,18 @@ +description: find -newer with missing reference file produces exactly one error line and exit code 1. +skip_assert_against_bash: false +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -newer nonexistent.txt +expect: + stdout: "" + stderr_contains: ["find: 'nonexistent.txt'"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/predicates/newer_nonexistent_L.yaml b/tests/scenarios/cmd/find/predicates/newer_nonexistent_L.yaml new file mode 100644 index 00000000..c9891c30 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_nonexistent_L.yaml @@ -0,0 +1,16 @@ +description: find -L -newer with truly nonexistent reference (not a symlink) is fatal. +skip_assert_against_bash: false +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -type f -newer nonexistent.txt +expect: + stdout: "" + stderr_contains: + - "find: 'nonexistent.txt'" + exit_code: 1 diff --git a/tests/scenarios/cmd/find/predicates/newer_self_reference.yaml b/tests/scenarios/cmd/find/predicates/newer_self_reference.yaml new file mode 100644 index 00000000..d8f30158 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_self_reference.yaml @@ -0,0 +1,15 @@ +description: -newer ref where ref is the file itself — file is not newer than itself. +skip_assert_against_bash: false +setup: + files: + - path: dir/only.txt + content: "only" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -newer dir/only.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/newer_symlink_L_follows_target.yaml b/tests/scenarios/cmd/find/predicates/newer_symlink_L_follows_target.yaml new file mode 100644 index 00000000..f1aab71e --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_symlink_L_follows_target.yaml @@ -0,0 +1,32 @@ +description: find -L -newer with symlink ref uses target mtime, not symlink mtime. +skip_assert_against_bash: false +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + mod_time: "2020-01-01T00:00:00Z" + - path: dir/ref_link + symlink: target.txt + - path: dir/old.txt + content: "old" + chmod: 0644 + mod_time: "2019-01-01T00:00:00Z" + - path: dir/mid.txt + content: "mid" + chmod: 0644 + mod_time: "2023-06-01T00:00:00Z" + - path: dir/new.txt + content: "new" + chmod: 0644 + mod_time: "2030-01-01T00:00:00Z" +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -newer dir/ref_link -type f +expect: + stdout_unordered: |+ + dir/mid.txt + dir/new.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/newer_symlink_P_uses_lstat.yaml b/tests/scenarios/cmd/find/predicates/newer_symlink_P_uses_lstat.yaml new file mode 100644 index 00000000..9e14b4c8 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_symlink_P_uses_lstat.yaml @@ -0,0 +1,31 @@ +description: find -P -newer with symlink ref uses symlink mtime, not target mtime. +skip_assert_against_bash: false +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + mod_time: "2020-01-01T00:00:00Z" + - path: dir/ref_link + symlink: target.txt + - path: dir/old.txt + content: "old" + chmod: 0644 + mod_time: "2019-01-01T00:00:00Z" + - path: dir/mid.txt + content: "mid" + chmod: 0644 + mod_time: "2023-06-01T00:00:00Z" + - path: dir/new.txt + content: "new" + chmod: 0644 + mod_time: "2030-01-01T00:00:00Z" +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -newer dir/ref_link -type f +expect: + stdout: |+ + dir/new.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/newer_symlink_ref.yaml b/tests/scenarios/cmd/find/predicates/newer_symlink_ref.yaml new file mode 100644 index 00000000..71ab1225 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/newer_symlink_ref.yaml @@ -0,0 +1,16 @@ +description: -newer with broken symlink ref succeeds in default -P mode (lstat). +skip_assert_against_bash: false +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/ref_link + symlink: nonexistent_target +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -newer dir/ref_link -type f +expect: + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/path.yaml b/tests/scenarios/cmd/find/predicates/path.yaml new file mode 100644 index 00000000..645ad2ed --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/path.yaml @@ -0,0 +1,22 @@ +description: find -path matches full path with glob pattern. +setup: + files: + - path: src/main.go + content: "package main" + chmod: 0644 + - path: src/util.go + content: "package util" + chmod: 0644 + - path: doc/readme.md + content: "# Readme" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -path './src/*.go' -type f +expect: + stdout_unordered: |+ + ./src/main.go + ./src/util.go + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/path_malformed_bracket.yaml b/tests/scenarios/cmd/find/predicates/path_malformed_bracket.yaml new file mode 100644 index 00000000..7e51a52d --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/path_malformed_bracket.yaml @@ -0,0 +1,17 @@ +description: malformed bracket pattern in -path matches literal path. +setup: + files: + - path: "dir/[sub/file.txt" + content: "x" + chmod: 0644 + - path: dir/other/file.txt + content: "y" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -path 'dir/[sub/file.txt' +expect: + stdout: |+ + dir/[sub/file.txt + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/path_nested_start.yaml b/tests/scenarios/cmd/find/predicates/path_nested_start.yaml new file mode 100644 index 00000000..d954096f --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/path_nested_start.yaml @@ -0,0 +1,18 @@ +description: find -path with non-dot start path produces forward-slash-separated output. +setup: + files: + - path: src/sub/file.go + content: "package sub" + chmod: 0644 + - path: src/sub/other.txt + content: "other" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find src -path 'src/sub/*.go' -type f +expect: + stdout: |+ + src/sub/file.go + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/path_star_crosses_slash.yaml b/tests/scenarios/cmd/find/predicates/path_star_crosses_slash.yaml new file mode 100644 index 00000000..16721ac3 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/path_star_crosses_slash.yaml @@ -0,0 +1,18 @@ +description: find -path with '*' matches across '/' separators (GNU find behaviour). +setup: + files: + - path: d/a/file.txt + content: "hello" + chmod: 0644 + - path: d/b/other.txt + content: "world" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find d -path '*a/file*' +expect: + stdout: |+ + d/a/file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/true.yaml b/tests/scenarios/cmd/find/predicates/true.yaml new file mode 100644 index 00000000..92d8885a --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/true.yaml @@ -0,0 +1,20 @@ +description: find -true matches everything. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -true +expect: + stdout_unordered: |+ + dir + dir/a.txt + dir/b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/type_comma_separated.yaml b/tests/scenarios/cmd/find/predicates/type_comma_separated.yaml new file mode 100644 index 00000000..e40bed36 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/type_comma_separated.yaml @@ -0,0 +1,18 @@ +description: find -type f,d matches both files and directories. +skip_assert_against_bash: true +setup: + files: + - path: dir/sub/file.txt + content: "data" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f,d +expect: + stdout_unordered: |+ + dir + dir/sub + dir/sub/file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/type_dir.yaml b/tests/scenarios/cmd/find/predicates/type_dir.yaml new file mode 100644 index 00000000..33e171f6 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/type_dir.yaml @@ -0,0 +1,19 @@ +description: find -type d matches only directories. +setup: + files: + - path: dir/file.txt + content: "data" + chmod: 0644 + - path: dir/sub/nested.txt + content: "nested" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type d +expect: + stdout_unordered: |+ + dir + dir/sub + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/type_file.yaml b/tests/scenarios/cmd/find/predicates/type_file.yaml new file mode 100644 index 00000000..99d8de4a --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/type_file.yaml @@ -0,0 +1,19 @@ +description: find -type f matches only regular files. +setup: + files: + - path: dir/file.txt + content: "data" + chmod: 0644 + - path: dir/sub/nested.txt + content: "nested" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f +expect: + stdout_unordered: |+ + dir/file.txt + dir/sub/nested.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/type_symlink.yaml b/tests/scenarios/cmd/find/predicates/type_symlink.yaml new file mode 100644 index 00000000..5f0cc17d --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/type_symlink.yaml @@ -0,0 +1,18 @@ +description: find -type l matches symlinks without -L. +skip_assert_against_bash: true +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + - path: dir/link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type l +expect: + stdout: |+ + dir/link.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/wholename.yaml b/tests/scenarios/cmd/find/predicates/wholename.yaml new file mode 100644 index 00000000..ecbcf800 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/wholename.yaml @@ -0,0 +1,19 @@ +description: find -wholename is an alias for -path. +skip_assert_against_bash: true +setup: + files: + - path: dir/sub/file.txt + content: "data" + chmod: 0644 + - path: dir/other.txt + content: "other" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -wholename '*/sub/*' +expect: + stdout: |+ + dir/sub/file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/prune/basic.yaml b/tests/scenarios/cmd/find/prune/basic.yaml new file mode 100644 index 00000000..5ab3275e --- /dev/null +++ b/tests/scenarios/cmd/find/prune/basic.yaml @@ -0,0 +1,18 @@ +description: find -prune skips directory contents. +setup: + files: + - path: dir/skip/hidden.txt + content: "hidden" + chmod: 0644 + - path: dir/keep/visible.txt + content: "visible" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name skip -prune -o -type f -print +expect: + stdout: |+ + dir/keep/visible.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/prune/multiple_conditions.yaml b/tests/scenarios/cmd/find/prune/multiple_conditions.yaml new file mode 100644 index 00000000..cd3ce63e --- /dev/null +++ b/tests/scenarios/cmd/find/prune/multiple_conditions.yaml @@ -0,0 +1,22 @@ +description: find -prune with multiple prune targets. +setup: + files: + - path: dir/skip1/a.txt + content: "a" + chmod: 0644 + - path: dir/skip2/b.txt + content: "b" + chmod: 0644 + - path: dir/keep/c.txt + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir '(' -name skip1 -o -name skip2 ')' -prune -o -type f -print +expect: + stdout: |+ + dir/keep/c.txt + stderr: "" + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/find/prune/prune_wide_siblings.yaml b/tests/scenarios/cmd/find/prune/prune_wide_siblings.yaml new file mode 100644 index 00000000..932ed8de --- /dev/null +++ b/tests/scenarios/cmd/find/prune/prune_wide_siblings.yaml @@ -0,0 +1,50 @@ +description: find -prune skips one subdirectory among many wide siblings. +setup: + files: + - path: dir/skip/hidden.txt + content: "hidden" + chmod: 0644 + - path: dir/keep1/a.txt + content: "a" + chmod: 0644 + - path: dir/keep2/b.txt + content: "b" + chmod: 0644 + - path: dir/keep3/c.txt + content: "c" + chmod: 0644 + - path: dir/keep4/d.txt + content: "d" + chmod: 0644 + - path: dir/keep5/e.txt + content: "e" + chmod: 0644 + - path: dir/keep6/f.txt + content: "f" + chmod: 0644 + - path: dir/keep7/g.txt + content: "g" + chmod: 0644 + - path: dir/keep8/h.txt + content: "h" + chmod: 0644 + - path: dir/keep9/i.txt + content: "i" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name skip -prune -o -type f -print +expect: + stdout_unordered: |+ + dir/keep1/a.txt + dir/keep2/b.txt + dir/keep3/c.txt + dir/keep4/d.txt + dir/keep5/e.txt + dir/keep6/f.txt + dir/keep7/g.txt + dir/keep8/h.txt + dir/keep9/i.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/prune/prune_with_mindepth.yaml b/tests/scenarios/cmd/find/prune/prune_with_mindepth.yaml new file mode 100644 index 00000000..8a6f88b1 --- /dev/null +++ b/tests/scenarios/cmd/find/prune/prune_with_mindepth.yaml @@ -0,0 +1,23 @@ +description: "-prune below -mindepth threshold is never evaluated, so directory is descended into." +setup: + files: + - path: dir/skip/a.txt + content: "a" + chmod: 0644 + - path: dir/skip/sub/b.txt + content: "b" + chmod: 0644 + - path: dir/keep/c.txt + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -mindepth 2 -name skip -prune -o -type f -print +expect: + stdout_unordered: |+ + dir/keep/c.txt + dir/skip/a.txt + dir/skip/sub/b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_delete.yaml b/tests/scenarios/cmd/find/sandbox/blocked_delete.yaml new file mode 100644 index 00000000..468d3406 --- /dev/null +++ b/tests/scenarios/cmd/find/sandbox/blocked_delete.yaml @@ -0,0 +1,14 @@ +description: find -delete is blocked for sandbox safety. +skip_assert_against_bash: true # intentional: bash allows -delete; rshell blocks it +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -delete +expect: + stderr_contains: ["blocked"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_exec.yaml b/tests/scenarios/cmd/find/sandbox/blocked_exec.yaml new file mode 100644 index 00000000..8b5eef41 --- /dev/null +++ b/tests/scenarios/cmd/find/sandbox/blocked_exec.yaml @@ -0,0 +1,14 @@ +description: find -exec is blocked for sandbox safety. +skip_assert_against_bash: true # intentional: bash allows -exec; rshell blocks it +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -exec echo {} \; +expect: + stderr_contains: ["blocked"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_execdir.yaml b/tests/scenarios/cmd/find/sandbox/blocked_execdir.yaml new file mode 100644 index 00000000..e3ea2fdc --- /dev/null +++ b/tests/scenarios/cmd/find/sandbox/blocked_execdir.yaml @@ -0,0 +1,14 @@ +description: find -execdir is blocked for sandbox safety. +skip_assert_against_bash: true # intentional: bash allows -execdir; rshell blocks it +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -execdir echo {} \; +expect: + stderr_contains: ["blocked"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_fprint.yaml b/tests/scenarios/cmd/find/sandbox/blocked_fprint.yaml new file mode 100644 index 00000000..929bccc4 --- /dev/null +++ b/tests/scenarios/cmd/find/sandbox/blocked_fprint.yaml @@ -0,0 +1,14 @@ +description: find -fprint is blocked for sandbox safety. +skip_assert_against_bash: true # intentional: bash allows -fprint; rshell blocks it +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -fprint output.txt +expect: + stderr_contains: ["blocked"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_iregex.yaml b/tests/scenarios/cmd/find/sandbox/blocked_iregex.yaml new file mode 100644 index 00000000..4c4a5598 --- /dev/null +++ b/tests/scenarios/cmd/find/sandbox/blocked_iregex.yaml @@ -0,0 +1,14 @@ +description: find -iregex is blocked for sandbox safety. +skip_assert_against_bash: true # intentional: bash allows -iregex; rshell blocks it (ReDoS risk) +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -iregex '.*\.txt' +expect: + stderr_contains: ["blocked"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_newer_outside_sandbox.yaml b/tests/scenarios/cmd/find/sandbox/blocked_newer_outside_sandbox.yaml new file mode 100644 index 00000000..30b27e5a --- /dev/null +++ b/tests/scenarios/cmd/find/sandbox/blocked_newer_outside_sandbox.yaml @@ -0,0 +1,15 @@ +description: find -newer with a reference file outside allowed_paths is blocked. +skip_assert_against_bash: true # intentional: bash allows -newer /outside; rshell blocks it +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -newer /etc/hostname +expect: + stdout: "" + stderr_contains: ["find:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_ok.yaml b/tests/scenarios/cmd/find/sandbox/blocked_ok.yaml new file mode 100644 index 00000000..68d1023e --- /dev/null +++ b/tests/scenarios/cmd/find/sandbox/blocked_ok.yaml @@ -0,0 +1,14 @@ +description: find -ok is blocked for sandbox safety. +skip_assert_against_bash: true # intentional: bash allows -ok; rshell blocks it +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -ok echo {} \; +expect: + stderr_contains: ["blocked"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/sandbox/blocked_regex.yaml b/tests/scenarios/cmd/find/sandbox/blocked_regex.yaml new file mode 100644 index 00000000..2f3c98d6 --- /dev/null +++ b/tests/scenarios/cmd/find/sandbox/blocked_regex.yaml @@ -0,0 +1,14 @@ +description: find -regex is blocked for sandbox safety. +skip_assert_against_bash: true # intentional: bash allows -regex; rshell blocks it (ReDoS risk) +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -regex '.*\.txt' +expect: + stderr_contains: ["blocked"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/size/blocks_default.yaml b/tests/scenarios/cmd/find/size/blocks_default.yaml new file mode 100644 index 00000000..9649b013 --- /dev/null +++ b/tests/scenarios/cmd/find/size/blocks_default.yaml @@ -0,0 +1,19 @@ +description: find -size 1 with no suffix uses 512-byte blocks. +skip_assert_against_bash: true +setup: + files: + - path: dir/small.txt + content: "hi" + chmod: 0644 + - path: dir/empty.txt + content: "" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -size 1 +expect: + stdout: |+ + dir/small.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/size/bytes.yaml b/tests/scenarios/cmd/find/size/bytes.yaml new file mode 100644 index 00000000..0b1c7bb5 --- /dev/null +++ b/tests/scenarios/cmd/find/size/bytes.yaml @@ -0,0 +1,19 @@ +description: find -size with byte suffix matches file size. +skip_assert_against_bash: true +setup: + files: + - path: dir/small.txt + content: "hi" + chmod: 0644 + - path: dir/big.txt + content: "hello world, this is a larger file with more content in it" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -size +10c +expect: + stdout: |+ + dir/big.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/size/exact_bytes.yaml b/tests/scenarios/cmd/find/size/exact_bytes.yaml new file mode 100644 index 00000000..ddea2b9a --- /dev/null +++ b/tests/scenarios/cmd/find/size/exact_bytes.yaml @@ -0,0 +1,22 @@ +description: find -size 2c matches files exactly 2 bytes. +skip_assert_against_bash: true +setup: + files: + - path: dir/two.txt + content: "hi" + chmod: 0644 + - path: dir/three.txt + content: "hey" + chmod: 0644 + - path: dir/one.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -size 2c +expect: + stdout: |+ + dir/two.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/size/gigabytes.yaml b/tests/scenarios/cmd/find/size/gigabytes.yaml new file mode 100644 index 00000000..7a7320fd --- /dev/null +++ b/tests/scenarios/cmd/find/size/gigabytes.yaml @@ -0,0 +1,20 @@ +description: "find -size with G (gigabyte) unit works end-to-end." +setup: + files: + - path: dir/small.txt + content: "hello" + chmod: 0644 + - path: dir/tiny.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -size +1G + find dir -type f -size -2G +expect: + stdout_unordered: |+ + dir/small.txt + dir/tiny.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/size/kilobytes.yaml b/tests/scenarios/cmd/find/size/kilobytes.yaml new file mode 100644 index 00000000..79a21b37 --- /dev/null +++ b/tests/scenarios/cmd/find/size/kilobytes.yaml @@ -0,0 +1,19 @@ +description: find -size 1k matches files rounded up to 1024-byte blocks. +skip_assert_against_bash: true +setup: + files: + - path: dir/small.txt + content: "hi" + chmod: 0644 + - path: dir/empty.txt + content: "" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -size 1k +expect: + stdout: |+ + dir/small.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/size/megabytes.yaml b/tests/scenarios/cmd/find/size/megabytes.yaml new file mode 100644 index 00000000..94a6b7c0 --- /dev/null +++ b/tests/scenarios/cmd/find/size/megabytes.yaml @@ -0,0 +1,15 @@ +description: find -size +1M on small files matches nothing. +skip_assert_against_bash: true +setup: + files: + - path: dir/small.txt + content: "this is a small file" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -size +1M +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/size/various_units.yaml b/tests/scenarios/cmd/find/size/various_units.yaml new file mode 100644 index 00000000..1a7a26b5 --- /dev/null +++ b/tests/scenarios/cmd/find/size/various_units.yaml @@ -0,0 +1,23 @@ +description: find -size with negative byte count. +skip_assert_against_bash: true +setup: + files: + - path: dir/empty.txt + content: "" + chmod: 0644 + - path: dir/small.txt + content: "hi" + chmod: 0644 + - path: dir/bigger.txt + content: "hello world, how are you today?" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -size -5c +expect: + stdout_unordered: |+ + dir/empty.txt + dir/small.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/size/word_unit.yaml b/tests/scenarios/cmd/find/size/word_unit.yaml new file mode 100644 index 00000000..825d182a --- /dev/null +++ b/tests/scenarios/cmd/find/size/word_unit.yaml @@ -0,0 +1,22 @@ +description: find -size with w suffix uses 2-byte word blocks. +skip_assert_against_bash: true +setup: + files: + - path: dir/two.txt + content: "hi" + chmod: 0644 + - path: dir/three.txt + content: "hey" + chmod: 0644 + - path: dir/empty.txt + content: "" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -size 1w +expect: + stdout: |+ + dir/two.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/size/zero_bytes.yaml b/tests/scenarios/cmd/find/size/zero_bytes.yaml new file mode 100644 index 00000000..a10b9a58 --- /dev/null +++ b/tests/scenarios/cmd/find/size/zero_bytes.yaml @@ -0,0 +1,19 @@ +description: find -size 0c matches empty files. +skip_assert_against_bash: true +setup: + files: + - path: dir/empty.txt + content: "" + chmod: 0644 + - path: dir/notempty.txt + content: "data" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -size 0c +expect: + stdout: |+ + dir/empty.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/L_dangling_type_l.yaml b/tests/scenarios/cmd/find/symlinks/L_dangling_type_l.yaml new file mode 100644 index 00000000..c809d638 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/L_dangling_type_l.yaml @@ -0,0 +1,20 @@ +description: find -L -type l matches only dangling symlinks (stat fails, lstat shows symlink). +skip_assert_against_bash: false +setup: + files: + - path: dir/target.txt + content: "hello" + chmod: 0644 + - path: dir/valid_link.txt + symlink: target.txt + - path: dir/dangling.txt + symlink: nonexistent +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -maxdepth 1 -type l +expect: + stdout: |+ + dir/dangling.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/L_empty_follows.yaml b/tests/scenarios/cmd/find/symlinks/L_empty_follows.yaml new file mode 100644 index 00000000..0efa6917 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/L_empty_follows.yaml @@ -0,0 +1,18 @@ +description: find -L -empty follows symlink to empty target file. +skip_assert_against_bash: false +setup: + files: + - path: dir/empty_target.txt + content: "" + chmod: 0644 + - path: dir/empty_link.txt + symlink: empty_target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -maxdepth 1 -name empty_link.txt -empty +expect: + stdout: |+ + dir/empty_link.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/L_mmin_follows_target.yaml b/tests/scenarios/cmd/find/symlinks/L_mmin_follows_target.yaml new file mode 100644 index 00000000..2f6407f3 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/L_mmin_follows_target.yaml @@ -0,0 +1,19 @@ +description: find -L -mmin uses target file mtime, not symlink mtime. +skip_assert_against_bash: false +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + mod_time: "2020-06-15T00:00:00Z" + - path: dir/link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -maxdepth 1 -name link.txt -mmin +525600 -type f +expect: + stdout: |+ + dir/link.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/L_mtime_follows_target.yaml b/tests/scenarios/cmd/find/symlinks/L_mtime_follows_target.yaml new file mode 100644 index 00000000..b659f1f2 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/L_mtime_follows_target.yaml @@ -0,0 +1,19 @@ +description: find -L -mtime uses target file mtime, not symlink mtime. +skip_assert_against_bash: false +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + mod_time: "2020-06-15T00:00:00Z" + - path: dir/link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -maxdepth 1 -name link.txt -mtime +1000 -type f +expect: + stdout: |+ + dir/link.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/L_type_d_dir_symlink.yaml b/tests/scenarios/cmd/find/symlinks/L_type_d_dir_symlink.yaml new file mode 100644 index 00000000..4f4fb802 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/L_type_d_dir_symlink.yaml @@ -0,0 +1,18 @@ +description: find -L -type d matches symlink whose target is a directory. +skip_assert_against_bash: false +setup: + files: + - path: dir/realdir/file.txt + content: "x" + chmod: 0644 + - path: dir/dirlink + symlink: realdir +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -maxdepth 1 -name dirlink -type d +expect: + stdout: |+ + dir/dirlink + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/L_type_f_follows.yaml b/tests/scenarios/cmd/find/symlinks/L_type_f_follows.yaml new file mode 100644 index 00000000..3c641f81 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/L_type_f_follows.yaml @@ -0,0 +1,18 @@ +description: find -L -type f matches symlink whose target is a regular file. +skip_assert_against_bash: false +setup: + files: + - path: dir/target.txt + content: "hello" + chmod: 0644 + - path: dir/link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -maxdepth 1 -name link.txt -type f +expect: + stdout: |+ + dir/link.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/P_mmin_uses_symlink.yaml b/tests/scenarios/cmd/find/symlinks/P_mmin_uses_symlink.yaml new file mode 100644 index 00000000..5e44f10d --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/P_mmin_uses_symlink.yaml @@ -0,0 +1,18 @@ +description: find -P -mmin uses symlink mtime — recent symlink does not match large +N. +skip_assert_against_bash: false +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + mod_time: "2020-06-15T00:00:00Z" + - path: dir/link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -maxdepth 1 -name link.txt -mmin +525600 -type l +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/P_mtime_uses_symlink.yaml b/tests/scenarios/cmd/find/symlinks/P_mtime_uses_symlink.yaml new file mode 100644 index 00000000..d4c1a603 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/P_mtime_uses_symlink.yaml @@ -0,0 +1,19 @@ +description: find -P -mtime uses symlink mtime (recent), not target mtime (old). +skip_assert_against_bash: false +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + mod_time: "2020-06-15T00:00:00Z" + - path: dir/link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -maxdepth 1 -name link.txt -mtime -10 -type l +expect: + stdout: |+ + dir/link.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/P_overrides_L.yaml b/tests/scenarios/cmd/find/symlinks/P_overrides_L.yaml new file mode 100644 index 00000000..46c6cbd0 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/P_overrides_L.yaml @@ -0,0 +1,17 @@ +description: -P after -L overrides symlink following (last option wins). +skip_assert_against_bash: true +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + - path: dir/link + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find -L -P dir -name link -type l +expect: + stdout: |+ + dir/link + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/broken_symlink.yaml b/tests/scenarios/cmd/find/symlinks/broken_symlink.yaml new file mode 100644 index 00000000..e23b6d21 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/broken_symlink.yaml @@ -0,0 +1,18 @@ +description: find -L with broken symlink falls back to lstat. +skip_assert_against_bash: true +setup: + files: + - path: dir/good.txt + content: "good" + chmod: 0644 + - path: dir/broken.txt + symlink: nonexistent.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -type f +expect: + stdout: |+ + dir/good.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/dangling_root_L.yaml b/tests/scenarios/cmd/find/symlinks/dangling_root_L.yaml new file mode 100644 index 00000000..82eeb5e7 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/dangling_root_L.yaml @@ -0,0 +1,15 @@ +description: find -L with dangling symlink as starting path falls back to lstat. +skip_assert_against_bash: true # symlink setup differs +setup: + files: + - path: dangling + symlink: nonexistent_target +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dangling +expect: + stdout: |+ + dangling + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/follow_L_flag.yaml b/tests/scenarios/cmd/find/symlinks/follow_L_flag.yaml new file mode 100644 index 00000000..c4dca58a --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/follow_L_flag.yaml @@ -0,0 +1,18 @@ +description: find -L follows symlinks so -type f matches through links. +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + - path: dir/link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -type f +expect: + stdout_unordered: |+ + dir/link.txt + dir/target.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/follow_L_type_not_symlink.yaml b/tests/scenarios/cmd/find/symlinks/follow_L_type_not_symlink.yaml new file mode 100644 index 00000000..3ea95eba --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/follow_L_type_not_symlink.yaml @@ -0,0 +1,17 @@ +description: find -L -type l matches nothing because links are resolved. +skip_assert_against_bash: true +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + - path: dir/link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -type l +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/loop_detection_with_L.yaml b/tests/scenarios/cmd/find/symlinks/loop_detection_with_L.yaml new file mode 100644 index 00000000..eb235d35 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/loop_detection_with_L.yaml @@ -0,0 +1,17 @@ +description: -L detects symlink loop and does not print loop entry. +skip_assert_against_bash: true +setup: + files: + - path: dir/a/file.txt + content: "hello" + chmod: 0644 + - path: dir/a/loop + symlink: .. +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir +expect: + stdout_contains: ["dir/a/file.txt"] + stderr_contains: ["File system loop detected"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/symlinks/multiple_links_same_target.yaml b/tests/scenarios/cmd/find/symlinks/multiple_links_same_target.yaml new file mode 100644 index 00000000..14a3d230 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/multiple_links_same_target.yaml @@ -0,0 +1,20 @@ +description: -L traverses multiple symlinks to the same target without false loop errors. +setup: + files: + - path: shared/file.txt + content: "hello" + chmod: 0644 + - path: dir/link1 + symlink: ../shared + - path: dir/link2 + symlink: ../shared +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -type f +expect: + stdout_unordered: |+ + dir/link1/file.txt + dir/link2/file.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/no_follow_default.yaml b/tests/scenarios/cmd/find/symlinks/no_follow_default.yaml new file mode 100644 index 00000000..c43fcec6 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/no_follow_default.yaml @@ -0,0 +1,19 @@ +description: Default behavior lists symlinks as-is without following. +setup: + files: + - path: dir/target.txt + content: "target" + chmod: 0644 + - path: dir/link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + find dir +expect: + stdout_unordered: |+ + dir + dir/link.txt + dir/target.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/symlinks/symlink_loop_detection.yaml b/tests/scenarios/cmd/find/symlinks/symlink_loop_detection.yaml new file mode 100644 index 00000000..413f38bb --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/symlink_loop_detection.yaml @@ -0,0 +1,18 @@ +description: find -L with cyclic symlink terminates without infinite recursion. +skip_assert_against_bash: true +setup: + files: + - path: dir/a/file.txt + content: "data" + chmod: 0644 + - path: dir/a/loop + symlink: .. +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -maxdepth 5 -type f +expect: + stdout: |+ + dir/a/file.txt + stderr_contains: ["find: File system loop detected"] + exit_code: 1 diff --git a/tests/scenarios/cmd/find/symlinks/symlink_loop_identity.yaml b/tests/scenarios/cmd/find/symlinks/symlink_loop_identity.yaml new file mode 100644 index 00000000..41789565 --- /dev/null +++ b/tests/scenarios/cmd/find/symlinks/symlink_loop_identity.yaml @@ -0,0 +1,18 @@ +description: find -L detects symlink loops by file identity across different paths. +skip_assert_against_bash: true +setup: + files: + - path: dir/a/file.txt + content: "hello" + chmod: 0644 + - path: dir/a/link_to_dir + symlink: ../../dir +input: + allowed_paths: ["$DIR"] + script: |+ + find -L dir -maxdepth 10 -type f +expect: + stdout: |+ + dir/a/file.txt + stderr_contains: ["find: File system loop detected"] + exit_code: 1 diff --git a/tests/scenarios/cmd/ls/sandbox/outside_allowed_paths.yaml b/tests/scenarios/cmd/ls/sandbox/outside_allowed_paths.yaml index bc70f890..1496e750 100644 --- a/tests/scenarios/cmd/ls/sandbox/outside_allowed_paths.yaml +++ b/tests/scenarios/cmd/ls/sandbox/outside_allowed_paths.yaml @@ -11,5 +11,6 @@ input: expect: stdout: "" stderr: "ls: cannot access '/etc': permission denied\n" - stderr_windows: "ls: cannot access '/etc': statat etc: no such file or directory\n" + stderr_contains_windows: + - "ls: cannot access '/etc':" exit_code: 1 diff --git a/tests/scenarios_test.go b/tests/scenarios_test.go index 3f421904..b7d25b9f 100644 --- a/tests/scenarios_test.go +++ b/tests/scenarios_test.go @@ -17,6 +17,9 @@ import ( "strconv" "strings" "testing" + "time" + + "slices" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -48,7 +51,8 @@ type setupFile struct { Path string `yaml:"path"` Content string `yaml:"content"` Chmod os.FileMode `yaml:"chmod"` - Symlink string `yaml:"symlink"` // if set, create a symlink pointing to this target (relative to test dir) + Symlink string `yaml:"symlink"` // if set, create a symlink pointing to this target (relative to test dir) + ModTime string `yaml:"mod_time"` // if set, override the file's modification time (RFC 3339 format) } // input holds the shell script to execute. @@ -67,6 +71,7 @@ type input struct { // expected holds the expected output for a scenario. type expected struct { Stdout string `yaml:"stdout"` + StdoutUnordered string `yaml:"stdout_unordered"` StdoutWindows *string `yaml:"stdout_windows"` StdoutContains []string `yaml:"stdout_contains"` StdoutContainsWindows []string `yaml:"stdout_contains_windows"` @@ -133,6 +138,11 @@ func setupTestDir(t *testing.T, sc scenario) string { require.NoError(t, os.Chmod(fullPath, f.Chmod), "failed to chmod file %s", f.Path) } } + if f.ModTime != "" { + mt, err := time.Parse(time.RFC3339, f.ModTime) + require.NoError(t, err, "failed to parse mod_time for %s", f.Path) + require.NoError(t, os.Chtimes(fullPath, mt, mt), "failed to set mod_time for %s", f.Path) + } } return dir } @@ -223,6 +233,12 @@ func assertExpectations(t *testing.T, sc scenario, stdout, stderr string, exitCo for _, substr := range stdoutContains { assert.Contains(t, stdout, substr, "stdout should contain %q", substr) } + } else if sc.Expect.StdoutUnordered != "" { + wantLines := strings.Split(sc.Expect.StdoutUnordered, "\n") + gotLines := strings.Split(stdout, "\n") + slices.Sort(wantLines) + slices.Sort(gotLines) + assert.Equal(t, wantLines, gotLines, "stdout mismatch (unordered)") } else { assert.Equal(t, expectedStdout, stdout, "stdout mismatch") } @@ -262,6 +278,11 @@ func setupTestDirIn(t *testing.T, parentDir, scriptsDir, subdir string, sc scena require.NoError(t, os.Chmod(fullPath, f.Chmod), "failed to chmod file %s", f.Path) } } + if f.ModTime != "" { + mt, err := time.Parse(time.RFC3339, f.ModTime) + require.NoError(t, err, "failed to parse mod_time for %s", f.Path) + require.NoError(t, os.Chtimes(fullPath, mt, mt), "failed to set mod_time for %s", f.Path) + } } require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, subdir+".sh"), []byte(sc.Input.Script), 0644)) }