diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 434d1b9f..eabc114c 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -14,6 +14,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `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] [FILE]...` — output the first part of files (default: first 10 lines); `-z`/`--zero-terminated` and `--follow` are rejected +- ✅ `sort [-rnubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec) - ✅ `ls [-1aAdFhlpRrSt] [--offset N] [--limit N] [FILE]...` — list directory contents; `--offset`/`--limit` are non-standard pagination flags (single-directory only, silently ignored with `-R` or multiple arguments, capped at 1,000 entries per call); offset operates on filesystem order (not sorted order) for O(n) memory - ✅ `printf FORMAT [ARGUMENT]...` — format and print data to stdout; supports `%s`, `%b`, `%c`, `%d`, `%i`, `%o`, `%u`, `%x`, `%X`, `%e`, `%E`, `%f`, `%F`, `%g`, `%G`, `%%`; format reuse for excess arguments; `%n` rejected (security risk); `-v` rejected - ✅ `sed [-n] [-e SCRIPT] [-E|-r] [SCRIPT] [FILE]...` — stream editor for filtering and transforming text; uses RE2 regex engine; `-i`/`-f` rejected; `e`/`w`/`W`/`r`/`R` commands blocked diff --git a/interp/builtins/sort/builtin_sort_pentest_test.go b/interp/builtins/sort/builtin_sort_pentest_test.go new file mode 100644 index 00000000..4d443df9 --- /dev/null +++ b/interp/builtins/sort/builtin_sort_pentest_test.go @@ -0,0 +1,225 @@ +// 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 sort builtin. +// +// These tests probe rejected flags, memory safety, path edge cases, +// and flag injection scenarios. Tests that might hang are run in a +// goroutine with time.After to bound execution. + +package sort_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +const pentestTimeout = 10 * time.Second + +// sortRun is a shorthand for runScript with AllowedPaths=dir. +func sortRun(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) + } +} + +// --- Rejected flags (GTFOBins vectors) --- + +func TestCmdPentestOutputFlagRejected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello\n") + _, stderr, code := sortRun(t, "sort -o out.txt f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") + // Verify no file was created. + _, err := os.Stat(filepath.Join(dir, "out.txt")) + assert.True(t, os.IsNotExist(err)) +} + +func TestCmdPentestOutputFlagLong(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello\n") + _, stderr, code := sortRun(t, "sort --output=out.txt f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +func TestCmdPentestCompressProgramRejected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello\n") + _, stderr, code := sortRun(t, "sort --compress-program=sh f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +func TestCmdPentestTempDirRejected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello\n") + _, stderr, code := sortRun(t, "sort --temporary-directory=/tmp f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +// --- Path traversal --- + +func TestCmdPentestPathTraversal(t *testing.T) { + dir := t.TempDir() + _, stderr, code := sortRun(t, "sort ../../etc/passwd", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +func TestCmdPentestOutsideSandbox(t *testing.T) { + 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, "sort "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +// --- Nonexistent and empty files --- + +func TestCmdPentestNonexistentFile(t *testing.T) { + dir := t.TempDir() + _, stderr, code := sortRun(t, "sort does_not_exist.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +func TestCmdPentestEmptyFilename(t *testing.T) { + dir := t.TempDir() + _, _, code := sortRun(t, `sort ""`, dir) + assert.Equal(t, 1, code) +} + +func TestCmdPentestDirectoryAsFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + _, stderr, code := sortRun(t, "sort subdir", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +// --- Memory safety --- + +func TestCmdPentestLargeFile(t *testing.T) { + // A file with 10000 lines should sort without hanging. + dir := t.TempDir() + var buf bytes.Buffer + for i := 10000; i > 0; i-- { + buf.WriteString(fmt.Sprintf("%d\n", i)) + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "big.txt"), buf.Bytes(), 0644)) + mustNotHang(t, func() { + stdout, _, code := sortRun(t, "sort -n big.txt", dir) + assert.Equal(t, 0, code) + lines := strings.Split(strings.TrimSuffix(stdout, "\n"), "\n") + assert.Equal(t, 10000, len(lines)) + assert.Equal(t, "1", lines[0]) + assert.Equal(t, "10000", lines[len(lines)-1]) + }) +} + +func TestCmdPentestLongLine(t *testing.T) { + // A line just below the 1 MiB cap should succeed. + dir := t.TempDir() + line := bytes.Repeat([]byte("a"), 1<<20-2) + line = append(line, '\n') + require.NoError(t, os.WriteFile(filepath.Join(dir, "long.txt"), line, 0644)) + mustNotHang(t, func() { + stdout, _, code := sortRun(t, "sort long.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, string(line), stdout) + }) +} + +func TestCmdPentestLongLineExceedsCap(t *testing.T) { + // A line exceeding the 1 MiB cap should error, not crash. + 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 := sortRun(t, "sort huge.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") + }) +} + +// --- Flag injection --- + +func TestCmdPentestFlagViaExpansion(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello\n") + _, stderr, code := sortRun(t, `flag="--output=evil.txt"; sort $flag f.txt`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") + // Verify no file was created. + _, err := os.Stat(filepath.Join(dir, "evil.txt")) + assert.True(t, os.IsNotExist(err)) +} + +func TestCmdPentestUnknownLongFlag(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello\n") + _, stderr, code := sortRun(t, "sort --no-such-flag f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +func TestCmdPentestUnknownShortFlag(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello\n") + _, stderr, code := sortRun(t, "sort -Z f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +// --- Double dash --- + +func TestCmdPentestFlagLikeName(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "-r"), []byte("flag-file\n"), 0644)) + stdout, _, code := sortRun(t, "sort -- -r", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "flag-file\n", stdout) +} + +// --- Nil stdin --- + +func TestCmdPentestNilStdin(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := runScript(t, "sort -", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} diff --git a/interp/builtins/sort/cancellation_test.go b/interp/builtins/sort/cancellation_test.go new file mode 100644 index 00000000..3ff7a1a6 --- /dev/null +++ b/interp/builtins/sort/cancellation_test.go @@ -0,0 +1,79 @@ +// 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 sort + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/DataDog/rshell/interp/builtins" + "github.com/stretchr/testify/assert" +) + +func TestCheckSortedRespectsContextCancellation(t *testing.T) { + // A cancelled context should be detected on the first iteration, + // even for small inputs. + lines := []string{"a", "b", "c"} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + var stderr bytes.Buffer + callCtx := &builtins.CallContext{ + Stdout: &bytes.Buffer{}, + Stderr: &stderr, + } + + cmpFn := func(a, b string) int { + return strings.Compare(a, b) + } + + result := checkSorted(ctx, callCtx, lines, cmpFn, false, false, "-") + + assert.Equal(t, uint8(1), result.Code, + "checkSorted should return exit code 1 when context is cancelled") +} + +func TestCheckSortedCompletesWithoutCancellation(t *testing.T) { + // Verify that checkSorted works normally without cancellation. + lines := []string{"a", "b", "c"} + + var stderr bytes.Buffer + callCtx := &builtins.CallContext{ + Stdout: &bytes.Buffer{}, + Stderr: &stderr, + } + + cmpFn := func(a, b string) int { + return strings.Compare(a, b) + } + + result := checkSorted(context.Background(), callCtx, lines, cmpFn, false, false, "-") + + assert.Equal(t, uint8(0), result.Code, + "checkSorted should return exit code 0 for sorted input") +} + +func TestCheckSortedDetectsDisorder(t *testing.T) { + lines := []string{"b", "a", "c"} + + var stderr bytes.Buffer + callCtx := &builtins.CallContext{ + Stdout: &bytes.Buffer{}, + Stderr: &stderr, + } + + cmpFn := func(a, b string) int { + return strings.Compare(a, b) + } + + result := checkSorted(context.Background(), callCtx, lines, cmpFn, false, false, "-") + + assert.Equal(t, uint8(1), result.Code) + assert.Contains(t, stderr.String(), "disorder") +} diff --git a/interp/builtins/sort/key_extraction_test.go b/interp/builtins/sort/key_extraction_test.go new file mode 100644 index 00000000..848fc826 --- /dev/null +++ b/interp/builtins/sort/key_extraction_test.go @@ -0,0 +1,180 @@ +// 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 sort + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitBlankFieldsPreservesLeadingBlanks(t *testing.T) { + tests := []struct { + name string + input string + expect []string + }{ + { + name: "single space before token", + input: " x", + expect: []string{" x"}, + }, + { + name: "two spaces before token", + input: " abc", + expect: []string{" abc"}, + }, + { + name: "no leading blanks", + input: "A", + expect: []string{"A"}, + }, + { + name: "two fields with blanks", + input: "1 b", + expect: []string{"1", " b"}, + }, + { + name: "multiple fields with varying blanks", + input: " a bb c", + expect: []string{" a", " bb", " c"}, + }, + { + name: "tab as blank", + input: "\tx", + expect: []string{"\tx"}, + }, + { + name: "trailing blanks form a field", + input: "a ", + expect: []string{"a", " "}, + }, + { + name: "empty string", + input: "", + expect: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitBlankFields(tt.input) + assert.Equal(t, tt.expect, got) + }) + } +} + +func TestExtractKeyCharOffsetIncludesBlanks(t *testing.T) { + // When no -t separator is set, character offsets into a field must + // count from the start of the field including its leading blanks. + // This matches GNU sort behavior. + tests := []struct { + name string + line string + key keySpec + expect string + }{ + { + name: "offset into leading blank: ' x' k1.2,1.3 extracts 'x'", + line: " x", + key: keySpec{startField: 1, startChar: 2, endField: 1, endChar: 3}, + // field 1 = " x", char 2 = 'x', char 3 = past end → "x" + expect: "x", + }, + { + name: "offset lands on blank: ' abc' k1.1,1.1 extracts ' '", + line: " abc", + key: keySpec{startField: 1, startChar: 1, endField: 1, endChar: 1}, + // field 1 = " abc", char 1 = first space + expect: " ", + }, + { + name: "offset lands on second blank: ' abc' k1.2,1.2 extracts ' '", + line: " abc", + key: keySpec{startField: 1, startChar: 2, endField: 1, endChar: 2}, + // field 1 = " abc", char 2 = second space + expect: " ", + }, + { + name: "offset past blanks: ' abc' k1.3,1.3 extracts 'a'", + line: " abc", + key: keySpec{startField: 1, startChar: 3, endField: 1, endChar: 3}, + // field 1 = " abc", char 3 = 'a' + expect: "a", + }, + { + name: "second field blank preserved: '1 b' k2.1,2.1 extracts ' '", + line: "1 b", + key: keySpec{startField: 2, startChar: 1, endField: 2, endChar: 1}, + // field 2 = " b", char 1 = space + expect: " ", + }, + { + name: "second field offset past blank: '1 b' k2.2,2.2 extracts 'b'", + line: "1 b", + key: keySpec{startField: 2, startChar: 2, endField: 2, endChar: 2}, + // field 2 = " b", char 2 = 'b' + expect: "b", + }, + { + name: "end field beyond fields: 'abc' k1.2,2.1 extracts 'bc'", + line: "abc", + key: keySpec{startField: 1, startChar: 2, endField: 2, endChar: 1}, + // Only 1 field; end field 2 is out of range → treat as end-of-line + expect: "bc", + }, + { + name: "end field beyond fields multi-field: 'a b' k1.1,3.1 extracts 'a b'", + line: "a b", + key: keySpec{startField: 1, startChar: 1, endField: 3, endChar: 1}, + // 2 blank-split fields; end field 3 out of range → end-of-line + // position-based: returns line[0:] = "a b" + expect: "a b", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractKey(tt.line, tt.key, 0, false, false, false) + assert.Equal(t, tt.expect, got) + }) + } +} + +func TestExtractKeyEndBeforeStartIsZeroWidth(t *testing.T) { + // GNU sort treats -k 2,1 (end field < start field) as a zero-width + // key, producing an empty string that falls back to whole-line + // comparison during tie-breaking. + tests := []struct { + name string + line string + key keySpec + expect string + }{ + { + name: "end field before start field", + line: "1 b", + key: keySpec{startField: 2, endField: 1}, + expect: "", + }, + { + name: "end field well before start field", + line: "a bb ccc", + key: keySpec{startField: 3, endField: 1}, + expect: "", + }, + { + name: "start field beyond line still returns empty", + line: "only", + key: keySpec{startField: 2, endField: 1}, + expect: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractKey(tt.line, tt.key, 0, false, false, false) + assert.Equal(t, tt.expect, got) + }) + } +} diff --git a/interp/builtins/sort/sort.go b/interp/builtins/sort/sort.go new file mode 100644 index 00000000..9be9646a --- /dev/null +++ b/interp/builtins/sort/sort.go @@ -0,0 +1,1011 @@ +// 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 sort implements the sort builtin command. +// +// sort — sort lines of text files +// +// Usage: sort [OPTION]... [FILE]... +// +// Write sorted concatenation of all FILE(s) to standard output. +// With no FILE, or when FILE is -, read standard input. +// +// Accepted flags: +// +// -r, --reverse +// Reverse the result of comparisons (sort descending). +// +// -n, --numeric-sort +// Compare according to string numerical value. +// +// -u, --unique +// Output only the first of an equal run. +// +// -k, --key=KEYDEF +// Sort via a key definition; KEYDEF is F[.C][OPTS][,F[.C][OPTS]]. +// +// -t, --field-separator=SEP +// Use SEP as the field separator. +// +// -b, --ignore-leading-blanks +// Ignore leading blanks when finding sort keys. +// +// -f, --ignore-case +// Fold lowercase to uppercase for comparisons. +// +// -d, --dictionary-order +// Consider only blanks and alphanumeric characters. +// +// -c +// Check whether input is sorted; exit 1 if not. +// +// -C, --check=silent +// Like -c but do not print the diagnostic line. +// +// -s, --stable +// Stabilize sort by disabling last-resort comparison. +// +// -h, --help +// Print usage to stdout and exit 0. +// +// Rejected flags (unsafe): +// +// -o FILE (writes to filesystem) +// -T DIR (writes temp files) +// --compress-program (executes a binary) +// +// Exit codes: +// +// 0 Success (or input is sorted when using -c/-C). +// 1 Error, or input is NOT sorted when using -c/-C. +// +// Memory safety: +// +// sort must buffer all input before producing output. A maximum of +// MaxLines (1,000,000) lines is enforced to prevent OOM. Per-line cap +// of MaxLineBytes (1 MiB) is enforced via the scanner. All loops check +// ctx.Err() at each iteration to honour the shell's execution timeout. +package sort + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "slices" + "strconv" + "strings" + + "github.com/DataDog/rshell/interp/builtins" +) + +// Cmd is the sort builtin command descriptor. +var Cmd = builtins.Command{Name: "sort", MakeFlags: registerFlags} + +// checkTracker is a pflag.Value that tracks all --check/-c modes set +// during argument parsing so conflicting modes (diagnose vs silent) can +// be detected. GNU sort rejects mixed modes with "options '-cC' are +// incompatible". +type checkTracker struct { + last string // last mode set + sawDiag bool // saw diagnose/diagnose-first + sawSilent bool // saw silent/quiet + hasInvalid bool // true if an unrecognized value was seen + invalid string // first unrecognized value +} + +func (ct *checkTracker) String() string { return ct.last } +func (ct *checkTracker) Type() string { return "string" } + +func (ct *checkTracker) Set(s string) error { + ct.last = s + switch s { + case "silent", "quiet": + ct.sawSilent = true + case "diagnose", "diagnose-first": + ct.sawDiag = true + default: + if !ct.hasInvalid { + ct.invalid = s + } + ct.hasInvalid = true + } + return nil +} + +func (ct *checkTracker) conflict() bool { return ct.sawDiag && ct.sawSilent } + +// MaxLines is the maximum number of lines sort will buffer. Beyond this +// the command errors out to prevent unbounded memory growth. +const MaxLines = 1_000_000 + +// MaxLineBytes is the per-line buffer cap for the line scanner. +const MaxLineBytes = 1 << 20 // 1 MiB + +// MaxTotalBytes is the cumulative byte cap across all input lines. This +// prevents OOM when many lines are each below MaxLineBytes but collectively +// consume excessive memory. 256 MiB is generous for agent workloads. +const MaxTotalBytes = 256 * 1024 * 1024 // 256 MiB + +// registerFlags registers all sort flags and returns the bound handler. +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + help := fs.BoolP("help", "h", false, "print usage and exit") + reverse := fs.BoolP("reverse", "r", false, "reverse the result of comparisons") + numeric := fs.BoolP("numeric-sort", "n", false, "compare according to string numerical value") + unique := fs.BoolP("unique", "u", false, "output only the first of an equal run") + keyDefs := fs.StringArrayP("key", "k", nil, "sort via a key; KEYDEF is F[.C][OPTS][,F[.C][OPTS]]") + fieldSep := fs.StringP("field-separator", "t", "", "use SEP as the field separator") + ignBlanks := fs.BoolP("ignore-leading-blanks", "b", false, "ignore leading blanks") + ignCase := fs.BoolP("ignore-case", "f", false, "fold lower case to upper case characters") + dictOrder := fs.BoolP("dictionary-order", "d", false, "consider only blanks and alphanumeric characters") + // --check accepts optional values: "diagnose" (default), "silent", "quiet". + // -c is shorthand for --check (diagnose mode). + // -C is shorthand for silent check mode. + var checkFlag checkTracker + fs.VarP(&checkFlag, "check", "c", "check for sorted input; optionally =silent or =quiet") + checkSilentShort := fs.BoolP("check-silent-short", "C", false, "like -c, but do not report first bad line") + stable := fs.BoolP("stable", "s", false, "stabilize sort by disabling last-resort comparison") + + // --check with no value means diagnose mode. + fs.Lookup("check").NoOptDefVal = "diagnose" + // Hide internal flag. + fs.MarkHidden("check-silent-short") + + // Rejected flags — declare them so pflag parses them, then reject in handler. + fs.StringP("output", "o", "", "") + fs.String("temporary-directory", "", "") + fs.String("compress-program", "", "") + + // Hide rejected flags from help. + fs.MarkHidden("output") + fs.MarkHidden("temporary-directory") + fs.MarkHidden("compress-program") + + return func(ctx context.Context, callCtx *builtins.CallContext, files []string) builtins.Result { + if *help { + callCtx.Out("Usage: sort [OPTION]... [FILE]...\n") + callCtx.Out("Write sorted concatenation of all 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{} + } + + // Reject dangerous flags. + if fs.Changed("output") { + callCtx.Errf("sort: --output/-o is not supported (writes to filesystem)\n") + return builtins.Result{Code: 1} + } + if fs.Changed("temporary-directory") { + callCtx.Errf("sort: --temporary-directory is not supported (writes temp files)\n") + return builtins.Result{Code: 1} + } + if fs.Changed("compress-program") { + callCtx.Errf("sort: --compress-program is not supported (executes a binary)\n") + return builtins.Result{Code: 1} + } + + // Resolve check mode from --check[=VALUE] and -C flags. + checkEnabled := false + checkSilent := false + if fs.Changed("check") { + if checkFlag.hasInvalid { + callCtx.Errf("sort: invalid argument %q for '--check'\n", checkFlag.invalid) + return builtins.Result{Code: 2} + } + // Reject mixed diagnose/silent modes across repeated --check flags. + if checkFlag.conflict() { + callCtx.Errf("sort: options '-cC' are incompatible\n") + return builtins.Result{Code: 2} + } + checkEnabled = true + checkSilent = checkFlag.sawSilent + } + if *checkSilentShort { + // -C is equivalent to --check=silent. Reject only when + // --check was set to a diagnose mode (GNU compat). + if fs.Changed("check") && checkFlag.sawDiag { + callCtx.Errf("sort: options '-cC' are incompatible\n") + return builtins.Result{Code: 2} + } + checkEnabled = true + checkSilent = true + } + + // Validate -t flag: must be a single byte. + sep := byte(0) + hasSep := false + if fs.Changed("field-separator") { + if len(*fieldSep) == 0 { + callCtx.Errf("sort: empty tab\n") + return builtins.Result{Code: 2} + } + if len(*fieldSep) != 1 { + callCtx.Errf("sort: multi-character tab %q\n", *fieldSep) + return builtins.Result{Code: 2} + } + sep = (*fieldSep)[0] + hasSep = true + } + + // Parse key definitions. + globalOpts := keyOpts{ + numeric: *numeric, + reverse: *reverse, + ignBlanks: *ignBlanks, + ignCase: *ignCase, + dictOrder: *dictOrder, + } + + var keys []keySpec + if keyDefs != nil { + for _, kd := range *keyDefs { + k, err := parseKeyDef(kd) + if err != nil { + callCtx.Errf("sort: %s\n", err.Error()) + return builtins.Result{Code: 2} + } + keys = append(keys, k) + } + } + + // Validate incompatible global flags: -d and -n cannot coexist + // unless every key has per-key opts that override the globals. + if globalOpts.dictOrder && globalOpts.numeric { + globalsUsed := len(keys) == 0 + for _, k := range keys { + if !k.hasOpts { + globalsUsed = true + break + } + } + if globalsUsed { + callCtx.Errf("sort: options '-dn' are incompatible\n") + return builtins.Result{Code: 2} + } + } + + // Default to stdin when no files given. + if len(files) == 0 { + files = []string{"-"} + } + + // Build comparison function. Disable last-resort byte comparison + // when -s (stable) or -u (unique) is set — both require that + // key-equal lines compare as equal. + disableLastResort := *stable || *unique + cmpFn := buildCompare(keys, globalOpts, sep, hasSep, disableLastResort) + + // Shared byte counter across all files for cumulative memory tracking. + var totalBytes int64 + + // Check mode: verify the file is sorted (matches GNU). + // GNU sort -c rejects multiple file operands. + if checkEnabled { + if len(files) > 1 { + callCtx.Errf("sort: extra operand %q not allowed with -c\n", files[1]) + return builtins.Result{Code: 2} + } + file := files[0] + lines, err := readFile(ctx, callCtx, file, &totalBytes) + if err != nil { + name := file + if file == "-" { + name = "standard input" + } + callCtx.Errf("sort: %s: %s\n", name, callCtx.PortableErr(err)) + return builtins.Result{Code: 2} + } + return checkSorted(ctx, callCtx, lines, cmpFn, checkSilent, *unique, file) + } + + // Read all lines from all files. + var allLines []string + for _, file := range files { + if ctx.Err() != nil { + return builtins.Result{Code: 1} + } + lines, err := readFile(ctx, callCtx, file, &totalBytes) + if err != nil { + name := file + if file == "-" { + name = "standard input" + } + callCtx.Errf("sort: %s: %s\n", name, callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + allLines = append(allLines, lines...) + if len(allLines) > MaxLines { + callCtx.Errf("sort: input exceeds maximum of %d lines\n", MaxLines) + return builtins.Result{Code: 1} + } + } + + // Sort the lines. Check ctx.Err() on each comparison so that + // context cancellation can interrupt sort operations promptly. + sortCmp := func(a, b string) int { + if ctx.Err() != nil { + return 0 + } + return cmpFn(a, b) + } + if *stable || *unique { + slices.SortStableFunc(allLines, sortCmp) + } else { + slices.SortFunc(allLines, sortCmp) + } + + // Unique: suppress consecutive equal lines. + if *unique { + allLines = dedup(ctx, allLines, cmpFn) + } + + // Output. + for _, line := range allLines { + if ctx.Err() != nil { + return builtins.Result{Code: 1} + } + callCtx.Outf("%s\n", line) + } + + return builtins.Result{} + } +} + +// readFile reads all lines from a file (or stdin for "-"), stripping trailing newlines. +// totalBytes is a shared counter across all files for cumulative byte tracking. +func readFile(ctx context.Context, callCtx *builtins.CallContext, file string, totalBytes *int64) ([]string, error) { + var rc io.ReadCloser + if file == "-" { + if callCtx.Stdin == nil { + return nil, nil + } + rc = io.NopCloser(callCtx.Stdin) + } else { + f, err := callCtx.OpenFile(ctx, file, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + defer f.Close() + rc = f + } + + sc := bufio.NewScanner(rc) + buf := make([]byte, 4096) + sc.Buffer(buf, MaxLineBytes+1) + sc.Split(scanLinesPreserveCR) + + var lines []string + for sc.Scan() { + if ctx.Err() != nil { + return nil, ctx.Err() + } + line := sc.Text() + *totalBytes += int64(len(line)) + if *totalBytes > MaxTotalBytes { + return nil, errors.New("input exceeds maximum total size") + } + lines = append(lines, line) + if len(lines) > MaxLines { + return nil, errors.New("too many input lines") + } + } + if err := sc.Err(); err != nil { + return nil, err + } + return lines, nil +} + +// keyOpts holds the modifier flags for a sort key or the global default. +type keyOpts struct { + numeric bool + reverse bool + ignBlanks bool + ignCase bool + dictOrder bool +} + +// keySpec represents a parsed -k KEYDEF. +type keySpec struct { + startField int // 1-based + startChar int // 1-based, 0 means whole field + endField int // 1-based, 0 means end of line + endChar int // 1-based, 0 means end of field + opts keyOpts + hasOpts bool // true if modifiers were specified on this key + startIgnBlanks bool // -b on start position (skip leading blanks for start offset) + endIgnBlanks bool // -b on end position (skip leading blanks for end offset) +} + +// parseKeyDef parses a KEYDEF string like "2,2" or "1.2n,1.3" or "2nr". +func parseKeyDef(s string) (keySpec, error) { + var k keySpec + startPart := s + endPart := "" + if ci := strings.IndexByte(s, ','); ci >= 0 { + startPart = s[:ci] + endPart = s[ci+1:] + if endPart == "" { + return k, errors.New("invalid number after ','") + } + } + + start, opts, err := parseFieldSpec(startPart) + if err != nil { + return k, err + } + if start.hasDot && start.char == 0 { + return k, errors.New("character offset is zero") + } + k.startField = start.field + k.startChar = start.char + if opts.hasAny { + k.opts = opts.ko + k.hasOpts = true + k.startIgnBlanks = opts.ko.ignBlanks + } + + if endPart != "" { + end, endOpts, err := parseFieldSpec(endPart) + if err != nil { + return k, err + } + k.endField = end.field + k.endChar = end.char + if endOpts.hasAny { + k.endIgnBlanks = endOpts.ko.ignBlanks + k.opts = mergeOpts(k.opts, endOpts.ko) + k.hasOpts = true + } + } + + if k.startField < 1 { + return k, errors.New("invalid key: field number must be positive") + } + if endPart != "" && k.endField < 1 { + return k, errors.New("invalid key: field number is zero") + } + // Validate incompatible per-key options: -d and -n cannot coexist. + if k.hasOpts && k.opts.dictOrder && k.opts.numeric { + return k, errors.New("options '-dn' are incompatible") + } + return k, nil +} + +type fieldPos struct { + field int + char int + hasDot bool +} + +type parsedOpts struct { + ko keyOpts + hasAny bool +} + +// parseFieldSpec parses "F[.C][OPTS]" returning field/char positions and options. +func parseFieldSpec(s string) (fieldPos, parsedOpts, error) { + var fp fieldPos + var po parsedOpts + + // Extract trailing option letters. + i := 0 + for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') { + i++ + } + numPart := s[:i] + optPart := s[i:] + + // Parse options. + for _, c := range optPart { + po.hasAny = true + switch c { + case 'n': + po.ko.numeric = true + case 'r': + po.ko.reverse = true + case 'b': + po.ko.ignBlanks = true + case 'f': + po.ko.ignCase = true + case 'd': + po.ko.dictOrder = true + default: + return fp, po, fmt.Errorf("invalid key option: %c", c) + } + } + + // Parse F[.C]. + dotIdx := strings.IndexByte(numPart, '.') + if dotIdx >= 0 { + f, err := strconv.Atoi(numPart[:dotIdx]) + if err != nil { + return fp, po, errors.New("invalid field number in key") + } + fp.field = f + c, err := strconv.Atoi(numPart[dotIdx+1:]) + if err != nil { + return fp, po, errors.New("invalid character position in key") + } + fp.char = c + fp.hasDot = true + } else { + if numPart == "" { + return fp, po, errors.New("empty field specification") + } + f, err := strconv.Atoi(numPart) + if err != nil { + return fp, po, errors.New("invalid field number in key") + } + fp.field = f + } + + return fp, po, nil +} + +func mergeOpts(a, b keyOpts) keyOpts { + if b.numeric { + a.numeric = true + } + if b.reverse { + a.reverse = true + } + if b.ignBlanks { + a.ignBlanks = true + } + if b.ignCase { + a.ignCase = true + } + if b.dictOrder { + a.dictOrder = true + } + return a +} + +// extractKey extracts the sort key substring from a line based on a keySpec. +// It works with byte positions in the original line to avoid reconstruction +// artifacts (e.g. synthetic joiners doubling blanks in blank-separated mode). +// ignBlanksStart/ignBlanksEnd control whether leading blanks are skipped +// when computing the start/end byte positions respectively (GNU sort -b). +func extractKey(line string, k keySpec, sep byte, hasSep bool, ignBlanksStart, ignBlanksEnd bool) string { + // Compute field start/end byte positions in the original line. + type fieldBound struct{ start, end int } + var bounds []fieldBound + + if hasSep { + pos := 0 + for { + idx := strings.IndexByte(line[pos:], sep) + if idx < 0 { + bounds = append(bounds, fieldBound{pos, len(line)}) + break + } + bounds = append(bounds, fieldBound{pos, pos + idx}) + pos = pos + idx + 1 + } + } else { + // Blank-separated: fields are contiguous substrings of line. + pos := 0 + for _, f := range splitBlankFields(line) { + bounds = append(bounds, fieldBound{pos, pos + len(f)}) + pos += len(f) + } + } + + sf := k.startField - 1 + if sf >= len(bounds) { + return "" + } + + // Compute start byte position. + keyStart := bounds[sf].start + if ignBlanksStart { + // GNU sort skips blanks past the field boundary (e.g. past + // an empty field into separator characters) when -b is set. + for keyStart < len(line) && (line[keyStart] == ' ' || line[keyStart] == '\t') { + keyStart++ + } + } + if k.startChar > 0 { + keyStart += k.startChar - 1 + } + if keyStart >= len(line) { + return "" + } + + // Compute end byte position. + if k.endField == 0 { + return line[keyStart:] + } + + ef := k.endField - 1 + if ef >= len(bounds) { + // End field beyond available fields — treat as end-of-line. + return line[keyStart:] + } + endFieldStart := bounds[ef].start + if ignBlanksEnd { + // GNU sort skips blanks past the field boundary for -b, + // matching the start-position behavior. + for endFieldStart < len(line) && (line[endFieldStart] == ' ' || line[endFieldStart] == '\t') { + endFieldStart++ + } + } + + keyEnd := bounds[ef].end + if k.endChar > 0 { + keyEnd = endFieldStart + k.endChar + } + if keyEnd > len(line) { + keyEnd = len(line) + } + if keyStart >= keyEnd { + return "" + } + return line[keyStart:keyEnd] +} + +// splitBlankFields splits a line into fields using blank-to-non-blank +// transitions. Each field includes any preceding blanks (matching POSIX/GNU +// sort behavior where leading blanks are significant unless -b is set). +// For example, " b c" splits into [" b", " c"]. +func splitBlankFields(line string) []string { + var fields []string + i := 0 + n := len(line) + for i < n { + start := i + // Include leading blanks as part of this field. + for i < n && (line[i] == ' ' || line[i] == '\t') { + i++ + } + if i >= n { + // Only blanks remain — preserve as a field so trailing + // blank fields are kept (matching GNU sort behavior). + fields = append(fields, line[start:]) + break + } + // Non-blank content of the field. + for i < n && line[i] != ' ' && line[i] != '\t' { + i++ + } + fields = append(fields, line[start:i]) + } + return fields +} + +// compareStrings compares two strings applying the given key options. +func compareStrings(a, b string, opts keyOpts) int { + if opts.ignBlanks { + a = trimLeadingBlanks(a) + b = trimLeadingBlanks(b) + } + if opts.dictOrder { + a = dictFilter(a) + b = dictFilter(b) + } + if opts.numeric { + return compareNumeric(a, b) + } + if opts.ignCase { + au := foldCase(a) + bu := foldCase(b) + if au < bu { + return -1 + } + if au > bu { + return 1 + } + return 0 + } + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} + +// trimLeadingBlanks strips leading spaces and tabs from s. Unlike +// strings.TrimSpace, it does NOT strip trailing whitespace — matching +// GNU sort -b behavior. +func trimLeadingBlanks(s string) string { + i := 0 + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + return s[i:] +} + +// foldCase converts a string to uppercase for case-insensitive comparison. +func foldCase(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'a' && c <= 'z' { + c -= 'a' - 'A' + } + b.WriteByte(c) + } + return b.String() +} + +// dictFilter removes non-blank, non-alphanumeric characters. +func dictFilter(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c == ' ' || c == '\t' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + b.WriteByte(c) + } + } + return b.String() +} + +// compareNumeric compares two strings as numbers using string-based +// comparison to avoid float64 precision loss for large integers. +// Handles optional leading whitespace, sign, and decimal point. +// Non-numeric strings compare as 0. Matches GNU sort -n behavior. +func compareNumeric(a, b string) int { + aNeg, aInt, aFrac := parseNumParts(a) + bNeg, bInt, bFrac := parseNumParts(b) + + // Different signs: negative < positive. + if aNeg != bNeg { + if aNeg { + return -1 + } + return 1 + } + + // Same sign — compare magnitudes, flip for negative. + c := compareMagnitude(aInt, aFrac, bInt, bFrac) + if aNeg { + c = -c + } + return c +} + +// compareMagnitude compares two non-negative numbers represented as +// integer and fractional digit strings. +func compareMagnitude(aInt, aFrac, bInt, bFrac string) int { + // Compare integer parts: longer digit string is larger. + if len(aInt) != len(bInt) { + if len(aInt) < len(bInt) { + return -1 + } + return 1 + } + // Same length: compare digit-by-digit. + if aInt < bInt { + return -1 + } + if aInt > bInt { + return 1 + } + // Integer parts equal: compare fractional parts. + // Pad shorter fraction with trailing zeros conceptually. + la, lb := len(aFrac), len(bFrac) + minLen := la + if lb < minLen { + minLen = lb + } + for i := 0; i < minLen; i++ { + if aFrac[i] < bFrac[i] { + return -1 + } + if aFrac[i] > bFrac[i] { + return 1 + } + } + // Check remaining digits (longer fraction with non-zero trailing digits is larger). + if la > lb { + for i := lb; i < la; i++ { + if aFrac[i] > '0' { + return 1 + } + } + } else if lb > la { + for i := la; i < lb; i++ { + if bFrac[i] > '0' { + return -1 + } + } + } + return 0 +} + +// parseNumParts extracts the sign, integer digit string, and fractional +// digit string from a numeric prefix. Returns (negative, intDigits, fracDigits). +// Non-numeric input returns (false, "0", ""), which compares as zero. +func parseNumParts(s string) (bool, string, string) { + // GNU sort -n only skips leading blanks (space/tab), not all + // Unicode whitespace. Use manual skip instead of TrimSpace. + i := 0 + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + s = s[i:] + if s == "" { + return false, "0", "" + } + neg := false + i = 0 + if s[i] == '-' { + neg = true + i++ + } + // GNU sort -n does NOT accept '+' as a sign prefix — treat +N as non-numeric. + if i >= len(s) || (s[i] < '0' || s[i] > '9') && s[i] != '.' { + return false, "0", "" + } + // Parse integer digits. + intStart := i + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + intPart := s[intStart:i] + // Strip leading zeros from integer part. + j := 0 + for j < len(intPart)-1 && intPart[j] == '0' { + j++ + } + intPart = intPart[j:] + // Canonicalize empty integer part (e.g. ".5") to "0". + if intPart == "" { + intPart = "0" + } + + // Parse fractional digits. + fracPart := "" + if i < len(s) && s[i] == '.' { + i++ + fracStart := i + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + fracPart = s[fracStart:i] + } + + // Check if the value is actually zero (e.g. "-0", "-0.0"). + if isZeroNum(intPart, fracPart) { + neg = false + } + return neg, intPart, fracPart +} + +// isZeroNum returns true if the integer and fractional parts represent zero. +func isZeroNum(intPart, fracPart string) bool { + for _, c := range intPart { + if c != '0' { + return false + } + } + for _, c := range fracPart { + if c != '0' { + return false + } + } + return true +} + +// scanLinesPreserveCR is a bufio.SplitFunc that splits on \n but preserves +// \r in the token (unlike bufio.ScanLines which strips \r from \r\n). +// This ensures CRLF data is round-tripped faithfully through sort. +func scanLinesPreserveCR(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + for i := 0; i < len(data); i++ { + if data[i] == '\n' { + return i + 1, data[:i], nil + } + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil +} + +// buildCompare constructs the comparison function for sorting. +func buildCompare(keys []keySpec, globalOpts keyOpts, sep byte, hasSep bool, stableSort bool) func(a, b string) int { + return func(a, b string) int { + if len(keys) > 0 { + for _, k := range keys { + opts := globalOpts + if k.hasOpts { + opts = k.opts + } + // Determine start/end blank-skipping independently. + // GNU sort applies -b per-position: start-b and end-b + // are tracked separately on the key spec. + startB := opts.ignBlanks + endB := opts.ignBlanks + if k.hasOpts { + startB = k.startIgnBlanks + endB = k.endIgnBlanks + } + ka := extractKey(a, k, sep, hasSep, startB, endB) + kb := extractKey(b, k, sep, hasSep, startB, endB) + // Don't apply -b again in compareStrings — extractKey + // already handled blank-skipping during position computation. + compOpts := opts + compOpts.ignBlanks = false + c := compareStrings(ka, kb, compOpts) + if opts.reverse { + c = -c + } + if c != 0 { + return c + } + } + } else { + c := compareStrings(a, b, globalOpts) + if globalOpts.reverse { + c = -c + } + if c != 0 { + return c + } + } + // Last-resort: raw byte comparison (unless stable/unique). + // GNU sort reverses the last-resort when -r is the global option. + if stableSort { + return 0 + } + c := 0 + if a < b { + c = -1 + } else if a > b { + c = 1 + } + if globalOpts.reverse { + c = -c + } + return c + } +} + +// checkSorted verifies that lines are already sorted according to cmpFn. +// When unique is true, equal adjacent lines are also treated as a disorder +// (matching GNU sort -c -u which checks for strict ordering). +// file is the filename used in the diagnostic message (or "-" for stdin). +func checkSorted(ctx context.Context, callCtx *builtins.CallContext, lines []string, cmpFn func(a, b string) int, silent bool, unique bool, file string) builtins.Result { + for i := 1; i < len(lines); i++ { + if ctx.Err() != nil { + return builtins.Result{Code: 1} + } + c := cmpFn(lines[i-1], lines[i]) + if c > 0 || (unique && c == 0) { + if !silent { + callCtx.Errf("sort: %s:%d: disorder: %s\n", file, i+1, lines[i]) + } + return builtins.Result{Code: 1} + } + } + return builtins.Result{} +} + +// dedup removes consecutive equal lines (per cmpFn). +func dedup(ctx context.Context, lines []string, cmpFn func(a, b string) int) []string { + if len(lines) == 0 { + return lines + } + result := []string{lines[0]} + for i := 1; i < len(lines); i++ { + if ctx.Err() != nil { + return result + } + if cmpFn(lines[i-1], lines[i]) != 0 { + result = append(result, lines[i]) + } + } + return result +} diff --git a/interp/builtins/sort/sort_test.go b/interp/builtins/sort/sort_test.go new file mode 100644 index 00000000..6c948c09 --- /dev/null +++ b/interp/builtins/sort/sort_test.go @@ -0,0 +1,581 @@ +// 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 sort_test + +import ( + "context" + "os" + "path/filepath" + "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 sort 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. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) +} + +// --- Default behavior (lexicographic sort) --- + +func TestSortDefault(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "banana\napple\ncherry\n") + stdout, _, code := cmdRun(t, "sort f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "apple\nbanana\ncherry\n", stdout) +} + +func TestSortAlreadySorted(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a\nb\nc\n") + stdout, _, code := cmdRun(t, "sort f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\nb\nc\n", stdout) +} + +func TestSortEmptyFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "") + stdout, _, code := cmdRun(t, "sort f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestSortSingleLine(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "only\n") + stdout, _, code := cmdRun(t, "sort f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "only\n", stdout) +} + +func TestSortSingleLineNoNewline(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "only") + stdout, _, code := cmdRun(t, "sort f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "only\n", stdout) +} + +func TestSortDuplicateLines(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\nb\na\n") + stdout, _, code := cmdRun(t, "sort f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\na\nb\nb\n", stdout) +} + +// --- Reverse sort --- + +func TestSortReverse(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "banana\napple\ncherry\n") + stdout, _, code := cmdRun(t, "sort -r f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "cherry\nbanana\napple\n", stdout) +} + +func TestSortReverseLong(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "banana\napple\ncherry\n") + stdout, _, code := cmdRun(t, "sort --reverse f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "cherry\nbanana\napple\n", stdout) +} + +func TestSortReverseLastResortTieBreaker(t *testing.T) { + // When -r is global and keys tie, last-resort comparison must also reverse. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a:1\na:2\nb:1\n") + stdout, _, code := cmdRun(t, "sort -r -t : -k 1,1 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "b:1\na:2\na:1\n", stdout) +} + +func TestSortNumericNonNumericAsZero(t *testing.T) { + // Non-numeric lines should compare as 0 in -n mode (matching GNU). + dir := t.TempDir() + writeFile(t, dir, "f.txt", "A\n0\nB\n") + stdout, _, code := cmdRun(t, "sort -n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "0\nA\nB\n", stdout) +} + +// --- Numeric sort --- + +func TestSortNumeric(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "10\n2\n1\n20\n") + stdout, _, code := cmdRun(t, "sort -n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "1\n2\n10\n20\n", stdout) +} + +func TestSortNumericReverse(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "10\n2\n1\n20\n") + stdout, _, code := cmdRun(t, "sort -n -r f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "20\n10\n2\n1\n", stdout) +} + +func TestSortNumericWithNonNumeric(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "10\nabc\n2\n") + stdout, _, code := cmdRun(t, "sort -n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "abc\n2\n10\n", stdout) +} + +func TestSortNumericNegative(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "5\n-3\n0\n-10\n") + stdout, _, code := cmdRun(t, "sort -n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "-10\n-3\n0\n5\n", stdout) +} + +// --- Unique --- + +func TestSortUnique(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\nb\na\nc\n") + stdout, _, code := cmdRun(t, "sort -u f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\nb\nc\n", stdout) +} + +func TestSortUniqueNumeric(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "2\n1\n2\n3\n1\n") + stdout, _, code := cmdRun(t, "sort -n -u f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "1\n2\n3\n", stdout) +} + +func TestSortUniqueCaseInsensitive(t *testing.T) { + // sort -f -u should treat A and a as equal (no last-resort byte comparison). + dir := t.TempDir() + writeFile(t, dir, "f.txt", "A\na\nB\nb\n") + stdout, _, code := cmdRun(t, "sort -f -u f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "A\nB\n", stdout) +} + +func TestSortCheckUniqueDuplicates(t *testing.T) { + // sort -c -u should fail on adjacent equal lines. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a\na\nb\n") + _, stderr, code := cmdRun(t, "sort -c -u f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "disorder") +} + +func TestSortCheckUniqueSorted(t *testing.T) { + // sort -c -u on strictly unique sorted input should succeed. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a\nb\nc\n") + _, _, code := cmdRun(t, "sort -c -u f.txt", dir) + assert.Equal(t, 0, code) +} + +func TestSortNumericLargeIntegers(t *testing.T) { + // sort -n should correctly order very large integers (beyond float64 precision). + dir := t.TempDir() + writeFile(t, dir, "f.txt", "100000000000000000000\n99999999999999999999\n") + stdout, _, code := cmdRun(t, "sort -n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "99999999999999999999\n100000000000000000000\n", stdout) +} + +func TestSortCheckInvalidValue(t *testing.T) { + // --check=foo should be rejected. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a\nb\n") + _, stderr, code := cmdRun(t, "sort --check=foo f.txt", dir) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid argument") +} + +func TestSortNumericDecimal(t *testing.T) { + // sort -n should handle decimal numbers. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "1.5\n1.3\n1.7\n1.05\n") + stdout, _, code := cmdRun(t, "sort -n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "1.05\n1.3\n1.5\n1.7\n", stdout) +} + +// --- Ignore case --- + +func TestSortIgnoreCase(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "Banana\napple\nCherry\n") + stdout, _, code := cmdRun(t, "sort -f f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "apple\nBanana\nCherry\n", stdout) +} + +// --- Dictionary order --- + +func TestSortDictionaryOrder(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b-b\na.a\nc_c\n") + stdout, _, code := cmdRun(t, "sort -d f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a.a\nb-b\nc_c\n", stdout) +} + +// --- Ignore leading blanks --- + +func TestSortIgnoreLeadingBlanks(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", " banana\napple\n cherry\n") + stdout, _, code := cmdRun(t, "sort -b f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "apple\n banana\n cherry\n", stdout) +} + +// --- Field separator and key --- + +func TestSortFieldSeparatorKey(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "c:3\na:1\nb:2\n") + stdout, _, code := cmdRun(t, "sort -t : -k 2 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a:1\nb:2\nc:3\n", stdout) +} + +func TestSortFieldSeparatorPreservedInKey(t *testing.T) { + // When -t is used, the separator must be preserved in multi-field keys. + // If we incorrectly join with space, "a b" and "a:b" would compare equal. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "x:a b\ny:a:b\n") + stdout, _, code := cmdRun(t, "sort -t : -k 2 f.txt", dir) + assert.Equal(t, 0, code) + // "a b" (single field containing space) < "a:b" (two fields joined with :) + // because ' ' (0x20) < ':' (0x3a) in byte comparison. + assert.Equal(t, "x:a b\ny:a:b\n", stdout) +} + +func TestSortNumericKey(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "c:30\na:1\nb:20\n") + stdout, _, code := cmdRun(t, "sort -t : -k 2n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a:1\nb:20\nc:30\n", stdout) +} + +func TestSortKeyWithFieldRange(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "c:3:z\na:1:x\nb:2:y\n") + stdout, _, code := cmdRun(t, "sort -t : -k 2,2n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a:1:x\nb:2:y\nc:3:z\n", stdout) +} + +func TestSortMultipleKeys(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a:2\nb:1\na:1\n") + stdout, _, code := cmdRun(t, "sort -t : -k 1,1 -k 2,2n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a:1\na:2\nb:1\n", stdout) +} + +// --- Check sorted --- + +func TestSortCheckSorted(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a\nb\nc\n") + _, _, code := cmdRun(t, "sort -c f.txt", dir) + assert.Equal(t, 0, code) +} + +func TestSortCheckUnsorted(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\nc\n") + _, stderr, code := cmdRun(t, "sort -c f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "disorder") +} + +func TestSortCheckSilentUnsorted(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\nc\n") + _, stderr, code := cmdRun(t, "sort -C f.txt", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stderr) +} + +func TestSortCheckSilentLongForm(t *testing.T) { + // --check=silent should work like -C. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\nc\n") + _, stderr, code := cmdRun(t, "sort --check=silent f.txt", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stderr) +} + +func TestSortCheckQuietLongForm(t *testing.T) { + // --check=quiet should work like -C. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\nc\n") + _, stderr, code := cmdRun(t, "sort --check=quiet f.txt", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stderr) +} + +func TestSortCheckMultipleFilesRejected(t *testing.T) { + // GNU sort -c rejects multiple file operands. + dir := t.TempDir() + writeFile(t, dir, "a.txt", "a\nb\n") + writeFile(t, dir, "b.txt", "a\nb\n") + _, stderr, code := cmdRun(t, "sort -c a.txt b.txt", dir) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "extra operand") +} + +// --- Stable sort --- + +func TestSortStable(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b:2\na:1\nb:1\na:2\n") + stdout, _, code := cmdRun(t, "sort -s -t : -k 1,1 f.txt", dir) + assert.Equal(t, 0, code) + // With stable sort, equal keys preserve input order. + assert.Equal(t, "a:1\na:2\nb:2\nb:1\n", stdout) +} + +// --- Stdin --- + +func TestSortStdin(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", "banana\napple\ncherry\n") + stdout, _, code := cmdRun(t, "sort < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "apple\nbanana\ncherry\n", stdout) +} + +func TestSortStdinDash(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "src.txt", "banana\napple\ncherry\n") + stdout, _, code := cmdRun(t, "sort - < src.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "apple\nbanana\ncherry\n", stdout) +} + +func TestSortPipe(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "banana\napple\ncherry\n") + stdout, _, code := cmdRun(t, "cat f.txt | sort", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "apple\nbanana\ncherry\n", stdout) +} + +// --- Multiple files --- + +func TestSortMultipleFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "cherry\napple\n") + writeFile(t, dir, "b.txt", "banana\ndate\n") + stdout, _, code := cmdRun(t, "sort a.txt b.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "apple\nbanana\ncherry\ndate\n", stdout) +} + +// --- Help --- + +func TestSortHelp(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "sort --help", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") + assert.Empty(t, stderr) +} + +func TestSortHelpShort(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "sort -h", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") + assert.Empty(t, stderr) +} + +// --- Error cases --- + +func TestSortMissingFile(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "sort nonexistent.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +func TestSortUnknownFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "sort --no-such-flag f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +func TestSortOutputFlagRejected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello\n") + _, stderr, code := cmdRun(t, "sort -o out.txt f.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +func TestSortOutsideAllowedPaths(t *testing.T) { + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret"), 0644)) + secretPath := filepath.ToSlash(filepath.Join(secret, "secret.txt")) + _, stderr, code := runScript(t, "sort "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "sort:") +} + +// --- Regression tests for codex review findings --- + +func TestSortNumericPlusPrefixNonNumeric(t *testing.T) { + // GNU sort -n treats +N as non-numeric (value 0), not as positive N. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "+2\n1\n3\n") + stdout, _, code := cmdRun(t, "sort -n f.txt", dir) + assert.Equal(t, 0, code) + // +2 is non-numeric (0), sorts first via last-resort byte cmp ('+' < '1') + assert.Equal(t, "+2\n1\n3\n", stdout) +} + +func TestSortNumericDotPrefix(t *testing.T) { + // .5 should compare as 0.5, not sort before 0.4. + dir := t.TempDir() + writeFile(t, dir, "f.txt", ".5\n0.4\n0.6\n") + stdout, _, code := cmdRun(t, "sort -n f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "0.4\n.5\n0.6\n", stdout) +} + +func TestSortEmptyTabRejected(t *testing.T) { + // sort -t '' should be rejected with "empty tab". + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a\nb\n") + _, stderr, code := cmdRun(t, `sort -t "" f.txt`, dir) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "empty tab") +} + +func TestSortKeyEndFieldZeroRejected(t *testing.T) { + // -k 1,0 should be rejected (zero field number). + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a\nb\n") + _, stderr, code := cmdRun(t, "sort -k 1,0 f.txt", dir) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "sort:") +} + +func TestSortCRLFPreserved(t *testing.T) { + // CRLF line endings must be preserved through sort. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\r\na\r\n") + stdout, _, code := cmdRun(t, "sort f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\r\nb\r\n", stdout) +} + +func TestSortCRLFOnlyInSomeLines(t *testing.T) { + // Mixed line endings: \r\n and \n. CR should be preserved per line. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\r\na\nc\r\n") + stdout, _, code := cmdRun(t, "sort f.txt", dir) + assert.Equal(t, 0, code) + // "a" < "b\r" < "c\r" because \r (0x0D) comes after \n but a < b < c + assert.Equal(t, "a\nb\r\nc\r\n", stdout) +} + +func TestSortWhitespaceOnlyLinePreservedAsField(t *testing.T) { + // A whitespace-only line should get a non-empty key for -k sorting. + dir := t.TempDir() + writeFile(t, dir, "f.txt", "\ta\n \n") + stdout, _, code := cmdRun(t, "sort -k 1 f.txt", dir) + assert.Equal(t, 0, code) + // "\ta" (tab+a) before " " (space) — tab (0x09) < space (0x20) + assert.Equal(t, "\ta\n \n", stdout) +} + +func TestSortKeyCharOffsetZeroRejected(t *testing.T) { + // -k1.0 should be rejected (character offset must be >= 1). + dir := t.TempDir() + writeFile(t, dir, "f.txt", "a\nb\n") + _, stderr, code := cmdRun(t, "sort -k 1.0 f.txt", dir) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "sort:") +} + +// --- Trailing blank field preservation --- + +func TestSortTrailingBlankFieldPreserved(t *testing.T) { + dir := t.TempDir() + // "a\n" and "a \n" differ in field 2 (empty vs blank), so -u -k2 keeps both. + writeFile(t, dir, "f.txt", "a\na \n") + stdout, _, code := cmdRun(t, "sort -u -k2 f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\na \n", stdout) +} + +// --- Unique keeps first of equal --- + +func TestSortUniqueKeepsFirstOfEqual(t *testing.T) { + dir := t.TempDir() + // All lines are numerically zero; -u must keep the first input line. + writeFile(t, dir, "f.txt", "B\nA\nC\n") + stdout, _, code := cmdRun(t, "sort -n -u f.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "B\n", stdout) +} + +// --- Context cancellation --- +// Note: builtin-level cancellation (checkSorted, sortCmp, dedup) is tested +// via unit tests in cancellation_test.go. Integration tests through the +// runner cannot test pre-cancelled contexts because the runner's shouldStop +// method catches context cancellation before dispatching to the builtin. + +func TestSortContextCancellation(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\nc\n") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, code := runScriptCtx(ctx, t, "sort f.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) +} diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 03608922..a6f6df6d 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -21,6 +21,7 @@ import ( "github.com/DataDog/rshell/interp/builtins/ls" printfcmd "github.com/DataDog/rshell/interp/builtins/printf" "github.com/DataDog/rshell/interp/builtins/sed" + sortcmd "github.com/DataDog/rshell/interp/builtins/sort" "github.com/DataDog/rshell/interp/builtins/strings_cmd" "github.com/DataDog/rshell/interp/builtins/tail" "github.com/DataDog/rshell/interp/builtins/testcmd" @@ -45,6 +46,7 @@ func registerBuiltins() { grep.Cmd, head.Cmd, ls.Cmd, + sortcmd.Cmd, printfcmd.Cmd, sed.Cmd, strings_cmd.Cmd, diff --git a/tests/allowed_symbols_test.go b/tests/allowed_symbols_test.go index 2bf9c829..f3fae568 100644 --- a/tests/allowed_symbols_test.go +++ b/tests/allowed_symbols_test.go @@ -48,6 +48,8 @@ var builtinAllowedSymbols = []string{ "errors.Is", // errors.New — creates a simple error value; pure function, no I/O. "errors.New", + // fmt.Errorf — creates a formatted error value; pure function, no I/O. + "fmt.Errorf", // fmt.Sprintf — string formatting; pure function, no I/O. "fmt.Sprintf", // io/fs.DirEntry — interface type for directory entries; no side effects. @@ -104,6 +106,8 @@ var builtinAllowedSymbols = []string{ "slices.Reverse", // slices.SortFunc — sorts a slice with a comparison function; pure function, no I/O. "slices.SortFunc", + // slices.SortStableFunc — stable sort with a comparison function; pure function, no I/O. + "slices.SortStableFunc", // strings.Builder — efficient string concatenation; pure in-memory buffer, no I/O. "strings.Builder", // strings.ContainsRune — checks if a rune is in a string; pure function, no I/O. diff --git a/tests/scenarios/cmd/sort/basic/default_sort.yaml b/tests/scenarios/cmd/sort/basic/default_sort.yaml new file mode 100644 index 00000000..01397b98 --- /dev/null +++ b/tests/scenarios/cmd/sort/basic/default_sort.yaml @@ -0,0 +1,13 @@ +description: sort outputs lines in lexicographic order by default. +setup: + files: + - path: file.txt + content: "banana\napple\ncherry\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort file.txt +expect: + stdout: "apple\nbanana\ncherry\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/basic/empty_file.yaml b/tests/scenarios/cmd/sort/basic/empty_file.yaml new file mode 100644 index 00000000..09fa9bd1 --- /dev/null +++ b/tests/scenarios/cmd/sort/basic/empty_file.yaml @@ -0,0 +1,13 @@ +description: sort on an empty file produces no output. +setup: + files: + - path: file.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + sort file.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/basic/multiple_files.yaml b/tests/scenarios/cmd/sort/basic/multiple_files.yaml new file mode 100644 index 00000000..21578676 --- /dev/null +++ b/tests/scenarios/cmd/sort/basic/multiple_files.yaml @@ -0,0 +1,15 @@ +description: sort concatenates and sorts lines from multiple files. +setup: + files: + - path: a.txt + content: "cherry\napple\n" + - path: b.txt + content: "banana\ndate\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort a.txt b.txt +expect: + stdout: "apple\nbanana\ncherry\ndate\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/basic/pipe.yaml b/tests/scenarios/cmd/sort/basic/pipe.yaml new file mode 100644 index 00000000..2adbb006 --- /dev/null +++ b/tests/scenarios/cmd/sort/basic/pipe.yaml @@ -0,0 +1,13 @@ +description: sort works correctly in a pipeline. +setup: + files: + - path: file.txt + content: "banana\napple\ncherry\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat file.txt | sort +expect: + stdout: "apple\nbanana\ncherry\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/basic/single_line.yaml b/tests/scenarios/cmd/sort/basic/single_line.yaml new file mode 100644 index 00000000..d2f236ac --- /dev/null +++ b/tests/scenarios/cmd/sort/basic/single_line.yaml @@ -0,0 +1,13 @@ +description: sort on a single-line file outputs that line. +setup: + files: + - path: file.txt + content: "only\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort file.txt +expect: + stdout: "only\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/basic/stdin.yaml b/tests/scenarios/cmd/sort/basic/stdin.yaml new file mode 100644 index 00000000..3a91e936 --- /dev/null +++ b/tests/scenarios/cmd/sort/basic/stdin.yaml @@ -0,0 +1,13 @@ +description: sort reads from stdin when no file arguments are given. +setup: + files: + - path: src.txt + content: "banana\napple\ncherry\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort < src.txt +expect: + stdout: "apple\nbanana\ncherry\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/errors/missing_file.yaml b/tests/scenarios/cmd/sort/errors/missing_file.yaml new file mode 100644 index 00000000..8dc003ad --- /dev/null +++ b/tests/scenarios/cmd/sort/errors/missing_file.yaml @@ -0,0 +1,12 @@ +description: sort reports error for a nonexistent file. +setup: + files: [] +input: + allowed_paths: ["$DIR"] + script: |+ + sort nonexistent.txt +expect: + stdout: "" + stderr_contains: ["sort:"] + exit_code: 1 +skip_assert_against_bash: true # error message format differs diff --git a/tests/scenarios/cmd/sort/errors/unknown_flag.yaml b/tests/scenarios/cmd/sort/errors/unknown_flag.yaml new file mode 100644 index 00000000..eac9ff79 --- /dev/null +++ b/tests/scenarios/cmd/sort/errors/unknown_flag.yaml @@ -0,0 +1,14 @@ +description: sort rejects unknown flags. +setup: + files: + - path: f.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort --no-such-flag f.txt +expect: + stdout: "" + stderr_contains: ["sort:"] + exit_code: 1 +skip_assert_against_bash: true # error message format differs diff --git a/tests/scenarios/cmd/sort/flags/check_empty_value_reject.yaml b/tests/scenarios/cmd/sort/flags/check_empty_value_reject.yaml new file mode 100644 index 00000000..ca77086d --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/check_empty_value_reject.yaml @@ -0,0 +1,14 @@ +description: "sort rejects --check= (empty value) as invalid argument." +setup: + files: + - path: data.txt + content: "a\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort --check= data.txt +skip_assert_against_bash: true +expect: + stdout: "" + stderr_contains: ["invalid argument"] + exit_code: 2 diff --git a/tests/scenarios/cmd/sort/flags/check_mixed_modes_reject.yaml b/tests/scenarios/cmd/sort/flags/check_mixed_modes_reject.yaml new file mode 100644 index 00000000..3f8a4684 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/check_mixed_modes_reject.yaml @@ -0,0 +1,14 @@ +description: "sort rejects mixed diagnose/silent check modes." +setup: + files: + - path: data.txt + content: "a\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -c --check=silent data.txt +skip_assert_against_bash: true +expect: + stdout: "" + stderr: "sort: options '-cC' are incompatible\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/sort/flags/check_read_error_exit2.yaml b/tests/scenarios/cmd/sort/flags/check_read_error_exit2.yaml new file mode 100644 index 00000000..2c647fb9 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/check_read_error_exit2.yaml @@ -0,0 +1,10 @@ +description: "sort -c returns exit code 2 for read errors (not 1 which means disorder)." +input: + allowed_paths: ["$DIR"] + script: |+ + sort -c nonexistent.txt +skip_assert_against_bash: true +expect: + stdout: "" + stderr_contains: ["nonexistent.txt"] + exit_code: 2 diff --git a/tests/scenarios/cmd/sort/flags/check_silent.yaml b/tests/scenarios/cmd/sort/flags/check_silent.yaml new file mode 100644 index 00000000..98ff5ed1 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/check_silent.yaml @@ -0,0 +1,13 @@ +description: sort -C exits 1 with no diagnostic when input is not sorted. +setup: + files: + - path: file.txt + content: "b\na\nc\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -C file.txt +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/cmd/sort/flags/check_silent_equivalent.yaml b/tests/scenarios/cmd/sort/flags/check_silent_equivalent.yaml new file mode 100644 index 00000000..0c0b4690 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/check_silent_equivalent.yaml @@ -0,0 +1,13 @@ +description: "sort accepts -C with --check=silent (equivalent forms)." +setup: + files: + - path: data.txt + content: "a\nb\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -C --check=silent data.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/check_sorted.yaml b/tests/scenarios/cmd/sort/flags/check_sorted.yaml new file mode 100644 index 00000000..f470def3 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/check_sorted.yaml @@ -0,0 +1,13 @@ +description: sort -c exits 0 when input is sorted. +setup: + files: + - path: file.txt + content: "a\nb\nc\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -c file.txt +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/check_unsorted.yaml b/tests/scenarios/cmd/sort/flags/check_unsorted.yaml new file mode 100644 index 00000000..7911a55e --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/check_unsorted.yaml @@ -0,0 +1,13 @@ +description: sort -c exits 1 and prints disorder diagnostic when input is not sorted. +setup: + files: + - path: file.txt + content: "b\na\nc\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -c file.txt +expect: + stdout: "" + stderr: "sort: file.txt:2: disorder: a\n" + exit_code: 1 diff --git a/tests/scenarios/cmd/sort/flags/field_separator_key.yaml b/tests/scenarios/cmd/sort/flags/field_separator_key.yaml new file mode 100644 index 00000000..8542c21b --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/field_separator_key.yaml @@ -0,0 +1,13 @@ +description: sort -t and -k sort by a specific field. +setup: + files: + - path: file.txt + content: "c:3\na:1\nb:2\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -t : -k 2 file.txt +expect: + stdout: "a:1\nb:2\nc:3\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/ignore_case.yaml b/tests/scenarios/cmd/sort/flags/ignore_case.yaml new file mode 100644 index 00000000..e225d5de --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/ignore_case.yaml @@ -0,0 +1,13 @@ +description: sort -f folds lowercase to uppercase for comparison. +setup: + files: + - path: file.txt + content: "Banana\napple\nCherry\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -f file.txt +expect: + stdout: "apple\nBanana\nCherry\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/incompatible_check_flags.yaml b/tests/scenarios/cmd/sort/flags/incompatible_check_flags.yaml new file mode 100644 index 00000000..a438071a --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/incompatible_check_flags.yaml @@ -0,0 +1,14 @@ +description: "sort rejects conflicting -c and -C check flags." +setup: + files: + - path: data.txt + content: "a\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -c -C data.txt +skip_assert_against_bash: true +expect: + stdout: "" + stderr: "sort: options '-cC' are incompatible\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/sort/flags/incompatible_flags.yaml b/tests/scenarios/cmd/sort/flags/incompatible_flags.yaml new file mode 100644 index 00000000..75767dd9 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/incompatible_flags.yaml @@ -0,0 +1,14 @@ +description: "sort rejects incompatible flag combinations matching GNU behavior." +setup: + files: + - path: data.txt + content: "a\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -n -d data.txt +skip_assert_against_bash: true +expect: + stdout: "" + stderr: "sort: options '-dn' are incompatible\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/sort/flags/incompatible_flags_deferred.yaml b/tests/scenarios/cmd/sort/flags/incompatible_flags_deferred.yaml new file mode 100644 index 00000000..493d970f --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/incompatible_flags_deferred.yaml @@ -0,0 +1,13 @@ +description: "sort -d -n accepted when all keys have per-key opts that override globals." +setup: + files: + - path: data.txt + content: "b\na\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -d -n -k1,1n data.txt +expect: + stdout: "a\nb\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/incompatible_flags_per_key.yaml b/tests/scenarios/cmd/sort/flags/incompatible_flags_per_key.yaml new file mode 100644 index 00000000..8bc558d0 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/incompatible_flags_per_key.yaml @@ -0,0 +1,14 @@ +description: "sort rejects incompatible per-key flag combinations." +setup: + files: + - path: data.txt + content: "a\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 1nd data.txt +skip_assert_against_bash: true +expect: + stdout: "" + stderr: "sort: options '-dn' are incompatible\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/sort/flags/key_b_skips_past_empty_field.yaml b/tests/scenarios/cmd/sort/flags/key_b_skips_past_empty_field.yaml new file mode 100644 index 00000000..66c239ea --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_b_skips_past_empty_field.yaml @@ -0,0 +1,13 @@ +description: "sort -b skips blanks past empty field boundary with -t separator." +setup: + files: + - path: data.txt + content: "\t\tabc\tz\naaaa\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -t ' ' -k 1.4b,4b data.txt +expect: + stdout: "\t\tabc\tz\naaaa\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_char_offset_with_blanks.yaml b/tests/scenarios/cmd/sort/flags/key_char_offset_with_blanks.yaml new file mode 100644 index 00000000..25b8efd1 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_char_offset_with_blanks.yaml @@ -0,0 +1,13 @@ +description: "sort -k character offsets count from field start including leading blanks (GNU behavior)." +setup: + files: + - path: blanks.txt + content: " x\nA\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 1.2,1.3 blanks.txt +expect: + stdout: "A\n x\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_char_past_field.yaml b/tests/scenarios/cmd/sort/flags/key_char_past_field.yaml new file mode 100644 index 00000000..d07b5e58 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_char_past_field.yaml @@ -0,0 +1,13 @@ +description: "sort -k with startChar past field end continues into the line." +setup: + files: + - path: data.txt + content: "abc\n: c3\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 1.3 data.txt +expect: + stdout: "abc\n: c3\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_end_b_independent.yaml b/tests/scenarios/cmd/sort/flags/key_end_b_independent.yaml new file mode 100644 index 00000000..3d5b403e --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_end_b_independent.yaml @@ -0,0 +1,13 @@ +description: "sort -k with -b on end position only does not affect start position blank-skipping." +setup: + files: + - path: data.txt + content: ":3\na-+\t\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -u -k 2,2b data.txt +expect: + stdout: ":3\na-+\t\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_end_before_start.yaml b/tests/scenarios/cmd/sort/flags/key_end_before_start.yaml new file mode 100644 index 00000000..98ad8009 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_end_before_start.yaml @@ -0,0 +1,21 @@ +description: "sort -k with end field before start field treats key as zero-width (GNU behavior)." +setup: + files: + - path: multi.txt + content: "1 b\n2 a\n3 c\n" + - path: single.txt + content: "b\na\nc\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 2,1 multi.txt + echo --- + sort -k 2,1 -u multi.txt + echo --- + sort -k 2,1 single.txt + echo --- + sort -k 2,1 -u single.txt +expect: + stdout: "1 b\n2 a\n3 c\n---\n1 b\n---\na\nb\nc\n---\nb\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_end_before_start_with_char.yaml b/tests/scenarios/cmd/sort/flags/key_end_before_start_with_char.yaml new file mode 100644 index 00000000..877c68c5 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_end_before_start_with_char.yaml @@ -0,0 +1,13 @@ +description: "sort -k with end field before start field but char offset produces non-empty key." +setup: + files: + - path: data.txt + content: "c\tx\nb\t_ c\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 3,2.3 data.txt +expect: + stdout: "c\tx\nb\t_ c\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_end_char_past_field.yaml b/tests/scenarios/cmd/sort/flags/key_end_char_past_field.yaml new file mode 100644 index 00000000..f125d904 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_end_char_past_field.yaml @@ -0,0 +1,13 @@ +description: "sort -k with end char offset past field allows extending into separator." +setup: + files: + - path: data.txt + content: "x:a b\ny:a:b\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -t : -k 2.2,2.2 data.txt +expect: + stdout: "x:a b\ny:a:b\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_end_field_out_of_range.yaml b/tests/scenarios/cmd/sort/flags/key_end_field_out_of_range.yaml new file mode 100644 index 00000000..96f9009e --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_end_field_out_of_range.yaml @@ -0,0 +1,15 @@ +description: "sort -k with end field beyond available fields treats as end-of-line." +setup: + files: + - path: data.txt + content: "abc\nxyz\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 1.2,2.1 data.txt + echo --- + sort -k 1.2,2.1 -u data.txt +expect: + stdout: "abc\nxyz\n---\nabc\nxyz\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_ignore_blanks_offset.yaml b/tests/scenarios/cmd/sort/flags/key_ignore_blanks_offset.yaml new file mode 100644 index 00000000..7dac90c8 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_ignore_blanks_offset.yaml @@ -0,0 +1,13 @@ +description: "sort -k with -b skips leading blanks before computing char offset." +setup: + files: + - path: data.txt + content: " aa\n ba\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 1.2b data.txt +expect: + stdout: " ba\n aa\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_no_double_trim_blanks.yaml b/tests/scenarios/cmd/sort/flags/key_no_double_trim_blanks.yaml new file mode 100644 index 00000000..675a17eb --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_no_double_trim_blanks.yaml @@ -0,0 +1,13 @@ +description: "sort -k with -b does not double-trim extracted keys during comparison." +setup: + files: + - path: data.txt + content: "0\t\t\na\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 1.3b,4 data.txt +expect: + stdout: "a\n0\t\t\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_stop_char_zero.yaml b/tests/scenarios/cmd/sort/flags/key_stop_char_zero.yaml new file mode 100644 index 00000000..96668eab --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_stop_char_zero.yaml @@ -0,0 +1,13 @@ +description: "sort accepts .0 on stop position of key definition." +setup: + files: + - path: data.txt + content: "b\na\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 1.1,1.0 data.txt +expect: + stdout: "a\nb\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/key_trailing_comma.yaml b/tests/scenarios/cmd/sort/flags/key_trailing_comma.yaml new file mode 100644 index 00000000..16cdaba5 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/key_trailing_comma.yaml @@ -0,0 +1,14 @@ +description: "sort rejects -k with trailing comma." +setup: + files: + - path: data.txt + content: "a\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -k 1, data.txt +skip_assert_against_bash: true +expect: + stdout: "" + stderr_contains: ["invalid number after ','"] + exit_code: 2 diff --git a/tests/scenarios/cmd/sort/flags/numeric.yaml b/tests/scenarios/cmd/sort/flags/numeric.yaml new file mode 100644 index 00000000..7f56a697 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/numeric.yaml @@ -0,0 +1,13 @@ +description: sort -n sorts by numerical value. +setup: + files: + - path: file.txt + content: "10\n2\n1\n20\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -n file.txt +expect: + stdout: "1\n2\n10\n20\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/numeric_key.yaml b/tests/scenarios/cmd/sort/flags/numeric_key.yaml new file mode 100644 index 00000000..2c20e1b3 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/numeric_key.yaml @@ -0,0 +1,14 @@ +description: sort -k with n modifier sorts field numerically. +setup: + files: + - path: file.txt + content: "c:30\na:1\nb:20\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -t : -k 2n file.txt +expect: + stdout: "a:1\nb:20\nc:30\n" + stderr: "" + exit_code: 0 +skip_assert_against_bash: true # -k 2n glued syntax may differ diff --git a/tests/scenarios/cmd/sort/flags/numeric_vtab_non_numeric.yaml b/tests/scenarios/cmd/sort/flags/numeric_vtab_non_numeric.yaml new file mode 100644 index 00000000..58102590 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/numeric_vtab_non_numeric.yaml @@ -0,0 +1,13 @@ +description: "sort -n treats vertical tab prefix as non-numeric (not whitespace)." +setup: + files: + - path: data.txt + content: "\v2\n1\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -n data.txt +expect: + stdout: "\v2\n1\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/reverse.yaml b/tests/scenarios/cmd/sort/flags/reverse.yaml new file mode 100644 index 00000000..a8ad4957 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/reverse.yaml @@ -0,0 +1,13 @@ +description: sort -r reverses the sort order. +setup: + files: + - path: file.txt + content: "banana\napple\ncherry\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -r file.txt +expect: + stdout: "cherry\nbanana\napple\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/flags/unique.yaml b/tests/scenarios/cmd/sort/flags/unique.yaml new file mode 100644 index 00000000..220c0d42 --- /dev/null +++ b/tests/scenarios/cmd/sort/flags/unique.yaml @@ -0,0 +1,13 @@ +description: sort -u suppresses duplicate lines. +setup: + files: + - path: file.txt + content: "b\na\nb\na\nc\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -u file.txt +expect: + stdout: "a\nb\nc\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/sort/hardening/output_flag_rejected.yaml b/tests/scenarios/cmd/sort/hardening/output_flag_rejected.yaml new file mode 100644 index 00000000..cb0f703f --- /dev/null +++ b/tests/scenarios/cmd/sort/hardening/output_flag_rejected.yaml @@ -0,0 +1,15 @@ +# Per RULES.md: -o writes to filesystem and must be rejected +description: sort rejects the -o flag which writes to the filesystem. +skip_assert_against_bash: true # intentional restriction; -o is a valid GNU sort flag +setup: + files: + - path: f.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort -o out.txt f.txt +expect: + stdout: "" + stderr_contains: ["sort:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/sort/hardening/outside_allowed_paths.yaml b/tests/scenarios/cmd/sort/hardening/outside_allowed_paths.yaml new file mode 100644 index 00000000..01b6b851 --- /dev/null +++ b/tests/scenarios/cmd/sort/hardening/outside_allowed_paths.yaml @@ -0,0 +1,15 @@ +# Per RULES.md: file access must be sandboxed via AllowedPaths +description: sort is blocked from reading files outside the allowed paths sandbox. +skip_assert_against_bash: true # intentional sandbox restriction; bash/sort can read /etc/passwd freely +setup: + files: + - path: local.txt + content: "local\n" +input: + allowed_paths: ["$DIR"] + script: |+ + sort /etc/passwd +expect: + stdout: "" + stderr_contains: ["sort:"] + exit_code: 1