From 3c53f7b5fa86fde96e71d528f1daa1174c15ef25 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 11:18:52 +0100 Subject: [PATCH 01/20] Remove test/[ builtin implementation and all associated tests Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 557 ------------------ .../testcmd/testcmd_gnu_compat_test.go | 165 ------ .../builtins/testcmd/testcmd_pentest_test.go | 314 ---------- interp/builtins/testcmd/testcmd_test.go | 507 ---------------- interp/builtins/testcmd/testcmd_unix_test.go | 66 --- .../builtins/testcmd/testcmd_windows_test.go | 27 - interp/register_builtins.go | 3 - tests/scenarios/cmd/test/bracket/basic.yaml | 17 - .../cmd/test/bracket/missing_bracket.yaml | 9 - .../cmd/test/errors/unary_expected.yaml | 7 - tests/scenarios/cmd/test/files/directory.yaml | 20 - .../cmd/test/files/empty_operand.yaml | 17 - tests/scenarios/cmd/test/files/existence.yaml | 30 - tests/scenarios/cmd/test/files/size.yaml | 25 - .../scenarios/cmd/test/help/bracket_help.yaml | 9 - tests/scenarios/cmd/test/help/test_help.yaml | 9 - tests/scenarios/cmd/test/integers/basic.yaml | 32 - .../scenarios/cmd/test/integers/invalid.yaml | 9 - .../scenarios/cmd/test/integers/negative.yaml | 17 - tests/scenarios/cmd/test/logical/and_or.yaml | 26 - tests/scenarios/cmd/test/logical/not.yaml | 17 - .../cmd/test/logical/parentheses.yaml | 17 - .../cmd/test/logical/special_operands.yaml | 23 - .../cmd/test/strings/bare_string.yaml | 9 - .../cmd/test/strings/comparison.yaml | 20 - .../cmd/test/strings/empty_string.yaml | 9 - .../cmd/test/strings/empty_test.yaml | 9 - .../scenarios/cmd/test/strings/equality.yaml | 20 - .../cmd/test/strings/n_nonempty.yaml | 9 - .../cmd/test/strings/special_single_args.yaml | 23 - tests/scenarios/cmd/test/strings/z_empty.yaml | 9 - 31 files changed, 2031 deletions(-) delete mode 100644 interp/builtins/testcmd/testcmd.go delete mode 100644 interp/builtins/testcmd/testcmd_gnu_compat_test.go delete mode 100644 interp/builtins/testcmd/testcmd_pentest_test.go delete mode 100644 interp/builtins/testcmd/testcmd_test.go delete mode 100644 interp/builtins/testcmd/testcmd_unix_test.go delete mode 100644 interp/builtins/testcmd/testcmd_windows_test.go delete mode 100644 tests/scenarios/cmd/test/bracket/basic.yaml delete mode 100644 tests/scenarios/cmd/test/bracket/missing_bracket.yaml delete mode 100644 tests/scenarios/cmd/test/errors/unary_expected.yaml delete mode 100644 tests/scenarios/cmd/test/files/directory.yaml delete mode 100644 tests/scenarios/cmd/test/files/empty_operand.yaml delete mode 100644 tests/scenarios/cmd/test/files/existence.yaml delete mode 100644 tests/scenarios/cmd/test/files/size.yaml delete mode 100644 tests/scenarios/cmd/test/help/bracket_help.yaml delete mode 100644 tests/scenarios/cmd/test/help/test_help.yaml delete mode 100644 tests/scenarios/cmd/test/integers/basic.yaml delete mode 100644 tests/scenarios/cmd/test/integers/invalid.yaml delete mode 100644 tests/scenarios/cmd/test/integers/negative.yaml delete mode 100644 tests/scenarios/cmd/test/logical/and_or.yaml delete mode 100644 tests/scenarios/cmd/test/logical/not.yaml delete mode 100644 tests/scenarios/cmd/test/logical/parentheses.yaml delete mode 100644 tests/scenarios/cmd/test/logical/special_operands.yaml delete mode 100644 tests/scenarios/cmd/test/strings/bare_string.yaml delete mode 100644 tests/scenarios/cmd/test/strings/comparison.yaml delete mode 100644 tests/scenarios/cmd/test/strings/empty_string.yaml delete mode 100644 tests/scenarios/cmd/test/strings/empty_test.yaml delete mode 100644 tests/scenarios/cmd/test/strings/equality.yaml delete mode 100644 tests/scenarios/cmd/test/strings/n_nonempty.yaml delete mode 100644 tests/scenarios/cmd/test/strings/special_single_args.yaml delete mode 100644 tests/scenarios/cmd/test/strings/z_empty.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go deleted file mode 100644 index 65da9c9b..00000000 --- a/interp/builtins/testcmd/testcmd.go +++ /dev/null @@ -1,557 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed -// under the Apache License Version 2.0. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2026-present Datadog, Inc. - -// Package testcmd implements the POSIX test and [ builtin commands. -// -// Usage: -// -// test EXPRESSION -// [ EXPRESSION ] -// -// Evaluate a conditional expression and exit with status 0 (true) or 1 (false). -// The [ form requires a closing ] as the last argument. -// -// Exit codes: -// -// 0 — expression evaluates to true -// 1 — expression evaluates to false -// 2 — syntax/usage error -// -// Supported operators: -// -// File tests (unary): -// -// -e FILE FILE exists -// -f FILE FILE exists and is a regular file -// -d FILE FILE exists and is a directory -// -s FILE FILE exists and has size greater than zero -// -r FILE FILE exists and is readable -// -w FILE FILE exists and is writable -// -x FILE FILE exists and is executable -// -h FILE FILE exists and is a symbolic link -// -L FILE FILE exists and is a symbolic link (same as -h) -// -p FILE FILE exists and is a named pipe (FIFO) -// -// File comparison (binary): -// -// FILE1 -nt FILE2 FILE1 is newer (modification time) than FILE2 -// FILE1 -ot FILE2 FILE1 is older (modification time) than FILE2 -// -// String tests (unary): -// -// -z STRING length of STRING is zero -// -n STRING length of STRING is non-zero -// STRING STRING is non-empty (equivalent to -n STRING) -// -// String comparison (binary): -// -// S1 = S2 strings are equal -// S1 != S2 strings are not equal -// S1 < S2 S1 sorts before S2 (lexicographic) -// S1 > S2 S1 sorts after S2 (lexicographic) -// -// Integer comparison (binary): -// -// N1 -eq N2 integers are equal -// N1 -ne N2 integers are not equal -// N1 -lt N2 N1 is less than N2 -// N1 -le N2 N1 is less than or equal to N2 -// N1 -gt N2 N1 is greater than N2 -// N1 -ge N2 N1 is greater than or equal to N2 -// -// Logical operators: -// -// ! EXPR EXPR is false -// EXPR1 -a EXPR2 both EXPR1 and EXPR2 are true -// EXPR1 -o EXPR2 either EXPR1 or EXPR2 is true -// ( EXPR ) grouping (parentheses must be shell-escaped) -package testcmd - -import ( - "context" - "io/fs" - "math" - "strconv" - "strings" - - "github.com/DataDog/rshell/interp/builtins" -) - -// Cmd is the "test" builtin command registration. -var Cmd = builtins.Command{Name: "test", MakeFlags: builtins.NoFlags(runTest)} - -// BracketCmd is the "[" builtin command registration. -var BracketCmd = builtins.Command{Name: "[", MakeFlags: builtins.NoFlags(runBracket)} - -const helpText = `Usage: test EXPRESSION - or: [ EXPRESSION ] - -Evaluate conditional expression. - -Exit status: - 0 if EXPRESSION is true, - 1 if EXPRESSION is false, - 2 if an error occurred. - -File tests: - -e FILE FILE exists - -f FILE FILE is a regular file - -d FILE FILE is a directory - -s FILE FILE has size > 0 - -r FILE FILE is readable - -w FILE FILE is writable - -x FILE FILE is executable - -h FILE FILE is a symbolic link - -L FILE FILE is a symbolic link (same as -h) - -p FILE FILE is a named pipe - -File comparison: - FILE1 -nt FILE2 FILE1 is newer than FILE2 - FILE1 -ot FILE2 FILE1 is older than FILE2 - -String tests: - -z STRING STRING has zero length - -n STRING STRING has non-zero length - STRING STRING is non-empty - -String comparison: - S1 = S2 strings are equal - S1 != S2 strings are not equal - S1 < S2 S1 sorts before S2 - S1 > S2 S1 sorts after S2 - -Integer comparison: - N1 -eq N2 N1 equals N2 - N1 -ne N2 N1 is not equal to N2 - N1 -lt N2 N1 is less than N2 - N1 -le N2 N1 is less or equal to N2 - N1 -gt N2 N1 is greater than N2 - N1 -ge N2 N1 is greater or equal to N2 - -Logical: - ! EXPR EXPR is false - EXPR1 -a EXPR2 both true - EXPR1 -o EXPR2 either true - ( EXPR ) grouping -` - -const exitSyntaxError = 2 - -func runTest(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { - if len(args) == 1 && args[0] == "--help" { - callCtx.Out(helpText) - return builtins.Result{} - } - return evaluate(ctx, callCtx, "test", args) -} - -func runBracket(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { - if len(args) == 1 && args[0] == "--help" { - callCtx.Out(helpText) - return builtins.Result{} - } - if len(args) == 0 || args[len(args)-1] != "]" { - callCtx.Errf("[: missing `]'\n") - return builtins.Result{Code: exitSyntaxError} - } - return evaluate(ctx, callCtx, "[", args[:len(args)-1]) -} - -// parser holds the state for recursive-descent parsing of test expressions. -type parser struct { - ctx context.Context - callCtx *builtins.CallContext - cmdName string - args []string - pos int - err bool - depth int -} - -const maxParenDepth = 128 - -func evaluate(ctx context.Context, callCtx *builtins.CallContext, cmdName string, args []string) builtins.Result { - if len(args) == 0 { - return builtins.Result{Code: 1} - } - - p := &parser{ - ctx: ctx, - callCtx: callCtx, - cmdName: cmdName, - args: args, - } - - result := p.parseOr() - - if p.err { - return builtins.Result{Code: exitSyntaxError} - } - if p.pos < len(p.args) { - p.callCtx.Errf("%s: extra argument '%s'\n", p.cmdName, p.args[p.pos]) - return builtins.Result{Code: exitSyntaxError} - } - if result { - return builtins.Result{} - } - return builtins.Result{Code: 1} -} - -func (p *parser) peek() string { - if p.pos >= len(p.args) { - return "" - } - return p.args[p.pos] -} - -func (p *parser) advance() string { - s := p.args[p.pos] - p.pos++ - return s -} - -// parseOr handles EXPR1 -o EXPR2 (lowest precedence). -func (p *parser) parseOr() bool { - left := p.parseAnd() - for !p.err && p.ctx.Err() == nil && p.pos < len(p.args) && p.peek() == "-o" { - p.advance() - right := p.parseAnd() - left = left || right - } - return left -} - -// parseAnd handles EXPR1 -a EXPR2. -func (p *parser) parseAnd() bool { - left := p.parseNot() - for !p.err && p.ctx.Err() == nil && p.pos < len(p.args) && p.peek() == "-a" { - p.advance() - right := p.parseNot() - left = left && right - } - return left -} - -// parseNot handles ! EXPR. When ! is the last remaining token, it is -// treated as a non-empty string per POSIX single-argument rules. -// When ! is followed by a binary operator (e.g., "! = !"), it is treated -// as a literal string operand, not negation. -func (p *parser) parseNot() bool { - if p.pos < len(p.args) && p.peek() == "!" { - remaining := len(p.args) - p.pos - if remaining == 1 { - p.advance() - return true - } - // If "!" is followed by a binary operator, treat it as a literal - // operand (fall through to parsePrimary for binary expression). - if remaining >= 3 && isBinaryOp(p.args[p.pos+1]) { - return p.parsePrimary() - } - if p.depth >= maxParenDepth { - p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) - p.err = true - return false - } - p.depth++ - p.advance() - result := !p.parseNot() - p.depth-- - return result - } - return p.parsePrimary() -} - -// parsePrimary handles parenthesized expressions, unary operators, binary -// operators, and bare strings. It uses lookahead to implement the POSIX -// disambiguation rules (e.g., with 1 remaining arg, everything is a string). -func (p *parser) parsePrimary() bool { - if p.err || p.pos >= len(p.args) { - p.callCtx.Errf("%s: missing argument\n", p.cmdName) - p.err = true - return false - } - - cur := p.peek() - remaining := len(p.args) - p.pos - - // Only treat "(" as grouping when there are enough tokens and it is not - // used as a literal operand in a binary expression. A lone "(" with - // remaining==1 is a bare non-empty string per POSIX single-argument rules. - // When "(" is followed by a binary operator (e.g., "(" = "("), treat it - // as a literal string operand. - if cur == "(" && remaining > 1 && !(remaining >= 3 && isBinaryOp(p.args[p.pos+1])) { - if p.depth >= maxParenDepth { - p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) - p.err = true - return false - } - p.depth++ - p.advance() - if p.pos >= len(p.args) || p.peek() == ")" { - p.callCtx.Errf("%s: missing argument\n", p.cmdName) - p.err = true - return false - } - result := p.parseOr() - p.depth-- - if p.err { - return false - } - if p.pos >= len(p.args) || p.peek() != ")" { - p.callCtx.Errf("%s: missing ')'\n", p.cmdName) - p.err = true - return false - } - p.advance() - return result - } - - // POSIX disambiguation: if there are at least 3 remaining tokens and - // the second one is a binary operator, parse as a binary expression. - // This must be checked before unary operators to handle cases like - // "test -f = -f" (string comparison, not file test). - if remaining >= 3 { - op := p.args[p.pos+1] - if isBinaryOp(op) { - return p.parseBinaryExpr() - } - } - - // With 2+ remaining tokens, check for unary operators. - if remaining >= 2 { - if isUnaryFileOp(cur) { - return p.parseUnaryFileOp() - } - if cur == "-z" || cur == "-n" { - return p.parseUnaryStringOp() - } - // -o as unary tests whether a shell option is set. - // This restricted shell has no options, so always false. - if cur == "-o" { - p.advance() // consume -o - p.advance() // consume operand - return false - } - } - - // Single token or unrecognised: treat as bare string (true if non-empty). - p.advance() - return cur != "" -} - -func isBinaryOp(op string) bool { - switch op { - case "=", "==", "!=", "<", ">", - "-eq", "-ne", "-lt", "-le", "-gt", "-ge", - "-nt", "-ot": - return true - } - return false -} - -func isUnaryFileOp(op string) bool { - switch op { - case "-e", "-f", "-d", "-s", "-r", "-w", "-x", "-h", "-L", "-p": - return true - } - return false -} - -func (p *parser) consumeUnaryOperand(op string) (string, bool) { - if p.pos >= len(p.args) { - p.callCtx.Errf("%s: '%s': unary operator expected\n", p.cmdName, op) - p.err = true - return "", false - } - return p.advance(), true -} - -func (p *parser) parseUnaryFileOp() bool { - op := p.advance() - operand, ok := p.consumeUnaryOperand(op) - if !ok { - return false - } - return p.evalFileTest(op, operand) -} - -func (p *parser) parseUnaryStringOp() bool { - op := p.advance() - s, ok := p.consumeUnaryOperand(op) - if !ok { - return false - } - return (op == "-z") == (s == "") -} - -func (p *parser) parseBinaryExpr() bool { - left := p.advance() - op := p.advance() - if p.pos >= len(p.args) { - p.callCtx.Errf("%s: missing argument after '%s'\n", p.cmdName, op) - p.err = true - return false - } - right := p.advance() - - switch op { - case "=", "==": - return left == right - case "!=": - return left != right - case "<": - return left < right - case ">": - return left > right - case "-eq", "-ne", "-lt", "-le", "-gt", "-ge": - return p.evalIntCompare(left, op, right) - case "-nt", "-ot": - return p.evalFileCompare(left, op, right) - default: - p.callCtx.Errf("%s: unknown binary operator '%s'\n", p.cmdName, op) - p.err = true - return false - } -} - -func (p *parser) evalFileTest(op, path string) bool { - if path == "" { - return false - } - // For -r/-w/-x, use real access checks instead of mode bits. - if p.callCtx.AccessFile != nil { - switch op { - case "-r": - return p.callCtx.AccessFile(p.ctx, path, 0x04) == nil - case "-w": - return p.callCtx.AccessFile(p.ctx, path, 0x02) == nil - case "-x": - return p.callCtx.AccessFile(p.ctx, path, 0x01) == nil - } - } - - switch op { - case "-h", "-L": - if p.callCtx.LstatFile == nil { - return false - } - info, err := p.callCtx.LstatFile(p.ctx, path) - if err != nil { - return false - } - return info.Mode()&fs.ModeSymlink != 0 - default: - if p.callCtx.StatFile == nil { - return false - } - info, err := p.callCtx.StatFile(p.ctx, path) - if err != nil { - return false - } - return evalFileInfo(op, info) - } -} - -func evalFileInfo(op string, info fs.FileInfo) bool { - switch op { - case "-e": - return true - case "-f": - return info.Mode().IsRegular() - case "-d": - return info.IsDir() - case "-s": - return info.Size() > 0 - case "-r": - return info.Mode().Perm()&0444 != 0 - case "-w": - return info.Mode().Perm()&0222 != 0 - case "-x": - return info.Mode().Perm()&0111 != 0 - case "-p": - return info.Mode()&fs.ModeNamedPipe != 0 - } - return false -} - -func (p *parser) evalIntCompare(left, op, right string) bool { - l, ok := p.parseInt(left) - if !ok { - return false - } - r, ok := p.parseInt(right) - if !ok { - return false - } - switch op { - case "-eq": - return l == r - case "-ne": - return l != r - case "-lt": - return l < r - case "-le": - return l <= r - case "-gt": - return l > r - case "-ge": - return l >= r - } - return false -} - -func (p *parser) parseInt(s string) (int64, bool) { - s = strings.TrimSpace(s) - if s == "" { - p.callCtx.Errf("%s: : integer expression expected\n", p.cmdName) - p.err = true - return 0, false - } - n, err := strconv.ParseInt(s, 10, 64) - if err != nil { - // Check for overflow — clamp to boundaries like GNU test. - if numErr, ok := err.(*strconv.NumError); ok && numErr.Err == strconv.ErrRange { - if s[0] == '-' { - n = math.MinInt64 - } else { - n = math.MaxInt64 - } - return n, true - } - p.callCtx.Errf("%s: %s: integer expression expected\n", p.cmdName, s) - p.err = true - return 0, false - } - return n, true -} - -func (p *parser) evalFileCompare(left, op, right string) bool { - if p.callCtx.StatFile == nil { - return false - } - leftInfo, leftErr := p.callCtx.StatFile(p.ctx, left) - rightInfo, rightErr := p.callCtx.StatFile(p.ctx, right) - - switch op { - case "-nt": - if leftErr != nil { - return false - } - if rightErr != nil { - return true - } - return leftInfo.ModTime().After(rightInfo.ModTime()) - case "-ot": - if rightErr != nil { - return false - } - if leftErr != nil { - return true - } - return leftInfo.ModTime().Before(rightInfo.ModTime()) - } - return false -} diff --git a/interp/builtins/testcmd/testcmd_gnu_compat_test.go b/interp/builtins/testcmd/testcmd_gnu_compat_test.go deleted file mode 100644 index 700fd7d3..00000000 --- a/interp/builtins/testcmd/testcmd_gnu_compat_test.go +++ /dev/null @@ -1,165 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed -// under the Apache License Version 2.0. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2026-present Datadog, Inc. - -package testcmd_test - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -// TestGNUCompatTestEmptyIsFalse — no arguments = false. -// -// GNU: test; echo $? → 1 -func TestGNUCompatTestEmptyIsFalse(t *testing.T) { - stdout, stderr, code := runScript(t, "test; echo $?", "") - assert.Equal(t, "1\n", stdout) - assert.Empty(t, stderr) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestZeroLengthEmpty — -z "" = true. -// -// GNU: test -z ""; echo $? → 0 -func TestGNUCompatTestZeroLengthEmpty(t *testing.T) { - stdout, stderr, code := runScript(t, `test -z ""; echo $?`, "") - assert.Equal(t, "0\n", stdout) - assert.Empty(t, stderr) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestNonZeroLength — -n "hello" = true. -// -// GNU: test -n "hello"; echo $? → 0 -func TestGNUCompatTestNonZeroLength(t *testing.T) { - stdout, stderr, code := runScript(t, `test -n "hello"; echo $?`, "") - assert.Equal(t, "0\n", stdout) - assert.Empty(t, stderr) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestStringEquality — "t" = "t". -// -// GNU: test "t" = "t"; echo $? → 0 -func TestGNUCompatTestStringEquality(t *testing.T) { - stdout, _, code := runScript(t, `test "t" = "t"; echo $?`, "") - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestStringInequality — "t" = "f" → false. -// -// GNU: test "t" = "f"; echo $? → 1 -func TestGNUCompatTestStringInequality(t *testing.T) { - stdout, _, code := runScript(t, `test "t" = "f"; echo $?`, "") - assert.Equal(t, "1\n", stdout) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestIntegerEquality — 9 -eq 9. -// -// GNU: test 9 -eq 9; echo $? → 0 -func TestGNUCompatTestIntegerEquality(t *testing.T) { - stdout, _, code := runScript(t, `test 9 -eq 9; echo $?`, "") - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestIntegerWithLeadingZeros — 0 -eq 00. -// -// GNU: test 0 -eq 00; echo $? → 0 -func TestGNUCompatTestIntegerWithLeadingZeros(t *testing.T) { - stdout, _, code := runScript(t, `test 0 -eq 00; echo $?`, "") - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestInvalidIntegerHex — 0x0 -eq 00 → error. -// -// GNU: test 0x0 -eq 00; echo $? → 2 (stderr: "test: 0x0: integer expression expected") -func TestGNUCompatTestInvalidIntegerHex(t *testing.T) { - _, stderr, code := runScript(t, `test 0x0 -eq 00`, "") - assert.Equal(t, 2, code) - assert.Equal(t, "test: 0x0: integer expression expected\n", stderr) -} - -// TestGNUCompatTestNegation — ! "" = true. -// -// GNU: test ! ""; echo $? → 0 -func TestGNUCompatTestNegation(t *testing.T) { - stdout, _, code := runScript(t, `test ! ""; echo $?`, "") - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestAndOperator — "t" -a "t" = true. -// -// GNU: test "t" -a "t"; echo $? → 0 -func TestGNUCompatTestAndOperator(t *testing.T) { - stdout, _, code := runScript(t, `test "t" -a "t"; echo $?`, "") - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestOrOperator — "" -o "t" = true. -// -// GNU: test "" -o "t"; echo $? → 0 -func TestGNUCompatTestOrOperator(t *testing.T) { - stdout, _, code := runScript(t, `test "" -o "t"; echo $?`, "") - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestParentheses — ( "hello" ). -// -// GNU: test '(' "hello" ')'; echo $? → 0 -func TestGNUCompatTestParentheses(t *testing.T) { - stdout, _, code := runScript(t, `test '(' "hello" ')'; echo $?`, "") - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) -} - -// TestGNUCompatBracketMissingClose — [ 1 -eq → exit 2 + stderr. -// -// GNU: [ 1 -eq; echo $? → 2 (stderr: "[: missing `]'") -func TestGNUCompatBracketMissingClose(t *testing.T) { - _, stderr, code := runScript(t, `[ 1 -eq`, "") - assert.Equal(t, 2, code) - assert.Equal(t, "[: missing `]'\n", stderr) -} - -// TestGNUCompatTestFileExists — -f on regular file. -// -// GNU: test -f file.txt; echo $? → 0 -func TestGNUCompatTestFileExists(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "content\n") - stdout, _, code := cmdRun(t, `test -f file.txt; echo $?`, dir) - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) -} - -// TestGNUCompatTestNtOt — file modification time comparison. -// -// GNU: test newer -nt older; echo $? → 0 -// GNU: test older -ot newer; echo $? → 0 -func TestGNUCompatTestNtOt(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "older.txt", "old") - os.Chtimes(filepath.Join(dir, "older.txt"), time.Now().Add(-2*time.Hour), time.Now().Add(-2*time.Hour)) - writeFile(t, dir, "newer.txt", "new") - - stdout, _, code := cmdRun(t, `test newer.txt -nt older.txt; echo $?`, dir) - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) - - stdout, _, code = cmdRun(t, `test older.txt -ot newer.txt; echo $?`, dir) - assert.Equal(t, "0\n", stdout) - assert.Equal(t, 0, code) -} diff --git a/interp/builtins/testcmd/testcmd_pentest_test.go b/interp/builtins/testcmd/testcmd_pentest_test.go deleted file mode 100644 index 9520a6b3..00000000 --- a/interp/builtins/testcmd/testcmd_pentest_test.go +++ /dev/null @@ -1,314 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed -// under the Apache License Version 2.0. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2026-present Datadog, Inc. - -package testcmd_test - -import ( - "context" - "math" - "os" - "path/filepath" - "strconv" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/DataDog/rshell/interp" -) - -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(10 * time.Second): - t.Fatal("test timed out — possible hang") - } -} - -// --- Integer edge cases --- - -func TestPentestIntZero(t *testing.T) { - stdout, _, _ := runScript(t, `test 0 -eq 0; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestPentestIntOne(t *testing.T) { - stdout, _, _ := runScript(t, `test 1 -eq 1; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestPentestIntMaxInt32(t *testing.T) { - max32 := strconv.Itoa(math.MaxInt32) - stdout, _, _ := runScript(t, `test `+max32+` -eq `+max32+`; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestPentestIntMaxInt64(t *testing.T) { - max64 := strconv.FormatInt(math.MaxInt64, 10) - stdout, _, _ := runScript(t, `test `+max64+` -eq `+max64+`; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestPentestIntOverflowPositive(t *testing.T) { - huge := "99999999999999999999" - stdout, _, _ := runScript(t, `test `+huge+` -gt 0; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestPentestIntOverflowNegative(t *testing.T) { - huge := "-99999999999999999999" - stdout, _, _ := runScript(t, `test `+huge+` -lt 0; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestPentestIntEmptyString(t *testing.T) { - _, stderr, code := runScript(t, `test "" -eq 0`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "integer expression expected") -} - -func TestPentestIntWhitespaceOnly(t *testing.T) { - _, stderr, code := runScript(t, `test " " -eq 0`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "integer expression expected") -} - -func TestPentestIntHexRejected(t *testing.T) { - _, stderr, code := runScript(t, `test 0x10 -eq 16`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "integer expression expected") -} - -func TestPentestIntOctalNotInterpreted(t *testing.T) { - stdout, _, _ := runScript(t, `test 010 -eq 10; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestPentestIntFloatRejected(t *testing.T) { - _, stderr, code := runScript(t, `test 3.14 -eq 3`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "integer expression expected") -} - -// --- Special files --- - -func TestPentestDevNull(t *testing.T) { - mustNotHang(t, func() { - _, _, code := runScript(t, `test -f `+os.DevNull, "", interp.AllowedPaths([]string{filepath.Dir(os.DevNull)})) - _ = code - }) -} - -func TestPentestDevNullExists(t *testing.T) { - mustNotHang(t, func() { - _, _, code := runScript(t, `test -e `+os.DevNull, "", interp.AllowedPaths([]string{filepath.Dir(os.DevNull)})) - _ = code - }) -} - -// --- Path edge cases --- - -func TestPentestNonexistentFile(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test -f /absolutely/nonexistent`, dir) - assert.Equal(t, 1, code) -} - -func TestPentestEmptyFilename(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test -f ""`, dir) - assert.Equal(t, 1, code) -} - -func TestPentestDirectoryAsFile(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test -f .`, dir) - assert.Equal(t, 1, code) -} - -func TestPentestPathTraversal(t *testing.T) { - dir := t.TempDir() - _, _, code := runScript(t, `test -e ../../../etc/passwd`, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 1, code) -} - -func TestPentestDoubleSlashes(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "f.txt", "data") - _, _, code := cmdRun(t, `test -f .//f.txt`, dir) - assert.Equal(t, 0, code) -} - -func TestPentestFlagLikeFilename(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "-f", "data") - _, _, code := cmdRun(t, `test -e -- -f`, dir) - _ = code -} - -// --- Flag and argument injection --- - -func TestPentestUnknownFlag(t *testing.T) { - _, _, code := runScript(t, `test --no-such-flag`, "") - assert.Equal(t, 0, code) -} - -func TestPentestDoubleDash(t *testing.T) { - _, _, code := runScript(t, `test --`, "") - assert.Equal(t, 0, code) -} - -func TestPentestMultipleStdinArgs(t *testing.T) { - _, _, code := runScript(t, `test "-" = "-"`, "") - assert.Equal(t, 0, code) -} - -// --- Context timeout --- - -func TestPentestTimeoutOnFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "f.txt", "data") - mustNotHang(t, func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _, _, _ = runScriptCtx(ctx, t, `test -f f.txt`, dir, interp.AllowedPaths([]string{dir})) - }) -} - -// --- Bracket syntax edge cases --- - -func TestPentestBracketEmptyBrackets(t *testing.T) { - _, stderr, code := runScript(t, `[ ]`, "") - assert.Equal(t, 1, code, "stderr: %s", stderr) -} - -func TestPentestBracketOnlyClose(t *testing.T) { - _, stderr, code := runScript(t, `[`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "missing `]'") -} - -// --- Large argument count --- - -func TestPentestManyArguments(t *testing.T) { - mustNotHang(t, func() { - _, _, code := runScript(t, `test "a" -a "b" -a "c" -a "d" -a "e" -a "f"`, "") - assert.Equal(t, 0, code) - }) -} - -// --- Precedence / complex expressions --- - -func TestPentestPrecedenceAndOr(t *testing.T) { - stdout, _, _ := runScript(t, `test " " -o "" -a ""; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestPentestNestedParentheses(t *testing.T) { - stdout, _, _ := runScript(t, `test '(' '(' "a" = "a" ')' ')'; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestPentestMissingCloseParen(t *testing.T) { - _, stderr, code := runScript(t, `test '(' "a"`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "missing ')'") -} - -func TestPentestEmptyParens(t *testing.T) { - _, _, code := runScript(t, `test '(' ')'`, "") - assert.Equal(t, 2, code) -} - -func TestPentestDeepNesting(t *testing.T) { - // Build a deeply nested expression: ( ( ( ... "x" ... ) ) ) - var script string - depth := 200 - for i := 0; i < depth; i++ { - script += "'(' " - } - script += `"x"` - for i := 0; i < depth; i++ { - script += " ')'" - } - _, stderr, code := runScript(t, "test "+script, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "too deeply nested") -} - -func TestPentestDeepNesting1000(t *testing.T) { - // 1000+ levels of nesting should be rejected by depth limit, not stack overflow. - var script string - depth := 1000 - for i := 0; i < depth; i++ { - script += "'(' " - } - script += `"x"` - for i := 0; i < depth; i++ { - script += " ')'" - } - mustNotHang(t, func() { - _, stderr, code := runScript(t, "test "+script, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "too deeply nested") - }) -} - -func TestPentestNullByteInFilePath(t *testing.T) { - // Null bytes in file paths should not bypass sandbox checks. - dir := t.TempDir() - // test -f with a path containing a null byte - _, _, code := cmdRun(t, "test -f \"file\x00/../etc/passwd\"", dir) - assert.NotEqual(t, 0, code) -} - -func TestPentestVeryLongString(t *testing.T) { - // Very long string arguments should not cause panics or hangs. - mustNotHang(t, func() { - long := make([]byte, 100*1024) - for i := range long { - long[i] = 'a' - } - script := `test "` + string(long) + `" = "` + string(long) + `"; echo $?` - stdout, _, code := runScript(t, script, "") - assert.Equal(t, 0, code) - assert.Equal(t, "0\n", stdout) - }) -} - -func TestPentestVeryLongFilePath(t *testing.T) { - // Very long file path should not cause panics. - dir := t.TempDir() - long := make([]byte, 100*1024) - for i := range long { - long[i] = 'a' - } - mustNotHang(t, func() { - _, _, code := cmdRun(t, `test -f "`+string(long)+`"`, dir) - assert.Equal(t, 1, code) - }) -} - -func TestPentestBangAsStringOperand(t *testing.T) { - // "!" should be treated as a literal string in binary expressions - stdout, stderr, code := runScript(t, `test '!' = '!'; echo $?`, "") - assert.Equal(t, 0, code) - assert.Equal(t, "0\n", stdout) - assert.Empty(t, stderr) -} - -func TestPentestParenAsStringOperand(t *testing.T) { - // "(" should be treated as a literal string in binary expressions - stdout, stderr, code := runScript(t, `test '(' = '('; echo $?`, "") - assert.Equal(t, 0, code) - assert.Equal(t, "0\n", stdout) - assert.Empty(t, stderr) -} diff --git a/interp/builtins/testcmd/testcmd_test.go b/interp/builtins/testcmd/testcmd_test.go deleted file mode 100644 index 5e69998a..00000000 --- a/interp/builtins/testcmd/testcmd_test.go +++ /dev/null @@ -1,507 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed -// under the Apache License Version 2.0. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2026-present Datadog, Inc. - -package testcmd_test - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/DataDog/rshell/interp" - "github.com/DataDog/rshell/interp/builtins/testutil" -) - -func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { - t.Helper() - return testutil.RunScript(t, script, dir, opts...) -} - -func runScriptCtx(ctx context.Context, t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { - t.Helper() - return testutil.RunScriptCtx(ctx, t, script, dir, opts...) -} - -func cmdRun(t *testing.T, script, dir string) (string, string, int) { - t.Helper() - return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) -} - -func writeFile(t *testing.T, dir, name, content string) string { - t.Helper() - err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644) - if err != nil { - t.Fatal(err) - } - return name -} - -// --- String tests --- - -func TestTestEmptyArgs(t *testing.T) { - _, _, code := runScript(t, "test", "") - assert.Equal(t, 1, code) -} - -func TestTestBareString(t *testing.T) { - _, _, code := runScript(t, `test "hello"`, "") - assert.Equal(t, 0, code) -} - -func TestTestEmptyString(t *testing.T) { - _, _, code := runScript(t, `test ""`, "") - assert.Equal(t, 1, code) -} - -func TestTestZeroLength(t *testing.T) { - _, _, code := runScript(t, `test -z ""`, "") - assert.Equal(t, 0, code) -} - -func TestTestZeroLengthNonEmpty(t *testing.T) { - _, _, code := runScript(t, `test -z "hello"`, "") - assert.Equal(t, 1, code) -} - -func TestTestNonZeroLength(t *testing.T) { - _, _, code := runScript(t, `test -n "hello"`, "") - assert.Equal(t, 0, code) -} - -func TestTestNonZeroLengthEmpty(t *testing.T) { - _, _, code := runScript(t, `test -n ""`, "") - assert.Equal(t, 1, code) -} - -func TestTestStringEqual(t *testing.T) { - _, _, code := runScript(t, `test "abc" = "abc"`, "") - assert.Equal(t, 0, code) -} - -func TestTestStringNotEqual(t *testing.T) { - _, _, code := runScript(t, `test "abc" != "def"`, "") - assert.Equal(t, 0, code) -} - -func TestTestStringEqualFalse(t *testing.T) { - _, _, code := runScript(t, `test "abc" = "def"`, "") - assert.Equal(t, 1, code) -} - -func TestTestDoubleEqual(t *testing.T) { - _, _, code := runScript(t, `test "t" == "t"`, "") - assert.Equal(t, 0, code) -} - -func TestTestStringLessThan(t *testing.T) { - stdout, _, code := runScript(t, `test "a" \< "b"; echo $?`, "") - assert.Equal(t, 0, code) - assert.Equal(t, "0\n", stdout) -} - -func TestTestStringGreaterThan(t *testing.T) { - stdout, _, code := runScript(t, `test "b" \> "a"; echo $?`, "") - assert.Equal(t, 0, code) - assert.Equal(t, "0\n", stdout) -} - -// --- Integer comparison tests --- - -func TestTestIntEq(t *testing.T) { - _, _, code := runScript(t, `test 9 -eq 9`, "") - assert.Equal(t, 0, code) -} - -func TestTestIntNe(t *testing.T) { - _, _, code := runScript(t, `test 8 -ne 9`, "") - assert.Equal(t, 0, code) - - _, _, code = runScript(t, `test 9 -ne 9`, "") - assert.Equal(t, 1, code) -} - -func TestTestIntGt(t *testing.T) { - _, _, code := runScript(t, `test 5 -gt 4`, "") - assert.Equal(t, 0, code) -} - -func TestTestIntLt(t *testing.T) { - _, _, code := runScript(t, `test 4 -lt 5`, "") - assert.Equal(t, 0, code) -} - -func TestTestIntGe(t *testing.T) { - _, _, code := runScript(t, `test 5 -ge 5`, "") - assert.Equal(t, 0, code) -} - -func TestTestIntLe(t *testing.T) { - _, _, code := runScript(t, `test 5 -le 5`, "") - assert.Equal(t, 0, code) -} - -func TestTestIntNegative(t *testing.T) { - _, _, code := runScript(t, `test -1 -gt -2`, "") - assert.Equal(t, 0, code) -} - -func TestTestIntLeadingZero(t *testing.T) { - _, _, code := runScript(t, `test 0 -eq 00`, "") - assert.Equal(t, 0, code) -} - -func TestTestIntWhitespace(t *testing.T) { - stdout, _, _ := runScript(t, `test 0 -eq " 0 "; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestTestInvalidInteger(t *testing.T) { - _, stderr, code := runScript(t, `test 0x0 -eq 0`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "integer expression expected") -} - -func TestTestFloatRejected(t *testing.T) { - _, stderr, code := runScript(t, `test 123.45 -ge 6`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "123.45: integer expression expected") -} - -// --- Logical operator tests --- - -func TestTestAndTrue(t *testing.T) { - _, _, code := runScript(t, `test "a" -a "b"`, "") - assert.Equal(t, 0, code) -} - -func TestTestAndFalse(t *testing.T) { - _, _, code := runScript(t, `test "" -a "b"`, "") - assert.Equal(t, 1, code) -} - -func TestTestOrTrue(t *testing.T) { - _, _, code := runScript(t, `test "" -o "b"`, "") - assert.Equal(t, 0, code) -} - -func TestTestOrFalse(t *testing.T) { - _, _, code := runScript(t, `test "" -o ""`, "") - assert.Equal(t, 1, code) -} - -func TestTestNot(t *testing.T) { - _, _, code := runScript(t, `test ! ""`, "") - assert.Equal(t, 0, code) -} - -func TestTestNotTrue(t *testing.T) { - _, _, code := runScript(t, `test ! "hello"`, "") - assert.Equal(t, 1, code) -} - -func TestTestDoubleNot(t *testing.T) { - _, _, code := runScript(t, `test ! ! "hello"`, "") - assert.Equal(t, 0, code) -} - -func TestTestParentheses(t *testing.T) { - stdout, _, _ := runScript(t, `test '(' "hello" ')'; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -func TestTestParenthesesEmpty(t *testing.T) { - stdout, _, _ := runScript(t, `test '(' "" ')'; echo $?`, "") - assert.Equal(t, "1\n", stdout) -} - -func TestTestPrecedence(t *testing.T) { - stdout, _, _ := runScript(t, `test " " -o "" -a ""; echo $?`, "") - assert.Equal(t, "0\n", stdout) -} - -// --- File tests --- - -func TestTestFileExists(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "f.txt", "hello") - _, _, code := cmdRun(t, `test -e f.txt`, dir) - assert.Equal(t, 0, code) -} - -func TestTestFileNotExists(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test -e nonexistent`, dir) - assert.Equal(t, 1, code) -} - -func TestTestRegularFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "f.txt", "hello") - _, _, code := cmdRun(t, `test -f f.txt`, dir) - assert.Equal(t, 0, code) -} - -func TestTestDirNotRegular(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test -f .`, dir) - assert.Equal(t, 1, code) -} - -func TestTestIsDirectory(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test -d .`, dir) - assert.Equal(t, 0, code) -} - -func TestTestFileNotDirectory(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "f.txt", "hello") - _, _, code := cmdRun(t, `test -d f.txt`, dir) - assert.Equal(t, 1, code) -} - -func TestTestFileSizeGreaterThanZero(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "nonempty.txt", "data") - _, _, code := cmdRun(t, `test -s nonempty.txt`, dir) - assert.Equal(t, 0, code) -} - -func TestTestEmptyFileSize(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "empty.txt", "") - _, _, code := cmdRun(t, `test -s empty.txt`, dir) - assert.Equal(t, 1, code) -} - -func TestTestFileReadable(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "f.txt", "hello") - _, _, code := cmdRun(t, `test -r f.txt`, dir) - assert.Equal(t, 0, code) -} - -func TestTestFileWritable(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "f.txt", "hello") - _, _, code := cmdRun(t, `test -w f.txt`, dir) - assert.Equal(t, 0, code) -} - -func TestTestFileNotExecutable(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "f.txt", "hello") - _, _, code := cmdRun(t, `test -x f.txt`, dir) - assert.Equal(t, 1, code) -} - -// --- Bracket syntax tests --- - -func TestBracketBasic(t *testing.T) { - _, _, code := runScript(t, `[ "hello" ]`, "") - assert.Equal(t, 0, code) -} - -func TestBracketIntCompare(t *testing.T) { - _, _, code := runScript(t, `[ 1 -eq 1 ]`, "") - assert.Equal(t, 0, code) -} - -func TestBracketMissingClose(t *testing.T) { - _, stderr, code := runScript(t, `[ 1 -eq 1`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "missing `]'") -} - -func TestBracketEmpty(t *testing.T) { - _, stderr, code := runScript(t, `[`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "missing `]'") -} - -func TestTestLoneParenIsString(t *testing.T) { - _, _, code := runScript(t, `test "("`, "") - assert.Equal(t, 0, code) -} - -func TestTestEmptyFileOperand(t *testing.T) { - _, _, code := runScript(t, `test -e ""`, "") - assert.Equal(t, 1, code) - - _, _, code = runScript(t, `test -d ""`, "") - assert.Equal(t, 1, code) - - _, _, code = runScript(t, `test -f ""`, "") - assert.Equal(t, 1, code) -} - -// --- Help tests --- - -func TestTestHelp(t *testing.T) { - stdout, _, code := runScript(t, `test --help`, "") - assert.Equal(t, 0, code) - assert.Contains(t, stdout, "Usage:") -} - -func TestBracketHelp(t *testing.T) { - stdout, _, code := runScript(t, `[ --help`, "") - assert.Equal(t, 0, code) - assert.Contains(t, stdout, "Usage:") -} - -// --- Error cases --- - -func TestTestExtraArgument(t *testing.T) { - _, stderr, code := runScript(t, `test "a" "b" "c" "d" "e"`, "") - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "extra argument") -} - -// --- File comparison -nt / -ot tests --- - -func TestTestNewerThan(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "old.txt", "old") - old := filepath.Join(dir, "old.txt") - past := time.Now().Add(-2 * time.Hour) - os.Chtimes(old, past, past) - writeFile(t, dir, "new.txt", "new") - - _, _, code := cmdRun(t, `test new.txt -nt old.txt`, dir) - assert.Equal(t, 0, code) - - _, _, code = cmdRun(t, `test old.txt -nt new.txt`, dir) - assert.Equal(t, 1, code) -} - -func TestTestOlderThan(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "old.txt", "old") - old := filepath.Join(dir, "old.txt") - past := time.Now().Add(-2 * time.Hour) - os.Chtimes(old, past, past) - writeFile(t, dir, "new.txt", "new") - - _, _, code := cmdRun(t, `test old.txt -ot new.txt`, dir) - assert.Equal(t, 0, code) - - _, _, code = cmdRun(t, `test new.txt -ot old.txt`, dir) - assert.Equal(t, 1, code) -} - -func TestTestNtMissingFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "exists.txt", "data") - - _, _, code := cmdRun(t, `test exists.txt -nt nonexistent`, dir) - assert.Equal(t, 0, code) - - _, _, code = cmdRun(t, `test nonexistent -nt exists.txt`, dir) - assert.Equal(t, 1, code) -} - -func TestTestOtMissingFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "exists.txt", "data") - - _, _, code := cmdRun(t, `test nonexistent -ot exists.txt`, dir) - assert.Equal(t, 0, code) - - _, _, code = cmdRun(t, `test exists.txt -ot nonexistent`, dir) - assert.Equal(t, 1, code) -} - -// --- Special single-arg cases --- - -func TestTestSpecialSingleArgs(t *testing.T) { - cases := []string{"-", "--", "-0", "-f", "["} - for _, c := range cases { - t.Run(c, func(t *testing.T) { - stdout, _, _ := runScript(t, `test "`+c+`"; echo $?`, "") - assert.Equal(t, "0\n", stdout) - }) - } -} - -// --- Operator as literal string --- - -func TestTestSoloNot(t *testing.T) { - _, _, code := runScript(t, `test !`, "") - assert.Equal(t, 0, code) -} - -func TestTestSoloOperatorLiteral(t *testing.T) { - _, _, code := runScript(t, `test -a`, "") - assert.Equal(t, 0, code) - - _, _, code = runScript(t, `test -o`, "") - assert.Equal(t, 0, code) -} - -// --- Context cancellation --- - -func TestTestContextCancellation(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - cancel() - _, _, code := runScriptCtx(ctx, t, `test 1 -eq 1`, "") - // A pre-cancelled context prevents the runner from starting; exit code depends - // on how the runner surfaces the error — just verify it did not succeed. - _ = code -} - -// --- Sandbox enforcement --- - -func TestTestFileOutsideSandbox(t *testing.T) { - dir := t.TempDir() - other := t.TempDir() - writeFile(t, other, "secret.txt", "data") - - _, _, code := runScript(t, `test -f `+filepath.Join(other, "secret.txt"), dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 1, code) -} - -// --- Integer overflow clamping --- - -func TestTestIntOverflow(t *testing.T) { - stdout, _, code := runScript(t, `test 99999999999999999999 -gt 0; echo $?`, "") - assert.Equal(t, 0, code) - assert.Equal(t, "0\n", stdout) -} - -func TestTestIntNegOverflow(t *testing.T) { - stdout, _, code := runScript(t, `test -99999999999999999999 -lt 0; echo $?`, "") - assert.Equal(t, 0, code) - assert.Equal(t, "0\n", stdout) -} - -// --- Shell integration tests --- - -func TestTestWithAndList(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "data") - stdout, _, code := cmdRun(t, `test -f file.txt && echo "yes" || echo "no"`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestInForLoop(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "a.txt", "alpha") - writeFile(t, dir, "b.txt", "beta") - stdout, _, code := cmdRun(t, `for f in a.txt b.txt; do test -f "$f" && echo "$f exists"; done`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "a.txt exists\nb.txt exists\n", stdout) -} - -func TestBracketInAndList(t *testing.T) { - stdout, _, code := runScript(t, `[ "hello" = "hello" ] && echo "match"`, "") - assert.Equal(t, 0, code) - assert.Equal(t, "match\n", stdout) -} diff --git a/interp/builtins/testcmd/testcmd_unix_test.go b/interp/builtins/testcmd/testcmd_unix_test.go deleted file mode 100644 index d892a90e..00000000 --- a/interp/builtins/testcmd/testcmd_unix_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed -// under the Apache License Version 2.0. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2026-present Datadog, Inc. - -//go:build unix - -package testcmd_test - -import ( - "os" - "path/filepath" - "syscall" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/DataDog/rshell/interp" -) - -func TestTestSymlink(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "target.txt", "hello") - os.Symlink("target.txt", filepath.Join(dir, "link.txt")) - - _, _, code := runScript(t, `test -h link.txt`, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 0, code) - - _, _, code = runScript(t, `test -L link.txt`, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 0, code) - - _, _, code = runScript(t, `test -h target.txt`, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 1, code) -} - -func TestTestSymlinkNotRegular(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "target.txt", "hello") - os.Symlink("target.txt", filepath.Join(dir, "link.txt")) - - _, _, code := runScript(t, `test -f link.txt`, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 0, code) -} - -func TestTestExecutableFile(t *testing.T) { - dir := t.TempDir() - p := filepath.Join(dir, "exec.sh") - os.WriteFile(p, []byte("#!/bin/sh\n"), 0755) - - _, _, code := runScript(t, `test -x exec.sh`, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 0, code) -} - -func TestTestNamedPipe(t *testing.T) { - dir := t.TempDir() - pipe := filepath.Join(dir, "mypipe") - if err := syscall.Mkfifo(pipe, 0644); err != nil { - t.Skip("cannot create FIFO:", err) - } - - _, _, code := runScript(t, `test -p mypipe`, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 0, code) - - _, _, code = runScript(t, `test -p .`, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 1, code) -} diff --git a/interp/builtins/testcmd/testcmd_windows_test.go b/interp/builtins/testcmd/testcmd_windows_test.go deleted file mode 100644 index 69eecd04..00000000 --- a/interp/builtins/testcmd/testcmd_windows_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed -// under the Apache License Version 2.0. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2026-present Datadog, Inc. - -//go:build windows - -package testcmd_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/DataDog/rshell/interp" -) - -func TestTestWindowsReservedNames(t *testing.T) { - dir := t.TempDir() - reserved := []string{"CON", "PRN", "AUX", "NUL", "COM1", "LPT1"} - for _, name := range reserved { - t.Run(name, func(t *testing.T) { - _, _, code := runScript(t, `test -e `+name, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 1, code) - }) - } -} diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 8d7f50d5..14b38ec9 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -21,7 +21,6 @@ import ( "github.com/DataDog/rshell/interp/builtins/ls" "github.com/DataDog/rshell/interp/builtins/strings_cmd" "github.com/DataDog/rshell/interp/builtins/tail" - "github.com/DataDog/rshell/interp/builtins/testcmd" truecmd "github.com/DataDog/rshell/interp/builtins/true" "github.com/DataDog/rshell/interp/builtins/uniq" "github.com/DataDog/rshell/interp/builtins/wc" @@ -44,8 +43,6 @@ func registerBuiltins() { ls.Cmd, strings_cmd.Cmd, tail.Cmd, - testcmd.Cmd, - testcmd.BracketCmd, truecmd.Cmd, uniq.Cmd, wc.Cmd, diff --git a/tests/scenarios/cmd/test/bracket/basic.yaml b/tests/scenarios/cmd/test/bracket/basic.yaml deleted file mode 100644 index 102573e8..00000000 --- a/tests/scenarios/cmd/test/bracket/basic.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Derived from uutils test_test.rs — bracket syntax tests -description: "[ ] bracket syntax with closing bracket requirement" -input: - script: |+ - [ "hello" ] - echo "basic: $?" - [ 1 -eq 1 ] - echo "eq: $?" - [ 1 -eq 2 ] - echo "ne: $?" -expect: - stdout: | - basic: 0 - eq: 0 - ne: 1 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/bracket/missing_bracket.yaml b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml deleted file mode 100644 index 65cefd64..00000000 --- a/tests/scenarios/cmd/test/bracket/missing_bracket.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Derived from uutils test_test.rs — missing closing bracket -description: "[ without closing ] gives exit code 2 and error" -skip_assert_against_bash: true # bash prefixes error with scriptname:line -input: - script: |+ - [ 1 -eq 1 -expect: - stderr: "[: missing `]'\n" - exit_code: 2 diff --git a/tests/scenarios/cmd/test/errors/unary_expected.yaml b/tests/scenarios/cmd/test/errors/unary_expected.yaml deleted file mode 100644 index 8c3e402b..00000000 --- a/tests/scenarios/cmd/test/errors/unary_expected.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Derived from uutils test_test.rs — -o as unary option test -description: "test -o treats unknown option name as false" -input: - script: |+ - test -o arg -expect: - exit_code: 1 diff --git a/tests/scenarios/cmd/test/files/directory.yaml b/tests/scenarios/cmd/test/files/directory.yaml deleted file mode 100644 index 5c51546c..00000000 --- a/tests/scenarios/cmd/test/files/directory.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Derived from uutils test_test.rs — test -d on file vs directory -description: "test -d distinguishes directories from regular files" -setup: - files: - - path: afile.txt - content: "x" -input: - allowed_paths: ["$DIR"] - script: |+ - test -d . - echo "dot: $?" - test -d afile.txt - echo "file: $?" -expect: - stdout: | - dot: 0 - file: 1 - stderr: "" - exit_code: 0 -skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/files/empty_operand.yaml b/tests/scenarios/cmd/test/files/empty_operand.yaml deleted file mode 100644 index 2a705f07..00000000 --- a/tests/scenarios/cmd/test/files/empty_operand.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Empty filename operand should always return false -description: "test file operators return false for empty operand" -input: - script: |+ - test -e "" - echo "e: $?" - test -f "" - echo "f: $?" - test -d "" - echo "d: $?" -expect: - stdout: | - e: 1 - f: 1 - d: 1 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/files/existence.yaml b/tests/scenarios/cmd/test/files/existence.yaml deleted file mode 100644 index 96cb2a1b..00000000 --- a/tests/scenarios/cmd/test/files/existence.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Derived from uutils test_test.rs — file existence and type tests -description: "test -e, -f, -d on existing files and directories" -setup: - files: - - path: regular.txt - content: "hello\n" - chmod: 0644 -input: - allowed_paths: ["$DIR"] - script: |+ - test -e regular.txt - echo "exists: $?" - test -f regular.txt - echo "file: $?" - test -d . - echo "dir: $?" - test -e nonexistent - echo "noexist: $?" - test -f nonexistent - echo "nofile: $?" -expect: - stdout: | - exists: 0 - file: 0 - dir: 0 - noexist: 1 - nofile: 1 - stderr: "" - exit_code: 0 -skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/files/size.yaml b/tests/scenarios/cmd/test/files/size.yaml deleted file mode 100644 index 59c31781..00000000 --- a/tests/scenarios/cmd/test/files/size.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# Derived from uutils test_test.rs — test -s (size greater than zero) -description: "test -s on empty and non-empty files" -setup: - files: - - path: empty.txt - content: "" - - path: nonempty.txt - content: "data" -input: - allowed_paths: ["$DIR"] - script: |+ - test -s nonempty.txt - echo "nonempty: $?" - test -s empty.txt - echo "empty: $?" - test -s nonexistent - echo "missing: $?" -expect: - stdout: | - nonempty: 0 - empty: 1 - missing: 1 - stderr: "" - exit_code: 0 -skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/help/bracket_help.yaml b/tests/scenarios/cmd/test/help/bracket_help.yaml deleted file mode 100644 index 53ca164d..00000000 --- a/tests/scenarios/cmd/test/help/bracket_help.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Derived from uutils test_test.rs — --help for [ -description: "[ --help prints usage and exits 0" -input: - script: |+ - [ --help -expect: - stdout_contains: ["Usage:"] - exit_code: 0 -skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/test/help/test_help.yaml b/tests/scenarios/cmd/test/help/test_help.yaml deleted file mode 100644 index 9ef6c66c..00000000 --- a/tests/scenarios/cmd/test/help/test_help.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Derived from uutils test_test.rs — --help for test and [ -description: "test --help prints usage and exits 0" -input: - script: |+ - test --help -expect: - stdout_contains: ["Usage:"] - exit_code: 0 -skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/test/integers/basic.yaml b/tests/scenarios/cmd/test/integers/basic.yaml deleted file mode 100644 index 4a43487a..00000000 --- a/tests/scenarios/cmd/test/integers/basic.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# Derived from GNU coreutils test.pl eq/gt/lt tests -description: "test integer equality and comparison operators" -input: - script: |+ - test 9 -eq 9 - echo "eq: $?" - test 8 -eq 9 - echo "ne: $?" - test 5 -gt 4 - echo "gt: $?" - test 5 -gt 5 - echo "gt-eq: $?" - test 4 -lt 5 - echo "lt: $?" - test 5 -lt 5 - echo "lt-eq: $?" - test 5 -ge 5 - echo "ge-eq: $?" - test 5 -le 5 - echo "le-eq: $?" -expect: - stdout: | - eq: 0 - ne: 1 - gt: 0 - gt-eq: 1 - lt: 0 - lt-eq: 1 - ge-eq: 0 - le-eq: 0 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/integers/invalid.yaml b/tests/scenarios/cmd/test/integers/invalid.yaml deleted file mode 100644 index a9df07df..00000000 --- a/tests/scenarios/cmd/test/integers/invalid.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Derived from GNU coreutils test.pl inv-1 — invalid integer -description: "test rejects non-decimal integers with exit code 2" -skip_assert_against_bash: true # bash prefixes error with scriptname:line -input: - script: |+ - test 0x0 -eq 00 -expect: - stderr: "test: 0x0: integer expression expected\n" - exit_code: 2 diff --git a/tests/scenarios/cmd/test/integers/negative.yaml b/tests/scenarios/cmd/test/integers/negative.yaml deleted file mode 100644 index daabcf11..00000000 --- a/tests/scenarios/cmd/test/integers/negative.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Derived from GNU coreutils test.pl — negative integer tests -description: "test integer comparison with negative numbers" -input: - script: |+ - test -1 -gt -2 - echo "neg-gt: $?" - test -1 -lt -2 - echo "neg-lt: $?" - test 0 -eq 00 - echo "leading-zero: $?" -expect: - stdout: | - neg-gt: 0 - neg-lt: 1 - leading-zero: 0 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/and_or.yaml b/tests/scenarios/cmd/test/logical/and_or.yaml deleted file mode 100644 index 803392b7..00000000 --- a/tests/scenarios/cmd/test/logical/and_or.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Derived from GNU coreutils test.pl and-1..and-4, or-1..or-4 tests -description: "test logical AND (-a) and OR (-o) operators" -input: - script: |+ - test "t" -a "t" - echo "and-tt: $?" - test "" -a "t" - echo "and-ft: $?" - test "t" -a "" - echo "and-tf: $?" - test "t" -o "t" - echo "or-tt: $?" - test "" -o "t" - echo "or-ft: $?" - test "" -o "" - echo "or-ff: $?" -expect: - stdout: | - and-tt: 0 - and-ft: 1 - and-tf: 1 - or-tt: 0 - or-ft: 0 - or-ff: 1 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/not.yaml b/tests/scenarios/cmd/test/logical/not.yaml deleted file mode 100644 index 90c087c0..00000000 --- a/tests/scenarios/cmd/test/logical/not.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Derived from GNU coreutils test.pl — negation with ! -description: "test negation operator !" -input: - script: |+ - test ! "" - echo "not-empty: $?" - test ! "hello" - echo "not-str: $?" - test ! -f /nonexistent - echo "not-file: $?" -expect: - stdout: | - not-empty: 0 - not-str: 1 - not-file: 0 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/parentheses.yaml b/tests/scenarios/cmd/test/logical/parentheses.yaml deleted file mode 100644 index beec0ccf..00000000 --- a/tests/scenarios/cmd/test/logical/parentheses.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Derived from GNU coreutils test.pl paren tests -description: "test parenthesized expressions" -input: - script: |+ - test '(' "hello" ')' - echo "paren-str: $?" - test '(' "" ')' - echo "paren-empty: $?" - test '(' "a" = "a" ')' - echo "paren-eq: $?" -expect: - stdout: | - paren-str: 0 - paren-empty: 1 - paren-eq: 0 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/special_operands.yaml b/tests/scenarios/cmd/test/logical/special_operands.yaml deleted file mode 100644 index 6a9c9c4c..00000000 --- a/tests/scenarios/cmd/test/logical/special_operands.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# Edge cases where !, (, ) appear as literal operands in binary expressions -description: "test treats !, ( as literal string operands in binary expressions" -input: - script: |+ - test '!' = '!' - echo "bang-eq-bang: $?" - test '!' != 'x' - echo "bang-neq-x: $?" - test '(' = '(' - echo "paren-eq-paren: $?" - test '(' != ')' - echo "paren-neq-close: $?" - test ')' = ')' - echo "close-eq-close: $?" -expect: - stdout: | - bang-eq-bang: 0 - bang-neq-x: 0 - paren-eq-paren: 0 - paren-neq-close: 0 - close-eq-close: 0 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/bare_string.yaml b/tests/scenarios/cmd/test/strings/bare_string.yaml deleted file mode 100644 index 17b6bc0c..00000000 --- a/tests/scenarios/cmd/test/strings/bare_string.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Derived from GNU coreutils test.pl test 1c — bare string is true -description: "test with a non-empty string returns true" -input: - script: |+ - test "any-string" -expect: - stdout: "" - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/comparison.yaml b/tests/scenarios/cmd/test/strings/comparison.yaml deleted file mode 100644 index 2cf40148..00000000 --- a/tests/scenarios/cmd/test/strings/comparison.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Derived from GNU coreutils test.pl less-collate and greater-collate tests -description: "test string lexicographic comparison with < and >" -input: - script: |+ - test "a" \< "b" - echo "lt: $?" - test "a" \< "a" - echo "lt-eq: $?" - test "b" \> "a" - echo "gt: $?" - test "a" \> "a" - echo "gt-eq: $?" -expect: - stdout: | - lt: 0 - lt-eq: 1 - gt: 0 - gt-eq: 1 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/empty_string.yaml b/tests/scenarios/cmd/test/strings/empty_string.yaml deleted file mode 100644 index f26628ba..00000000 --- a/tests/scenarios/cmd/test/strings/empty_string.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Derived from GNU coreutils test.pl test 1e — empty string is false -description: "test with empty string argument returns false" -input: - script: |+ - test "" -expect: - stdout: "" - stderr: "" - exit_code: 1 diff --git a/tests/scenarios/cmd/test/strings/empty_test.yaml b/tests/scenarios/cmd/test/strings/empty_test.yaml deleted file mode 100644 index 29d262da..00000000 --- a/tests/scenarios/cmd/test/strings/empty_test.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Derived from GNU coreutils test.pl — empty test returns false -description: "test with no arguments returns false (exit 1)" -input: - script: |+ - test -expect: - stdout: "" - stderr: "" - exit_code: 1 diff --git a/tests/scenarios/cmd/test/strings/equality.yaml b/tests/scenarios/cmd/test/strings/equality.yaml deleted file mode 100644 index f05e7c4e..00000000 --- a/tests/scenarios/cmd/test/strings/equality.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Derived from GNU coreutils test.pl streq tests -description: "test string equality and inequality" -input: - script: |+ - test "t" = "t" - echo "eq-same: $?" - test "t" = "f" - echo "eq-diff: $?" - test "t" != "t" - echo "ne-same: $?" - test "t" != "f" - echo "ne-diff: $?" -expect: - stdout: | - eq-same: 0 - eq-diff: 1 - ne-same: 1 - ne-diff: 0 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/n_nonempty.yaml b/tests/scenarios/cmd/test/strings/n_nonempty.yaml deleted file mode 100644 index e9d0bddf..00000000 --- a/tests/scenarios/cmd/test/strings/n_nonempty.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Derived from GNU coreutils test.pl test 1d — -n on non-empty string -description: "test -n on a non-empty string returns true" -input: - script: |+ - test -n "any-string" -expect: - stdout: "" - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/special_single_args.yaml b/tests/scenarios/cmd/test/strings/special_single_args.yaml deleted file mode 100644 index 372d6ef6..00000000 --- a/tests/scenarios/cmd/test/strings/special_single_args.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# Derived from GNU coreutils test.pl — special single-arg cases -description: "test treats various special tokens as non-empty strings" -input: - script: |+ - test "-" - echo "dash: $?" - test "--" - echo "ddash: $?" - test "-0" - echo "zero: $?" - test "-f" - echo "flag-f: $?" - test "(" - echo "paren: $?" -expect: - stdout: | - dash: 0 - ddash: 0 - zero: 0 - flag-f: 0 - paren: 0 - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/z_empty.yaml b/tests/scenarios/cmd/test/strings/z_empty.yaml deleted file mode 100644 index e3bbb20c..00000000 --- a/tests/scenarios/cmd/test/strings/z_empty.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Derived from GNU coreutils test.pl test 1b — -z on empty string -description: "test -z on empty string returns true" -input: - script: |+ - test -z "" -expect: - stdout: "" - stderr: "" - exit_code: 0 From ed598e9158d4bdf4783714d1c19d7380b3df165a Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 11:21:02 +0100 Subject: [PATCH 02/20] Revert "Remove test/[ builtin implementation and all associated tests" This reverts commit 3c53f7b5fa86fde96e71d528f1daa1174c15ef25. --- interp/builtins/testcmd/testcmd.go | 557 ++++++++++++++++++ .../testcmd/testcmd_gnu_compat_test.go | 165 ++++++ .../builtins/testcmd/testcmd_pentest_test.go | 314 ++++++++++ interp/builtins/testcmd/testcmd_test.go | 507 ++++++++++++++++ interp/builtins/testcmd/testcmd_unix_test.go | 66 +++ .../builtins/testcmd/testcmd_windows_test.go | 27 + interp/register_builtins.go | 3 + tests/scenarios/cmd/test/bracket/basic.yaml | 17 + .../cmd/test/bracket/missing_bracket.yaml | 9 + .../cmd/test/errors/unary_expected.yaml | 7 + tests/scenarios/cmd/test/files/directory.yaml | 20 + .../cmd/test/files/empty_operand.yaml | 17 + tests/scenarios/cmd/test/files/existence.yaml | 30 + tests/scenarios/cmd/test/files/size.yaml | 25 + .../scenarios/cmd/test/help/bracket_help.yaml | 9 + tests/scenarios/cmd/test/help/test_help.yaml | 9 + tests/scenarios/cmd/test/integers/basic.yaml | 32 + .../scenarios/cmd/test/integers/invalid.yaml | 9 + .../scenarios/cmd/test/integers/negative.yaml | 17 + tests/scenarios/cmd/test/logical/and_or.yaml | 26 + tests/scenarios/cmd/test/logical/not.yaml | 17 + .../cmd/test/logical/parentheses.yaml | 17 + .../cmd/test/logical/special_operands.yaml | 23 + .../cmd/test/strings/bare_string.yaml | 9 + .../cmd/test/strings/comparison.yaml | 20 + .../cmd/test/strings/empty_string.yaml | 9 + .../cmd/test/strings/empty_test.yaml | 9 + .../scenarios/cmd/test/strings/equality.yaml | 20 + .../cmd/test/strings/n_nonempty.yaml | 9 + .../cmd/test/strings/special_single_args.yaml | 23 + tests/scenarios/cmd/test/strings/z_empty.yaml | 9 + 31 files changed, 2031 insertions(+) create mode 100644 interp/builtins/testcmd/testcmd.go create mode 100644 interp/builtins/testcmd/testcmd_gnu_compat_test.go create mode 100644 interp/builtins/testcmd/testcmd_pentest_test.go create mode 100644 interp/builtins/testcmd/testcmd_test.go create mode 100644 interp/builtins/testcmd/testcmd_unix_test.go create mode 100644 interp/builtins/testcmd/testcmd_windows_test.go create mode 100644 tests/scenarios/cmd/test/bracket/basic.yaml create mode 100644 tests/scenarios/cmd/test/bracket/missing_bracket.yaml create mode 100644 tests/scenarios/cmd/test/errors/unary_expected.yaml create mode 100644 tests/scenarios/cmd/test/files/directory.yaml create mode 100644 tests/scenarios/cmd/test/files/empty_operand.yaml create mode 100644 tests/scenarios/cmd/test/files/existence.yaml create mode 100644 tests/scenarios/cmd/test/files/size.yaml create mode 100644 tests/scenarios/cmd/test/help/bracket_help.yaml create mode 100644 tests/scenarios/cmd/test/help/test_help.yaml create mode 100644 tests/scenarios/cmd/test/integers/basic.yaml create mode 100644 tests/scenarios/cmd/test/integers/invalid.yaml create mode 100644 tests/scenarios/cmd/test/integers/negative.yaml create mode 100644 tests/scenarios/cmd/test/logical/and_or.yaml create mode 100644 tests/scenarios/cmd/test/logical/not.yaml create mode 100644 tests/scenarios/cmd/test/logical/parentheses.yaml create mode 100644 tests/scenarios/cmd/test/logical/special_operands.yaml create mode 100644 tests/scenarios/cmd/test/strings/bare_string.yaml create mode 100644 tests/scenarios/cmd/test/strings/comparison.yaml create mode 100644 tests/scenarios/cmd/test/strings/empty_string.yaml create mode 100644 tests/scenarios/cmd/test/strings/empty_test.yaml create mode 100644 tests/scenarios/cmd/test/strings/equality.yaml create mode 100644 tests/scenarios/cmd/test/strings/n_nonempty.yaml create mode 100644 tests/scenarios/cmd/test/strings/special_single_args.yaml create mode 100644 tests/scenarios/cmd/test/strings/z_empty.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go new file mode 100644 index 00000000..65da9c9b --- /dev/null +++ b/interp/builtins/testcmd/testcmd.go @@ -0,0 +1,557 @@ +// 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 testcmd implements the POSIX test and [ builtin commands. +// +// Usage: +// +// test EXPRESSION +// [ EXPRESSION ] +// +// Evaluate a conditional expression and exit with status 0 (true) or 1 (false). +// The [ form requires a closing ] as the last argument. +// +// Exit codes: +// +// 0 — expression evaluates to true +// 1 — expression evaluates to false +// 2 — syntax/usage error +// +// Supported operators: +// +// File tests (unary): +// +// -e FILE FILE exists +// -f FILE FILE exists and is a regular file +// -d FILE FILE exists and is a directory +// -s FILE FILE exists and has size greater than zero +// -r FILE FILE exists and is readable +// -w FILE FILE exists and is writable +// -x FILE FILE exists and is executable +// -h FILE FILE exists and is a symbolic link +// -L FILE FILE exists and is a symbolic link (same as -h) +// -p FILE FILE exists and is a named pipe (FIFO) +// +// File comparison (binary): +// +// FILE1 -nt FILE2 FILE1 is newer (modification time) than FILE2 +// FILE1 -ot FILE2 FILE1 is older (modification time) than FILE2 +// +// String tests (unary): +// +// -z STRING length of STRING is zero +// -n STRING length of STRING is non-zero +// STRING STRING is non-empty (equivalent to -n STRING) +// +// String comparison (binary): +// +// S1 = S2 strings are equal +// S1 != S2 strings are not equal +// S1 < S2 S1 sorts before S2 (lexicographic) +// S1 > S2 S1 sorts after S2 (lexicographic) +// +// Integer comparison (binary): +// +// N1 -eq N2 integers are equal +// N1 -ne N2 integers are not equal +// N1 -lt N2 N1 is less than N2 +// N1 -le N2 N1 is less than or equal to N2 +// N1 -gt N2 N1 is greater than N2 +// N1 -ge N2 N1 is greater than or equal to N2 +// +// Logical operators: +// +// ! EXPR EXPR is false +// EXPR1 -a EXPR2 both EXPR1 and EXPR2 are true +// EXPR1 -o EXPR2 either EXPR1 or EXPR2 is true +// ( EXPR ) grouping (parentheses must be shell-escaped) +package testcmd + +import ( + "context" + "io/fs" + "math" + "strconv" + "strings" + + "github.com/DataDog/rshell/interp/builtins" +) + +// Cmd is the "test" builtin command registration. +var Cmd = builtins.Command{Name: "test", MakeFlags: builtins.NoFlags(runTest)} + +// BracketCmd is the "[" builtin command registration. +var BracketCmd = builtins.Command{Name: "[", MakeFlags: builtins.NoFlags(runBracket)} + +const helpText = `Usage: test EXPRESSION + or: [ EXPRESSION ] + +Evaluate conditional expression. + +Exit status: + 0 if EXPRESSION is true, + 1 if EXPRESSION is false, + 2 if an error occurred. + +File tests: + -e FILE FILE exists + -f FILE FILE is a regular file + -d FILE FILE is a directory + -s FILE FILE has size > 0 + -r FILE FILE is readable + -w FILE FILE is writable + -x FILE FILE is executable + -h FILE FILE is a symbolic link + -L FILE FILE is a symbolic link (same as -h) + -p FILE FILE is a named pipe + +File comparison: + FILE1 -nt FILE2 FILE1 is newer than FILE2 + FILE1 -ot FILE2 FILE1 is older than FILE2 + +String tests: + -z STRING STRING has zero length + -n STRING STRING has non-zero length + STRING STRING is non-empty + +String comparison: + S1 = S2 strings are equal + S1 != S2 strings are not equal + S1 < S2 S1 sorts before S2 + S1 > S2 S1 sorts after S2 + +Integer comparison: + N1 -eq N2 N1 equals N2 + N1 -ne N2 N1 is not equal to N2 + N1 -lt N2 N1 is less than N2 + N1 -le N2 N1 is less or equal to N2 + N1 -gt N2 N1 is greater than N2 + N1 -ge N2 N1 is greater or equal to N2 + +Logical: + ! EXPR EXPR is false + EXPR1 -a EXPR2 both true + EXPR1 -o EXPR2 either true + ( EXPR ) grouping +` + +const exitSyntaxError = 2 + +func runTest(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) == 1 && args[0] == "--help" { + callCtx.Out(helpText) + return builtins.Result{} + } + return evaluate(ctx, callCtx, "test", args) +} + +func runBracket(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) == 1 && args[0] == "--help" { + callCtx.Out(helpText) + return builtins.Result{} + } + if len(args) == 0 || args[len(args)-1] != "]" { + callCtx.Errf("[: missing `]'\n") + return builtins.Result{Code: exitSyntaxError} + } + return evaluate(ctx, callCtx, "[", args[:len(args)-1]) +} + +// parser holds the state for recursive-descent parsing of test expressions. +type parser struct { + ctx context.Context + callCtx *builtins.CallContext + cmdName string + args []string + pos int + err bool + depth int +} + +const maxParenDepth = 128 + +func evaluate(ctx context.Context, callCtx *builtins.CallContext, cmdName string, args []string) builtins.Result { + if len(args) == 0 { + return builtins.Result{Code: 1} + } + + p := &parser{ + ctx: ctx, + callCtx: callCtx, + cmdName: cmdName, + args: args, + } + + result := p.parseOr() + + if p.err { + return builtins.Result{Code: exitSyntaxError} + } + if p.pos < len(p.args) { + p.callCtx.Errf("%s: extra argument '%s'\n", p.cmdName, p.args[p.pos]) + return builtins.Result{Code: exitSyntaxError} + } + if result { + return builtins.Result{} + } + return builtins.Result{Code: 1} +} + +func (p *parser) peek() string { + if p.pos >= len(p.args) { + return "" + } + return p.args[p.pos] +} + +func (p *parser) advance() string { + s := p.args[p.pos] + p.pos++ + return s +} + +// parseOr handles EXPR1 -o EXPR2 (lowest precedence). +func (p *parser) parseOr() bool { + left := p.parseAnd() + for !p.err && p.ctx.Err() == nil && p.pos < len(p.args) && p.peek() == "-o" { + p.advance() + right := p.parseAnd() + left = left || right + } + return left +} + +// parseAnd handles EXPR1 -a EXPR2. +func (p *parser) parseAnd() bool { + left := p.parseNot() + for !p.err && p.ctx.Err() == nil && p.pos < len(p.args) && p.peek() == "-a" { + p.advance() + right := p.parseNot() + left = left && right + } + return left +} + +// parseNot handles ! EXPR. When ! is the last remaining token, it is +// treated as a non-empty string per POSIX single-argument rules. +// When ! is followed by a binary operator (e.g., "! = !"), it is treated +// as a literal string operand, not negation. +func (p *parser) parseNot() bool { + if p.pos < len(p.args) && p.peek() == "!" { + remaining := len(p.args) - p.pos + if remaining == 1 { + p.advance() + return true + } + // If "!" is followed by a binary operator, treat it as a literal + // operand (fall through to parsePrimary for binary expression). + if remaining >= 3 && isBinaryOp(p.args[p.pos+1]) { + return p.parsePrimary() + } + if p.depth >= maxParenDepth { + p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) + p.err = true + return false + } + p.depth++ + p.advance() + result := !p.parseNot() + p.depth-- + return result + } + return p.parsePrimary() +} + +// parsePrimary handles parenthesized expressions, unary operators, binary +// operators, and bare strings. It uses lookahead to implement the POSIX +// disambiguation rules (e.g., with 1 remaining arg, everything is a string). +func (p *parser) parsePrimary() bool { + if p.err || p.pos >= len(p.args) { + p.callCtx.Errf("%s: missing argument\n", p.cmdName) + p.err = true + return false + } + + cur := p.peek() + remaining := len(p.args) - p.pos + + // Only treat "(" as grouping when there are enough tokens and it is not + // used as a literal operand in a binary expression. A lone "(" with + // remaining==1 is a bare non-empty string per POSIX single-argument rules. + // When "(" is followed by a binary operator (e.g., "(" = "("), treat it + // as a literal string operand. + if cur == "(" && remaining > 1 && !(remaining >= 3 && isBinaryOp(p.args[p.pos+1])) { + if p.depth >= maxParenDepth { + p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) + p.err = true + return false + } + p.depth++ + p.advance() + if p.pos >= len(p.args) || p.peek() == ")" { + p.callCtx.Errf("%s: missing argument\n", p.cmdName) + p.err = true + return false + } + result := p.parseOr() + p.depth-- + if p.err { + return false + } + if p.pos >= len(p.args) || p.peek() != ")" { + p.callCtx.Errf("%s: missing ')'\n", p.cmdName) + p.err = true + return false + } + p.advance() + return result + } + + // POSIX disambiguation: if there are at least 3 remaining tokens and + // the second one is a binary operator, parse as a binary expression. + // This must be checked before unary operators to handle cases like + // "test -f = -f" (string comparison, not file test). + if remaining >= 3 { + op := p.args[p.pos+1] + if isBinaryOp(op) { + return p.parseBinaryExpr() + } + } + + // With 2+ remaining tokens, check for unary operators. + if remaining >= 2 { + if isUnaryFileOp(cur) { + return p.parseUnaryFileOp() + } + if cur == "-z" || cur == "-n" { + return p.parseUnaryStringOp() + } + // -o as unary tests whether a shell option is set. + // This restricted shell has no options, so always false. + if cur == "-o" { + p.advance() // consume -o + p.advance() // consume operand + return false + } + } + + // Single token or unrecognised: treat as bare string (true if non-empty). + p.advance() + return cur != "" +} + +func isBinaryOp(op string) bool { + switch op { + case "=", "==", "!=", "<", ">", + "-eq", "-ne", "-lt", "-le", "-gt", "-ge", + "-nt", "-ot": + return true + } + return false +} + +func isUnaryFileOp(op string) bool { + switch op { + case "-e", "-f", "-d", "-s", "-r", "-w", "-x", "-h", "-L", "-p": + return true + } + return false +} + +func (p *parser) consumeUnaryOperand(op string) (string, bool) { + if p.pos >= len(p.args) { + p.callCtx.Errf("%s: '%s': unary operator expected\n", p.cmdName, op) + p.err = true + return "", false + } + return p.advance(), true +} + +func (p *parser) parseUnaryFileOp() bool { + op := p.advance() + operand, ok := p.consumeUnaryOperand(op) + if !ok { + return false + } + return p.evalFileTest(op, operand) +} + +func (p *parser) parseUnaryStringOp() bool { + op := p.advance() + s, ok := p.consumeUnaryOperand(op) + if !ok { + return false + } + return (op == "-z") == (s == "") +} + +func (p *parser) parseBinaryExpr() bool { + left := p.advance() + op := p.advance() + if p.pos >= len(p.args) { + p.callCtx.Errf("%s: missing argument after '%s'\n", p.cmdName, op) + p.err = true + return false + } + right := p.advance() + + switch op { + case "=", "==": + return left == right + case "!=": + return left != right + case "<": + return left < right + case ">": + return left > right + case "-eq", "-ne", "-lt", "-le", "-gt", "-ge": + return p.evalIntCompare(left, op, right) + case "-nt", "-ot": + return p.evalFileCompare(left, op, right) + default: + p.callCtx.Errf("%s: unknown binary operator '%s'\n", p.cmdName, op) + p.err = true + return false + } +} + +func (p *parser) evalFileTest(op, path string) bool { + if path == "" { + return false + } + // For -r/-w/-x, use real access checks instead of mode bits. + if p.callCtx.AccessFile != nil { + switch op { + case "-r": + return p.callCtx.AccessFile(p.ctx, path, 0x04) == nil + case "-w": + return p.callCtx.AccessFile(p.ctx, path, 0x02) == nil + case "-x": + return p.callCtx.AccessFile(p.ctx, path, 0x01) == nil + } + } + + switch op { + case "-h", "-L": + if p.callCtx.LstatFile == nil { + return false + } + info, err := p.callCtx.LstatFile(p.ctx, path) + if err != nil { + return false + } + return info.Mode()&fs.ModeSymlink != 0 + default: + if p.callCtx.StatFile == nil { + return false + } + info, err := p.callCtx.StatFile(p.ctx, path) + if err != nil { + return false + } + return evalFileInfo(op, info) + } +} + +func evalFileInfo(op string, info fs.FileInfo) bool { + switch op { + case "-e": + return true + case "-f": + return info.Mode().IsRegular() + case "-d": + return info.IsDir() + case "-s": + return info.Size() > 0 + case "-r": + return info.Mode().Perm()&0444 != 0 + case "-w": + return info.Mode().Perm()&0222 != 0 + case "-x": + return info.Mode().Perm()&0111 != 0 + case "-p": + return info.Mode()&fs.ModeNamedPipe != 0 + } + return false +} + +func (p *parser) evalIntCompare(left, op, right string) bool { + l, ok := p.parseInt(left) + if !ok { + return false + } + r, ok := p.parseInt(right) + if !ok { + return false + } + switch op { + case "-eq": + return l == r + case "-ne": + return l != r + case "-lt": + return l < r + case "-le": + return l <= r + case "-gt": + return l > r + case "-ge": + return l >= r + } + return false +} + +func (p *parser) parseInt(s string) (int64, bool) { + s = strings.TrimSpace(s) + if s == "" { + p.callCtx.Errf("%s: : integer expression expected\n", p.cmdName) + p.err = true + return 0, false + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + // Check for overflow — clamp to boundaries like GNU test. + if numErr, ok := err.(*strconv.NumError); ok && numErr.Err == strconv.ErrRange { + if s[0] == '-' { + n = math.MinInt64 + } else { + n = math.MaxInt64 + } + return n, true + } + p.callCtx.Errf("%s: %s: integer expression expected\n", p.cmdName, s) + p.err = true + return 0, false + } + return n, true +} + +func (p *parser) evalFileCompare(left, op, right string) bool { + if p.callCtx.StatFile == nil { + return false + } + leftInfo, leftErr := p.callCtx.StatFile(p.ctx, left) + rightInfo, rightErr := p.callCtx.StatFile(p.ctx, right) + + switch op { + case "-nt": + if leftErr != nil { + return false + } + if rightErr != nil { + return true + } + return leftInfo.ModTime().After(rightInfo.ModTime()) + case "-ot": + if rightErr != nil { + return false + } + if leftErr != nil { + return true + } + return leftInfo.ModTime().Before(rightInfo.ModTime()) + } + return false +} diff --git a/interp/builtins/testcmd/testcmd_gnu_compat_test.go b/interp/builtins/testcmd/testcmd_gnu_compat_test.go new file mode 100644 index 00000000..700fd7d3 --- /dev/null +++ b/interp/builtins/testcmd/testcmd_gnu_compat_test.go @@ -0,0 +1,165 @@ +// 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 testcmd_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestGNUCompatTestEmptyIsFalse — no arguments = false. +// +// GNU: test; echo $? → 1 +func TestGNUCompatTestEmptyIsFalse(t *testing.T) { + stdout, stderr, code := runScript(t, "test; echo $?", "") + assert.Equal(t, "1\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestZeroLengthEmpty — -z "" = true. +// +// GNU: test -z ""; echo $? → 0 +func TestGNUCompatTestZeroLengthEmpty(t *testing.T) { + stdout, stderr, code := runScript(t, `test -z ""; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestNonZeroLength — -n "hello" = true. +// +// GNU: test -n "hello"; echo $? → 0 +func TestGNUCompatTestNonZeroLength(t *testing.T) { + stdout, stderr, code := runScript(t, `test -n "hello"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestStringEquality — "t" = "t". +// +// GNU: test "t" = "t"; echo $? → 0 +func TestGNUCompatTestStringEquality(t *testing.T) { + stdout, _, code := runScript(t, `test "t" = "t"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestStringInequality — "t" = "f" → false. +// +// GNU: test "t" = "f"; echo $? → 1 +func TestGNUCompatTestStringInequality(t *testing.T) { + stdout, _, code := runScript(t, `test "t" = "f"; echo $?`, "") + assert.Equal(t, "1\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestIntegerEquality — 9 -eq 9. +// +// GNU: test 9 -eq 9; echo $? → 0 +func TestGNUCompatTestIntegerEquality(t *testing.T) { + stdout, _, code := runScript(t, `test 9 -eq 9; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestIntegerWithLeadingZeros — 0 -eq 00. +// +// GNU: test 0 -eq 00; echo $? → 0 +func TestGNUCompatTestIntegerWithLeadingZeros(t *testing.T) { + stdout, _, code := runScript(t, `test 0 -eq 00; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestInvalidIntegerHex — 0x0 -eq 00 → error. +// +// GNU: test 0x0 -eq 00; echo $? → 2 (stderr: "test: 0x0: integer expression expected") +func TestGNUCompatTestInvalidIntegerHex(t *testing.T) { + _, stderr, code := runScript(t, `test 0x0 -eq 00`, "") + assert.Equal(t, 2, code) + assert.Equal(t, "test: 0x0: integer expression expected\n", stderr) +} + +// TestGNUCompatTestNegation — ! "" = true. +// +// GNU: test ! ""; echo $? → 0 +func TestGNUCompatTestNegation(t *testing.T) { + stdout, _, code := runScript(t, `test ! ""; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestAndOperator — "t" -a "t" = true. +// +// GNU: test "t" -a "t"; echo $? → 0 +func TestGNUCompatTestAndOperator(t *testing.T) { + stdout, _, code := runScript(t, `test "t" -a "t"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestOrOperator — "" -o "t" = true. +// +// GNU: test "" -o "t"; echo $? → 0 +func TestGNUCompatTestOrOperator(t *testing.T) { + stdout, _, code := runScript(t, `test "" -o "t"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestParentheses — ( "hello" ). +// +// GNU: test '(' "hello" ')'; echo $? → 0 +func TestGNUCompatTestParentheses(t *testing.T) { + stdout, _, code := runScript(t, `test '(' "hello" ')'; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatBracketMissingClose — [ 1 -eq → exit 2 + stderr. +// +// GNU: [ 1 -eq; echo $? → 2 (stderr: "[: missing `]'") +func TestGNUCompatBracketMissingClose(t *testing.T) { + _, stderr, code := runScript(t, `[ 1 -eq`, "") + assert.Equal(t, 2, code) + assert.Equal(t, "[: missing `]'\n", stderr) +} + +// TestGNUCompatTestFileExists — -f on regular file. +// +// GNU: test -f file.txt; echo $? → 0 +func TestGNUCompatTestFileExists(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "content\n") + stdout, _, code := cmdRun(t, `test -f file.txt; echo $?`, dir) + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestNtOt — file modification time comparison. +// +// GNU: test newer -nt older; echo $? → 0 +// GNU: test older -ot newer; echo $? → 0 +func TestGNUCompatTestNtOt(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "older.txt", "old") + os.Chtimes(filepath.Join(dir, "older.txt"), time.Now().Add(-2*time.Hour), time.Now().Add(-2*time.Hour)) + writeFile(t, dir, "newer.txt", "new") + + stdout, _, code := cmdRun(t, `test newer.txt -nt older.txt; echo $?`, dir) + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) + + stdout, _, code = cmdRun(t, `test older.txt -ot newer.txt; echo $?`, dir) + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} diff --git a/interp/builtins/testcmd/testcmd_pentest_test.go b/interp/builtins/testcmd/testcmd_pentest_test.go new file mode 100644 index 00000000..9520a6b3 --- /dev/null +++ b/interp/builtins/testcmd/testcmd_pentest_test.go @@ -0,0 +1,314 @@ +// 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 testcmd_test + +import ( + "context" + "math" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +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(10 * time.Second): + t.Fatal("test timed out — possible hang") + } +} + +// --- Integer edge cases --- + +func TestPentestIntZero(t *testing.T) { + stdout, _, _ := runScript(t, `test 0 -eq 0; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntOne(t *testing.T) { + stdout, _, _ := runScript(t, `test 1 -eq 1; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntMaxInt32(t *testing.T) { + max32 := strconv.Itoa(math.MaxInt32) + stdout, _, _ := runScript(t, `test `+max32+` -eq `+max32+`; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntMaxInt64(t *testing.T) { + max64 := strconv.FormatInt(math.MaxInt64, 10) + stdout, _, _ := runScript(t, `test `+max64+` -eq `+max64+`; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntOverflowPositive(t *testing.T) { + huge := "99999999999999999999" + stdout, _, _ := runScript(t, `test `+huge+` -gt 0; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntOverflowNegative(t *testing.T) { + huge := "-99999999999999999999" + stdout, _, _ := runScript(t, `test `+huge+` -lt 0; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntEmptyString(t *testing.T) { + _, stderr, code := runScript(t, `test "" -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +func TestPentestIntWhitespaceOnly(t *testing.T) { + _, stderr, code := runScript(t, `test " " -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +func TestPentestIntHexRejected(t *testing.T) { + _, stderr, code := runScript(t, `test 0x10 -eq 16`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +func TestPentestIntOctalNotInterpreted(t *testing.T) { + stdout, _, _ := runScript(t, `test 010 -eq 10; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntFloatRejected(t *testing.T) { + _, stderr, code := runScript(t, `test 3.14 -eq 3`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +// --- Special files --- + +func TestPentestDevNull(t *testing.T) { + mustNotHang(t, func() { + _, _, code := runScript(t, `test -f `+os.DevNull, "", interp.AllowedPaths([]string{filepath.Dir(os.DevNull)})) + _ = code + }) +} + +func TestPentestDevNullExists(t *testing.T) { + mustNotHang(t, func() { + _, _, code := runScript(t, `test -e `+os.DevNull, "", interp.AllowedPaths([]string{filepath.Dir(os.DevNull)})) + _ = code + }) +} + +// --- Path edge cases --- + +func TestPentestNonexistentFile(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -f /absolutely/nonexistent`, dir) + assert.Equal(t, 1, code) +} + +func TestPentestEmptyFilename(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -f ""`, dir) + assert.Equal(t, 1, code) +} + +func TestPentestDirectoryAsFile(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -f .`, dir) + assert.Equal(t, 1, code) +} + +func TestPentestPathTraversal(t *testing.T) { + dir := t.TempDir() + _, _, code := runScript(t, `test -e ../../../etc/passwd`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) +} + +func TestPentestDoubleSlashes(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "data") + _, _, code := cmdRun(t, `test -f .//f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestPentestFlagLikeFilename(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "-f", "data") + _, _, code := cmdRun(t, `test -e -- -f`, dir) + _ = code +} + +// --- Flag and argument injection --- + +func TestPentestUnknownFlag(t *testing.T) { + _, _, code := runScript(t, `test --no-such-flag`, "") + assert.Equal(t, 0, code) +} + +func TestPentestDoubleDash(t *testing.T) { + _, _, code := runScript(t, `test --`, "") + assert.Equal(t, 0, code) +} + +func TestPentestMultipleStdinArgs(t *testing.T) { + _, _, code := runScript(t, `test "-" = "-"`, "") + assert.Equal(t, 0, code) +} + +// --- Context timeout --- + +func TestPentestTimeoutOnFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "data") + mustNotHang(t, func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, _ = runScriptCtx(ctx, t, `test -f f.txt`, dir, interp.AllowedPaths([]string{dir})) + }) +} + +// --- Bracket syntax edge cases --- + +func TestPentestBracketEmptyBrackets(t *testing.T) { + _, stderr, code := runScript(t, `[ ]`, "") + assert.Equal(t, 1, code, "stderr: %s", stderr) +} + +func TestPentestBracketOnlyClose(t *testing.T) { + _, stderr, code := runScript(t, `[`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing `]'") +} + +// --- Large argument count --- + +func TestPentestManyArguments(t *testing.T) { + mustNotHang(t, func() { + _, _, code := runScript(t, `test "a" -a "b" -a "c" -a "d" -a "e" -a "f"`, "") + assert.Equal(t, 0, code) + }) +} + +// --- Precedence / complex expressions --- + +func TestPentestPrecedenceAndOr(t *testing.T) { + stdout, _, _ := runScript(t, `test " " -o "" -a ""; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestNestedParentheses(t *testing.T) { + stdout, _, _ := runScript(t, `test '(' '(' "a" = "a" ')' ')'; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestMissingCloseParen(t *testing.T) { + _, stderr, code := runScript(t, `test '(' "a"`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing ')'") +} + +func TestPentestEmptyParens(t *testing.T) { + _, _, code := runScript(t, `test '(' ')'`, "") + assert.Equal(t, 2, code) +} + +func TestPentestDeepNesting(t *testing.T) { + // Build a deeply nested expression: ( ( ( ... "x" ... ) ) ) + var script string + depth := 200 + for i := 0; i < depth; i++ { + script += "'(' " + } + script += `"x"` + for i := 0; i < depth; i++ { + script += " ')'" + } + _, stderr, code := runScript(t, "test "+script, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "too deeply nested") +} + +func TestPentestDeepNesting1000(t *testing.T) { + // 1000+ levels of nesting should be rejected by depth limit, not stack overflow. + var script string + depth := 1000 + for i := 0; i < depth; i++ { + script += "'(' " + } + script += `"x"` + for i := 0; i < depth; i++ { + script += " ')'" + } + mustNotHang(t, func() { + _, stderr, code := runScript(t, "test "+script, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "too deeply nested") + }) +} + +func TestPentestNullByteInFilePath(t *testing.T) { + // Null bytes in file paths should not bypass sandbox checks. + dir := t.TempDir() + // test -f with a path containing a null byte + _, _, code := cmdRun(t, "test -f \"file\x00/../etc/passwd\"", dir) + assert.NotEqual(t, 0, code) +} + +func TestPentestVeryLongString(t *testing.T) { + // Very long string arguments should not cause panics or hangs. + mustNotHang(t, func() { + long := make([]byte, 100*1024) + for i := range long { + long[i] = 'a' + } + script := `test "` + string(long) + `" = "` + string(long) + `"; echo $?` + stdout, _, code := runScript(t, script, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) + }) +} + +func TestPentestVeryLongFilePath(t *testing.T) { + // Very long file path should not cause panics. + dir := t.TempDir() + long := make([]byte, 100*1024) + for i := range long { + long[i] = 'a' + } + mustNotHang(t, func() { + _, _, code := cmdRun(t, `test -f "`+string(long)+`"`, dir) + assert.Equal(t, 1, code) + }) +} + +func TestPentestBangAsStringOperand(t *testing.T) { + // "!" should be treated as a literal string in binary expressions + stdout, stderr, code := runScript(t, `test '!' = '!'; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) + assert.Empty(t, stderr) +} + +func TestPentestParenAsStringOperand(t *testing.T) { + // "(" should be treated as a literal string in binary expressions + stdout, stderr, code := runScript(t, `test '(' = '('; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) + assert.Empty(t, stderr) +} diff --git a/interp/builtins/testcmd/testcmd_test.go b/interp/builtins/testcmd/testcmd_test.go new file mode 100644 index 00000000..5e69998a --- /dev/null +++ b/interp/builtins/testcmd/testcmd_test.go @@ -0,0 +1,507 @@ +// 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 testcmd_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, dir, opts...) +} + +func runScriptCtx(ctx context.Context, t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScriptCtx(ctx, t, script, dir, opts...) +} + +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644) + if err != nil { + t.Fatal(err) + } + return name +} + +// --- String tests --- + +func TestTestEmptyArgs(t *testing.T) { + _, _, code := runScript(t, "test", "") + assert.Equal(t, 1, code) +} + +func TestTestBareString(t *testing.T) { + _, _, code := runScript(t, `test "hello"`, "") + assert.Equal(t, 0, code) +} + +func TestTestEmptyString(t *testing.T) { + _, _, code := runScript(t, `test ""`, "") + assert.Equal(t, 1, code) +} + +func TestTestZeroLength(t *testing.T) { + _, _, code := runScript(t, `test -z ""`, "") + assert.Equal(t, 0, code) +} + +func TestTestZeroLengthNonEmpty(t *testing.T) { + _, _, code := runScript(t, `test -z "hello"`, "") + assert.Equal(t, 1, code) +} + +func TestTestNonZeroLength(t *testing.T) { + _, _, code := runScript(t, `test -n "hello"`, "") + assert.Equal(t, 0, code) +} + +func TestTestNonZeroLengthEmpty(t *testing.T) { + _, _, code := runScript(t, `test -n ""`, "") + assert.Equal(t, 1, code) +} + +func TestTestStringEqual(t *testing.T) { + _, _, code := runScript(t, `test "abc" = "abc"`, "") + assert.Equal(t, 0, code) +} + +func TestTestStringNotEqual(t *testing.T) { + _, _, code := runScript(t, `test "abc" != "def"`, "") + assert.Equal(t, 0, code) +} + +func TestTestStringEqualFalse(t *testing.T) { + _, _, code := runScript(t, `test "abc" = "def"`, "") + assert.Equal(t, 1, code) +} + +func TestTestDoubleEqual(t *testing.T) { + _, _, code := runScript(t, `test "t" == "t"`, "") + assert.Equal(t, 0, code) +} + +func TestTestStringLessThan(t *testing.T) { + stdout, _, code := runScript(t, `test "a" \< "b"; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +func TestTestStringGreaterThan(t *testing.T) { + stdout, _, code := runScript(t, `test "b" \> "a"; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +// --- Integer comparison tests --- + +func TestTestIntEq(t *testing.T) { + _, _, code := runScript(t, `test 9 -eq 9`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntNe(t *testing.T) { + _, _, code := runScript(t, `test 8 -ne 9`, "") + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test 9 -ne 9`, "") + assert.Equal(t, 1, code) +} + +func TestTestIntGt(t *testing.T) { + _, _, code := runScript(t, `test 5 -gt 4`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntLt(t *testing.T) { + _, _, code := runScript(t, `test 4 -lt 5`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntGe(t *testing.T) { + _, _, code := runScript(t, `test 5 -ge 5`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntLe(t *testing.T) { + _, _, code := runScript(t, `test 5 -le 5`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntNegative(t *testing.T) { + _, _, code := runScript(t, `test -1 -gt -2`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntLeadingZero(t *testing.T) { + _, _, code := runScript(t, `test 0 -eq 00`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntWhitespace(t *testing.T) { + stdout, _, _ := runScript(t, `test 0 -eq " 0 "; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestTestInvalidInteger(t *testing.T) { + _, stderr, code := runScript(t, `test 0x0 -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +func TestTestFloatRejected(t *testing.T) { + _, stderr, code := runScript(t, `test 123.45 -ge 6`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "123.45: integer expression expected") +} + +// --- Logical operator tests --- + +func TestTestAndTrue(t *testing.T) { + _, _, code := runScript(t, `test "a" -a "b"`, "") + assert.Equal(t, 0, code) +} + +func TestTestAndFalse(t *testing.T) { + _, _, code := runScript(t, `test "" -a "b"`, "") + assert.Equal(t, 1, code) +} + +func TestTestOrTrue(t *testing.T) { + _, _, code := runScript(t, `test "" -o "b"`, "") + assert.Equal(t, 0, code) +} + +func TestTestOrFalse(t *testing.T) { + _, _, code := runScript(t, `test "" -o ""`, "") + assert.Equal(t, 1, code) +} + +func TestTestNot(t *testing.T) { + _, _, code := runScript(t, `test ! ""`, "") + assert.Equal(t, 0, code) +} + +func TestTestNotTrue(t *testing.T) { + _, _, code := runScript(t, `test ! "hello"`, "") + assert.Equal(t, 1, code) +} + +func TestTestDoubleNot(t *testing.T) { + _, _, code := runScript(t, `test ! ! "hello"`, "") + assert.Equal(t, 0, code) +} + +func TestTestParentheses(t *testing.T) { + stdout, _, _ := runScript(t, `test '(' "hello" ')'; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestTestParenthesesEmpty(t *testing.T) { + stdout, _, _ := runScript(t, `test '(' "" ')'; echo $?`, "") + assert.Equal(t, "1\n", stdout) +} + +func TestTestPrecedence(t *testing.T) { + stdout, _, _ := runScript(t, `test " " -o "" -a ""; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +// --- File tests --- + +func TestTestFileExists(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -e f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileNotExists(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -e nonexistent`, dir) + assert.Equal(t, 1, code) +} + +func TestTestRegularFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -f f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestDirNotRegular(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -f .`, dir) + assert.Equal(t, 1, code) +} + +func TestTestIsDirectory(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -d .`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileNotDirectory(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -d f.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileSizeGreaterThanZero(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "nonempty.txt", "data") + _, _, code := cmdRun(t, `test -s nonempty.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestEmptyFileSize(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "empty.txt", "") + _, _, code := cmdRun(t, `test -s empty.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileReadable(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -r f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileWritable(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -w f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileNotExecutable(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -x f.txt`, dir) + assert.Equal(t, 1, code) +} + +// --- Bracket syntax tests --- + +func TestBracketBasic(t *testing.T) { + _, _, code := runScript(t, `[ "hello" ]`, "") + assert.Equal(t, 0, code) +} + +func TestBracketIntCompare(t *testing.T) { + _, _, code := runScript(t, `[ 1 -eq 1 ]`, "") + assert.Equal(t, 0, code) +} + +func TestBracketMissingClose(t *testing.T) { + _, stderr, code := runScript(t, `[ 1 -eq 1`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing `]'") +} + +func TestBracketEmpty(t *testing.T) { + _, stderr, code := runScript(t, `[`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing `]'") +} + +func TestTestLoneParenIsString(t *testing.T) { + _, _, code := runScript(t, `test "("`, "") + assert.Equal(t, 0, code) +} + +func TestTestEmptyFileOperand(t *testing.T) { + _, _, code := runScript(t, `test -e ""`, "") + assert.Equal(t, 1, code) + + _, _, code = runScript(t, `test -d ""`, "") + assert.Equal(t, 1, code) + + _, _, code = runScript(t, `test -f ""`, "") + assert.Equal(t, 1, code) +} + +// --- Help tests --- + +func TestTestHelp(t *testing.T) { + stdout, _, code := runScript(t, `test --help`, "") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") +} + +func TestBracketHelp(t *testing.T) { + stdout, _, code := runScript(t, `[ --help`, "") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") +} + +// --- Error cases --- + +func TestTestExtraArgument(t *testing.T) { + _, stderr, code := runScript(t, `test "a" "b" "c" "d" "e"`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "extra argument") +} + +// --- File comparison -nt / -ot tests --- + +func TestTestNewerThan(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "old.txt", "old") + old := filepath.Join(dir, "old.txt") + past := time.Now().Add(-2 * time.Hour) + os.Chtimes(old, past, past) + writeFile(t, dir, "new.txt", "new") + + _, _, code := cmdRun(t, `test new.txt -nt old.txt`, dir) + assert.Equal(t, 0, code) + + _, _, code = cmdRun(t, `test old.txt -nt new.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestOlderThan(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "old.txt", "old") + old := filepath.Join(dir, "old.txt") + past := time.Now().Add(-2 * time.Hour) + os.Chtimes(old, past, past) + writeFile(t, dir, "new.txt", "new") + + _, _, code := cmdRun(t, `test old.txt -ot new.txt`, dir) + assert.Equal(t, 0, code) + + _, _, code = cmdRun(t, `test new.txt -ot old.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestNtMissingFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "exists.txt", "data") + + _, _, code := cmdRun(t, `test exists.txt -nt nonexistent`, dir) + assert.Equal(t, 0, code) + + _, _, code = cmdRun(t, `test nonexistent -nt exists.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestOtMissingFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "exists.txt", "data") + + _, _, code := cmdRun(t, `test nonexistent -ot exists.txt`, dir) + assert.Equal(t, 0, code) + + _, _, code = cmdRun(t, `test exists.txt -ot nonexistent`, dir) + assert.Equal(t, 1, code) +} + +// --- Special single-arg cases --- + +func TestTestSpecialSingleArgs(t *testing.T) { + cases := []string{"-", "--", "-0", "-f", "["} + for _, c := range cases { + t.Run(c, func(t *testing.T) { + stdout, _, _ := runScript(t, `test "`+c+`"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + }) + } +} + +// --- Operator as literal string --- + +func TestTestSoloNot(t *testing.T) { + _, _, code := runScript(t, `test !`, "") + assert.Equal(t, 0, code) +} + +func TestTestSoloOperatorLiteral(t *testing.T) { + _, _, code := runScript(t, `test -a`, "") + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test -o`, "") + assert.Equal(t, 0, code) +} + +// --- Context cancellation --- + +func TestTestContextCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + cancel() + _, _, code := runScriptCtx(ctx, t, `test 1 -eq 1`, "") + // A pre-cancelled context prevents the runner from starting; exit code depends + // on how the runner surfaces the error — just verify it did not succeed. + _ = code +} + +// --- Sandbox enforcement --- + +func TestTestFileOutsideSandbox(t *testing.T) { + dir := t.TempDir() + other := t.TempDir() + writeFile(t, other, "secret.txt", "data") + + _, _, code := runScript(t, `test -f `+filepath.Join(other, "secret.txt"), dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) +} + +// --- Integer overflow clamping --- + +func TestTestIntOverflow(t *testing.T) { + stdout, _, code := runScript(t, `test 99999999999999999999 -gt 0; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +func TestTestIntNegOverflow(t *testing.T) { + stdout, _, code := runScript(t, `test -99999999999999999999 -lt 0; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +// --- Shell integration tests --- + +func TestTestWithAndList(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "data") + stdout, _, code := cmdRun(t, `test -f file.txt && echo "yes" || echo "no"`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestInForLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha") + writeFile(t, dir, "b.txt", "beta") + stdout, _, code := cmdRun(t, `for f in a.txt b.txt; do test -f "$f" && echo "$f exists"; done`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a.txt exists\nb.txt exists\n", stdout) +} + +func TestBracketInAndList(t *testing.T) { + stdout, _, code := runScript(t, `[ "hello" = "hello" ] && echo "match"`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "match\n", stdout) +} diff --git a/interp/builtins/testcmd/testcmd_unix_test.go b/interp/builtins/testcmd/testcmd_unix_test.go new file mode 100644 index 00000000..d892a90e --- /dev/null +++ b/interp/builtins/testcmd/testcmd_unix_test.go @@ -0,0 +1,66 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build unix + +package testcmd_test + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +func TestTestSymlink(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "target.txt", "hello") + os.Symlink("target.txt", filepath.Join(dir, "link.txt")) + + _, _, code := runScript(t, `test -h link.txt`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test -L link.txt`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test -h target.txt`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) +} + +func TestTestSymlinkNotRegular(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "target.txt", "hello") + os.Symlink("target.txt", filepath.Join(dir, "link.txt")) + + _, _, code := runScript(t, `test -f link.txt`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) +} + +func TestTestExecutableFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "exec.sh") + os.WriteFile(p, []byte("#!/bin/sh\n"), 0755) + + _, _, code := runScript(t, `test -x exec.sh`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) +} + +func TestTestNamedPipe(t *testing.T) { + dir := t.TempDir() + pipe := filepath.Join(dir, "mypipe") + if err := syscall.Mkfifo(pipe, 0644); err != nil { + t.Skip("cannot create FIFO:", err) + } + + _, _, code := runScript(t, `test -p mypipe`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test -p .`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) +} diff --git a/interp/builtins/testcmd/testcmd_windows_test.go b/interp/builtins/testcmd/testcmd_windows_test.go new file mode 100644 index 00000000..69eecd04 --- /dev/null +++ b/interp/builtins/testcmd/testcmd_windows_test.go @@ -0,0 +1,27 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build windows + +package testcmd_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +func TestTestWindowsReservedNames(t *testing.T) { + dir := t.TempDir() + reserved := []string{"CON", "PRN", "AUX", "NUL", "COM1", "LPT1"} + for _, name := range reserved { + t.Run(name, func(t *testing.T) { + _, _, code := runScript(t, `test -e `+name, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) + }) + } +} diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 14b38ec9..8d7f50d5 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -21,6 +21,7 @@ import ( "github.com/DataDog/rshell/interp/builtins/ls" "github.com/DataDog/rshell/interp/builtins/strings_cmd" "github.com/DataDog/rshell/interp/builtins/tail" + "github.com/DataDog/rshell/interp/builtins/testcmd" truecmd "github.com/DataDog/rshell/interp/builtins/true" "github.com/DataDog/rshell/interp/builtins/uniq" "github.com/DataDog/rshell/interp/builtins/wc" @@ -43,6 +44,8 @@ func registerBuiltins() { ls.Cmd, strings_cmd.Cmd, tail.Cmd, + testcmd.Cmd, + testcmd.BracketCmd, truecmd.Cmd, uniq.Cmd, wc.Cmd, diff --git a/tests/scenarios/cmd/test/bracket/basic.yaml b/tests/scenarios/cmd/test/bracket/basic.yaml new file mode 100644 index 00000000..102573e8 --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/basic.yaml @@ -0,0 +1,17 @@ +# Derived from uutils test_test.rs — bracket syntax tests +description: "[ ] bracket syntax with closing bracket requirement" +input: + script: |+ + [ "hello" ] + echo "basic: $?" + [ 1 -eq 1 ] + echo "eq: $?" + [ 1 -eq 2 ] + echo "ne: $?" +expect: + stdout: | + basic: 0 + eq: 0 + ne: 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/bracket/missing_bracket.yaml b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml new file mode 100644 index 00000000..65cefd64 --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml @@ -0,0 +1,9 @@ +# Derived from uutils test_test.rs — missing closing bracket +description: "[ without closing ] gives exit code 2 and error" +skip_assert_against_bash: true # bash prefixes error with scriptname:line +input: + script: |+ + [ 1 -eq 1 +expect: + stderr: "[: missing `]'\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/test/errors/unary_expected.yaml b/tests/scenarios/cmd/test/errors/unary_expected.yaml new file mode 100644 index 00000000..8c3e402b --- /dev/null +++ b/tests/scenarios/cmd/test/errors/unary_expected.yaml @@ -0,0 +1,7 @@ +# Derived from uutils test_test.rs — -o as unary option test +description: "test -o treats unknown option name as false" +input: + script: |+ + test -o arg +expect: + exit_code: 1 diff --git a/tests/scenarios/cmd/test/files/directory.yaml b/tests/scenarios/cmd/test/files/directory.yaml new file mode 100644 index 00000000..5c51546c --- /dev/null +++ b/tests/scenarios/cmd/test/files/directory.yaml @@ -0,0 +1,20 @@ +# Derived from uutils test_test.rs — test -d on file vs directory +description: "test -d distinguishes directories from regular files" +setup: + files: + - path: afile.txt + content: "x" +input: + allowed_paths: ["$DIR"] + script: |+ + test -d . + echo "dot: $?" + test -d afile.txt + echo "file: $?" +expect: + stdout: | + dot: 0 + file: 1 + stderr: "" + exit_code: 0 +skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/files/empty_operand.yaml b/tests/scenarios/cmd/test/files/empty_operand.yaml new file mode 100644 index 00000000..2a705f07 --- /dev/null +++ b/tests/scenarios/cmd/test/files/empty_operand.yaml @@ -0,0 +1,17 @@ +# Empty filename operand should always return false +description: "test file operators return false for empty operand" +input: + script: |+ + test -e "" + echo "e: $?" + test -f "" + echo "f: $?" + test -d "" + echo "d: $?" +expect: + stdout: | + e: 1 + f: 1 + d: 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/files/existence.yaml b/tests/scenarios/cmd/test/files/existence.yaml new file mode 100644 index 00000000..96cb2a1b --- /dev/null +++ b/tests/scenarios/cmd/test/files/existence.yaml @@ -0,0 +1,30 @@ +# Derived from uutils test_test.rs — file existence and type tests +description: "test -e, -f, -d on existing files and directories" +setup: + files: + - path: regular.txt + content: "hello\n" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + test -e regular.txt + echo "exists: $?" + test -f regular.txt + echo "file: $?" + test -d . + echo "dir: $?" + test -e nonexistent + echo "noexist: $?" + test -f nonexistent + echo "nofile: $?" +expect: + stdout: | + exists: 0 + file: 0 + dir: 0 + noexist: 1 + nofile: 1 + stderr: "" + exit_code: 0 +skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/files/size.yaml b/tests/scenarios/cmd/test/files/size.yaml new file mode 100644 index 00000000..59c31781 --- /dev/null +++ b/tests/scenarios/cmd/test/files/size.yaml @@ -0,0 +1,25 @@ +# Derived from uutils test_test.rs — test -s (size greater than zero) +description: "test -s on empty and non-empty files" +setup: + files: + - path: empty.txt + content: "" + - path: nonempty.txt + content: "data" +input: + allowed_paths: ["$DIR"] + script: |+ + test -s nonempty.txt + echo "nonempty: $?" + test -s empty.txt + echo "empty: $?" + test -s nonexistent + echo "missing: $?" +expect: + stdout: | + nonempty: 0 + empty: 1 + missing: 1 + stderr: "" + exit_code: 0 +skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/help/bracket_help.yaml b/tests/scenarios/cmd/test/help/bracket_help.yaml new file mode 100644 index 00000000..53ca164d --- /dev/null +++ b/tests/scenarios/cmd/test/help/bracket_help.yaml @@ -0,0 +1,9 @@ +# Derived from uutils test_test.rs — --help for [ +description: "[ --help prints usage and exits 0" +input: + script: |+ + [ --help +expect: + stdout_contains: ["Usage:"] + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/test/help/test_help.yaml b/tests/scenarios/cmd/test/help/test_help.yaml new file mode 100644 index 00000000..9ef6c66c --- /dev/null +++ b/tests/scenarios/cmd/test/help/test_help.yaml @@ -0,0 +1,9 @@ +# Derived from uutils test_test.rs — --help for test and [ +description: "test --help prints usage and exits 0" +input: + script: |+ + test --help +expect: + stdout_contains: ["Usage:"] + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/test/integers/basic.yaml b/tests/scenarios/cmd/test/integers/basic.yaml new file mode 100644 index 00000000..4a43487a --- /dev/null +++ b/tests/scenarios/cmd/test/integers/basic.yaml @@ -0,0 +1,32 @@ +# Derived from GNU coreutils test.pl eq/gt/lt tests +description: "test integer equality and comparison operators" +input: + script: |+ + test 9 -eq 9 + echo "eq: $?" + test 8 -eq 9 + echo "ne: $?" + test 5 -gt 4 + echo "gt: $?" + test 5 -gt 5 + echo "gt-eq: $?" + test 4 -lt 5 + echo "lt: $?" + test 5 -lt 5 + echo "lt-eq: $?" + test 5 -ge 5 + echo "ge-eq: $?" + test 5 -le 5 + echo "le-eq: $?" +expect: + stdout: | + eq: 0 + ne: 1 + gt: 0 + gt-eq: 1 + lt: 0 + lt-eq: 1 + ge-eq: 0 + le-eq: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integers/invalid.yaml b/tests/scenarios/cmd/test/integers/invalid.yaml new file mode 100644 index 00000000..a9df07df --- /dev/null +++ b/tests/scenarios/cmd/test/integers/invalid.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl inv-1 — invalid integer +description: "test rejects non-decimal integers with exit code 2" +skip_assert_against_bash: true # bash prefixes error with scriptname:line +input: + script: |+ + test 0x0 -eq 00 +expect: + stderr: "test: 0x0: integer expression expected\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/test/integers/negative.yaml b/tests/scenarios/cmd/test/integers/negative.yaml new file mode 100644 index 00000000..daabcf11 --- /dev/null +++ b/tests/scenarios/cmd/test/integers/negative.yaml @@ -0,0 +1,17 @@ +# Derived from GNU coreutils test.pl — negative integer tests +description: "test integer comparison with negative numbers" +input: + script: |+ + test -1 -gt -2 + echo "neg-gt: $?" + test -1 -lt -2 + echo "neg-lt: $?" + test 0 -eq 00 + echo "leading-zero: $?" +expect: + stdout: | + neg-gt: 0 + neg-lt: 1 + leading-zero: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/and_or.yaml b/tests/scenarios/cmd/test/logical/and_or.yaml new file mode 100644 index 00000000..803392b7 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/and_or.yaml @@ -0,0 +1,26 @@ +# Derived from GNU coreutils test.pl and-1..and-4, or-1..or-4 tests +description: "test logical AND (-a) and OR (-o) operators" +input: + script: |+ + test "t" -a "t" + echo "and-tt: $?" + test "" -a "t" + echo "and-ft: $?" + test "t" -a "" + echo "and-tf: $?" + test "t" -o "t" + echo "or-tt: $?" + test "" -o "t" + echo "or-ft: $?" + test "" -o "" + echo "or-ff: $?" +expect: + stdout: | + and-tt: 0 + and-ft: 1 + and-tf: 1 + or-tt: 0 + or-ft: 0 + or-ff: 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/not.yaml b/tests/scenarios/cmd/test/logical/not.yaml new file mode 100644 index 00000000..90c087c0 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/not.yaml @@ -0,0 +1,17 @@ +# Derived from GNU coreutils test.pl — negation with ! +description: "test negation operator !" +input: + script: |+ + test ! "" + echo "not-empty: $?" + test ! "hello" + echo "not-str: $?" + test ! -f /nonexistent + echo "not-file: $?" +expect: + stdout: | + not-empty: 0 + not-str: 1 + not-file: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/parentheses.yaml b/tests/scenarios/cmd/test/logical/parentheses.yaml new file mode 100644 index 00000000..beec0ccf --- /dev/null +++ b/tests/scenarios/cmd/test/logical/parentheses.yaml @@ -0,0 +1,17 @@ +# Derived from GNU coreutils test.pl paren tests +description: "test parenthesized expressions" +input: + script: |+ + test '(' "hello" ')' + echo "paren-str: $?" + test '(' "" ')' + echo "paren-empty: $?" + test '(' "a" = "a" ')' + echo "paren-eq: $?" +expect: + stdout: | + paren-str: 0 + paren-empty: 1 + paren-eq: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/special_operands.yaml b/tests/scenarios/cmd/test/logical/special_operands.yaml new file mode 100644 index 00000000..6a9c9c4c --- /dev/null +++ b/tests/scenarios/cmd/test/logical/special_operands.yaml @@ -0,0 +1,23 @@ +# Edge cases where !, (, ) appear as literal operands in binary expressions +description: "test treats !, ( as literal string operands in binary expressions" +input: + script: |+ + test '!' = '!' + echo "bang-eq-bang: $?" + test '!' != 'x' + echo "bang-neq-x: $?" + test '(' = '(' + echo "paren-eq-paren: $?" + test '(' != ')' + echo "paren-neq-close: $?" + test ')' = ')' + echo "close-eq-close: $?" +expect: + stdout: | + bang-eq-bang: 0 + bang-neq-x: 0 + paren-eq-paren: 0 + paren-neq-close: 0 + close-eq-close: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/bare_string.yaml b/tests/scenarios/cmd/test/strings/bare_string.yaml new file mode 100644 index 00000000..17b6bc0c --- /dev/null +++ b/tests/scenarios/cmd/test/strings/bare_string.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl test 1c — bare string is true +description: "test with a non-empty string returns true" +input: + script: |+ + test "any-string" +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/comparison.yaml b/tests/scenarios/cmd/test/strings/comparison.yaml new file mode 100644 index 00000000..2cf40148 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/comparison.yaml @@ -0,0 +1,20 @@ +# Derived from GNU coreutils test.pl less-collate and greater-collate tests +description: "test string lexicographic comparison with < and >" +input: + script: |+ + test "a" \< "b" + echo "lt: $?" + test "a" \< "a" + echo "lt-eq: $?" + test "b" \> "a" + echo "gt: $?" + test "a" \> "a" + echo "gt-eq: $?" +expect: + stdout: | + lt: 0 + lt-eq: 1 + gt: 0 + gt-eq: 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/empty_string.yaml b/tests/scenarios/cmd/test/strings/empty_string.yaml new file mode 100644 index 00000000..f26628ba --- /dev/null +++ b/tests/scenarios/cmd/test/strings/empty_string.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl test 1e — empty string is false +description: "test with empty string argument returns false" +input: + script: |+ + test "" +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/cmd/test/strings/empty_test.yaml b/tests/scenarios/cmd/test/strings/empty_test.yaml new file mode 100644 index 00000000..29d262da --- /dev/null +++ b/tests/scenarios/cmd/test/strings/empty_test.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl — empty test returns false +description: "test with no arguments returns false (exit 1)" +input: + script: |+ + test +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/cmd/test/strings/equality.yaml b/tests/scenarios/cmd/test/strings/equality.yaml new file mode 100644 index 00000000..f05e7c4e --- /dev/null +++ b/tests/scenarios/cmd/test/strings/equality.yaml @@ -0,0 +1,20 @@ +# Derived from GNU coreutils test.pl streq tests +description: "test string equality and inequality" +input: + script: |+ + test "t" = "t" + echo "eq-same: $?" + test "t" = "f" + echo "eq-diff: $?" + test "t" != "t" + echo "ne-same: $?" + test "t" != "f" + echo "ne-diff: $?" +expect: + stdout: | + eq-same: 0 + eq-diff: 1 + ne-same: 1 + ne-diff: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/n_nonempty.yaml b/tests/scenarios/cmd/test/strings/n_nonempty.yaml new file mode 100644 index 00000000..e9d0bddf --- /dev/null +++ b/tests/scenarios/cmd/test/strings/n_nonempty.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl test 1d — -n on non-empty string +description: "test -n on a non-empty string returns true" +input: + script: |+ + test -n "any-string" +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/special_single_args.yaml b/tests/scenarios/cmd/test/strings/special_single_args.yaml new file mode 100644 index 00000000..372d6ef6 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/special_single_args.yaml @@ -0,0 +1,23 @@ +# Derived from GNU coreutils test.pl — special single-arg cases +description: "test treats various special tokens as non-empty strings" +input: + script: |+ + test "-" + echo "dash: $?" + test "--" + echo "ddash: $?" + test "-0" + echo "zero: $?" + test "-f" + echo "flag-f: $?" + test "(" + echo "paren: $?" +expect: + stdout: | + dash: 0 + ddash: 0 + zero: 0 + flag-f: 0 + paren: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/z_empty.yaml b/tests/scenarios/cmd/test/strings/z_empty.yaml new file mode 100644 index 00000000..e3bbb20c --- /dev/null +++ b/tests/scenarios/cmd/test/strings/z_empty.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl test 1b — -z on empty string +description: "test -z on empty string returns true" +input: + script: |+ + test -z "" +expect: + stdout: "" + stderr: "" + exit_code: 0 From a1d513e5c4835796fe12ac356ad1266e96b739f0 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 11:33:33 +0100 Subject: [PATCH 03/20] Address review comments: add comment and scenario tests - Add comment noting permission bit fallback limitation for -r/-w/-x when AccessFile is nil (no ownership check) - Add scenario test for integer overflow clamping behavior (skip_assert_against_bash: bash rejects overflow, rshell clamps) - Add scenario test for extra-argument error (exit code 2) Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 3 +++ .../scenarios/cmd/test/errors/extra_argument.yaml | 8 ++++++++ tests/scenarios/cmd/test/integers/overflow.yaml | 14 ++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 tests/scenarios/cmd/test/errors/extra_argument.yaml create mode 100644 tests/scenarios/cmd/test/integers/overflow.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 65da9c9b..3949c80c 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -466,6 +466,9 @@ func evalFileInfo(op string, info fs.FileInfo) bool { case "-s": return info.Size() > 0 case "-r": + // NOTE: This fallback checks any permission bit (user/group/other) and does not + // account for file ownership. In production AccessFile is always set and this path + // is not reached; actual file access still goes through the sandbox. return info.Mode().Perm()&0444 != 0 case "-w": return info.Mode().Perm()&0222 != 0 diff --git a/tests/scenarios/cmd/test/errors/extra_argument.yaml b/tests/scenarios/cmd/test/errors/extra_argument.yaml new file mode 100644 index 00000000..1a4b66f9 --- /dev/null +++ b/tests/scenarios/cmd/test/errors/extra_argument.yaml @@ -0,0 +1,8 @@ +description: "test reports error for unconsumed extra arguments" +skip_assert_against_bash: true # bash error message format differs (includes scriptname:line prefix) +input: + script: |+ + test "a" "b" "c" "d" "e" +expect: + stderr_contains: ["extra argument"] + exit_code: 2 diff --git a/tests/scenarios/cmd/test/integers/overflow.yaml b/tests/scenarios/cmd/test/integers/overflow.yaml new file mode 100644 index 00000000..6b04a2b0 --- /dev/null +++ b/tests/scenarios/cmd/test/integers/overflow.yaml @@ -0,0 +1,14 @@ +description: "test clamps integer overflow to int64 boundaries" +skip_assert_against_bash: true # bash rejects overflow with exit code 2; rshell intentionally clamps to MaxInt64/MinInt64 matching GNU test +input: + script: |+ + test 99999999999999999999 -gt 0 + echo "pos-overflow: $?" + test -99999999999999999999 -lt 0 + echo "neg-overflow: $?" +expect: + stdout: | + pos-overflow: 0 + neg-overflow: 0 + stderr: "" + exit_code: 0 From cb7e6f1e015b0f43be83f46ce91a5ca46e5eb881 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 11:56:53 +0100 Subject: [PATCH 04/20] Address review comments: add docs, == test, rename misleading file - Add test/[ builtin to SHELL_FEATURES.md and README.md (P2 finding) - Add == (double-equals) operator scenario test to equality.yaml (P3) - Rename unary_expected.yaml to unary_option.yaml to match content (P3) Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- SHELL_FEATURES.md | 3 ++- .../test/errors/{unary_expected.yaml => unary_option.yaml} | 0 tests/scenarios/cmd/test/strings/equality.yaml | 6 ++++++ 4 files changed, 9 insertions(+), 2 deletions(-) rename tests/scenarios/cmd/test/errors/{unary_expected.yaml => unary_option.yaml} (100%) diff --git a/README.md b/README.md index b6690f57..e14ff1ec 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Linux, macOS, and Windows. ``` tests/scenarios/ -├── cmd/ # builtin command tests (echo, cat, grep, head, tail, uniq, wc, ...) +├── cmd/ # builtin command tests (echo, cat, grep, head, tail, test, uniq, wc, ...) └── shell/ # shell feature tests (pipes, variables, control flow, ...) ``` diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 40746388..9dbc1ab0 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -17,6 +17,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `ls [-1aAdFhlpRrSt] [FILE]...` — list directory contents - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected +- ✅ `test EXPRESSION` / `[ EXPRESSION ]` — evaluate conditional expression (file tests, string/integer comparison, logical operators) - ✅ `true` — return exit code 0 - ✅ `uniq [OPTION]... [INPUT]` — report or omit repeated lines - ✅ `wc [-l] [-w] [-c] [-m] [FILE]...` — count lines, words, bytes, or characters in files @@ -85,7 +86,7 @@ Blocked features are rejected before execution with exit code 2. - ❌ Background execution: `cmd &` - ❌ Coprocesses: `coproc` - ❌ `time` -- ❌ `[[ ... ]]` test expressions +- ❌ `[[ ... ]]` extended test expressions (bash extension) - ❌ `(( ... ))` arithmetic commands - ❌ `declare`, `export`, `local`, `readonly`, `let` diff --git a/tests/scenarios/cmd/test/errors/unary_expected.yaml b/tests/scenarios/cmd/test/errors/unary_option.yaml similarity index 100% rename from tests/scenarios/cmd/test/errors/unary_expected.yaml rename to tests/scenarios/cmd/test/errors/unary_option.yaml diff --git a/tests/scenarios/cmd/test/strings/equality.yaml b/tests/scenarios/cmd/test/strings/equality.yaml index f05e7c4e..59b91673 100644 --- a/tests/scenarios/cmd/test/strings/equality.yaml +++ b/tests/scenarios/cmd/test/strings/equality.yaml @@ -10,11 +10,17 @@ input: echo "ne-same: $?" test "t" != "f" echo "ne-diff: $?" + test "t" == "t" + echo "eq-double: $?" + test "t" == "f" + echo "eq-double-diff: $?" expect: stdout: | eq-same: 0 eq-diff: 1 ne-same: 1 ne-diff: 0 + eq-double: 0 + eq-double-diff: 1 stderr: "" exit_code: 0 From fd16919ba15531c1806700b08170dd5c31bcc810 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 12:05:15 +0100 Subject: [PATCH 05/20] Add -a as unary file existence test (bash compat) Bash treats `test -a FILE` (2 args) as equivalent to `test -e FILE`. This is a deprecated POSIX feature but still supported by bash. Add -a to the unary file operator set and handle it identically to -e in the file test evaluator. Add scenario test coverage. Addresses P1 review finding from iteration 2. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 6 +++-- .../scenarios/cmd/test/files/existence_a.yaml | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 tests/scenarios/cmd/test/files/existence_a.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 3949c80c..26a0e1aa 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -23,6 +23,7 @@ // // File tests (unary): // +// -a FILE FILE exists (deprecated POSIX synonym for -e) // -e FILE FILE exists // -f FILE FILE exists and is a regular file // -d FILE FILE exists and is a directory @@ -96,6 +97,7 @@ Exit status: 2 if an error occurred. File tests: + -a FILE FILE exists (deprecated synonym for -e) -e FILE FILE exists -f FILE FILE is a regular file -d FILE FILE is a directory @@ -354,7 +356,7 @@ func isBinaryOp(op string) bool { func isUnaryFileOp(op string) bool { switch op { - case "-e", "-f", "-d", "-s", "-r", "-w", "-x", "-h", "-L", "-p": + case "-a", "-e", "-f", "-d", "-s", "-r", "-w", "-x", "-h", "-L", "-p": return true } return false @@ -457,7 +459,7 @@ func (p *parser) evalFileTest(op, path string) bool { func evalFileInfo(op string, info fs.FileInfo) bool { switch op { - case "-e": + case "-a", "-e": return true case "-f": return info.Mode().IsRegular() diff --git a/tests/scenarios/cmd/test/files/existence_a.yaml b/tests/scenarios/cmd/test/files/existence_a.yaml new file mode 100644 index 00000000..cee9d64e --- /dev/null +++ b/tests/scenarios/cmd/test/files/existence_a.yaml @@ -0,0 +1,27 @@ +# test -a FILE — deprecated POSIX unary file existence test (synonym for -e) +description: "test -a FILE works like test -e FILE for file existence" +setup: + files: + - path: existing.txt + content: "hello\n" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + test -a existing.txt + echo "exists: $?" + test -a nonexistent + echo "noexist: $?" + [ -a existing.txt ] + echo "bracket exists: $?" + [ -a nonexistent ] + echo "bracket noexist: $?" +expect: + stdout: | + exists: 0 + noexist: 1 + bracket exists: 0 + bracket noexist: 1 + stderr: "" + exit_code: 0 +skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support From 3ab2bc6017e15c2662b2f8bba775b0ce6a673d37 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 12:15:15 +0100 Subject: [PATCH 06/20] Fix POSIX 3-arg disambiguation for -a/-o binary operators When exactly 3 tokens remain and the middle one is -a or -o, treat them as binary AND/OR operators (string non-emptiness) per POSIX. Previously, expressions like `test -f -a -d` incorrectly parsed -f as a unary file test on "-a" instead of treating -a as binary AND. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 17 +++++++++++- .../test/logical/binary_disambiguation.yaml | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/scenarios/cmd/test/logical/binary_disambiguation.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 26a0e1aa..a9af4e13 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -252,6 +252,9 @@ func (p *parser) parseNot() bool { if remaining >= 3 && isBinaryOp(p.args[p.pos+1]) { return p.parsePrimary() } + if remaining == 3 && (p.args[p.pos+1] == "-a" || p.args[p.pos+1] == "-o") { + return p.parsePrimary() + } if p.depth >= maxParenDepth { p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) p.err = true @@ -284,7 +287,7 @@ func (p *parser) parsePrimary() bool { // remaining==1 is a bare non-empty string per POSIX single-argument rules. // When "(" is followed by a binary operator (e.g., "(" = "("), treat it // as a literal string operand. - if cur == "(" && remaining > 1 && !(remaining >= 3 && isBinaryOp(p.args[p.pos+1])) { + if cur == "(" && remaining > 1 && !(remaining >= 3 && isBinaryOp(p.args[p.pos+1])) && !(remaining == 3 && (p.args[p.pos+1] == "-a" || p.args[p.pos+1] == "-o")) { if p.depth >= maxParenDepth { p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) p.err = true @@ -320,6 +323,14 @@ func (p *parser) parsePrimary() bool { if isBinaryOp(op) { return p.parseBinaryExpr() } + // POSIX 3-arg rule: when exactly 3 tokens remain and the middle + // one is -a/-o, treat as binary AND/OR with string operands. + // e.g., "test -f -a -d" → "-f" (non-empty) AND "-d" (non-empty). + // Only when exactly 3 remain — with more tokens, -a/-o are handled + // by parseAnd/parseOr at their proper precedence level. + if remaining == 3 && (op == "-a" || op == "-o") { + return p.parseBinaryExpr() + } } // With 2+ remaining tokens, check for unary operators. @@ -412,6 +423,10 @@ func (p *parser) parseBinaryExpr() bool { return p.evalIntCompare(left, op, right) case "-nt", "-ot": return p.evalFileCompare(left, op, right) + case "-a": + return left != "" && right != "" + case "-o": + return left != "" || right != "" default: p.callCtx.Errf("%s: unknown binary operator '%s'\n", p.cmdName, op) p.err = true diff --git a/tests/scenarios/cmd/test/logical/binary_disambiguation.yaml b/tests/scenarios/cmd/test/logical/binary_disambiguation.yaml new file mode 100644 index 00000000..54b455b5 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/binary_disambiguation.yaml @@ -0,0 +1,26 @@ +# POSIX 3-arg disambiguation: -a/-o as binary operators when second of 3 tokens +description: "test treats -a/-o as binary AND/OR when they are the second of 3 arguments" +input: + script: |+ + test -f -a -d + echo "f-a-d: $?" + test -f -o -d + echo "f-o-d: $?" + test "(" -a ")" + echo "paren-a: $?" + test "!" -a "!" + echo "bang-a-bang: $?" + test "" -a "x" + echo "empty-a-x: $?" + test "" -o "x" + echo "empty-o-x: $?" +expect: + stdout: | + f-a-d: 0 + f-o-d: 0 + paren-a: 0 + bang-a-bang: 0 + empty-a-x: 1 + empty-o-x: 0 + stderr: "" + exit_code: 0 From da2f69effd65b2329f152d531dae7f1c9306bfb9 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 12:39:21 +0100 Subject: [PATCH 07/20] Fix bash compat: restrict ! and ( disambiguation to 3-arg, reject int overflow Address review findings: - Restrict `!` and `(` literal disambiguation to exactly 3 remaining tokens (POSIX 3-arg rule). Previously `remaining >= 3` was too broad, causing `test '!' = '!' -a x` to silently succeed instead of exiting 2 like bash. - Reject integer overflow instead of clamping to MinInt64/MaxInt64. Bash exits 2 with "integer expression expected" for overflowing values like 99999999999999999999. - Remove unused math import and math.MinInt64 from symbol allowlist. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 20 ++++++------------- .../builtins/testcmd/testcmd_pentest_test.go | 10 ++++++---- interp/builtins/testcmd/testcmd_test.go | 12 ++++++----- tests/allowed_symbols_test.go | 2 -- .../scenarios/cmd/test/integers/overflow.yaml | 9 ++++----- 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index a9af4e13..efe39b00 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -73,7 +73,6 @@ package testcmd import ( "context" "io/fs" - "math" "strconv" "strings" @@ -247,9 +246,11 @@ func (p *parser) parseNot() bool { p.advance() return true } - // If "!" is followed by a binary operator, treat it as a literal - // operand (fall through to parsePrimary for binary expression). - if remaining >= 3 && isBinaryOp(p.args[p.pos+1]) { + // POSIX 3-arg rule: if exactly 3 tokens remain and "!" is followed + // by a binary operator, treat it as a literal string operand + // (fall through to parsePrimary for binary expression). + // With more than 3 tokens, "!" is always negation. + if remaining == 3 && isBinaryOp(p.args[p.pos+1]) { return p.parsePrimary() } if remaining == 3 && (p.args[p.pos+1] == "-a" || p.args[p.pos+1] == "-o") { @@ -287,7 +288,7 @@ func (p *parser) parsePrimary() bool { // remaining==1 is a bare non-empty string per POSIX single-argument rules. // When "(" is followed by a binary operator (e.g., "(" = "("), treat it // as a literal string operand. - if cur == "(" && remaining > 1 && !(remaining >= 3 && isBinaryOp(p.args[p.pos+1])) && !(remaining == 3 && (p.args[p.pos+1] == "-a" || p.args[p.pos+1] == "-o")) { + if cur == "(" && remaining > 1 && !(remaining == 3 && isBinaryOp(p.args[p.pos+1])) && !(remaining == 3 && (p.args[p.pos+1] == "-a" || p.args[p.pos+1] == "-o")) { if p.depth >= maxParenDepth { p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) p.err = true @@ -532,15 +533,6 @@ func (p *parser) parseInt(s string) (int64, bool) { } n, err := strconv.ParseInt(s, 10, 64) if err != nil { - // Check for overflow — clamp to boundaries like GNU test. - if numErr, ok := err.(*strconv.NumError); ok && numErr.Err == strconv.ErrRange { - if s[0] == '-' { - n = math.MinInt64 - } else { - n = math.MaxInt64 - } - return n, true - } p.callCtx.Errf("%s: %s: integer expression expected\n", p.cmdName, s) p.err = true return 0, false diff --git a/interp/builtins/testcmd/testcmd_pentest_test.go b/interp/builtins/testcmd/testcmd_pentest_test.go index 9520a6b3..96d7c4be 100644 --- a/interp/builtins/testcmd/testcmd_pentest_test.go +++ b/interp/builtins/testcmd/testcmd_pentest_test.go @@ -59,14 +59,16 @@ func TestPentestIntMaxInt64(t *testing.T) { func TestPentestIntOverflowPositive(t *testing.T) { huge := "99999999999999999999" - stdout, _, _ := runScript(t, `test `+huge+` -gt 0; echo $?`, "") - assert.Equal(t, "0\n", stdout) + stdout, stderr, _ := runScript(t, `test `+huge+` -gt 0; echo $?`, "") + assert.Equal(t, "2\n", stdout) + assert.Contains(t, stderr, "integer expression expected") } func TestPentestIntOverflowNegative(t *testing.T) { huge := "-99999999999999999999" - stdout, _, _ := runScript(t, `test `+huge+` -lt 0; echo $?`, "") - assert.Equal(t, "0\n", stdout) + stdout, stderr, _ := runScript(t, `test `+huge+` -lt 0; echo $?`, "") + assert.Equal(t, "2\n", stdout) + assert.Contains(t, stderr, "integer expression expected") } func TestPentestIntEmptyString(t *testing.T) { diff --git a/interp/builtins/testcmd/testcmd_test.go b/interp/builtins/testcmd/testcmd_test.go index 5e69998a..d4901c77 100644 --- a/interp/builtins/testcmd/testcmd_test.go +++ b/interp/builtins/testcmd/testcmd_test.go @@ -467,18 +467,20 @@ func TestTestFileOutsideSandbox(t *testing.T) { assert.Equal(t, 1, code) } -// --- Integer overflow clamping --- +// --- Integer overflow rejection (matches bash: exit 2) --- func TestTestIntOverflow(t *testing.T) { - stdout, _, code := runScript(t, `test 99999999999999999999 -gt 0; echo $?`, "") + stdout, stderr, code := runScript(t, `test 99999999999999999999 -gt 0; echo $?`, "") assert.Equal(t, 0, code) - assert.Equal(t, "0\n", stdout) + assert.Equal(t, "2\n", stdout) + assert.Contains(t, stderr, "integer expression expected") } func TestTestIntNegOverflow(t *testing.T) { - stdout, _, code := runScript(t, `test -99999999999999999999 -lt 0; echo $?`, "") + stdout, stderr, code := runScript(t, `test -99999999999999999999 -lt 0; echo $?`, "") assert.Equal(t, 0, code) - assert.Equal(t, "0\n", stdout) + assert.Equal(t, "2\n", stdout) + assert.Contains(t, stderr, "integer expression expected") } // --- Shell integration tests --- diff --git a/tests/allowed_symbols_test.go b/tests/allowed_symbols_test.go index e74d5d17..e4a5a618 100644 --- a/tests/allowed_symbols_test.go +++ b/tests/allowed_symbols_test.go @@ -72,8 +72,6 @@ var builtinAllowedSymbols = []string{ "math.MaxInt32", // math.MaxInt64 — integer constant; no side effects. "math.MaxInt64", - // math.MinInt64 — integer constant; no side effects. - "math.MinInt64", // os.FileInfo — file metadata interface returned by Stat; no I/O side effects. "os.FileInfo", // os.O_RDONLY — read-only file flag constant; cannot open files by itself. diff --git a/tests/scenarios/cmd/test/integers/overflow.yaml b/tests/scenarios/cmd/test/integers/overflow.yaml index 6b04a2b0..6883f0be 100644 --- a/tests/scenarios/cmd/test/integers/overflow.yaml +++ b/tests/scenarios/cmd/test/integers/overflow.yaml @@ -1,5 +1,4 @@ -description: "test clamps integer overflow to int64 boundaries" -skip_assert_against_bash: true # bash rejects overflow with exit code 2; rshell intentionally clamps to MaxInt64/MinInt64 matching GNU test +description: "test rejects integer overflow with exit code 2 (matches bash)" input: script: |+ test 99999999999999999999 -gt 0 @@ -8,7 +7,7 @@ input: echo "neg-overflow: $?" expect: stdout: | - pos-overflow: 0 - neg-overflow: 0 - stderr: "" + pos-overflow: 2 + neg-overflow: 2 + stderr_contains: ["integer expression expected"] exit_code: 0 From 3de1dbe8a2f748c7676a7f5cee549bdaf882f8a0 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 12:53:54 +0100 Subject: [PATCH 08/20] Fix POSIX 3-arg rule firing inside recursive parseAnd/parseOr calls The 3-arg disambiguation for -a/-o used `remaining` (args from current parse position) instead of `len(p.args)` (total args). This caused it to fire inside recursive calls, breaking operator precedence for 5+ argument expressions. For example, `test "" -a "" -o " "` incorrectly returned 1 instead of 0. Fix: use `len(p.args) == 3` instead of `remaining == 3` for the POSIX 3-arg rule. Also extract `isThreeArgBinary` and `isBinaryOpOrLogical` helpers to simplify the long compound conditions. Add scenario test for -a/-o precedence in multi-arg expressions. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 44 ++++++++++++------- .../cmd/test/logical/precedence.yaml | 23 ++++++++++ 2 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 tests/scenarios/cmd/test/logical/precedence.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index efe39b00..de5ec6fa 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -246,14 +246,12 @@ func (p *parser) parseNot() bool { p.advance() return true } - // POSIX 3-arg rule: if exactly 3 tokens remain and "!" is followed - // by a binary operator, treat it as a literal string operand - // (fall through to parsePrimary for binary expression). - // With more than 3 tokens, "!" is always negation. - if remaining == 3 && isBinaryOp(p.args[p.pos+1]) { - return p.parsePrimary() - } - if remaining == 3 && (p.args[p.pos+1] == "-a" || p.args[p.pos+1] == "-o") { + // POSIX 3-arg rule: if the total argument count is exactly 3 and + // "!" is followed by a binary operator, treat it as a literal + // string operand (fall through to parsePrimary for binary expression). + // We check len(p.args) (total args) not remaining, because this rule + // must not fire inside recursive parseAnd/parseOr calls. + if len(p.args) == 3 && isBinaryOpOrLogical(p.args[p.pos+1]) { return p.parsePrimary() } if p.depth >= maxParenDepth { @@ -288,7 +286,7 @@ func (p *parser) parsePrimary() bool { // remaining==1 is a bare non-empty string per POSIX single-argument rules. // When "(" is followed by a binary operator (e.g., "(" = "("), treat it // as a literal string operand. - if cur == "(" && remaining > 1 && !(remaining == 3 && isBinaryOp(p.args[p.pos+1])) && !(remaining == 3 && (p.args[p.pos+1] == "-a" || p.args[p.pos+1] == "-o")) { + if cur == "(" && remaining > 1 && !isThreeArgBinary(p.args, p.pos) { if p.depth >= maxParenDepth { p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) p.err = true @@ -324,12 +322,12 @@ func (p *parser) parsePrimary() bool { if isBinaryOp(op) { return p.parseBinaryExpr() } - // POSIX 3-arg rule: when exactly 3 tokens remain and the middle - // one is -a/-o, treat as binary AND/OR with string operands. - // e.g., "test -f -a -d" → "-f" (non-empty) AND "-d" (non-empty). - // Only when exactly 3 remain — with more tokens, -a/-o are handled - // by parseAnd/parseOr at their proper precedence level. - if remaining == 3 && (op == "-a" || op == "-o") { + // POSIX 3-arg rule: when the total argument count is exactly 3 + // and the middle token is -a/-o, treat as binary AND/OR with + // string operands. e.g., "test -f -a -d" → "-f" AND "-d". + // We check len(p.args) (total args) not remaining, because this + // rule must not fire inside recursive parseAnd/parseOr calls. + if len(p.args) == 3 && (op == "-a" || op == "-o") { return p.parseBinaryExpr() } } @@ -366,6 +364,22 @@ func isBinaryOp(op string) bool { return false } +// isBinaryOpOrLogical returns true if op is a binary comparison operator +// or a logical operator (-a/-o) that can act as a binary operator in the +// POSIX 3-argument form. +func isBinaryOpOrLogical(op string) bool { + return isBinaryOp(op) || op == "-a" || op == "-o" +} + +// isThreeArgBinary returns true when the total argument count is exactly 3 +// and the token at pos+1 is a binary or logical operator. This implements +// the POSIX 3-argument disambiguation rule, which must only fire at the +// top level (total args == 3), never inside recursive descent calls where +// remaining == 3 but total args > 3. +func isThreeArgBinary(args []string, pos int) bool { + return len(args) == 3 && pos+1 < len(args) && isBinaryOpOrLogical(args[pos+1]) +} + func isUnaryFileOp(op string) bool { switch op { case "-a", "-e", "-f", "-d", "-s", "-r", "-w", "-x", "-h", "-L", "-p": diff --git a/tests/scenarios/cmd/test/logical/precedence.yaml b/tests/scenarios/cmd/test/logical/precedence.yaml new file mode 100644 index 00000000..554a1d6a --- /dev/null +++ b/tests/scenarios/cmd/test/logical/precedence.yaml @@ -0,0 +1,23 @@ +description: "test -a/-o operator precedence in multi-arg expressions" +input: + script: |+ + # -a binds tighter than -o: ("" AND "") OR " " = false OR true = true + test "" -a "" -o " " + echo "case1: $?" + # -a binds tighter than -o: ("x" AND "y") OR "" = true OR false = true + test "x" -a "y" -o "" + echo "case2: $?" + # -a binds tighter than -o: ("" AND "y") OR "" = false OR false = false + test "" -a "y" -o "" + echo "case3: $?" + # Multiple -o: "" OR "x" OR "" = true + test "" -o "x" -o "" + echo "case4: $?" +expect: + stdout: | + case1: 0 + case2: 0 + case3: 1 + case4: 0 + stderr: "" + exit_code: 0 From 74c4407a2f8aa9ce2b7187c000d3d48460cb9278 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 14:23:31 +0100 Subject: [PATCH 09/20] Fix POSIX 3-arg disambiguation for nested ! subexpressions Use subexprStart to scope the 3-arg disambiguation rule to the current subexpression rather than the total argument count. This allows the rule to fire after ! negation (e.g., "test ! ! = !", "test ! -f -a -d", "test ! ( = (") while still preventing it from triggering inside -a/-o chains. Also use exact stderr match in overflow.yaml per project conventions, and add negated_disambiguation.yaml scenario test. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 56 ++++++++++++------- .../scenarios/cmd/test/integers/overflow.yaml | 4 +- .../test/logical/negated_disambiguation.yaml | 26 +++++++++ 3 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 tests/scenarios/cmd/test/logical/negated_disambiguation.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index de5ec6fa..e3563300 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -169,6 +169,14 @@ type parser struct { pos int err bool depth int + // subexprStart marks the beginning of the current subexpression for + // POSIX disambiguation. It is set to 0 initially and updated when + // entering a new subexpression boundary (after ! negation or inside + // parentheses). The 3-arg disambiguation rule fires when the + // subexpression length (len(args) - subexprStart) is exactly 3, + // preventing it from triggering inside parseAnd/parseOr chains while + // still allowing it inside nested ! or (...) contexts. + subexprStart int } const maxParenDepth = 128 @@ -246,12 +254,14 @@ func (p *parser) parseNot() bool { p.advance() return true } - // POSIX 3-arg rule: if the total argument count is exactly 3 and - // "!" is followed by a binary operator, treat it as a literal - // string operand (fall through to parsePrimary for binary expression). - // We check len(p.args) (total args) not remaining, because this rule - // must not fire inside recursive parseAnd/parseOr calls. - if len(p.args) == 3 && isBinaryOpOrLogical(p.args[p.pos+1]) { + // POSIX 3-arg rule: if the current subexpression has exactly 3 + // tokens and "!" is followed by a binary operator, treat "!" as a + // literal string operand (fall through to parsePrimary for binary + // expression). We use subexprStart to scope this to the current + // subexpression, so it fires for both top-level 3-arg forms and + // nested ones (e.g., "test ! ! = !") but not inside -a/-o chains. + subexprLen := len(p.args) - p.subexprStart + if subexprLen == 3 && isBinaryOpOrLogical(p.args[p.pos+1]) { return p.parsePrimary() } if p.depth >= maxParenDepth { @@ -261,7 +271,10 @@ func (p *parser) parseNot() bool { } p.depth++ p.advance() + saved := p.subexprStart + p.subexprStart = p.pos // new subexpression after ! result := !p.parseNot() + p.subexprStart = saved p.depth-- return result } @@ -286,7 +299,7 @@ func (p *parser) parsePrimary() bool { // remaining==1 is a bare non-empty string per POSIX single-argument rules. // When "(" is followed by a binary operator (e.g., "(" = "("), treat it // as a literal string operand. - if cur == "(" && remaining > 1 && !isThreeArgBinary(p.args, p.pos) { + if cur == "(" && remaining > 1 && !p.isThreeArgBinary(p.pos) { if p.depth >= maxParenDepth { p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) p.err = true @@ -322,12 +335,13 @@ func (p *parser) parsePrimary() bool { if isBinaryOp(op) { return p.parseBinaryExpr() } - // POSIX 3-arg rule: when the total argument count is exactly 3 - // and the middle token is -a/-o, treat as binary AND/OR with - // string operands. e.g., "test -f -a -d" → "-f" AND "-d". - // We check len(p.args) (total args) not remaining, because this - // rule must not fire inside recursive parseAnd/parseOr calls. - if len(p.args) == 3 && (op == "-a" || op == "-o") { + // POSIX 3-arg rule: when the current subexpression has exactly 3 + // tokens and the middle token is -a/-o, treat as binary AND/OR + // with string operands. e.g., "test -f -a -d" → "-f" AND "-d". + // We use subexprStart (not remaining) so this fires for nested + // subexpressions after ! but not inside -a/-o chains. + subexprLen := len(p.args) - p.subexprStart + if subexprLen == 3 && (op == "-a" || op == "-o") { return p.parseBinaryExpr() } } @@ -371,13 +385,15 @@ func isBinaryOpOrLogical(op string) bool { return isBinaryOp(op) || op == "-a" || op == "-o" } -// isThreeArgBinary returns true when the total argument count is exactly 3 -// and the token at pos+1 is a binary or logical operator. This implements -// the POSIX 3-argument disambiguation rule, which must only fire at the -// top level (total args == 3), never inside recursive descent calls where -// remaining == 3 but total args > 3. -func isThreeArgBinary(args []string, pos int) bool { - return len(args) == 3 && pos+1 < len(args) && isBinaryOpOrLogical(args[pos+1]) +// isThreeArgBinary returns true when the current subexpression has exactly 3 +// tokens and the token at pos+1 is a binary or logical operator. This +// implements the POSIX 3-argument disambiguation rule. The subexpression +// length is computed from p.subexprStart (set at the top level and updated +// when entering ! negation), so the rule fires for both top-level 3-arg +// forms and nested ones (e.g., "test ! ! = !") but not inside -a/-o chains. +func (p *parser) isThreeArgBinary(pos int) bool { + subexprLen := len(p.args) - p.subexprStart + return subexprLen == 3 && pos+1 < len(p.args) && isBinaryOpOrLogical(p.args[pos+1]) } func isUnaryFileOp(op string) bool { diff --git a/tests/scenarios/cmd/test/integers/overflow.yaml b/tests/scenarios/cmd/test/integers/overflow.yaml index 6883f0be..7f9d4135 100644 --- a/tests/scenarios/cmd/test/integers/overflow.yaml +++ b/tests/scenarios/cmd/test/integers/overflow.yaml @@ -9,5 +9,7 @@ expect: stdout: | pos-overflow: 2 neg-overflow: 2 - stderr_contains: ["integer expression expected"] + stderr: | + test: 99999999999999999999: integer expression expected + test: -99999999999999999999: integer expression expected exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/negated_disambiguation.yaml b/tests/scenarios/cmd/test/logical/negated_disambiguation.yaml new file mode 100644 index 00000000..612b6fd8 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/negated_disambiguation.yaml @@ -0,0 +1,26 @@ +# POSIX 3-arg disambiguation inside negated subexpressions. +# After ! consumes one arg, the remaining 3 tokens should apply +# the 3-arg rule for binary operators, -a/-o, and ( as literal. +description: "test POSIX 3-arg disambiguation inside ! negation" +input: + script: |+ + # ! negates inner 3-arg binary "!" = "!" → !(true) → exit 1 + test ! '!' = '!' + echo "negate-bang-eq: $?" + # ! negates inner 3-arg binary "!" = "x" → !(false) → exit 0 + test ! '!' = x + echo "negate-bang-neq: $?" + # ! negates inner 3-arg binary "(" = "(" → !(true) → exit 1 + test ! '(' = '(' + echo "negate-paren-eq: $?" + # ! negates inner 3-arg form "-f -a -d": -f AND -d as strings → !(true) → exit 1 + test ! -f -a -d + echo "negate-a-binary: $?" +expect: + stdout: | + negate-bang-eq: 1 + negate-bang-neq: 0 + negate-paren-eq: 1 + negate-a-binary: 1 + stderr: "" + exit_code: 0 From abae442cce916ae5f28dea7bb911c9f7c70d1823 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 14:38:35 +0100 Subject: [PATCH 10/20] Fix subexprStart not saved/restored in parenthesized subexpressions Add subexprEnd field to track the end boundary of POSIX 3-arg disambiguation scope. When entering parenthesized (...) groups, both subexprStart and subexprEnd are now saved/restored, with subexprEnd set to the position of the matching ')'. This prevents the 3-arg rule from miscounting tokens that include the closing paren. Also adds skip_assert_against_bash to overflow.yaml since bash stderr includes scriptname:line prefix that differs from rshell. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 55 +++++++++++++++---- .../scenarios/cmd/test/integers/overflow.yaml | 1 + .../test/logical/paren_disambiguation.yaml | 19 +++++++ 3 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 tests/scenarios/cmd/test/logical/paren_disambiguation.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index e3563300..224fd0f5 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -172,11 +172,14 @@ type parser struct { // subexprStart marks the beginning of the current subexpression for // POSIX disambiguation. It is set to 0 initially and updated when // entering a new subexpression boundary (after ! negation or inside - // parentheses). The 3-arg disambiguation rule fires when the - // subexpression length (len(args) - subexprStart) is exactly 3, - // preventing it from triggering inside parseAnd/parseOr chains while - // still allowing it inside nested ! or (...) contexts. + // parentheses). subexprEnd marks the exclusive end of the current + // subexpression (defaults to len(args), set to the position of ")" + // inside parenthesized groups). The 3-arg disambiguation rule fires + // when the subexpression length (subexprEnd - subexprStart) is exactly + // 3, preventing it from triggering inside parseAnd/parseOr chains + // while still allowing it inside nested ! or (...) contexts. subexprStart int + subexprEnd int } const maxParenDepth = 128 @@ -187,10 +190,11 @@ func evaluate(ctx context.Context, callCtx *builtins.CallContext, cmdName string } p := &parser{ - ctx: ctx, - callCtx: callCtx, - cmdName: cmdName, - args: args, + ctx: ctx, + callCtx: callCtx, + cmdName: cmdName, + args: args, + subexprEnd: len(args), } result := p.parseOr() @@ -260,7 +264,7 @@ func (p *parser) parseNot() bool { // expression). We use subexprStart to scope this to the current // subexpression, so it fires for both top-level 3-arg forms and // nested ones (e.g., "test ! ! = !") but not inside -a/-o chains. - subexprLen := len(p.args) - p.subexprStart + subexprLen := p.subexprEnd - p.subexprStart if subexprLen == 3 && isBinaryOpOrLogical(p.args[p.pos+1]) { return p.parsePrimary() } @@ -312,7 +316,16 @@ func (p *parser) parsePrimary() bool { p.err = true return false } + savedStart := p.subexprStart + savedEnd := p.subexprEnd + p.subexprStart = p.pos // new subexpression inside parens + // Find matching ')' to set the subexpression end boundary. + // This allows the 3-arg disambiguation rule to correctly + // count only tokens between '(' and ')'. + p.subexprEnd = p.findMatchingParen(p.pos) result := p.parseOr() + p.subexprStart = savedStart + p.subexprEnd = savedEnd p.depth-- if p.err { return false @@ -340,7 +353,7 @@ func (p *parser) parsePrimary() bool { // with string operands. e.g., "test -f -a -d" → "-f" AND "-d". // We use subexprStart (not remaining) so this fires for nested // subexpressions after ! but not inside -a/-o chains. - subexprLen := len(p.args) - p.subexprStart + subexprLen := p.subexprEnd - p.subexprStart if subexprLen == 3 && (op == "-a" || op == "-o") { return p.parseBinaryExpr() } @@ -392,10 +405,30 @@ func isBinaryOpOrLogical(op string) bool { // when entering ! negation), so the rule fires for both top-level 3-arg // forms and nested ones (e.g., "test ! ! = !") but not inside -a/-o chains. func (p *parser) isThreeArgBinary(pos int) bool { - subexprLen := len(p.args) - p.subexprStart + subexprLen := p.subexprEnd - p.subexprStart return subexprLen == 3 && pos+1 < len(p.args) && isBinaryOpOrLogical(p.args[pos+1]) } +// findMatchingParen scans forward from start to find the position of the +// matching ')' token, accounting for nested '(' ... ')' groups. If no +// matching ')' is found, it returns len(p.args) as a fallback (the parse +// will later report a "missing ')'" error). +func (p *parser) findMatchingParen(start int) int { + depth := 1 + for i := start; i < len(p.args); i++ { + switch p.args[i] { + case "(": + depth++ + case ")": + depth-- + if depth == 0 { + return i + } + } + } + return len(p.args) +} + func isUnaryFileOp(op string) bool { switch op { case "-a", "-e", "-f", "-d", "-s", "-r", "-w", "-x", "-h", "-L", "-p": diff --git a/tests/scenarios/cmd/test/integers/overflow.yaml b/tests/scenarios/cmd/test/integers/overflow.yaml index 7f9d4135..2d99589e 100644 --- a/tests/scenarios/cmd/test/integers/overflow.yaml +++ b/tests/scenarios/cmd/test/integers/overflow.yaml @@ -1,4 +1,5 @@ description: "test rejects integer overflow with exit code 2 (matches bash)" +skip_assert_against_bash: true # bash stderr includes scriptname:line prefix input: script: |+ test 99999999999999999999 -gt 0 diff --git a/tests/scenarios/cmd/test/logical/paren_disambiguation.yaml b/tests/scenarios/cmd/test/logical/paren_disambiguation.yaml new file mode 100644 index 00000000..85f0a989 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/paren_disambiguation.yaml @@ -0,0 +1,19 @@ +description: "test POSIX 3-arg disambiguation inside parenthesized subexpressions" +input: + script: |+ + # Inner ( "" -o "x" ) — -o is a logical OR operator inside parens, not 3-arg binary + test '(' "" -o "x" ')' + echo "paren-or: $?" + # Inner ( "a" = "b" ) — binary = inside parens + test '(' "a" = "a" ')' + echo "paren-eq: $?" + # Nested: ! ( "a" = "b" ) — negated grouping + test '!' '(' "a" = "b" ')' + echo "not-paren-neq: $?" +expect: + stdout: | + paren-or: 0 + paren-eq: 0 + not-paren-neq: 0 + stderr: "" + exit_code: 0 From d03b0575765c98c7e0e329597ff9d83e970bf1b3 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 15:21:26 +0100 Subject: [PATCH 11/20] Fix POSIX 3-arg ( X ) disambiguation to match bash behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When test has exactly 3 args in the form ( X ), the middle token should be treated as a string non-emptiness test (POSIX 3-arg rule 3), not as grouped expression parsing. This prevents misinterpretation of special tokens like ! and -n inside the group. Examples that now work correctly: test "(" "!" ")" → exit 0 (non-empty string) test "(" "" ")" → exit 1 (empty string) test "(" -n ")" → exit 0 (non-empty string) When X is a binary operator (e.g., =), the existing isThreeArgBinary rule handles it as a binary comparison: ( = ) → exit 1 (( ≠ )). Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 16 ++++++++++ .../cmd/test/logical/paren_three_arg.yaml | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/scenarios/cmd/test/logical/paren_three_arg.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 224fd0f5..c7a6849a 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -298,6 +298,22 @@ func (p *parser) parsePrimary() bool { cur := p.peek() remaining := len(p.args) - p.pos + // POSIX 3-arg rule: when the subexpression is exactly "( X )" and X is + // NOT a binary operator, treat the middle token as a string non-emptiness + // test. This prevents bash-compat issues where X is "!", "-n", etc. that + // would be misinterpreted as operators inside a group. e.g., + // test "(" "!" ")" → 0 (non-empty string "!") + // test "(" "" ")" → 1 (empty string) + // When X IS a binary operator (e.g., "="), the isThreeArgBinary check + // below handles it as "(" = ")" (string comparison). + subexprLen := p.subexprEnd - p.subexprStart + if cur == "(" && subexprLen == 3 && p.pos+2 < len(p.args) && p.args[p.pos+2] == ")" && !isBinaryOpOrLogical(p.args[p.pos+1]) { + p.advance() // skip "(" + s := p.advance() + p.advance() // skip ")" + return s != "" + } + // Only treat "(" as grouping when there are enough tokens and it is not // used as a literal operand in a binary expression. A lone "(" with // remaining==1 is a bare non-empty string per POSIX single-argument rules. diff --git a/tests/scenarios/cmd/test/logical/paren_three_arg.yaml b/tests/scenarios/cmd/test/logical/paren_three_arg.yaml new file mode 100644 index 00000000..df0b57ae --- /dev/null +++ b/tests/scenarios/cmd/test/logical/paren_three_arg.yaml @@ -0,0 +1,31 @@ +name: "test: POSIX 3-arg ( X ) disambiguation" +tests: + - name: "( ! ) treats ! as non-empty string" + command: 'test "(" "!" ")"; echo $?' + expect: + stdout: "0\n" + + - name: "( empty ) treats empty as false" + command: 'test "(" "" ")"; echo $?' + expect: + stdout: "1\n" + + - name: "( -n ) treats -n as non-empty string" + command: 'test "(" -n ")"; echo $?' + expect: + stdout: "0\n" + + - name: "( -e ) treats -e as non-empty string" + command: 'test "(" -e ")"; echo $?' + expect: + stdout: "0\n" + + - name: "( = ) applies binary = rule: ( equals )" + command: 'test "(" "=" ")"; echo $?' + expect: + stdout: "1\n" + + - name: "( != ) applies binary != rule: ( not-equals )" + command: 'test "(" "!=" ")"; echo $?' + expect: + stdout: "0\n" From 6e1f65f97ec6bb09a4010664c69aae6cade5a086 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 16:42:37 +0100 Subject: [PATCH 12/20] Fix paren_three_arg.yaml to use correct scenario YAML schema The file used a wrong schema (name/tests/command) that doesn't match the test framework's expected scenario struct (description/input/expect). This caused the tests to silently pass without actually running any assertions. Convert to the standard scenario format so the POSIX 3-arg ( X ) disambiguation tests are actually validated against both rshell and bash. Co-Authored-By: Claude Opus 4.6 --- .../cmd/test/logical/paren_three_arg.yaml | 65 ++++++++++--------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/tests/scenarios/cmd/test/logical/paren_three_arg.yaml b/tests/scenarios/cmd/test/logical/paren_three_arg.yaml index df0b57ae..ff232ed8 100644 --- a/tests/scenarios/cmd/test/logical/paren_three_arg.yaml +++ b/tests/scenarios/cmd/test/logical/paren_three_arg.yaml @@ -1,31 +1,34 @@ -name: "test: POSIX 3-arg ( X ) disambiguation" -tests: - - name: "( ! ) treats ! as non-empty string" - command: 'test "(" "!" ")"; echo $?' - expect: - stdout: "0\n" - - - name: "( empty ) treats empty as false" - command: 'test "(" "" ")"; echo $?' - expect: - stdout: "1\n" - - - name: "( -n ) treats -n as non-empty string" - command: 'test "(" -n ")"; echo $?' - expect: - stdout: "0\n" - - - name: "( -e ) treats -e as non-empty string" - command: 'test "(" -e ")"; echo $?' - expect: - stdout: "0\n" - - - name: "( = ) applies binary = rule: ( equals )" - command: 'test "(" "=" ")"; echo $?' - expect: - stdout: "1\n" - - - name: "( != ) applies binary != rule: ( not-equals )" - command: 'test "(" "!=" ")"; echo $?' - expect: - stdout: "0\n" +# POSIX 3-arg ( X ) disambiguation: when test has exactly 3 args "(" X ")", +# the middle token is treated as a string non-emptiness test (unless it's a +# binary operator, in which case it becomes a binary comparison). +description: "test POSIX 3-arg ( X ) disambiguation" +input: + script: |+ + # ( ! ) treats ! as non-empty string + test "(" "!" ")" + echo "bang: $?" + # ( empty ) treats empty string as false + test "(" "" ")" + echo "empty: $?" + # ( -n ) treats -n as non-empty string, not unary operator + test "(" -n ")" + echo "dash-n: $?" + # ( -e ) treats -e as non-empty string, not file test + test "(" -e ")" + echo "dash-e: $?" + # ( = ) applies binary = rule: "(" equals ")" + test "(" "=" ")" + echo "eq: $?" + # ( != ) applies binary != rule: "(" not-equals ")" + test "(" "!=" ")" + echo "neq: $?" +expect: + stdout: | + bang: 0 + empty: 1 + dash-n: 0 + dash-e: 0 + eq: 1 + neq: 0 + stderr: "" + exit_code: 0 From a5630490c12a54a45dbcb70a2540064c865c2ada Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 16:56:32 +0100 Subject: [PATCH 13/20] Fix dangling ! after -a/-o, paren subexpr lookahead, document ==, assert pentest exit codes - Fix dangling `!` after logical operators: use subexpression bounds instead of global remaining count so `test -n x -a !` correctly returns exit 2 (argument expected) like bash, while `test !` and `test x -a !` (3-arg rule) still work correctly. - Fix lookahead within parenthesized subexpressions: use subexprEnd instead of len(args) for `remaining` in parsePrimary so that `test '(' -n = ')'` correctly parses as grouped `-n "="` (exit 0) instead of reading past the closing `)`. - Add `==` (double-equals) operator to helpText and doc comment. - Add exit code assertions to three pentest tests that previously discarded the exit code with `_ = code`. - Add scenario tests: dangling_not.yaml and paren_subexpr.yaml, both validated against bash. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 19 ++++++++++++--- .../builtins/testcmd/testcmd_pentest_test.go | 8 ++++--- .../cmd/test/logical/dangling_not.yaml | 23 +++++++++++++++++++ .../cmd/test/logical/paren_subexpr.yaml | 23 +++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/scenarios/cmd/test/logical/dangling_not.yaml create mode 100644 tests/scenarios/cmd/test/logical/paren_subexpr.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index c7a6849a..3af4c9f6 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -49,6 +49,7 @@ // String comparison (binary): // // S1 = S2 strings are equal +// S1 == S2 strings are equal (synonym for =) // S1 != S2 strings are not equal // S1 < S2 S1 sorts before S2 (lexicographic) // S1 > S2 S1 sorts after S2 (lexicographic) @@ -119,6 +120,7 @@ String tests: String comparison: S1 = S2 strings are equal + S1 == S2 strings are equal (synonym for =) S1 != S2 strings are not equal S1 < S2 S1 sorts before S2 S1 > S2 S1 sorts after S2 @@ -253,8 +255,15 @@ func (p *parser) parseAnd() bool { // as a literal string operand, not negation. func (p *parser) parseNot() bool { if p.pos < len(p.args) && p.peek() == "!" { - remaining := len(p.args) - p.pos - if remaining == 1 { + // When "!" is the only token in the current subexpression, treat + // it as a non-empty string per POSIX single-argument rules. + // We use subexpression bounds (not global remaining count) so + // that "!" after -a/-o in a larger expression is still treated + // as negation requiring an operand. e.g.: + // test ! → "!" is non-empty string → exit 0 + // test -n x -a ! → "!" is negation, missing arg → exit 2 + // test x -a ! → 3-arg rule handles it as binary -a + if p.subexprEnd-p.subexprStart == 1 { p.advance() return true } @@ -296,7 +305,11 @@ func (p *parser) parsePrimary() bool { } cur := p.peek() - remaining := len(p.args) - p.pos + // Use the subexpression boundary (not len(args)) so that lookahead + // inside parenthesized groups does not read past the closing ')'. + // At the top level subexprEnd == len(args); inside (...) it points + // to the position of the matching ')'. + remaining := p.subexprEnd - p.pos // POSIX 3-arg rule: when the subexpression is exactly "( X )" and X is // NOT a binary operator, treat the middle token as a string non-emptiness diff --git a/interp/builtins/testcmd/testcmd_pentest_test.go b/interp/builtins/testcmd/testcmd_pentest_test.go index 96d7c4be..bf02c9c5 100644 --- a/interp/builtins/testcmd/testcmd_pentest_test.go +++ b/interp/builtins/testcmd/testcmd_pentest_test.go @@ -105,14 +105,14 @@ func TestPentestIntFloatRejected(t *testing.T) { func TestPentestDevNull(t *testing.T) { mustNotHang(t, func() { _, _, code := runScript(t, `test -f `+os.DevNull, "", interp.AllowedPaths([]string{filepath.Dir(os.DevNull)})) - _ = code + assert.Equal(t, 1, code, "/dev/null is not a regular file") }) } func TestPentestDevNullExists(t *testing.T) { mustNotHang(t, func() { _, _, code := runScript(t, `test -e `+os.DevNull, "", interp.AllowedPaths([]string{filepath.Dir(os.DevNull)})) - _ = code + assert.Equal(t, 0, code, "/dev/null should exist") }) } @@ -153,7 +153,9 @@ func TestPentestFlagLikeFilename(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "-f", "data") _, _, code := cmdRun(t, `test -e -- -f`, dir) - _ = code + // 3 args: "-e", "--", "-f" — parsed as unary -e with operand "--", + // then "-f" is an extra argument → exit 2 (syntax error). + assert.Equal(t, 2, code) } // --- Flag and argument injection --- diff --git a/tests/scenarios/cmd/test/logical/dangling_not.yaml b/tests/scenarios/cmd/test/logical/dangling_not.yaml new file mode 100644 index 00000000..67e66857 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/dangling_not.yaml @@ -0,0 +1,23 @@ +description: "test rejects dangling ! after -a/-o (bash compat)" +input: + script: |+ + # 4-arg: -n x is true, then -a, then ! is negation needing operand + test -n x -a ! + echo "n-x-a-bang: $?" + # 4-arg: same with -o + test -n x -o ! + echo "n-x-o-bang: $?" + # 3-arg: POSIX 3-arg rule treats -a as binary operator + test x -a ! + echo "x-a-bang: $?" + # 3-arg: POSIX 3-arg rule treats -o as binary operator + test x -o ! + echo "x-o-bang: $?" +expect: + stdout: | + n-x-a-bang: 2 + n-x-o-bang: 2 + x-a-bang: 0 + x-o-bang: 0 + stderr_contains: ["argument"] + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/paren_subexpr.yaml b/tests/scenarios/cmd/test/logical/paren_subexpr.yaml new file mode 100644 index 00000000..6998acd5 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/paren_subexpr.yaml @@ -0,0 +1,23 @@ +description: "test parenthesized subexpressions with unary operators" +input: + script: |+ + # ( -n "=" ) — grouped unary -n with "=" as operand → true + test '(' -n = ')' + echo "paren-n-eq: $?" + # ( -n foo ) — grouped unary -n with "foo" → true + test '(' -n foo ')' + echo "paren-n-foo: $?" + # ( -z "" ) — grouped unary -z with "" → true + test '(' -z "" ')' + echo "paren-z-empty: $?" + # ( -z "x" ) — grouped unary -z with "x" → false + test '(' -z x ')' + echo "paren-z-x: $?" +expect: + stdout: | + paren-n-eq: 0 + paren-n-foo: 0 + paren-z-empty: 0 + paren-z-x: 1 + stderr: "" + exit_code: 0 From bcb87b53baa3dfcabbbdd1d83d89f27f611a4b64 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 17:16:55 +0100 Subject: [PATCH 14/20] [iter 1] Remove stale math.MinInt64 from allowed symbols list The overflow clamping using math.MinInt64 was removed from testcmd.go but the symbol remained in the allowlist, causing TestBuiltinAllowedSymbols to fail. Co-Authored-By: Claude Opus 4.6 --- tests/allowed_symbols_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/allowed_symbols_test.go b/tests/allowed_symbols_test.go index 0b272b2f..81d99b92 100644 --- a/tests/allowed_symbols_test.go +++ b/tests/allowed_symbols_test.go @@ -82,9 +82,7 @@ var builtinAllowedSymbols = []string{ "math.MaxInt64", // math.MaxUint64 — integer constant; no side effects. "math.MaxUint64", - // math.MinInt64 — integer constant; no side effects. - "math.MinInt64", - // math.NaN — returns IEEE 754 NaN value; pure function, no I/O. +// math.NaN — returns IEEE 754 NaN value; pure function, no I/O. "math.NaN", // os.FileInfo — file metadata interface returned by Stat; no I/O side effects. "os.FileInfo", From b9e76282aa1b42047de029309eced3041de0f22c Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 17:23:27 +0100 Subject: [PATCH 15/20] [iter 1] Fix sandbox stat/lstat for null device on Windows The pathSandbox.stat and lstat methods use os.Root.Stat which cannot resolve Windows device names like NUL. This caused `test -e NUL` to fail on Windows, breaking TestPentestDevNullExists in CI. Handle /dev/null (and NUL on Windows) as a special case by using os.Stat(os.DevNull) directly, bypassing the os.Root sandbox for the null device only. Co-Authored-By: Claude Opus 4.6 --- interp/allowed_paths.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index 44270a1c..132600a1 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -196,6 +196,13 @@ func (s *pathSandbox) readDir(ctx context.Context, path string) ([]fs.DirEntry, // metadata-only access — no file descriptor is opened, so it works on // unreadable files and does not block on special files (e.g. FIFOs). func (s *pathSandbox) stat(ctx context.Context, path string) (fs.FileInfo, error) { + // The null device (/dev/null on Unix, NUL on Windows) is always + // allowed and must be stat-ed directly because os.Root.Stat cannot + // resolve platform device names (e.g. NUL on Windows). + if isDevNull(path) { + return os.Stat(os.DevNull) + } + absPath := toAbs(path, HandlerCtx(ctx).Dir) root, relPath, ok := s.resolve(absPath) @@ -214,6 +221,11 @@ func (s *pathSandbox) stat(ctx context.Context, path string) (fs.FileInfo, error // metadata-only call, but does not follow symbolic links — the returned // FileInfo describes the link itself rather than its target. func (s *pathSandbox) lstat(ctx context.Context, path string) (fs.FileInfo, error) { + // The null device is never a symlink, so lstat behaves like stat. + if isDevNull(path) { + return os.Stat(os.DevNull) + } + absPath := toAbs(path, HandlerCtx(ctx).Dir) root, relPath, ok := s.resolve(absPath) From 224612dd14bbb6106b6ae4db4d8bc60322c0eb32 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 17:26:52 +0100 Subject: [PATCH 16/20] [iter 2] Fix indentation in allowed_symbols_test.go Restore leading tab on math.NaN comment that was lost when math.MinInt64 entry was removed. Co-Authored-By: Claude Opus 4.6 --- tests/allowed_symbols_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/allowed_symbols_test.go b/tests/allowed_symbols_test.go index 81d99b92..70c706a3 100644 --- a/tests/allowed_symbols_test.go +++ b/tests/allowed_symbols_test.go @@ -82,7 +82,7 @@ var builtinAllowedSymbols = []string{ "math.MaxInt64", // math.MaxUint64 — integer constant; no side effects. "math.MaxUint64", -// math.NaN — returns IEEE 754 NaN value; pure function, no I/O. + // math.NaN — returns IEEE 754 NaN value; pure function, no I/O. "math.NaN", // os.FileInfo — file metadata interface returned by Stat; no I/O side effects. "os.FileInfo", From aaa639f848247e3ee5ea089c748cce1666646daf Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 17:29:39 +0100 Subject: [PATCH 17/20] [iter 2] Update Windows reserved names test to expect NUL as existing NUL is the Windows null device (equivalent to /dev/null on Unix) and should report as existing via test -e, consistent with the sandbox fix in allowed_paths.go. Co-Authored-By: Claude Opus 4.6 --- .../builtins/testcmd/testcmd_windows_test.go | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/interp/builtins/testcmd/testcmd_windows_test.go b/interp/builtins/testcmd/testcmd_windows_test.go index 69eecd04..896f557f 100644 --- a/interp/builtins/testcmd/testcmd_windows_test.go +++ b/interp/builtins/testcmd/testcmd_windows_test.go @@ -17,11 +17,23 @@ import ( func TestTestWindowsReservedNames(t *testing.T) { dir := t.TempDir() - reserved := []string{"CON", "PRN", "AUX", "NUL", "COM1", "LPT1"} - for _, name := range reserved { - t.Run(name, func(t *testing.T) { - _, _, code := runScript(t, `test -e `+name, dir, interp.AllowedPaths([]string{dir})) - assert.Equal(t, 1, code) + // NUL is the Windows null device (equivalent to /dev/null) and should + // be reported as existing, just like /dev/null on Unix. + reserved := []struct { + name string + code int + }{ + {"CON", 1}, + {"PRN", 1}, + {"AUX", 1}, + {"NUL", 0}, + {"COM1", 1}, + {"LPT1", 1}, + } + for _, tc := range reserved { + t.Run(tc.name, func(t *testing.T) { + _, _, code := runScript(t, `test -e `+tc.name, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, tc.code, code) }) } } From 59af121f95cbd4437bf55225e77cf10da49363d6 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 18:11:39 +0100 Subject: [PATCH 18/20] [iter 1] Match bash error message and remove skip_assert_against_bash - Change "extra argument" error to "too many arguments" to match bash - Remove skip_assert_against_bash from overflow.yaml and extra_argument.yaml - Use stderr_contains for both scenarios so stderr format differences (bash prefix) don't block validation of stdout and exit code against bash Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 2 +- interp/builtins/testcmd/testcmd_pentest_test.go | 2 +- interp/builtins/testcmd/testcmd_test.go | 2 +- tests/scenarios/cmd/test/errors/extra_argument.yaml | 3 +-- tests/scenarios/cmd/test/integers/overflow.yaml | 5 +---- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 3af4c9f6..a26c8cb2 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -205,7 +205,7 @@ func evaluate(ctx context.Context, callCtx *builtins.CallContext, cmdName string return builtins.Result{Code: exitSyntaxError} } if p.pos < len(p.args) { - p.callCtx.Errf("%s: extra argument '%s'\n", p.cmdName, p.args[p.pos]) + p.callCtx.Errf("%s: too many arguments\n", p.cmdName) return builtins.Result{Code: exitSyntaxError} } if result { diff --git a/interp/builtins/testcmd/testcmd_pentest_test.go b/interp/builtins/testcmd/testcmd_pentest_test.go index bf02c9c5..c429a078 100644 --- a/interp/builtins/testcmd/testcmd_pentest_test.go +++ b/interp/builtins/testcmd/testcmd_pentest_test.go @@ -154,7 +154,7 @@ func TestPentestFlagLikeFilename(t *testing.T) { writeFile(t, dir, "-f", "data") _, _, code := cmdRun(t, `test -e -- -f`, dir) // 3 args: "-e", "--", "-f" — parsed as unary -e with operand "--", - // then "-f" is an extra argument → exit 2 (syntax error). + // then "-f" is unconsumed → exit 2 (too many arguments). assert.Equal(t, 2, code) } diff --git a/interp/builtins/testcmd/testcmd_test.go b/interp/builtins/testcmd/testcmd_test.go index d4901c77..90540649 100644 --- a/interp/builtins/testcmd/testcmd_test.go +++ b/interp/builtins/testcmd/testcmd_test.go @@ -360,7 +360,7 @@ func TestBracketHelp(t *testing.T) { func TestTestExtraArgument(t *testing.T) { _, stderr, code := runScript(t, `test "a" "b" "c" "d" "e"`, "") assert.Equal(t, 2, code) - assert.Contains(t, stderr, "extra argument") + assert.Contains(t, stderr, "too many arguments") } // --- File comparison -nt / -ot tests --- diff --git a/tests/scenarios/cmd/test/errors/extra_argument.yaml b/tests/scenarios/cmd/test/errors/extra_argument.yaml index 1a4b66f9..186aa12b 100644 --- a/tests/scenarios/cmd/test/errors/extra_argument.yaml +++ b/tests/scenarios/cmd/test/errors/extra_argument.yaml @@ -1,8 +1,7 @@ description: "test reports error for unconsumed extra arguments" -skip_assert_against_bash: true # bash error message format differs (includes scriptname:line prefix) input: script: |+ test "a" "b" "c" "d" "e" expect: - stderr_contains: ["extra argument"] + stderr_contains: ["too many arguments"] exit_code: 2 diff --git a/tests/scenarios/cmd/test/integers/overflow.yaml b/tests/scenarios/cmd/test/integers/overflow.yaml index 2d99589e..6883f0be 100644 --- a/tests/scenarios/cmd/test/integers/overflow.yaml +++ b/tests/scenarios/cmd/test/integers/overflow.yaml @@ -1,5 +1,4 @@ description: "test rejects integer overflow with exit code 2 (matches bash)" -skip_assert_against_bash: true # bash stderr includes scriptname:line prefix input: script: |+ test 99999999999999999999 -gt 0 @@ -10,7 +9,5 @@ expect: stdout: | pos-overflow: 2 neg-overflow: 2 - stderr: | - test: 99999999999999999999: integer expression expected - test: -99999999999999999999: integer expression expected + stderr_contains: ["integer expression expected"] exit_code: 0 From 91c27734161e216280a407ffbe70fead0981ac1c Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 18:22:18 +0100 Subject: [PATCH 19/20] [iter 2] Use exact stderr assertion in overflow.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace stderr_contains with exact stderr match in the overflow test scenario, per AGENTS.md convention. Add skip_assert_against_bash since bash prefixes errors with scriptname:line which inherently differs. The rshell error message "integer expression expected" already matches bash's wording — no shell implementation change needed. Co-Authored-By: Claude Opus 4.6 --- tests/scenarios/cmd/test/integers/overflow.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/scenarios/cmd/test/integers/overflow.yaml b/tests/scenarios/cmd/test/integers/overflow.yaml index 6883f0be..8dc5ab46 100644 --- a/tests/scenarios/cmd/test/integers/overflow.yaml +++ b/tests/scenarios/cmd/test/integers/overflow.yaml @@ -1,4 +1,5 @@ description: "test rejects integer overflow with exit code 2 (matches bash)" +skip_assert_against_bash: true # bash prefixes error with scriptname:line input: script: |+ test 99999999999999999999 -gt 0 @@ -9,5 +10,7 @@ expect: stdout: | pos-overflow: 2 neg-overflow: 2 - stderr_contains: ["integer expression expected"] + stderr: | + test: 99999999999999999999: integer expression expected + test: -99999999999999999999: integer expression expected exit_code: 0 From fb172cf477fd1b88b1a7c700876c6043c9f9d3bc Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 12 Mar 2026 18:50:06 +0100 Subject: [PATCH 20/20] [iter 4] Fix POSIX 4-arg paren disambiguation and lone ( in compound expressions Two bash-compat fixes for the test builtin: 1. Add POSIX 4-arg rule for ( X Y ) form: when the subexpression has exactly 4 tokens with first="(" and last=")", evaluate the inner 2 tokens as a 2-arg expression. This prevents findMatchingParen from incorrectly matching a literal ")" in the data. e.g., test "(" "!" ")" ")" now returns exit 1 (matching bash) instead of exit 2 "too many arguments". 2. Treat lone "(" as grouping (not bare string) inside compound expressions. When "(" appears after -o/-a with remaining==1 but subexprLen>1, it now triggers grouping which correctly fails with "missing argument". e.g., test -a ! -o "(" now returns exit 2 (matching bash) instead of exit 0. Added paren_four_arg.yaml scenario test validated against bash. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 41 ++++++++++++++++--- .../cmd/test/logical/paren_four_arg.yaml | 23 +++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 tests/scenarios/cmd/test/logical/paren_four_arg.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index a26c8cb2..4eb48936 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -327,12 +327,41 @@ func (p *parser) parsePrimary() bool { return s != "" } - // Only treat "(" as grouping when there are enough tokens and it is not - // used as a literal operand in a binary expression. A lone "(" with - // remaining==1 is a bare non-empty string per POSIX single-argument rules. - // When "(" is followed by a binary operator (e.g., "(" = "("), treat it - // as a literal string operand. - if cur == "(" && remaining > 1 && !p.isThreeArgBinary(p.pos) { + // POSIX 4-arg rule: when the subexpression is exactly "( X Y )" where + // the first token is "(" and the last is ")", evaluate the inner 2 tokens + // as a 2-arg expression. This prevents findMatchingParen from incorrectly + // matching a literal ")" in the data. e.g., + // test "(" "!" ")" ")" → inner "! )" → NOT non-empty ")" → false → exit 1 + // test "(" "-n" "x" ")" → inner "-n x" → true → exit 0 + if cur == "(" && subexprLen == 4 && p.pos+3 < len(p.args) && p.args[p.pos+3] == ")" { + p.advance() // skip "(" + savedStart := p.subexprStart + savedEnd := p.subexprEnd + p.subexprStart = p.pos + p.subexprEnd = p.pos + 2 // inner 2 tokens + result := p.parseOr() + p.subexprStart = savedStart + p.subexprEnd = savedEnd + if p.err { + return false + } + if p.pos >= len(p.args) || p.peek() != ")" { + p.callCtx.Errf("%s: missing ')'\n", p.cmdName) + p.err = true + return false + } + p.advance() // skip ")" + return result + } + + // Treat "(" as grouping when there are tokens after it, or when it + // appears as the last token inside a compound expression (subexprLen > 1). + // A lone "(" as the only argument (subexprLen == 1) is a bare non-empty + // string per POSIX single-argument rules. When "(" is followed by a + // binary operator (e.g., "(" = "("), treat it as a literal string operand. + // In compound expressions like "test -f x -o (", the lone "(" triggers + // grouping which correctly fails with a missing argument error. + if cur == "(" && (remaining > 1 || subexprLen > 1) && !p.isThreeArgBinary(p.pos) { if p.depth >= maxParenDepth { p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) p.err = true diff --git a/tests/scenarios/cmd/test/logical/paren_four_arg.yaml b/tests/scenarios/cmd/test/logical/paren_four_arg.yaml new file mode 100644 index 00000000..63cdda68 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/paren_four_arg.yaml @@ -0,0 +1,23 @@ +# POSIX 4-arg ( X Y ) disambiguation: when test has exactly 4 args +# "(" X Y ")" where first is "(" and last is ")", the inner 2 tokens +# are evaluated as a 2-arg expression. This handles cases where ")" +# appears as data inside the parenthesized group. +description: "test POSIX 4-arg ( X Y ) disambiguation" +input: + script: |+ + # ( ! ) ) treats inner as "! )" → NOT non-empty ")" → false + test '(' '!' ')' ')' + echo "not-paren: $?" + # ( -n x ) treats inner as "-n x" → string has non-zero length → true + test '(' '-n' 'x' ')' + echo "n-x: $?" + # ( ! "" ) treats inner as "! ''" → NOT empty → true + test '(' '!' '' ')' + echo "not-empty: $?" +expect: + stdout: | + not-paren: 1 + n-x: 0 + not-empty: 0 + stderr: "" + exit_code: 0