Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3c53f7b
Remove test/[ builtin implementation and all associated tests
AlexandreYang Mar 12, 2026
ed598e9
Revert "Remove test/[ builtin implementation and all associated tests"
AlexandreYang Mar 12, 2026
4915794
Merge branch 'main' into alex/verif_test_base
AlexandreYang Mar 12, 2026
6b9cf17
Merge branch 'alex/verif_test_base' into alex/verif_test
AlexandreYang Mar 12, 2026
a1d513e
Address review comments: add comment and scenario tests
AlexandreYang Mar 12, 2026
1fb0c36
Merge branch 'main' into alex/verif_test_base
AlexandreYang Mar 12, 2026
db98e44
Merge branch 'alex/verif_test_base' into alex/verif_test
AlexandreYang Mar 12, 2026
cb7e6f1
Address review comments: add docs, == test, rename misleading file
AlexandreYang Mar 12, 2026
fd16919
Add -a as unary file existence test (bash compat)
AlexandreYang Mar 12, 2026
2ef9d7e
Merge branch 'main' into alex/verif_test_base
AlexandreYang Mar 12, 2026
375e04d
Merge branch 'alex/verif_test_base' into alex/verif_test
AlexandreYang Mar 12, 2026
3ab2bc6
Fix POSIX 3-arg disambiguation for -a/-o binary operators
AlexandreYang Mar 12, 2026
da2f69e
Fix bash compat: restrict ! and ( disambiguation to 3-arg, reject int…
AlexandreYang Mar 12, 2026
c763eab
Merge branch 'main' into alex/verif_test_base
AlexandreYang Mar 12, 2026
634e008
Merge branch 'alex/verif_test_base' into alex/verif_test
AlexandreYang Mar 12, 2026
3de1dbe
Fix POSIX 3-arg rule firing inside recursive parseAnd/parseOr calls
AlexandreYang Mar 12, 2026
74c4407
Fix POSIX 3-arg disambiguation for nested ! subexpressions
AlexandreYang Mar 12, 2026
abae442
Fix subexprStart not saved/restored in parenthesized subexpressions
AlexandreYang Mar 12, 2026
d03b057
Fix POSIX 3-arg ( X ) disambiguation to match bash behavior
AlexandreYang Mar 12, 2026
6e1f65f
Fix paren_three_arg.yaml to use correct scenario YAML schema
AlexandreYang Mar 12, 2026
a563049
Fix dangling ! after -a/-o, paren subexpr lookahead, document ==, ass…
AlexandreYang Mar 12, 2026
ea024ab
Merge origin/main into alex/verif_test
AlexandreYang Mar 12, 2026
bcb87b5
[iter 1] Remove stale math.MinInt64 from allowed symbols list
AlexandreYang Mar 12, 2026
b9e7628
[iter 1] Fix sandbox stat/lstat for null device on Windows
AlexandreYang Mar 12, 2026
224612d
[iter 2] Fix indentation in allowed_symbols_test.go
AlexandreYang Mar 12, 2026
aaa639f
[iter 2] Update Windows reserved names test to expect NUL as existing
AlexandreYang Mar 12, 2026
ab24551
Merge branch 'main' into alex/verif_test_base
AlexandreYang Mar 12, 2026
f3ccffb
Merge branch 'alex/verif_test_base' into alex/verif_test
AlexandreYang Mar 12, 2026
59af121
[iter 1] Match bash error message and remove skip_assert_against_bash
AlexandreYang Mar 12, 2026
91c2773
[iter 2] Use exact stderr assertion in overflow.yaml
AlexandreYang Mar 12, 2026
fb172cf
[iter 4] Fix POSIX 4-arg paren disambiguation and lone ( in compound …
AlexandreYang Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...)
```

Expand Down
3 changes: 2 additions & 1 deletion SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
12 changes: 12 additions & 0 deletions interp/allowed_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
193 changes: 163 additions & 30 deletions interp/builtins/testcmd/testcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -72,7 +74,6 @@ package testcmd
import (
"context"
"io/fs"
"math"
"strconv"
"strings"

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Comment on lines +287 to 289

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid 3-arg disambiguation after stacked negations

Resetting subexprStart on every ! causes nested negations to incorrectly trigger the 3-argument -a/-o rule on trailing tokens, so invalid expressions get accepted instead of returning syntax errors. For example, test ! ! -f -a = now returns a boolean result in rshell, while bash returns exit code 2 (too many arguments); this regression is introduced by treating each nested ! as a fresh 3-token subexpression and then parsing -f -a = as a valid binary form.

Useful? React with 👍 / 👎.

p.subexprStart = saved
p.depth--
return result
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading