diff --git a/.claude/skills/implement-posix-command/SKILL.md b/.claude/skills/implement-posix-command/SKILL.md new file mode 100644 index 00000000..f4e57406 --- /dev/null +++ b/.claude/skills/implement-posix-command/SKILL.md @@ -0,0 +1,508 @@ +--- +name: implement-posix-command +description: Implement a new POSIX command as a builtin in the safe shell interpreter +argument-hint: "" +--- + +Implement the **$ARGUMENTS** command as a builtin in `interp/`. + +--- + +## ⛔ STOP — READ THIS BEFORE DOING ANYTHING ELSE ⛔ + +You MUST follow this execution protocol. Skipping steps has caused defects in every prior run of this skill. + +### 1. Create the full task list FIRST + +Your very first action — before reading ANY files, before writing ANY code — is to call TaskCreate exactly 9 times, once for each step below (Steps 1–9). Use these exact subjects: + +1. "Step 1: Research the command" +2. "Step 2: User confirms which flags to implement" +3. "Step 3: Set up POSIX tests" +4. "Step 4: Implement Go tests" +5. "Step 5: Implement the $ARGUMENTS command" +6. "Step 6: Verify and Harden" +7. "Step 7: Code review" +8. "Step 8: Exploratory pentest" +9. "Step 9: Update documentation" + +### 2. Execution order and gating + +Steps run in this order: + +``` +Step 1 → Step 2 → Steps 3 + 4 + 5 (parallel) → Step 6 → Step 7 → Step 8 +``` + +**Sequential steps (1 → 2):** Before starting step N, call TaskList and verify step N-1 is `completed`. Set step N to `in_progress`. + +**Parallel steps (3, 4, 5):** Once Step 2 is `completed`, set Steps 3, 4, and 5 all to `in_progress` at the same time and work on all three concurrently. The implementation (Step 5) and the tests (Steps 3, 4) are all guided by the approved spec from Step 2 — they do not need to wait for each other. + +**Convergence (6 → 7 → 8 → 9):** Before starting Step 6, call TaskList and verify Steps 3, 4, AND 5 are all `completed`. Then proceed sequentially through 6 → 7 → 8 → 9. + +Before marking any step as `completed`: +- Re-read the step description and verify every sub-bullet is satisfied +- If any sub-bullet is not done, keep working — do NOT mark it completed + +### 3. Never skip steps + +- Do NOT skip research (Step 1) because you think you already know the command +- Do NOT skip shell tests (Step 3) — download and adapt the GNU coreutils tests +- Do NOT skip review (Step 7) or pentest (Step 8) because "tests pass" +- Steps 1 and 2 require user interaction — do NOT auto-approve on the user's behalf + +If you catch yourself wanting to skip a step, STOP and do the step anyway. + +--- + +## Context + +The safe shell interpreter (`interp/`) implements all commands as Go builtins — it never executes host binaries. All security and safety constraints are defined in `.claude/skills/implement-posix-command/RULES.md`. Read that file first before writing any code. + +Key structural facts about this codebase: +- Builtin implementations live in `interp/builtins/` (`package builtins`), one file per command +- Each builtin is a standalone function (not a method on Runner): `func builtinCmd(ctx context.Context, callCtx *CallContext, args []string) Result` +- File access MUST go through `callCtx.OpenFile()` — never `os.Open()` directly +- Output goes to `callCtx.Stdout`/`callCtx.Stderr` via `callCtx.Out()`, `callCtx.Outf()`, `callCtx.Errf()` +- Return `Result{}` for success, `Result{Code: 1}` for failure +- Builtins are registered in the `registry` map in `interp/builtins/builtins.go` + +## Step 1: Research the command + +Before writing any code: + +1. Read `.claude/skills/implement-posix-command/RULES.md` in full. +2. Read the POSIX specification behavior for **$ARGUMENTS** — what flags are standard, what flags are dangerous (write/execute), and what the expected output format is. +3. Read the associated GTFOBins recommendations, if any, which can be found at https://gtfobins.org/gtfobins/$ARGUMENTS. These contain information on unsafe flags and vulnerabilities that we will need to avoid. + +## Step 2: User confirms which flags to implement + +Based on your research, suggest which flags should originally be supported as part of implementing this command. +All flags must obey the rules from RULES.md. Our goal here is to implement the most common flags which +obey RULES.md. Use your knowledge of these tools to help determine which flags are common and worth implementing. +For the original implementation, err on the side of selecting fewer, more important flags. + +Determine: +- Which flags are safe to support (read-only, no exec) +- Which flags MUST be rejected with a clear error (any that write, delete, or execute) +- stdin support (does the command read from stdin when no files are given?) +- Exit code behavior (when should it return 0 vs 1?) +- Memory safety approach (streaming vs buffered, max sizes) +- Whether the command could read indefinitely from an infinite source (e.g. stdin from /dev/zero) — if so, it will need `context.Context` threading (see Step 5) + +Show the user a summary that describes each standard flag +you found in the POSIX documentation. Group the flags by "will implement", "maybe implement", and "do not implement." +For each flag, show the flag name and a very brief (1-2 sentence) description of what it does. + +Enter plan mode with `EnterPlanMode` and present the flag list and implementation approach. Wait for user approval. + +Once the user has confirmed the flags to be implemented, we will create the first bit of Go code +for our command implementation. Create `interp/builtins/$ARGUMENTS.go` (`package builtins`) +with just the package header and a detailed doc comment describing the command and listing all +accepted flags that will be implemented. + +## Step 3: Set up POSIX tests + +**GATE CHECK**: Call TaskList. Step 2 must be `completed` before starting this step. Set Steps 3, 4, and 5 all to `in_progress` now — they run in parallel. + +Download two reference test suites: + +```bash +# GNU coreutils — GPL v3; use as reference for test *design*, not verbatim copy +curl -sL https://github.com/coreutils/coreutils/archive/refs/heads/master.tar.gz | tar -xz -C /tmp + +# uutils/coreutils Rust rewrite — MIT license; test logic can be freely adapted +curl -sL https://github.com/uutils/coreutils/archive/refs/heads/main.tar.gz | tar -xz -C /tmp +``` + +**GNU coreutils**: Look in `/tmp/coreutils-master/tests/$ARGUMENTS/` for test cases. For each test file: + +1. **Filter**: Skip tests wholly concerned with flags we decided not to implement (e.g. `--follow`, inotify, `--pid`). Also skip tests that rely on obsolete POSIX2 syntax (e.g. `_POSIX2_VERSION` env var, combined flag+number forms like `-1l`), platform-specific kernel features (`/proc`, `/sys`), or the GNU test framework helpers (`retry_delay_`, `compare`, `framework_failure_`). + +**uutils/coreutils**: Look in `/tmp/coreutils-main/tests/by-util/test_$ARGUMENTS.rs` for test cases. Because uutils tests are MIT-licensed, the test logic and inputs/outputs can be adapted more freely. uutils tests tend to cover: +- Negative count modes (`-n -N`, `-c -N`) — skip if we did not implement these +- Obsolete positional syntax (`-1`, `-14c`) +- Multi-file header edge cases (`-v`, `-q`, `--silent`) +- Bad UTF-8 / binary passthrough +- Large-value integer edge cases and overflow guards +- Write-error handling (pipes writing to `/dev/full`) + +Cross-reference both sources: if a case appears in uutils but not GNU coreutils (or vice versa), it is often worth including — uutils fills gaps the GNU shell test scripts miss. + +2. **Translate**: For each remaining test case from either source, create one YAML scenario file at `tests/scenarios/cmd/$ARGUMENTS/`. The YAML format is: + +```yaml +description: One sentence describing what this scenario tests. +setup: + files: + - path: relative/path/in/tempdir + content: "file content here" + chmod: 0644 # optional + symlink: target/path # optional; creates a symlink instead of a file +input: + allowed_paths: ["$DIR"] # "$DIR" resolves to the temp dir; omit to block all file access + script: |+ + $ARGUMENTS some/file +expect: + stdout: "expected output\n" # exact match + stdout_contains: ["substring"] # list; use instead of stdout for partial matches + stderr: "" # exact match; use stderr_contains for partial matches + stderr_contains: ["partial"] # list + exit_code: 0 +``` + +**`stdout_contains` and `stderr_contains` must be YAML lists**, not scalar strings. +`stdout_contains: "text"` is invalid — always write `stdout_contains: ["text"]`. + +Group scenario files into subdirectories by concern (e.g. `lines/`, `bytes/`, `headers/`, `stdin/`, `errors/`, `hardening/`). + +**`stderr` vs `stderr_contains`**: Prefer `expect.stderr` (exact match) over `stderr_contains` (substring) unless the error message contains platform-specific text. + +Note the source test in a comment at the top of each YAML file (e.g. `# Derived from GNU coreutils tail.pl test n-3` or `# Derived from uutils test_tail.rs::test_n_3`). + +Write scenarios covering: +- Each implemented flag at least once +- Edge cases: empty file, single-line file, file with no trailing newline +- Error cases: missing file, directory as argument, invalid flag/argument values +- Flags that should be rejected (e.g. `-f`, `--follow`): verify `exit_code: 1` and stderr message + +## Step 4: Implement Go tests + +**PARALLEL STEP**: This runs concurrently with Steps 3 and 5. No gate check needed — Step 2 being `completed` is sufficient. + +Files are organized as follows: + +- **Implementation** → `interp/builtins/$ARGUMENTS.go` (`package builtins`) +- **Go tests** → `interp/builtins/tests/$ARGUMENTS/` (`package $ARGUMENTS_test`) +- **YAML scenarios** → `tests/scenarios/cmd/$ARGUMENTS/` (already done in Step 3) + +The `builtins/tests/$ARGUMENTS/` directory contains **only** `_test.go` files. Go does not +include test-only directories in the real import graph, so there is no import cycle even though +the tests import `interp` (which imports `builtins`). The implementation stays flat in `builtins/` +and is registered there; the subdirectory is purely for test organization. + +Do **not** put the implementation in `tests/` — that would require the sub-package to import +`builtins` for `CallContext`/`Result`, while `builtins` imports the sub-package for registration, +creating a cycle. + +All test files use `package $ARGUMENTS_test`. They import `interp` (not `builtins` directly) and +exercise the command end-to-end through the shell runner. + +### Exit code behaviour in Go tests + +`runScript` returns `(stdout, stderr string, exitCode int)` — you can assert the exit code directly +without writing any custom helper. Builtins signal failure via `Result{Code: 1}`, which the +interpreter converts to an `ExitStatus` error that `runScript` already unwraps for you. + +To verify that a command rejected a bad flag or argument, check both stderr and the returned exit +code: + +```go +_, stderr, code := runScript(t, "tail --follow file", dir, interp.AllowedPaths([]string{dir})) +assert.Equal(t, 1, code) +assert.Contains(t, stderr, "tail:") +``` + +### Test helpers + +Each test file requires a local `runScript` helper (since it is in `package $ARGUMENTS_test`, not +`package interp_test`). Define it at the top of `tests/$ARGUMENTS/$ARGUMENTS_test.go` along with `runScriptCtx` +for timeout-aware tests: + +```go +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return runScriptCtx(context.Background(), t, script, dir, opts...) +} + +func runScriptCtx(ctx context.Context, t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(script), "") + require.NoError(t, err) + var outBuf, errBuf bytes.Buffer + allOpts := append([]interp.RunnerOption{interp.StdIO(nil, &outBuf, &errBuf)}, opts...) + runner, err := interp.New(allOpts...) + require.NoError(t, err) + defer runner.Close() + if dir != "" { + runner.Dir = dir + } + err = runner.Run(ctx, prog) + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else if ctx.Err() == nil { + t.Fatalf("unexpected error: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} +``` + +### Command-specific run wrapper + +To avoid repeating `interp.AllowedPaths([]string{dir})` on every call, define a wrapper at the +top of `$ARGUMENTS_test.go`: + +```go +func cmdRun(t *testing.T, script, dir string) (stdout, stderr string, exitCode int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} +``` + +Use this wrapper throughout the test file. Use `runScript` directly only when you need different or +no `AllowedPaths` (e.g. for access-denied tests). + +Tests should be written to the following specifications: + +- All implemented flags are exercised in at least one test +- Review RULES.md and write tests verifying that the rules are honored where possible, checking for runaway memory allocations, infinite loops / hangs, etc +- Use `os.DevNull` instead of hardcoded `/dev/null` so tests compile on all platforms +- For tests that are inherently platform-specific (symlinks, Windows reserved names, directory reads), create separate files with build tags: + - `builtin_$ARGUMENTS_unix_test.go` with `//go:build unix` at the top + - `builtin_$ARGUMENTS_windows_test.go` with `//go:build windows` at the top +- When writing tests that pipe through another builtin (e.g. `cat file | $ARGUMENTS`), account for that builtin's output behaviour. For example, the `cat` builtin uses `fmt.Fprintln` which adds a trailing `\n` to each line — a binary file piped through `cat` will have a `\n` appended that was not in the original file. +- Do **not** use `echo -n` — the `echo` builtin does not support the `-n` flag and will emit the literal string `-n ` instead of suppressing the newline. For empty or newline-free stdin, write an empty file via `setup.files` in a YAML scenario or create a temp file in the test setup. + +Verify the tests build and all fail (since we have no implementation yet). + +### GNU equivalence tests + +After the main test file is written, also write +`interp/builtin_$ARGUMENTS_gnu_compat_test.go` (`package interp_test`). + +These tests assert byte-for-byte output equivalence between our builtin and GNU coreutils for +the cases most sensitive to formatting: line counts, trailing newlines, byte mode, headers, +quiet/verbose flags. + +**Capturing reference output** + +Run the real GNU tool to collect expected outputs, then embed them as string literals in the +test file. This means the tests run without any GNU tooling present on CI — it is captured +once, reviewed by the author, and committed. + +How to get GNU $ARGUMENTS depends on what is available: + +- **macOS with Homebrew coreutils** (most common on a developer Mac): + ```bash + brew install coreutils # one-time + g$ARGUMENTS --version # verify it is GNU, not BSD + # then run: g$ARGUMENTS [flags] [file] | cat -A to see exact bytes + ``` +- **Docker** (works everywhere, guaranteed to be Linux GNU coreutils): + ```bash + echo "alpha\nbeta\ngamma" > /tmp/testfile.txt + docker run --rm -v /tmp:/tmp alpine sh -c \ + 'apk add -q coreutils && $ARGUMENTS -n 3 /tmp/testfile.txt | cat -A' + ``` + +Use `cat -A` (or `cat -v`) while capturing to make invisible characters (CR, trailing spaces) +visible before you write them into the test file. + +**What to cover** + +At minimum, write one test per formatting-sensitive scenario: + +| Scenario | Why it matters | +|----------|----------------| +| Default output on a file longer than the default limit | verifies the off-by-one on the ring/count boundary | +| `-n N` smaller than file length | basic count accuracy | +| `-n 0` | degenerate case: no output | +| `-n N` larger than file length | should not truncate | +| `+N` offset mode (`-n +2`) | completely different code path | +| Long-form flag (`--lines=N`) | pflag alias wiring | +| No trailing newline preserved | should not add a `\n` | +| Empty file | no output, no crash | +| `-v` single file | header printed | +| Two files, default | header + blank-line separator format | +| `-q` / `--quiet` two files | no headers | +| `--silent` two files | alias for `--quiet` | +| `-c N` byte mode | different output path than line mode | +| `-c +N` byte offset | byte version of offset mode | +| Both `-c` and `-n` given | last flag wins (`-n` overrides `-c`) | +| Rejected flag (e.g. `-f`) | exit 1 + non-empty stderr | + +**Test structure** + +Keep each test self-contained: create temp files with the exact content used for the GNU +reference run, invoke the builtin, and `assert.Equal` the captured string: + +```go +// TestGNUCompatCmdVerboseSingleFile — -v prints header even for a single file. +// +// GNU command: g$ARGUMENTS -v one.txt (one.txt = "only one line\n") +// Expected: "==> one.txt <==\nonly one line\n" +func TestGNUCompatCmdVerboseSingleFile(t *testing.T) { + dir := setupCmdDir(t, map[string]string{"one.txt": "only one line\n"}) + stdout, _, exitCode := cmdRun(t, "$ARGUMENTS -v one.txt", dir) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "==> one.txt <==\nonly one line\n", stdout) +} +``` + +Include a comment on each test identifying the exact GNU invocation and its raw output so a +future maintainer can reproduce and update the reference without running the real tool from +scratch. + +## Step 5: Implement the $ARGUMENTS command + +**PARALLEL STEP**: This runs concurrently with Steps 3 and 4. No gate check needed — Step 2 being `completed` is sufficient. + +Create `interp/builtins/$ARGUMENTS.go` (`package builtins`) following the patterns in +the existing builtins (e.g. `cat.go`): + +1. **Function signature**: `func builtin$ARGUMENTS(ctx context.Context, callCtx *CallContext, args []string) Result`. All builtins take `ctx` — check `ctx.Err()` before every read in any loop. +2. **I/O**: Write output via `callCtx.Out(s)` / `callCtx.Outf(format, ...)` and errors via `callCtx.Errf(format, ...)`. Do not use `os.Stdout`/`os.Stderr` directly. +3. **File access**: Open files via `callCtx.OpenFile(ctx, path, os.O_RDONLY, 0)` — never `os.Open()`. This enforces the allowed-paths sandbox automatically. +4. **Return values**: Return `Result{}` for success and `Result{Code: 1}` for failure. Do not panic or return Go errors for user-facing failures. +5. **Flag parsing**: Use pflag. Any unregistered flag is automatically rejected. Register `-h`/`--help` and handle it per RULES.md. For string flags that may receive an empty value or a special prefix (e.g. `+N` offset syntax), detect whether the flag was explicitly provided using `fs.Changed("flagname")` rather than comparing the value to `""`. Using `*flagStr != ""` is wrong in these cases — an explicit `cmd -n ""` would silently fall through to the default instead of being rejected. +6. **Bounded reads**: Cap all buffer allocations; never allocate based on unclamped user input. + +Register the command in `interp/builtins/builtins.go`: +- Add an entry to the `registry` map: `"$ARGUMENTS": builtin$ARGUMENTS` + +**Update the import allowlist.** `tests/import_allowlist_test.go` enforces a symbol-level allowlist for all builtin implementation files. If your implementation uses any package symbols not already listed in `builtinAllowedSymbols`, add them — one entry per symbol in `"importpath.Symbol"` form. Every addition must comply with RULES.md: do not add any symbol that writes to the filesystem, executes binaries, or otherwise violates the safety rules (e.g. `os.Create`, `os.OpenFile` with write flags, `exec.Command`, `os.Remove`). Read-only `os` constants and types (e.g. `os.O_RDONLY`, `os.FileMode`) are fine; filesystem-accessing functions are not. + +**Do not modify any other existing files** unless directly required by the registration or allowlist steps above. + +## Step 6: Verify and Harden + +**GATE CHECK**: Call TaskList. Steps 3, 4, AND 5 must all be `completed` before starting this step. Set this step to `in_progress` now. + +Run the tests: + +```bash +go test ./interp/... ./tests/... +``` + +Fix any failures before finishing. + +After the initial test suite is passing, write another round of tests focused on: + +- 100% code coverage of the implementation +- Additional tests specific to the rules in RULES.md. For example, if the implementation passes user input into buffer allocations, ensure in tests that this input is clamped to an appropriate value and not passed as-is to the buffer. + +## Step 7: Code review + +**GATE CHECK**: Call TaskList. Step 6 must be `completed` before starting this step. Set this step to `in_progress` now. + +Run two review passes in parallel, then fix every finding before finishing. + +### Part A: RULES.md compliance + +Spawn parallel review agents — one per section of RULES.md — to audit the final implementation and test suite against every rule: + +- Memory Safety & Resource Limits + DoS Prevention + Special File Handling +- Input Validation & Error Handling + Integer Safety +- Cross-Platform Compatibility + Output Consistency +- Testing Requirements (verify every rule has corresponding test coverage) + +### Part B: General Go code quality + +Review the implementation for standard Go best practices: + +- **Error handling**: every `io.Writer.Write`, `io.Copy`, and `fmt.Fprintf` to a writer must have its error checked or explicitly discarded with `_` +- **Context cancellation**: `ctx.Err()` must be checked at the top of every loop that reads input — including scanner loops, not just explicit `Read` calls +- **Resource cleanup**: `defer` must be used to close files and other resources; when a file is opened inside a loop, use an IIFE (`func() error { defer f.Close(); ... }()`) to scope the defer to the loop iteration rather than the function +- **DRY**: functions that differ only in variable names or error strings must be merged; use a `kind string` parameter for error messages +- **Sentinel values**: `-1` or other magic sentinel ints used to select between modes should be replaced by a named `type … int` with named constants +- **Redundant conditionals**: simplify boolean expressions to the minimum necessary branches (e.g. `(a || b) && !c` instead of `(a && !c) || b` followed by `if c { … = false }`) +- **Variable re-derivation**: the same logical value must not be encoded twice in different types (e.g. both a `byte` and a `string` for the line separator) +- **Test helpers**: a test must not run the same command twice just to observe different aspects; consolidate into a single runner that captures both stdout/stderr and exit code + +For each issue found in either review, fix it immediately. Re-run tests after all fixes. + +### Second-pass review + +After all findings from Parts A and B are fixed and tests are green, do a second independent review pass. Re-read the implementation file from the top as if you have never seen it before — do not reference the previous review findings. Look for: + +- Anything the first pass missed because it was obscured by the issues that were just fixed +- New problems introduced by the fixes themselves (e.g., a simplification that quietly dropped a nil check or error return) +- Any logic that is now clearly wrong with the cleaned-up code as context + +Fix any new findings and re-run tests. Only then declare Step 7 complete. + +## Step 8: Exploratory pentest + +**GATE CHECK**: Call TaskList. Step 7 must be `completed` before starting this step. Set this step to `in_progress` now. + +Perform all pentest exercises as Go tests in a dedicated file: + +`interp/builtin_$ARGUMENTS_pentest_test.go` (`package interp_test`) + +Use the command-specific wrapper (e.g. `cmdRun`) or `runScript` directly. Use `context.WithTimeout` on individual tests to catch hangs: + +```go +func TestCmdPentestInfiniteSource(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // exercise the command ... +} +``` + +Run with `go test ./interp/... -run TestCmdPentest -timeout 120s`. For any surprising result, check whether GNU coreutils behaves the same way before deciding whether to fix it — surprising-but-matching-GNU is documenting a known behaviour, not a bug. + +### Integer edge cases +- `-n 0`, `-n 1`, `-n MaxInt32`, `-n MaxInt64`, `-n MaxInt64+1`, `-n 99999999999999999999` +- `-n -1`, `-n -9999999999` (should reject) +- `-n +0`, `-n +1`, `-n +MaxInt64` +- `-n ''`, `-n ' '` (empty / whitespace) +- Same set for `-c` + +### Special files / infinite sources +- Command in default line mode on `/dev/zero`, `/dev/random` — note whether it errors fast or spins +- Same in `-c` (byte) mode — compare timing against `gtail` to confirm matching behaviour +- `/dev/null` (empty source), `/proc` or `/sys` files if on Linux + +### Long lines +- Line of `maxLineBytes - 1` bytes (should succeed) +- Line of exactly `maxLineBytes` bytes (documents where the cap actually bites) +- Line of `maxLineBytes + 1` bytes (should fail) +- Two lines each near the cap; verify last-line selection is correct + +### Memory / resource exhaustion +- `-n MaxInt32` on a small file (verifies clamping, not OOM) +- `-c MaxInt32` on a small file +- 200+ file arguments (verifies no FD leak) +- 1M-line file through last-N and +N offset modes (verifies ring buffer correctness at scale) + +### Path and filename edge cases +- Absolute path, `../` traversal, `//double//slashes`, `/etc/././hosts` +- Non-existent file, directory as file, empty-string filename +- Filename starting with `-` (use `--` separator) +- Symlink to a regular file, dangling symlink, circular symlink +- Symlink to `/dev/zero` (same DoS check as direct special file) + +### Flag and argument injection +- Unknown flags (`-f`, `--follow`, `--no-such-flag`): confirm exit 1 + stderr, not fatal error +- Flag value via word expansion: `for flag in -f; do cmd $flag file; done` +- `--` end-of-flags followed by flag-like filenames +- Multiple `-` (stdin) arguments + +### Behavior matching +For any case where behaviour differs from expectation, run the equivalent `gtail` invocation and compare. Differences fall into three categories: +1. **Matches GNU** — document in a code comment, no code change needed +2. **Safer than GNU** — document; generally keep our behaviour +3. **Worse than GNU** — fix it + +## Step 9: Update documentation + +**GATE CHECK**: Call TaskList. Step 8 must be `completed` before starting this step. Set this step to `in_progress` now. + +Update `SHELL_COMMANDS.md` in the repository root. Add a row for the new command to the reference table, following the existing format: + +``` +| `$ARGUMENTS [FILE ...]` | `-x X` (desc), `-y` (desc) | One-sentence description of what the command does. | +``` + +Guidelines: +- List only the most commonly used flags in the Options column; omit rare or verbose-only flags +- Keep the short description to one sentence that matches the command's doc comment +- Insert the row in alphabetical order by command name + +After updating, verify the file looks correct, then commit everything together if not already committed, or amend/add to the existing commit. diff --git a/AGENTS.md b/AGENTS.md index a33e142a..122193cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,12 @@ The shell is supported on Linux, Windows and macOS. ## Testing +- Before submitting any change that touches `tests/scenarios/` or builtin implementations, run the bash comparison tests locally. These are skipped by default and require Docker: + ``` + RSHELL_BASH_TEST=1 go test ./tests/ -run TestShellScenariosAgainstBash -timeout 120s + ``` + The test suite runs all scenarios against `debian:bookworm-slim` (GNU bash + GNU coreutils) and compares output byte-for-byte. Only set `skip_assert_against_bash: true` in a scenario when the behavior intentionally diverges from bash (e.g. sandbox restrictions, blocked commands). + - In test scenarios, use `expect.stderr` when possible instead of `stderr_contains`. - Test scenarios are asserted against bash by default. Only set `skip_assert_against_bash: true` for features that intentionally diverge from standard bash behavior (e.g. blocked commands, restricted redirects, readonly enforcement). - When expected output differs on Windows (e.g. path separators `\` vs `/`), use Windows-specific assertion fields: diff --git a/SHELL_COMMANDS.md b/SHELL_COMMANDS.md index fd4f8bd8..91b73772 100644 --- a/SHELL_COMMANDS.md +++ b/SHELL_COMMANDS.md @@ -8,6 +8,7 @@ Short reference for builtin commands available in `pkg/shell`. | `false` | none | Exit with status `1`. | | `echo [ARG ...]` | none | Print arguments separated by spaces, then newline. | | `cat [FILE ...]` | `-` (read stdin) | Print files; with no args, read stdin. | +| `head [FILE ...]` | `-n N` (lines), `-c N` (bytes), `-q`/`--quiet`/`--silent` (no headers), `-v` (force headers) | Print first 10 lines of each FILE; with no FILE or `-`, read stdin. | | `exit [N]` | `N` (status code) | Exit the shell with `N` (default: last status). | | `break [N]` | `N` (loop levels) | Break current loop, or `N` enclosing loops. | | `continue [N]` | `N` (loop levels) | Continue current loop, or `N` enclosing loops. | diff --git a/interp/builtin_head_gnu_compat_test.go b/interp/builtin_head_gnu_compat_test.go new file mode 100644 index 00000000..aa6c9561 --- /dev/null +++ b/interp/builtin_head_gnu_compat_test.go @@ -0,0 +1,242 @@ +// 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. + +// GNU compatibility tests for the head builtin. +// +// Expected outputs were captured from GNU coreutils head 9.10 (macOS Homebrew +// ghead) and are embedded as string literals so the tests run without any GNU +// tooling present on CI. To reproduce a reference output, run: +// +// ghead [flags] [file] # then inspect with cat -A to see exact bytes + +package interp_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +// setupHeadDir creates a temp dir and writes the given files into it. +func setupHeadDir(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) + } + return dir +} + +// headCmdRun runs a head command with AllowedPaths scoped to dir. +func headCmdRun(t *testing.T, script, dir string) (stdout, stderr string, exitCode int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +// fiveLineContent is used across multiple GNU compat tests. +const fiveLineContent = "alpha\nbeta\ngamma\ndelta\nepsilon\n" + +// twelveLineContent is used to exercise the default 10-line cap. +const twelveLineContent = "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" + +// TestGNUCompatDefaultOutput — default output on a 12-line file. +// +// GNU command: ghead twelve.txt +// Expected: first 10 lines +func TestGNUCompatDefaultOutput(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"twelve.txt": twelveLineContent}) + stdout, _, code := headCmdRun(t, "head twelve.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\n", stdout) +} + +// TestGNUCompatLinesN — -n N smaller than file length. +// +// GNU command: ghead -n 3 five.txt (five.txt = fiveLineContent) +// Expected: "alpha\nbeta\ngamma\n" +func TestGNUCompatLinesN(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + stdout, _, code := headCmdRun(t, "head -n 3 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +// TestGNUCompatLinesZero — -n 0: no output. +// +// GNU command: ghead -n 0 five.txt +// Expected: "" +func TestGNUCompatLinesZero(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + stdout, _, code := headCmdRun(t, "head -n 0 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +// TestGNUCompatLinesLargerThanFile — -n N larger than file: print all lines. +// +// GNU command: ghead -n 100 five.txt +// Expected: fiveLineContent +func TestGNUCompatLinesLargerThanFile(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + stdout, _, code := headCmdRun(t, "head -n 100 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLineContent, stdout) +} + +// TestGNUCompatPositivePrefix — +N prefix is treated as positive N (not an offset). +// +// GNU command: ghead -n +2 five.txt +// Expected: "alpha\nbeta\n" (same as -n 2) +func TestGNUCompatPositivePrefix(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + stdout, _, code := headCmdRun(t, "head -n +2 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +// TestGNUCompatLongFormLines — --lines=N long form. +// +// GNU command: ghead --lines=3 five.txt +// Expected: "alpha\nbeta\ngamma\n" +func TestGNUCompatLongFormLines(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + stdout, _, code := headCmdRun(t, "head --lines=3 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +// TestGNUCompatNoTrailingNewline — last line without newline is reproduced exactly. +// +// GNU command: ghead -n 2 nonewline.txt (nonewline.txt = "no newline at end") +// Expected: "no newline at end" (no trailing \n added) +func TestGNUCompatNoTrailingNewline(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"nonewline.txt": "no newline at end"}) + stdout, _, code := headCmdRun(t, "head -n 2 nonewline.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "no newline at end", stdout) +} + +// TestGNUCompatEmptyFile — empty file produces no output. +// +// GNU command: ghead empty.txt (empty.txt = "") +// Expected: "" +func TestGNUCompatEmptyFile(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"empty.txt": ""}) + stdout, _, code := headCmdRun(t, "head empty.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +// TestGNUCompatVerboseSingleFile — -v prints header even for a single file. +// +// GNU command: ghead -v one.txt (one.txt = "only one line\n") +// Expected: "==> one.txt <==\nonly one line\n" +func TestGNUCompatVerboseSingleFile(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"one.txt": "only one line\n"}) + stdout, _, code := headCmdRun(t, "head -v one.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> one.txt <==\nonly one line\n", stdout) +} + +// TestGNUCompatTwoFilesDefault — two files: headers and blank-line separator. +// +// GNU command: ghead -n 2 five.txt nonewline.txt +// Expected: "==> five.txt <==\nalpha\nbeta\n\n==> nonewline.txt <==\nno newline at end" +func TestGNUCompatTwoFilesDefault(t *testing.T) { + dir := setupHeadDir(t, map[string]string{ + "five.txt": fiveLineContent, + "nonewline.txt": "no newline at end", + }) + stdout, _, code := headCmdRun(t, "head -n 2 five.txt nonewline.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> five.txt <==\nalpha\nbeta\n\n==> nonewline.txt <==\nno newline at end", stdout) +} + +// TestGNUCompatQuietTwoFiles — -q suppresses headers for multiple files. +// +// GNU command: ghead -q -n 2 five.txt nonewline.txt +// Expected: "alpha\nbeta\nno newline at end" +func TestGNUCompatQuietTwoFiles(t *testing.T) { + dir := setupHeadDir(t, map[string]string{ + "five.txt": fiveLineContent, + "nonewline.txt": "no newline at end", + }) + stdout, _, code := headCmdRun(t, "head -q -n 2 five.txt nonewline.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\nno newline at end", stdout) +} + +// TestGNUCompatSilentTwoFiles — --silent is an alias for --quiet. +// +// GNU command: ghead --silent -n 2 five.txt nonewline.txt +// Expected: "alpha\nbeta\nno newline at end" +func TestGNUCompatSilentTwoFiles(t *testing.T) { + dir := setupHeadDir(t, map[string]string{ + "five.txt": fiveLineContent, + "nonewline.txt": "no newline at end", + }) + stdout, _, code := headCmdRun(t, "head --silent -n 2 five.txt nonewline.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\nno newline at end", stdout) +} + +// TestGNUCompatBytesMode — -c N outputs exactly N bytes. +// +// GNU command: ghead -c 5 five.txt +// Expected: "alpha" (first 5 bytes of "alpha\nbeta\n...") +func TestGNUCompatBytesMode(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + stdout, _, code := headCmdRun(t, "head -c 5 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) +} + +// TestGNUCompatBytesModePositivePrefix — -c +N is treated as -c N. +// +// GNU command: ghead -c +3 five.txt +// Expected: "alp" +func TestGNUCompatBytesModePositivePrefix(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + stdout, _, code := headCmdRun(t, "head -c +3 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alp", stdout) +} + +// TestGNUCompatLastFlagWinsBytes — -n then -c: last flag (-c) wins. +// +// GNU command: ghead -n 2 -c 5 five.txt +// Expected: "alpha" (byte mode, 5 bytes) +func TestGNUCompatLastFlagWinsBytes(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + stdout, _, code := headCmdRun(t, "head -n 2 -c 5 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) +} + +// TestGNUCompatLastFlagWinsLines — -c then -n: last flag (-n) wins. +// +// GNU command: ghead -c 5 -n 2 five.txt +// Expected: "alpha\nbeta\n" (line mode, 2 lines) +func TestGNUCompatLastFlagWinsLines(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + stdout, _, code := headCmdRun(t, "head -c 5 -n 2 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +// TestGNUCompatRejectedFlag — unknown flag produces exit 1 and non-empty stderr. +// +// GNU command: ghead --no-such-flag five.txt → exit 1, stderr non-empty +func TestGNUCompatRejectedFlag(t *testing.T) { + dir := setupHeadDir(t, map[string]string{"five.txt": fiveLineContent}) + _, stderr, code := headCmdRun(t, "head --no-such-flag five.txt", dir) + assert.Equal(t, 1, code) + assert.NotEmpty(t, stderr) +} diff --git a/interp/builtin_head_pentest_test.go b/interp/builtin_head_pentest_test.go new file mode 100644 index 00000000..a3b97808 --- /dev/null +++ b/interp/builtin_head_pentest_test.go @@ -0,0 +1,528 @@ +// 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. + +// Exploratory pentest for the head builtin. +// +// These tests probe integer edge cases, special files, long lines, resource +// exhaustion, path edge cases, and flag injection scenarios. Tests that might +// hang are run in a goroutine with time.After to bound execution. + +package interp_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +const pentestTimeout = 10 * time.Second + +// headRun is a shorthand for runScript with AllowedPaths=dir. +func headRun(t *testing.T, script, dir string, extraPaths ...string) (stdout, stderr string, exitCode int) { + t.Helper() + paths := append([]string{dir}, extraPaths...) + return runScript(t, script, dir, interp.AllowedPaths(paths)) +} + +// mustNotHang runs f in a goroutine and fails the test if it does not return +// within pentestTimeout. +func mustNotHang(t *testing.T, f func()) { + t.Helper() + done := make(chan struct{}) + go func() { + defer close(done) + f() + }() + select { + case <-done: + case <-time.After(pentestTimeout): + t.Fatalf("operation did not complete within %s", pentestTimeout) + } +} + +// --- Integer edge cases: -n --- + +func TestCmdPentestLinesNZero(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + stdout, _, code := headRun(t, "head -n 0 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestCmdPentestLinesNOne(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("line1\nline2\n"), 0644)) + stdout, _, code := headRun(t, "head -n 1 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line1\n", stdout) +} + +func TestCmdPentestLinesNMaxInt32(t *testing.T) { + // 2147483647: must be clamped / handled without OOM on a small file. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 2147483647 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny\n", stdout) + }) +} + +func TestCmdPentestLinesNMaxInt64(t *testing.T) { + // 9223372036854775807 = max int64; clamped to maxHeadCount. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 9223372036854775807 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny\n", stdout) + }) +} + +func TestCmdPentestLinesNMaxInt64PlusOne(t *testing.T) { + // 9223372036854775808 overflows int64 — ParseInt returns an error. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) + _, stderr, code := headRun(t, "head -n 9223372036854775808 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNHugeOverflow(t *testing.T) { + // A numeric string too large for int64. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) + _, stderr, code := headRun(t, "head -n 99999999999999999999 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNNegativeOne(t *testing.T) { + // Negative counts must be rejected (we don't implement elide-tail mode). + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head -n -1 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNNegativeLarge(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head -n -9999999999 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNPlusZero(t *testing.T) { + // "+0" is parsed by ParseInt as 0. GNU head -n +0 = no output. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + stdout, _, code := headRun(t, "head -n +0 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestCmdPentestLinesNPlusOne(t *testing.T) { + // "+1" = 1 line (positive sign, matches GNU head behavior). + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("alpha\nbeta\n"), 0644)) + stdout, _, code := headRun(t, "head -n +1 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\n", stdout) +} + +func TestCmdPentestLinesNEmptyString(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, `head -n "" f.txt`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNWhitespace(t *testing.T) { + // A whitespace string — ParseInt rejects it. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, `head -n " " f.txt`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +// --- Integer edge cases: -c --- + +func TestCmdPentestBytesNZero(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello"), 0644)) + stdout, _, code := headRun(t, "head -c 0 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestCmdPentestBytesNOne(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello"), 0644)) + stdout, _, code := headRun(t, "head -c 1 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "h", stdout) +} + +func TestCmdPentestBytesNMaxInt32(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -c 2147483647 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny", stdout) + }) +} + +func TestCmdPentestBytesNHugeOverflow(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny"), 0644)) + _, stderr, code := headRun(t, "head -c 99999999999999999999 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestBytesNNegative(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello"), 0644)) + _, stderr, code := headRun(t, "head -c -1 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestBytesPlusMaxInt64(t *testing.T) { + // "+9223372036854775807" = max int64; clamped to maxHeadCount. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -c +9223372036854775807 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny", stdout) + }) +} + +// --- Special files / infinite sources (Unix only) --- + +func TestCmdPentestDevNull(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/null on Windows") + } + // /dev/null is empty; head should produce no output and exit 0. + dir := t.TempDir() + stdout, _, code := headRun(t, "head /dev/null", dir, "/dev") + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestCmdPentestDevZeroLineMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + // head -n 3 /dev/zero: /dev/zero produces infinite NUL bytes with no \n. + // The scanner's 1MiB line cap will trigger an error, so the command MUST + // terminate rather than hang. Matches GNU behavior (scanner errors out). + dir := t.TempDir() + mustNotHang(t, func() { + _, _, _ = headRun(t, "head -n 3 /dev/zero", dir, "/dev") + // We only assert non-hang; exit code may be 0 or 1. + }) +} + +func TestCmdPentestDevZeroByteMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + // head -c 10 /dev/zero: must read exactly 10 bytes and stop. + dir := t.TempDir() + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -c 10 /dev/zero", dir, "/dev") + assert.Equal(t, 0, code) + assert.Equal(t, string(bytes.Repeat([]byte{0}, 10)), stdout) + }) +} + +// --- Long lines --- + +func TestCmdPentestLongLineBelowCap(t *testing.T) { + // 1MiB - 1 bytes of 'a' followed by \n — should succeed. + dir := t.TempDir() + content := bytes.Repeat([]byte("a"), 1<<20-1) + content = append(content, '\n') + require.NoError(t, os.WriteFile(filepath.Join(dir, "long.txt"), content, 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 1 long.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(content), stdout) + }) +} + +func TestCmdPentestLongLineExactlyAtCap(t *testing.T) { + // Exactly 1 MiB of 'a' with no newline. bufio.Scanner.Buffer(buf, max) + // cannot hold a token of exactly max bytes (the limit is exclusive), so + // this must error just like a line that exceeds the cap. + dir := t.TempDir() + content := bytes.Repeat([]byte("a"), 1<<20) + require.NoError(t, os.WriteFile(filepath.Join(dir, "exact.txt"), content, 0644)) + mustNotHang(t, func() { + _, stderr, code := headRun(t, "head -n 1 exact.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") + }) +} + +func TestCmdPentestLongLineExactCapWithNewline(t *testing.T) { + // Exactly 1 MiB of 'a' followed by \n. The scanner sees the token as + // 1 MiB + 1 byte (content + newline). scanLinesPreservingNewline includes + // the \n in the token, so the token exceeds maxHeadLineBytes and the + // scanner must error. + dir := t.TempDir() + content := bytes.Repeat([]byte("a"), 1<<20) + content = append(content, '\n') + require.NoError(t, os.WriteFile(filepath.Join(dir, "exact_nl.txt"), content, 0644)) + mustNotHang(t, func() { + _, stderr, code := headRun(t, "head -n 1 exact_nl.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") + }) +} + +func TestCmdPentestLongLineExceedsCap(t *testing.T) { + // 1MiB + 1 bytes of 'a' (no newline) — scanner errors (line too long). + dir := t.TempDir() + content := bytes.Repeat([]byte("a"), 1<<20+1) + require.NoError(t, os.WriteFile(filepath.Join(dir, "huge.txt"), content, 0644)) + mustNotHang(t, func() { + _, stderr, code := headRun(t, "head -n 1 huge.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") + }) +} + +func TestCmdPentestTwoLinesNearCap(t *testing.T) { + // Two lines each just below the cap; head -n 2 should output both. + dir := t.TempDir() + line := bytes.Repeat([]byte("b"), 1<<20-2) + line = append(line, '\n') + content := append(append([]byte{}, line...), line...) + require.NoError(t, os.WriteFile(filepath.Join(dir, "two.txt"), content, 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 2 two.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(content), stdout) + }) +} + +// --- Memory / resource exhaustion --- + +func TestCmdPentestMaxInt32CountSmallFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "small.txt"), []byte("small\n"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 2147483647 small.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "small\n", stdout) + }) +} + +func TestCmdPentestMaxInt32ByteCountSmallFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "small.txt"), []byte("tiny"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -c 2147483647 small.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny", stdout) + }) +} + +func TestCmdPentestManyFileArguments(t *testing.T) { + // 210 file arguments: verify no file-descriptor leak or crash. + dir := t.TempDir() + var sb strings.Builder + sb.WriteString("head -q -n 1") + for i := 0; i < 210; i++ { + name := fmt.Sprintf("f%03d.txt", i) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(fmt.Sprintf("file%d\n", i)), 0644)) + sb.WriteString(" ") + sb.WriteString(name) + } + mustNotHang(t, func() { + stdout, _, code := headRun(t, sb.String(), dir) + assert.Equal(t, 0, code) + // 210 files × one "file%d\n" line = 210 lines. + assert.Equal(t, 210, strings.Count(stdout, "\n")) + }) +} + +func TestCmdPentestMillionLineFile(t *testing.T) { + // 1M-line file: head -n 5 must output first 5 lines quickly. + dir := t.TempDir() + var buf bytes.Buffer + for i := 0; i < 1_000_000; i++ { + buf.WriteString("x\n") + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "million.txt"), buf.Bytes(), 0644)) + done := make(chan struct{}) + go func() { + defer close(done) + stdout, _, code := headRun(t, "head -n 5 million.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "x\nx\nx\nx\nx\n", stdout) + }() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("head on 1M-line file did not complete within 30s") + } +} + +// --- Path and filename edge cases --- + +func TestCmdPentestNonexistentFile(t *testing.T) { + dir := t.TempDir() + _, stderr, code := headRun(t, "head does_not_exist.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestEmptyFilename(t *testing.T) { + // An empty string as a filename should produce an error. + dir := t.TempDir() + _, _, code := headRun(t, `head ""`, dir) + assert.Equal(t, 1, code) +} + +func TestCmdPentestFlagLikeName(t *testing.T) { + // A file whose name starts with '-' must be accessible via '--'. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "-n"), []byte("flag-file\n"), 0644)) + stdout, _, code := headRun(t, "head -- -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "flag-file\n", stdout) +} + +func TestCmdPentestDirectoryAsFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + _, stderr, code := headRun(t, "head subdir", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestOutsideSandbox(t *testing.T) { + // Attempting to read a file outside the allowed path must be blocked. + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "s.txt"), []byte("secret"), 0644)) + secretPath := filepath.ToSlash(filepath.Join(secret, "s.txt")) + _, stderr, code := runScript(t, "head "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestPathTraversal(t *testing.T) { + // Path traversal with ../ must be blocked by the sandbox. + dir := t.TempDir() + _, stderr, code := headRun(t, "head ../../etc/passwd", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +// --- Flag and argument injection --- + +func TestCmdPentestUnknownLongFlag(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head --no-such-flag f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestUnknownShortFlag(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head -f f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestFollowFlagRejected(t *testing.T) { + // --follow is a tail flag that must not be silently accepted by head. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head --follow f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestZeroTerminatedFlagRejected(t *testing.T) { + // -z / --zero-terminated is a GNU extension we do not support. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head -z f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestMultipleDashes(t *testing.T) { + // Two '-' args: both read from the same stdin fd. + // The first '-' uses a bufio.Scanner which reads ahead in 4096-byte + // chunks. For a small file, the scanner consumes the entire stdin in one + // Read call, leaving nothing for the second '-'. + // This is Safer-than-GNU: our scanner-buffered implementation exhausts + // stdin after the first '-'. GNU head uses lower-level I/O that restores + // the fd position, but we are read-only and do not lseek. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "src.txt"), []byte("alpha\nbeta\ngamma\n"), 0644)) + stdout, _, code := headRun(t, "head -q -n 1 - - < src.txt", dir) + assert.Equal(t, 0, code) + // First '-' outputs "alpha\n"; second '-' sees empty stdin (buffered ahead). + assert.Equal(t, "alpha\n", stdout) +} + +func TestCmdPentestFlagViaExpansion(t *testing.T) { + // Flag injected via variable expansion: unknown flag → exit 1. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, `flag="-f"; head $flag f.txt`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +// --- Behavior matching (GNU comparison notes) --- + +func TestCmdPentestGNUMatchPositivePrefix(t *testing.T) { + // GNU head -n +N treats + as a positive sign, outputting N lines. + // Matches GNU: ghead -n +2 = first 2 lines. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("alpha\nbeta\ngamma\n"), 0644)) + stdout, _, code := headRun(t, "head -n +2 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) // Matches GNU +} + +func TestCmdPentestGNUMatchNegativeRejected(t *testing.T) { + // GNU head -n -N is elide-tail mode (all but last N lines). + // We intentionally do NOT support this (safer-than-GNU: we reject + // rather than implement a ring buffer). + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("alpha\nbeta\ngamma\n"), 0644)) + _, stderr, code := headRun(t, "head -n -1 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} diff --git a/interp/builtins/head.go b/interp/builtins/head.go new file mode 100644 index 00000000..badaaaa2 --- /dev/null +++ b/interp/builtins/head.go @@ -0,0 +1,323 @@ +// 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 builtins implements safe shell builtin commands. +// +// head — output the first part of files +// +// Usage: head [OPTION]... [FILE]... +// +// Print the first 10 lines of each FILE to standard output. +// With more than one FILE, precede each with a header giving the file name. +// With no FILE, or when FILE is -, read standard input. +// +// Accepted flags: +// +// -n N, --lines=N +// Output the first N lines (default 10). A leading '+' (e.g. +5) is +// treated as a positive sign and is equivalent to plain 5. +// +// -c N, --bytes=N +// Output the first N bytes instead of lines. A leading '+' is treated +// as a positive sign. If both -n and -c are specified, the last flag +// on the command line takes effect. +// +// -q, --quiet, --silent +// Never print file name headers. --silent is an alias for --quiet. +// +// -v, --verbose +// Always print file name headers, even when only one file is given. +// +// -h, --help +// Print this usage message to stdout and exit 0. +// +// Exit codes: +// +// 0 All files processed successfully. +// 1 At least one error occurred (missing file, invalid argument, etc.). +// +// Memory safety: +// +// Line mode uses a streaming scanner with a per-line cap of maxHeadLineBytes +// (1 MiB). Lines that exceed this cap cause an error rather than an +// unbounded allocation. Byte mode reads in fixed-size chunks; it never +// allocates proportionally to user-supplied N. All loops check ctx.Err() +// at each iteration to honour the shell's execution timeout and to support +// graceful cancellation. + +package builtins + +import ( + "bufio" + "context" + "errors" + "io" + "os" + "strconv" + + "github.com/spf13/pflag" +) + +func init() { + register("head", builtinHead) +} + +// maxHeadCount is the maximum accepted line or byte count. Values above this +// are clamped. This prevents huge theoretical allocations while remaining +// larger than any practical file. +const maxHeadCount = 1<<31 - 1 // 2 147 483 647 + +// maxHeadLineBytes is the per-line buffer cap for the line scanner. Lines +// longer than this are reported as an error instead of being buffered. +const maxHeadLineBytes = 1 << 20 // 1 MiB + +func builtinHead(ctx context.Context, callCtx *CallContext, args []string) Result { + fs := pflag.NewFlagSet("head", pflag.ContinueOnError) + fs.SetOutput(io.Discard) + + help := fs.BoolP("help", "h", false, "print usage and exit") + quiet := fs.BoolP("quiet", "q", false, "never print file name headers") + _ = fs.Bool("silent", false, "alias for --quiet") + verbose := fs.BoolP("verbose", "v", false, "always print file name headers") + + // linesFlag and bytesFlag share a sequence counter so that after parsing + // we can compare their pos fields to determine which appeared last on the + // command line. pflag calls Set() in parse order, so the last flag Set + // gets the highest pos value — no raw arg scanning required. + var modeSeq int + linesFlag := newHeadModeFlag(&modeSeq, "10") + bytesFlag := newHeadModeFlag(&modeSeq, "") + fs.VarP(linesFlag, "lines", "n", "print the first N lines instead of the first 10") + fs.VarP(bytesFlag, "bytes", "c", "print the first N bytes instead of lines") + + if err := fs.Parse(args); err != nil { + callCtx.Errf("head: %v\n", err) + return Result{Code: 1} + } + + if *help { + callCtx.Out("Usage: head [OPTION]... [FILE]...\n") + callCtx.Out("Print the first 10 lines of each FILE to standard output.\n") + callCtx.Out("With no FILE, or when FILE is -, read standard input.\n\n") + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + return Result{} + } + + // --silent is an alias for --quiet. + if fs.Changed("silent") { + *quiet = true + } + + // Bytes mode wins if -c/--bytes was parsed after -n/--lines. When neither + // is set both pos fields are 0 (false → line mode). When only one is set + // the other stays 0, so the comparison selects correctly. + useBytesMode := bytesFlag.pos > linesFlag.pos + + // Parse the count for the chosen mode. + countStr := linesFlag.val + modeLabel := "lines" + if useBytesMode { + countStr = bytesFlag.val + modeLabel = "bytes" + } + + count, ok := headParseCount(countStr) + if !ok { + callCtx.Errf("head: invalid number of %s: %q\n", modeLabel, countStr) + return Result{Code: 1} + } + + // Collect file arguments; default to stdin. + files := fs.Args() + if len(files) == 0 { + files = []string{"-"} + } + + // Header printing: on by default for multiple files, suppressed by -q, + // forced for a single file by -v. + printHeaders := len(files) > 1 || *verbose + if *quiet { + printHeaders = false + } + + var failed bool + for i, file := range files { + if ctx.Err() != nil { + break + } + if err := headProcessFile(ctx, callCtx, file, i, printHeaders, useBytesMode, count); err != nil { + name := file + if file == "-" { + name = "standard input" + } + callCtx.Errf("head: %s: %s\n", name, callCtx.PortableErr(err)) + failed = true + } + } + + if failed { + return Result{Code: 1} + } + return Result{} +} + +// headProcessFile opens and processes one file (or stdin for "-"). +func headProcessFile(ctx context.Context, callCtx *CallContext, file string, idx int, printHeaders, useBytesMode bool, count int64) error { + var rc io.ReadCloser + name := file + if file == "-" { + name = "standard input" + // Print the header before the nil-stdin guard so that -v always + // emits a header for stdin even when no input stream is present. + if printHeaders { + if idx > 0 { + callCtx.Out("\n") + } + callCtx.Outf("==> %s <==\n", name) + } + if callCtx.Stdin == nil { + return nil + } + rc = io.NopCloser(callCtx.Stdin) + } else { + f, err := callCtx.OpenFile(ctx, file, os.O_RDONLY, 0) + if err != nil { + return err + } + defer f.Close() + rc = f + // Header is printed after a successful open so that a file that + // cannot be opened produces no header (matches GNU head behaviour). + if printHeaders { + if idx > 0 { + callCtx.Out("\n") + } + callCtx.Outf("==> %s <==\n", name) + } + } + + if useBytesMode { + return headBytes(ctx, callCtx, rc, count) + } + return headLines(ctx, callCtx, rc, count) +} + +// headLines writes the first count lines of r to callCtx.Stdout, preserving +// line endings exactly (including a missing final newline). +func headLines(ctx context.Context, callCtx *CallContext, r io.Reader, count int64) error { + sc := bufio.NewScanner(r) + buf := make([]byte, 4096) + sc.Buffer(buf, maxHeadLineBytes) + sc.Split(scanLinesPreservingNewline) + + var emitted int64 + for emitted < count && sc.Scan() { + if ctx.Err() != nil { + return ctx.Err() + } + if _, err := callCtx.Stdout.Write(sc.Bytes()); err != nil { + return err + } + emitted++ + } + return sc.Err() +} + +// headBytes writes the first count bytes of r to callCtx.Stdout. It reads +// in fixed-size chunks; the buffer is capped at chunkSize but shrunk to +// count when count is smaller, avoiding unnecessary allocation for small +// byte requests (e.g. head -c 5). +func headBytes(ctx context.Context, callCtx *CallContext, r io.Reader, count int64) error { + if count == 0 { + return nil + } + const chunkSize = 32 * 1024 + buf := make([]byte, min(int64(chunkSize), count)) + remaining := count + for remaining > 0 { + if ctx.Err() != nil { + return ctx.Err() + } + toRead := min(int64(chunkSize), remaining) + n, err := r.Read(buf[:toRead]) + if n > 0 { + remaining -= int64(n) + if _, werr := callCtx.Stdout.Write(buf[:n]); werr != nil { + return werr + } + } + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + } + return nil +} + +// headParseCount parses a line or byte count string. A leading '+' is +// accepted (treated as a positive sign by strconv.ParseInt, matching GNU +// head behavior). Returns (count, true) on success, (0, false) on failure. +func headParseCount(s string) (int64, bool) { + if s == "" { + return 0, false + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil || n < 0 { + return 0, false + } + if n > maxHeadCount { + n = maxHeadCount + } + return n, true +} + +// headModeFlag is a pflag.Value implementation for -n/--lines and -c/--bytes. +// Two headModeFlag values share a *seq counter; each call to Set increments +// the counter and records the new value in pos. After pflag.Parse, comparing +// pos fields reveals which flag appeared last on the command line — without +// scanning raw args or inspecting individual characters of flag tokens. +type headModeFlag struct { + val string + seq *int // shared per-invocation counter; incremented on every Set call + pos int // counter value when Set was last called; 0 means never set +} + +func newHeadModeFlag(seq *int, defaultVal string) *headModeFlag { + return &headModeFlag{val: defaultVal, seq: seq} +} + +func (f *headModeFlag) String() string { return f.val } +func (f *headModeFlag) Set(s string) error { + f.val = s + *f.seq++ + f.pos = *f.seq + return nil +} +func (f *headModeFlag) Type() string { return "string" } + +// scanLinesPreservingNewline is a bufio.SplitFunc that includes the line +// terminator (\n) in the returned token. Unlike bufio.ScanLines, it does not +// strip \r\n or \n, so the caller reproduces the exact file content. If the +// file's last line has no terminator, the bare bytes are returned as the +// final token. +func scanLinesPreservingNewline(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + for i, b := range data { + if b == '\n' { + return i + 1, data[:i+1], nil + } + } + if atEOF { + // Last line has no trailing newline; return what we have. + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} diff --git a/interp/builtins/tests/head/head_test.go b/interp/builtins/tests/head/head_test.go new file mode 100644 index 00000000..5fd37d27 --- /dev/null +++ b/interp/builtins/tests/head/head_test.go @@ -0,0 +1,733 @@ +// 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 head_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/interp" +) + +// runScriptCtx runs a shell script with a context and returns stdout, stderr, +// and the exit code. +func runScriptCtx(ctx context.Context, t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(script), "") + require.NoError(t, err) + + var outBuf, errBuf bytes.Buffer + allOpts := append([]interp.RunnerOption{interp.StdIO(nil, &outBuf, &errBuf)}, opts...) + runner, err := interp.New(allOpts...) + require.NoError(t, err) + defer runner.Close() + + if dir != "" { + runner.Dir = dir + } + + err = runner.Run(ctx, prog) + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else if ctx.Err() == nil { + t.Fatalf("unexpected error: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +// runScript runs a shell script and returns stdout, stderr, and the exit code. +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return runScriptCtx(context.Background(), t, script, dir, opts...) +} + +// cmdRun runs a head command with AllowedPaths set to dir. +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +// writeFile creates a file in dir with the given content and returns its name. +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) + return name +} + +// fiveLines is a 5-line file used across multiple tests. +const fiveLines = "alpha\nbeta\ngamma\ndelta\nepsilon\n" + +// twelveLines is a 12-line file used to test the default 10-line limit. +const twelveLines = "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" + +// --- Default behavior --- + +func TestHeadDefaultTenLines(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", twelveLines) + stdout, _, code := cmdRun(t, "head file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\n", stdout) +} + +func TestHeadFileShorterThanDefault(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, stdout) +} + +func TestHeadEmptyFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "empty.txt", "") + stdout, _, code := cmdRun(t, "head empty.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +// --- -n / --lines flag --- + +func TestHeadLinesN3(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 3 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +func TestHeadLinesN0(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 0 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestHeadLinesLargerThanFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 100 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, stdout) +} + +func TestHeadLinesLongForm(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head --lines=3 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +func TestHeadLinesPositivePrefix(t *testing.T) { + // GNU head: "+N" is treated as plain N (positive sign). + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n +2 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +func TestHeadLinesGlued(t *testing.T) { + // -n3 (value glued to flag) is supported by pflag. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n3 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +// --- No trailing newline preservation --- + +func TestHeadNoTrailingNewline(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "no newline at end") + stdout, _, code := cmdRun(t, "head -n 2 file.txt", dir) + assert.Equal(t, 0, code) + // Single line without newline — output exactly as-is. + assert.Equal(t, "no newline at end", stdout) +} + +func TestHeadLastLineNoNewline(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "line1\nline2") + stdout, _, code := cmdRun(t, "head -n 2 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line1\nline2", stdout) +} + +func TestHeadFirstLineNewlineSecondNot(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "line1\nline2") + stdout, _, code := cmdRun(t, "head -n 1 file.txt", dir) + assert.Equal(t, 0, code) + // Only the first line (with its newline) is printed. + assert.Equal(t, "line1\n", stdout) +} + +// --- -c / --bytes flag --- + +func TestHeadBytesN5(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c 5 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) +} + +func TestHeadBytesN0(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c 0 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestHeadBytesLargerThanFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c 9999 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, stdout) +} + +func TestHeadBytesLongForm(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head --bytes=5 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) +} + +func TestHeadBytesPositivePrefix(t *testing.T) { + // GNU head: "+N" is treated as plain N for -c too. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c +3 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alp", stdout) +} + +func TestHeadBytesBinaryContent(t *testing.T) { + dir := t.TempDir() + // Write binary content including null bytes. + content := "a\x00b\x00c\x00d" + writeFile(t, dir, "file.bin", content) + stdout, _, code := cmdRun(t, "head -c 5 file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\x00b\x00c", stdout) +} + +// --- Last flag wins (-n vs -c) --- + +func TestHeadLastFlagWinsBytes(t *testing.T) { + // -n 2 -c 5: last flag is -c, so byte mode with 5 bytes. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 2 -c 5 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) +} + +func TestHeadLastFlagWinsLines(t *testing.T) { + // -c 5 -n 2: last flag is -n, so line mode with 2 lines. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c 5 -n 2 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +// --- Headers (-v / -q / --silent) --- + +func TestHeadVerboseSingleFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "one.txt", "only one line\n") + stdout, _, code := cmdRun(t, "head -v one.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> one.txt <==\nonly one line\n", stdout) +} + +func TestHeadTwoFilesDefaultHeaders(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\nbeta\n") + writeFile(t, dir, "b.txt", "gamma\n") + stdout, _, code := cmdRun(t, "head -n 2 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\nalpha\nbeta\n\n==> b.txt <==\ngamma\n", stdout) +} + +func TestHeadTwoFilesSecondNoNewline(t *testing.T) { + // Verifies that the separator \n before the second header is always printed, + // regardless of whether the first file ended with a newline. + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\nbeta\n") + writeFile(t, dir, "b.txt", "no newline") + stdout, _, code := cmdRun(t, "head -n 2 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\nalpha\nbeta\n\n==> b.txt <==\nno newline", stdout) +} + +func TestHeadFirstFileNoNewline(t *testing.T) { + // When first file ends without \n, the header separator still adds \n. + dir := t.TempDir() + writeFile(t, dir, "a.txt", "no newline") + writeFile(t, dir, "b.txt", "next\n") + stdout, _, code := cmdRun(t, "head -n 1 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\nno newline\n==> b.txt <==\nnext\n", stdout) +} + +func TestHeadQuietTwoFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\nbeta\n") + writeFile(t, dir, "b.txt", "gamma\n") + stdout, _, code := cmdRun(t, "head -q -n 2 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +func TestHeadSilentAlias(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\nbeta\n") + writeFile(t, dir, "b.txt", "gamma\n") + stdout, _, code := cmdRun(t, "head --silent -n 2 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +func TestHeadVerboseTwoFiles(t *testing.T) { + // -v on multiple files still works (headers always printed). + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\n") + writeFile(t, dir, "b.txt", "beta\n") + stdout, _, code := cmdRun(t, "head -v -n 1 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\nalpha\n\n==> b.txt <==\nbeta\n", stdout) +} + +// --- Stdin --- + +func TestHeadStdinImplicit(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 2 < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +func TestHeadStdinDash(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 2 - < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +func TestHeadStdinVerbose(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", "hello\n") + stdout, _, code := cmdRun(t, "head -v - < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> standard input <==\nhello\n", stdout) +} + +// --- Help --- + +func TestHeadHelp(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "head --help", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") + assert.Contains(t, stdout, "--lines") + assert.Contains(t, stdout, "--bytes") + assert.Empty(t, stderr) +} + +func TestHeadHelpShort(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "head -h", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") + assert.Empty(t, stderr) +} + +// --- Error cases --- + +func TestHeadMissingFile(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "head nonexistent.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + _, stderr, code := cmdRun(t, "head subdir", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadUnknownFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "head --follow file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadUnknownShortFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "head -f file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadInvalidCountString(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + _, stderr, code := cmdRun(t, "head -n abc file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadNegativeCount(t *testing.T) { + // GNU head -n -N means "all but last N lines" — we do NOT support that. + // We reject negative counts. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + _, stderr, code := cmdRun(t, "head -n -1 file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadNegativeBytesCount(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + _, stderr, code := cmdRun(t, "head -c -1 file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadOutsideAllowedPaths(t *testing.T) { + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret"), 0644)) + + secretPath := strings.ReplaceAll(filepath.Join(secret, "secret.txt"), `\`, `/`) + _, stderr, code := runScript(t, "head "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadMultipleFilesSomeFailSomeSuc(t *testing.T) { + // When some files fail and some succeed, exit code is 1 and successful + // files still produce output. + dir := t.TempDir() + writeFile(t, dir, "good.txt", "hello\n") + stdout, stderr, code := cmdRun(t, "head -n 1 good.txt nonexistent.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stdout, "hello") + assert.Contains(t, stderr, "head:") +} + +// --- RULES.md compliance --- + +func TestHeadLargeCountClamped(t *testing.T) { + // A count larger than maxHeadCount (1<<31-1) must be clamped, not cause OOM. + // We pass a very large count on a tiny file; it should output the file content + // without crashing or hanging. + dir := t.TempDir() + writeFile(t, dir, "small.txt", "tiny\n") + stdout, _, code := cmdRun(t, "head -n 9999999999 small.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny\n", stdout) +} + +func TestHeadLargeByteCountClamped(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "small.txt", "tiny") + stdout, _, code := cmdRun(t, "head -c 9999999999 small.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny", stdout) +} + +func TestHeadContextCancellation(t *testing.T) { + // The command must stop when the context is cancelled. + dir := t.TempDir() + // Use a pipe: create a heredoc that provides input. + writeFile(t, dir, "data.txt", fiveLines) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Should complete well within 5 seconds. + _, _, code := runScriptCtx(ctx, t, "head -n 3 data.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) +} + +func TestHeadDoubleDash(t *testing.T) { + // After --, all args are treated as file names, even if they look like flags. + dir := t.TempDir() + writeFile(t, dir, "-n", "flag-looking-name\n") + stdout, _, code := cmdRun(t, "head -- -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "flag-looking-name\n", stdout) +} + +func TestHeadNullBytesInContent(t *testing.T) { + // Binary content with null bytes must not crash or hang. + dir := t.TempDir() + content := "a\x00b\x00c\x00\n" + writeFile(t, dir, "binary.bin", content) + stdout, _, code := cmdRun(t, "head -n 1 binary.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, content, stdout) +} + +func TestHeadCRLFPreserved(t *testing.T) { + // CRLF line endings must be preserved exactly in the output. + dir := t.TempDir() + writeFile(t, dir, "crlf.txt", "line1\r\nline2\r\nline3\r\n") + stdout, _, code := cmdRun(t, "head -n 2 crlf.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line1\r\nline2\r\n", stdout) +} + +func TestHeadPipeInput(t *testing.T) { + // Verify head works correctly in a pipeline. + dir := t.TempDir() + writeFile(t, dir, "file.txt", twelveLines) + // cat file.txt | head -n 3 + stdout, _, code := cmdRun(t, "cat file.txt | head -n 3", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line01\nline02\nline03\n", stdout) +} + +func TestHeadLineModeOnLineExactlyAtCap(t *testing.T) { + // A line of exactly maxHeadLineBytes (1 MiB) with no newline. + // bufio.Scanner.Buffer(buf, max) cannot hold a token of exactly max + // bytes (the limit is exclusive), so this must error like an over-cap line. + dir := t.TempDir() + content := make([]byte, 1<<20) + for i := range content { + content[i] = 'a' + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "exact.txt"), content, 0644)) + _, _, code := cmdRun(t, "head -n 1 exact.txt", dir) + assert.Equal(t, 1, code) +} + +func TestHeadLineModeOnSingleLineBeyondCap(t *testing.T) { + // A line of maxHeadLineBytes+1 (1 MiB + 1 byte) with no newline. + // Exceeds the scanner buffer cap and must error, not crash. + dir := t.TempDir() + oneMiBPlusOne := make([]byte, 1<<20+1) + for i := range oneMiBPlusOne { + oneMiBPlusOne[i] = 'a' + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "huge.txt"), oneMiBPlusOne, 0644)) + _, stderr, code := cmdRun(t, "head -n 1 huge.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadLineModeOnLineBelowCap(t *testing.T) { + // A line just below the 1MiB cap should succeed. + dir := t.TempDir() + // Write (1MiB - 1) bytes of 'b' followed by a newline. + content := make([]byte, 1<<20-1) + for i := range content { + content[i] = 'b' + } + content = append(content, '\n') + require.NoError(t, os.WriteFile(filepath.Join(dir, "large.txt"), content, 0644)) + stdout, _, code := cmdRun(t, "head -n 1 large.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(content), stdout) +} + +func TestHeadEmptyCountString(t *testing.T) { + dir := t.TempDir() + // pflag with StringP default "10" means "-n" alone with no value is an error. + // If somehow an empty string is passed, it should be rejected. + writeFile(t, dir, "file.txt", fiveLines) + _, stderr, code := cmdRun(t, `head -n "" file.txt`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadNilStdin(t *testing.T) { + // When head is asked to read stdin ("-") but the shell has no stdin, + // it should produce no output and exit 0 (callCtx.Stdin == nil path). + dir := t.TempDir() + // runScript with no stdin redirect — shell stdin stays nil. + stdout, stderr, code := runScript(t, "head -", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} + +func TestHeadNilStdinVerbose(t *testing.T) { + // -v must print the header for stdin even when callCtx.Stdin == nil. + // Previously the nil guard fired before the header block, silently + // skipping the "==> standard input <==" line. + dir := t.TempDir() + stdout, stderr, code := runScript(t, "head -v -", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + assert.Equal(t, "==> standard input <==\n", stdout) + assert.Equal(t, "", stderr) +} + +func TestHeadBytesAppearsLastWithDoubleDash(t *testing.T) { + // pflag stops parsing at "--", so file names after "--" are never + // mistaken for flags. With -n and -c both set before "--", the + // last-flag-wins logic applies (bytes mode because -c appears last). + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + // -n 3 -c 5 -- file.txt: both set, -c appears last before --, so byte mode. + stdout, _, code := cmdRun(t, "head -n 3 -c 5 -- file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) // first 5 bytes +} + +func TestHeadContextPreCancelled(t *testing.T) { + // A pre-cancelled context should cause the command to abort immediately. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before running + + // We don't assert a specific exit code (context cancellation may or may + // not surface as exit code 1 depending on timing), but we must not hang. + done := make(chan struct{}) + go func() { + runScriptCtx(ctx, t, "head -n 5 file.txt", dir, interp.AllowedPaths([]string{dir})) + close(done) + }() + select { + case <-done: + // completed without hanging + case <-time.After(5 * time.Second): + t.Fatal("head with pre-cancelled context did not return within 5s") + } +} + +func TestHeadNoOctalInterpretation08(t *testing.T) { + // "08" must be interpreted as decimal 8, not rejected as an invalid octal. + dir := t.TempDir() + writeFile(t, dir, "file.txt", twelveLines) + stdout, _, code := cmdRun(t, "head -n 08 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, 8, strings.Count(stdout, "\n")) +} + +func TestHeadNoOctalInterpretation010(t *testing.T) { + // "010" must be interpreted as decimal 10, not octal 8. + dir := t.TempDir() + writeFile(t, dir, "file.txt", twelveLines) + stdout, _, code := cmdRun(t, "head -n 010 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, 10, strings.Count(stdout, "\n")) +} + +// --- Bad UTF-8 / binary passthrough --- + +// TestHeadBadUTF8ByteMode verifies that invalid UTF-8 bytes are passed through +// unchanged in byte mode. +// +// Derived from uutils test_head.rs::test_bad_utf8 +func TestHeadBadUTF8ByteMode(t *testing.T) { + dir := t.TempDir() + content := []byte{0xfc, 0x80, 0x80, 0x80, 0x80, 0xaf} + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.bin"), content, 0644)) + stdout, _, code := cmdRun(t, "head -c 6 bad.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(content), stdout) +} + +// TestHeadBadUTF8LineMode verifies that invalid UTF-8 bytes within lines are +// passed through unchanged in line mode. +// +// Derived from uutils test_head.rs::test_bad_utf8_lines +func TestHeadBadUTF8LineMode(t *testing.T) { + dir := t.TempDir() + // Three lines, each containing invalid UTF-8; request first 2 lines. + // input: \xfc\x80\x80\x80\x80\xaf\n b\xfc...\xaf\n b\xfc...\xaf (no final newline) + // expected: first 2 lines only, bytes preserved verbatim. + badSeq := []byte{0xfc, 0x80, 0x80, 0x80, 0x80, 0xaf} + line1 := append(append([]byte(nil), badSeq...), '\n') + line2 := append(append([]byte("b"), badSeq...), '\n') + line3 := append([]byte("b"), badSeq...) + input := append(append(append([]byte(nil), line1...), line2...), line3...) + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.bin"), input, 0644)) + + expected := append(append([]byte(nil), line1...), line2...) + stdout, _, code := cmdRun(t, "head -n 2 bad.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(expected), stdout) +} + +// --- Multi-file edge cases --- + +// TestHeadTwoEmptyFilesHeaders verifies that headers and the blank-line +// separator are still emitted when both files are empty. +// +// Derived from uutils test_head.rs::test_multiple_files +func TestHeadTwoEmptyFilesHeaders(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "") + writeFile(t, dir, "b.txt", "") + stdout, _, code := cmdRun(t, "head a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\n\n==> b.txt <==\n", stdout) +} + +// TestHeadMultipleFilesWithStdin verifies that '-' interleaved among file +// arguments reads stdin and prints a "standard input" header alongside the +// file headers. +// +// Derived from uutils test_head.rs::test_multiple_files_with_stdin +func TestHeadMultipleFilesWithStdin(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "empty.txt", "") + writeFile(t, dir, "stdin_src.txt", "hello\n") + stdout, _, code := cmdRun(t, "head empty.txt - empty.txt < stdin_src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> empty.txt <==\n\n==> standard input <==\nhello\n\n==> empty.txt <==\n", stdout) +} + +// TestHeadAllNonexistentFiles verifies that each nonexistent file gets its own +// error message and no headers are printed for failed opens. +// +// Derived from uutils test_head.rs::test_multiple_nonexistent_files +func TestHeadAllNonexistentFiles(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "head missing1.txt missing2.txt", dir) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "missing1.txt") + assert.Contains(t, stderr, "missing2.txt") + assert.NotContains(t, stdout, "==> missing1.txt <==") + assert.NotContains(t, stdout, "==> missing2.txt <==") +} diff --git a/interp/builtins/tests/head/head_unix_test.go b/interp/builtins/tests/head/head_unix_test.go new file mode 100644 index 00000000..824ab1a8 --- /dev/null +++ b/interp/builtins/tests/head/head_unix_test.go @@ -0,0 +1,61 @@ +// 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 unix + +package head_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +func TestHeadSymlinkToFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "real.txt", "hello from real\n") + // Use a relative symlink target so os.Root can follow it within the sandbox. + require.NoError(t, os.Symlink("real.txt", filepath.Join(dir, "link.txt"))) + stdout, _, code := cmdRun(t, "head -n 1 link.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "hello from real\n", stdout) +} + +func TestHeadDanglingSymlink(t *testing.T) { + dir := t.TempDir() + // Create a symlink pointing nowhere. + require.NoError(t, os.Symlink(filepath.Join(dir, "does_not_exist.txt"), filepath.Join(dir, "dangling.txt"))) + _, stderr, code := cmdRun(t, "head dangling.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadDevNull(t *testing.T) { + // /dev/null is an empty source: head should output nothing and exit 0. + // (Only meaningful on Unix; uses allowed path bypass since /dev/null is outside dir.) + dir := t.TempDir() + stdout, _, code := runScript(t, "head /dev/null", dir, + interp.AllowedPaths([]string{dir, "/dev"}), + ) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestHeadPermissionDenied(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "noperms.txt", "secret\n") + require.NoError(t, os.Chmod(filepath.Join(dir, "noperms.txt"), 0000)) + t.Cleanup(func() { + _ = os.Chmod(filepath.Join(dir, "noperms.txt"), 0644) + }) + _, stderr, code := cmdRun(t, "head noperms.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} diff --git a/interp/builtins/tests/head/head_windows_test.go b/interp/builtins/tests/head/head_windows_test.go new file mode 100644 index 00000000..03f82b6b --- /dev/null +++ b/interp/builtins/tests/head/head_windows_test.go @@ -0,0 +1,29 @@ +// 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 head_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHeadWindowsReservedName(t *testing.T) { + // Windows reserved device names (CON, PRN, AUX, NUL, COM1, LPT1, etc.) + // must never be opened as files — attempting to do so can hang or behave + // unexpectedly. The sandbox (AllowedPaths) should block access, resulting + // in a permission-denied error rather than a hang. + dir := t.TempDir() + for _, name := range []string{"CON", "PRN", "AUX", "NUL", "COM1", "LPT1"} { + t.Run(name, func(t *testing.T) { + _, stderr, code := cmdRun(t, "head "+name, dir) + assert.Equal(t, 1, code, "expected failure for reserved name %s", name) + assert.Contains(t, stderr, "head:", "expected head: prefix in stderr for %s", name) + }) + } +} diff --git a/tests/import_allowlist_test.go b/tests/import_allowlist_test.go index a30785be..f05cbbb7 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -28,12 +28,20 @@ import ( // All packages not listed here are implicitly banned, including all // third-party packages and other internal module packages. var builtinAllowedSymbols = []string{ + "bufio.NewScanner", "context.Context", + "errors.Is", + "github.com/spf13/pflag.ContinueOnError", + "github.com/spf13/pflag.NewFlagSet", "io.Copy", + "io.Discard", + "io.EOF", "io.NopCloser", "io.ReadCloser", + "io.Reader", "os.O_RDONLY", "strconv.Atoi", + "strconv.ParseInt", } // permanentlyBanned lists packages that may never be imported by builtin diff --git a/tests/scenarios/cmd/head/bytes/basic.yaml b/tests/scenarios/cmd/head/bytes/basic.yaml new file mode 100644 index 00000000..68d83b43 --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/basic.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl obs-2 and obs-3 tests +description: head -c N outputs exactly the first N bytes. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -c 5 file.txt +expect: + stdout: "alpha" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/bytes/larger_than_file.yaml b/tests/scenarios/cmd/head/bytes/larger_than_file.yaml new file mode 100644 index 00000000..5ba9842c --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/larger_than_file.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl tests +description: head -c N larger than file outputs the entire file without error. +setup: + files: + - path: file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + head -c 9999 file.txt +expect: + stdout: "hello" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/bytes/last_flag_wins_bytes.yaml b/tests/scenarios/cmd/head/bytes/last_flag_wins_bytes.yaml new file mode 100644 index 00000000..2a50d09d --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/last_flag_wins_bytes.yaml @@ -0,0 +1,15 @@ +# Derived from GNU coreutils head behavior: last of -n/-c wins +description: When -n and -c are both given, the last flag wins; here -c wins. +skip_assert_against_bash: true +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 2 -c 5 file.txt +expect: + stdout: "alpha" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/bytes/last_flag_wins_lines.yaml b/tests/scenarios/cmd/head/bytes/last_flag_wins_lines.yaml new file mode 100644 index 00000000..50a00ed3 --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/last_flag_wins_lines.yaml @@ -0,0 +1,15 @@ +# Derived from GNU coreutils head behavior: last of -n/-c wins +description: When -c and -n are both given, the last flag wins; here -n wins. +skip_assert_against_bash: true +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -c 5 -n 2 file.txt +expect: + stdout: "alpha\nbeta\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/bytes/long_form.yaml b/tests/scenarios/cmd/head/bytes/long_form.yaml new file mode 100644 index 00000000..6208eb9c --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/long_form.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl tests +description: head --bytes=N long form is equivalent to -c N. +setup: + files: + - path: file.txt + content: "alpha\nbeta\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head --bytes=5 file.txt +expect: + stdout: "alpha" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/bytes/zero.yaml b/tests/scenarios/cmd/head/bytes/zero.yaml new file mode 100644 index 00000000..28e801c1 --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/zero.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl test +description: head -c 0 produces no output. +setup: + files: + - path: file.txt + content: "alpha\nbeta\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -c 0 file.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/errors/all_missing.yaml b/tests/scenarios/cmd/head/errors/all_missing.yaml new file mode 100644 index 00000000..a1364435 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/all_missing.yaml @@ -0,0 +1,12 @@ +# Derived from uutils test_head.rs::test_multiple_nonexistent_files +description: All nonexistent files each produce an error message; no headers are printed. +input: + allowed_paths: ["$DIR"] + script: |+ + head missing1.txt missing2.txt +expect: + stdout: "" + stderr_contains: + - "missing1.txt" + - "missing2.txt" + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/directory.yaml b/tests/scenarios/cmd/head/errors/directory.yaml new file mode 100644 index 00000000..86bf5170 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/directory.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX error behavior +description: head exits 1 with an error when given a directory as an argument. +setup: + files: + - path: subdir/.keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + head subdir +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/invalid_n_flag.yaml b/tests/scenarios/cmd/head/errors/invalid_n_flag.yaml new file mode 100644 index 00000000..b38e3115 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/invalid_n_flag.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX error behavior +description: head exits 1 when -n is given a non-numeric argument. +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n abc file.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/missing_file.yaml b/tests/scenarios/cmd/head/errors/missing_file.yaml new file mode 100644 index 00000000..ad7fc72d --- /dev/null +++ b/tests/scenarios/cmd/head/errors/missing_file.yaml @@ -0,0 +1,10 @@ +# Derived from standard POSIX error behavior +description: head exits 1 and prints an error when a file does not exist. +input: + allowed_paths: ["$DIR"] + script: |+ + head nonexistent.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/multiple_some_fail.yaml b/tests/scenarios/cmd/head/errors/multiple_some_fail.yaml new file mode 100644 index 00000000..89e39f08 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/multiple_some_fail.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX error behavior +description: head continues processing remaining files even after one fails, and exits 1. +setup: + files: + - path: good.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -q good.txt nonexistent.txt +expect: + stdout: "hello\n" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/negative_count.yaml b/tests/scenarios/cmd/head/errors/negative_count.yaml new file mode 100644 index 00000000..3ad71682 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/negative_count.yaml @@ -0,0 +1,15 @@ +# We intentionally reject negative counts (we do not implement -n -N elide-tail mode) +description: head exits 1 when -n is given a negative count. +skip_assert_against_bash: true +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n -1 file.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/unknown_flag.yaml b/tests/scenarios/cmd/head/errors/unknown_flag.yaml new file mode 100644 index 00000000..27469df7 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/unknown_flag.yaml @@ -0,0 +1,15 @@ +# Per RULES.md: every dangerous/unsupported flag must have a test verifying rejection +description: head exits 1 with an error when given an unknown flag. +skip_assert_against_bash: true +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head --follow file.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/hardening/double_dash_separator.yaml b/tests/scenarios/cmd/head/hardening/double_dash_separator.yaml new file mode 100644 index 00000000..d0a997a5 --- /dev/null +++ b/tests/scenarios/cmd/head/hardening/double_dash_separator.yaml @@ -0,0 +1,15 @@ +# Standard POSIX -- end-of-flags behavior +description: head treats arguments after -- as file names, even if they look like flags. +skip_assert_against_bash: true +setup: + files: + - path: -n + content: "flag-looking-name\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -- -n +expect: + stdout: "flag-looking-name\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/hardening/large_count_clamped.yaml b/tests/scenarios/cmd/head/hardening/large_count_clamped.yaml new file mode 100644 index 00000000..00d37f7a --- /dev/null +++ b/tests/scenarios/cmd/head/hardening/large_count_clamped.yaml @@ -0,0 +1,15 @@ +# Per RULES.md: count values must be clamped to prevent allocation attacks +description: head accepts very large -n counts by clamping; does not OOM on small files. +skip_assert_against_bash: true +setup: + files: + - path: small.txt + content: "tiny\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 9999999999 small.txt +expect: + stdout: "tiny\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml b/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml new file mode 100644 index 00000000..bd0df4c5 --- /dev/null +++ b/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml @@ -0,0 +1,15 @@ +# Per RULES.md: file access must be sandboxed via AllowedPaths +description: head is blocked from reading files outside the allowed paths sandbox. +skip_assert_against_bash: true # intentional sandbox restriction; bash/head can read /etc/passwd freely +setup: + files: + - path: local.txt + content: "local\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head /etc/passwd +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/headers/quiet_two_files.yaml b/tests/scenarios/cmd/head/headers/quiet_two_files.yaml new file mode 100644 index 00000000..9436682a --- /dev/null +++ b/tests/scenarios/cmd/head/headers/quiet_two_files.yaml @@ -0,0 +1,16 @@ +# Derived from GNU coreutils head behavior with -q +description: head -q suppresses file name headers even for multiple files. +setup: + files: + - path: a.txt + content: "alpha\nbeta\n" + - path: b.txt + content: "gamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -q -n 2 a.txt b.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/silent_alias.yaml b/tests/scenarios/cmd/head/headers/silent_alias.yaml new file mode 100644 index 00000000..a43efd70 --- /dev/null +++ b/tests/scenarios/cmd/head/headers/silent_alias.yaml @@ -0,0 +1,16 @@ +# Derived from GNU coreutils head behavior with --silent +description: head --silent is an alias for --quiet and suppresses headers. +setup: + files: + - path: a.txt + content: "alpha\nbeta\n" + - path: b.txt + content: "gamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head --silent -n 2 a.txt b.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/two_empty_files.yaml b/tests/scenarios/cmd/head/headers/two_empty_files.yaml new file mode 100644 index 00000000..975e328a --- /dev/null +++ b/tests/scenarios/cmd/head/headers/two_empty_files.yaml @@ -0,0 +1,16 @@ +# Derived from uutils test_head.rs::test_multiple_files +description: Two empty files still get headers and a blank-line separator between them. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + head a.txt b.txt +expect: + stdout: "==> a.txt <==\n\n==> b.txt <==\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/two_files_default.yaml b/tests/scenarios/cmd/head/headers/two_files_default.yaml new file mode 100644 index 00000000..66e4b518 --- /dev/null +++ b/tests/scenarios/cmd/head/headers/two_files_default.yaml @@ -0,0 +1,16 @@ +# Derived from GNU coreutils head behavior with multiple files +description: head with two files prints headers and a blank-line separator between them. +setup: + files: + - path: a.txt + content: "alpha\nbeta\n" + - path: b.txt + content: "gamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 2 a.txt b.txt +expect: + stdout: "==> a.txt <==\nalpha\nbeta\n\n==> b.txt <==\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/verbose_single_file.yaml b/tests/scenarios/cmd/head/headers/verbose_single_file.yaml new file mode 100644 index 00000000..7659bc50 --- /dev/null +++ b/tests/scenarios/cmd/head/headers/verbose_single_file.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head behavior with -v +description: head -v prints a header even for a single file. +setup: + files: + - path: one.txt + content: "only one line\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -v one.txt +expect: + stdout: "==> one.txt <==\nonly one line\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/default_ten_lines.yaml b/tests/scenarios/cmd/head/lines/default_ten_lines.yaml new file mode 100644 index 00000000..65e3c3a4 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/default_ten_lines.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl test basic-11 +description: head outputs exactly 10 lines by default when file has more than 10. +setup: + files: + - path: file.txt + content: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head file.txt +expect: + stdout: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/empty_file.yaml b/tests/scenarios/cmd/head/lines/empty_file.yaml new file mode 100644 index 00000000..6466724c --- /dev/null +++ b/tests/scenarios/cmd/head/lines/empty_file.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl idem-0 test +description: head on an empty file produces no output and exits 0. +setup: + files: + - path: empty.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + head empty.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/fewer_than_default.yaml b/tests/scenarios/cmd/head/lines/fewer_than_default.yaml new file mode 100644 index 00000000..8a45b525 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/fewer_than_default.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl test basic-09 +description: head outputs all lines when file has fewer than 10. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head file.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/long_form.yaml b/tests/scenarios/cmd/head/lines/long_form.yaml new file mode 100644 index 00000000..2c397bf9 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/long_form.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl basic tests +description: head --lines=N long form is equivalent to -n N. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\ndelta\nepsilon\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head --lines=3 file.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/n_flag.yaml b/tests/scenarios/cmd/head/lines/n_flag.yaml new file mode 100644 index 00000000..a1f2e28d --- /dev/null +++ b/tests/scenarios/cmd/head/lines/n_flag.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl basic tests +description: head -n 3 outputs the first 3 lines of a file. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\ndelta\nepsilon\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 3 file.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/n_larger_than_file.yaml b/tests/scenarios/cmd/head/lines/n_larger_than_file.yaml new file mode 100644 index 00000000..f3802728 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/n_larger_than_file.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl basic tests +description: head -n N larger than file length outputs all lines without error. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 100 file.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/n_zero.yaml b/tests/scenarios/cmd/head/lines/n_zero.yaml new file mode 100644 index 00000000..87f7a0ca --- /dev/null +++ b/tests/scenarios/cmd/head/lines/n_zero.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl test +description: head -n 0 produces no output. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 0 file.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/no_octal_interpretation.yaml b/tests/scenarios/cmd/head/lines/no_octal_interpretation.yaml new file mode 100644 index 00000000..3425c5f5 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/no_octal_interpretation.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl tests no-oct-3 and no-oct-4 +description: head -n 08 and -n 010 are interpreted as decimal 8 and 10, not octal. +setup: + files: + - path: file.txt + content: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 08 file.txt +expect: + stdout: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/no_trailing_newline.yaml b/tests/scenarios/cmd/head/lines/no_trailing_newline.yaml new file mode 100644 index 00000000..d77afc90 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/no_trailing_newline.yaml @@ -0,0 +1,15 @@ +# Derived from GNU coreutils head.pl idem-1 test +description: head preserves a file's missing trailing newline exactly. +skip_assert_against_bash: false +setup: + files: + - path: file.txt + content: "no newline at end" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 1 file.txt +expect: + stdout: "no newline at end" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/null_bytes.yaml b/tests/scenarios/cmd/head/lines/null_bytes.yaml new file mode 100644 index 00000000..386fcc28 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/null_bytes.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl null-1 test +description: head handles null bytes in file content without crashing. +setup: + files: + - path: file.txt + content: "a\x00a\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 1 file.txt +expect: + stdout: "a\x00a\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/dash_explicit.yaml b/tests/scenarios/cmd/head/stdin/dash_explicit.yaml new file mode 100644 index 00000000..ce86a742 --- /dev/null +++ b/tests/scenarios/cmd/head/stdin/dash_explicit.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX head behavior +description: head - explicitly reads from standard input. +setup: + files: + - path: src.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 2 - < src.txt +expect: + stdout: "alpha\nbeta\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/implicit.yaml b/tests/scenarios/cmd/head/stdin/implicit.yaml new file mode 100644 index 00000000..0272fc82 --- /dev/null +++ b/tests/scenarios/cmd/head/stdin/implicit.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX head behavior +description: head with no file arguments reads from standard input. +setup: + files: + - path: src.txt + content: "alpha\nbeta\ngamma\ndelta\nepsilon\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 2 < src.txt +expect: + stdout: "alpha\nbeta\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml b/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml new file mode 100644 index 00000000..31742250 --- /dev/null +++ b/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml @@ -0,0 +1,16 @@ +# Derived from uutils test_head.rs::test_multiple_files_with_stdin +description: Stdin interleaved with file args shows a standard input header alongside file headers. +setup: + files: + - path: empty.txt + content: "" + - path: stdin_src.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head empty.txt - empty.txt < stdin_src.txt +expect: + stdout: "==> empty.txt <==\n\n==> standard input <==\nhello\n\n==> empty.txt <==\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/pipe.yaml b/tests/scenarios/cmd/head/stdin/pipe.yaml new file mode 100644 index 00000000..213cbd6d --- /dev/null +++ b/tests/scenarios/cmd/head/stdin/pipe.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX head behavior in pipelines +description: head works correctly in a pipeline receiving input from cat. +setup: + files: + - path: file.txt + content: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat file.txt | head -n 3 +expect: + stdout: "line01\nline02\nline03\n" + stderr: "" + exit_code: 0