From c98300c8b27f26b73ac28f5676302da816a93551 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 15:24:25 -0400 Subject: [PATCH 01/14] Add head builtin command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `head` as a safe Go builtin in interp/builtins/head.go, following the same patterns as existing builtins (cat, echo, etc.). ## Behavior - Default: print first 10 lines of each FILE to stdout - Reads from stdin when no files are given or when FILE is `-` - Multi-file output preceded by `==> filename <==` headers with blank-line separators - All file access goes through callCtx.OpenFile() — the AllowedPaths sandbox is enforced automatically ## Flags implemented - `-n N` / `--lines=N` — print first N lines (default 10) - `-c N` / `--bytes=N` — print first N bytes instead of lines - `-q` / `--quiet` / `--silent` — suppress file headers - `-v` / `--verbose` — always print file headers, even for a single file - `-h` / `--help` — print usage to stdout and exit 0 - When both `-n` and `-c` are given, the last flag on the command line wins (matches GNU head) ## Memory safety - Line mode uses bufio.Scanner with a custom SplitFunc (scanLinesPreservingNewline) that preserves exact line endings including CRLF and handles files with no trailing newline. A per-line cap of 1 MiB (maxHeadLineBytes) causes an error rather than unbounded allocation. - Byte mode reads in fixed 32 KiB chunks; allocation never scales with user-supplied N. - User-supplied counts are clamped to maxHeadCount (2^31-1) before any use. - ctx.Err() is checked at every loop iteration to honor execution timeouts. ## Tests - interp/builtins/tests/head/head_test.go — unit tests covering all flags, edge cases, RULES.md compliance (line cap, count clamping, CRLF, nil stdin, context cancellation) - interp/builtins/tests/head/head_unix_test.go — symlink follow, dangling symlink, /dev/null, permission denied - interp/builtins/tests/head/head_windows_test.go — Windows reserved device names rejected - interp/builtin_head_gnu_compat_test.go — byte-for-byte output equivalence against GNU coreutils 9.10 (ghead) reference outputs embedded as string literals - interp/builtin_head_pentest_test.go — integer edge cases (0, MaxInt32, overflow, negative), special files (/dev/zero DoS check), long lines, resource exhaustion (200+ file args, 1M-line file), path traversal, flag injection - tests/scenarios/cmd/head/ — 27 YAML scenario files grouped by concern: lines/, bytes/, headers/, stdin/, errors/, hardening/ Co-Authored-By: Claude Sonnet 4.6 --- interp/builtin_head_gnu_compat_test.go | 242 +++++++ interp/builtin_head_pentest_test.go | 498 ++++++++++++++ interp/builtins/head.go | 320 +++++++++ interp/builtins/tests/head/head_test.go | 628 ++++++++++++++++++ interp/builtins/tests/head/head_unix_test.go | 61 ++ .../builtins/tests/head/head_windows_test.go | 29 + tests/import_allowlist_test.go | 8 + tests/scenarios/cmd/head/bytes/basic.yaml | 14 + .../cmd/head/bytes/larger_than_file.yaml | 14 + .../cmd/head/bytes/last_flag_wins_bytes.yaml | 15 + .../cmd/head/bytes/last_flag_wins_lines.yaml | 15 + tests/scenarios/cmd/head/bytes/long_form.yaml | 14 + tests/scenarios/cmd/head/bytes/zero.yaml | 14 + .../scenarios/cmd/head/errors/directory.yaml | 14 + .../cmd/head/errors/invalid_n_flag.yaml | 14 + .../cmd/head/errors/missing_file.yaml | 10 + .../cmd/head/errors/multiple_some_fail.yaml | 14 + .../cmd/head/errors/negative_count.yaml | 15 + .../cmd/head/errors/unknown_flag.yaml | 15 + .../head/hardening/double_dash_separator.yaml | 15 + .../head/hardening/large_count_clamped.yaml | 15 + .../head/hardening/outside_allowed_paths.yaml | 14 + .../cmd/head/headers/quiet_two_files.yaml | 16 + .../cmd/head/headers/silent_alias.yaml | 16 + .../cmd/head/headers/two_files_default.yaml | 16 + .../cmd/head/headers/verbose_single_file.yaml | 14 + .../cmd/head/lines/default_ten_lines.yaml | 14 + .../scenarios/cmd/head/lines/empty_file.yaml | 14 + .../cmd/head/lines/fewer_than_default.yaml | 14 + tests/scenarios/cmd/head/lines/long_form.yaml | 14 + tests/scenarios/cmd/head/lines/n_flag.yaml | 14 + .../cmd/head/lines/n_larger_than_file.yaml | 14 + tests/scenarios/cmd/head/lines/n_zero.yaml | 14 + .../head/lines/no_octal_interpretation.yaml | 14 + .../cmd/head/lines/no_trailing_newline.yaml | 15 + .../scenarios/cmd/head/lines/null_bytes.yaml | 14 + .../cmd/head/stdin/dash_explicit.yaml | 14 + tests/scenarios/cmd/head/stdin/implicit.yaml | 14 + tests/scenarios/cmd/head/stdin/pipe.yaml | 14 + 39 files changed, 2243 insertions(+) create mode 100644 interp/builtin_head_gnu_compat_test.go create mode 100644 interp/builtin_head_pentest_test.go create mode 100644 interp/builtins/head.go create mode 100644 interp/builtins/tests/head/head_test.go create mode 100644 interp/builtins/tests/head/head_unix_test.go create mode 100644 interp/builtins/tests/head/head_windows_test.go create mode 100644 tests/scenarios/cmd/head/bytes/basic.yaml create mode 100644 tests/scenarios/cmd/head/bytes/larger_than_file.yaml create mode 100644 tests/scenarios/cmd/head/bytes/last_flag_wins_bytes.yaml create mode 100644 tests/scenarios/cmd/head/bytes/last_flag_wins_lines.yaml create mode 100644 tests/scenarios/cmd/head/bytes/long_form.yaml create mode 100644 tests/scenarios/cmd/head/bytes/zero.yaml create mode 100644 tests/scenarios/cmd/head/errors/directory.yaml create mode 100644 tests/scenarios/cmd/head/errors/invalid_n_flag.yaml create mode 100644 tests/scenarios/cmd/head/errors/missing_file.yaml create mode 100644 tests/scenarios/cmd/head/errors/multiple_some_fail.yaml create mode 100644 tests/scenarios/cmd/head/errors/negative_count.yaml create mode 100644 tests/scenarios/cmd/head/errors/unknown_flag.yaml create mode 100644 tests/scenarios/cmd/head/hardening/double_dash_separator.yaml create mode 100644 tests/scenarios/cmd/head/hardening/large_count_clamped.yaml create mode 100644 tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml create mode 100644 tests/scenarios/cmd/head/headers/quiet_two_files.yaml create mode 100644 tests/scenarios/cmd/head/headers/silent_alias.yaml create mode 100644 tests/scenarios/cmd/head/headers/two_files_default.yaml create mode 100644 tests/scenarios/cmd/head/headers/verbose_single_file.yaml create mode 100644 tests/scenarios/cmd/head/lines/default_ten_lines.yaml create mode 100644 tests/scenarios/cmd/head/lines/empty_file.yaml create mode 100644 tests/scenarios/cmd/head/lines/fewer_than_default.yaml create mode 100644 tests/scenarios/cmd/head/lines/long_form.yaml create mode 100644 tests/scenarios/cmd/head/lines/n_flag.yaml create mode 100644 tests/scenarios/cmd/head/lines/n_larger_than_file.yaml create mode 100644 tests/scenarios/cmd/head/lines/n_zero.yaml create mode 100644 tests/scenarios/cmd/head/lines/no_octal_interpretation.yaml create mode 100644 tests/scenarios/cmd/head/lines/no_trailing_newline.yaml create mode 100644 tests/scenarios/cmd/head/lines/null_bytes.yaml create mode 100644 tests/scenarios/cmd/head/stdin/dash_explicit.yaml create mode 100644 tests/scenarios/cmd/head/stdin/implicit.yaml create mode 100644 tests/scenarios/cmd/head/stdin/pipe.yaml 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..86b13934 --- /dev/null +++ b/interp/builtin_head_pentest_test.go @@ -0,0 +1,498 @@ +// 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 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..321dcaee --- /dev/null +++ b/interp/builtins/head.go @@ -0,0 +1,320 @@ +// 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" + "io" + "os" + "strconv" + "strings" + + "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") + lines := fs.StringP("lines", "n", "10", "print the first N lines instead of the first 10") + bytes_ := fs.StringP("bytes", "c", "", "print the first N bytes instead of lines") + 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") + + 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 + } + + // Determine mode: lines vs bytes. When both flags are provided, the last + // one on the command line wins (matches GNU head behavior). When neither + // or only -n is provided, useBytesMode stays false (line mode is default). + linesChanged := fs.Changed("lines") + bytesChanged := fs.Changed("bytes") + + useBytesMode := false + switch { + case linesChanged && bytesChanged: + useBytesMode = headBytesAppearsLast(args) + case bytesChanged: + useBytesMode = true + // default: line mode (useBytesMode = false already) + } + + // Parse the count for the chosen mode. + countStr := *lines + modeLabel := "lines" + if useBytesMode { + countStr = *bytes_ + 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 == "-" { + if callCtx.Stdin == nil { + return nil + } + rc = io.NopCloser(callCtx.Stdin) + name = "(standard input)" + } else { + f, err := callCtx.OpenFile(ctx, file, os.O_RDONLY, 0) + if err != nil { + return err + } + rc = f + } + defer rc.Close() + + 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 and never allocates proportionally to count. +func headBytes(ctx context.Context, callCtx *CallContext, r io.Reader, count int64) error { + const chunkSize = 32 * 1024 + buf := make([]byte, chunkSize) + 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 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 +} + +// headBytesAppearsLast reports whether the last mode-selecting flag in args +// is -c/--bytes. This implements the GNU "last flag wins" behavior when both +// -n and -c appear on the same command line. +// +// lastBytes and lastLines are initialised to -1 ("not seen"). Any valid +// arg index is ≥ 0, so the comparison lastBytes > lastLines correctly +// selects the flag that appeared later, and returns false when neither (or +// only one) mode flag is present. +func headBytesAppearsLast(args []string) bool { + lastBytes, lastLines := -1, -1 + for i, arg := range args { + if arg == "--" { + break + } + if headIsModeFlag(arg, 'c', "--bytes") { + lastBytes = i + } else if headIsModeFlag(arg, 'n', "--lines") { + lastLines = i + } + } + return lastBytes > lastLines +} + +// headIsModeFlag reports whether arg is a short flag token for the given +// short rune (e.g. 'c') or a long flag token (e.g. "--bytes" / "--bytes=N"). +// It matches: -X, -XN (value glued), --long, --long=N. +func headIsModeFlag(arg string, short byte, long string) bool { + return arg == string([]byte{'-', short}) || + (len(arg) > 2 && arg[0] == '-' && arg[1] == short && arg[2] != '-') || + arg == long || + strings.HasPrefix(arg, long+"=") +} + +// 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..a7882776 --- /dev/null +++ b/interp/builtins/tests/head/head_test.go @@ -0,0 +1,628 @@ +// 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 TestHeadLineModeOnSingleLineBeyondCap(t *testing.T) { + // A line exactly at the 1MiB cap should cause an error, not a crash. + dir := t.TempDir() + // Write 1MiB + 1 bytes of 'a' with no newline. + 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)) + // head should error with exit code 1 (line too long for scanner). + _, 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 TestHeadBytesAppearsLastWithDoubleDash(t *testing.T) { + // headBytesAppearsLast must stop scanning at "--" so it doesn't mistake + // file names like "--bytes=5" for flags. With -n and -c both set before + // the "--" separator, the last-flag-wins logic applies. + 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")) +} 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..6c8a4ce1 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", + "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", + "strings.HasPrefix", } // 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/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..79efe984 --- /dev/null +++ b/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml @@ -0,0 +1,14 @@ +# Per RULES.md: file access must be sandboxed via AllowedPaths +description: head is blocked from reading files outside the allowed paths sandbox. +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_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/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 From 1dd5faa54152d0592ec18f1b754b18682ad8e915 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 15:29:35 -0400 Subject: [PATCH 02/14] Fix head bash comparison tests: add skip_assert_against_bash where needed The bash:5.2 Docker image uses BusyBox head, which only supports POSIX short flags (-n, -c). Four scenarios needed skip_assert_against_bash: - lines/long_form: --lines=N is a GNU extension (BusyBox only has -n) - bytes/long_form: --bytes=N is a GNU extension (BusyBox only has -c) - headers/silent_alias: --silent is a GNU extension not in BusyBox - hardening/outside_allowed_paths: intentional sandbox restriction; bash can read /etc/passwd freely, so the expected exit_code: 1 is intentional divergence from bash behavior Co-Authored-By: Claude Sonnet 4.6 --- tests/scenarios/cmd/head/bytes/long_form.yaml | 1 + tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml | 1 + tests/scenarios/cmd/head/headers/silent_alias.yaml | 1 + tests/scenarios/cmd/head/lines/long_form.yaml | 1 + 4 files changed, 4 insertions(+) diff --git a/tests/scenarios/cmd/head/bytes/long_form.yaml b/tests/scenarios/cmd/head/bytes/long_form.yaml index 6208eb9c..13d94165 100644 --- a/tests/scenarios/cmd/head/bytes/long_form.yaml +++ b/tests/scenarios/cmd/head/bytes/long_form.yaml @@ -1,5 +1,6 @@ # Derived from GNU coreutils head.pl tests description: head --bytes=N long form is equivalent to -c N. +skip_assert_against_bash: true # --bytes= is a GNU extension; BusyBox head (bash:5.2 image) only supports -c setup: files: - path: file.txt diff --git a/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml b/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml index 79efe984..40733892 100644 --- a/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml +++ b/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml @@ -1,5 +1,6 @@ # 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 can read /etc/passwd freely setup: files: - path: local.txt diff --git a/tests/scenarios/cmd/head/headers/silent_alias.yaml b/tests/scenarios/cmd/head/headers/silent_alias.yaml index a43efd70..7e066776 100644 --- a/tests/scenarios/cmd/head/headers/silent_alias.yaml +++ b/tests/scenarios/cmd/head/headers/silent_alias.yaml @@ -1,5 +1,6 @@ # Derived from GNU coreutils head behavior with --silent description: head --silent is an alias for --quiet and suppresses headers. +skip_assert_against_bash: true # --silent is a GNU extension; BusyBox head (bash:5.2 image) does not support it setup: files: - path: a.txt diff --git a/tests/scenarios/cmd/head/lines/long_form.yaml b/tests/scenarios/cmd/head/lines/long_form.yaml index 2c397bf9..af655618 100644 --- a/tests/scenarios/cmd/head/lines/long_form.yaml +++ b/tests/scenarios/cmd/head/lines/long_form.yaml @@ -1,5 +1,6 @@ # Derived from GNU coreutils head.pl basic tests description: head --lines=N long form is equivalent to -n N. +skip_assert_against_bash: true # --lines= is a GNU extension; BusyBox head (bash:5.2 image) only supports -n setup: files: - path: file.txt From 584cdb7adbd6009356ac31384efe2758579c8961 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 15:34:11 -0400 Subject: [PATCH 03/14] Switch bash comparison test image to debian:bookworm-slim bash:5.2 uses Alpine/BusyBox head which lacks GNU long-form flags (--lines=N, --bytes=N, --silent). debian:bookworm-slim is an official Docker image (1B+ pulls) with GNU bash 5.2 and GNU coreutils 9.1, giving byte-for-byte GNU compatibility without BusyBox limitations. Also reverts the three incorrect skip_assert_against_bash additions from the previous commit (long_form and silent_alias scenarios now run against GNU head as intended). Retains skip_assert_against_bash on hardening/outside_allowed_paths since that test intentionally diverges from bash behavior (sandbox restriction vs unrestricted host access). Co-Authored-By: Claude Sonnet 4.6 --- tests/scenarios/cmd/head/bytes/long_form.yaml | 1 - tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml | 2 +- tests/scenarios/cmd/head/headers/silent_alias.yaml | 1 - tests/scenarios/cmd/head/lines/long_form.yaml | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/scenarios/cmd/head/bytes/long_form.yaml b/tests/scenarios/cmd/head/bytes/long_form.yaml index 13d94165..6208eb9c 100644 --- a/tests/scenarios/cmd/head/bytes/long_form.yaml +++ b/tests/scenarios/cmd/head/bytes/long_form.yaml @@ -1,6 +1,5 @@ # Derived from GNU coreutils head.pl tests description: head --bytes=N long form is equivalent to -c N. -skip_assert_against_bash: true # --bytes= is a GNU extension; BusyBox head (bash:5.2 image) only supports -c setup: files: - path: file.txt diff --git a/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml b/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml index 40733892..bd0df4c5 100644 --- a/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml +++ b/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml @@ -1,6 +1,6 @@ # 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 can read /etc/passwd freely +skip_assert_against_bash: true # intentional sandbox restriction; bash/head can read /etc/passwd freely setup: files: - path: local.txt diff --git a/tests/scenarios/cmd/head/headers/silent_alias.yaml b/tests/scenarios/cmd/head/headers/silent_alias.yaml index 7e066776..a43efd70 100644 --- a/tests/scenarios/cmd/head/headers/silent_alias.yaml +++ b/tests/scenarios/cmd/head/headers/silent_alias.yaml @@ -1,6 +1,5 @@ # Derived from GNU coreutils head behavior with --silent description: head --silent is an alias for --quiet and suppresses headers. -skip_assert_against_bash: true # --silent is a GNU extension; BusyBox head (bash:5.2 image) does not support it setup: files: - path: a.txt diff --git a/tests/scenarios/cmd/head/lines/long_form.yaml b/tests/scenarios/cmd/head/lines/long_form.yaml index af655618..2c397bf9 100644 --- a/tests/scenarios/cmd/head/lines/long_form.yaml +++ b/tests/scenarios/cmd/head/lines/long_form.yaml @@ -1,6 +1,5 @@ # Derived from GNU coreutils head.pl basic tests description: head --lines=N long form is equivalent to -n N. -skip_assert_against_bash: true # --lines= is a GNU extension; BusyBox head (bash:5.2 image) only supports -n setup: files: - path: file.txt From f9973d5a71d0c3f99c4d98ebbd3dac3763c8006c Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 15:38:42 -0400 Subject: [PATCH 04/14] Document RSHELL_BASH_TEST bash comparison tests in AGENTS.md Adds a note to the Testing section explaining how to run the bash comparison test suite locally (requires Docker, skipped by default). Clarifies when skip_assert_against_bash is appropriate. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) 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: From 6cbb218fae0cf10ecf50a7cd18dd5e3b8e2de0cb Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 15:54:33 -0400 Subject: [PATCH 05/14] Replace manual arg scanning in head with pflag Value ordering headBytesAppearsLast and headIsModeFlag manually scanned raw args and inspected individual characters (arg[1]) to detect which of -n/-c appeared last on the command line. This violated the RULES.md principle of not writing manual flag-parsing loops, and also failed to detect mode flags combined with boolean short flags (e.g. -vn3). Fix: implement headModeFlag, a pflag.Value that records a parse-order sequence number each time Set() is called. Both -n/--lines and -c/--bytes share a *seq counter; after fs.Parse, comparing their pos fields reveals which was parsed last. pflag calls Set() in the correct order for all flag forms including combined boolean+value shorts. This removes headBytesAppearsLast, headIsModeFlag, and the strings import entirely. Mode selection reduces to a single comparison: useBytesMode := bytesFlag.pos > linesFlag.pos Also removes strings.HasPrefix from the import allowlist since it is no longer referenced by any builtin. Co-Authored-By: Claude Sonnet 4.6 --- interp/builtins/head.go | 84 +++++++++++-------------- interp/builtins/tests/head/head_test.go | 6 +- tests/import_allowlist_test.go | 1 - 3 files changed, 39 insertions(+), 52 deletions(-) diff --git a/interp/builtins/head.go b/interp/builtins/head.go index 321dcaee..029c7b9c 100644 --- a/interp/builtins/head.go +++ b/interp/builtins/head.go @@ -55,7 +55,6 @@ import ( "io" "os" "strconv" - "strings" "github.com/spf13/pflag" ) @@ -78,12 +77,20 @@ func builtinHead(ctx context.Context, callCtx *CallContext, args []string) Resul fs.SetOutput(io.Discard) help := fs.BoolP("help", "h", false, "print usage and exit") - lines := fs.StringP("lines", "n", "10", "print the first N lines instead of the first 10") - bytes_ := fs.StringP("bytes", "c", "", "print the first N bytes instead of lines") 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} @@ -103,26 +110,16 @@ func builtinHead(ctx context.Context, callCtx *CallContext, args []string) Resul *quiet = true } - // Determine mode: lines vs bytes. When both flags are provided, the last - // one on the command line wins (matches GNU head behavior). When neither - // or only -n is provided, useBytesMode stays false (line mode is default). - linesChanged := fs.Changed("lines") - bytesChanged := fs.Changed("bytes") - - useBytesMode := false - switch { - case linesChanged && bytesChanged: - useBytesMode = headBytesAppearsLast(args) - case bytesChanged: - useBytesMode = true - // default: line mode (useBytesMode = false already) - } + // 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 := *lines + countStr := linesFlag.val modeLabel := "lines" if useBytesMode { - countStr = *bytes_ + countStr = bytesFlag.val modeLabel = "bytes" } @@ -264,38 +261,29 @@ func headParseCount(s string) (int64, bool) { return n, true } -// headBytesAppearsLast reports whether the last mode-selecting flag in args -// is -c/--bytes. This implements the GNU "last flag wins" behavior when both -// -n and -c appear on the same command line. -// -// lastBytes and lastLines are initialised to -1 ("not seen"). Any valid -// arg index is ≥ 0, so the comparison lastBytes > lastLines correctly -// selects the flag that appeared later, and returns false when neither (or -// only one) mode flag is present. -func headBytesAppearsLast(args []string) bool { - lastBytes, lastLines := -1, -1 - for i, arg := range args { - if arg == "--" { - break - } - if headIsModeFlag(arg, 'c', "--bytes") { - lastBytes = i - } else if headIsModeFlag(arg, 'n', "--lines") { - lastLines = i - } - } - return lastBytes > lastLines +// 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 } -// headIsModeFlag reports whether arg is a short flag token for the given -// short rune (e.g. 'c') or a long flag token (e.g. "--bytes" / "--bytes=N"). -// It matches: -X, -XN (value glued), --long, --long=N. -func headIsModeFlag(arg string, short byte, long string) bool { - return arg == string([]byte{'-', short}) || - (len(arg) > 2 && arg[0] == '-' && arg[1] == short && arg[2] != '-') || - arg == long || - strings.HasPrefix(arg, long+"=") +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 diff --git a/interp/builtins/tests/head/head_test.go b/interp/builtins/tests/head/head_test.go index a7882776..17643a95 100644 --- a/interp/builtins/tests/head/head_test.go +++ b/interp/builtins/tests/head/head_test.go @@ -575,9 +575,9 @@ func TestHeadNilStdin(t *testing.T) { } func TestHeadBytesAppearsLastWithDoubleDash(t *testing.T) { - // headBytesAppearsLast must stop scanning at "--" so it doesn't mistake - // file names like "--bytes=5" for flags. With -n and -c both set before - // the "--" separator, the last-flag-wins logic applies. + // 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. diff --git a/tests/import_allowlist_test.go b/tests/import_allowlist_test.go index 6c8a4ce1..784ceb50 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -41,7 +41,6 @@ var builtinAllowedSymbols = []string{ "os.O_RDONLY", "strconv.Atoi", "strconv.ParseInt", - "strings.HasPrefix", } // permanentlyBanned lists packages that may never be imported by builtin From 415f6d301d108cc43ed2583c71cc2b846efe712d Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 15:56:33 -0400 Subject: [PATCH 06/14] Add head to SHELL_COMMANDS.md; add doc step to implement-posix-command skill SHELL_COMMANDS.md: add head row with supported flags (-n, -c, -q/--quiet/--silent, -v). implement-posix-command skill: add Step 9 (Update documentation) which requires adding a row to SHELL_COMMANDS.md after every new command is implemented. Updates task count from 8 to 9 and execution order description accordingly. Co-Authored-By: Claude Sonnet 4.6 --- .../skills/implement-posix-command/SKILL.md | 485 ++++++++++++++++++ SHELL_COMMANDS.md | 1 + 2 files changed, 486 insertions(+) create mode 100644 .claude/skills/implement-posix-command/SKILL.md diff --git a/.claude/skills/implement-posix-command/SKILL.md b/.claude/skills/implement-posix-command/SKILL.md new file mode 100644 index 00000000..b0d0f7f9 --- /dev/null +++ b/.claude/skills/implement-posix-command/SKILL.md @@ -0,0 +1,485 @@ +--- +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 the GNU coreutils source for reference: + +```bash +# GitHub mirror is more reliable than ftp.gnu.org +curl -sL https://github.com/coreutils/coreutils/archive/refs/heads/master.tar.gz | tar -xz -C /tmp +``` + +Look in `/tmp/coreutils-master/tests/$ARGUMENTS/` for the GNU 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_`). + +2. **Translate**: For each remaining test case, 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`). + +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. Do not declare the implementation done until every finding is resolved. + +## 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/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. | From ba6769a2ae07e8ccef26b5e472c0cbb31a8d03f9 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 16:01:06 -0400 Subject: [PATCH 07/14] Fix head: nil-stdin + -v now prints header before early return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit headProcessFile returned nil before reaching the header block when stdin was absent (callCtx.Stdin == nil), so `head -v -` with no stdin silently skipped the "==> (standard input) <==" header. Fix: move header printing into each branch so the ordering is correct for both cases: - stdin: print header → nil guard → read (header always emitted) - regular file: open → error-return if failed → print header → read (failed open still produces no header, matching GNU head) Adds TestHeadNilStdinVerbose to cover the fixed path. Co-Authored-By: Claude Sonnet 4.6 --- interp/builtins/head.go | 24 ++++++++++++++++-------- interp/builtins/tests/head/head_test.go | 11 +++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/interp/builtins/head.go b/interp/builtins/head.go index 029c7b9c..5ff60050 100644 --- a/interp/builtins/head.go +++ b/interp/builtins/head.go @@ -168,25 +168,33 @@ func headProcessFile(ctx context.Context, callCtx *CallContext, file string, idx 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) - name = "(standard input)" } else { f, err := callCtx.OpenFile(ctx, file, os.O_RDONLY, 0) if err != nil { return err } rc = f - } - defer rc.Close() - - if printHeaders { - if idx > 0 { - callCtx.Out("\n") + // 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) } - callCtx.Outf("==> %s <==\n", name) } if useBytesMode { diff --git a/interp/builtins/tests/head/head_test.go b/interp/builtins/tests/head/head_test.go index 17643a95..06edc708 100644 --- a/interp/builtins/tests/head/head_test.go +++ b/interp/builtins/tests/head/head_test.go @@ -574,6 +574,17 @@ func TestHeadNilStdin(t *testing.T) { 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 From b9862e5920d599c5d471c946fca6be824a82f56b Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 16:03:09 -0400 Subject: [PATCH 08/14] Shrink headBytes buffer to min(chunkSize, count) for small requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always allocating 32 KiB regardless of count was wasteful for small byte requests (e.g. head -c 5 allocated 32 KiB). Capping the initial buffer at min(chunkSize, count) avoids the excess allocation while keeping the chunked-read behaviour for large counts. The buf[:toRead] slicing remains safe: toRead = min(chunkSize, remaining) ≤ remaining ≤ count ≤ len(buf) in every iteration. Co-Authored-By: Claude Sonnet 4.6 --- interp/builtins/head.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/interp/builtins/head.go b/interp/builtins/head.go index 5ff60050..bf913e4b 100644 --- a/interp/builtins/head.go +++ b/interp/builtins/head.go @@ -225,10 +225,12 @@ func headLines(ctx context.Context, callCtx *CallContext, r io.Reader, count int } // headBytes writes the first count bytes of r to callCtx.Stdout. It reads -// in fixed-size chunks and never allocates proportionally to count. +// 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 { const chunkSize = 32 * 1024 - buf := make([]byte, chunkSize) + buf := make([]byte, min(int64(chunkSize), count)) remaining := count for remaining > 0 { if ctx.Err() != nil { From f174510ef137b4f59ec15b784dd53d267eb036a8 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 16:06:25 -0400 Subject: [PATCH 09/14] Add uutils/coreutils as second test reference source in implement-posix-command skill uutils has MIT-licensed tests that can be adapted more freely than GNU's GPL v3, and covers edge cases (bad UTF-8, integer overflow, write errors) that the GNU shell scripts often miss. Co-Authored-By: Claude Sonnet 4.6 --- .../skills/implement-posix-command/SKILL.md | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/.claude/skills/implement-posix-command/SKILL.md b/.claude/skills/implement-posix-command/SKILL.md index b0d0f7f9..0a7221d9 100644 --- a/.claude/skills/implement-posix-command/SKILL.md +++ b/.claude/skills/implement-posix-command/SKILL.md @@ -105,18 +105,31 @@ accepted flags that will be implemented. **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 the GNU coreutils source for reference: +Download two reference test suites: ```bash -# GitHub mirror is more reliable than ftp.gnu.org +# 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 ``` -Look in `/tmp/coreutils-master/tests/$ARGUMENTS/` for the GNU test cases. For each test file: +**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_`). -2. **Translate**: For each remaining test case, create one YAML scenario file at `tests/scenarios/cmd/$ARGUMENTS/`. The YAML format is: +**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. @@ -145,7 +158,7 @@ Group scenario files into subdirectories by concern (e.g. `lines/`, `bytes/`, `h **`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`). +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 From 7cf7829debf0b37e94dde09cec6ac78b25e87efd Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 16:15:42 -0400 Subject: [PATCH 10/14] Add head tests adapted from uutils/coreutils covering four gaps Four cases present in the uutils MIT-licensed test suite were not covered: - Bad UTF-8 byte passthrough in both byte and line mode - Two empty files still emit headers + blank-line separator - Stdin interleaved with file args shows (standard input) header - All-nonexistent files each get their own error, no headers printed Also adds three matching YAML scenarios (bash-comparable) for the multi-file and error cases. Co-Authored-By: Claude Sonnet 4.6 --- interp/builtins/tests/head/head_test.go | 81 +++++++++++++++++++ .../cmd/head/errors/all_missing.yaml | 12 +++ .../cmd/head/headers/two_empty_files.yaml | 16 ++++ .../cmd/head/stdin/mixed_with_files.yaml | 16 ++++ 4 files changed, 125 insertions(+) create mode 100644 tests/scenarios/cmd/head/errors/all_missing.yaml create mode 100644 tests/scenarios/cmd/head/headers/two_empty_files.yaml create mode 100644 tests/scenarios/cmd/head/stdin/mixed_with_files.yaml diff --git a/interp/builtins/tests/head/head_test.go b/interp/builtins/tests/head/head_test.go index 06edc708..942a8367 100644 --- a/interp/builtins/tests/head/head_test.go +++ b/interp/builtins/tests/head/head_test.go @@ -637,3 +637,84 @@ func TestHeadNoOctalInterpretation010(t *testing.T) { 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/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/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/stdin/mixed_with_files.yaml b/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml new file mode 100644 index 00000000..fdf8c499 --- /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 From 08db79b71e51fd59c651c10a1bc2a7826b3238fc Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 16:23:10 -0400 Subject: [PATCH 11/14] Add second-pass review round to implement-posix-command skill Step 7 After fixing first-pass findings, a fresh re-read catches issues that were obscured by the original bugs or introduced by the fixes themselves. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/implement-posix-command/SKILL.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.claude/skills/implement-posix-command/SKILL.md b/.claude/skills/implement-posix-command/SKILL.md index 0a7221d9..f4e57406 100644 --- a/.claude/skills/implement-posix-command/SKILL.md +++ b/.claude/skills/implement-posix-command/SKILL.md @@ -414,7 +414,17 @@ Review the implementation for standard Go best practices: - **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. Do not declare the implementation done until every finding is resolved. +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 From bf1fb7a00923f946fe37387fcdea2517c3970ec4 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 16:31:00 -0400 Subject: [PATCH 12/14] Fix file descriptor leak in head builtin and minor hardening - Add defer f.Close() for files opened by headProcessFile. Previously, files were never explicitly closed, leaking FDs until GC (visible with 210+ file args in the pentest). - Add early return in headBytes when count==0 to avoid a zero-length buffer allocation. - Use errors.Is(err, io.EOF) instead of direct comparison per Go idiom. - Add errors.Is to the import allowlist. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/head.go | 7 ++++++- tests/import_allowlist_test.go | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/interp/builtins/head.go b/interp/builtins/head.go index bf913e4b..843bd41f 100644 --- a/interp/builtins/head.go +++ b/interp/builtins/head.go @@ -52,6 +52,7 @@ package builtins import ( "bufio" "context" + "errors" "io" "os" "strconv" @@ -186,6 +187,7 @@ func headProcessFile(ctx context.Context, callCtx *CallContext, file string, idx 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). @@ -229,6 +231,9 @@ func headLines(ctx context.Context, callCtx *CallContext, r io.Reader, count int // 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 @@ -244,7 +249,7 @@ func headBytes(ctx context.Context, callCtx *CallContext, r io.Reader, count int return werr } } - if err == io.EOF { + if errors.Is(err, io.EOF) { return nil } if err != nil { diff --git a/tests/import_allowlist_test.go b/tests/import_allowlist_test.go index 784ceb50..f05cbbb7 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -30,6 +30,7 @@ import ( var builtinAllowedSymbols = []string{ "bufio.NewScanner", "context.Context", + "errors.Is", "github.com/spf13/pflag.ContinueOnError", "github.com/spf13/pflag.NewFlagSet", "io.Copy", From 9fa1493045b689881769395440e256c8d86b186b Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 16:35:31 -0400 Subject: [PATCH 13/14] Fix boundary tests for bufio.Scanner cap (exclusive, not inclusive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bufio.Scanner.Buffer(buf, max) cannot hold a token of exactly max bytes — the limit is exclusive. Update TestHeadLineModeOnLineExactlyAtCap and TestCmdPentestLongLineExactlyAtCap to expect failure (exit code 1) since the effective max token size is maxHeadLineBytes-1. Co-Authored-By: Claude Opus 4.6 --- interp/builtin_head_pentest_test.go | 30 +++++++++++++++++++++++++ interp/builtins/tests/head/head_test.go | 19 +++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/interp/builtin_head_pentest_test.go b/interp/builtin_head_pentest_test.go index 86b13934..a3b97808 100644 --- a/interp/builtin_head_pentest_test.go +++ b/interp/builtin_head_pentest_test.go @@ -272,6 +272,36 @@ func TestCmdPentestLongLineBelowCap(t *testing.T) { }) } +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() diff --git a/interp/builtins/tests/head/head_test.go b/interp/builtins/tests/head/head_test.go index 942a8367..21a62e70 100644 --- a/interp/builtins/tests/head/head_test.go +++ b/interp/builtins/tests/head/head_test.go @@ -523,16 +523,29 @@ func TestHeadPipeInput(t *testing.T) { 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 exactly at the 1MiB cap should cause an error, not a crash. + // 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() - // Write 1MiB + 1 bytes of 'a' with no newline. 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)) - // head should error with exit code 1 (line too long for scanner). _, stderr, code := cmdRun(t, "head -n 1 huge.txt", dir) assert.Equal(t, 1, code) assert.Contains(t, stderr, "head:") From 188589fe40559af4e60ea143992ca006da425a44 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Mon, 9 Mar 2026 16:40:23 -0400 Subject: [PATCH 14/14] Fix stdin header label to match GNU head ("standard input" not "(standard input)") GNU head uses "standard input" (without parentheses) in ==> ... <== headers. Update the label in head.go and fix all corresponding test assertions and YAML scenario expectations. Co-Authored-By: Claude Sonnet 4.6 --- interp/builtins/head.go | 4 ++-- interp/builtins/tests/head/head_test.go | 10 +++++----- tests/scenarios/cmd/head/stdin/mixed_with_files.yaml | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/interp/builtins/head.go b/interp/builtins/head.go index 843bd41f..badaaaa2 100644 --- a/interp/builtins/head.go +++ b/interp/builtins/head.go @@ -151,7 +151,7 @@ func builtinHead(ctx context.Context, callCtx *CallContext, args []string) Resul if err := headProcessFile(ctx, callCtx, file, i, printHeaders, useBytesMode, count); err != nil { name := file if file == "-" { - name = "(standard input)" + name = "standard input" } callCtx.Errf("head: %s: %s\n", name, callCtx.PortableErr(err)) failed = true @@ -169,7 +169,7 @@ func headProcessFile(ctx context.Context, callCtx *CallContext, file string, idx var rc io.ReadCloser name := file if file == "-" { - name = "(standard input)" + 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 { diff --git a/interp/builtins/tests/head/head_test.go b/interp/builtins/tests/head/head_test.go index 21a62e70..5fd37d27 100644 --- a/interp/builtins/tests/head/head_test.go +++ b/interp/builtins/tests/head/head_test.go @@ -348,7 +348,7 @@ func TestHeadStdinVerbose(t *testing.T) { 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) + assert.Equal(t, "==> standard input <==\nhello\n", stdout) } // --- Help --- @@ -590,11 +590,11 @@ func TestHeadNilStdin(t *testing.T) { 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. + // 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, "==> standard input <==\n", stdout) assert.Equal(t, "", stderr) } @@ -704,7 +704,7 @@ func TestHeadTwoEmptyFilesHeaders(t *testing.T) { } // TestHeadMultipleFilesWithStdin verifies that '-' interleaved among file -// arguments reads stdin and prints a "(standard input)" header alongside the +// arguments reads stdin and prints a "standard input" header alongside the // file headers. // // Derived from uutils test_head.rs::test_multiple_files_with_stdin @@ -714,7 +714,7 @@ func TestHeadMultipleFilesWithStdin(t *testing.T) { 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) + 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 diff --git a/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml b/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml index fdf8c499..31742250 100644 --- a/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml +++ b/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml @@ -1,5 +1,5 @@ # 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. +description: Stdin interleaved with file args shows a standard input header alongside file headers. setup: files: - path: empty.txt @@ -11,6 +11,6 @@ input: script: |+ head empty.txt - empty.txt < stdin_src.txt expect: - stdout: "==> empty.txt <==\n\n==> (standard input) <==\nhello\n\n==> empty.txt <==\n" + stdout: "==> empty.txt <==\n\n==> standard input <==\nhello\n\n==> empty.txt <==\n" stderr: "" exit_code: 0