From 07bfd997a553482f36bcf6e93b0631864f4085bc Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Wed, 11 Mar 2026 16:11:52 -0400 Subject: [PATCH 1/6] Remove head builtin command Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- SHELL_FEATURES.md | 1 - .../head/builtin_head_pentest_test.go | 528 ------------- interp/builtins/head/head.go | 317 -------- interp/builtins/head/head_gnu_compat_test.go | 226 ------ interp/builtins/head/head_test.go | 707 ------------------ interp/builtins/head/head_unix_test.go | 61 -- interp/builtins/head/head_windows_test.go | 29 - interp/register_builtins.go | 2 - 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 - .../cmd/head/errors/all_missing.yaml | 12 - .../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 | 15 - .../cmd/head/headers/quiet_two_files.yaml | 16 - .../cmd/head/headers/silent_alias.yaml | 16 - .../cmd/head/headers/two_empty_files.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 - .../cmd/head/stdin/mixed_with_files.yaml | 16 - tests/scenarios/cmd/head/stdin/pipe.yaml | 14 - 44 files changed, 1 insertion(+), 2374 deletions(-) delete mode 100644 interp/builtins/head/builtin_head_pentest_test.go delete mode 100644 interp/builtins/head/head.go delete mode 100644 interp/builtins/head/head_gnu_compat_test.go delete mode 100644 interp/builtins/head/head_test.go delete mode 100644 interp/builtins/head/head_unix_test.go delete mode 100644 interp/builtins/head/head_windows_test.go delete mode 100644 tests/scenarios/cmd/head/bytes/basic.yaml delete mode 100644 tests/scenarios/cmd/head/bytes/larger_than_file.yaml delete mode 100644 tests/scenarios/cmd/head/bytes/last_flag_wins_bytes.yaml delete mode 100644 tests/scenarios/cmd/head/bytes/last_flag_wins_lines.yaml delete mode 100644 tests/scenarios/cmd/head/bytes/long_form.yaml delete mode 100644 tests/scenarios/cmd/head/bytes/zero.yaml delete mode 100644 tests/scenarios/cmd/head/errors/all_missing.yaml delete mode 100644 tests/scenarios/cmd/head/errors/directory.yaml delete mode 100644 tests/scenarios/cmd/head/errors/invalid_n_flag.yaml delete mode 100644 tests/scenarios/cmd/head/errors/missing_file.yaml delete mode 100644 tests/scenarios/cmd/head/errors/multiple_some_fail.yaml delete mode 100644 tests/scenarios/cmd/head/errors/negative_count.yaml delete mode 100644 tests/scenarios/cmd/head/errors/unknown_flag.yaml delete mode 100644 tests/scenarios/cmd/head/hardening/double_dash_separator.yaml delete mode 100644 tests/scenarios/cmd/head/hardening/large_count_clamped.yaml delete mode 100644 tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml delete mode 100644 tests/scenarios/cmd/head/headers/quiet_two_files.yaml delete mode 100644 tests/scenarios/cmd/head/headers/silent_alias.yaml delete mode 100644 tests/scenarios/cmd/head/headers/two_empty_files.yaml delete mode 100644 tests/scenarios/cmd/head/headers/two_files_default.yaml delete mode 100644 tests/scenarios/cmd/head/headers/verbose_single_file.yaml delete mode 100644 tests/scenarios/cmd/head/lines/default_ten_lines.yaml delete mode 100644 tests/scenarios/cmd/head/lines/empty_file.yaml delete mode 100644 tests/scenarios/cmd/head/lines/fewer_than_default.yaml delete mode 100644 tests/scenarios/cmd/head/lines/long_form.yaml delete mode 100644 tests/scenarios/cmd/head/lines/n_flag.yaml delete mode 100644 tests/scenarios/cmd/head/lines/n_larger_than_file.yaml delete mode 100644 tests/scenarios/cmd/head/lines/n_zero.yaml delete mode 100644 tests/scenarios/cmd/head/lines/no_octal_interpretation.yaml delete mode 100644 tests/scenarios/cmd/head/lines/no_trailing_newline.yaml delete mode 100644 tests/scenarios/cmd/head/lines/null_bytes.yaml delete mode 100644 tests/scenarios/cmd/head/stdin/dash_explicit.yaml delete mode 100644 tests/scenarios/cmd/head/stdin/implicit.yaml delete mode 100644 tests/scenarios/cmd/head/stdin/mixed_with_files.yaml delete mode 100644 tests/scenarios/cmd/head/stdin/pipe.yaml diff --git a/README.md b/README.md index b6690f57..46c36e1c 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Linux, macOS, and Windows. ``` tests/scenarios/ -├── cmd/ # builtin command tests (echo, cat, grep, head, tail, uniq, wc, ...) +├── cmd/ # builtin command tests (echo, cat, grep, tail, uniq, wc, ...) └── shell/ # shell feature tests (pipes, variables, control flow, ...) ``` diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index b8312cce..3c171783 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -13,7 +13,6 @@ Blocked features are rejected before execution with exit code 2. - ✅ `exit [N]` — exit the shell with status N (default 0) - ✅ `false` — return exit code 1 - ✅ `grep [-EFGivclLnHhoqsxw] [-e PATTERN] [-m NUM] [-A NUM] [-B NUM] [-C NUM] PATTERN [FILE]...` — print lines that match patterns; uses RE2 regex engine (linear-time, no backtracking) -- ✅ `head [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the first part of files (default: first 10 lines) - ✅ `ls [-1aAdFhlpRrSt] [FILE]...` — list directory contents - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected diff --git a/interp/builtins/head/builtin_head_pentest_test.go b/interp/builtins/head/builtin_head_pentest_test.go deleted file mode 100644 index 1221c711..00000000 --- a/interp/builtins/head/builtin_head_pentest_test.go +++ /dev/null @@ -1,528 +0,0 @@ -// 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 head_test - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/DataDog/rshell/interp" -) - -const pentestTimeout = 10 * time.Second - -// headRun is a shorthand for runScript with AllowedPaths=dir. -func headRun(t *testing.T, script, dir string, extraPaths ...string) (stdout, stderr string, exitCode int) { - t.Helper() - paths := append([]string{dir}, extraPaths...) - return runScript(t, script, dir, interp.AllowedPaths(paths)) -} - -// mustNotHang runs f in a goroutine and fails the test if it does not return -// within pentestTimeout. -func mustNotHang(t *testing.T, f func()) { - t.Helper() - done := make(chan struct{}) - go func() { - defer close(done) - f() - }() - select { - case <-done: - case <-time.After(pentestTimeout): - t.Fatalf("operation did not complete within %s", pentestTimeout) - } -} - -// --- Integer edge cases: -n --- - -func TestCmdPentestLinesNZero(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - stdout, _, code := headRun(t, "head -n 0 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "", stdout) -} - -func TestCmdPentestLinesNOne(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("line1\nline2\n"), 0644)) - stdout, _, code := headRun(t, "head -n 1 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "line1\n", stdout) -} - -func TestCmdPentestLinesNMaxInt32(t *testing.T) { - // 2147483647: must be clamped / handled without OOM on a small file. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) - mustNotHang(t, func() { - stdout, _, code := headRun(t, "head -n 2147483647 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "tiny\n", stdout) - }) -} - -func TestCmdPentestLinesNMaxInt64(t *testing.T) { - // 9223372036854775807 = max int64; clamped to maxHeadCount. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) - mustNotHang(t, func() { - stdout, _, code := headRun(t, "head -n 9223372036854775807 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "tiny\n", stdout) - }) -} - -func TestCmdPentestLinesNMaxInt64PlusOne(t *testing.T) { - // 9223372036854775808 overflows int64 — ParseInt returns an error. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) - _, stderr, code := headRun(t, "head -n 9223372036854775808 f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestLinesNHugeOverflow(t *testing.T) { - // A numeric string too large for int64. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) - _, stderr, code := headRun(t, "head -n 99999999999999999999 f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestLinesNNegativeOne(t *testing.T) { - // Negative counts must be rejected (we don't implement elide-tail mode). - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - _, stderr, code := headRun(t, "head -n -1 f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestLinesNNegativeLarge(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - _, stderr, code := headRun(t, "head -n -9999999999 f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestLinesNPlusZero(t *testing.T) { - // "+0" is parsed by ParseInt as 0. GNU head -n +0 = no output. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - stdout, _, code := headRun(t, "head -n +0 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "", stdout) -} - -func TestCmdPentestLinesNPlusOne(t *testing.T) { - // "+1" = 1 line (positive sign, matches GNU head behavior). - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("alpha\nbeta\n"), 0644)) - stdout, _, code := headRun(t, "head -n +1 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\n", stdout) -} - -func TestCmdPentestLinesNEmptyString(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - _, stderr, code := headRun(t, `head -n "" f.txt`, dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestLinesNWhitespace(t *testing.T) { - // A whitespace string — ParseInt rejects it. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - _, stderr, code := headRun(t, `head -n " " f.txt`, dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -// --- Integer edge cases: -c --- - -func TestCmdPentestBytesNZero(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello"), 0644)) - stdout, _, code := headRun(t, "head -c 0 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "", stdout) -} - -func TestCmdPentestBytesNOne(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello"), 0644)) - stdout, _, code := headRun(t, "head -c 1 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "h", stdout) -} - -func TestCmdPentestBytesNMaxInt32(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny"), 0644)) - mustNotHang(t, func() { - stdout, _, code := headRun(t, "head -c 2147483647 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "tiny", stdout) - }) -} - -func TestCmdPentestBytesNHugeOverflow(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny"), 0644)) - _, stderr, code := headRun(t, "head -c 99999999999999999999 f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestBytesNNegative(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello"), 0644)) - _, stderr, code := headRun(t, "head -c -1 f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestBytesPlusMaxInt64(t *testing.T) { - // "+9223372036854775807" = max int64; clamped to maxHeadCount. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny"), 0644)) - mustNotHang(t, func() { - stdout, _, code := headRun(t, "head -c +9223372036854775807 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "tiny", stdout) - }) -} - -// --- Special files / infinite sources (Unix only) --- - -func TestCmdPentestDevNull(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("no /dev/null on Windows") - } - // /dev/null is empty; head should produce no output and exit 0. - dir := t.TempDir() - stdout, _, code := headRun(t, "head /dev/null", dir, "/dev") - assert.Equal(t, 0, code) - assert.Equal(t, "", stdout) -} - -func TestCmdPentestDevZeroLineMode(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("no /dev/zero on Windows") - } - // head -n 3 /dev/zero: /dev/zero produces infinite NUL bytes with no \n. - // The scanner's 1MiB line cap will trigger an error, so the command MUST - // terminate rather than hang. Matches GNU behavior (scanner errors out). - dir := t.TempDir() - mustNotHang(t, func() { - _, _, _ = headRun(t, "head -n 3 /dev/zero", dir, "/dev") - // We only assert non-hang; exit code may be 0 or 1. - }) -} - -func TestCmdPentestDevZeroByteMode(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("no /dev/zero on Windows") - } - // head -c 10 /dev/zero: must read exactly 10 bytes and stop. - dir := t.TempDir() - mustNotHang(t, func() { - stdout, _, code := headRun(t, "head -c 10 /dev/zero", dir, "/dev") - assert.Equal(t, 0, code) - assert.Equal(t, string(bytes.Repeat([]byte{0}, 10)), stdout) - }) -} - -// --- Long lines --- - -func TestCmdPentestLongLineBelowCap(t *testing.T) { - // 1MiB - 1 bytes of 'a' followed by \n — should succeed. - dir := t.TempDir() - content := bytes.Repeat([]byte("a"), 1<<20-1) - content = append(content, '\n') - require.NoError(t, os.WriteFile(filepath.Join(dir, "long.txt"), content, 0644)) - mustNotHang(t, func() { - stdout, _, code := headRun(t, "head -n 1 long.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, string(content), stdout) - }) -} - -func TestCmdPentestLongLineExactlyAtCap(t *testing.T) { - // Exactly 1 MiB of 'a' with no newline. bufio.Scanner.Buffer(buf, max) - // cannot hold a token of exactly max bytes (the limit is exclusive), so - // this must error just like a line that exceeds the cap. - dir := t.TempDir() - content := bytes.Repeat([]byte("a"), 1<<20) - require.NoError(t, os.WriteFile(filepath.Join(dir, "exact.txt"), content, 0644)) - mustNotHang(t, func() { - _, stderr, code := headRun(t, "head -n 1 exact.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") - }) -} - -func TestCmdPentestLongLineExactCapWithNewline(t *testing.T) { - // Exactly 1 MiB of 'a' followed by \n. The scanner sees the token as - // 1 MiB + 1 byte (content + newline). scanLinesPreservingNewline includes - // the \n in the token, so the token exceeds maxHeadLineBytes and the - // scanner must error. - dir := t.TempDir() - content := bytes.Repeat([]byte("a"), 1<<20) - content = append(content, '\n') - require.NoError(t, os.WriteFile(filepath.Join(dir, "exact_nl.txt"), content, 0644)) - mustNotHang(t, func() { - _, stderr, code := headRun(t, "head -n 1 exact_nl.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") - }) -} - -func TestCmdPentestLongLineExceedsCap(t *testing.T) { - // 1MiB + 1 bytes of 'a' (no newline) — scanner errors (line too long). - dir := t.TempDir() - content := bytes.Repeat([]byte("a"), 1<<20+1) - require.NoError(t, os.WriteFile(filepath.Join(dir, "huge.txt"), content, 0644)) - mustNotHang(t, func() { - _, stderr, code := headRun(t, "head -n 1 huge.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") - }) -} - -func TestCmdPentestTwoLinesNearCap(t *testing.T) { - // Two lines each just below the cap; head -n 2 should output both. - dir := t.TempDir() - line := bytes.Repeat([]byte("b"), 1<<20-2) - line = append(line, '\n') - content := append(append([]byte{}, line...), line...) - require.NoError(t, os.WriteFile(filepath.Join(dir, "two.txt"), content, 0644)) - mustNotHang(t, func() { - stdout, _, code := headRun(t, "head -n 2 two.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, string(content), stdout) - }) -} - -// --- Memory / resource exhaustion --- - -func TestCmdPentestMaxInt32CountSmallFile(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "small.txt"), []byte("small\n"), 0644)) - mustNotHang(t, func() { - stdout, _, code := headRun(t, "head -n 2147483647 small.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "small\n", stdout) - }) -} - -func TestCmdPentestMaxInt32ByteCountSmallFile(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "small.txt"), []byte("tiny"), 0644)) - mustNotHang(t, func() { - stdout, _, code := headRun(t, "head -c 2147483647 small.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "tiny", stdout) - }) -} - -func TestCmdPentestManyFileArguments(t *testing.T) { - // 210 file arguments: verify no file-descriptor leak or crash. - dir := t.TempDir() - var sb strings.Builder - sb.WriteString("head -q -n 1") - for i := 0; i < 210; i++ { - name := fmt.Sprintf("f%03d.txt", i) - require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(fmt.Sprintf("file%d\n", i)), 0644)) - sb.WriteString(" ") - sb.WriteString(name) - } - mustNotHang(t, func() { - stdout, _, code := headRun(t, sb.String(), dir) - assert.Equal(t, 0, code) - // 210 files × one "file%d\n" line = 210 lines. - assert.Equal(t, 210, strings.Count(stdout, "\n")) - }) -} - -func TestCmdPentestMillionLineFile(t *testing.T) { - // 1M-line file: head -n 5 must output first 5 lines quickly. - dir := t.TempDir() - var buf bytes.Buffer - for i := 0; i < 1_000_000; i++ { - buf.WriteString("x\n") - } - require.NoError(t, os.WriteFile(filepath.Join(dir, "million.txt"), buf.Bytes(), 0644)) - done := make(chan struct{}) - go func() { - defer close(done) - stdout, _, code := headRun(t, "head -n 5 million.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "x\nx\nx\nx\nx\n", stdout) - }() - select { - case <-done: - case <-time.After(30 * time.Second): - t.Fatal("head on 1M-line file did not complete within 30s") - } -} - -// --- Path and filename edge cases --- - -func TestCmdPentestNonexistentFile(t *testing.T) { - dir := t.TempDir() - _, stderr, code := headRun(t, "head does_not_exist.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestEmptyFilename(t *testing.T) { - // An empty string as a filename should produce an error. - dir := t.TempDir() - _, _, code := headRun(t, `head ""`, dir) - assert.Equal(t, 1, code) -} - -func TestCmdPentestFlagLikeName(t *testing.T) { - // A file whose name starts with '-' must be accessible via '--'. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "-n"), []byte("flag-file\n"), 0644)) - stdout, _, code := headRun(t, "head -- -n", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "flag-file\n", stdout) -} - -func TestCmdPentestDirectoryAsFile(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) - _, stderr, code := headRun(t, "head subdir", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestOutsideSandbox(t *testing.T) { - // Attempting to read a file outside the allowed path must be blocked. - allowed := t.TempDir() - secret := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(secret, "s.txt"), []byte("secret"), 0644)) - secretPath := filepath.ToSlash(filepath.Join(secret, "s.txt")) - _, stderr, code := runScript(t, "head "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestPathTraversal(t *testing.T) { - // Path traversal with ../ must be blocked by the sandbox. - dir := t.TempDir() - _, stderr, code := headRun(t, "head ../../etc/passwd", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -// --- Flag and argument injection --- - -func TestCmdPentestUnknownLongFlag(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - _, stderr, code := headRun(t, "head --no-such-flag f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestUnknownShortFlag(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - _, stderr, code := headRun(t, "head -f f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestFollowFlagRejected(t *testing.T) { - // --follow is a tail flag that must not be silently accepted by head. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - _, stderr, code := headRun(t, "head --follow f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestZeroTerminatedFlagRejected(t *testing.T) { - // -z / --zero-terminated is a GNU extension we do not support. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - _, stderr, code := headRun(t, "head -z f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestCmdPentestMultipleDashes(t *testing.T) { - // Two '-' args: both read from the same stdin fd. - // The first '-' uses a bufio.Scanner which reads ahead in 4096-byte - // chunks. For a small file, the scanner consumes the entire stdin in one - // Read call, leaving nothing for the second '-'. - // This is Safer-than-GNU: our scanner-buffered implementation exhausts - // stdin after the first '-'. GNU head uses lower-level I/O that restores - // the fd position, but we are read-only and do not lseek. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "src.txt"), []byte("alpha\nbeta\ngamma\n"), 0644)) - stdout, _, code := headRun(t, "head -q -n 1 - - < src.txt", dir) - assert.Equal(t, 0, code) - // First '-' outputs "alpha\n"; second '-' sees empty stdin (buffered ahead). - assert.Equal(t, "alpha\n", stdout) -} - -func TestCmdPentestFlagViaExpansion(t *testing.T) { - // Flag injected via variable expansion: unknown flag → exit 1. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) - _, stderr, code := headRun(t, `flag="-f"; head $flag f.txt`, dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -// --- Behavior matching (GNU comparison notes) --- - -func TestCmdPentestGNUMatchPositivePrefix(t *testing.T) { - // GNU head -n +N treats + as a positive sign, outputting N lines. - // Matches GNU: ghead -n +2 = first 2 lines. - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("alpha\nbeta\ngamma\n"), 0644)) - stdout, _, code := headRun(t, "head -n +2 f.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\n", stdout) // Matches GNU -} - -func TestCmdPentestGNUMatchNegativeRejected(t *testing.T) { - // GNU head -n -N is elide-tail mode (all but last N lines). - // We intentionally do NOT support this (safer-than-GNU: we reject - // rather than implement a ring buffer). - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("alpha\nbeta\ngamma\n"), 0644)) - _, stderr, code := headRun(t, "head -n -1 f.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} diff --git a/interp/builtins/head/head.go b/interp/builtins/head/head.go deleted file mode 100644 index 103a57d6..00000000 --- a/interp/builtins/head/head.go +++ /dev/null @@ -1,317 +0,0 @@ -// 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 implements the head builtin command. -// -// 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 MaxLineBytes -// (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 head - -import ( - "bufio" - "context" - "errors" - "io" - "os" - "strconv" - - "github.com/DataDog/rshell/interp/builtins" -) - -// Cmd is the head builtin command descriptor. -var Cmd = builtins.Command{Name: "head", MakeFlags: registerFlags} - -// MaxCount 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 MaxCount = 1<<31 - 1 // 2 147 483 647 - -// MaxLineBytes is the per-line buffer cap for the line scanner. Lines -// longer than this are reported as an error instead of being buffered. -const MaxLineBytes = 1 << 20 // 1 MiB - -// registerFlags registers all head flags on the framework-provided FlagSet and -// returns a bound handler whose flag variables are captured by closure. The -// framework calls Parse and passes positional arguments to the handler. -func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { - help := fs.BoolP("help", "h", false, "print usage and exit") - quiet := fs.BoolP("quiet", "q", false, "never print file name headers") - _ = fs.Bool("silent", false, "alias for --quiet") - verbose := fs.BoolP("verbose", "v", false, "always print file name headers") - - // linesFlag and bytesFlag share a sequence counter so that after parsing - // we can compare their pos fields to determine which appeared last on the - // command line. pflag calls Set() in parse order, so the last flag Set - // gets the highest pos value — no raw arg scanning required. - var modeSeq int - linesFlag := newModeFlag(&modeSeq, "10") - bytesFlag := newModeFlag(&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") - - return func(ctx context.Context, callCtx *builtins.CallContext, files []string) builtins.Result { - 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 builtins.Result{} - } - - // --silent is an alias for --quiet. - if fs.Changed("silent") { - *quiet = true - } - - // Bytes mode wins if -c/--bytes was parsed after -n/--lines. When neither - // is set both pos fields are 0 (false → line mode). When only one is set - // the other stays 0, so the comparison selects correctly. - useBytesMode := bytesFlag.pos > linesFlag.pos - - // Parse the count for the chosen mode. - countStr := linesFlag.val - modeLabel := "lines" - if useBytesMode { - countStr = bytesFlag.val - modeLabel = "bytes" - } - - count, ok := parseCount(countStr) - if !ok { - callCtx.Errf("head: invalid number of %s: %q\n", modeLabel, countStr) - return builtins.Result{Code: 1} - } - - // Default to stdin when no file arguments were given. - 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 := processFile(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 builtins.Result{Code: 1} - } - return builtins.Result{} - } -} - -// processFile opens and processes one file (or stdin for "-"). -func processFile(ctx context.Context, callCtx *builtins.CallContext, file string, idx int, printHeaders, useBytesMode bool, count int64) error { - var rc io.ReadCloser - name := file - if file == "-" { - name = "standard input" - // Print the header before the nil-stdin guard so that -v always - // emits a header for stdin even when no input stream is present. - if printHeaders { - if idx > 0 { - callCtx.Out("\n") - } - callCtx.Outf("==> %s <==\n", name) - } - if callCtx.Stdin == nil { - return nil - } - rc = io.NopCloser(callCtx.Stdin) - } else { - f, err := callCtx.OpenFile(ctx, file, os.O_RDONLY, 0) - if err != nil { - return err - } - defer f.Close() - rc = f - // Header is printed after a successful open so that a file that - // cannot be opened produces no header (matches GNU head behaviour). - if printHeaders { - if idx > 0 { - callCtx.Out("\n") - } - callCtx.Outf("==> %s <==\n", name) - } - } - - if useBytesMode { - return readBytes(ctx, callCtx, rc, count) - } - return readLines(ctx, callCtx, rc, count) -} - -// readLines writes the first count lines of r to callCtx.Stdout, preserving -// line endings exactly (including a missing final newline). -func readLines(ctx context.Context, callCtx *builtins.CallContext, r io.Reader, count int64) error { - sc := bufio.NewScanner(r) - buf := make([]byte, 4096) - sc.Buffer(buf, MaxLineBytes) - 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() -} - -// readBytes writes the first count bytes of r to callCtx.Stdout. It reads -// in fixed-size chunks; the buffer is capped at chunkSize but shrunk to -// count when count is smaller, avoiding unnecessary allocation for small -// byte requests (e.g. head -c 5). -func readBytes(ctx context.Context, callCtx *builtins.CallContext, r io.Reader, count int64) error { - if count == 0 { - return nil - } - const chunkSize = 32 * 1024 - buf := make([]byte, min(int64(chunkSize), count)) - remaining := count - for remaining > 0 { - if ctx.Err() != nil { - return ctx.Err() - } - toRead := min(int64(chunkSize), remaining) - n, err := r.Read(buf[:toRead]) - if n > 0 { - remaining -= int64(n) - if _, werr := callCtx.Stdout.Write(buf[:n]); werr != nil { - return werr - } - } - if errors.Is(err, io.EOF) { - return nil - } - if err != nil { - return err - } - } - return nil -} - -// parseCount 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 parseCount(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 > MaxCount { - n = MaxCount - } - return n, true -} - -// modeFlag is a pflag.Value implementation for -n/--lines and -c/--bytes. -// Two modeFlag 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 modeFlag struct { - val string - seq *int // shared per-invocation counter; incremented on every Set call - pos int // counter value when Set was last called; 0 means never set -} - -func newModeFlag(seq *int, defaultVal string) *modeFlag { - return &modeFlag{val: defaultVal, seq: seq} -} - -func (f *modeFlag) String() string { return f.val } -func (f *modeFlag) Set(s string) error { - f.val = s - *f.seq++ - f.pos = *f.seq - return nil -} -func (f *modeFlag) Type() string { return "string" } - -// scanLinesPreservingNewline is a bufio.SplitFunc that includes the line -// terminator (\n) in the returned token. Unlike bufio.ScanLines, it does not -// strip \r\n or \n, so the caller reproduces the exact file content. If the -// file's last line has no terminator, the bare bytes are returned as the -// final token. -func scanLinesPreservingNewline(data []byte, atEOF bool) (advance int, token []byte, err error) { - if atEOF && len(data) == 0 { - return 0, nil, nil - } - for i, b := range data { - if b == '\n' { - return i + 1, data[:i+1], nil - } - } - if atEOF { - // Last line has no trailing newline; return what we have. - return len(data), data, nil - } - // Request more data. - return 0, nil, nil -} diff --git a/interp/builtins/head/head_gnu_compat_test.go b/interp/builtins/head/head_gnu_compat_test.go deleted file mode 100644 index e12d5b88..00000000 --- a/interp/builtins/head/head_gnu_compat_test.go +++ /dev/null @@ -1,226 +0,0 @@ -// 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 head_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestGNUCompatDefaultOutput — default output on a 12-line file. -// -// GNU command: ghead twelve.txt -// Expected: first 10 lines -func TestGNUCompatDefaultOutput(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "twelve.txt", twelveLines) - stdout, _, code := cmdRun(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 = fiveLines) -// Expected: "alpha\nbeta\ngamma\n" -func TestGNUCompatLinesN(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - stdout, _, code := cmdRun(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: fiveLines -func TestGNUCompatLinesLargerThanFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -n 100 five.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, fiveLines, 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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "nonewline.txt", "no newline at end") - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "empty.txt", "") - stdout, _, code := cmdRun(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 := 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) -} - -// 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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - writeFile(t, dir, "nonewline.txt", "no newline at end") - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - writeFile(t, dir, "nonewline.txt", "no newline at end") - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - writeFile(t, dir, "nonewline.txt", "no newline at end") - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - stdout, _, code := cmdRun(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 := t.TempDir() - writeFile(t, dir, "five.txt", fiveLines) - _, stderr, code := cmdRun(t, "head --no-such-flag five.txt", dir) - assert.Equal(t, 1, code) - assert.NotEmpty(t, stderr) -} diff --git a/interp/builtins/head/head_test.go b/interp/builtins/head/head_test.go deleted file mode 100644 index c7630de8..00000000 --- a/interp/builtins/head/head_test.go +++ /dev/null @@ -1,707 +0,0 @@ -// 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 ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/DataDog/rshell/interp" - "github.com/DataDog/rshell/interp/builtins/testutil" -) - -// 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() - return testutil.RunScriptCtx(ctx, t, script, dir, opts...) -} - -// 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 testutil.RunScript(t, script, dir, opts...) -} - -// cmdRun runs a head command with AllowedPaths set to dir. -func cmdRun(t *testing.T, script, dir string) (string, string, int) { - t.Helper() - return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) -} - -// writeFile creates a file in dir with the given content and returns its name. -func writeFile(t *testing.T, dir, name, content string) string { - t.Helper() - require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) - return name -} - -// fiveLines is a 5-line file used across multiple tests. -const fiveLines = "alpha\nbeta\ngamma\ndelta\nepsilon\n" - -// twelveLines is a 12-line file used to test the default 10-line limit. -const twelveLines = "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" - -// --- Default behavior --- - -func TestHeadDefaultTenLines(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", twelveLines) - stdout, _, code := cmdRun(t, "head file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\n", stdout) -} - -func TestHeadFileShorterThanDefault(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, fiveLines, stdout) -} - -func TestHeadEmptyFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "empty.txt", "") - stdout, _, code := cmdRun(t, "head empty.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "", stdout) -} - -// --- -n / --lines flag --- - -func TestHeadLinesN3(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -n 3 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) -} - -func TestHeadLinesN0(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -n 0 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "", stdout) -} - -func TestHeadLinesLargerThanFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -n 100 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, fiveLines, stdout) -} - -func TestHeadLinesLongForm(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head --lines=3 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) -} - -func TestHeadLinesPositivePrefix(t *testing.T) { - // GNU head: "+N" is treated as plain N (positive sign). - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -n +2 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\n", stdout) -} - -func TestHeadLinesGlued(t *testing.T) { - // -n3 (value glued to flag) is supported by pflag. - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -n3 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) -} - -// --- No trailing newline preservation --- - -func TestHeadNoTrailingNewline(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "no newline at end") - stdout, _, code := cmdRun(t, "head -n 2 file.txt", dir) - assert.Equal(t, 0, code) - // Single line without newline — output exactly as-is. - assert.Equal(t, "no newline at end", stdout) -} - -func TestHeadLastLineNoNewline(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "line1\nline2") - stdout, _, code := cmdRun(t, "head -n 2 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "line1\nline2", stdout) -} - -func TestHeadFirstLineNewlineSecondNot(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "line1\nline2") - stdout, _, code := cmdRun(t, "head -n 1 file.txt", dir) - assert.Equal(t, 0, code) - // Only the first line (with its newline) is printed. - assert.Equal(t, "line1\n", stdout) -} - -// --- -c / --bytes flag --- - -func TestHeadBytesN5(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -c 5 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha", stdout) -} - -func TestHeadBytesN0(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -c 0 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "", stdout) -} - -func TestHeadBytesLargerThanFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -c 9999 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, fiveLines, stdout) -} - -func TestHeadBytesLongForm(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head --bytes=5 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha", stdout) -} - -func TestHeadBytesPositivePrefix(t *testing.T) { - // GNU head: "+N" is treated as plain N for -c too. - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -c +3 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alp", stdout) -} - -func TestHeadBytesBinaryContent(t *testing.T) { - dir := t.TempDir() - // Write binary content including null bytes. - content := "a\x00b\x00c\x00d" - writeFile(t, dir, "file.bin", content) - stdout, _, code := cmdRun(t, "head -c 5 file.bin", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "a\x00b\x00c", stdout) -} - -// --- Last flag wins (-n vs -c) --- - -func TestHeadLastFlagWinsBytes(t *testing.T) { - // -n 2 -c 5: last flag is -c, so byte mode with 5 bytes. - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -n 2 -c 5 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha", stdout) -} - -func TestHeadLastFlagWinsLines(t *testing.T) { - // -c 5 -n 2: last flag is -n, so line mode with 2 lines. - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -c 5 -n 2 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\n", stdout) -} - -// --- Headers (-v / -q / --silent) --- - -func TestHeadVerboseSingleFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "one.txt", "only one line\n") - stdout, _, code := cmdRun(t, "head -v one.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "==> one.txt <==\nonly one line\n", stdout) -} - -func TestHeadTwoFilesDefaultHeaders(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "a.txt", "alpha\nbeta\n") - writeFile(t, dir, "b.txt", "gamma\n") - stdout, _, code := cmdRun(t, "head -n 2 a.txt b.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "==> a.txt <==\nalpha\nbeta\n\n==> b.txt <==\ngamma\n", stdout) -} - -func TestHeadTwoFilesSecondNoNewline(t *testing.T) { - // Verifies that the separator \n before the second header is always printed, - // regardless of whether the first file ended with a newline. - dir := t.TempDir() - writeFile(t, dir, "a.txt", "alpha\nbeta\n") - writeFile(t, dir, "b.txt", "no newline") - stdout, _, code := cmdRun(t, "head -n 2 a.txt b.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "==> a.txt <==\nalpha\nbeta\n\n==> b.txt <==\nno newline", stdout) -} - -func TestHeadFirstFileNoNewline(t *testing.T) { - // When first file ends without \n, the header separator still adds \n. - dir := t.TempDir() - writeFile(t, dir, "a.txt", "no newline") - writeFile(t, dir, "b.txt", "next\n") - stdout, _, code := cmdRun(t, "head -n 1 a.txt b.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "==> a.txt <==\nno newline\n==> b.txt <==\nnext\n", stdout) -} - -func TestHeadQuietTwoFiles(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "a.txt", "alpha\nbeta\n") - writeFile(t, dir, "b.txt", "gamma\n") - stdout, _, code := cmdRun(t, "head -q -n 2 a.txt b.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) -} - -func TestHeadSilentAlias(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "a.txt", "alpha\nbeta\n") - writeFile(t, dir, "b.txt", "gamma\n") - stdout, _, code := cmdRun(t, "head --silent -n 2 a.txt b.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) -} - -func TestHeadVerboseTwoFiles(t *testing.T) { - // -v on multiple files still works (headers always printed). - dir := t.TempDir() - writeFile(t, dir, "a.txt", "alpha\n") - writeFile(t, dir, "b.txt", "beta\n") - stdout, _, code := cmdRun(t, "head -v -n 1 a.txt b.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "==> a.txt <==\nalpha\n\n==> b.txt <==\nbeta\n", stdout) -} - -// --- Stdin --- - -func TestHeadStdinImplicit(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "src.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -n 2 < src.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\n", stdout) -} - -func TestHeadStdinDash(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "src.txt", fiveLines) - stdout, _, code := cmdRun(t, "head -n 2 - < src.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha\nbeta\n", stdout) -} - -func TestHeadStdinVerbose(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "src.txt", "hello\n") - stdout, _, code := cmdRun(t, "head -v - < src.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "==> standard input <==\nhello\n", stdout) -} - -// --- Help --- - -func TestHeadHelp(t *testing.T) { - dir := t.TempDir() - stdout, stderr, code := cmdRun(t, "head --help", dir) - assert.Equal(t, 0, code) - assert.Contains(t, stdout, "Usage:") - assert.Contains(t, stdout, "--lines") - assert.Contains(t, stdout, "--bytes") - assert.Empty(t, stderr) -} - -func TestHeadHelpShort(t *testing.T) { - dir := t.TempDir() - stdout, stderr, code := cmdRun(t, "head -h", dir) - assert.Equal(t, 0, code) - assert.Contains(t, stdout, "Usage:") - assert.Empty(t, stderr) -} - -// --- Error cases --- - -func TestHeadMissingFile(t *testing.T) { - dir := t.TempDir() - _, stderr, code := cmdRun(t, "head nonexistent.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadDirectory(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) - _, stderr, code := cmdRun(t, "head subdir", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadUnknownFlag(t *testing.T) { - dir := t.TempDir() - _, stderr, code := cmdRun(t, "head --follow file.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadUnknownShortFlag(t *testing.T) { - dir := t.TempDir() - _, stderr, code := cmdRun(t, "head -f file.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadInvalidCountString(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - _, stderr, code := cmdRun(t, "head -n abc file.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadNegativeCount(t *testing.T) { - // GNU head -n -N means "all but last N lines" — we do NOT support that. - // We reject negative counts. - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - _, stderr, code := cmdRun(t, "head -n -1 file.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadNegativeBytesCount(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - _, stderr, code := cmdRun(t, "head -c -1 file.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadOutsideAllowedPaths(t *testing.T) { - allowed := t.TempDir() - secret := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret"), 0644)) - - secretPath := strings.ReplaceAll(filepath.Join(secret, "secret.txt"), `\`, `/`) - _, stderr, code := runScript(t, "head "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadMultipleFilesSomeFailSomeSuc(t *testing.T) { - // When some files fail and some succeed, exit code is 1 and successful - // files still produce output. - dir := t.TempDir() - writeFile(t, dir, "good.txt", "hello\n") - stdout, stderr, code := cmdRun(t, "head -n 1 good.txt nonexistent.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stdout, "hello") - assert.Contains(t, stderr, "head:") -} - -// --- RULES.md compliance --- - -func TestHeadLargeCountClamped(t *testing.T) { - // A count larger than maxHeadCount (1<<31-1) must be clamped, not cause OOM. - // We pass a very large count on a tiny file; it should output the file content - // without crashing or hanging. - dir := t.TempDir() - writeFile(t, dir, "small.txt", "tiny\n") - stdout, _, code := cmdRun(t, "head -n 9999999999 small.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "tiny\n", stdout) -} - -func TestHeadLargeByteCountClamped(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "small.txt", "tiny") - stdout, _, code := cmdRun(t, "head -c 9999999999 small.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "tiny", stdout) -} - -func TestHeadContextCancellation(t *testing.T) { - // The command must stop when the context is cancelled. - dir := t.TempDir() - // Use a pipe: create a heredoc that provides input. - writeFile(t, dir, "data.txt", fiveLines) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Should complete well within 5 seconds. - _, _, code := runScriptCtx(ctx, t, "head -n 3 data.txt", dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 0, code) -} - -func TestHeadDoubleDash(t *testing.T) { - // After --, all args are treated as file names, even if they look like flags. - dir := t.TempDir() - writeFile(t, dir, "-n", "flag-looking-name\n") - stdout, _, code := cmdRun(t, "head -- -n", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "flag-looking-name\n", stdout) -} - -func TestHeadNullBytesInContent(t *testing.T) { - // Binary content with null bytes must not crash or hang. - dir := t.TempDir() - content := "a\x00b\x00c\x00\n" - writeFile(t, dir, "binary.bin", content) - stdout, _, code := cmdRun(t, "head -n 1 binary.bin", dir) - assert.Equal(t, 0, code) - assert.Equal(t, content, stdout) -} - -func TestHeadCRLFPreserved(t *testing.T) { - // CRLF line endings must be preserved exactly in the output. - dir := t.TempDir() - writeFile(t, dir, "crlf.txt", "line1\r\nline2\r\nline3\r\n") - stdout, _, code := cmdRun(t, "head -n 2 crlf.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "line1\r\nline2\r\n", stdout) -} - -func TestHeadPipeInput(t *testing.T) { - // Verify head works correctly in a pipeline. - dir := t.TempDir() - writeFile(t, dir, "file.txt", twelveLines) - // cat file.txt | head -n 3 - stdout, _, code := cmdRun(t, "cat file.txt | head -n 3", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "line01\nline02\nline03\n", stdout) -} - -func TestHeadLineModeOnLineExactlyAtCap(t *testing.T) { - // A line of exactly maxHeadLineBytes (1 MiB) with no newline. - // bufio.Scanner.Buffer(buf, max) cannot hold a token of exactly max - // bytes (the limit is exclusive), so this must error like an over-cap line. - dir := t.TempDir() - content := make([]byte, 1<<20) - for i := range content { - content[i] = 'a' - } - require.NoError(t, os.WriteFile(filepath.Join(dir, "exact.txt"), content, 0644)) - _, _, code := cmdRun(t, "head -n 1 exact.txt", dir) - assert.Equal(t, 1, code) -} - -func TestHeadLineModeOnSingleLineBeyondCap(t *testing.T) { - // A line of maxHeadLineBytes+1 (1 MiB + 1 byte) with no newline. - // Exceeds the scanner buffer cap and must error, not crash. - dir := t.TempDir() - oneMiBPlusOne := make([]byte, 1<<20+1) - for i := range oneMiBPlusOne { - oneMiBPlusOne[i] = 'a' - } - require.NoError(t, os.WriteFile(filepath.Join(dir, "huge.txt"), oneMiBPlusOne, 0644)) - _, stderr, code := cmdRun(t, "head -n 1 huge.txt", dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadLineModeOnLineBelowCap(t *testing.T) { - // A line just below the 1MiB cap should succeed. - dir := t.TempDir() - // Write (1MiB - 1) bytes of 'b' followed by a newline. - content := make([]byte, 1<<20-1) - for i := range content { - content[i] = 'b' - } - content = append(content, '\n') - require.NoError(t, os.WriteFile(filepath.Join(dir, "large.txt"), content, 0644)) - stdout, _, code := cmdRun(t, "head -n 1 large.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, string(content), stdout) -} - -func TestHeadEmptyCountString(t *testing.T) { - dir := t.TempDir() - // pflag with StringP default "10" means "-n" alone with no value is an error. - // If somehow an empty string is passed, it should be rejected. - writeFile(t, dir, "file.txt", fiveLines) - _, stderr, code := cmdRun(t, `head -n "" file.txt`, dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "head:") -} - -func TestHeadNilStdin(t *testing.T) { - // When head is asked to read stdin ("-") but the shell has no stdin, - // it should produce no output and exit 0 (callCtx.Stdin == nil path). - dir := t.TempDir() - // runScript with no stdin redirect — shell stdin stays nil. - stdout, stderr, code := runScript(t, "head -", dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 0, code) - assert.Equal(t, "", stdout) - assert.Equal(t, "", stderr) -} - -func TestHeadNilStdinVerbose(t *testing.T) { - // -v must print the header for stdin even when callCtx.Stdin == nil. - // Previously the nil guard fired before the header block, silently - // skipping the "==> standard input <==" line. - dir := t.TempDir() - stdout, stderr, code := runScript(t, "head -v -", dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 0, code) - assert.Equal(t, "==> standard input <==\n", stdout) - assert.Equal(t, "", stderr) -} - -func TestHeadBytesAppearsLastWithDoubleDash(t *testing.T) { - // pflag stops parsing at "--", so file names after "--" are never - // mistaken for flags. With -n and -c both set before "--", the - // last-flag-wins logic applies (bytes mode because -c appears last). - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - // -n 3 -c 5 -- file.txt: both set, -c appears last before --, so byte mode. - stdout, _, code := cmdRun(t, "head -n 3 -c 5 -- file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "alpha", stdout) // first 5 bytes -} - -func TestHeadContextPreCancelled(t *testing.T) { - // A pre-cancelled context should cause the command to abort immediately. - dir := t.TempDir() - writeFile(t, dir, "file.txt", fiveLines) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // cancel before running - - // We don't assert a specific exit code (context cancellation may or may - // not surface as exit code 1 depending on timing), but we must not hang. - done := make(chan struct{}) - go func() { - runScriptCtx(ctx, t, "head -n 5 file.txt", dir, interp.AllowedPaths([]string{dir})) - close(done) - }() - select { - case <-done: - // completed without hanging - case <-time.After(5 * time.Second): - t.Fatal("head with pre-cancelled context did not return within 5s") - } -} - -func TestHeadNoOctalInterpretation08(t *testing.T) { - // "08" must be interpreted as decimal 8, not rejected as an invalid octal. - dir := t.TempDir() - writeFile(t, dir, "file.txt", twelveLines) - stdout, _, code := cmdRun(t, "head -n 08 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, 8, strings.Count(stdout, "\n")) -} - -func TestHeadNoOctalInterpretation010(t *testing.T) { - // "010" must be interpreted as decimal 10, not octal 8. - dir := t.TempDir() - writeFile(t, dir, "file.txt", twelveLines) - stdout, _, code := cmdRun(t, "head -n 010 file.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, 10, strings.Count(stdout, "\n")) -} - -// --- Bad UTF-8 / binary passthrough --- - -// TestHeadBadUTF8ByteMode verifies that invalid UTF-8 bytes are passed through -// unchanged in byte mode. -// -// Derived from uutils test_head.rs::test_bad_utf8 -func TestHeadBadUTF8ByteMode(t *testing.T) { - dir := t.TempDir() - content := []byte{0xfc, 0x80, 0x80, 0x80, 0x80, 0xaf} - require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.bin"), content, 0644)) - stdout, _, code := cmdRun(t, "head -c 6 bad.bin", dir) - assert.Equal(t, 0, code) - assert.Equal(t, string(content), stdout) -} - -// TestHeadBadUTF8LineMode verifies that invalid UTF-8 bytes within lines are -// passed through unchanged in line mode. -// -// Derived from uutils test_head.rs::test_bad_utf8_lines -func TestHeadBadUTF8LineMode(t *testing.T) { - dir := t.TempDir() - // Three lines, each containing invalid UTF-8; request first 2 lines. - // input: \xfc\x80\x80\x80\x80\xaf\n b\xfc...\xaf\n b\xfc...\xaf (no final newline) - // expected: first 2 lines only, bytes preserved verbatim. - badSeq := []byte{0xfc, 0x80, 0x80, 0x80, 0x80, 0xaf} - line1 := append(append([]byte(nil), badSeq...), '\n') - line2 := append(append([]byte("b"), badSeq...), '\n') - line3 := append([]byte("b"), badSeq...) - input := append(append(append([]byte(nil), line1...), line2...), line3...) - require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.bin"), input, 0644)) - - expected := append(append([]byte(nil), line1...), line2...) - stdout, _, code := cmdRun(t, "head -n 2 bad.bin", dir) - assert.Equal(t, 0, code) - assert.Equal(t, string(expected), stdout) -} - -// --- Multi-file edge cases --- - -// TestHeadTwoEmptyFilesHeaders verifies that headers and the blank-line -// separator are still emitted when both files are empty. -// -// Derived from uutils test_head.rs::test_multiple_files -func TestHeadTwoEmptyFilesHeaders(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "a.txt", "") - writeFile(t, dir, "b.txt", "") - stdout, _, code := cmdRun(t, "head a.txt b.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "==> a.txt <==\n\n==> b.txt <==\n", stdout) -} - -// TestHeadMultipleFilesWithStdin verifies that '-' interleaved among file -// arguments reads stdin and prints a "standard input" header alongside the -// file headers. -// -// Derived from uutils test_head.rs::test_multiple_files_with_stdin -func TestHeadMultipleFilesWithStdin(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "empty.txt", "") - writeFile(t, dir, "stdin_src.txt", "hello\n") - stdout, _, code := cmdRun(t, "head empty.txt - empty.txt < stdin_src.txt", dir) - assert.Equal(t, 0, code) - assert.Equal(t, "==> empty.txt <==\n\n==> standard input <==\nhello\n\n==> empty.txt <==\n", stdout) -} - -// TestHeadAllNonexistentFiles verifies that each nonexistent file gets its own -// error message and no headers are printed for failed opens. -// -// Derived from uutils test_head.rs::test_multiple_nonexistent_files -func TestHeadAllNonexistentFiles(t *testing.T) { - dir := t.TempDir() - stdout, stderr, code := cmdRun(t, "head missing1.txt missing2.txt", dir) - assert.Equal(t, 1, code) - assert.Empty(t, stdout) - assert.Contains(t, stderr, "missing1.txt") - assert.Contains(t, stderr, "missing2.txt") - assert.NotContains(t, stdout, "==> missing1.txt <==") - assert.NotContains(t, stdout, "==> missing2.txt <==") -} diff --git a/interp/builtins/head/head_unix_test.go b/interp/builtins/head/head_unix_test.go deleted file mode 100644 index 824ab1a8..00000000 --- a/interp/builtins/head/head_unix_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// 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/head/head_windows_test.go b/interp/builtins/head/head_windows_test.go deleted file mode 100644 index 03f82b6b..00000000 --- a/interp/builtins/head/head_windows_test.go +++ /dev/null @@ -1,29 +0,0 @@ -// 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/interp/register_builtins.go b/interp/register_builtins.go index 8d7f50d5..b26dfb53 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -17,7 +17,6 @@ import ( "github.com/DataDog/rshell/interp/builtins/exit" falsecmd "github.com/DataDog/rshell/interp/builtins/false" "github.com/DataDog/rshell/interp/builtins/grep" - "github.com/DataDog/rshell/interp/builtins/head" "github.com/DataDog/rshell/interp/builtins/ls" "github.com/DataDog/rshell/interp/builtins/strings_cmd" "github.com/DataDog/rshell/interp/builtins/tail" @@ -40,7 +39,6 @@ func registerBuiltins() { exit.Cmd, falsecmd.Cmd, grep.Cmd, - head.Cmd, ls.Cmd, strings_cmd.Cmd, tail.Cmd, diff --git a/tests/scenarios/cmd/head/bytes/basic.yaml b/tests/scenarios/cmd/head/bytes/basic.yaml deleted file mode 100644 index 68d83b43..00000000 --- a/tests/scenarios/cmd/head/bytes/basic.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 5ba9842c..00000000 --- a/tests/scenarios/cmd/head/bytes/larger_than_file.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 2a50d09d..00000000 --- a/tests/scenarios/cmd/head/bytes/last_flag_wins_bytes.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index 50a00ed3..00000000 --- a/tests/scenarios/cmd/head/bytes/last_flag_wins_lines.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index 6208eb9c..00000000 --- a/tests/scenarios/cmd/head/bytes/long_form.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 28e801c1..00000000 --- a/tests/scenarios/cmd/head/bytes/zero.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Derived from GNU coreutils head.pl test -description: head -c 0 produces no output. -setup: - files: - - path: file.txt - content: "alpha\nbeta\n" -input: - allowed_paths: ["$DIR"] - script: |+ - head -c 0 file.txt -expect: - stdout: "" - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/head/errors/all_missing.yaml b/tests/scenarios/cmd/head/errors/all_missing.yaml deleted file mode 100644 index a1364435..00000000 --- a/tests/scenarios/cmd/head/errors/all_missing.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Derived from uutils test_head.rs::test_multiple_nonexistent_files -description: All nonexistent files each produce an error message; no headers are printed. -input: - allowed_paths: ["$DIR"] - script: |+ - head missing1.txt missing2.txt -expect: - stdout: "" - stderr_contains: - - "missing1.txt" - - "missing2.txt" - exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/directory.yaml b/tests/scenarios/cmd/head/errors/directory.yaml deleted file mode 100644 index 86bf5170..00000000 --- a/tests/scenarios/cmd/head/errors/directory.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index b38e3115..00000000 --- a/tests/scenarios/cmd/head/errors/invalid_n_flag.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index ad7fc72d..00000000 --- a/tests/scenarios/cmd/head/errors/missing_file.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# 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 deleted file mode 100644 index 89e39f08..00000000 --- a/tests/scenarios/cmd/head/errors/multiple_some_fail.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 3ad71682..00000000 --- a/tests/scenarios/cmd/head/errors/negative_count.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index 27469df7..00000000 --- a/tests/scenarios/cmd/head/errors/unknown_flag.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index d0a997a5..00000000 --- a/tests/scenarios/cmd/head/hardening/double_dash_separator.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index 00d37f7a..00000000 --- a/tests/scenarios/cmd/head/hardening/large_count_clamped.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index bd0df4c5..00000000 --- a/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Per RULES.md: file access must be sandboxed via AllowedPaths -description: head is blocked from reading files outside the allowed paths sandbox. -skip_assert_against_bash: true # intentional sandbox restriction; bash/head can read /etc/passwd freely -setup: - files: - - path: local.txt - content: "local\n" -input: - allowed_paths: ["$DIR"] - script: |+ - head /etc/passwd -expect: - stdout: "" - stderr_contains: ["head:"] - exit_code: 1 diff --git a/tests/scenarios/cmd/head/headers/quiet_two_files.yaml b/tests/scenarios/cmd/head/headers/quiet_two_files.yaml deleted file mode 100644 index 9436682a..00000000 --- a/tests/scenarios/cmd/head/headers/quiet_two_files.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index a43efd70..00000000 --- a/tests/scenarios/cmd/head/headers/silent_alias.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Derived from GNU coreutils head behavior with --silent -description: head --silent is an alias for --quiet and suppresses headers. -setup: - files: - - path: a.txt - content: "alpha\nbeta\n" - - path: b.txt - content: "gamma\n" -input: - allowed_paths: ["$DIR"] - script: |+ - head --silent -n 2 a.txt b.txt -expect: - stdout: "alpha\nbeta\ngamma\n" - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/two_empty_files.yaml b/tests/scenarios/cmd/head/headers/two_empty_files.yaml deleted file mode 100644 index 975e328a..00000000 --- a/tests/scenarios/cmd/head/headers/two_empty_files.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Derived from uutils test_head.rs::test_multiple_files -description: Two empty files still get headers and a blank-line separator between them. -setup: - files: - - path: a.txt - content: "" - - path: b.txt - content: "" -input: - allowed_paths: ["$DIR"] - script: |+ - head a.txt b.txt -expect: - stdout: "==> a.txt <==\n\n==> b.txt <==\n" - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/two_files_default.yaml b/tests/scenarios/cmd/head/headers/two_files_default.yaml deleted file mode 100644 index 66e4b518..00000000 --- a/tests/scenarios/cmd/head/headers/two_files_default.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index 7659bc50..00000000 --- a/tests/scenarios/cmd/head/headers/verbose_single_file.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 65e3c3a4..00000000 --- a/tests/scenarios/cmd/head/lines/default_ten_lines.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 6466724c..00000000 --- a/tests/scenarios/cmd/head/lines/empty_file.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 8a45b525..00000000 --- a/tests/scenarios/cmd/head/lines/fewer_than_default.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 2c397bf9..00000000 --- a/tests/scenarios/cmd/head/lines/long_form.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index a1f2e28d..00000000 --- a/tests/scenarios/cmd/head/lines/n_flag.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index f3802728..00000000 --- a/tests/scenarios/cmd/head/lines/n_larger_than_file.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 87f7a0ca..00000000 --- a/tests/scenarios/cmd/head/lines/n_zero.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 3425c5f5..00000000 --- a/tests/scenarios/cmd/head/lines/no_octal_interpretation.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index d77afc90..00000000 --- a/tests/scenarios/cmd/head/lines/no_trailing_newline.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index 386fcc28..00000000 --- a/tests/scenarios/cmd/head/lines/null_bytes.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index ce86a742..00000000 --- a/tests/scenarios/cmd/head/stdin/dash_explicit.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 0272fc82..00000000 --- a/tests/scenarios/cmd/head/stdin/implicit.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Derived from standard POSIX head behavior -description: head with no file arguments reads from standard input. -setup: - files: - - path: src.txt - content: "alpha\nbeta\ngamma\ndelta\nepsilon\n" -input: - allowed_paths: ["$DIR"] - script: |+ - head -n 2 < src.txt -expect: - stdout: "alpha\nbeta\n" - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml b/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml deleted file mode 100644 index 31742250..00000000 --- a/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Derived from uutils test_head.rs::test_multiple_files_with_stdin -description: Stdin interleaved with file args shows a standard input header alongside file headers. -setup: - files: - - path: empty.txt - content: "" - - path: stdin_src.txt - content: "hello\n" -input: - allowed_paths: ["$DIR"] - script: |+ - head empty.txt - empty.txt < stdin_src.txt -expect: - stdout: "==> empty.txt <==\n\n==> standard input <==\nhello\n\n==> empty.txt <==\n" - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/pipe.yaml b/tests/scenarios/cmd/head/stdin/pipe.yaml deleted file mode 100644 index 213cbd6d..00000000 --- a/tests/scenarios/cmd/head/stdin/pipe.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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 001f4f10194c0e3a2b0b28dd7c6105c1bf8e3265 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Wed, 11 Mar 2026 16:12:11 -0400 Subject: [PATCH 2/6] Implement head builtin command Adds `head` as a safe builtin with support for: - `-n N` / `--lines=N`: output first N lines (default 10) - `-c N` / `--bytes=N`: output first N bytes - `-q` / `--quiet` / `--silent`: suppress file headers - `-v` / `--verbose`: always print file headers - Multiple files with `==> filename <==` headers - stdin via `-` or implicit when no files given - Memory-safe chunked I/O; large N values clamped to prevent allocations - 35 YAML scenario tests covering lines, bytes, headers, errors, stdin, and hardening Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- SHELL_FEATURES.md | 1 + .../head/builtin_head_pentest_test.go | 528 +++++++++++++ interp/builtins/head/head.go | 317 ++++++++ interp/builtins/head/head_gnu_compat_test.go | 226 ++++++ interp/builtins/head/head_test.go | 707 ++++++++++++++++++ interp/builtins/head/head_unix_test.go | 61 ++ interp/builtins/head/head_windows_test.go | 29 + interp/register_builtins.go | 2 + tests/scenarios/cmd/head/bytes/basic.yaml | 14 + .../cmd/head/bytes/larger_than_file.yaml | 14 + .../cmd/head/bytes/last_flag_wins_bytes.yaml | 14 + .../cmd/head/bytes/last_flag_wins_lines.yaml | 14 + tests/scenarios/cmd/head/bytes/long_form.yaml | 14 + tests/scenarios/cmd/head/bytes/zero.yaml | 14 + .../cmd/head/errors/all_missing.yaml | 12 + .../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 | 14 + .../head/hardening/double_dash_separator.yaml | 14 + .../head/hardening/large_count_clamped.yaml | 14 + .../head/hardening/outside_allowed_paths.yaml | 15 + .../cmd/head/headers/quiet_two_files.yaml | 16 + .../cmd/head/headers/silent_alias.yaml | 16 + .../cmd/head/headers/two_empty_files.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 + .../cmd/head/stdin/mixed_with_files.yaml | 16 + tests/scenarios/cmd/head/stdin/pipe.yaml | 14 + 44 files changed, 2369 insertions(+), 1 deletion(-) create mode 100644 interp/builtins/head/builtin_head_pentest_test.go create mode 100644 interp/builtins/head/head.go create mode 100644 interp/builtins/head/head_gnu_compat_test.go create mode 100644 interp/builtins/head/head_test.go create mode 100644 interp/builtins/head/head_unix_test.go create mode 100644 interp/builtins/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/all_missing.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_empty_files.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/mixed_with_files.yaml create mode 100644 tests/scenarios/cmd/head/stdin/pipe.yaml diff --git a/README.md b/README.md index 46c36e1c..b6690f57 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Linux, macOS, and Windows. ``` tests/scenarios/ -├── cmd/ # builtin command tests (echo, cat, grep, tail, uniq, wc, ...) +├── cmd/ # builtin command tests (echo, cat, grep, head, tail, uniq, wc, ...) └── shell/ # shell feature tests (pipes, variables, control flow, ...) ``` diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 3c171783..b8312cce 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -13,6 +13,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `exit [N]` — exit the shell with status N (default 0) - ✅ `false` — return exit code 1 - ✅ `grep [-EFGivclLnHhoqsxw] [-e PATTERN] [-m NUM] [-A NUM] [-B NUM] [-C NUM] PATTERN [FILE]...` — print lines that match patterns; uses RE2 regex engine (linear-time, no backtracking) +- ✅ `head [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the first part of files (default: first 10 lines) - ✅ `ls [-1aAdFhlpRrSt] [FILE]...` — list directory contents - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected diff --git a/interp/builtins/head/builtin_head_pentest_test.go b/interp/builtins/head/builtin_head_pentest_test.go new file mode 100644 index 00000000..1221c711 --- /dev/null +++ b/interp/builtins/head/builtin_head_pentest_test.go @@ -0,0 +1,528 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Exploratory pentest for the head builtin. +// +// These tests probe integer edge cases, special files, long lines, resource +// exhaustion, path edge cases, and flag injection scenarios. Tests that might +// hang are run in a goroutine with time.After to bound execution. + +package head_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +const pentestTimeout = 10 * time.Second + +// headRun is a shorthand for runScript with AllowedPaths=dir. +func headRun(t *testing.T, script, dir string, extraPaths ...string) (stdout, stderr string, exitCode int) { + t.Helper() + paths := append([]string{dir}, extraPaths...) + return runScript(t, script, dir, interp.AllowedPaths(paths)) +} + +// mustNotHang runs f in a goroutine and fails the test if it does not return +// within pentestTimeout. +func mustNotHang(t *testing.T, f func()) { + t.Helper() + done := make(chan struct{}) + go func() { + defer close(done) + f() + }() + select { + case <-done: + case <-time.After(pentestTimeout): + t.Fatalf("operation did not complete within %s", pentestTimeout) + } +} + +// --- Integer edge cases: -n --- + +func TestCmdPentestLinesNZero(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + stdout, _, code := headRun(t, "head -n 0 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestCmdPentestLinesNOne(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("line1\nline2\n"), 0644)) + stdout, _, code := headRun(t, "head -n 1 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line1\n", stdout) +} + +func TestCmdPentestLinesNMaxInt32(t *testing.T) { + // 2147483647: must be clamped / handled without OOM on a small file. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 2147483647 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny\n", stdout) + }) +} + +func TestCmdPentestLinesNMaxInt64(t *testing.T) { + // 9223372036854775807 = max int64; clamped to maxHeadCount. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 9223372036854775807 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny\n", stdout) + }) +} + +func TestCmdPentestLinesNMaxInt64PlusOne(t *testing.T) { + // 9223372036854775808 overflows int64 — ParseInt returns an error. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) + _, stderr, code := headRun(t, "head -n 9223372036854775808 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNHugeOverflow(t *testing.T) { + // A numeric string too large for int64. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny\n"), 0644)) + _, stderr, code := headRun(t, "head -n 99999999999999999999 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNNegativeOne(t *testing.T) { + // Negative counts must be rejected (we don't implement elide-tail mode). + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head -n -1 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNNegativeLarge(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head -n -9999999999 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNPlusZero(t *testing.T) { + // "+0" is parsed by ParseInt as 0. GNU head -n +0 = no output. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + stdout, _, code := headRun(t, "head -n +0 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestCmdPentestLinesNPlusOne(t *testing.T) { + // "+1" = 1 line (positive sign, matches GNU head behavior). + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("alpha\nbeta\n"), 0644)) + stdout, _, code := headRun(t, "head -n +1 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\n", stdout) +} + +func TestCmdPentestLinesNEmptyString(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, `head -n "" f.txt`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestLinesNWhitespace(t *testing.T) { + // A whitespace string — ParseInt rejects it. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, `head -n " " f.txt`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +// --- Integer edge cases: -c --- + +func TestCmdPentestBytesNZero(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello"), 0644)) + stdout, _, code := headRun(t, "head -c 0 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestCmdPentestBytesNOne(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello"), 0644)) + stdout, _, code := headRun(t, "head -c 1 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "h", stdout) +} + +func TestCmdPentestBytesNMaxInt32(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -c 2147483647 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny", stdout) + }) +} + +func TestCmdPentestBytesNHugeOverflow(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny"), 0644)) + _, stderr, code := headRun(t, "head -c 99999999999999999999 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestBytesNNegative(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello"), 0644)) + _, stderr, code := headRun(t, "head -c -1 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestBytesPlusMaxInt64(t *testing.T) { + // "+9223372036854775807" = max int64; clamped to maxHeadCount. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("tiny"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -c +9223372036854775807 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny", stdout) + }) +} + +// --- Special files / infinite sources (Unix only) --- + +func TestCmdPentestDevNull(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/null on Windows") + } + // /dev/null is empty; head should produce no output and exit 0. + dir := t.TempDir() + stdout, _, code := headRun(t, "head /dev/null", dir, "/dev") + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestCmdPentestDevZeroLineMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + // head -n 3 /dev/zero: /dev/zero produces infinite NUL bytes with no \n. + // The scanner's 1MiB line cap will trigger an error, so the command MUST + // terminate rather than hang. Matches GNU behavior (scanner errors out). + dir := t.TempDir() + mustNotHang(t, func() { + _, _, _ = headRun(t, "head -n 3 /dev/zero", dir, "/dev") + // We only assert non-hang; exit code may be 0 or 1. + }) +} + +func TestCmdPentestDevZeroByteMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + // head -c 10 /dev/zero: must read exactly 10 bytes and stop. + dir := t.TempDir() + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -c 10 /dev/zero", dir, "/dev") + assert.Equal(t, 0, code) + assert.Equal(t, string(bytes.Repeat([]byte{0}, 10)), stdout) + }) +} + +// --- Long lines --- + +func TestCmdPentestLongLineBelowCap(t *testing.T) { + // 1MiB - 1 bytes of 'a' followed by \n — should succeed. + dir := t.TempDir() + content := bytes.Repeat([]byte("a"), 1<<20-1) + content = append(content, '\n') + require.NoError(t, os.WriteFile(filepath.Join(dir, "long.txt"), content, 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 1 long.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(content), stdout) + }) +} + +func TestCmdPentestLongLineExactlyAtCap(t *testing.T) { + // Exactly 1 MiB of 'a' with no newline. bufio.Scanner.Buffer(buf, max) + // cannot hold a token of exactly max bytes (the limit is exclusive), so + // this must error just like a line that exceeds the cap. + dir := t.TempDir() + content := bytes.Repeat([]byte("a"), 1<<20) + require.NoError(t, os.WriteFile(filepath.Join(dir, "exact.txt"), content, 0644)) + mustNotHang(t, func() { + _, stderr, code := headRun(t, "head -n 1 exact.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") + }) +} + +func TestCmdPentestLongLineExactCapWithNewline(t *testing.T) { + // Exactly 1 MiB of 'a' followed by \n. The scanner sees the token as + // 1 MiB + 1 byte (content + newline). scanLinesPreservingNewline includes + // the \n in the token, so the token exceeds maxHeadLineBytes and the + // scanner must error. + dir := t.TempDir() + content := bytes.Repeat([]byte("a"), 1<<20) + content = append(content, '\n') + require.NoError(t, os.WriteFile(filepath.Join(dir, "exact_nl.txt"), content, 0644)) + mustNotHang(t, func() { + _, stderr, code := headRun(t, "head -n 1 exact_nl.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") + }) +} + +func TestCmdPentestLongLineExceedsCap(t *testing.T) { + // 1MiB + 1 bytes of 'a' (no newline) — scanner errors (line too long). + dir := t.TempDir() + content := bytes.Repeat([]byte("a"), 1<<20+1) + require.NoError(t, os.WriteFile(filepath.Join(dir, "huge.txt"), content, 0644)) + mustNotHang(t, func() { + _, stderr, code := headRun(t, "head -n 1 huge.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") + }) +} + +func TestCmdPentestTwoLinesNearCap(t *testing.T) { + // Two lines each just below the cap; head -n 2 should output both. + dir := t.TempDir() + line := bytes.Repeat([]byte("b"), 1<<20-2) + line = append(line, '\n') + content := append(append([]byte{}, line...), line...) + require.NoError(t, os.WriteFile(filepath.Join(dir, "two.txt"), content, 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 2 two.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(content), stdout) + }) +} + +// --- Memory / resource exhaustion --- + +func TestCmdPentestMaxInt32CountSmallFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "small.txt"), []byte("small\n"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -n 2147483647 small.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "small\n", stdout) + }) +} + +func TestCmdPentestMaxInt32ByteCountSmallFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "small.txt"), []byte("tiny"), 0644)) + mustNotHang(t, func() { + stdout, _, code := headRun(t, "head -c 2147483647 small.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny", stdout) + }) +} + +func TestCmdPentestManyFileArguments(t *testing.T) { + // 210 file arguments: verify no file-descriptor leak or crash. + dir := t.TempDir() + var sb strings.Builder + sb.WriteString("head -q -n 1") + for i := 0; i < 210; i++ { + name := fmt.Sprintf("f%03d.txt", i) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(fmt.Sprintf("file%d\n", i)), 0644)) + sb.WriteString(" ") + sb.WriteString(name) + } + mustNotHang(t, func() { + stdout, _, code := headRun(t, sb.String(), dir) + assert.Equal(t, 0, code) + // 210 files × one "file%d\n" line = 210 lines. + assert.Equal(t, 210, strings.Count(stdout, "\n")) + }) +} + +func TestCmdPentestMillionLineFile(t *testing.T) { + // 1M-line file: head -n 5 must output first 5 lines quickly. + dir := t.TempDir() + var buf bytes.Buffer + for i := 0; i < 1_000_000; i++ { + buf.WriteString("x\n") + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "million.txt"), buf.Bytes(), 0644)) + done := make(chan struct{}) + go func() { + defer close(done) + stdout, _, code := headRun(t, "head -n 5 million.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "x\nx\nx\nx\nx\n", stdout) + }() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("head on 1M-line file did not complete within 30s") + } +} + +// --- Path and filename edge cases --- + +func TestCmdPentestNonexistentFile(t *testing.T) { + dir := t.TempDir() + _, stderr, code := headRun(t, "head does_not_exist.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestEmptyFilename(t *testing.T) { + // An empty string as a filename should produce an error. + dir := t.TempDir() + _, _, code := headRun(t, `head ""`, dir) + assert.Equal(t, 1, code) +} + +func TestCmdPentestFlagLikeName(t *testing.T) { + // A file whose name starts with '-' must be accessible via '--'. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "-n"), []byte("flag-file\n"), 0644)) + stdout, _, code := headRun(t, "head -- -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "flag-file\n", stdout) +} + +func TestCmdPentestDirectoryAsFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + _, stderr, code := headRun(t, "head subdir", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestOutsideSandbox(t *testing.T) { + // Attempting to read a file outside the allowed path must be blocked. + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "s.txt"), []byte("secret"), 0644)) + secretPath := filepath.ToSlash(filepath.Join(secret, "s.txt")) + _, stderr, code := runScript(t, "head "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestPathTraversal(t *testing.T) { + // Path traversal with ../ must be blocked by the sandbox. + dir := t.TempDir() + _, stderr, code := headRun(t, "head ../../etc/passwd", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +// --- Flag and argument injection --- + +func TestCmdPentestUnknownLongFlag(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head --no-such-flag f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestUnknownShortFlag(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head -f f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestFollowFlagRejected(t *testing.T) { + // --follow is a tail flag that must not be silently accepted by head. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head --follow f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestZeroTerminatedFlagRejected(t *testing.T) { + // -z / --zero-terminated is a GNU extension we do not support. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, "head -z f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestCmdPentestMultipleDashes(t *testing.T) { + // Two '-' args: both read from the same stdin fd. + // The first '-' uses a bufio.Scanner which reads ahead in 4096-byte + // chunks. For a small file, the scanner consumes the entire stdin in one + // Read call, leaving nothing for the second '-'. + // This is Safer-than-GNU: our scanner-buffered implementation exhausts + // stdin after the first '-'. GNU head uses lower-level I/O that restores + // the fd position, but we are read-only and do not lseek. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "src.txt"), []byte("alpha\nbeta\ngamma\n"), 0644)) + stdout, _, code := headRun(t, "head -q -n 1 - - < src.txt", dir) + assert.Equal(t, 0, code) + // First '-' outputs "alpha\n"; second '-' sees empty stdin (buffered ahead). + assert.Equal(t, "alpha\n", stdout) +} + +func TestCmdPentestFlagViaExpansion(t *testing.T) { + // Flag injected via variable expansion: unknown flag → exit 1. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0644)) + _, stderr, code := headRun(t, `flag="-f"; head $flag f.txt`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +// --- Behavior matching (GNU comparison notes) --- + +func TestCmdPentestGNUMatchPositivePrefix(t *testing.T) { + // GNU head -n +N treats + as a positive sign, outputting N lines. + // Matches GNU: ghead -n +2 = first 2 lines. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("alpha\nbeta\ngamma\n"), 0644)) + stdout, _, code := headRun(t, "head -n +2 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) // Matches GNU +} + +func TestCmdPentestGNUMatchNegativeRejected(t *testing.T) { + // GNU head -n -N is elide-tail mode (all but last N lines). + // We intentionally do NOT support this (safer-than-GNU: we reject + // rather than implement a ring buffer). + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("alpha\nbeta\ngamma\n"), 0644)) + _, stderr, code := headRun(t, "head -n -1 f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} diff --git a/interp/builtins/head/head.go b/interp/builtins/head/head.go new file mode 100644 index 00000000..103a57d6 --- /dev/null +++ b/interp/builtins/head/head.go @@ -0,0 +1,317 @@ +// 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 implements the head builtin command. +// +// 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 MaxLineBytes +// (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 head + +import ( + "bufio" + "context" + "errors" + "io" + "os" + "strconv" + + "github.com/DataDog/rshell/interp/builtins" +) + +// Cmd is the head builtin command descriptor. +var Cmd = builtins.Command{Name: "head", MakeFlags: registerFlags} + +// MaxCount 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 MaxCount = 1<<31 - 1 // 2 147 483 647 + +// MaxLineBytes is the per-line buffer cap for the line scanner. Lines +// longer than this are reported as an error instead of being buffered. +const MaxLineBytes = 1 << 20 // 1 MiB + +// registerFlags registers all head flags on the framework-provided FlagSet and +// returns a bound handler whose flag variables are captured by closure. The +// framework calls Parse and passes positional arguments to the handler. +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + help := fs.BoolP("help", "h", false, "print usage and exit") + quiet := fs.BoolP("quiet", "q", false, "never print file name headers") + _ = fs.Bool("silent", false, "alias for --quiet") + verbose := fs.BoolP("verbose", "v", false, "always print file name headers") + + // linesFlag and bytesFlag share a sequence counter so that after parsing + // we can compare their pos fields to determine which appeared last on the + // command line. pflag calls Set() in parse order, so the last flag Set + // gets the highest pos value — no raw arg scanning required. + var modeSeq int + linesFlag := newModeFlag(&modeSeq, "10") + bytesFlag := newModeFlag(&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") + + return func(ctx context.Context, callCtx *builtins.CallContext, files []string) builtins.Result { + 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 builtins.Result{} + } + + // --silent is an alias for --quiet. + if fs.Changed("silent") { + *quiet = true + } + + // Bytes mode wins if -c/--bytes was parsed after -n/--lines. When neither + // is set both pos fields are 0 (false → line mode). When only one is set + // the other stays 0, so the comparison selects correctly. + useBytesMode := bytesFlag.pos > linesFlag.pos + + // Parse the count for the chosen mode. + countStr := linesFlag.val + modeLabel := "lines" + if useBytesMode { + countStr = bytesFlag.val + modeLabel = "bytes" + } + + count, ok := parseCount(countStr) + if !ok { + callCtx.Errf("head: invalid number of %s: %q\n", modeLabel, countStr) + return builtins.Result{Code: 1} + } + + // Default to stdin when no file arguments were given. + 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 := processFile(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 builtins.Result{Code: 1} + } + return builtins.Result{} + } +} + +// processFile opens and processes one file (or stdin for "-"). +func processFile(ctx context.Context, callCtx *builtins.CallContext, file string, idx int, printHeaders, useBytesMode bool, count int64) error { + var rc io.ReadCloser + name := file + if file == "-" { + name = "standard input" + // Print the header before the nil-stdin guard so that -v always + // emits a header for stdin even when no input stream is present. + if printHeaders { + if idx > 0 { + callCtx.Out("\n") + } + callCtx.Outf("==> %s <==\n", name) + } + if callCtx.Stdin == nil { + return nil + } + rc = io.NopCloser(callCtx.Stdin) + } else { + f, err := callCtx.OpenFile(ctx, file, os.O_RDONLY, 0) + if err != nil { + return err + } + defer f.Close() + rc = f + // Header is printed after a successful open so that a file that + // cannot be opened produces no header (matches GNU head behaviour). + if printHeaders { + if idx > 0 { + callCtx.Out("\n") + } + callCtx.Outf("==> %s <==\n", name) + } + } + + if useBytesMode { + return readBytes(ctx, callCtx, rc, count) + } + return readLines(ctx, callCtx, rc, count) +} + +// readLines writes the first count lines of r to callCtx.Stdout, preserving +// line endings exactly (including a missing final newline). +func readLines(ctx context.Context, callCtx *builtins.CallContext, r io.Reader, count int64) error { + sc := bufio.NewScanner(r) + buf := make([]byte, 4096) + sc.Buffer(buf, MaxLineBytes) + 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() +} + +// readBytes writes the first count bytes of r to callCtx.Stdout. It reads +// in fixed-size chunks; the buffer is capped at chunkSize but shrunk to +// count when count is smaller, avoiding unnecessary allocation for small +// byte requests (e.g. head -c 5). +func readBytes(ctx context.Context, callCtx *builtins.CallContext, r io.Reader, count int64) error { + if count == 0 { + return nil + } + const chunkSize = 32 * 1024 + buf := make([]byte, min(int64(chunkSize), count)) + remaining := count + for remaining > 0 { + if ctx.Err() != nil { + return ctx.Err() + } + toRead := min(int64(chunkSize), remaining) + n, err := r.Read(buf[:toRead]) + if n > 0 { + remaining -= int64(n) + if _, werr := callCtx.Stdout.Write(buf[:n]); werr != nil { + return werr + } + } + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + } + return nil +} + +// parseCount 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 parseCount(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 > MaxCount { + n = MaxCount + } + return n, true +} + +// modeFlag is a pflag.Value implementation for -n/--lines and -c/--bytes. +// Two modeFlag 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 modeFlag struct { + val string + seq *int // shared per-invocation counter; incremented on every Set call + pos int // counter value when Set was last called; 0 means never set +} + +func newModeFlag(seq *int, defaultVal string) *modeFlag { + return &modeFlag{val: defaultVal, seq: seq} +} + +func (f *modeFlag) String() string { return f.val } +func (f *modeFlag) Set(s string) error { + f.val = s + *f.seq++ + f.pos = *f.seq + return nil +} +func (f *modeFlag) Type() string { return "string" } + +// scanLinesPreservingNewline is a bufio.SplitFunc that includes the line +// terminator (\n) in the returned token. Unlike bufio.ScanLines, it does not +// strip \r\n or \n, so the caller reproduces the exact file content. If the +// file's last line has no terminator, the bare bytes are returned as the +// final token. +func scanLinesPreservingNewline(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + for i, b := range data { + if b == '\n' { + return i + 1, data[:i+1], nil + } + } + if atEOF { + // Last line has no trailing newline; return what we have. + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} diff --git a/interp/builtins/head/head_gnu_compat_test.go b/interp/builtins/head/head_gnu_compat_test.go new file mode 100644 index 00000000..e12d5b88 --- /dev/null +++ b/interp/builtins/head/head_gnu_compat_test.go @@ -0,0 +1,226 @@ +// 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 head_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestGNUCompatDefaultOutput — default output on a 12-line file. +// +// GNU command: ghead twelve.txt +// Expected: first 10 lines +func TestGNUCompatDefaultOutput(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "twelve.txt", twelveLines) + stdout, _, code := cmdRun(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 = fiveLines) +// Expected: "alpha\nbeta\ngamma\n" +func TestGNUCompatLinesN(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + stdout, _, code := cmdRun(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: fiveLines +func TestGNUCompatLinesLargerThanFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 100 five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, 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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "nonewline.txt", "no newline at end") + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "empty.txt", "") + stdout, _, code := cmdRun(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 := 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) +} + +// 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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + writeFile(t, dir, "nonewline.txt", "no newline at end") + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + writeFile(t, dir, "nonewline.txt", "no newline at end") + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + writeFile(t, dir, "nonewline.txt", "no newline at end") + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + stdout, _, code := cmdRun(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 := t.TempDir() + writeFile(t, dir, "five.txt", fiveLines) + _, stderr, code := cmdRun(t, "head --no-such-flag five.txt", dir) + assert.Equal(t, 1, code) + assert.NotEmpty(t, stderr) +} diff --git a/interp/builtins/head/head_test.go b/interp/builtins/head/head_test.go new file mode 100644 index 00000000..c7630de8 --- /dev/null +++ b/interp/builtins/head/head_test.go @@ -0,0 +1,707 @@ +// 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 ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +// 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() + return testutil.RunScriptCtx(ctx, t, script, dir, opts...) +} + +// 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 testutil.RunScript(t, script, dir, opts...) +} + +// cmdRun runs a head command with AllowedPaths set to dir. +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +// writeFile creates a file in dir with the given content and returns its name. +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) + return name +} + +// fiveLines is a 5-line file used across multiple tests. +const fiveLines = "alpha\nbeta\ngamma\ndelta\nepsilon\n" + +// twelveLines is a 12-line file used to test the default 10-line limit. +const twelveLines = "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" + +// --- Default behavior --- + +func TestHeadDefaultTenLines(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", twelveLines) + stdout, _, code := cmdRun(t, "head file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\n", stdout) +} + +func TestHeadFileShorterThanDefault(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, stdout) +} + +func TestHeadEmptyFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "empty.txt", "") + stdout, _, code := cmdRun(t, "head empty.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +// --- -n / --lines flag --- + +func TestHeadLinesN3(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 3 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +func TestHeadLinesN0(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 0 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestHeadLinesLargerThanFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 100 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, stdout) +} + +func TestHeadLinesLongForm(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head --lines=3 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +func TestHeadLinesPositivePrefix(t *testing.T) { + // GNU head: "+N" is treated as plain N (positive sign). + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n +2 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +func TestHeadLinesGlued(t *testing.T) { + // -n3 (value glued to flag) is supported by pflag. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n3 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +// --- No trailing newline preservation --- + +func TestHeadNoTrailingNewline(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "no newline at end") + stdout, _, code := cmdRun(t, "head -n 2 file.txt", dir) + assert.Equal(t, 0, code) + // Single line without newline — output exactly as-is. + assert.Equal(t, "no newline at end", stdout) +} + +func TestHeadLastLineNoNewline(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "line1\nline2") + stdout, _, code := cmdRun(t, "head -n 2 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line1\nline2", stdout) +} + +func TestHeadFirstLineNewlineSecondNot(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "line1\nline2") + stdout, _, code := cmdRun(t, "head -n 1 file.txt", dir) + assert.Equal(t, 0, code) + // Only the first line (with its newline) is printed. + assert.Equal(t, "line1\n", stdout) +} + +// --- -c / --bytes flag --- + +func TestHeadBytesN5(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c 5 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) +} + +func TestHeadBytesN0(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c 0 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestHeadBytesLargerThanFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c 9999 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, stdout) +} + +func TestHeadBytesLongForm(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head --bytes=5 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) +} + +func TestHeadBytesPositivePrefix(t *testing.T) { + // GNU head: "+N" is treated as plain N for -c too. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c +3 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alp", stdout) +} + +func TestHeadBytesBinaryContent(t *testing.T) { + dir := t.TempDir() + // Write binary content including null bytes. + content := "a\x00b\x00c\x00d" + writeFile(t, dir, "file.bin", content) + stdout, _, code := cmdRun(t, "head -c 5 file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\x00b\x00c", stdout) +} + +// --- Last flag wins (-n vs -c) --- + +func TestHeadLastFlagWinsBytes(t *testing.T) { + // -n 2 -c 5: last flag is -c, so byte mode with 5 bytes. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 2 -c 5 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) +} + +func TestHeadLastFlagWinsLines(t *testing.T) { + // -c 5 -n 2: last flag is -n, so line mode with 2 lines. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -c 5 -n 2 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +// --- Headers (-v / -q / --silent) --- + +func TestHeadVerboseSingleFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "one.txt", "only one line\n") + stdout, _, code := cmdRun(t, "head -v one.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> one.txt <==\nonly one line\n", stdout) +} + +func TestHeadTwoFilesDefaultHeaders(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\nbeta\n") + writeFile(t, dir, "b.txt", "gamma\n") + stdout, _, code := cmdRun(t, "head -n 2 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\nalpha\nbeta\n\n==> b.txt <==\ngamma\n", stdout) +} + +func TestHeadTwoFilesSecondNoNewline(t *testing.T) { + // Verifies that the separator \n before the second header is always printed, + // regardless of whether the first file ended with a newline. + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\nbeta\n") + writeFile(t, dir, "b.txt", "no newline") + stdout, _, code := cmdRun(t, "head -n 2 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\nalpha\nbeta\n\n==> b.txt <==\nno newline", stdout) +} + +func TestHeadFirstFileNoNewline(t *testing.T) { + // When first file ends without \n, the header separator still adds \n. + dir := t.TempDir() + writeFile(t, dir, "a.txt", "no newline") + writeFile(t, dir, "b.txt", "next\n") + stdout, _, code := cmdRun(t, "head -n 1 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\nno newline\n==> b.txt <==\nnext\n", stdout) +} + +func TestHeadQuietTwoFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\nbeta\n") + writeFile(t, dir, "b.txt", "gamma\n") + stdout, _, code := cmdRun(t, "head -q -n 2 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +func TestHeadSilentAlias(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\nbeta\n") + writeFile(t, dir, "b.txt", "gamma\n") + stdout, _, code := cmdRun(t, "head --silent -n 2 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\ngamma\n", stdout) +} + +func TestHeadVerboseTwoFiles(t *testing.T) { + // -v on multiple files still works (headers always printed). + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha\n") + writeFile(t, dir, "b.txt", "beta\n") + stdout, _, code := cmdRun(t, "head -v -n 1 a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\nalpha\n\n==> b.txt <==\nbeta\n", stdout) +} + +// --- Stdin --- + +func TestHeadStdinImplicit(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 2 < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +func TestHeadStdinDash(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", fiveLines) + stdout, _, code := cmdRun(t, "head -n 2 - < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha\nbeta\n", stdout) +} + +func TestHeadStdinVerbose(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", "hello\n") + stdout, _, code := cmdRun(t, "head -v - < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> standard input <==\nhello\n", stdout) +} + +// --- Help --- + +func TestHeadHelp(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "head --help", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") + assert.Contains(t, stdout, "--lines") + assert.Contains(t, stdout, "--bytes") + assert.Empty(t, stderr) +} + +func TestHeadHelpShort(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "head -h", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") + assert.Empty(t, stderr) +} + +// --- Error cases --- + +func TestHeadMissingFile(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "head nonexistent.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + _, stderr, code := cmdRun(t, "head subdir", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadUnknownFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "head --follow file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadUnknownShortFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "head -f file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadInvalidCountString(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + _, stderr, code := cmdRun(t, "head -n abc file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadNegativeCount(t *testing.T) { + // GNU head -n -N means "all but last N lines" — we do NOT support that. + // We reject negative counts. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + _, stderr, code := cmdRun(t, "head -n -1 file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadNegativeBytesCount(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + _, stderr, code := cmdRun(t, "head -c -1 file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadOutsideAllowedPaths(t *testing.T) { + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret"), 0644)) + + secretPath := strings.ReplaceAll(filepath.Join(secret, "secret.txt"), `\`, `/`) + _, stderr, code := runScript(t, "head "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadMultipleFilesSomeFailSomeSuc(t *testing.T) { + // When some files fail and some succeed, exit code is 1 and successful + // files still produce output. + dir := t.TempDir() + writeFile(t, dir, "good.txt", "hello\n") + stdout, stderr, code := cmdRun(t, "head -n 1 good.txt nonexistent.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stdout, "hello") + assert.Contains(t, stderr, "head:") +} + +// --- RULES.md compliance --- + +func TestHeadLargeCountClamped(t *testing.T) { + // A count larger than maxHeadCount (1<<31-1) must be clamped, not cause OOM. + // We pass a very large count on a tiny file; it should output the file content + // without crashing or hanging. + dir := t.TempDir() + writeFile(t, dir, "small.txt", "tiny\n") + stdout, _, code := cmdRun(t, "head -n 9999999999 small.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny\n", stdout) +} + +func TestHeadLargeByteCountClamped(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "small.txt", "tiny") + stdout, _, code := cmdRun(t, "head -c 9999999999 small.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "tiny", stdout) +} + +func TestHeadContextCancellation(t *testing.T) { + // The command must stop when the context is cancelled. + dir := t.TempDir() + // Use a pipe: create a heredoc that provides input. + writeFile(t, dir, "data.txt", fiveLines) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Should complete well within 5 seconds. + _, _, code := runScriptCtx(ctx, t, "head -n 3 data.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) +} + +func TestHeadDoubleDash(t *testing.T) { + // After --, all args are treated as file names, even if they look like flags. + dir := t.TempDir() + writeFile(t, dir, "-n", "flag-looking-name\n") + stdout, _, code := cmdRun(t, "head -- -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "flag-looking-name\n", stdout) +} + +func TestHeadNullBytesInContent(t *testing.T) { + // Binary content with null bytes must not crash or hang. + dir := t.TempDir() + content := "a\x00b\x00c\x00\n" + writeFile(t, dir, "binary.bin", content) + stdout, _, code := cmdRun(t, "head -n 1 binary.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, content, stdout) +} + +func TestHeadCRLFPreserved(t *testing.T) { + // CRLF line endings must be preserved exactly in the output. + dir := t.TempDir() + writeFile(t, dir, "crlf.txt", "line1\r\nline2\r\nline3\r\n") + stdout, _, code := cmdRun(t, "head -n 2 crlf.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line1\r\nline2\r\n", stdout) +} + +func TestHeadPipeInput(t *testing.T) { + // Verify head works correctly in a pipeline. + dir := t.TempDir() + writeFile(t, dir, "file.txt", twelveLines) + // cat file.txt | head -n 3 + stdout, _, code := cmdRun(t, "cat file.txt | head -n 3", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line01\nline02\nline03\n", stdout) +} + +func TestHeadLineModeOnLineExactlyAtCap(t *testing.T) { + // A line of exactly maxHeadLineBytes (1 MiB) with no newline. + // bufio.Scanner.Buffer(buf, max) cannot hold a token of exactly max + // bytes (the limit is exclusive), so this must error like an over-cap line. + dir := t.TempDir() + content := make([]byte, 1<<20) + for i := range content { + content[i] = 'a' + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "exact.txt"), content, 0644)) + _, _, code := cmdRun(t, "head -n 1 exact.txt", dir) + assert.Equal(t, 1, code) +} + +func TestHeadLineModeOnSingleLineBeyondCap(t *testing.T) { + // A line of maxHeadLineBytes+1 (1 MiB + 1 byte) with no newline. + // Exceeds the scanner buffer cap and must error, not crash. + dir := t.TempDir() + oneMiBPlusOne := make([]byte, 1<<20+1) + for i := range oneMiBPlusOne { + oneMiBPlusOne[i] = 'a' + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "huge.txt"), oneMiBPlusOne, 0644)) + _, stderr, code := cmdRun(t, "head -n 1 huge.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadLineModeOnLineBelowCap(t *testing.T) { + // A line just below the 1MiB cap should succeed. + dir := t.TempDir() + // Write (1MiB - 1) bytes of 'b' followed by a newline. + content := make([]byte, 1<<20-1) + for i := range content { + content[i] = 'b' + } + content = append(content, '\n') + require.NoError(t, os.WriteFile(filepath.Join(dir, "large.txt"), content, 0644)) + stdout, _, code := cmdRun(t, "head -n 1 large.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(content), stdout) +} + +func TestHeadEmptyCountString(t *testing.T) { + dir := t.TempDir() + // pflag with StringP default "10" means "-n" alone with no value is an error. + // If somehow an empty string is passed, it should be rejected. + writeFile(t, dir, "file.txt", fiveLines) + _, stderr, code := cmdRun(t, `head -n "" file.txt`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "head:") +} + +func TestHeadNilStdin(t *testing.T) { + // When head is asked to read stdin ("-") but the shell has no stdin, + // it should produce no output and exit 0 (callCtx.Stdin == nil path). + dir := t.TempDir() + // runScript with no stdin redirect — shell stdin stays nil. + stdout, stderr, code := runScript(t, "head -", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} + +func TestHeadNilStdinVerbose(t *testing.T) { + // -v must print the header for stdin even when callCtx.Stdin == nil. + // Previously the nil guard fired before the header block, silently + // skipping the "==> standard input <==" line. + dir := t.TempDir() + stdout, stderr, code := runScript(t, "head -v -", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + assert.Equal(t, "==> standard input <==\n", stdout) + assert.Equal(t, "", stderr) +} + +func TestHeadBytesAppearsLastWithDoubleDash(t *testing.T) { + // pflag stops parsing at "--", so file names after "--" are never + // mistaken for flags. With -n and -c both set before "--", the + // last-flag-wins logic applies (bytes mode because -c appears last). + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + // -n 3 -c 5 -- file.txt: both set, -c appears last before --, so byte mode. + stdout, _, code := cmdRun(t, "head -n 3 -c 5 -- file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha", stdout) // first 5 bytes +} + +func TestHeadContextPreCancelled(t *testing.T) { + // A pre-cancelled context should cause the command to abort immediately. + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before running + + // We don't assert a specific exit code (context cancellation may or may + // not surface as exit code 1 depending on timing), but we must not hang. + done := make(chan struct{}) + go func() { + runScriptCtx(ctx, t, "head -n 5 file.txt", dir, interp.AllowedPaths([]string{dir})) + close(done) + }() + select { + case <-done: + // completed without hanging + case <-time.After(5 * time.Second): + t.Fatal("head with pre-cancelled context did not return within 5s") + } +} + +func TestHeadNoOctalInterpretation08(t *testing.T) { + // "08" must be interpreted as decimal 8, not rejected as an invalid octal. + dir := t.TempDir() + writeFile(t, dir, "file.txt", twelveLines) + stdout, _, code := cmdRun(t, "head -n 08 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, 8, strings.Count(stdout, "\n")) +} + +func TestHeadNoOctalInterpretation010(t *testing.T) { + // "010" must be interpreted as decimal 10, not octal 8. + dir := t.TempDir() + writeFile(t, dir, "file.txt", twelveLines) + stdout, _, code := cmdRun(t, "head -n 010 file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, 10, strings.Count(stdout, "\n")) +} + +// --- Bad UTF-8 / binary passthrough --- + +// TestHeadBadUTF8ByteMode verifies that invalid UTF-8 bytes are passed through +// unchanged in byte mode. +// +// Derived from uutils test_head.rs::test_bad_utf8 +func TestHeadBadUTF8ByteMode(t *testing.T) { + dir := t.TempDir() + content := []byte{0xfc, 0x80, 0x80, 0x80, 0x80, 0xaf} + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.bin"), content, 0644)) + stdout, _, code := cmdRun(t, "head -c 6 bad.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(content), stdout) +} + +// TestHeadBadUTF8LineMode verifies that invalid UTF-8 bytes within lines are +// passed through unchanged in line mode. +// +// Derived from uutils test_head.rs::test_bad_utf8_lines +func TestHeadBadUTF8LineMode(t *testing.T) { + dir := t.TempDir() + // Three lines, each containing invalid UTF-8; request first 2 lines. + // input: \xfc\x80\x80\x80\x80\xaf\n b\xfc...\xaf\n b\xfc...\xaf (no final newline) + // expected: first 2 lines only, bytes preserved verbatim. + badSeq := []byte{0xfc, 0x80, 0x80, 0x80, 0x80, 0xaf} + line1 := append(append([]byte(nil), badSeq...), '\n') + line2 := append(append([]byte("b"), badSeq...), '\n') + line3 := append([]byte("b"), badSeq...) + input := append(append(append([]byte(nil), line1...), line2...), line3...) + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.bin"), input, 0644)) + + expected := append(append([]byte(nil), line1...), line2...) + stdout, _, code := cmdRun(t, "head -n 2 bad.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(expected), stdout) +} + +// --- Multi-file edge cases --- + +// TestHeadTwoEmptyFilesHeaders verifies that headers and the blank-line +// separator are still emitted when both files are empty. +// +// Derived from uutils test_head.rs::test_multiple_files +func TestHeadTwoEmptyFilesHeaders(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "") + writeFile(t, dir, "b.txt", "") + stdout, _, code := cmdRun(t, "head a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> a.txt <==\n\n==> b.txt <==\n", stdout) +} + +// TestHeadMultipleFilesWithStdin verifies that '-' interleaved among file +// arguments reads stdin and prints a "standard input" header alongside the +// file headers. +// +// Derived from uutils test_head.rs::test_multiple_files_with_stdin +func TestHeadMultipleFilesWithStdin(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "empty.txt", "") + writeFile(t, dir, "stdin_src.txt", "hello\n") + stdout, _, code := cmdRun(t, "head empty.txt - empty.txt < stdin_src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "==> empty.txt <==\n\n==> standard input <==\nhello\n\n==> empty.txt <==\n", stdout) +} + +// TestHeadAllNonexistentFiles verifies that each nonexistent file gets its own +// error message and no headers are printed for failed opens. +// +// Derived from uutils test_head.rs::test_multiple_nonexistent_files +func TestHeadAllNonexistentFiles(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "head missing1.txt missing2.txt", dir) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "missing1.txt") + assert.Contains(t, stderr, "missing2.txt") + assert.NotContains(t, stdout, "==> missing1.txt <==") + assert.NotContains(t, stdout, "==> missing2.txt <==") +} diff --git a/interp/builtins/head/head_unix_test.go b/interp/builtins/head/head_unix_test.go new file mode 100644 index 00000000..824ab1a8 --- /dev/null +++ b/interp/builtins/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/head/head_windows_test.go b/interp/builtins/head/head_windows_test.go new file mode 100644 index 00000000..03f82b6b --- /dev/null +++ b/interp/builtins/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/interp/register_builtins.go b/interp/register_builtins.go index b26dfb53..8d7f50d5 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -17,6 +17,7 @@ import ( "github.com/DataDog/rshell/interp/builtins/exit" falsecmd "github.com/DataDog/rshell/interp/builtins/false" "github.com/DataDog/rshell/interp/builtins/grep" + "github.com/DataDog/rshell/interp/builtins/head" "github.com/DataDog/rshell/interp/builtins/ls" "github.com/DataDog/rshell/interp/builtins/strings_cmd" "github.com/DataDog/rshell/interp/builtins/tail" @@ -39,6 +40,7 @@ func registerBuiltins() { exit.Cmd, falsecmd.Cmd, grep.Cmd, + head.Cmd, ls.Cmd, strings_cmd.Cmd, tail.Cmd, 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..1b9d1cd0 --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/last_flag_wins_bytes.yaml @@ -0,0 +1,14 @@ +# 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. +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..c5f1fe02 --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/last_flag_wins_lines.yaml @@ -0,0 +1,14 @@ +# 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. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -c 5 -n 2 file.txt +expect: + stdout: "alpha\nbeta\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/bytes/long_form.yaml b/tests/scenarios/cmd/head/bytes/long_form.yaml new file mode 100644 index 00000000..6208eb9c --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/long_form.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl tests +description: head --bytes=N long form is equivalent to -c N. +setup: + files: + - path: file.txt + content: "alpha\nbeta\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head --bytes=5 file.txt +expect: + stdout: "alpha" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/bytes/zero.yaml b/tests/scenarios/cmd/head/bytes/zero.yaml new file mode 100644 index 00000000..28e801c1 --- /dev/null +++ b/tests/scenarios/cmd/head/bytes/zero.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl test +description: head -c 0 produces no output. +setup: + files: + - path: file.txt + content: "alpha\nbeta\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -c 0 file.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/errors/all_missing.yaml b/tests/scenarios/cmd/head/errors/all_missing.yaml new file mode 100644 index 00000000..a1364435 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/all_missing.yaml @@ -0,0 +1,12 @@ +# Derived from uutils test_head.rs::test_multiple_nonexistent_files +description: All nonexistent files each produce an error message; no headers are printed. +input: + allowed_paths: ["$DIR"] + script: |+ + head missing1.txt missing2.txt +expect: + stdout: "" + stderr_contains: + - "missing1.txt" + - "missing2.txt" + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/directory.yaml b/tests/scenarios/cmd/head/errors/directory.yaml new file mode 100644 index 00000000..86bf5170 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/directory.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX error behavior +description: head exits 1 with an error when given a directory as an argument. +setup: + files: + - path: subdir/.keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + head subdir +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/invalid_n_flag.yaml b/tests/scenarios/cmd/head/errors/invalid_n_flag.yaml new file mode 100644 index 00000000..b38e3115 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/invalid_n_flag.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX error behavior +description: head exits 1 when -n is given a non-numeric argument. +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n abc file.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/missing_file.yaml b/tests/scenarios/cmd/head/errors/missing_file.yaml new file mode 100644 index 00000000..ad7fc72d --- /dev/null +++ b/tests/scenarios/cmd/head/errors/missing_file.yaml @@ -0,0 +1,10 @@ +# Derived from standard POSIX error behavior +description: head exits 1 and prints an error when a file does not exist. +input: + allowed_paths: ["$DIR"] + script: |+ + head nonexistent.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/multiple_some_fail.yaml b/tests/scenarios/cmd/head/errors/multiple_some_fail.yaml new file mode 100644 index 00000000..89e39f08 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/multiple_some_fail.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX error behavior +description: head continues processing remaining files even after one fails, and exits 1. +setup: + files: + - path: good.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -q good.txt nonexistent.txt +expect: + stdout: "hello\n" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/negative_count.yaml b/tests/scenarios/cmd/head/errors/negative_count.yaml new file mode 100644 index 00000000..3ad71682 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/negative_count.yaml @@ -0,0 +1,15 @@ +# We intentionally reject negative counts (we do not implement -n -N elide-tail mode) +description: head exits 1 when -n is given a negative count. +skip_assert_against_bash: true +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n -1 file.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/errors/unknown_flag.yaml b/tests/scenarios/cmd/head/errors/unknown_flag.yaml new file mode 100644 index 00000000..de866172 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/unknown_flag.yaml @@ -0,0 +1,14 @@ +# 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. +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..00a7020c --- /dev/null +++ b/tests/scenarios/cmd/head/hardening/double_dash_separator.yaml @@ -0,0 +1,14 @@ +# Standard POSIX -- end-of-flags behavior +description: head treats arguments after -- as file names, even if they look like flags. +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..e6c05ae3 --- /dev/null +++ b/tests/scenarios/cmd/head/hardening/large_count_clamped.yaml @@ -0,0 +1,14 @@ +# 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. +setup: + files: + - path: small.txt + content: "tiny\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 9999999999 small.txt +expect: + stdout: "tiny\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml b/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml new file mode 100644 index 00000000..bd0df4c5 --- /dev/null +++ b/tests/scenarios/cmd/head/hardening/outside_allowed_paths.yaml @@ -0,0 +1,15 @@ +# Per RULES.md: file access must be sandboxed via AllowedPaths +description: head is blocked from reading files outside the allowed paths sandbox. +skip_assert_against_bash: true # intentional sandbox restriction; bash/head can read /etc/passwd freely +setup: + files: + - path: local.txt + content: "local\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head /etc/passwd +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/headers/quiet_two_files.yaml b/tests/scenarios/cmd/head/headers/quiet_two_files.yaml new file mode 100644 index 00000000..9436682a --- /dev/null +++ b/tests/scenarios/cmd/head/headers/quiet_two_files.yaml @@ -0,0 +1,16 @@ +# Derived from GNU coreutils head behavior with -q +description: head -q suppresses file name headers even for multiple files. +setup: + files: + - path: a.txt + content: "alpha\nbeta\n" + - path: b.txt + content: "gamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -q -n 2 a.txt b.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/silent_alias.yaml b/tests/scenarios/cmd/head/headers/silent_alias.yaml new file mode 100644 index 00000000..a43efd70 --- /dev/null +++ b/tests/scenarios/cmd/head/headers/silent_alias.yaml @@ -0,0 +1,16 @@ +# Derived from GNU coreutils head behavior with --silent +description: head --silent is an alias for --quiet and suppresses headers. +setup: + files: + - path: a.txt + content: "alpha\nbeta\n" + - path: b.txt + content: "gamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head --silent -n 2 a.txt b.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/two_empty_files.yaml b/tests/scenarios/cmd/head/headers/two_empty_files.yaml new file mode 100644 index 00000000..975e328a --- /dev/null +++ b/tests/scenarios/cmd/head/headers/two_empty_files.yaml @@ -0,0 +1,16 @@ +# Derived from uutils test_head.rs::test_multiple_files +description: Two empty files still get headers and a blank-line separator between them. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + head a.txt b.txt +expect: + stdout: "==> a.txt <==\n\n==> b.txt <==\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/two_files_default.yaml b/tests/scenarios/cmd/head/headers/two_files_default.yaml new file mode 100644 index 00000000..66e4b518 --- /dev/null +++ b/tests/scenarios/cmd/head/headers/two_files_default.yaml @@ -0,0 +1,16 @@ +# Derived from GNU coreutils head behavior with multiple files +description: head with two files prints headers and a blank-line separator between them. +setup: + files: + - path: a.txt + content: "alpha\nbeta\n" + - path: b.txt + content: "gamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 2 a.txt b.txt +expect: + stdout: "==> a.txt <==\nalpha\nbeta\n\n==> b.txt <==\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/verbose_single_file.yaml b/tests/scenarios/cmd/head/headers/verbose_single_file.yaml new file mode 100644 index 00000000..7659bc50 --- /dev/null +++ b/tests/scenarios/cmd/head/headers/verbose_single_file.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head behavior with -v +description: head -v prints a header even for a single file. +setup: + files: + - path: one.txt + content: "only one line\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -v one.txt +expect: + stdout: "==> one.txt <==\nonly one line\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/default_ten_lines.yaml b/tests/scenarios/cmd/head/lines/default_ten_lines.yaml new file mode 100644 index 00000000..65e3c3a4 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/default_ten_lines.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl test basic-11 +description: head outputs exactly 10 lines by default when file has more than 10. +setup: + files: + - path: file.txt + content: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head file.txt +expect: + stdout: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/empty_file.yaml b/tests/scenarios/cmd/head/lines/empty_file.yaml new file mode 100644 index 00000000..6466724c --- /dev/null +++ b/tests/scenarios/cmd/head/lines/empty_file.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl idem-0 test +description: head on an empty file produces no output and exits 0. +setup: + files: + - path: empty.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + head empty.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/fewer_than_default.yaml b/tests/scenarios/cmd/head/lines/fewer_than_default.yaml new file mode 100644 index 00000000..8a45b525 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/fewer_than_default.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl test basic-09 +description: head outputs all lines when file has fewer than 10. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head file.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/long_form.yaml b/tests/scenarios/cmd/head/lines/long_form.yaml new file mode 100644 index 00000000..2c397bf9 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/long_form.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl basic tests +description: head --lines=N long form is equivalent to -n N. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\ndelta\nepsilon\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head --lines=3 file.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/n_flag.yaml b/tests/scenarios/cmd/head/lines/n_flag.yaml new file mode 100644 index 00000000..a1f2e28d --- /dev/null +++ b/tests/scenarios/cmd/head/lines/n_flag.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl basic tests +description: head -n 3 outputs the first 3 lines of a file. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\ndelta\nepsilon\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 3 file.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/n_larger_than_file.yaml b/tests/scenarios/cmd/head/lines/n_larger_than_file.yaml new file mode 100644 index 00000000..f3802728 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/n_larger_than_file.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl basic tests +description: head -n N larger than file length outputs all lines without error. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 100 file.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/n_zero.yaml b/tests/scenarios/cmd/head/lines/n_zero.yaml new file mode 100644 index 00000000..87f7a0ca --- /dev/null +++ b/tests/scenarios/cmd/head/lines/n_zero.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl test +description: head -n 0 produces no output. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 0 file.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/no_octal_interpretation.yaml b/tests/scenarios/cmd/head/lines/no_octal_interpretation.yaml new file mode 100644 index 00000000..3425c5f5 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/no_octal_interpretation.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl tests no-oct-3 and no-oct-4 +description: head -n 08 and -n 010 are interpreted as decimal 8 and 10, not octal. +setup: + files: + - path: file.txt + content: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 08 file.txt +expect: + stdout: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/no_trailing_newline.yaml b/tests/scenarios/cmd/head/lines/no_trailing_newline.yaml new file mode 100644 index 00000000..d77afc90 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/no_trailing_newline.yaml @@ -0,0 +1,15 @@ +# Derived from GNU coreutils head.pl idem-1 test +description: head preserves a file's missing trailing newline exactly. +skip_assert_against_bash: false +setup: + files: + - path: file.txt + content: "no newline at end" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 1 file.txt +expect: + stdout: "no newline at end" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/null_bytes.yaml b/tests/scenarios/cmd/head/lines/null_bytes.yaml new file mode 100644 index 00000000..386fcc28 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/null_bytes.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils head.pl null-1 test +description: head handles null bytes in file content without crashing. +setup: + files: + - path: file.txt + content: "a\x00a\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 1 file.txt +expect: + stdout: "a\x00a\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/dash_explicit.yaml b/tests/scenarios/cmd/head/stdin/dash_explicit.yaml new file mode 100644 index 00000000..ce86a742 --- /dev/null +++ b/tests/scenarios/cmd/head/stdin/dash_explicit.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX head behavior +description: head - explicitly reads from standard input. +setup: + files: + - path: src.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 2 - < src.txt +expect: + stdout: "alpha\nbeta\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/implicit.yaml b/tests/scenarios/cmd/head/stdin/implicit.yaml new file mode 100644 index 00000000..0272fc82 --- /dev/null +++ b/tests/scenarios/cmd/head/stdin/implicit.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX head behavior +description: head with no file arguments reads from standard input. +setup: + files: + - path: src.txt + content: "alpha\nbeta\ngamma\ndelta\nepsilon\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n 2 < src.txt +expect: + stdout: "alpha\nbeta\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml b/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml new file mode 100644 index 00000000..31742250 --- /dev/null +++ b/tests/scenarios/cmd/head/stdin/mixed_with_files.yaml @@ -0,0 +1,16 @@ +# Derived from uutils test_head.rs::test_multiple_files_with_stdin +description: Stdin interleaved with file args shows a standard input header alongside file headers. +setup: + files: + - path: empty.txt + content: "" + - path: stdin_src.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head empty.txt - empty.txt < stdin_src.txt +expect: + stdout: "==> empty.txt <==\n\n==> standard input <==\nhello\n\n==> empty.txt <==\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/stdin/pipe.yaml b/tests/scenarios/cmd/head/stdin/pipe.yaml new file mode 100644 index 00000000..213cbd6d --- /dev/null +++ b/tests/scenarios/cmd/head/stdin/pipe.yaml @@ -0,0 +1,14 @@ +# Derived from standard POSIX head behavior in pipelines +description: head works correctly in a pipeline receiving input from cat. +setup: + files: + - path: file.txt + content: "line01\nline02\nline03\nline04\nline05\nline06\nline07\nline08\nline09\nline10\nline11\nline12\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat file.txt | head -n 3 +expect: + stdout: "line01\nline02\nline03\n" + stderr: "" + exit_code: 0 From a4d1bb312f6ba92e2d5723691d2668dac21208e2 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Wed, 11 Mar 2026 16:28:10 -0400 Subject: [PATCH 3/6] Address review comments: header separator bug, -q/-v last-flag-wins, -z docs Three issues fixed: 1. Spurious leading \n separator when first file fails to open (P2 bash compat): Replace idx-based separator logic with prevHeaderPrinted bool. The separator is now only printed when a previous file was successfully processed, matching GNU head behavior (head missing.txt good.txt no longer produces a leading \n). 2. -q/-v last-flag-wins semantics (P1 bash compat, raised by @codex review): Replace *bool quiet/verbose with boolSeqFlag that shares a sequence counter. The flag with the highest pos wins, so "head -q -v file" prints headers (-v wins) and "head -v -q file" suppresses them (-q wins), matching GNU head. --silent shares the same counter as --quiet and acts as an alias. Uses pflag's NoOptDefVal="true" so the flags remain no-argument booleans. 3. SHELL_FEATURES.md incorrectly listed -z in the head synopsis (P2 docs): Remove -z from the synopsis and add a note that -z/--zero-terminated and --follow are rejected (unlike tail which actually implements -z). Add test scenarios: first_fails_second_succeeds, last_flag_wins_verbose, last_flag_wins_quiet. Co-Authored-By: Claude Sonnet 4.6 --- SHELL_FEATURES.md | 2 +- interp/builtins/head/head.go | 79 ++++++++++++++----- .../errors/first_fails_second_succeeds.yaml | 13 +++ .../head/headers/last_flag_wins_quiet.yaml | 13 +++ .../head/headers/last_flag_wins_verbose.yaml | 13 +++ 5 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 tests/scenarios/cmd/head/errors/first_fails_second_succeeds.yaml create mode 100644 tests/scenarios/cmd/head/headers/last_flag_wins_quiet.yaml create mode 100644 tests/scenarios/cmd/head/headers/last_flag_wins_verbose.yaml diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index b8312cce..c50d682d 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -13,7 +13,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `exit [N]` — exit the shell with status N (default 0) - ✅ `false` — return exit code 1 - ✅ `grep [-EFGivclLnHhoqsxw] [-e PATTERN] [-m NUM] [-A NUM] [-B NUM] [-C NUM] PATTERN [FILE]...` — print lines that match patterns; uses RE2 regex engine (linear-time, no backtracking) -- ✅ `head [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the first part of files (default: first 10 lines) +- ✅ `head [-n N|-c N] [-q|-v] [FILE]...` — output the first part of files (default: first 10 lines); `-z`/`--zero-terminated` and `--follow` are rejected - ✅ `ls [-1aAdFhlpRrSt] [FILE]...` — list directory contents - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected diff --git a/interp/builtins/head/head.go b/interp/builtins/head/head.go index 103a57d6..faeff489 100644 --- a/interp/builtins/head/head.go +++ b/interp/builtins/head/head.go @@ -76,9 +76,19 @@ const MaxLineBytes = 1 << 20 // 1 MiB // framework calls Parse and passes positional arguments to the handler. func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { help := fs.BoolP("help", "h", false, "print usage and exit") - quiet := fs.BoolP("quiet", "q", false, "never print file name headers") - _ = fs.Bool("silent", false, "alias for --quiet") - verbose := fs.BoolP("verbose", "v", false, "always print file name headers") + + // quietFlag, silentFlag, and verboseFlag share a sequence counter so that + // after parsing we can determine which of -q/--quiet/--silent/-v/--verbose + // appeared last on the command line — the last flag wins, matching GNU head. + // NoOptDefVal is set to "true" so pflag treats these as boolean flags that + // can be given without a "=value" argument (e.g. "--quiet" not "--quiet=true"). + var headerSeq int + quietFlag := newBoolSeqFlag(&headerSeq) + silentFlag := newBoolSeqFlag(&headerSeq) + verboseFlag := newBoolSeqFlag(&headerSeq) + fs.VarPF(quietFlag, "quiet", "q", "never print file name headers").NoOptDefVal = "true" + fs.VarPF(silentFlag, "silent", "", "alias for --quiet").NoOptDefVal = "true" + fs.VarPF(verboseFlag, "verbose", "v", "always print file name headers").NoOptDefVal = "true" // linesFlag and bytesFlag share a sequence counter so that after parsing // we can compare their pos fields to determine which appeared last on the @@ -100,11 +110,6 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { return builtins.Result{} } - // --silent is an alias for --quiet. - if fs.Changed("silent") { - *quiet = true - } - // Bytes mode wins if -c/--bytes was parsed after -n/--lines. When neither // is set both pos fields are 0 (false → line mode). When only one is set // the other stays 0, so the comparison selects correctly. @@ -129,25 +134,38 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { 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 + // Header printing: the last of -q/--quiet/--silent and -v/--verbose wins + // (matching GNU head). --silent is an alias for --quiet and shares the + // same sequence counter. If none are specified, print headers only when + // multiple files are given. + lastQuietPos := max(quietFlag.pos, silentFlag.pos) + var printHeaders bool + switch { + case verboseFlag.pos > lastQuietPos: + printHeaders = true // -v was specified last + case lastQuietPos > verboseFlag.pos: + printHeaders = false // -q or --silent was specified last + default: + printHeaders = len(files) > 1 // neither: default multi-file behaviour } var failed bool - for i, file := range files { + var printedHeader bool + for _, file := range files { if ctx.Err() != nil { break } - if err := processFile(ctx, callCtx, file, i, printHeaders, useBytesMode, count); err != nil { + if err := processFile(ctx, callCtx, file, printedHeader, printHeaders, useBytesMode, count); err != nil { name := file if file == "-" { name = "standard input" } callCtx.Errf("head: %s: %s\n", name, callCtx.PortableErr(err)) failed = true + } else if printHeaders { + // A header was successfully printed for this file; subsequent + // files should emit a blank-line separator before their header. + printedHeader = true } } @@ -159,7 +177,9 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } // processFile opens and processes one file (or stdin for "-"). -func processFile(ctx context.Context, callCtx *builtins.CallContext, file string, idx int, printHeaders, useBytesMode bool, count int64) error { +// prevHeaderPrinted reports whether a header was already emitted for a previous +// file; when true, a blank-line separator is printed before this file's header. +func processFile(ctx context.Context, callCtx *builtins.CallContext, file string, prevHeaderPrinted bool, printHeaders, useBytesMode bool, count int64) error { var rc io.ReadCloser name := file if file == "-" { @@ -167,7 +187,7 @@ func processFile(ctx context.Context, callCtx *builtins.CallContext, file string // 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 { + if prevHeaderPrinted { callCtx.Out("\n") } callCtx.Outf("==> %s <==\n", name) @@ -186,7 +206,7 @@ func processFile(ctx context.Context, callCtx *builtins.CallContext, file string // 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 { + if prevHeaderPrinted { callCtx.Out("\n") } callCtx.Outf("==> %s <==\n", name) @@ -294,6 +314,29 @@ func (f *modeFlag) Set(s string) error { } func (f *modeFlag) Type() string { return "string" } +// boolSeqFlag is a pflag.Value implementation for boolean flags that share a +// sequence counter with other flags. After pflag.Parse, comparing the pos +// fields of flags that share a counter reveals which was specified last. +// This is used to implement last-flag-wins semantics for -q/--quiet/--silent +// versus -v/--verbose. +type boolSeqFlag struct { + seq *int + pos int +} + +func newBoolSeqFlag(seq *int) *boolSeqFlag { + return &boolSeqFlag{seq: seq} +} + +func (f *boolSeqFlag) String() string { return "false" } +func (f *boolSeqFlag) Set(_ string) error { + *f.seq++ + f.pos = *f.seq + return nil +} +func (f *boolSeqFlag) Type() string { return "bool" } +func (f *boolSeqFlag) IsBoolFlag() bool { return true } + // 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 diff --git a/tests/scenarios/cmd/head/errors/first_fails_second_succeeds.yaml b/tests/scenarios/cmd/head/errors/first_fails_second_succeeds.yaml new file mode 100644 index 00000000..0bca6347 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/first_fails_second_succeeds.yaml @@ -0,0 +1,13 @@ +description: head continues after the first file fails to open; no spurious blank line before the second file's header. +setup: + files: + - path: good.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head missing.txt good.txt +expect: + stdout: "==> good.txt <==\nhello\n" + stderr_contains: ["missing.txt"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/headers/last_flag_wins_quiet.yaml b/tests/scenarios/cmd/head/headers/last_flag_wins_quiet.yaml new file mode 100644 index 00000000..4d83fd98 --- /dev/null +++ b/tests/scenarios/cmd/head/headers/last_flag_wins_quiet.yaml @@ -0,0 +1,13 @@ +description: When -v and -q are both given, the last flag wins; here -q comes last so headers are suppressed. +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -v -q file.txt +expect: + stdout: "hello\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/headers/last_flag_wins_verbose.yaml b/tests/scenarios/cmd/head/headers/last_flag_wins_verbose.yaml new file mode 100644 index 00000000..64297a10 --- /dev/null +++ b/tests/scenarios/cmd/head/headers/last_flag_wins_verbose.yaml @@ -0,0 +1,13 @@ +description: When -q and -v are both given, the last flag wins; here -v comes last so headers are printed. +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -q -v file.txt +expect: + stdout: "==> file.txt <==\nhello\n" + stderr: "" + exit_code: 0 From 9b3764a551e58921a00bd6d5834a206a878a3bf4 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Wed, 11 Mar 2026 16:37:53 -0400 Subject: [PATCH 4/6] Address P3 review comments: remove dead IsBoolFlag, add -c -N scenario - Remove boolSeqFlag.IsBoolFlag() which pflag never calls for VarP/VarPF flags. Add a comment explaining that NoOptDefVal = "true" is the actual mechanism. This prevents future readers from thinking IsBoolFlag() is the active mechanism and accidentally removing the NoOptDefVal lines. - Add tests/scenarios/cmd/head/errors/negative_bytes_count.yaml to document the intentional rejection of head -c -N (elide-tail mode for bytes). Marked with skip_assert_against_bash: true since bash supports head -c -N but we intentionally do not implement it. Co-Authored-By: Claude Sonnet 4.6 --- interp/builtins/head/head.go | 9 +++++++-- .../cmd/head/errors/negative_bytes_count.yaml | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 tests/scenarios/cmd/head/errors/negative_bytes_count.yaml diff --git a/interp/builtins/head/head.go b/interp/builtins/head/head.go index faeff489..546dc390 100644 --- a/interp/builtins/head/head.go +++ b/interp/builtins/head/head.go @@ -334,8 +334,13 @@ func (f *boolSeqFlag) Set(_ string) error { f.pos = *f.seq return nil } -func (f *boolSeqFlag) Type() string { return "bool" } -func (f *boolSeqFlag) IsBoolFlag() bool { return true } +func (f *boolSeqFlag) Type() string { return "bool" } + +// Note: pflag does NOT use an IsBoolFlag() method for flags registered via +// VarP/VarPF. The mechanism that makes these flags accept no value argument +// (e.g. "--quiet" rather than "--quiet=true") is NoOptDefVal = "true", set +// at registration time. IsBoolFlag() is intentionally absent here to avoid +// misleading future readers into thinking it is the active mechanism. // scanLinesPreservingNewline is a bufio.SplitFunc that includes the line // terminator (\n) in the returned token. Unlike bufio.ScanLines, it does not diff --git a/tests/scenarios/cmd/head/errors/negative_bytes_count.yaml b/tests/scenarios/cmd/head/errors/negative_bytes_count.yaml new file mode 100644 index 00000000..fa67bf6d --- /dev/null +++ b/tests/scenarios/cmd/head/errors/negative_bytes_count.yaml @@ -0,0 +1,15 @@ +# We intentionally reject negative byte counts (we do not implement -c -N elide-tail mode) +description: head exits 1 when -c is given a negative count. +skip_assert_against_bash: true +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -c -1 file.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 From 9804b0895bff586d108d8fc64cb187dc4f55720e Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Thu, 12 Mar 2026 08:56:05 -0400 Subject: [PATCH 5/6] Address review comments: boolean flag args, seekable stdin, printedHeader tracking - boolSeqFlag.Set: reject non-"true" values so --quiet=false and --verbose=false exit 1 with an error, matching GNU head behaviour - readLines: wrap scanner in byteCountReader and seek back excess buffered bytes when stdin is seekable (e.g. redirected from a file), so a second '-' operand reads from the correct stream position; non-seekable fds (pipes) are detected via a Seek(0,SeekCurrent) probe and left as-is (pipe read-ahead is accepted as consumed) - processFile: return (headerPrinted bool, err error) so the caller sets printedHeader=true whenever a header was emitted, even when a subsequent read error occurs, preventing a missing blank-line separator - Add io.ReadSeeker and io.SeekCurrent to the builtin symbol allowlist - Add scenario tests: boolean_flag_with_argument.yaml, repeated_dash_seekable.yaml - Update pentest test expectation now that seekable-stdin is correct Co-Authored-By: Claude Sonnet 4.6 --- .../head/builtin_head_pentest_test.go | 15 +-- interp/builtins/head/head.go | 122 ++++++++++++++---- tests/allowed_symbols_test.go | 4 + .../errors/boolean_flag_with_argument.yaml | 16 +++ .../head/stdin/repeated_dash_seekable.yaml | 13 ++ 5 files changed, 133 insertions(+), 37 deletions(-) create mode 100644 tests/scenarios/cmd/head/errors/boolean_flag_with_argument.yaml create mode 100644 tests/scenarios/cmd/head/stdin/repeated_dash_seekable.yaml diff --git a/interp/builtins/head/builtin_head_pentest_test.go b/interp/builtins/head/builtin_head_pentest_test.go index 1221c711..7f8548b3 100644 --- a/interp/builtins/head/builtin_head_pentest_test.go +++ b/interp/builtins/head/builtin_head_pentest_test.go @@ -480,19 +480,16 @@ func TestCmdPentestZeroTerminatedFlagRejected(t *testing.T) { } 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. + // Two '-' args with seekable stdin (redirected from a file). + // readLines wraps the scanner in a byteCountReader and seeks back any + // bytes the scanner buffered but did not emit. This allows the second '-' + // to read from the correct stream position, matching GNU head behaviour. 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) + // First '-' outputs "alpha\n"; second '-' reads from the seeked-back position → "beta\n". + assert.Equal(t, "alpha\nbeta\n", stdout) } func TestCmdPentestFlagViaExpansion(t *testing.T) { diff --git a/interp/builtins/head/head.go b/interp/builtins/head/head.go index 546dc390..640e1176 100644 --- a/interp/builtins/head/head.go +++ b/interp/builtins/head/head.go @@ -155,16 +155,21 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { if ctx.Err() != nil { break } - if err := processFile(ctx, callCtx, file, printedHeader, printHeaders, useBytesMode, count); err != nil { + hp, err := processFile(ctx, callCtx, file, printedHeader, printHeaders, useBytesMode, count) + if err != nil { name := file if file == "-" { name = "standard input" } callCtx.Errf("head: %s: %s\n", name, callCtx.PortableErr(err)) failed = true - } else if printHeaders { - // A header was successfully printed for this file; subsequent - // files should emit a blank-line separator before their header. + } + if hp { + // A header was printed for this file; subsequent files should + // emit a blank-line separator before their header. This is set + // regardless of whether a read error occurred so that the + // separator is not lost when a file opens successfully (header + // printed) but reading later fails. printedHeader = true } } @@ -179,8 +184,9 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { // processFile opens and processes one file (or stdin for "-"). // prevHeaderPrinted reports whether a header was already emitted for a previous // file; when true, a blank-line separator is printed before this file's header. -func processFile(ctx context.Context, callCtx *builtins.CallContext, file string, prevHeaderPrinted bool, printHeaders, useBytesMode bool, count int64) error { - var rc io.ReadCloser +// It returns (headerPrinted, err): headerPrinted is true whenever a header line +// was actually written to output, regardless of whether a read error follows. +func processFile(ctx context.Context, callCtx *builtins.CallContext, file string, prevHeaderPrinted bool, printHeaders, useBytesMode bool, count int64) (headerPrinted bool, err error) { name := file if file == "-" { name = "standard input" @@ -191,53 +197,106 @@ func processFile(ctx context.Context, callCtx *builtins.CallContext, file string callCtx.Out("\n") } callCtx.Outf("==> %s <==\n", name) + headerPrinted = true } if callCtx.Stdin == nil { - return nil + return headerPrinted, nil } - rc = io.NopCloser(callCtx.Stdin) - } else { - f, err := callCtx.OpenFile(ctx, file, os.O_RDONLY, 0) - if err != nil { - return err + // Pass callCtx.Stdin directly (not wrapped in NopCloser) so that + // readLines can seek back buffered bytes when stdin is seekable + // (e.g. redirected from a file). This allows a second '-' operand + // to continue reading from the correct stream position, matching + // GNU head behaviour. + r := callCtx.Stdin + if useBytesMode { + return headerPrinted, readBytes(ctx, callCtx, r, count) } - defer f.Close() - rc = f - // Header is printed after a successful open so that a file that - // cannot be opened produces no header (matches GNU head behaviour). - if printHeaders { - if prevHeaderPrinted { - callCtx.Out("\n") - } - callCtx.Outf("==> %s <==\n", name) + return headerPrinted, readLines(ctx, callCtx, r, count) + } + f, ferr := callCtx.OpenFile(ctx, file, os.O_RDONLY, 0) + if ferr != nil { + return false, ferr + } + defer f.Close() + // 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 prevHeaderPrinted { + callCtx.Out("\n") } + callCtx.Outf("==> %s <==\n", name) + headerPrinted = true } - if useBytesMode { - return readBytes(ctx, callCtx, rc, count) + return headerPrinted, readBytes(ctx, callCtx, f, count) } - return readLines(ctx, callCtx, rc, count) + return headerPrinted, readLines(ctx, callCtx, f, count) } // readLines writes the first count lines of r to callCtx.Stdout, preserving // line endings exactly (including a missing final newline). +// +// When r implements io.ReadSeeker (e.g. stdin redirected from a file), readLines +// seeks back any bytes the internal scanner read ahead but did not include in +// the N-line output. This allows a subsequent read on the same stream (e.g. a +// second '-' operand) to start from the correct position, matching GNU head. func readLines(ctx context.Context, callCtx *builtins.CallContext, r io.Reader, count int64) error { - sc := bufio.NewScanner(r) + // Wrap r in a byte counter so we can compute how many bytes the scanner + // consumed from the underlying source — needed for the seek-back below. + cr := &byteCountReader{r: r} + sc := bufio.NewScanner(cr) buf := make([]byte, 4096) sc.Buffer(buf, MaxLineBytes) sc.Split(scanLinesPreservingNewline) var emitted int64 + var bytesEmitted int64 for emitted < count && sc.Scan() { if ctx.Err() != nil { return ctx.Err() } - if _, err := callCtx.Stdout.Write(sc.Bytes()); err != nil { + token := sc.Bytes() + if _, err := callCtx.Stdout.Write(token); err != nil { return err } emitted++ + bytesEmitted += int64(len(token)) + } + if err := sc.Err(); err != nil { + return err + } + // If the underlying reader supports seeking, rewind any bytes the scanner + // read ahead from the source but did not include in the N-line output. + // excess = bytes pulled from r by the scanner − bytes returned as tokens. + // + // *os.File always satisfies io.ReadSeeker but Seek fails at runtime for + // non-seekable fds (pipes, sockets). We probe with a no-op Seek(0, Current) + // first; if it fails the stream is non-seekable and we skip the rewind + // (pipe read-ahead is accepted as consumed, matching OS behaviour). + if rs, ok := r.(io.ReadSeeker); ok { + if excess := cr.total - bytesEmitted; excess > 0 { + if _, err := rs.Seek(0, io.SeekCurrent); err == nil { + if _, err := rs.Seek(-excess, io.SeekCurrent); err != nil { + return err + } + } + } } - return sc.Err() + return nil +} + +// byteCountReader is an io.Reader wrapper that counts the total bytes read +// from the underlying reader. Used by readLines to calculate scanner read-ahead +// so that seekable streams can be rewound to the correct position. +type byteCountReader struct { + r io.Reader + total int64 +} + +func (c *byteCountReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.total += int64(n) + return n, err } // readBytes writes the first count bytes of r to callCtx.Stdout. It reads @@ -329,7 +388,14 @@ func newBoolSeqFlag(seq *int) *boolSeqFlag { } func (f *boolSeqFlag) String() string { return "false" } -func (f *boolSeqFlag) Set(_ string) error { +func (f *boolSeqFlag) Set(s string) error { + // GNU head rejects --quiet= and --verbose= with an error. + // With NoOptDefVal = "true", pflag calls Set("true") for bare --quiet and + // Set("") when an explicit = is given. We accept only "true" + // (the NoOptDefVal) and reject any other value to match GNU head behaviour. + if s != "true" { + return errors.New("option doesn't allow an argument") + } *f.seq++ f.pos = *f.seq return nil diff --git a/tests/allowed_symbols_test.go b/tests/allowed_symbols_test.go index e74d5d17..59d56f88 100644 --- a/tests/allowed_symbols_test.go +++ b/tests/allowed_symbols_test.go @@ -68,6 +68,10 @@ var builtinAllowedSymbols = []string{ "io.ReadCloser", // io.Reader — interface type; no side effects. "io.Reader", + // io.ReadSeeker — interface type combining Reader and Seeker; no side effects. + "io.ReadSeeker", + // io.SeekCurrent — whence constant for Seek(offset, SeekCurrent); pure constant. + "io.SeekCurrent", // math.MaxInt32 — integer constant; no side effects. "math.MaxInt32", // math.MaxInt64 — integer constant; no side effects. diff --git a/tests/scenarios/cmd/head/errors/boolean_flag_with_argument.yaml b/tests/scenarios/cmd/head/errors/boolean_flag_with_argument.yaml new file mode 100644 index 00000000..5c090f48 --- /dev/null +++ b/tests/scenarios/cmd/head/errors/boolean_flag_with_argument.yaml @@ -0,0 +1,16 @@ +# GNU head rejects --quiet= and --verbose= (options don't allow arguments). +# Our error message differs from GNU head so bash comparison is skipped. +description: head exits 1 when --quiet or --verbose are given an explicit argument. +skip_assert_against_bash: true +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head --quiet=false file.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/head/stdin/repeated_dash_seekable.yaml b/tests/scenarios/cmd/head/stdin/repeated_dash_seekable.yaml new file mode 100644 index 00000000..20570afb --- /dev/null +++ b/tests/scenarios/cmd/head/stdin/repeated_dash_seekable.yaml @@ -0,0 +1,13 @@ +description: When stdin is redirected from a file (seekable), repeated - operands each read independently from the current stream position. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -q -n 1 - - < file.txt +expect: + stdout: "alpha\nbeta\n" + stderr: "" + exit_code: 0 From 0049f481e7e0c83a27e049351df60979ac0af601 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Thu, 12 Mar 2026 08:58:42 -0400 Subject: [PATCH 6/6] Fix: validate all explicitly-set mode flags, not just the winner head -n xyz -c 1 now exits 1 with an error even though -c wins the last-flag-wins election. Mirrors GNU head behaviour: all given mode flag values are validated upfront before determining which mode is active. Co-Authored-By: Claude Sonnet 4.6 --- interp/builtins/head/head.go | 19 ++++++++++++++++++- .../cmd/head/errors/invalid_losing_flag.yaml | 13 +++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 tests/scenarios/cmd/head/errors/invalid_losing_flag.yaml diff --git a/interp/builtins/head/head.go b/interp/builtins/head/head.go index 640e1176..8ab22a7a 100644 --- a/interp/builtins/head/head.go +++ b/interp/builtins/head/head.go @@ -110,12 +110,29 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { return builtins.Result{} } + // Validate all explicitly set mode flags upfront. GNU head rejects + // invalid values even for flags that are overridden by a later mode + // flag on the command line (e.g. "head -n xyz -c 1" fails). + if linesFlag.pos > 0 { + if _, ok := parseCount(linesFlag.val); !ok { + callCtx.Errf("head: invalid number of lines: %q\n", linesFlag.val) + return builtins.Result{Code: 1} + } + } + if bytesFlag.pos > 0 { + if _, ok := parseCount(bytesFlag.val); !ok { + callCtx.Errf("head: invalid number of bytes: %q\n", bytesFlag.val) + return builtins.Result{Code: 1} + } + } + // 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. + // Parse the count for the chosen mode (handles the default "10" for + // linesFlag when neither flag was explicitly given). countStr := linesFlag.val modeLabel := "lines" if useBytesMode { diff --git a/tests/scenarios/cmd/head/errors/invalid_losing_flag.yaml b/tests/scenarios/cmd/head/errors/invalid_losing_flag.yaml new file mode 100644 index 00000000..b2e5992c --- /dev/null +++ b/tests/scenarios/cmd/head/errors/invalid_losing_flag.yaml @@ -0,0 +1,13 @@ +description: head validates all explicitly-given mode flags, not just the winner; -n xyz -c 1 fails even though -c wins. +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -n xyz -c 1 file.txt +expect: + stdout: "" + stderr_contains: ["head:"] + exit_code: 1