diff --git a/README.md b/README.md index 8d2ab8e3..cc2df95a 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 2eb5d070..f37d1933 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -18,6 +18,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `printf FORMAT [ARGUMENT]...` — format and print data to stdout; supports `%s`, `%b`, `%c`, `%d`, `%i`, `%o`, `%u`, `%x`, `%X`, `%e`, `%E`, `%f`, `%F`, `%g`, `%G`, `%%`; format reuse for excess arguments; `%n` rejected (security risk); `-v` rejected - ✅ `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) - ✅ `tr [-cdsCt] SET1 [SET2]` — translate, squeeze, and/or delete characters from stdin - ✅ `true` — return exit code 0 - ✅ `uniq [OPTION]... [INPUT]` — report or omit repeated lines @@ -91,7 +92,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/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) diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 65da9c9b..4eb48936 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 @@ -48,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) @@ -72,7 +74,6 @@ package testcmd import ( "context" "io/fs" - "math" "strconv" "strings" @@ -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 @@ -118,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 @@ -168,6 +171,17 @@ 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). 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 @@ -178,10 +192,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() @@ -190,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 { @@ -240,14 +255,26 @@ 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 } - // 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 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 := p.subexprEnd - p.subexprStart + if subexprLen == 3 && isBinaryOpOrLogical(p.args[p.pos+1]) { return p.parsePrimary() } if p.depth >= maxParenDepth { @@ -257,7 +284,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 } @@ -275,14 +305,63 @@ func (p *parser) parsePrimary() bool { } 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])) { + // 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 + // 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 != "" + } + + // 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 @@ -295,7 +374,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 @@ -318,6 +406,15 @@ func (p *parser) parsePrimary() bool { if isBinaryOp(op) { return p.parseBinaryExpr() } + // 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 := p.subexprEnd - p.subexprStart + if subexprLen == 3 && (op == "-a" || op == "-o") { + return p.parseBinaryExpr() + } } // With 2+ remaining tokens, check for unary operators. @@ -352,9 +449,47 @@ 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 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 := 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 "-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 @@ -410,6 +545,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 @@ -457,7 +596,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() @@ -466,6 +605,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 @@ -512,15 +654,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..c429a078 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) { @@ -103,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") }) } @@ -151,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 unconsumed → exit 2 (too many arguments). + assert.Equal(t, 2, code) } // --- Flag and argument injection --- diff --git a/interp/builtins/testcmd/testcmd_test.go b/interp/builtins/testcmd/testcmd_test.go index 5e69998a..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 --- @@ -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/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) }) } } diff --git a/tests/allowed_symbols_test.go b/tests/allowed_symbols_test.go index 0b272b2f..70c706a3 100644 --- a/tests/allowed_symbols_test.go +++ b/tests/allowed_symbols_test.go @@ -82,8 +82,6 @@ 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", // os.FileInfo — file metadata interface returned by Stat; no I/O side effects. 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..186aa12b --- /dev/null +++ b/tests/scenarios/cmd/test/errors/extra_argument.yaml @@ -0,0 +1,7 @@ +description: "test reports error for unconsumed extra arguments" +input: + script: |+ + test "a" "b" "c" "d" "e" +expect: + stderr_contains: ["too many arguments"] + exit_code: 2 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/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 diff --git a/tests/scenarios/cmd/test/integers/overflow.yaml b/tests/scenarios/cmd/test/integers/overflow.yaml new file mode 100644 index 00000000..8dc5ab46 --- /dev/null +++ b/tests/scenarios/cmd/test/integers/overflow.yaml @@ -0,0 +1,16 @@ +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 + echo "pos-overflow: $?" + test -99999999999999999999 -lt 0 + echo "neg-overflow: $?" +expect: + stdout: | + pos-overflow: 2 + neg-overflow: 2 + stderr: | + test: 99999999999999999999: integer expression expected + test: -99999999999999999999: integer expression expected + exit_code: 0 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 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/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 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 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 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 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..ff232ed8 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/paren_three_arg.yaml @@ -0,0 +1,34 @@ +# 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 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 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