From 48ef475437f9e9ab5ab31971882c0bb2c0faf985 Mon Sep 17 00:00:00 2001 From: "datadog-prod-us1-6[bot]" <266788760+datadog-prod-us1-6[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:44:30 +0000 Subject: [PATCH 1/2] Re-implement cat builtin with full flag support Co-authored-by: AlexandreYang <49917914+AlexandreYang@users.noreply.github.com> --- .../builtins/cat/builtin_cat_pentest_test.go | 288 ++++++++++ interp/builtins/cat/cat.go | 355 +++++++++++- interp/builtins/cat/cat_gnu_compat_test.go | 253 +++++++++ interp/builtins/cat/cat_test.go | 532 ++++++++++++++++++ .../cmd/cat/combined/show_all_A.yaml | 14 + .../scenarios/cmd/cat/combined/u_ignored.yaml | 14 + .../cat/hardening/double_dash_separator.yaml | 13 + .../cmd/cat/hardening/unknown_flag.yaml | 9 + tests/scenarios/cmd/cat/help/help_flag.yaml | 9 + .../cmd/cat/number/across_files.yaml | 16 + tests/scenarios/cmd/cat/number/basic_n.yaml | 14 + .../scenarios/cmd/cat/number/blank_lines.yaml | 14 + .../cat/number_nonblank/b_overrides_n.yaml | 14 + .../cmd/cat/number_nonblank/basic_b.yaml | 14 + .../scenarios/cmd/cat/show_ends/basic_E.yaml | 14 + .../scenarios/cmd/cat/show_tabs/basic_T.yaml | 14 + tests/scenarios/cmd/cat/squeeze/basic_s.yaml | 14 + .../cmd/cat/squeeze/squeeze_with_number.yaml | 14 + 18 files changed, 1598 insertions(+), 17 deletions(-) create mode 100644 interp/builtins/cat/builtin_cat_pentest_test.go create mode 100644 interp/builtins/cat/cat_gnu_compat_test.go create mode 100644 interp/builtins/cat/cat_test.go create mode 100644 tests/scenarios/cmd/cat/combined/show_all_A.yaml create mode 100644 tests/scenarios/cmd/cat/combined/u_ignored.yaml create mode 100644 tests/scenarios/cmd/cat/hardening/double_dash_separator.yaml create mode 100644 tests/scenarios/cmd/cat/hardening/unknown_flag.yaml create mode 100644 tests/scenarios/cmd/cat/help/help_flag.yaml create mode 100644 tests/scenarios/cmd/cat/number/across_files.yaml create mode 100644 tests/scenarios/cmd/cat/number/basic_n.yaml create mode 100644 tests/scenarios/cmd/cat/number/blank_lines.yaml create mode 100644 tests/scenarios/cmd/cat/number_nonblank/b_overrides_n.yaml create mode 100644 tests/scenarios/cmd/cat/number_nonblank/basic_b.yaml create mode 100644 tests/scenarios/cmd/cat/show_ends/basic_E.yaml create mode 100644 tests/scenarios/cmd/cat/show_tabs/basic_T.yaml create mode 100644 tests/scenarios/cmd/cat/squeeze/basic_s.yaml create mode 100644 tests/scenarios/cmd/cat/squeeze/squeeze_with_number.yaml diff --git a/interp/builtins/cat/builtin_cat_pentest_test.go b/interp/builtins/cat/builtin_cat_pentest_test.go new file mode 100644 index 00000000..ec290f5e --- /dev/null +++ b/interp/builtins/cat/builtin_cat_pentest_test.go @@ -0,0 +1,288 @@ +// 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 cat_test + +import ( + "context" + "math" + "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/cat" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +const pentestTimeout = 10 * time.Second + +func catRun(t *testing.T, script, dir string, extraPaths ...string) (string, string, int) { + t.Helper() + paths := append([]string{dir}, extraPaths...) + return testutil.RunScript(t, script, dir, interp.AllowedPaths(paths)) +} + +func catRunCtx(ctx context.Context, t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return testutil.RunScriptCtx(ctx, t, script, dir, interp.AllowedPaths([]string{dir})) +} + +func mustNotHang(t *testing.T, fn func()) { + t.Helper() + done := make(chan struct{}) + go func() { + fn() + close(done) + }() + select { + case <-done: + case <-time.After(pentestTimeout): + t.Fatal("operation did not complete within timeout") + } +} + +// --- Flag and argument injection --- + +func TestCatPentestUnknownLongFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := catRun(t, "cat --no-such-flag file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatPentestUnknownShortFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := catRun(t, "cat -f file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatPentestFlagViaExpansion(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "ok\n") + _, stderr, code := catRun(t, `for flag in -f; do cat $flag file.txt; done`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatPentestDoubleDashThenFlagLikeFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "-n", "content\n") + stdout, _, code := catRun(t, "cat -- -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "content\n", stdout) +} + +func TestCatPentestMultipleStdinDash(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", "hello\n") + stdout, _, code := catRun(t, "cat - - < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +// --- Path and filename edge cases --- + +func TestCatPentestNonexistentFile(t *testing.T) { + dir := t.TempDir() + _, stderr, code := catRun(t, "cat does_not_exist", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatPentestDirectoryAsFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + _, stderr, code := catRun(t, "cat subdir", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatPentestEmptyStringFilename(t *testing.T) { + dir := t.TempDir() + _, stderr, code := catRun(t, `cat ""`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatPentestPathTraversal(t *testing.T) { + dir := t.TempDir() + outer := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outer, "secret.txt"), []byte("secret"), 0644)) + outerPath := strings.ReplaceAll(filepath.Join(outer, "secret.txt"), `\`, `/`) + _, stderr, code := catRun(t, "cat "+outerPath, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +// --- Long lines --- + +func TestCatPentestLineAtCapMinus1(t *testing.T) { + dir := t.TempDir() + content := make([]byte, cat.MaxLineBytes-1) + for i := range content { + content[i] = 'a' + } + content = append(content, '\n') + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), content, 0644)) + stdout, _, code := catRun(t, "cat -n file.txt", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, " 1\t") +} + +func TestCatPentestLineAtCapExact(t *testing.T) { + dir := t.TempDir() + content := make([]byte, cat.MaxLineBytes) + for i := range content { + content[i] = 'a' + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), content, 0644)) + _, _, code := catRun(t, "cat -n file.txt", dir) + assert.Equal(t, 1, code) +} + +func TestCatPentestLineAtCapPlus1(t *testing.T) { + dir := t.TempDir() + content := make([]byte, cat.MaxLineBytes+1) + for i := range content { + content[i] = 'a' + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), content, 0644)) + _, stderr, code := catRun(t, "cat -n file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +// --- Memory / resource exhaustion --- + +func TestCatPentestSmallFileRawMode(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "tiny.txt", "small\n") + mustNotHang(t, func() { + stdout, _, code := catRun(t, "cat tiny.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "small\n", stdout) + }) +} + +func TestCatPentestManyFiles(t *testing.T) { + dir := t.TempDir() + var args []string + for i := 0; i < 200; i++ { + name := "f" + strings.Repeat("0", 3-len(string(rune('0'+i/100)))) + string(rune('0'+i/100)) + string(rune('0'+i%100/10)) + string(rune('0'+i%10)) + ".txt" + writeFile(t, dir, name, "line\n") + args = append(args, name) + } + mustNotHang(t, func() { + _, _, code := catRun(t, "cat "+strings.Join(args, " "), dir) + assert.Equal(t, 0, code) + }) +} + +func TestCatPentestLargeFileNumbered(t *testing.T) { + dir := t.TempDir() + var sb strings.Builder + for i := 0; i < 10000; i++ { + sb.WriteString("line\n") + } + writeFile(t, dir, "big.txt", sb.String()) + mustNotHang(t, func() { + stdout, _, code := catRun(t, "cat -n big.txt", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, " 10000\t") + }) +} + +// --- Special files / infinite sources --- + +func TestCatPentestDevNull(t *testing.T) { + dir := t.TempDir() + mustNotHang(t, func() { + stdout, _, code := catRun(t, "cat "+os.DevNull, dir, filepath.Dir(os.DevNull)) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + }) +} + +func TestCatPentestContextCancelledDuringRaw(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", strings.Repeat("x\n", 10000)) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + mustNotHang(t, func() { + catRunCtx(ctx, t, "cat file.txt", dir) + }) +} + +func TestCatPentestContextCancelledDuringLines(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", strings.Repeat("x\n", 10000)) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + mustNotHang(t, func() { + catRunCtx(ctx, t, "cat -n file.txt", dir) + }) +} + +// --- Integer edge cases in line numbering --- + +func TestCatPentestLineNumberMaxInt(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello\n") + mustNotHang(t, func() { + stdout, _, code := catRun(t, "cat -n file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\thello\n", stdout) + }) +} + +func TestCatPentestLineNumberWidthOverflow(t *testing.T) { + _ = math.MaxInt64 + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello\n") + stdout, _, code := catRun(t, "cat -n file.txt", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, " 1\t") +} + +// --- Behavior matching --- + +func TestCatPentestSqueezeOnlyAffectsBlankLines(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n \n \nb\n") + stdout, _, code := catRun(t, "cat -s file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\n \n \nb\n", stdout) +} + +func TestCatPentestCRLFNotBlank(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n\r\n\r\nb\n") + stdout, _, code := catRun(t, "cat -s file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\n\r\n\r\nb\n", stdout) +} + +func TestCatPentestShowEndsCRLFNoV(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\rb\r\nc\n\r\nd\r") + stdout, _, code := catRun(t, "cat -E file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\rb^M$\nc$\n^M$\nd\r", stdout) +} + +func TestCatPentestShowEndsCRLFWithV(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\r\nb\n") + stdout, _, code := catRun(t, "cat -vE file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a^M$\nb$\n", stdout) +} diff --git a/interp/builtins/cat/cat.go b/interp/builtins/cat/cat.go index 2a759e22..b67cde2a 100644 --- a/interp/builtins/cat/cat.go +++ b/interp/builtins/cat/cat.go @@ -7,60 +7,381 @@ // // cat — concatenate and print files // -// Usage: cat [FILE]... +// Usage: cat [OPTION]... [FILE]... // // Concatenate FILE(s) to standard output. // With no FILE, or when FILE is -, read standard input. // +// Accepted flags: +// +// -n, --number +// Number all output lines, starting at 1. Line numbers are +// right-justified in a 6-character field followed by a tab. +// +// -b, --number-nonblank +// Number only non-blank output lines, starting at 1. Overrides -n. +// +// -s, --squeeze-blank +// Squeeze multiple consecutive blank lines into a single blank line. +// +// -E, --show-ends +// Display a $ character at the end of each line. +// +// -T, --show-tabs +// Display TAB characters as ^I. +// +// -v, --show-nonprinting +// Display non-printing characters using ^ and M- notation, except +// for line-feed and TAB. +// +// -A, --show-all +// Equivalent to -vET. +// +// -e +// Equivalent to -vE. +// +// -t +// Equivalent to -vT. +// +// -u +// Ignored (output is already unbuffered). Accepted for POSIX +// compatibility. +// +// -h, --help +// Print usage to stdout and exit 0. +// // Exit codes: // // 0 All files processed successfully. // 1 At least one error occurred (missing file, permission denied, etc.). +// +// Memory safety: +// +// All processing is streaming: input is read line-by-line with a per-line +// cap of MaxLineBytes (1 MiB). Lines exceeding this cap cause an error +// rather than an unbounded allocation. All read loops check ctx.Err() at +// each iteration to honour the shell's execution timeout and support +// graceful cancellation. package cat import ( + "bufio" "context" + "errors" "io" "os" + "github.com/spf13/pflag" + "github.com/DataDog/rshell/interp/builtins" ) // Cmd is the cat builtin command descriptor. var Cmd = builtins.Command{Name: "cat", Run: run} +// 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 + +const ( + rawBufSize = 32 * 1024 // read buffer for catRaw + scanBufInit = 4096 // initial scanner buffer + lineBufInit = 4096 // initial output-line buffer + lineNumWidth = 6 // GNU cat line-number field width +) + func run(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { - if len(args) == 0 { - args = []string{"-"} + fs := pflag.NewFlagSet("cat", pflag.ContinueOnError) + fs.SetOutput(io.Discard) + + help := fs.BoolP("help", "h", false, "print usage and exit") + number := fs.BoolP("number", "n", false, "number all output lines") + numberNonblank := fs.BoolP("number-nonblank", "b", false, "number non-blank output lines, overrides -n") + squeezeBlank := fs.BoolP("squeeze-blank", "s", false, "suppress repeated empty output lines") + showEnds := fs.BoolP("show-ends", "E", false, "display $ at end of each line") + showTabs := fs.BoolP("show-tabs", "T", false, "display TAB characters as ^I") + showNonprinting := fs.BoolP("show-nonprinting", "v", false, "use ^ and M- notation, except for LFD and TAB") + showAll := fs.BoolP("show-all", "A", false, "equivalent to -vET") + flagE := fs.BoolP("show-nonprinting-ends", "e", false, "equivalent to -vE") + flagT := fs.BoolP("show-nonprinting-tabs", "t", false, "equivalent to -vT") + _ = fs.BoolP("unbuffered", "u", false, "ignored") + + if err := fs.Parse(args); err != nil { + callCtx.Errf("cat: %v\n", err) + return builtins.Result{Code: 1} + } + + if *help { + callCtx.Out("Usage: cat [OPTION]... [FILE]...\n") + callCtx.Out("Concatenate FILE(s) 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{} + } + + if *showAll { + *showNonprinting = true + *showEnds = true + *showTabs = true + } + if *flagE { + *showNonprinting = true + *showEnds = true + } + if *flagT { + *showNonprinting = true + *showTabs = true + } + if *numberNonblank { + *number = false + } + + needsLineProcessing := *number || *numberNonblank || *squeezeBlank || + *showEnds || *showTabs || *showNonprinting + + files := fs.Args() + if len(files) == 0 { + files = []string{"-"} + } + + st := &state{ + number: *number, + numberNonblank: *numberNonblank, + squeezeBlank: *squeezeBlank, + showEnds: *showEnds, + showTabs: *showTabs, + showNonprinting: *showNonprinting, + lineNum: 1, } + var failed bool - for _, arg := range args { - if err := catFile(ctx, callCtx, arg); err != nil { - callCtx.Errf("cat: %s: %s\n", arg, callCtx.PortableErr(err)) + for _, file := range files { + if ctx.Err() != nil { + break + } + var err error + if needsLineProcessing { + err = catLines(ctx, callCtx, file, st) + } else { + err = catRaw(ctx, callCtx, file) + } + if err != nil { + name := file + if file == "-" { + name = "standard input" + } + callCtx.Errf("cat: %s: %s\n", name, callCtx.PortableErr(err)) failed = true } } + if failed { return builtins.Result{Code: 1} } return builtins.Result{} } -func catFile(ctx context.Context, callCtx *builtins.CallContext, path string) error { - var rc io.ReadCloser - if path == "-" { +type state struct { + number bool + numberNonblank bool + squeezeBlank bool + showEnds bool + showTabs bool + showNonprinting bool + lineNum int + prevBlank bool +} + +func openReader(ctx context.Context, callCtx *builtins.CallContext, file string) (io.ReadCloser, error) { + if file == "-" { if callCtx.Stdin == nil { + return nil, nil + } + return io.NopCloser(callCtx.Stdin), nil + } + return callCtx.OpenFile(ctx, file, os.O_RDONLY, 0) +} + +// catRaw streams a file to stdout with bounded reads and context checking. +// Used when no line-processing flags are active. +func catRaw(ctx context.Context, callCtx *builtins.CallContext, file string) error { + rc, err := openReader(ctx, callCtx, file) + if err != nil { + return err + } + if rc == nil { + return nil + } + defer rc.Close() + + buf := make([]byte, rawBufSize) + for { + if ctx.Err() != nil { + return ctx.Err() + } + n, readErr := rc.Read(buf) + if n > 0 { + if _, werr := callCtx.Stdout.Write(buf[:n]); werr != nil { + return werr + } + } + if errors.Is(readErr, io.EOF) { return nil } - rc = io.NopCloser(callCtx.Stdin) - } else { - f, err := callCtx.OpenFile(ctx, path, os.O_RDONLY, 0) - if err != nil { - return err + if readErr != nil { + return readErr } - rc = f + } +} + +// catLines reads a file line-by-line, applying display transformations. +func catLines(ctx context.Context, callCtx *builtins.CallContext, file string, st *state) error { + rc, err := openReader(ctx, callCtx, file) + if err != nil { + return err + } + if rc == nil { + return nil } defer rc.Close() - _, err := io.Copy(callCtx.Stdout, rc) - return err + + sc := bufio.NewScanner(rc) + buf := make([]byte, scanBufInit) + sc.Buffer(buf, MaxLineBytes) + sc.Split(scanLinesPreservingNewline) + + out := make([]byte, 0, lineBufInit) + + for sc.Scan() { + if ctx.Err() != nil { + return ctx.Err() + } + + line := sc.Bytes() + content, term := splitTerminator(line) + hasTerm := len(term) > 0 + blank := len(content) == 0 && hasTerm + + if st.squeezeBlank && blank && st.prevBlank { + continue + } + st.prevBlank = blank + + out = out[:0] + + if (st.numberNonblank && !blank) || st.number { + out = appendNumber(out, st.lineNum) + st.lineNum++ + } + + // GNU cat -E converts \r immediately before \n to ^M to prevent + // the terminal cursor from overwriting the $ marker. When -v is + // active the \r is already converted by appendNonprinting. + hasCRLF := st.showEnds && !st.showNonprinting && + len(content) > 0 && content[len(content)-1] == '\r' && hasTerm + + processLen := len(content) + if hasCRLF { + processLen-- + } + + for _, b := range content[:processLen] { + if st.showTabs && b == '\t' { + out = append(out, '^', 'I') + } else if st.showNonprinting { + out = appendNonprinting(out, b) + } else { + out = append(out, b) + } + } + + if hasCRLF { + out = append(out, '^', 'M') + } + + if st.showEnds && hasTerm { + out = append(out, '$') + } + + out = append(out, term...) + + if _, werr := callCtx.Stdout.Write(out); werr != nil { + return werr + } + } + return sc.Err() +} + +// splitTerminator separates a scanner token into the content portion and +// the line terminator (\n), if present. +func splitTerminator(line []byte) (content, term []byte) { + n := len(line) + if n > 0 && line[n-1] == '\n' { + return line[:n-1], line[n-1:] + } + return line, nil +} + +// appendNonprinting encodes a single byte in ^ and M- notation. +// TAB and LF pass through unchanged (they have their own flags). +func appendNonprinting(out []byte, b byte) []byte { + switch { + case b == '\t': + return append(out, '\t') + case b == '\n': + return append(out, '\n') + case b < 32: + return append(out, '^', b+64) + case b < 127: + return append(out, b) + case b == 127: + return append(out, '^', '?') + case b < 128+32: + return append(out, 'M', '-', '^', b-128+64) + case b < 128+127: + return append(out, 'M', '-', b-128) + default: // 255 + return append(out, 'M', '-', '^', '?') + } +} + +// appendNumber formats n as a right-justified field of lineNumWidth +// characters followed by a tab, matching the GNU cat line-number format. +func appendNumber(out []byte, n int) []byte { + var digits [20]byte + pos := len(digits) + v := n + if v <= 0 { + pos-- + digits[pos] = '0' + } + for v > 0 { + pos-- + digits[pos] = byte('0' + v%10) + v /= 10 + } + for i := len(digits) - pos; i < lineNumWidth; i++ { + out = append(out, ' ') + } + out = append(out, digits[pos:]...) + return append(out, '\t') +} + +// 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. +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 { + return len(data), data, nil + } + return 0, nil, nil } diff --git a/interp/builtins/cat/cat_gnu_compat_test.go b/interp/builtins/cat/cat_gnu_compat_test.go new file mode 100644 index 00000000..9d3b61d0 --- /dev/null +++ b/interp/builtins/cat/cat_gnu_compat_test.go @@ -0,0 +1,253 @@ +// 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 cat_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupCatDir(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) + } + return dir +} + +func setupCatDirBytes(t *testing.T, files map[string][]byte) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), content, 0644)) + } + return dir +} + +// TestGNUCompatCatPlain — plain cat outputs file contents verbatim. +// +// GNU command: gcat five.txt +// Expected: "alpha\nbeta\ngamma\ndelta\nepsilon\n" +func TestGNUCompatCatPlain(t *testing.T) { + dir := setupCatDir(t, map[string]string{"five.txt": fiveLines}) + stdout, _, code := cmdRun(t, "cat five.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, stdout) +} + +// TestGNUCompatCatNumberN — -n numbers all lines. +// +// GNU command: gcat -n three.txt (three.txt = "a\nb\nc\n") +// Expected: " 1\ta\n 2\tb\n 3\tc\n" +func TestGNUCompatCatNumberN(t *testing.T) { + dir := setupCatDir(t, map[string]string{"three.txt": "a\nb\nc\n"}) + stdout, _, code := cmdRun(t, "cat -n three.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n 2\tb\n 3\tc\n", stdout) +} + +// TestGNUCompatCatNumberNWithBlanks — -n numbers blank lines too. +// +// GNU command: gcat -n blanks.txt (blanks.txt = "a\n\nb\n") +// Expected: " 1\ta\n 2\t\n 3\tb\n" +func TestGNUCompatCatNumberNWithBlanks(t *testing.T) { + dir := setupCatDir(t, map[string]string{"blanks.txt": "a\n\nb\n"}) + stdout, _, code := cmdRun(t, "cat -n blanks.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n 2\t\n 3\tb\n", stdout) +} + +// TestGNUCompatCatNumberNonblankB — -b skips blank lines. +// +// GNU command: gcat -b blanks.txt (blanks.txt = "a\n\nb\n") +// Expected: " 1\ta\n\n 2\tb\n" +func TestGNUCompatCatNumberNonblankB(t *testing.T) { + dir := setupCatDir(t, map[string]string{"blanks.txt": "a\n\nb\n"}) + stdout, _, code := cmdRun(t, "cat -b blanks.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n\n 2\tb\n", stdout) +} + +// TestGNUCompatCatSqueezeBlankS — -s squeezes consecutive blank lines. +// +// GNU command: gcat -s squeeze.txt (squeeze.txt = "a\n\n\n\nb\n") +// Expected: "a\n\nb\n" +func TestGNUCompatCatSqueezeBlankS(t *testing.T) { + dir := setupCatDir(t, map[string]string{"squeeze.txt": "a\n\n\n\nb\n"}) + stdout, _, code := cmdRun(t, "cat -s squeeze.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\n\nb\n", stdout) +} + +// TestGNUCompatCatShowEndsE — -E displays $ at end of each line. +// +// GNU command: gcat -E ends.txt (ends.txt = "alpha\nbeta\n") +// Expected: "alpha$\nbeta$\n" +func TestGNUCompatCatShowEndsE(t *testing.T) { + dir := setupCatDir(t, map[string]string{"ends.txt": "alpha\nbeta\n"}) + stdout, _, code := cmdRun(t, "cat -E ends.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha$\nbeta$\n", stdout) +} + +// TestGNUCompatCatShowEndsCRLF — -E converts \r before \n to ^M. +// +// GNU command: printf 'a\r\nb\n' | gcat -E +// Expected: "a^M$\nb$\n" +func TestGNUCompatCatShowEndsCRLF(t *testing.T) { + dir := setupCatDir(t, map[string]string{"crlf.txt": "a\r\nb\n"}) + stdout, _, code := cmdRun(t, "cat -E crlf.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a^M$\nb$\n", stdout) +} + +// TestGNUCompatCatShowTabsT — -T displays TAB as ^I. +// +// GNU command: gcat -T tabs.txt (tabs.txt = "a\tb\n") +// Expected: "a^Ib\n" +func TestGNUCompatCatShowTabsT(t *testing.T) { + dir := setupCatDir(t, map[string]string{"tabs.txt": "a\tb\n"}) + stdout, _, code := cmdRun(t, "cat -T tabs.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a^Ib\n", stdout) +} + +// TestGNUCompatCatShowNonprintingV — -v shows control chars. +// +// GNU command: printf '\x00\x01\x1f\n' | gcat -v +// Expected: "^@^A^_\n" +func TestGNUCompatCatShowNonprintingV(t *testing.T) { + dir := setupCatDirBytes(t, map[string][]byte{"ctrl.bin": {0x00, 0x01, 0x1f, '\n'}}) + stdout, _, code := cmdRun(t, "cat -v ctrl.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^@^A^_\n", stdout) +} + +// TestGNUCompatCatShowNonprintingHighBytes — -v shows M- notation for high bytes. +// +// GNU command: printf '\x80\x9f\xa0\xfe\xff\n' | gcat -v +// Expected: "M-^@M-^_M- M-~M-^?\n" +func TestGNUCompatCatShowNonprintingHighBytes(t *testing.T) { + dir := setupCatDirBytes(t, map[string][]byte{"high.bin": {0x80, 0x9f, 0xa0, 0xfe, 0xff, '\n'}}) + stdout, _, code := cmdRun(t, "cat -v high.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "M-^@M-^_M- M-~M-^?\n", stdout) +} + +// TestGNUCompatCatShowAllA — -A is equivalent to -vET. +// +// GNU command: printf '\x00\ta\n' | gcat -A +// Expected: "^@^Ia$\n" +func TestGNUCompatCatShowAllA(t *testing.T) { + dir := setupCatDirBytes(t, map[string][]byte{"all.bin": {0x00, '\t', 'a', '\n'}}) + stdout, _, code := cmdRun(t, "cat -A all.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^@^Ia$\n", stdout) +} + +// TestGNUCompatCatEmptyFile — empty file produces no output. +// +// GNU command: gcat empty.txt (empty.txt is 0 bytes) +// Expected: "" +func TestGNUCompatCatEmptyFile(t *testing.T) { + dir := setupCatDir(t, map[string]string{"empty.txt": ""}) + stdout, _, code := cmdRun(t, "cat empty.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +// TestGNUCompatCatNoTrailingNewline — file without trailing newline. +// +// GNU command: printf 'no newline' | gcat +// Expected: "no newline" +func TestGNUCompatCatNoTrailingNewline(t *testing.T) { + dir := setupCatDir(t, map[string]string{"noterm.txt": "no newline"}) + stdout, _, code := cmdRun(t, "cat noterm.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "no newline", stdout) +} + +// TestGNUCompatCatShowEndsNoTrailingNewline — -E with no trailing newline. +// +// GNU command: printf 'hello' | gcat -E +// Expected: "hello" (no $ because no newline) +func TestGNUCompatCatShowEndsNoTrailingNewline(t *testing.T) { + dir := setupCatDir(t, map[string]string{"noterm.txt": "hello"}) + stdout, _, code := cmdRun(t, "cat -E noterm.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "hello", stdout) +} + +// TestGNUCompatCatVPreservesTab — -v does not convert TAB. +// +// GNU command: printf 'a\tb\n' | gcat -v +// Expected: "a\tb\n" +func TestGNUCompatCatVPreservesTab(t *testing.T) { + dir := setupCatDir(t, map[string]string{"tabs.txt": "a\tb\n"}) + stdout, _, code := cmdRun(t, "cat -v tabs.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\tb\n", stdout) +} + +// TestGNUCompatCatBOverridesN — -b overrides -n regardless of order. +// +// GNU command: gcat -n -b blanks.txt +// Expected: " 1\ta\n\n 2\tb\n" +func TestGNUCompatCatBOverridesN(t *testing.T) { + dir := setupCatDir(t, map[string]string{"blanks.txt": "a\n\nb\n"}) + stdout, _, code := cmdRun(t, "cat -n -b blanks.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n\n 2\tb\n", stdout) +} + +// TestGNUCompatCatFlagEComposite — -e enables -v and -E. +// +// GNU command: printf '\x00a\n' | gcat -e +// Expected: "^@a$\n" +func TestGNUCompatCatFlagEComposite(t *testing.T) { + dir := setupCatDirBytes(t, map[string][]byte{"file.bin": {0x00, 'a', '\n'}}) + stdout, _, code := cmdRun(t, "cat -e file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^@a$\n", stdout) +} + +// TestGNUCompatCatFlagTComposite — -t enables -v and -T. +// +// GNU command: printf '\x00\t\n' | gcat -t +// Expected: "^@^I\n" +func TestGNUCompatCatFlagTComposite(t *testing.T) { + dir := setupCatDirBytes(t, map[string][]byte{"file.bin": {0x00, '\t', '\n'}}) + stdout, _, code := cmdRun(t, "cat -t file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^@^I\n", stdout) +} + +// TestGNUCompatCatNumberAcrossFiles — line numbers continue across files. +// +// GNU command: gcat -n a.txt b.txt (a.txt="one\n", b.txt="two\nthree\n") +// Expected: " 1\tone\n 2\ttwo\n 3\tthree\n" +func TestGNUCompatCatNumberAcrossFiles(t *testing.T) { + dir := setupCatDir(t, map[string]string{"a.txt": "one\n", "b.txt": "two\nthree\n"}) + stdout, _, code := cmdRun(t, "cat -n a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\tone\n 2\ttwo\n 3\tthree\n", stdout) +} + +// TestGNUCompatCatSqueezeWithNumber — -sn squeezes before numbering. +// +// GNU command: gcat -sn squeeze.txt (squeeze.txt = "a\n\n\nb\n") +// Expected: " 1\ta\n 2\t\n 3\tb\n" +func TestGNUCompatCatSqueezeWithNumber(t *testing.T) { + dir := setupCatDir(t, map[string]string{"squeeze.txt": "a\n\n\nb\n"}) + stdout, _, code := cmdRun(t, "cat -sn squeeze.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n 2\t\n 3\tb\n", stdout) +} diff --git a/interp/builtins/cat/cat_test.go b/interp/builtins/cat/cat_test.go new file mode 100644 index 00000000..efa19b5e --- /dev/null +++ b/interp/builtins/cat/cat_test.go @@ -0,0 +1,532 @@ +// 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 cat_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" +) + +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...) +} + +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, dir, opts...) +} + +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +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 +} + +const fiveLines = "alpha\nbeta\ngamma\ndelta\nepsilon\n" + +// --- Basic (no flags) --- + +func TestCatSingleFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "cat file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, stdout) +} + +func TestCatMultipleFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "hello\n") + writeFile(t, dir, "b.txt", "world\n") + stdout, _, code := cmdRun(t, "cat a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\nworld\n", stdout) +} + +func TestCatEmptyFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "empty.txt", "") + stdout, _, code := cmdRun(t, "cat empty.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestCatNoTrailingNewline(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "no newline") + stdout, _, code := cmdRun(t, "cat file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "no newline", stdout) +} + +func TestCatStdinDash(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", "hello\n") + stdout, _, code := cmdRun(t, "cat - < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +func TestCatStdinImplicit(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", fiveLines) + stdout, _, code := cmdRun(t, "cat < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, fiveLines, stdout) +} + +func TestCatNilStdin(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := runScript(t, "cat", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} + +// --- -n / --number --- + +func TestCatNumberLines(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "alpha\nbeta\ngamma\n") + stdout, _, code := cmdRun(t, "cat -n file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\talpha\n 2\tbeta\n 3\tgamma\n", stdout) +} + +func TestCatNumberLinesLongForm(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "alpha\nbeta\n") + stdout, _, code := cmdRun(t, "cat --number file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\talpha\n 2\tbeta\n", stdout) +} + +func TestCatNumberBlankLines(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n\nb\n") + stdout, _, code := cmdRun(t, "cat -n file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n 2\t\n 3\tb\n", stdout) +} + +func TestCatNumberNoTrailingNewline(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "line1\nline2") + stdout, _, code := cmdRun(t, "cat -n file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\tline1\n 2\tline2", stdout) +} + +func TestCatNumberAcrossFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "one\n") + writeFile(t, dir, "b.txt", "two\nthree\n") + stdout, _, code := cmdRun(t, "cat -n a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\tone\n 2\ttwo\n 3\tthree\n", stdout) +} + +func TestCatNumberEmptyFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "empty.txt", "") + stdout, _, code := cmdRun(t, "cat -n empty.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +// --- -b / --number-nonblank --- + +func TestCatNumberNonblank(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "alpha\n\nbeta\n\ngamma\n") + stdout, _, code := cmdRun(t, "cat -b file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\talpha\n\n 2\tbeta\n\n 3\tgamma\n", stdout) +} + +func TestCatNumberNonblankOverridesNumber(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n\nb\n") + stdout, _, code := cmdRun(t, "cat -n -b file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n\n 2\tb\n", stdout) +} + +func TestCatNumberNonblankOverridesNumberReversed(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n\nb\n") + stdout, _, code := cmdRun(t, "cat -b -n file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n\n 2\tb\n", stdout) +} + +// --- -s / --squeeze-blank --- + +func TestCatSqueezeBlank(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n\n\n\nb\n\n\nc\n") + stdout, _, code := cmdRun(t, "cat -s file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\n\nb\n\nc\n", stdout) +} + +func TestCatSqueezeBlankWithNumber(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n\n\nb\n") + stdout, _, code := cmdRun(t, "cat -sn file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n 2\t\n 3\tb\n", stdout) +} + +func TestCatSqueezeBlankWithNumberNonblank(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n\n\nb\n") + stdout, _, code := cmdRun(t, "cat -sb file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n\n 2\tb\n", stdout) +} + +func TestCatSqueezeAcrossFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "x\n\n") + writeFile(t, dir, "b.txt", "\ny\n") + stdout, _, code := cmdRun(t, "cat -s a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "x\n\ny\n", stdout) +} + +// --- -E / --show-ends --- + +func TestCatShowEnds(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "alpha\nbeta\n") + stdout, _, code := cmdRun(t, "cat -E file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "alpha$\nbeta$\n", stdout) +} + +func TestCatShowEndsNoTrailingNewline(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello") + stdout, _, code := cmdRun(t, "cat -E file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "hello", stdout) +} + +func TestCatShowEndsCRLF(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\r\nb\n") + stdout, _, code := cmdRun(t, "cat -E file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a^M$\nb$\n", stdout) +} + +func TestCatShowEndsEmptyLines(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n\nb\n") + stdout, _, code := cmdRun(t, "cat -E file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a$\n$\nb$\n", stdout) +} + +// --- -T / --show-tabs --- + +func TestCatShowTabs(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\tb\n") + stdout, _, code := cmdRun(t, "cat -T file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a^Ib\n", stdout) +} + +func TestCatShowTabsNoNewline(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "\thello") + stdout, _, code := cmdRun(t, "cat -T file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^Ihello", stdout) +} + +// --- -v / --show-nonprinting --- + +func TestCatShowNonprinting(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.bin"), []byte{0x00, 0x01, 0x1f, '\n'}, 0644)) + stdout, _, code := cmdRun(t, "cat -v file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^@^A^_\n", stdout) +} + +func TestCatShowNonprintingDEL(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.bin"), []byte{0x7f, '\n'}, 0644)) + stdout, _, code := cmdRun(t, "cat -v file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^?\n", stdout) +} + +func TestCatShowNonprintingHighBytes(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.bin"), []byte{0x80, 0x9f, 0xa0, 0xfe, 0xff, '\n'}, 0644)) + stdout, _, code := cmdRun(t, "cat -v file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "M-^@M-^_M- M-~M-^?\n", stdout) +} + +func TestCatShowNonprintingPreservesTab(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\tb\n") + stdout, _, code := cmdRun(t, "cat -v file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\tb\n", stdout) +} + +// --- -A / --show-all --- + +func TestCatShowAll(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.bin"), []byte{0x00, '\t', 'a', '\n'}, 0644)) + stdout, _, code := cmdRun(t, "cat -A file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^@^Ia$\n", stdout) +} + +// --- -e (equivalent to -vE) --- + +func TestCatFlagE(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.bin"), []byte{0x00, 'a', '\n'}, 0644)) + stdout, _, code := cmdRun(t, "cat -e file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^@a$\n", stdout) +} + +func TestCatFlagEPreservesTab(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "\ta\n") + stdout, _, code := cmdRun(t, "cat -e file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "\ta$\n", stdout) +} + +// --- -t (equivalent to -vT) --- + +func TestCatFlagT(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.bin"), []byte{0x00, '\t', '\n'}, 0644)) + stdout, _, code := cmdRun(t, "cat -t file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "^@^I\n", stdout) +} + +// --- -u (ignored) --- + +func TestCatUIgnored(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello\n") + stdout, _, code := cmdRun(t, "cat -u file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +// --- Combined flags --- + +func TestCatCombinedSNB(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\n\n\n\nb\n") + stdout, _, code := cmdRun(t, "cat -snb file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta\n\n 2\tb\n", stdout) +} + +func TestCatNumberedShowEndsShowTabs(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "a\tb\n") + stdout, _, code := cmdRun(t, "cat -nET file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\ta^Ib$\n", stdout) +} + +// --- Help --- + +func TestCatHelp(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "cat --help", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") + assert.Contains(t, stdout, "--number") + assert.Empty(t, stderr) +} + +func TestCatHelpShort(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "cat -h", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") + assert.Empty(t, stderr) +} + +// --- Error cases --- + +func TestCatMissingFile(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cat nonexistent.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + _, stderr, code := cmdRun(t, "cat subdir", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatUnknownFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cat --follow file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatUnknownShortFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cat -f file.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatMultipleFilesSomeFailSomeSucceed(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "good.txt", "hello\n") + stdout, stderr, code := cmdRun(t, "cat good.txt nonexistent.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stdout, "hello") + assert.Contains(t, stderr, "cat:") +} + +func TestCatOutsideAllowedPaths(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, "cat "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +// --- RULES.md compliance --- + +func TestCatDoubleDash(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "-n", "flag-looking-name\n") + stdout, _, code := cmdRun(t, "cat -- -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "flag-looking-name\n", stdout) +} + +func TestCatNullBytesPassthrough(t *testing.T) { + dir := t.TempDir() + content := "a\x00b\x00c\n" + writeFile(t, dir, "file.bin", content) + stdout, _, code := cmdRun(t, "cat file.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, content, stdout) +} + +func TestCatCRLFPreserved(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "line1\r\nline2\r\n") + stdout, _, code := cmdRun(t, "cat file.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "line1\r\nline2\r\n", stdout) +} + +func TestCatBadUTF8Passthrough(t *testing.T) { + dir := t.TempDir() + content := []byte{0xfc, 0x80, 0x80, 0x80, 0x80, 0xaf, '\n'} + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.bin"), content, 0644)) + stdout, _, code := cmdRun(t, "cat bad.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(content), stdout) +} + +func TestCatLineModeLineBeyondCap(t *testing.T) { + dir := t.TempDir() + content := make([]byte, 1<<20+1) + for i := range content { + content[i] = 'a' + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "huge.txt"), content, 0644)) + _, stderr, code := cmdRun(t, "cat -n huge.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") +} + +func TestCatLineModeLineBelowCap(t *testing.T) { + dir := t.TempDir() + 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, "cat -n large.txt", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, " 1\t") +} + +func TestCatContextCancellation(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, code := runScriptCtx(ctx, t, "cat file.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) +} + +func TestCatContextPreCancelled(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan struct{}) + go func() { + runScriptCtx(ctx, t, "cat file.txt", dir, interp.AllowedPaths([]string{dir})) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("cat with pre-cancelled context did not return within 5s") + } +} + +func TestCatPipeInput(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", fiveLines) + stdout, _, code := cmdRun(t, "cat file.txt | cat -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, " 1\talpha\n 2\tbeta\n 3\tgamma\n 4\tdelta\n 5\tepsilon\n", stdout) +} diff --git a/tests/scenarios/cmd/cat/combined/show_all_A.yaml b/tests/scenarios/cmd/cat/combined/show_all_A.yaml new file mode 100644 index 00000000..5cdbf831 --- /dev/null +++ b/tests/scenarios/cmd/cat/combined/show_all_A.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_stdin_show_all +description: cat -A is equivalent to -vET (shows all non-printing, tabs, and ends). +setup: + files: + - path: file.txt + content: "\thello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -A file.txt +expect: + stdout: "^Ihello$\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/combined/u_ignored.yaml b/tests/scenarios/cmd/cat/combined/u_ignored.yaml new file mode 100644 index 00000000..82aa82f8 --- /dev/null +++ b/tests/scenarios/cmd/cat/combined/u_ignored.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_u_ignored +description: cat -u is accepted and silently ignored (POSIX compatibility). +setup: + files: + - path: file.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -u file.txt +expect: + stdout: "hello\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/hardening/double_dash_separator.yaml b/tests/scenarios/cmd/cat/hardening/double_dash_separator.yaml new file mode 100644 index 00000000..00bd6741 --- /dev/null +++ b/tests/scenarios/cmd/cat/hardening/double_dash_separator.yaml @@ -0,0 +1,13 @@ +description: cat -- treats flag-like filenames as files. +setup: + files: + - path: "-n" + content: "flag-looking-name\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -- -n +expect: + stdout: "flag-looking-name\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/hardening/unknown_flag.yaml b/tests/scenarios/cmd/cat/hardening/unknown_flag.yaml new file mode 100644 index 00000000..ea7ec61b --- /dev/null +++ b/tests/scenarios/cmd/cat/hardening/unknown_flag.yaml @@ -0,0 +1,9 @@ +description: cat rejects unknown flags with exit code 1. +skip_assert_against_bash: true +input: + script: |+ + cat --follow file.txt +expect: + stdout: "" + stderr_contains: ["cat:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/cat/help/help_flag.yaml b/tests/scenarios/cmd/cat/help/help_flag.yaml new file mode 100644 index 00000000..2f19a2d5 --- /dev/null +++ b/tests/scenarios/cmd/cat/help/help_flag.yaml @@ -0,0 +1,9 @@ +description: cat --help prints usage to stdout and exits 0. +skip_assert_against_bash: true +input: + script: |+ + cat --help +expect: + stdout_contains: ["Usage:"] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/number/across_files.yaml b/tests/scenarios/cmd/cat/number/across_files.yaml new file mode 100644 index 00000000..e769b501 --- /dev/null +++ b/tests/scenarios/cmd/cat/number/across_files.yaml @@ -0,0 +1,16 @@ +# Derived from uutils test_cat.rs::test_numbered_lines_no_trailing_newline +description: cat -n continues line numbers across multiple files. +setup: + files: + - path: a.txt + content: "one\n" + - path: b.txt + content: "two\nthree\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -n a.txt b.txt +expect: + stdout: " 1\tone\n 2\ttwo\n 3\tthree\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/number/basic_n.yaml b/tests/scenarios/cmd/cat/number/basic_n.yaml new file mode 100644 index 00000000..f1646d25 --- /dev/null +++ b/tests/scenarios/cmd/cat/number/basic_n.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_numbered_lines_no_trailing_newline +description: cat -n numbers all output lines. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -n file.txt +expect: + stdout: " 1\talpha\n 2\tbeta\n 3\tgamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/number/blank_lines.yaml b/tests/scenarios/cmd/cat/number/blank_lines.yaml new file mode 100644 index 00000000..8c39a000 --- /dev/null +++ b/tests/scenarios/cmd/cat/number/blank_lines.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_numbered_lines_no_trailing_newline +description: cat -n numbers blank lines too. +setup: + files: + - path: file.txt + content: "a\n\nb\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -n file.txt +expect: + stdout: " 1\ta\n 2\t\n 3\tb\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/number_nonblank/b_overrides_n.yaml b/tests/scenarios/cmd/cat/number_nonblank/b_overrides_n.yaml new file mode 100644 index 00000000..ee6cf38c --- /dev/null +++ b/tests/scenarios/cmd/cat/number_nonblank/b_overrides_n.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_non_blank_overrides_number +description: cat -b overrides -n (only non-blank lines are numbered). +setup: + files: + - path: file.txt + content: "a\n\nb\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -n -b file.txt +expect: + stdout: " 1\ta\n\n 2\tb\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/number_nonblank/basic_b.yaml b/tests/scenarios/cmd/cat/number_nonblank/basic_b.yaml new file mode 100644 index 00000000..ceb2f576 --- /dev/null +++ b/tests/scenarios/cmd/cat/number_nonblank/basic_b.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_stdin_number_non_blank +description: cat -b numbers only non-blank output lines. +setup: + files: + - path: file.txt + content: "alpha\n\nbeta\n\ngamma\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -b file.txt +expect: + stdout: " 1\talpha\n\n 2\tbeta\n\n 3\tgamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/show_ends/basic_E.yaml b/tests/scenarios/cmd/cat/show_ends/basic_E.yaml new file mode 100644 index 00000000..ba4f142c --- /dev/null +++ b/tests/scenarios/cmd/cat/show_ends/basic_E.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_stdin_show_ends +description: cat -E displays $ at end of each line. +setup: + files: + - path: file.txt + content: "alpha\nbeta\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -E file.txt +expect: + stdout: "alpha$\nbeta$\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/show_tabs/basic_T.yaml b/tests/scenarios/cmd/cat/show_tabs/basic_T.yaml new file mode 100644 index 00000000..74cf4830 --- /dev/null +++ b/tests/scenarios/cmd/cat/show_tabs/basic_T.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_stdin_show_tabs +description: cat -T displays TAB characters as ^I. +setup: + files: + - path: file.txt + content: "a\tb\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -T file.txt +expect: + stdout: "a^Ib\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/squeeze/basic_s.yaml b/tests/scenarios/cmd/cat/squeeze/basic_s.yaml new file mode 100644 index 00000000..d37cd8db --- /dev/null +++ b/tests/scenarios/cmd/cat/squeeze/basic_s.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_stdin_squeeze_blank +description: cat -s squeezes consecutive blank lines into one. +setup: + files: + - path: file.txt + content: "a\n\n\n\nb\n\n\nc\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -s file.txt +expect: + stdout: "a\n\nb\n\nc\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/squeeze/squeeze_with_number.yaml b/tests/scenarios/cmd/cat/squeeze/squeeze_with_number.yaml new file mode 100644 index 00000000..a47eb113 --- /dev/null +++ b/tests/scenarios/cmd/cat/squeeze/squeeze_with_number.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_cat.rs::test_squeeze_blank_before_numbering +description: cat -sn squeezes blank lines before numbering. +setup: + files: + - path: file.txt + content: "a\n\n\nb\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat -sn file.txt +expect: + stdout: " 1\ta\n 2\t\n 3\tb\n" + stderr: "" + exit_code: 0 From 413f8cb3e427c1704f1a3dba6c9892927f8c25de Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 12:22:26 +0100 Subject: [PATCH 2/2] Fix TestCatPentestDevNull on Windows: assert sandbox blocks reserved device names Co-Authored-By: Claude Opus 4.6 --- interp/builtins/cat/builtin_cat_pentest_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/interp/builtins/cat/builtin_cat_pentest_test.go b/interp/builtins/cat/builtin_cat_pentest_test.go index ec290f5e..03e2d5c4 100644 --- a/interp/builtins/cat/builtin_cat_pentest_test.go +++ b/interp/builtins/cat/builtin_cat_pentest_test.go @@ -204,6 +204,17 @@ func TestCatPentestLargeFileNumbered(t *testing.T) { // --- Special files / infinite sources --- func TestCatPentestDevNull(t *testing.T) { + if os.DevNull == "NUL" { + // Windows reserved device names (NUL, CON, etc.) are blocked by the + // sandbox to prevent hangs — same as head's TestHeadWindowsReservedName. + dir := t.TempDir() + mustNotHang(t, func() { + _, stderr, code := catRun(t, "cat NUL", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") + }) + return + } dir := t.TempDir() mustNotHang(t, func() { stdout, _, code := catRun(t, "cat "+os.DevNull, dir, filepath.Dir(os.DevNull))