From 054f2637c1839b75184233a96872af0aa0cebf3b Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Thu, 12 Mar 2026 11:23:03 -0400 Subject: [PATCH 1/2] Add native Go fuzz tests for builtin commands Adds testing.F fuzz tests for head, cat, wc, tail, and grep builtins. Each command gets seed corpus covering empty input, no trailing newline, NUL bytes, buffer boundaries (4097 bytes), very long single lines, and all-newlines input. Fuzz functions use context.WithTimeout to catch hangs, assert exit codes are 0 or 1, and verify output invariants (e.g. head -n K produces at most K lines). Both file-based and stdin-via-redirection variants are included for each command. Co-Authored-By: Claude Sonnet 4.6 --- interp/builtins/tests/cat/cat_fuzz_test.go | 128 +++++++++++++++ interp/builtins/tests/grep/grep_fuzz_test.go | 150 ++++++++++++++++++ interp/builtins/tests/head/head_fuzz_test.go | 156 +++++++++++++++++++ interp/builtins/tests/tail/tail_fuzz_test.go | 156 +++++++++++++++++++ interp/builtins/tests/wc/wc_fuzz_test.go | 148 ++++++++++++++++++ 5 files changed, 738 insertions(+) create mode 100644 interp/builtins/tests/cat/cat_fuzz_test.go create mode 100644 interp/builtins/tests/grep/grep_fuzz_test.go create mode 100644 interp/builtins/tests/head/head_fuzz_test.go create mode 100644 interp/builtins/tests/tail/tail_fuzz_test.go create mode 100644 interp/builtins/tests/wc/wc_fuzz_test.go diff --git a/interp/builtins/tests/cat/cat_fuzz_test.go b/interp/builtins/tests/cat/cat_fuzz_test.go new file mode 100644 index 00000000..6fcf16ce --- /dev/null +++ b/interp/builtins/tests/cat/cat_fuzz_test.go @@ -0,0 +1,128 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package cat_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/DataDog/rshell/interp" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +func cmdRunCtx(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})) +} + +// FuzzCat fuzzes cat with arbitrary file content and verifies output equals input. +func FuzzCat(f *testing.F) { + f.Add([]byte("hello\nworld\n")) + f.Add([]byte{}) + f.Add([]byte("no newline")) + f.Add([]byte("a\x00b\n")) + f.Add(bytes.Repeat([]byte("x"), 4097)) + f.Add([]byte("\n\n\n")) + f.Add(bytes.Repeat([]byte("y"), 4096)) + f.Add([]byte{0xff, 0xfe, 0x00, 0x01}) + + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 1<<20 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stdout, _, code := cmdRunCtx(ctx, t, "cat input.txt", dir) + if code != 0 && code != 1 { + t.Errorf("unexpected exit code %d", code) + } + + // cat must output exactly the file contents + if code == 0 && stdout != string(input) { + t.Errorf("cat output differs from input: got %d bytes, want %d bytes", len(stdout), len(input)) + } + }) +} + +// FuzzCatNumberLines fuzzes cat -n with arbitrary file content. +func FuzzCatNumberLines(f *testing.F) { + f.Add([]byte("line1\nline2\n")) + f.Add([]byte{}) + f.Add([]byte("no newline")) + f.Add([]byte("a\x00b\nc\n")) + f.Add([]byte("\n\n\n")) + + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 1<<20 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, "cat -n input.txt", dir) + if code != 0 && code != 1 { + t.Errorf("cat -n unexpected exit code %d", code) + } + }) +} + +// FuzzCatStdin fuzzes cat reading from stdin via shell redirection. +func FuzzCatStdin(f *testing.F) { + f.Add([]byte("hello\nworld\n")) + f.Add([]byte{}) + f.Add([]byte("no newline")) + f.Add([]byte("a\x00b\n")) + f.Add(bytes.Repeat([]byte("x"), 4097)) + f.Add([]byte("\n\n\n")) + + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 1<<20 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "stdin.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stdout, _, code := cmdRunCtx(ctx, t, "cat < stdin.txt", dir) + if code != 0 && code != 1 { + t.Errorf("cat stdin unexpected exit code %d", code) + } + + if code == 0 && stdout != string(input) { + t.Errorf("cat stdin output differs from input: got %d bytes, want %d bytes", len(stdout), len(input)) + } + }) +} diff --git a/interp/builtins/tests/grep/grep_fuzz_test.go b/interp/builtins/tests/grep/grep_fuzz_test.go new file mode 100644 index 00000000..cb4edd50 --- /dev/null +++ b/interp/builtins/tests/grep/grep_fuzz_test.go @@ -0,0 +1,150 @@ +// 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 grep_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/DataDog/rshell/interp" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +func cmdRunCtx(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})) +} + +// safePattern escapes a byte slice into a shell-safe single-quoted string. +// Single-quoted strings in bash cannot contain single quotes, so we use +// a simple fixed pattern approach instead. +func fixedPatterns() []string { + return []string{".", "a", "foo", "^$", "[a-z]", ".*"} +} + +// FuzzGrepFileContent fuzzes grep with a fixed pattern and arbitrary file content. +func FuzzGrepFileContent(f *testing.F) { + f.Add([]byte("apple\nbanana\ncherry\n"), "banana") + f.Add([]byte{}, "anything") + f.Add([]byte("no newline"), "new") + f.Add([]byte("a\x00b\nc\n"), "a") + f.Add(bytes.Repeat([]byte("x"), 4097), "x") + f.Add([]byte("\n\n\n"), ".") + f.Add([]byte("hello world\nfoo bar\n"), "foo") + f.Add([]byte{0xff, 0xfe}, "a") + + f.Fuzz(func(t *testing.T, input []byte, pattern string) { + if len(input) > 1<<20 { + return + } + // Skip patterns that would be problematic in shell quoting + for _, c := range pattern { + if c == '\'' || c == '\x00' || c == '\n' { + return + } + } + if len(pattern) == 0 { + return + } + if len(pattern) > 100 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Use single-quoted pattern to avoid shell interpretation + script := "grep '" + pattern + "' input.txt" + _, _, code := cmdRunCtx(ctx, t, script, dir) + // grep exits 0 (match found), 1 (no match), or 2 (error/invalid regex) + if code != 0 && code != 1 && code != 2 { + t.Errorf("grep unexpected exit code %d", code) + } + }) +} + +// FuzzGrepStdin fuzzes grep reading from stdin with arbitrary content. +func FuzzGrepStdin(f *testing.F) { + f.Add([]byte("apple\nbanana\ncherry\n")) + f.Add([]byte{}) + f.Add([]byte("no newline")) + f.Add([]byte("a\x00b\nc\n")) + f.Add(bytes.Repeat([]byte("x"), 4097)) + f.Add([]byte("\n\n\n")) + + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 1<<20 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "stdin.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, "grep '.' < stdin.txt", dir) + if code != 0 && code != 1 && code != 2 { + t.Errorf("grep stdin unexpected exit code %d", code) + } + }) +} + +// FuzzGrepFlags fuzzes grep with various flags and arbitrary file content. +func FuzzGrepFlags(f *testing.F) { + f.Add([]byte("Hello\nworld\nHELLO\n"), true, false) + f.Add([]byte("line1\nline2\n"), false, true) + f.Add([]byte{}, true, true) + f.Add([]byte("no newline"), false, false) + f.Add(bytes.Repeat([]byte("abc\n"), 100), true, false) + + f.Fuzz(func(t *testing.T, input []byte, caseInsensitive bool, invertMatch bool) { + if len(input) > 1<<20 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + flags := "" + if caseInsensitive { + flags += " -i" + } + if invertMatch { + flags += " -v" + } + + script := "grep" + flags + " 'a' input.txt" + _, _, code := cmdRunCtx(ctx, t, script, dir) + if code != 0 && code != 1 && code != 2 { + t.Errorf("grep%s unexpected exit code %d", flags, code) + } + }) +} diff --git a/interp/builtins/tests/head/head_fuzz_test.go b/interp/builtins/tests/head/head_fuzz_test.go new file mode 100644 index 00000000..35dca919 --- /dev/null +++ b/interp/builtins/tests/head/head_fuzz_test.go @@ -0,0 +1,156 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package head_test + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DataDog/rshell/interp" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +func cmdRunCtx(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})) +} + +// FuzzHeadLines fuzzes head -n N with arbitrary file content. +func FuzzHeadLines(f *testing.F) { + f.Add([]byte("line1\nline2\nline3\n"), int64(2)) + f.Add([]byte{}, int64(0)) + f.Add([]byte("no newline"), int64(1)) + f.Add([]byte("a\x00b\nc\n"), int64(2)) + f.Add(bytes.Repeat([]byte("x"), 4097), int64(1)) + f.Add([]byte("\n\n\n"), int64(5)) + f.Add(bytes.Repeat([]byte("y"), 4096), int64(1)) + f.Add([]byte("hello\nworld\n"), int64(10)) + + f.Fuzz(func(t *testing.T, input []byte, n int64) { + if len(input) > 1<<20 { + return + } + if n < 0 { + return + } + if n > 10000 { + n = 10000 + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("head -n %d input.txt", n), dir) + if code != 0 && code != 1 { + t.Errorf("unexpected exit code %d", code) + } + + // If successful, output line count must be <= n + if code == 0 && n >= 0 { + lineCount := strings.Count(stdout, "\n") + if int64(lineCount) > n { + t.Errorf("head -n %d produced %d newlines in output", n, lineCount) + } + } + }) +} + +// FuzzHeadBytes fuzzes head -c N with arbitrary file content. +func FuzzHeadBytes(f *testing.F) { + f.Add([]byte("line1\nline2\nline3\n"), int64(5)) + f.Add([]byte{}, int64(0)) + f.Add([]byte("no newline"), int64(3)) + f.Add([]byte("a\x00b\nc\n"), int64(4)) + f.Add(bytes.Repeat([]byte("x"), 4097), int64(4096)) + f.Add([]byte("\n\n\n"), int64(2)) + + f.Fuzz(func(t *testing.T, input []byte, n int64) { + if len(input) > 1<<20 { + return + } + if n < 0 { + return + } + if n > 10000 { + n = 10000 + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("head -c %d input.txt", n), dir) + if code != 0 && code != 1 { + t.Errorf("unexpected exit code %d", code) + } + + // If successful, output byte count must be <= n + if code == 0 { + outLen := int64(len(stdout)) + if outLen > n { + t.Errorf("head -c %d produced %d bytes of output", n, outLen) + } + } + }) +} + +// FuzzHeadStdin fuzzes head -n N reading from stdin via shell redirection. +func FuzzHeadStdin(f *testing.F) { + f.Add([]byte("line1\nline2\nline3\n"), int64(2)) + f.Add([]byte{}, int64(1)) + f.Add([]byte("no newline"), int64(1)) + f.Add([]byte("a\x00b\nc\n"), int64(2)) + f.Add(bytes.Repeat([]byte("x"), 4097), int64(1)) + f.Add([]byte("\n\n\n"), int64(3)) + + f.Fuzz(func(t *testing.T, input []byte, n int64) { + if len(input) > 1<<20 { + return + } + if n < 0 { + return + } + if n > 10000 { + n = 10000 + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "stdin.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("head -n %d < stdin.txt", n), dir) + if code != 0 && code != 1 { + t.Errorf("unexpected exit code %d (stdin mode)", code) + } + }) +} diff --git a/interp/builtins/tests/tail/tail_fuzz_test.go b/interp/builtins/tests/tail/tail_fuzz_test.go new file mode 100644 index 00000000..a9703f12 --- /dev/null +++ b/interp/builtins/tests/tail/tail_fuzz_test.go @@ -0,0 +1,156 @@ +// 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 tail_test + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DataDog/rshell/interp" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +func cmdRunCtx(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})) +} + +// FuzzTailLines fuzzes tail -n N with arbitrary file content. +func FuzzTailLines(f *testing.F) { + f.Add([]byte("line1\nline2\nline3\n"), int64(2)) + f.Add([]byte{}, int64(0)) + f.Add([]byte("no newline"), int64(1)) + f.Add([]byte("a\x00b\nc\n"), int64(2)) + f.Add(bytes.Repeat([]byte("x"), 4097), int64(1)) + f.Add([]byte("\n\n\n"), int64(5)) + f.Add(bytes.Repeat([]byte("y"), 4096), int64(1)) + f.Add([]byte("hello\nworld\n"), int64(10)) + + f.Fuzz(func(t *testing.T, input []byte, n int64) { + if len(input) > 1<<20 { + return + } + if n < 0 { + return + } + if n > 10000 { + n = 10000 + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -n %d input.txt", n), dir) + if code != 0 && code != 1 { + t.Errorf("tail -n %d unexpected exit code %d", n, code) + } + + // If successful, output line count must be <= n + if code == 0 && n >= 0 { + lineCount := strings.Count(stdout, "\n") + if int64(lineCount) > n { + t.Errorf("tail -n %d produced %d newlines in output", n, lineCount) + } + } + }) +} + +// FuzzTailBytes fuzzes tail -c N with arbitrary file content. +func FuzzTailBytes(f *testing.F) { + f.Add([]byte("line1\nline2\nline3\n"), int64(5)) + f.Add([]byte{}, int64(0)) + f.Add([]byte("no newline"), int64(3)) + f.Add([]byte("a\x00b\nc\n"), int64(4)) + f.Add(bytes.Repeat([]byte("x"), 4097), int64(4096)) + f.Add([]byte("\n\n\n"), int64(2)) + + f.Fuzz(func(t *testing.T, input []byte, n int64) { + if len(input) > 1<<20 { + return + } + if n < 0 { + return + } + if n > 10000 { + n = 10000 + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -c %d input.txt", n), dir) + if code != 0 && code != 1 { + t.Errorf("tail -c %d unexpected exit code %d", n, code) + } + + // If successful, output byte count must be <= n + if code == 0 { + outLen := int64(len(stdout)) + if outLen > n { + t.Errorf("tail -c %d produced %d bytes of output", n, outLen) + } + } + }) +} + +// FuzzTailStdin fuzzes tail -n N reading from stdin via shell redirection. +func FuzzTailStdin(f *testing.F) { + f.Add([]byte("line1\nline2\nline3\n"), int64(2)) + f.Add([]byte{}, int64(1)) + f.Add([]byte("no newline"), int64(1)) + f.Add([]byte("a\x00b\nc\n"), int64(2)) + f.Add(bytes.Repeat([]byte("x"), 4097), int64(1)) + f.Add([]byte("\n\n\n"), int64(3)) + + f.Fuzz(func(t *testing.T, input []byte, n int64) { + if len(input) > 1<<20 { + return + } + if n < 0 { + return + } + if n > 10000 { + n = 10000 + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "stdin.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -n %d < stdin.txt", n), dir) + if code != 0 && code != 1 { + t.Errorf("tail stdin unexpected exit code %d", code) + } + }) +} diff --git a/interp/builtins/tests/wc/wc_fuzz_test.go b/interp/builtins/tests/wc/wc_fuzz_test.go new file mode 100644 index 00000000..7233ef5d --- /dev/null +++ b/interp/builtins/tests/wc/wc_fuzz_test.go @@ -0,0 +1,148 @@ +// 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 wc_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/DataDog/rshell/interp" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +func cmdRunCtx(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})) +} + +// FuzzWc fuzzes wc (default mode: lines, words, bytes) with arbitrary file content. +func FuzzWc(f *testing.F) { + f.Add([]byte("hello world\n")) + f.Add([]byte{}) + f.Add([]byte("no newline")) + f.Add([]byte("a\x00b\nc\n")) + f.Add(bytes.Repeat([]byte("x"), 4097)) + f.Add([]byte("\n\n\n")) + f.Add(bytes.Repeat([]byte("word "), 100)) + + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 1<<20 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, "wc input.txt", dir) + if code != 0 && code != 1 { + t.Errorf("wc unexpected exit code %d", code) + } + }) +} + +// FuzzWcLines fuzzes wc -l with arbitrary file content. +func FuzzWcLines(f *testing.F) { + f.Add([]byte("line1\nline2\nline3\n")) + f.Add([]byte{}) + f.Add([]byte("no newline")) + f.Add([]byte("a\x00b\nc\n")) + f.Add(bytes.Repeat([]byte("x"), 4097)) + f.Add([]byte("\n\n\n")) + + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 1<<20 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, "wc -l input.txt", dir) + if code != 0 && code != 1 { + t.Errorf("wc -l unexpected exit code %d", code) + } + }) +} + +// FuzzWcBytes fuzzes wc -c with arbitrary file content. +func FuzzWcBytes(f *testing.F) { + f.Add([]byte("hello\n")) + f.Add([]byte{}) + f.Add([]byte("no newline")) + f.Add([]byte("a\x00b\nc\n")) + f.Add(bytes.Repeat([]byte("x"), 4097)) + + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 1<<20 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, "wc -c input.txt", dir) + if code != 0 && code != 1 { + t.Errorf("wc -c unexpected exit code %d", code) + } + }) +} + +// FuzzWcStdin fuzzes wc reading from stdin via shell redirection. +func FuzzWcStdin(f *testing.F) { + f.Add([]byte("hello world\n")) + f.Add([]byte{}) + f.Add([]byte("no newline")) + f.Add([]byte("a\x00b\n")) + f.Add(bytes.Repeat([]byte("x"), 4097)) + f.Add([]byte("\n\n\n")) + + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 1<<20 { + return + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "stdin.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, "wc < stdin.txt", dir) + if code != 0 && code != 1 { + t.Errorf("wc stdin unexpected exit code %d", code) + } + }) +} From 7edf529f7ecaaaeaffa9216d59bcd53d7e83d75d Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Thu, 12 Mar 2026 12:12:51 -0400 Subject: [PATCH 2/2] Address review comments on fuzz tests PR - Remove unused cmdRun function from all 5 fuzz test files (cat, grep, head, tail, wc); only cmdRunCtx (with context for timeout) is needed - Remove dead fixedPatterns() function and stale comment from grep fuzz tests (artifact of an earlier design approach) - Add utf8.ValidString guard in FuzzGrepFileContent to skip non-UTF-8 patterns that would be rejected by the shell parser before reaching the grep builtin, ensuring the fuzz corpus exercises grep logic - Add FuzzTailLinesOffset and FuzzTailBytesOffset to cover the +N offset code paths in tail (skip-first-N-lines/bytes mode), which were not previously fuzzed Co-Authored-By: Claude Sonnet 4.6 --- interp/builtins/tests/cat/cat_fuzz_test.go | 5 -- interp/builtins/tests/grep/grep_fuzz_test.go | 23 +++--- interp/builtins/tests/head/head_fuzz_test.go | 5 -- interp/builtins/tests/tail/tail_fuzz_test.go | 82 ++++++++++++++++++-- interp/builtins/tests/wc/wc_fuzz_test.go | 5 -- 5 files changed, 87 insertions(+), 33 deletions(-) diff --git a/interp/builtins/tests/cat/cat_fuzz_test.go b/interp/builtins/tests/cat/cat_fuzz_test.go index 6fcf16ce..86a02379 100644 --- a/interp/builtins/tests/cat/cat_fuzz_test.go +++ b/interp/builtins/tests/cat/cat_fuzz_test.go @@ -17,11 +17,6 @@ import ( "github.com/DataDog/rshell/interp/builtins/testutil" ) -func cmdRun(t *testing.T, script, dir string) (string, string, int) { - t.Helper() - return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) -} - func cmdRunCtx(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})) diff --git a/interp/builtins/tests/grep/grep_fuzz_test.go b/interp/builtins/tests/grep/grep_fuzz_test.go index cb4edd50..ff2de18e 100644 --- a/interp/builtins/tests/grep/grep_fuzz_test.go +++ b/interp/builtins/tests/grep/grep_fuzz_test.go @@ -13,27 +13,17 @@ import ( "testing" "time" + "unicode/utf8" + "github.com/DataDog/rshell/interp" "github.com/DataDog/rshell/interp/builtins/testutil" ) -func cmdRun(t *testing.T, script, dir string) (string, string, int) { - t.Helper() - return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) -} - func cmdRunCtx(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})) } -// safePattern escapes a byte slice into a shell-safe single-quoted string. -// Single-quoted strings in bash cannot contain single quotes, so we use -// a simple fixed pattern approach instead. -func fixedPatterns() []string { - return []string{".", "a", "foo", "^$", "[a-z]", ".*"} -} - // FuzzGrepFileContent fuzzes grep with a fixed pattern and arbitrary file content. func FuzzGrepFileContent(f *testing.F) { f.Add([]byte("apple\nbanana\ncherry\n"), "banana") @@ -49,7 +39,14 @@ func FuzzGrepFileContent(f *testing.F) { if len(input) > 1<<20 { return } - // Skip patterns that would be problematic in shell quoting + // Skip patterns containing non-UTF-8 sequences: the shell parser's + // tokenizer rejects them before grep runs, so they exercise the parser + // error path rather than the grep builtin. + if !utf8.ValidString(pattern) { + return + } + // Skip patterns that would be problematic in shell quoting or cause the + // shell parser to fail before grep runs. for _, c := range pattern { if c == '\'' || c == '\x00' || c == '\n' { return diff --git a/interp/builtins/tests/head/head_fuzz_test.go b/interp/builtins/tests/head/head_fuzz_test.go index 35dca919..167c9ede 100644 --- a/interp/builtins/tests/head/head_fuzz_test.go +++ b/interp/builtins/tests/head/head_fuzz_test.go @@ -19,11 +19,6 @@ import ( "github.com/DataDog/rshell/interp/builtins/testutil" ) -func cmdRun(t *testing.T, script, dir string) (string, string, int) { - t.Helper() - return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) -} - func cmdRunCtx(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})) diff --git a/interp/builtins/tests/tail/tail_fuzz_test.go b/interp/builtins/tests/tail/tail_fuzz_test.go index a9703f12..9e5b7c43 100644 --- a/interp/builtins/tests/tail/tail_fuzz_test.go +++ b/interp/builtins/tests/tail/tail_fuzz_test.go @@ -19,11 +19,6 @@ import ( "github.com/DataDog/rshell/interp/builtins/testutil" ) -func cmdRun(t *testing.T, script, dir string) (string, string, int) { - t.Helper() - return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) -} - func cmdRunCtx(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})) @@ -154,3 +149,80 @@ func FuzzTailStdin(f *testing.F) { } }) } + +// FuzzTailLinesOffset fuzzes tail -n +N (skip-first-N-lines offset mode). +func FuzzTailLinesOffset(f *testing.F) { + f.Add([]byte("line1\nline2\nline3\n"), int64(1)) + f.Add([]byte("line1\nline2\nline3\n"), int64(2)) + f.Add([]byte{}, int64(1)) + f.Add([]byte("no newline"), int64(1)) + f.Add([]byte("a\x00b\nc\n"), int64(2)) + f.Add(bytes.Repeat([]byte("x"), 4097), int64(1)) + f.Add([]byte("\n\n\n"), int64(5)) + f.Add([]byte("hello\nworld\n"), int64(100)) + + f.Fuzz(func(t *testing.T, input []byte, n int64) { + if len(input) > 1<<20 { + return + } + if n < 1 { + return + } + if n > 10000 { + n = 10000 + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -n +%d input.txt", n), dir) + if code != 0 && code != 1 { + t.Errorf("tail -n +%d unexpected exit code %d", n, code) + } + }) +} + +// FuzzTailBytesOffset fuzzes tail -c +N (skip-first-N-bytes offset mode). +func FuzzTailBytesOffset(f *testing.F) { + f.Add([]byte("hello\nworld\n"), int64(1)) + f.Add([]byte("hello\nworld\n"), int64(6)) + f.Add([]byte{}, int64(1)) + f.Add([]byte("no newline"), int64(3)) + f.Add([]byte("a\x00b\nc\n"), int64(2)) + f.Add(bytes.Repeat([]byte("x"), 4097), int64(4096)) + f.Add([]byte("\n\n\n"), int64(2)) + f.Add([]byte("hello\nworld\n"), int64(100)) + + f.Fuzz(func(t *testing.T, input []byte, n int64) { + if len(input) > 1<<20 { + return + } + if n < 1 { + return + } + if n > 10000 { + n = 10000 + } + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "input.txt"), input, 0644) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -c +%d input.txt", n), dir) + if code != 0 && code != 1 { + t.Errorf("tail -c +%d unexpected exit code %d", n, code) + } + }) +} + diff --git a/interp/builtins/tests/wc/wc_fuzz_test.go b/interp/builtins/tests/wc/wc_fuzz_test.go index 7233ef5d..98dc739c 100644 --- a/interp/builtins/tests/wc/wc_fuzz_test.go +++ b/interp/builtins/tests/wc/wc_fuzz_test.go @@ -17,11 +17,6 @@ import ( "github.com/DataDog/rshell/interp/builtins/testutil" ) -func cmdRun(t *testing.T, script, dir string) (string, string, int) { - t.Helper() - return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) -} - func cmdRunCtx(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}))