diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index c615f4f1..3aaf14be 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -12,7 +12,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `echo [-neE] [ARG]...` — write arguments to stdout; `-n` suppresses trailing newline, `-e` enables backslash escapes, `-E` disables them (default) - ✅ `exit [N]` — exit the shell with status N (default 0) - ✅ `false` — return exit code 1 -- ✅ `find [-L] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `-name`, `-iname`, `-path`, `-ipath`, `-type`, `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-prune`, logical operators (`!`, `-a`, `-o`, `()`); blocks `-exec`, `-delete`, `-regex` for sandbox safety +- ✅ `find [-L] [-P] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `--help`, `-name`, `-iname`, `-path`, `-ipath`, `-type` (b,c,d,f,l,p,s), `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-perm`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-prune`, `-quit`, 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 - ✅ `help` — display all available builtin commands with brief descriptions; for detailed flag info, use ` --help` diff --git a/allowedpaths/portable_unix.go b/allowedpaths/portable_unix.go index e507007d..96d395b8 100644 --- a/allowedpaths/portable_unix.go +++ b/allowedpaths/portable_unix.go @@ -29,16 +29,90 @@ func FileIdentity(_ string, info fs.FileInfo, _ *Sandbox) (uint64, uint64, bool) return uint64(st.Dev), uint64(st.Ino), true } +func (r *root) accessCheck(rel string, checkRead, checkWrite, checkExec bool) (fs.FileInfo, error) { + // Write-only or exec-only checks (no read): single Stat + mode-bit + // inspection. No TOCTOU because there is only one resolution. + if !checkRead { + info, err := r.root.Stat(rel) + if err != nil { + return nil, err + } + if !effectiveHasPerm(info, false, checkWrite, checkExec) { + return info, os.ErrPermission + } + return info, nil + } + + // Read checks: open-first to get an fd, then fstat the fd. + // O_NONBLOCK prevents blocking on FIFOs (open returns immediately + // even without a writer). It is harmless on regular files and dirs. + f, openErr := r.root.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0) + if openErr != nil { + // OpenFile failed. Possible reasons: + // - Permission denied on a regular file (kernel/ACL) + // - Unopenable type (Unix socket → ENXIO/EOPNOTSUPP) + // - Path does not exist or symlink escape blocked + // + // Fall back to Stat for metadata. This is NOT a TOCTOU risk: + // the open already failed, so there is no fd pointing to a + // wrong inode. + info, err := r.root.Stat(rel) + if err != nil { + return nil, err + } + // For regular files, the open failure is the kernel's + // authoritative answer (may reflect ACLs that mode bits + // miss). Trust it. + if info.Mode().IsRegular() { + return info, os.ErrPermission + } + // Non-regular files that can't be opened (e.g. sockets): + // fall back to mode-bit inspection. + if !effectiveHasPerm(info, checkRead, checkWrite, checkExec) { + return info, os.ErrPermission + } + return info, nil + } + + // OpenFile succeeded — fstat the fd for metadata from this exact inode. + info, err := f.Stat() + closeErr := f.Close() + if err != nil { + return nil, err + } + if closeErr != nil { + return nil, closeErr + } + + // For regular files, the successful open proves read permission + // (kernel-level, ACL-accurate). For FIFOs and directories, + // O_NONBLOCK open succeeds regardless of read permission, so + // mode-bit check is still needed. + if !info.Mode().IsRegular() { + if !effectiveHasPerm(info, checkRead, checkWrite, checkExec) { + return info, os.ErrPermission + } + return info, nil + } + + // Regular file: read proven. Check write/exec if needed. + if checkWrite || checkExec { + if !effectiveHasPerm(info, false, checkWrite, checkExec) { + return info, os.ErrPermission + } + } + return info, nil +} + // 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 -// the effective UID and GID of the running process. +// permission by inspecting the file's owner/group/other permission class +// that applies to the effective UID and GID of the running process. // // On Unix this uses the Stat_t from info.Sys() to determine the owning // UID/GID and then selects the owner, group, or other permission bits // accordingly. If the type assertion fails (should not happen in practice), // it falls back to checking any-class bits. -func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWrite, checkExec bool) bool { +func effectiveHasPerm(info fs.FileInfo, checkRead, checkWrite, checkExec bool) bool { perm := info.Mode().Perm() // Determine which permission class applies to the current process. @@ -46,11 +120,16 @@ func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWr ownerBits := fs.FileMode(0007) // other bits by default if st, ok := info.Sys().(*syscall.Stat_t); ok { uid := os.Getuid() + if uid == 0 { + // Root bypasses read/write permission checks (CAP_DAC_OVERRIDE). + // Execute still requires at least one x bit to be set. + if checkExec && perm&0111 == 0 { + return false + } + return true + } gid := os.Getgid() switch { - case uid == 0: - // root can read/write anything; for execute, any x bit suffices. - ownerBits = 0777 case int(st.Uid) == uid: ownerBits = 0700 case int(st.Gid) == gid: @@ -70,14 +149,18 @@ func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWr } } + if checkRead { + if perm&0444&ownerBits == 0 { + return false + } + } if checkWrite { - // Intersect the write mask with the applicable owner bits. - if perm&writeMask&ownerBits == 0 { + if perm&0222&ownerBits == 0 { return false } } if checkExec { - if perm&execMask&ownerBits == 0 { + if perm&0111&ownerBits == 0 { return false } } diff --git a/allowedpaths/portable_windows.go b/allowedpaths/portable_windows.go index 62de2413..6f64b06d 100644 --- a/allowedpaths/portable_windows.go +++ b/allowedpaths/portable_windows.go @@ -44,13 +44,44 @@ func FileIdentity(absPath string, _ fs.FileInfo, sandbox *Sandbox) (uint64, uint 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. -func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWrite, checkExec bool) bool { - perm := info.Mode().Perm() - if checkWrite && perm&writeMask == 0 { - return false - } - return !(checkExec && perm&execMask == 0) +// accessCheck verifies the path is inside the sandbox via os.Root.Stat, +// then checks read permission by attempting to open the file through +// os.Root. This respects NTFS ACLs — the kernel denies the open if +// the current user lacks read permission. Named pipes cannot appear in +// regular directories on Windows, so this cannot block. +// +// - Read: verified by opening through os.Root (respects NTFS ACLs). +// - Write: checked via mode bits from Stat. On Windows, +// FILE_ATTRIBUTE_READONLY clears the write permission bits in +// Mode().Perm(), so mode-bit inspection is reliable. +// - Execute: Windows has no POSIX execute bits. The check always +// returns ErrPermission so that test -x behaves like a POSIX shell. +func (r *root) accessCheck(rel string, checkRead, checkWrite, checkExec bool) (fs.FileInfo, error) { + info, err := r.root.Stat(rel) + if err != nil { + return nil, err + } + + // Windows has no POSIX execute bits — always deny execute checks. + if checkExec { + return info, os.ErrPermission + } + + // On Windows, FILE_ATTRIBUTE_READONLY clears the write permission + // bits in Mode().Perm(). Check them for write access. + if checkWrite && info.Mode().Perm()&0200 == 0 { + return info, os.ErrPermission + } + + if checkRead && !info.IsDir() { + f, err := r.root.OpenFile(rel, os.O_RDONLY, 0) + if err != nil { + return info, os.ErrPermission + } + if err := f.Close(); err != nil { + return info, err + } + } + + return info, nil } diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 40c74293..9cccd6ed 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -87,6 +87,18 @@ func (s *Sandbox) resolve(absPath string) (*os.Root, string, bool) { // Access checks whether the resolved path is accessible with the given mode. // All operations go through os.Root to stay within the sandbox. // Mode: 0x04 = read, 0x02 = write, 0x01 = execute. +// +// On Unix, read permission for regular files is verified by attempting +// to open through os.Root with O_NONBLOCK (fd-relative openat, respects +// POSIX ACLs, never blocks on FIFOs). Metadata is obtained from the +// opened fd via fstat to eliminate TOCTOU between open and stat. +// For special files where open fails (e.g. sockets), and for write and +// execute checks, mode-bit inspection is used on the fd-relative Stat +// result. On Windows, the same OpenFile approach is used for read +// checks; write and execute checks are not performed. +// +// All operations are fd-relative through os.Root — no filesystem path is +// re-resolved through the mutable namespace after initial validation. func (s *Sandbox) Access(path string, cwd string, mode uint32) error { absPath := toAbs(path, cwd) @@ -102,42 +114,14 @@ func (s *Sandbox) Access(path string, cwd string, mode uint32) error { continue } - // Open through os.Root once. This checks read access and gives - // us a file descriptor for an atomic Stat (no TOCTOU window). - f, err := ar.root.Open(rel) + // accessCheck opens or stats the path through os.Root and + // performs the permission check (fd-relative OpenFile with + // O_NONBLOCK for reads on Unix, mode-bit inspection for + // everything else). + _, err = ar.accessCheck(rel, mode&0x04 != 0, mode&0x02 != 0, mode&0x01 != 0) if err != nil { - if mode&0x04 != 0 && !IsErrIsDirectory(err) { - return PortablePathError(err) - } - // Read not requested, or target is a directory; fall back to Stat. - info, serr := ar.root.Stat(rel) - if serr != nil { - return PortablePathError(serr) - } - if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) { - return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} - } - return nil - } - - // For write and execute, use mode bits from f.Stat() on the - // open fd — atomic, no TOCTOU window. - // The sandbox is read-only so -w is informational only. - // effectiveHasPerm checks the permission class (owner/group/other) - // that applies to the current process's effective UID/GID on Unix, - // rather than the union of all classes. - if mode&0x03 != 0 { - info, err := f.Stat() - if err != nil { - f.Close() - return PortablePathError(err) - } - if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) { - f.Close() - return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} - } + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} } - f.Close() return nil } return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} diff --git a/allowedpaths/sandbox_unix_test.go b/allowedpaths/sandbox_unix_test.go new file mode 100644 index 00000000..2d45e74c --- /dev/null +++ b/allowedpaths/sandbox_unix_test.go @@ -0,0 +1,416 @@ +// 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. + +//go:build !windows + +package allowedpaths + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAccessFIFODoesNotBlock verifies that Access on a FIFO (named pipe) with +// no writer returns immediately instead of blocking. Before the fix, Access +// used os.Root.Open which blocks on FIFOs until a writer appears. +func TestAccessFIFODoesNotBlock(t *testing.T) { + dir := t.TempDir() + fifoPath := filepath.Join(dir, "pipe") + require.NoError(t, syscall.Mkfifo(fifoPath, 0644)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + done := make(chan error, 1) + go func() { + done <- sb.Access("pipe", dir, 0x04) // read check + }() + + select { + case err := <-done: + // Should succeed (file exists and is readable) without blocking. + assert.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Access blocked on FIFO — expected non-blocking stat-based check") + } +} + +// TestAccessReadPermissionDenied verifies that Access returns an error for +// files that are not readable by the current user. +func TestAccessReadPermissionDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root bypasses permission checks") + } + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "noread.txt"), []byte("secret"), 0200)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.Access("noread.txt", dir, 0x04) + assert.ErrorIs(t, err, os.ErrPermission) +} + +// TestAccessWriteDenied verifies that Access returns an error for files +// that are not writable by the current user (mode 0444). +func TestAccessWriteDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root bypasses permission checks") + } + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "readonly.txt"), []byte("data"), 0444)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.Access("readonly.txt", dir, 0x02) + assert.ErrorIs(t, err, os.ErrPermission) +} + +// TestAccessExecDenied verifies that Access returns an error for files +// that are not executable by the current user (mode 0644). +func TestAccessExecDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root bypasses permission checks") + } + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "noexec.txt"), []byte("data"), 0644)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.Access("noexec.txt", dir, 0x01) + assert.ErrorIs(t, err, os.ErrPermission) +} + +// TestAccessReadAllowed verifies that Access succeeds for a readable file. +func TestAccessReadAllowed(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "readable.txt"), []byte("data"), 0644)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.NoError(t, sb.Access("readable.txt", dir, 0x04)) +} + +// TestAccessWriteAllowed verifies that Access succeeds for a writable file. +func TestAccessWriteAllowed(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "writable.txt"), []byte("data"), 0644)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.NoError(t, sb.Access("writable.txt", dir, 0x02)) +} + +// TestAccessExecAllowed verifies that Access succeeds for an executable file. +func TestAccessExecAllowed(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "script.sh"), []byte("#!/bin/sh"), 0755)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.NoError(t, sb.Access("script.sh", dir, 0x01)) +} + +// TestAccessNonexistent verifies that Access fails for a missing file. +func TestAccessNonexistent(t *testing.T) { + dir := t.TempDir() + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.Access("missing.txt", dir, 0x04) + assert.Error(t, err) +} + +// TestAccessOutsideSandbox verifies that Access fails for a path +// outside the sandbox. +func TestAccessOutsideSandbox(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.Access(filepath.Join(outside, "secret.txt"), dir, 0x04) + assert.ErrorIs(t, err, os.ErrPermission) +} + +// TestAccessDirectory verifies that Access works on directories. +func TestAccessDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.NoError(t, sb.Access("subdir", dir, 0x04)) +} + +// TestAccessSymlinkWithinSandbox verifies that Access succeeds for a +// symlink that resolves to a target within the sandbox. +func TestAccessSymlinkWithinSandbox(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "target.txt"), []byte("data"), 0644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.NoError(t, sb.Access("link.txt", dir, 0x04)) +} + +// TestAccessSymlinkEscapeBlocked verifies that Access blocks symlinks +// that resolve outside the sandbox. +func TestAccessSymlinkEscapeBlocked(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644)) + require.NoError(t, os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(dir, "escape.txt"))) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.Access("escape.txt", dir, 0x04) + assert.Error(t, err) +} + +// TestAccessCombinedModes verifies that Access correctly checks +// combined permission modes (read+write, read+exec, etc.). +func TestAccessCombinedModes(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root bypasses permission checks") + } + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "rx.sh"), []byte("#!/bin/sh"), 0555)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + // Read+exec should succeed on 0555 file. + assert.NoError(t, sb.Access("rx.sh", dir, 0x04|0x01)) + + // Write should fail on 0555 file. + assert.ErrorIs(t, sb.Access("rx.sh", dir, 0x02), os.ErrPermission) + + // Read+write should fail on 0555 file. + assert.ErrorIs(t, sb.Access("rx.sh", dir, 0x04|0x02), os.ErrPermission) +} + +// TestAccessReadRegularFileOpenFile verifies that read access on a +// regular file uses the fd-relative OpenFile path (not syscall.Access). +// A file with 0200 (write-only) should be denied. +func TestAccessReadRegularFileOpenFile(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root bypasses permission checks") + } + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "writeonly.txt"), []byte("data"), 0200)) + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + assert.ErrorIs(t, sb.Access("writeonly.txt", dir, 0x04), os.ErrPermission) +} + +// TestAccessReadRegularFileAllowed verifies read succeeds on a +// readable regular file via the OpenFile path. +func TestAccessReadRegularFileAllowed(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "readable.txt"), []byte("data"), 0644)) + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + assert.NoError(t, sb.Access("readable.txt", dir, 0x04)) +} + +// TestAccessFIFOReadFallsBackToModeBits verifies that FIFOs do NOT +// use OpenFile (which would block) and instead use effectiveHasPerm. +// A readable FIFO (0644) should pass the mode-bit check. +func TestAccessFIFOReadFallsBackToModeBits(t *testing.T) { + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + done := make(chan error, 1) + go func() { done <- sb.Access("pipe", dir, 0x04) }() + select { + case err := <-done: + assert.NoError(t, err) // readable FIFO should pass mode-bit check + case <-time.After(2 * time.Second): + t.Fatal("Access blocked on FIFO") + } +} + +// TestAccessFIFOReadDeniedModeBits verifies that a non-readable FIFO +// (0200) is correctly denied via mode-bit fallback. +func TestAccessFIFOReadDeniedModeBits(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root bypasses permission checks") + } + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0200)) + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + done := make(chan error, 1) + go func() { done <- sb.Access("pipe", dir, 0x04) }() + select { + case err := <-done: + assert.ErrorIs(t, err, os.ErrPermission) + case <-time.After(2 * time.Second): + t.Fatal("Access blocked on FIFO") + } +} + +// TestAccessDirectoryReadUsesModeBits verifies that directory read +// checks use mode-bit fallback (not OpenFile, which returns a handle). +func TestAccessDirectoryReadUsesModeBits(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + assert.NoError(t, sb.Access("subdir", dir, 0x04)) +} + +// TestAccessDirectoryReadDenied verifies that a non-readable directory +// (0300) is denied via mode-bit inspection. +func TestAccessDirectoryReadDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root bypasses permission checks") + } + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "noread"), 0300)) + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + assert.ErrorIs(t, sb.Access("noread", dir, 0x04), os.ErrPermission) +} + +// TestAccessReadWriteCombined verifies combined read+write checks. +// Read uses OpenFile (ACL-accurate), write uses effectiveHasPerm. +func TestAccessReadWriteCombined(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root bypasses permission checks") + } + dir := t.TempDir() + // 0444 = readable but not writable + require.NoError(t, os.WriteFile(filepath.Join(dir, "readonly.txt"), []byte("data"), 0444)) + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + // Read-only succeeds + assert.NoError(t, sb.Access("readonly.txt", dir, 0x04)) + // Write fails + assert.ErrorIs(t, sb.Access("readonly.txt", dir, 0x02), os.ErrPermission) + // Read+write fails (write component fails) + assert.ErrorIs(t, sb.Access("readonly.txt", dir, 0x04|0x02), os.ErrPermission) +} + +// TestAccessFdRelativeSymlink verifies that the permission check stays +// fd-relative. Access through a symlink within the sandbox works because +// both Stat and OpenFile resolve through os.Root's fd. +func TestAccessFdRelativeSymlink(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "target.txt"), []byte("data"), 0644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + assert.NoError(t, sb.Access("link.txt", dir, 0x04)) +} + +// TestAccessFdRelativeEscapeBlocked verifies that symlink escapes +// are blocked at the os.Root level for both Stat and OpenFile. +func TestAccessFdRelativeEscapeBlocked(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644)) + require.NoError(t, os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(dir, "escape.txt"))) + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + assert.Error(t, sb.Access("escape.txt", dir, 0x04)) +} + +// TestAccessSocketFallsBackToStat verifies that Unix sockets, which +// cannot be opened with open(2), fall back to Stat + effectiveHasPerm. +func TestAccessSocketFallsBackToStat(t *testing.T) { + // Unix socket paths have a ~104-byte limit on macOS. Use a short + // temp directory to avoid EINVAL from bind(2). + dir, err := os.MkdirTemp("/tmp", "sock") + require.NoError(t, err) + defer os.RemoveAll(dir) + sockPath := filepath.Join(dir, "s.sock") + + fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + require.NoError(t, err) + defer syscall.Close(fd) + require.NoError(t, syscall.Bind(fd, &syscall.SockaddrUnix{Name: sockPath})) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + // Socket with default permissions should pass read check via the + // Stat fallback path (OpenFile fails on sockets). + assert.NoError(t, sb.Access("s.sock", dir, 0x04)) +} + +// TestAccessFIFONonBlocking verifies that O_NONBLOCK prevents blocking +// on a FIFO with no writer. Core of the TOCTOU fix: even if an attacker +// swaps a regular file for a FIFO, the open returns immediately. +func TestAccessFIFONonBlocking(t *testing.T) { + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "fifo"), 0644)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + start := time.Now() + done := make(chan error, 1) + go func() { done <- sb.Access("fifo", dir, 0x04) }() + select { + case err := <-done: + assert.NoError(t, err) + // With O_NONBLOCK, this should complete in well under 100ms. + assert.Less(t, time.Since(start), 500*time.Millisecond, + "FIFO access took too long — O_NONBLOCK may not be working") + case <-time.After(2 * time.Second): + t.Fatal("Access blocked on FIFO — O_NONBLOCK not effective") + } +} diff --git a/allowedpaths/sandbox_windows_test.go b/allowedpaths/sandbox_windows_test.go new file mode 100644 index 00000000..1b221b0b --- /dev/null +++ b/allowedpaths/sandbox_windows_test.go @@ -0,0 +1,128 @@ +// 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. + +//go:build windows + +package allowedpaths + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAccessReadAllowedWindows verifies that Access succeeds for a +// readable file on Windows, using the OpenFile-based check. +func TestAccessReadAllowedWindows(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "readable.txt"), []byte("data"), 0644)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.NoError(t, sb.Access("readable.txt", dir, 0x04)) +} + +// TestAccessNonexistentWindows verifies that Access fails for a +// missing file on Windows. +func TestAccessNonexistentWindows(t *testing.T) { + dir := t.TempDir() + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.Access("missing.txt", dir, 0x04) + assert.Error(t, err) +} + +// TestAccessOutsideSandboxWindows verifies that Access rejects paths +// outside the sandbox. +func TestAccessOutsideSandboxWindows(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.Access(filepath.Join(outside, "secret.txt"), dir, 0x04) + assert.ErrorIs(t, err, os.ErrPermission) +} + +// TestAccessDirectoryReadWindows verifies that Access works on +// directories (directory read check skips OpenFile). +func TestAccessDirectoryReadWindows(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.NoError(t, sb.Access("subdir", dir, 0x04)) +} + +// TestAccessSymlinkEscapeBlockedWindows verifies that Access blocks +// symlinks that resolve outside the sandbox on Windows. +func TestAccessSymlinkEscapeBlockedWindows(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644)) + require.NoError(t, os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(dir, "escape.txt"))) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.Access("escape.txt", dir, 0x04) + assert.Error(t, err) +} + +// TestAccessSymlinkWithinSandboxWindows verifies that Access succeeds +// for a symlink that resolves within the sandbox on Windows. +func TestAccessSymlinkWithinSandboxWindows(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "target.txt"), []byte("data"), 0644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.NoError(t, sb.Access("link.txt", dir, 0x04)) +} + +// TestAccessWriteDeniedWindows verifies that write checks correctly +// deny access to read-only files on Windows via mode-bit inspection. +func TestAccessWriteDeniedWindows(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "readonly.txt"), []byte("data"), 0444)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.ErrorIs(t, sb.Access("readonly.txt", dir, 0x02), os.ErrPermission) +} + +// TestAccessExecAlwaysDeniedWindows verifies that execute checks +// always return ErrPermission on Windows (no POSIX execute bits). +func TestAccessExecAlwaysDeniedWindows(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("data"), 0644)) + + sb, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + // Windows has no POSIX execute bits — always denied. + assert.ErrorIs(t, sb.Access("data.txt", dir, 0x01), os.ErrPermission) +} diff --git a/allowedsymbols/symbols_allowedpaths.go b/allowedsymbols/symbols_allowedpaths.go index bbb2ce12..01c286be 100644 --- a/allowedsymbols/symbols_allowedpaths.go +++ b/allowedsymbols/symbols_allowedpaths.go @@ -55,5 +55,6 @@ var allowedpathsAllowedSymbols = []string{ "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.O_NONBLOCK", // non-blocking open flag; prevents blocking on FIFOs during access checks. Pure constant. "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 a4434bc9..3dc41d6a 100644 --- a/allowedsymbols/symbols_builtins.go +++ b/allowedsymbols/symbols_builtins.go @@ -73,9 +73,15 @@ var builtinPerCommandSymbols = map[string][]string{ "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.FileMode", // file permission bits type; pure type. + "io/fs.ModeCharDevice", // file mode bit constant for character devices; pure constant. + "io/fs.ModeDevice", // file mode bit constant for block devices; pure constant. "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. @@ -87,7 +93,9 @@ var builtinPerCommandSymbols = map[string][]string{ "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. + "strconv.ParseUint", // string-to-unsigned-int conversion; pure function, no I/O. "strings.HasPrefix", // pure function for prefix matching; no I/O. + "strings.Split", // splits a string by separator into a slice; pure function, no I/O. "strings.ToLower", // converts string to lowercase; pure function, no I/O. "time.Duration", // duration type; pure integer alias, no I/O. "time.Hour", // constant representing one hour; no side effects. @@ -398,6 +406,9 @@ var builtinAllowedSymbols = []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 information; no side effects. + "io/fs.FileMode", // file permission bits type; pure type. + "io/fs.ModeCharDevice", // file mode bit constant for character devices; pure constant. + "io/fs.ModeDevice", // file mode bit constant for block devices; pure constant. "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. diff --git a/builtins/find/eval.go b/builtins/find/eval.go index 66259b83..33dad83b 100644 --- a/builtins/find/eval.go +++ b/builtins/find/eval.go @@ -18,6 +18,7 @@ import ( type evalResult struct { matched bool prune bool // skip descending into this directory + quit bool // stop all iteration immediately (-quit) } // evalContext holds state needed during expression evaluation. @@ -44,23 +45,29 @@ func evaluate(ec *evalContext, e *expr) evalResult { switch e.kind { case exprAnd: left := evaluate(ec, e.left) + if left.quit { + return left + } if !left.matched { return evalResult{prune: left.prune} } right := evaluate(ec, e.right) - return evalResult{matched: right.matched, prune: left.prune || right.prune} + return evalResult{matched: right.matched, prune: left.prune || right.prune, quit: right.quit} case exprOr: left := evaluate(ec, e.left) + if left.quit { + return 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} + return evalResult{matched: right.matched, prune: left.prune || right.prune, quit: right.quit} case exprNot: r := evaluate(ec, e.operand) - return evalResult{matched: !r.matched, prune: r.prune} + return evalResult{matched: !r.matched, prune: r.prune, quit: r.quit} case exprName: name := baseName(ec.relPath) @@ -94,6 +101,13 @@ func evaluate(ec *evalContext, e *expr) evalResult { case exprMmin: return evalResult{matched: evalMmin(ec, e.numVal, e.numCmp)} + case exprPerm: + // Use full 12-bit mode (including setuid/setgid/sticky), not just Perm() which is only 9 bits. + return evalResult{matched: matchPerm(ec.info.Mode(), e.permVal, e.permCmp)} + + case exprQuit: + return evalResult{matched: true, quit: true} + case exprPrint: ec.callCtx.Outf("%s\n", ec.printPath) return evalResult{matched: true} diff --git a/builtins/find/expr.go b/builtins/find/expr.go index 7e25394c..01e66c64 100644 --- a/builtins/find/expr.go +++ b/builtins/find/expr.go @@ -34,6 +34,8 @@ const ( exprNewer // -newer file exprMtime // -mtime n exprMmin // -mmin n + exprPerm // -perm mode + exprQuit // -quit exprPrint // -print exprPrint0 // -print0 exprPrune // -prune @@ -76,16 +78,21 @@ type sizeUnit struct { // 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 + strVal string // pattern for name/iname/path/ipath, type char, file path for newer/samefile, format for printf sizeVal sizeUnit // for -size - numVal int64 // for -mtime, -mmin + numVal int64 // for -mtime, -mmin, -atime, -amin, -ctime, -cmin, -uid, -gid, -links, -inum numCmp cmpOp // comparison operator for numeric predicates + permVal uint32 // for -perm: permission bits + permCmp byte // for -perm: '=' exact, '-' all bits, '/' any bit left *expr // for and/or right *expr // for and/or operand *expr // for not } // isAction returns true if this expression is an output action. +// Only actual output actions suppress implicit -print; -quit is +// control flow (handled at evaluation time by checking quit before +// implicit print) and does not affect the implicit-print decision. func (e *expr) isAction() bool { return e.kind == exprPrint || e.kind == exprPrint0 } @@ -118,6 +125,11 @@ type parseResult struct { minDepth int // -1 = not specified } +// errHelpRequested is a sentinel error returned by parsePrimary when --help +// appears as a standalone predicate token (not consumed as an argument by +// another predicate like -name). +var errHelpRequested = errors.New("find: help requested") + // blocked predicates that are forbidden for sandbox safety. var blockedPredicates = map[string]string{ "-exec": "arbitrary command execution is blocked", @@ -285,6 +297,11 @@ func (p *parser) parsePrimary() (*expr, error) { tok := p.advance() + // --help as a standalone predicate triggers help output. + if tok == "--help" { + return nil, errHelpRequested + } + // Check blocked predicates. if reason, blocked := blockedPredicates[tok]; blocked { return nil, fmt.Errorf("find: %s: %s", tok, reason) @@ -311,6 +328,10 @@ func (p *parser) parsePrimary() (*expr, error) { return p.parseNumericPredicate(exprMtime) case "-mmin": return p.parseNumericPredicate(exprMmin) + case "-perm": + return p.parsePermPredicate() + case "-quit": + return &expr{kind: exprQuit}, nil case "-print": return &expr{kind: exprPrint}, nil case "-print0": @@ -373,7 +394,7 @@ func (p *parser) parseTypePredicate() (*expr, error) { continue } switch c { - case 'f', 'd', 'l', 'p', 's': + case 'b', 'c', '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) @@ -459,6 +480,205 @@ func (p *parser) parseDepthOption(isMax bool) (*expr, error) { return &expr{kind: exprTrue}, nil } +// parsePermPredicate parses -perm MODE where MODE is: +// - 0644 exact match (octal) +// - -0644 all bits must be set +// - /0644 any bit must be set +// - u=rwx,g=rx,o=rx symbolic mode (parsed to octal) +func (p *parser) parsePermPredicate() (*expr, error) { + if p.pos >= len(p.args) { + return nil, errors.New("find: missing argument for -perm") + } + val := p.advance() + if len(val) == 0 { + return nil, errors.New("find: missing argument for -perm") + } + + var cmpMode byte = '=' // default: exact match + modeStr := val + if modeStr[0] == '-' { + cmpMode = '-' + modeStr = modeStr[1:] + } else if modeStr[0] == '/' { + cmpMode = '/' + modeStr = modeStr[1:] + } + + if len(modeStr) == 0 { + return nil, fmt.Errorf("find: invalid mode '%s'", val) + } + + // Try octal parse first. + mode, err := strconv.ParseUint(modeStr, 8, 32) + if err != nil { + // Try symbolic mode parse. + mode64, serr := parseSymbolicMode(modeStr) + if serr != nil { + return nil, fmt.Errorf("find: invalid mode '%s'", val) + } + mode = mode64 + } + if mode > 07777 { + return nil, fmt.Errorf("find: invalid mode '%s'", val) + } + + return &expr{kind: exprPerm, permVal: uint32(mode), permCmp: cmpMode}, nil +} + +// parseSymbolicMode parses a symbolic permission string like "u=rwx,g=rx,o=rx" +// or "a=r" into an octal permission value. +// +// Supports: who (u/g/o/a), operators (=/+/-), perms (r/w/x/X/s/t), +// copy-bits (g=u, o=g, etc.), and conditional execute (X). +func parseSymbolicMode(s string) (uint64, error) { + var mode uint64 + for _, clause := range strings.Split(s, ",") { + if len(clause) == 0 { + return 0, fmt.Errorf("empty clause") + } + // Parse who: u, g, o, a (default: a) + i := 0 + who := byte(0) // bitmask: 4=u, 2=g, 1=o + whoLoop: + for i < len(clause) { + switch clause[i] { + case 'u': + who |= 4 + case 'g': + who |= 2 + case 'o': + who |= 1 + case 'a': + who |= 7 + default: + break whoLoop + } + i++ + } + if who == 0 { + who = 7 // default: a (all) + } + if i >= len(clause) { + return 0, fmt.Errorf("missing operator") + } + op := clause[i] + if op != '=' && op != '+' && op != '-' { + return 0, fmt.Errorf("invalid operator '%c'", op) + } + i++ + // Parse perms: r, w, x, X, s, t, or copy-bits (u/g/o). + var bits uint64 // rwx bits (applied per-class) + var special uint64 // special bits (setuid/setgid/sticky, applied globally) + + // Check for copy-bits source (u/g/o). Copy-bits must be the + // sole perm token — no mixing with rwxXst (GNU rejects "g=ur"). + if i < len(clause) && (clause[i] == 'u' || clause[i] == 'g' || clause[i] == 'o') { + switch clause[i] { + case 'u': + bits = uint64((mode >> 6) & 7) + case 'g': + bits = uint64((mode >> 3) & 7) + case 'o': + bits = uint64(mode & 7) + } + i++ + if i < len(clause) { + return 0, fmt.Errorf("invalid permission '%c'", clause[i]) + } + } else { + for i < len(clause) { + switch clause[i] { + case 'r': + bits |= 4 + case 'w': + bits |= 2 + case 'x': + bits |= 1 + case 'X': + // Conditional execute: set x only if the mode + // being built already has any execute bit set. + if mode&0111 != 0 { + bits |= 1 + } + case 's': + // setuid for user, setgid for group + if who&4 != 0 { + special |= 0o4000 + } + if who&2 != 0 { + special |= 0o2000 + } + case 't': + if who&1 != 0 { + special |= 0o1000 // sticky + } + default: + return 0, fmt.Errorf("invalid permission '%c'", clause[i]) + } + i++ + } + } + // Apply rwx bits to the appropriate class positions. + // '=' clears all bits for the class first, then sets the new ones. + // '+' only sets bits (OR). '-' only clears bits (AND NOT). + if who&4 != 0 { // user + switch op { + case '=': + mode &^= 7 << 6 // clear user bits + mode |= bits << 6 // set new bits + case '+': + mode |= bits << 6 + case '-': + mode &^= bits << 6 + } + } + if who&2 != 0 { // group + switch op { + case '=': + mode &^= 7 << 3 + mode |= bits << 3 + case '+': + mode |= bits << 3 + case '-': + mode &^= bits << 3 + } + } + if who&1 != 0 { // other + switch op { + case '=': + mode &^= 7 + mode |= bits + case '+': + mode |= bits + case '-': + mode &^= bits + } + } + // Apply special bits (setuid/setgid/sticky). + // For '=', clear the class's special bits first so that e.g. + // "u=s,u=rwx" correctly drops setuid on the second clause. + if op == '=' { + if who&4 != 0 { + mode &^= 0o4000 // clear setuid + } + if who&2 != 0 { + mode &^= 0o2000 // clear setgid + } + // sticky is associated with 'other' or 'all' + if who&1 != 0 { + mode &^= 0o1000 // clear sticky + } + } + switch op { + case '=', '+': + mode |= special + case '-': + mode &^= special + } + } + return mode, nil +} + // parseSize parses a -size argument like "+10k", "-5M", "100c". func parseSize(s string) (sizeUnit, error) { if len(s) == 0 { @@ -525,6 +745,10 @@ func (k exprKind) String() string { return "-mtime" case exprMmin: return "-mmin" + case exprPerm: + return "-perm" + case exprQuit: + return "-quit" case exprPrint: return "-print" case exprPrint0: diff --git a/builtins/find/expr_test.go b/builtins/find/expr_test.go index 459ba5cc..a8a554f7 100644 --- a/builtins/find/expr_test.go +++ b/builtins/find/expr_test.go @@ -178,6 +178,36 @@ func TestParsePathPredicateUsesParsePathPredicate(t *testing.T) { } } +// TestParseTypePredicate validates the parser accepts b and c type characters. +func TestParseTypePredicate(t *testing.T) { + tests := []struct { + name string + arg string + wantErr bool + }{ + {"b valid", "b", false}, + {"c valid", "c", false}, + {"b,c valid", "b,c", false}, + {"f,b,c valid", "f,b,c", false}, + {"all types", "b,c,f,d,l,p,s", false}, + {"x invalid", "x", true}, + {"trailing comma", "b,", true}, + {"leading comma", ",c", true}, + {"no comma separator", "bc", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseExpression([]string{"-type", tt.arg}) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + // TestParseBlockedPredicates verifies all dangerous predicates are blocked. func TestParseBlockedPredicates(t *testing.T) { blocked := []string{ @@ -199,6 +229,126 @@ func TestParseBlockedPredicates(t *testing.T) { } } +// TestParsePermPredicate verifies -perm parsing for octal and symbolic modes. +func TestParsePermPredicate(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + permVal uint32 + permCmp byte + }{ + {"exact octal 644", []string{"-perm", "644"}, false, 0o644, '='}, + {"exact octal 0755", []string{"-perm", "0755"}, false, 0o755, '='}, + {"all bits 0111", []string{"-perm", "-0111"}, false, 0o111, '-'}, + {"any bit 0222", []string{"-perm", "/0222"}, false, 0o222, '/'}, + {"exact 0", []string{"-perm", "0"}, false, 0, '='}, + {"symbolic u=rwx", []string{"-perm", "u=rwx"}, false, 0o700, '='}, + {"symbolic a=r", []string{"-perm", "a=r"}, false, 0o444, '='}, + {"symbolic u=rw,g=r,o=r", []string{"-perm", "u=rw,g=r,o=r"}, false, 0o644, '='}, + {"symbolic = overwrites", []string{"-perm", "u=rw,u=x"}, false, 0o100, '='}, + {"symbolic + adds", []string{"-perm", "u=rw,u+x"}, false, 0o700, '='}, + {"all bits symbolic", []string{"-perm", "-u=x"}, false, 0o100, '-'}, + {"symbolic setuid", []string{"-perm", "-u=s"}, false, 0o4000, '-'}, + {"symbolic setgid", []string{"-perm", "-g=s"}, false, 0o2000, '-'}, + {"symbolic sticky", []string{"-perm", "-o=t"}, false, 0o1000, '-'}, + {"symbolic setuid+exec", []string{"-perm", "u=xs"}, false, 0o4100, '='}, + {"symbolic = clears special", []string{"-perm", "u=s,u=rwx"}, false, 0o700, '='}, + // Copy-bits: basic + {"copy g=u from empty", []string{"-perm", "g=u"}, false, 0o000, '='}, + {"copy u=rwx,g=u", []string{"-perm", "u=rwx,g=u"}, false, 0o770, '='}, + {"copy u=rw,o=u", []string{"-perm", "u=rw,o=u"}, false, 0o606, '='}, + {"copy o=r,g=o", []string{"-perm", "o=r,g=o"}, false, 0o044, '='}, + {"copy u=rwx,g=u,o=g cascade", []string{"-perm", "u=rwx,g=u,o=g"}, false, 0o777, '='}, + {"copy g=o from empty", []string{"-perm", "g=o"}, false, 0o000, '='}, + {"copy u=g", []string{"-perm", "g=rx,u=g"}, false, 0o550, '='}, + {"copy o=g", []string{"-perm", "g=rx,o=g"}, false, 0o055, '='}, + // Copy-bits: with + and - operators + {"copy g+u adds", []string{"-perm", "u=rwx,g=r,g+u"}, false, 0o770, '='}, + {"copy g-u removes", []string{"-perm", "u=rx,g=rwx,g-u"}, false, 0o520, '='}, + // Copy-bits: special bits NOT copied + {"copy g=u does not copy setuid", []string{"-perm", "u=s,g=u"}, false, 0o4000, '='}, + // Copy-bits: invalid mixing + {"copy g=ur invalid", []string{"-perm", "g=ur"}, true, 0, 0}, + {"copy g=uo invalid", []string{"-perm", "g=uo"}, true, 0, 0}, + // Conditional execute X + {"X from zero no-op", []string{"-perm", "a+X"}, false, 0o000, '='}, + {"X with existing x", []string{"-perm", "u=x,a+X"}, false, 0o111, '='}, + {"X with = from zero", []string{"-perm", "u=X"}, false, 0o000, '='}, + {"X with = and existing x", []string{"-perm", "u=rx,g=X"}, false, 0o510, '='}, + {"a=rX from zero", []string{"-perm", "a=rX"}, false, 0o444, '='}, + {"u=rwx,a=rX", []string{"-perm", "u=rwx,a=rX"}, false, 0o555, '='}, + {"a+rX from zero", []string{"-perm", "a+rX"}, false, 0o444, '='}, + {"u=x,a+rX", []string{"-perm", "u=x,a+rX"}, false, 0o555, '='}, + // Sticky bit respects who mask (t only applies when who includes o/a) + {"sticky u+t is no-op", []string{"-perm", "u+t"}, false, 0o000, '='}, + {"sticky g=t is no-op", []string{"-perm", "g=t"}, false, 0o000, '='}, + {"sticky ug+t is no-op", []string{"-perm", "ug+t"}, false, 0o000, '='}, + {"sticky o+t sets", []string{"-perm", "o+t"}, false, 0o1000, '='}, + {"sticky a=t sets", []string{"-perm", "a=t"}, false, 0o1000, '='}, + {"sticky ug+st sets suid sgid only", []string{"-perm", "ug+st"}, false, 0o6000, '='}, + // Errors + {"missing arg", []string{"-perm"}, true, 0, 0}, + {"invalid octal", []string{"-perm", "xyz"}, true, 0, 0}, + {"invalid mode 99999", []string{"-perm", "99999"}, true, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pr, err := parseExpression(tt.args) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, exprPerm, pr.expr.kind) + assert.Equal(t, tt.permVal, pr.expr.permVal) + assert.Equal(t, tt.permCmp, pr.expr.permCmp) + } + }) + } +} + +// TestParseNewPredicates verifies the new predicates parse correctly. +func TestParseNewPredicates(t *testing.T) { + // No-arg predicates. + noArgTests := []struct { + arg string + kind exprKind + }{ + {"-quit", exprQuit}, + } + for _, tt := range noArgTests { + t.Run(tt.arg, func(t *testing.T) { + pr, err := parseExpression([]string{tt.arg}) + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, tt.kind, pr.expr.kind) + }) + } + +} + +// TestParseHelpRequested verifies that --help as a standalone predicate +// returns errHelpRequested, and that -name --help consumes it as a pattern. +func TestParseHelpRequested(t *testing.T) { + t.Run("standalone --help", func(t *testing.T) { + _, err := parseExpression([]string{"--help"}) + assert.ErrorIs(t, err, errHelpRequested) + }) + t.Run("--help after other predicate", func(t *testing.T) { + _, err := parseExpression([]string{"-true", "--help"}) + assert.ErrorIs(t, err, errHelpRequested) + }) + t.Run("-name consumes --help as pattern", func(t *testing.T) { + pr, err := parseExpression([]string{"-name", "--help"}) + require.NoError(t, err) + require.NotNil(t, pr.expr) + assert.Equal(t, exprName, pr.expr.kind) + assert.Equal(t, "--help", pr.expr.strVal) + }) +} + // TestParseExpressionLimits verifies AST depth and node limits. func TestParseExpressionLimits(t *testing.T) { t.Run("depth limit", func(t *testing.T) { diff --git a/builtins/find/find.go b/builtins/find/find.go index bc6084fe..1e48eaf1 100644 --- a/builtins/find/find.go +++ b/builtins/find/find.go @@ -7,7 +7,7 @@ // // find — search for files in a directory hierarchy // -// Usage: find [-L] [PATH...] [EXPRESSION] +// Usage: find [-H] [-L] [-P] [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 @@ -15,7 +15,8 @@ // // Global options: // -// -L Follow symbolic links. +// --help Print usage information and exit. +// -L Follow symbolic links. // // Supported predicates: // @@ -23,18 +24,19 @@ // -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. +// -type TYPE — file type: b,c,d,f,l,p,s. 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) +// -perm MODE — permission bits match MODE (octal or symbolic) // -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 +// -quit — exit immediately // -true — always true // -false — always false // @@ -106,6 +108,9 @@ func run(ctx context.Context, callCtx *builtins.CallContext, args []string) buil optLoop: for i < len(args) { switch args[i] { + case "--help": + printHelp(callCtx) + return builtins.Result{} case "-L": followLinks = true i++ @@ -140,13 +145,18 @@ optLoop: paths = []string{"."} } + exprArgs := args[i:] + // 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 { + if errors.Is(err, errHelpRequested) { + printHelp(callCtx) + return builtins.Result{} + } callCtx.Errf("%s\n", err.Error()) return builtins.Result{Code: 1} } @@ -224,7 +234,7 @@ optLoop: failed = true continue } - if walkPath(ctx, callCtx, startPath, walkOptions{ + wr := walkPath(ctx, callCtx, startPath, walkOptions{ expression: expression, implicitPrint: implicitPrint, followLinks: followLinks, @@ -232,9 +242,13 @@ optLoop: minDepth: minDepth, now: now, eagerNewerErrors: eagerNewerErrors, - }) { + }) + if wr.failed { failed = true } + if wr.quit { + break + } } } @@ -254,6 +268,47 @@ func isExpressionStart(arg string) bool { return strings.HasPrefix(arg, "-") && len(arg) > 1 } +// printHelp outputs the find usage information. +func printHelp(callCtx *builtins.CallContext) { + callCtx.Out("Usage: find [-L] [-P] [path...] [expression]\n\n") + callCtx.Out("Search directory trees, evaluating an expression for each file found.\n") + callCtx.Out("Default path is the current directory; default expression is -print.\n\n") + callCtx.Out("Options:\n") + callCtx.Out(" --help Print this help and exit.\n") + callCtx.Out(" -L Follow symbolic links.\n") + callCtx.Out(" -P Never follow symbolic links (default).\n\n") + callCtx.Out("Tests:\n") + callCtx.Out(" -name PATTERN Base name matches shell glob PATTERN.\n") + callCtx.Out(" -iname PATTERN Like -name but case-insensitive.\n") + callCtx.Out(" -path PATTERN Full path matches shell glob PATTERN.\n") + callCtx.Out(" -ipath PATTERN Like -path but case-insensitive.\n") + callCtx.Out(" -type TYPE File type: b,c,d,f,l,p,s. Comma-separated for OR.\n") + callCtx.Out(" -size N[cwbkMG] File size (+N=greater, -N=less, N=exact).\n") + callCtx.Out(" -empty Empty regular file or directory.\n") + callCtx.Out(" -newer FILE Modified more recently than FILE.\n") + callCtx.Out(" -mtime N Modified N days ago (+N=more, -N=less).\n") + callCtx.Out(" -mmin N Modified N minutes ago (+N=more, -N=less).\n") + callCtx.Out(" -perm MODE Permission bits match MODE (octal or symbolic).\n") + callCtx.Out(" -maxdepth N Descend at most N levels.\n") + callCtx.Out(" -mindepth N Apply tests only at depth >= N.\n") + callCtx.Out(" -true Always true.\n") + callCtx.Out(" -false Always false.\n\n") + callCtx.Out("Actions:\n") + callCtx.Out(" -print Print path followed by newline.\n") + callCtx.Out(" -print0 Print path followed by NUL.\n") + callCtx.Out(" -prune Skip directory subtree.\n") + callCtx.Out(" -quit Exit immediately.\n\n") + callCtx.Out("Operators:\n") + callCtx.Out(" ( EXPR ) Grouping.\n") + callCtx.Out(" ! EXPR / -not EXPR Negation.\n") + callCtx.Out(" EXPR -a EXPR / EXPR -and EXPR Conjunction (implicit).\n") + callCtx.Out(" EXPR -o EXPR / EXPR -or EXPR Disjunction.\n\n") + callCtx.Out("Blocked predicates [sandbox]:\n") + callCtx.Out(" -exec, -execdir, -delete, -ok, -okdir Execution/deletion.\n") + callCtx.Out(" -fls, -fprint, -fprint0, -fprintf File writes.\n") + callCtx.Out(" -regex, -iregex ReDoS risk.\n") +} + // walkOptions holds configuration for a single walkPath invocation. type walkOptions struct { expression *expr @@ -265,16 +320,23 @@ type walkOptions struct { eagerNewerErrors map[string]bool } +// walkResult holds the outcome of a walk operation. +type walkResult struct { + failed bool // at least one error occurred + quit bool // -quit was triggered +} + // walkPath walks the directory tree rooted at startPath, evaluating the -// expression for each entry. Returns true if any error occurred. +// expression for each entry. func walkPath( ctx context.Context, callCtx *builtins.CallContext, startPath string, opts walkOptions, -) bool { +) walkResult { now := opts.now failed := false + quit := false newerCache := map[string]time.Time{} newerErrors := map[string]bool{} for k, v := range opts.eagerNewerErrors { @@ -287,8 +349,6 @@ func walkPath( 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 { @@ -296,17 +356,12 @@ func walkPath( } if err != nil { callCtx.Errf("find: '%s': %s\n", startPath, callCtx.PortableErr(err)) - return true + return walkResult{failed: 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. + // path from root to the current node. // dirIterator streams directory entries one at a time via ReadDir(1), // keeping memory usage proportional to tree depth, not directory width. @@ -319,10 +374,8 @@ func walkPath( 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. + // checkLoop detects symlink loops for -L mode. + checkLoop := func(path string, info iofs.FileInfo, ancestorIDs map[builtins.FileID]string, ancestorPaths map[string]bool) (bool, map[builtins.FileID]string, map[string]bool) { var childAncestorIDs map[builtins.FileID]string var childAncestorPaths map[string]bool if info.IsDir() && opts.followLinks { @@ -334,7 +387,7 @@ func walkPath( 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 + return true, nil, nil } childAncestorIDs = make(map[builtins.FileID]string, len(ancestorIDs)+1) for k, v := range ancestorIDs { @@ -347,7 +400,7 @@ func walkPath( if ancestorPaths[path] { callCtx.Errf("find: File system loop detected; '%s' has already been visited.\n", path) failed = true - return false, true, nil, nil + return true, nil, nil } childAncestorPaths = make(map[string]bool, len(ancestorPaths)+1) for k := range ancestorPaths { @@ -356,7 +409,12 @@ func walkPath( childAncestorPaths[path] = true } } + return false, childAncestorIDs, childAncestorPaths + } + // processEntry evaluates the expression for a single file entry. + // Returns (prune, quit). + processEntry := func(path string, info iofs.FileInfo, depth int) (bool, bool) { ec := &evalContext{ callCtx: callCtx, ctx: ctx, @@ -377,26 +435,38 @@ func walkPath( if len(newerErrors) > 0 || ec.failed { failed = true } + if result.quit { + return prune, true + } if result.matched && opts.implicitPrint { callCtx.Outf("%s\n", path) } } - return prune, false, childAncestorIDs, childAncestorPaths + return prune, false } // Process the starting path. - prune, isLoop, childAncIDs, childAncPaths := processEntry(startPath, startInfo, 0, nil, nil) + isLoop, childAncIDs, childAncPaths := checkLoop(startPath, startInfo, nil, nil) + + startPrune := false + if !isLoop { + var q bool + startPrune, q = processEntry(startPath, startInfo, 0) + if q { + return walkResult{failed: failed, quit: true} + } + } // 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 { + if !isLoop && !startPrune && 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 + return walkResult{failed: true} } iterStack = append(iterStack, &dirIterator{ dir: dir, @@ -408,8 +478,8 @@ func walkPath( } for len(iterStack) > 0 { - if ctx.Err() != nil { - failed = true + if ctx.Err() != nil || quit { + failed = failed || ctx.Err() != nil break } @@ -420,7 +490,6 @@ func walkPath( continue } - // Read one entry at a time from the directory. dirEntries, readErr := top.dir.ReadDir(1) if readErr != nil { if readErr != io.EOF { @@ -442,8 +511,6 @@ func walkPath( 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 { @@ -460,11 +527,17 @@ func walkPath( } } - prune, isLoop, cAncIDs, cAncPaths := processEntry(childPath, childInfo, top.depth, top.ancestorIDs, top.ancestorPaths) + isLoop, cAncIDs, cAncPaths := checkLoop(childPath, childInfo, top.ancestorIDs, top.ancestorPaths) if isLoop { continue } + prune, q := processEntry(childPath, childInfo, top.depth) + if q { + quit = true + break + } + // Descend into child directories unless pruned or beyond maxdepth. if childInfo.IsDir() && !prune && top.depth < opts.maxDepth { dir, openErr := callCtx.OpenDir(ctx, childPath) @@ -488,7 +561,7 @@ func walkPath( it.dir.Close() } - return failed + return walkResult{failed: failed, quit: quit} } // collectNewerRefs walks the expression tree and returns all -newer reference paths. diff --git a/builtins/find/match.go b/builtins/find/match.go index c37bb95a..73b05dba 100644 --- a/builtins/find/match.go +++ b/builtins/find/match.go @@ -12,6 +12,38 @@ import ( "unicode/utf8" ) +// matchPerm checks whether filePerm matches the target permission bits +// according to the comparison mode: +// +// '=' exact: filePerm == target +// '-' all bits: filePerm & target == target +// '/' any bit: filePerm & target != 0 (special: if target==0, always true) +func matchPerm(filePerm iofs.FileMode, target uint32, cmpMode byte) bool { + // Go stores setuid/setgid/sticky as high flag bits (ModeSetuid, etc.), + // not in the Unix 12-bit position. Convert to Unix-style 12-bit mode. + fp := uint32(filePerm.Perm()) // bits 8-0 (rwxrwxrwx) + if filePerm&iofs.ModeSetuid != 0 { + fp |= 0o4000 + } + if filePerm&iofs.ModeSetgid != 0 { + fp |= 0o2000 + } + if filePerm&iofs.ModeSticky != 0 { + fp |= 0o1000 + } + switch cmpMode { + case '-': + return fp&target == target + case '/': + if target == 0 { + return true // GNU find: -perm /0 matches everything + } + return fp&target != 0 + default: // '=' + return fp == target + } +} + // 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), @@ -55,6 +87,10 @@ func fileTypeChar(info iofs.FileInfo) byte { return 'p' case mode&iofs.ModeSocket != 0: return 's' + case mode&iofs.ModeCharDevice != 0: + return 'c' + case mode&iofs.ModeDevice != 0: + return 'b' default: return '?' } diff --git a/builtins/find/match_test.go b/builtins/find/match_test.go index efaabf80..dfb35be9 100644 --- a/builtins/find/match_test.go +++ b/builtins/find/match_test.go @@ -6,6 +6,7 @@ package find import ( + iofs "io/fs" "testing" "github.com/stretchr/testify/assert" @@ -155,6 +156,66 @@ func TestMatchClassEdgeCases(t *testing.T) { assert.False(t, matched) } +// TestFileTypeChar verifies fileTypeChar returns the correct character for each file mode. +func TestFileTypeChar(t *testing.T) { + tests := []struct { + name string + mode iofs.FileMode + want byte + }{ + {"regular file", 0o644, 'f'}, + {"directory", iofs.ModeDir, 'd'}, + {"symlink", iofs.ModeSymlink, 'l'}, + {"named pipe", iofs.ModeNamedPipe, 'p'}, + {"socket", iofs.ModeSocket, 's'}, + {"char device", iofs.ModeCharDevice, 'c'}, + {"block device", iofs.ModeDevice, 'b'}, + {"both device bits set", iofs.ModeDevice | iofs.ModeCharDevice, 'c'}, + {"irregular", iofs.ModeIrregular, '?'}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &fakeFileInfo{mode: tt.mode} + got := fileTypeChar(info) + assert.Equal(t, tt.want, got, "fileTypeChar(mode=%v)", tt.mode) + }) + } +} + +// TestMatchType verifies matchType with block and char device types. +func TestMatchType(t *testing.T) { + blockDev := &fakeFileInfo{mode: iofs.ModeDevice} + charDev := &fakeFileInfo{mode: iofs.ModeCharDevice} + regular := &fakeFileInfo{mode: 0o644} + + tests := []struct { + name string + info iofs.FileInfo + typeArg string + want bool + }{ + {"block matches -type b", blockDev, "b", true}, + {"block no match -type c", blockDev, "c", false}, + {"char matches -type c", charDev, "c", true}, + {"char no match -type b", charDev, "b", false}, + {"block matches -type b,c", blockDev, "b,c", true}, + {"char matches -type b,c", charDev, "b,c", true}, + {"regular no match -type b", regular, "b", false}, + {"regular no match -type c", regular, "c", false}, + {"regular no match -type b,c", regular, "b,c", false}, + {"regular matches -type f", regular, "f", true}, + {"block no match -type f", blockDev, "f", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchType(tt.info, tt.typeArg) + assert.Equal(t, tt.want, got) + }) + } +} + func TestCompareNumeric(t *testing.T) { // Exact match assert.True(t, compareNumeric(5, 5, cmpExact)) @@ -171,6 +232,52 @@ func TestCompareNumeric(t *testing.T) { assert.False(t, compareNumeric(6, 5, cmpLess)) } +func TestMatchPerm(t *testing.T) { + tests := []struct { + name string + filePerm iofs.FileMode + target uint32 + cmpMode byte + want bool + }{ + // Exact match + {"exact 644 match", 0o644, 0o644, '=', true}, + {"exact 644 no match 755", 0o755, 0o644, '=', false}, + {"exact 0 match", 0, 0, '=', true}, + {"exact 777 match", 0o777, 0o777, '=', true}, + + // All bits set (-) + {"all bits 0111 on 755", 0o755, 0o111, '-', true}, + {"all bits 0111 on 644", 0o644, 0o111, '-', false}, + {"all bits 0444 on 644", 0o644, 0o444, '-', true}, + {"all bits 0 always true", 0o644, 0, '-', true}, + {"all bits 0777 on 755", 0o755, 0o777, '-', false}, + {"all bits 0777 on 777", 0o777, 0o777, '-', true}, + + // Any bit set (/) + {"any bit 0111 on 755", 0o755, 0o111, '/', true}, + {"any bit 0111 on 644", 0o644, 0o111, '/', false}, + {"any bit 0222 on 644", 0o644, 0o222, '/', true}, + {"any bit 0 always true", 0o644, 0, '/', true}, + + // Special bits (setuid/setgid/sticky) — Go uses high flag bits + {"setuid exact", 0o755 | iofs.ModeSetuid, 0o4755, '=', true}, + {"setuid all bits", 0o755 | iofs.ModeSetuid, 0o4000, '-', true}, + {"setuid not set", 0o755, 0o4000, '-', false}, + {"setgid any bit", 0o755 | iofs.ModeSetgid, 0o2000, '/', true}, + {"sticky exact", 0o755 | iofs.ModeSticky, 0o1755, '=', true}, + {"any bit 0001 on 644", 0o644, 0o001, '/', false}, + {"any bit 0100 on 755", 0o755, 0o100, '/', true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchPerm(tt.filePerm, tt.target, tt.cmpMode) + assert.Equal(t, tt.want, got) + }) + } +} + func TestPathGlobMatchMalformedBracket(t *testing.T) { // Unclosed bracket patterns fall back to literal comparison. assert.True(t, pathGlobMatch("[", "[")) diff --git a/tests/scenarios/cmd/find/basic/help.yaml b/tests/scenarios/cmd/find/basic/help.yaml new file mode 100644 index 00000000..2064ed0a --- /dev/null +++ b/tests/scenarios/cmd/find/basic/help.yaml @@ -0,0 +1,15 @@ +description: find --help prints usage to stdout and exits 0. +skip_assert_against_bash: true +input: + script: |+ + find --help +expect: + stdout_contains: + - "Usage:" + - "find" + - "-name" + - "-type" + - "-print" + - "Blocked predicates" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/help_after_path.yaml b/tests/scenarios/cmd/find/basic/help_after_path.yaml new file mode 100644 index 00000000..a28a40d8 --- /dev/null +++ b/tests/scenarios/cmd/find/basic/help_after_path.yaml @@ -0,0 +1,11 @@ +description: find --help prints usage to stdout and exits 0. +skip_assert_against_bash: true +input: + script: |+ + find . --help +expect: + stdout_contains: + - "Usage:" + - "find" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/basic/help_not_consumed_by_name.yaml b/tests/scenarios/cmd/find/basic/help_not_consumed_by_name.yaml new file mode 100644 index 00000000..79c12816 --- /dev/null +++ b/tests/scenarios/cmd/find/basic/help_not_consumed_by_name.yaml @@ -0,0 +1,14 @@ +description: find -name --help treats --help as a pattern, not a help flag. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name --help +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/errors/perm_invalid.yaml b/tests/scenarios/cmd/find/errors/perm_invalid.yaml new file mode 100644 index 00000000..f4f60839 --- /dev/null +++ b/tests/scenarios/cmd/find/errors/perm_invalid.yaml @@ -0,0 +1,16 @@ +description: find -perm with invalid mode string reports an error. +skip_assert_against_bash: true +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -perm xyz +expect: + stdout: "" + stderr_contains: + - "invalid mode" + exit_code: 1 diff --git a/tests/scenarios/cmd/find/errors/perm_missing_arg.yaml b/tests/scenarios/cmd/find/errors/perm_missing_arg.yaml new file mode 100644 index 00000000..3e0ba70c --- /dev/null +++ b/tests/scenarios/cmd/find/errors/perm_missing_arg.yaml @@ -0,0 +1,16 @@ +description: find -perm without an argument reports a missing argument error. +skip_assert_against_bash: true +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -perm +expect: + stdout: "" + stderr_contains: + - "missing argument" + exit_code: 1 diff --git a/tests/scenarios/cmd/find/predicates/perm_any_write.yaml b/tests/scenarios/cmd/find/predicates/perm_any_write.yaml new file mode 100644 index 00000000..d9072847 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/perm_any_write.yaml @@ -0,0 +1,18 @@ +description: find -perm /0222 matches files with any write bit set. +setup: + files: + - path: dir/rw.txt + content: "rw" + chmod: 0644 + - path: dir/ro.txt + content: "ro" + chmod: 0444 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -perm /0222 +expect: + stdout: |+ + dir/rw.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/perm_conditional_exec.yaml b/tests/scenarios/cmd/find/predicates/perm_conditional_exec.yaml new file mode 100644 index 00000000..88af5e80 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/perm_conditional_exec.yaml @@ -0,0 +1,19 @@ +description: "-perm with conditional execute (X) matches correctly." +skip_assert_against_bash: true +setup: + files: + - path: dir/f555 + content: "" + chmod: 0555 + - path: dir/f444 + content: "" + chmod: 0444 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -perm u=rwx,a=rX | sort +expect: + stdout: "dir/f555\n" + stdout_windows: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/perm_copy_bits.yaml b/tests/scenarios/cmd/find/predicates/perm_copy_bits.yaml new file mode 100644 index 00000000..69f35c78 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/perm_copy_bits.yaml @@ -0,0 +1,19 @@ +description: "-perm with copy-bits symbolic mode (g=u) matches correctly." +skip_assert_against_bash: true +setup: + files: + - path: dir/f770 + content: "" + chmod: 0770 + - path: dir/f750 + content: "" + chmod: 0750 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -perm u=rwx,g=u | sort +expect: + stdout: "dir/f770\n" + stdout_windows: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/perm_exact.yaml b/tests/scenarios/cmd/find/predicates/perm_exact.yaml new file mode 100644 index 00000000..0f6d49eb --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/perm_exact.yaml @@ -0,0 +1,19 @@ +description: find -perm 644 matches files with exact mode 0644. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.sh + content: "b" + chmod: 0755 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -perm 644 +expect: + stdout: |+ + dir/a.txt + stdout_windows: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/perm_minus.yaml b/tests/scenarios/cmd/find/predicates/perm_minus.yaml new file mode 100644 index 00000000..1782201f --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/perm_minus.yaml @@ -0,0 +1,19 @@ +description: find -perm -0111 matches files with all execute bits set. +setup: + files: + - path: dir/a.sh + content: "a" + chmod: 0755 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -perm -0111 +expect: + stdout: |+ + dir/a.sh + stdout_windows: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/perm_slash.yaml b/tests/scenarios/cmd/find/predicates/perm_slash.yaml new file mode 100644 index 00000000..db73cba4 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/perm_slash.yaml @@ -0,0 +1,19 @@ +description: find -perm /0111 matches files with any execute bit set. +setup: + files: + - path: dir/a.sh + content: "a" + chmod: 0700 + - path: dir/b.txt + content: "b" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -perm /0111 +expect: + stdout: |+ + dir/a.sh + stdout_windows: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/perm_symbolic.yaml b/tests/scenarios/cmd/find/predicates/perm_symbolic.yaml new file mode 100644 index 00000000..3b58f7ee --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/perm_symbolic.yaml @@ -0,0 +1,19 @@ +description: find -perm with symbolic mode u=rw,g=r,o=r matches 0644 files. +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 + - path: dir/b.sh + content: "b" + chmod: 0755 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -perm u=rw,g=r,o=r +expect: + stdout: |+ + dir/a.txt + stdout_windows: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/quit.yaml b/tests/scenarios/cmd/find/predicates/quit.yaml new file mode 100644 index 00000000..b16f1a51 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/quit.yaml @@ -0,0 +1,18 @@ +description: find -quit stops after the first match. +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 -print -quit +expect: + stdout_contains: + - ".txt" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/quit_first_match.yaml b/tests/scenarios/cmd/find/predicates/quit_first_match.yaml new file mode 100644 index 00000000..0d24182f --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/quit_first_match.yaml @@ -0,0 +1,21 @@ +description: find -name target -print -quit prints exactly one match then stops. +setup: + files: + - path: dir/sub1/target.txt + content: "a" + chmod: 0644 + - path: dir/sub2/target.txt + content: "b" + chmod: 0644 + - path: dir/sub3/other.txt + content: "c" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -name 'target.txt' -print -quit +expect: + stdout_contains: + - "target.txt" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/quit_no_implicit_print.yaml b/tests/scenarios/cmd/find/predicates/quit_no_implicit_print.yaml new file mode 100644 index 00000000..7c82c71f --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/quit_no_implicit_print.yaml @@ -0,0 +1,14 @@ +description: find -quit alone produces no output (suppresses implicit -print). +setup: + files: + - path: dir/a.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -quit +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/quit_or_short_circuit.yaml b/tests/scenarios/cmd/find/predicates/quit_or_short_circuit.yaml new file mode 100644 index 00000000..5dd3ae00 --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/quit_or_short_circuit.yaml @@ -0,0 +1,16 @@ +description: find with -quit in OR stops after the quit-triggering match. +setup: + files: + - path: dir/target.txt + content: "a" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find dir -type f -name 'target.txt' -print -quit -o -type d -print +expect: + stdout: |+ + dir + dir/target.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/quit_unreachable_branch.yaml b/tests/scenarios/cmd/find/predicates/quit_unreachable_branch.yaml new file mode 100644 index 00000000..badf64af --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/quit_unreachable_branch.yaml @@ -0,0 +1,20 @@ +description: find with -quit on unreachable OR branch still prints via 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 -true -o -quit +expect: + stdout_contains: + - "dir" + - "dir/a.txt" + - "dir/b.txt" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/find/predicates/type_block_char.yaml b/tests/scenarios/cmd/find/predicates/type_block_char.yaml new file mode 100644 index 00000000..c45534dc --- /dev/null +++ b/tests/scenarios/cmd/find/predicates/type_block_char.yaml @@ -0,0 +1,16 @@ +description: find -type b, -type c, and -type b,c are accepted by the parser. +setup: + files: + - path: dummy.txt + content: "x" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + find . -type b + find . -type c + find . -type b,c +expect: + stdout: "" + stderr: "" + exit_code: 0