From 334d0c860ec4827cf4cacba0166df8d5b9804115 Mon Sep 17 00:00:00 2001 From: "datadog-prod-us1-5[bot]" <266081015+datadog-prod-us1-5[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:44:45 +0000 Subject: [PATCH 01/12] Implement POSIX test and [ builtin commands Co-authored-by: AlexandreYang <49917914+AlexandreYang@users.noreply.github.com> --- interp/allowed_paths.go | 34 ++ interp/builtins/builtins.go | 8 +- interp/builtins/testcmd/testcmd.go | 496 ++++++++++++++++++ .../testcmd/testcmd_gnu_compat_test.go | 165 ++++++ .../builtins/testcmd/testcmd_pentest_test.go | 229 ++++++++ interp/builtins/testcmd/testcmd_test.go | 491 +++++++++++++++++ interp/builtins/testcmd/testcmd_unix_test.go | 66 +++ .../builtins/testcmd/testcmd_windows_test.go | 27 + interp/register_builtins.go | 3 + interp/runner_exec.go | 7 + tests/import_allowlist_test.go | 16 + tests/scenarios/cmd/test/bracket/basic.yaml | 17 + .../cmd/test/bracket/missing_bracket.yaml | 8 + .../cmd/test/errors/unary_expected.yaml | 8 + tests/scenarios/cmd/test/files/directory.yaml | 20 + 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 | 8 + .../scenarios/cmd/test/integers/negative.yaml | 17 + tests/scenarios/cmd/test/logical/and_or.yaml | 26 + tests/scenarios/cmd/test/logical/not.yaml | 18 + .../cmd/test/logical/parentheses.yaml | 17 + .../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 | 21 + tests/scenarios/cmd/test/strings/z_empty.yaml | 9 + 33 files changed, 1891 insertions(+), 1 deletion(-) 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/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/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/allowed_paths.go b/interp/allowed_paths.go index 50dce290..8ff0728c 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -133,6 +133,40 @@ func (s *pathSandbox) readDir(ctx context.Context, path string) ([]fs.DirEntry, return entries, nil } +// stat implements the restricted stat policy. The file is resolved through +// os.Root for atomic path validation, matching the open policy. +func (s *pathSandbox) stat(ctx context.Context, path string) (fs.FileInfo, error) { + absPath := toAbs(path, HandlerCtx(ctx).Dir) + + root, relPath, ok := s.resolve(absPath) + if !ok { + return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission} + } + + info, err := root.Stat(relPath) + if err != nil { + return nil, portablePathError(err) + } + return info, nil +} + +// lstat implements the restricted lstat policy. Unlike stat, it does not +// follow symlinks — it returns information about the link itself. +func (s *pathSandbox) lstat(ctx context.Context, path string) (fs.FileInfo, error) { + absPath := toAbs(path, HandlerCtx(ctx).Dir) + + root, relPath, ok := s.resolve(absPath) + if !ok { + return nil, &os.PathError{Op: "lstat", Path: path, Err: os.ErrPermission} + } + + info, err := root.Lstat(relPath) + if err != nil { + return nil, portablePathError(err) + } + return info, nil +} + // Close releases all os.Root file descriptors. It is safe to call multiple times. func (s *pathSandbox) Close() error { if s == nil { diff --git a/interp/builtins/builtins.go b/interp/builtins/builtins.go index 44fbd46f..3a49599b 100644 --- a/interp/builtins/builtins.go +++ b/interp/builtins/builtins.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "io" + "io/fs" "os" ) @@ -31,6 +32,12 @@ type CallContext struct { // OpenFile opens a file within the shell's path restrictions. OpenFile func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) + // StatFile returns file info within the shell's path restrictions (follows symlinks). + StatFile func(ctx context.Context, path string) (fs.FileInfo, error) + + // LstatFile returns file info within the shell's path restrictions (does not follow symlinks). + LstatFile func(ctx context.Context, path string) (fs.FileInfo, error) + // PortableErr normalizes an OS error to a POSIX-style message. PortableErr func(err error) string } @@ -88,4 +95,3 @@ func Lookup(name string) (HandlerFunc, bool) { fn, ok := registry[name] return fn, ok } - diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go new file mode 100644 index 00000000..6d6a54de --- /dev/null +++ b/interp/builtins/testcmd/testcmd.go @@ -0,0 +1,496 @@ +// 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", Run: runTest} + +// BracketCmd is the "[" builtin command registration. +var BracketCmd = builtins.Command{Name: "[", Run: 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 +} + +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. +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 + } + p.advance() + return !p.parseNot() + } + 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 + + if cur == "(" { + 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() + 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() + } + } + + // 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 { + switch op { + case "-h", "-L": + info, err := p.callCtx.LstatFile(p.ctx, path) + if err != nil { + return false + } + return info.Mode()&fs.ModeSymlink != 0 + default: + 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: invalid integer ''\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: invalid integer '%s'\n", p.cmdName, s) + p.err = true + return 0, false + } + return n, true +} + +func (p *parser) evalFileCompare(left, op, right string) bool { + 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..12cab65d --- /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: invalid integer '0x0'") +func TestGNUCompatTestInvalidIntegerHex(t *testing.T) { + _, stderr, code := runScript(t, `test 0x0 -eq 00`, "") + assert.Equal(t, 2, code) + assert.Equal(t, "test: invalid integer '0x0'\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..47762316 --- /dev/null +++ b/interp/builtins/testcmd/testcmd_pentest_test.go @@ -0,0 +1,229 @@ +// 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, "invalid integer") +} + +func TestPentestIntWhitespaceOnly(t *testing.T) { + _, stderr, code := runScript(t, `test " " -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +func TestPentestIntHexRejected(t *testing.T) { + _, stderr, code := runScript(t, `test 0x10 -eq 16`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +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, "invalid integer") +} + +// --- 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) +} diff --git a/interp/builtins/testcmd/testcmd_test.go b/interp/builtins/testcmd/testcmd_test.go new file mode 100644 index 00000000..b4667d6b --- /dev/null +++ b/interp/builtins/testcmd/testcmd_test.go @@ -0,0 +1,491 @@ +// 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, "invalid integer") +} + +func TestTestFloatRejected(t *testing.T) { + _, stderr, code := runScript(t, `test 123.45 -ge 6`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer '123.45'") +} + +// --- 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 ']'") +} + +// --- 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..cc063fe4 --- /dev/null +++ b/interp/builtins/testcmd/testcmd_unix_test.go @@ -0,0 +1,66 @@ +//go:build unix + +// 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" + "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..1ce33899 --- /dev/null +++ b/interp/builtins/testcmd/testcmd_windows_test.go @@ -0,0 +1,27 @@ +//go:build windows + +// 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 ( + "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 f6ff973d..f87b24d3 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -16,6 +16,7 @@ import ( "github.com/DataDog/rshell/interp/builtins/exit" falsecmd "github.com/DataDog/rshell/interp/builtins/false" "github.com/DataDog/rshell/interp/builtins/head" + "github.com/DataDog/rshell/interp/builtins/testcmd" truecmd "github.com/DataDog/rshell/interp/builtins/true" ) @@ -31,6 +32,8 @@ func registerBuiltins() { exit.Cmd, falsecmd.Cmd, head.Cmd, + testcmd.Cmd, + testcmd.BracketCmd, truecmd.Cmd, } { builtins.Register(cmd.Name, cmd.Run) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 4c3b4870..1973214f 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "io" + "io/fs" "os" "sync" @@ -208,6 +209,12 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { OpenFile: func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) { return r.open(ctx, path, flags, mode, false) }, + StatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { + return r.sandbox.stat(r.handlerCtx(ctx, todoPos), path) + }, + LstatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { + return r.sandbox.lstat(r.handlerCtx(ctx, todoPos), path) + }, PortableErr: portableErrMsg, } if r.stdin != nil { // do not assign a typed nil into the io.Reader interface diff --git a/tests/import_allowlist_test.go b/tests/import_allowlist_test.go index 3ba7053a..d7203682 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -38,6 +38,12 @@ var builtinAllowedSymbols = []string{ "context.Context", // errors.Is — error comparison; pure function, no I/O. "errors.Is", + // fs.FileInfo — interface for file metadata; pure type, no side effects. + "io/fs.FileInfo", + // fs.ModeNamedPipe — file mode bit constant; no side effects. + "io/fs.ModeNamedPipe", + // fs.ModeSymlink — file mode bit constant; no side effects. + "io/fs.ModeSymlink", // pflag.ContinueOnError — flag parse-error mode constant; no side effects. "github.com/spf13/pflag.ContinueOnError", // pflag.NewFlagSet — CLI flag parsing; operates only on string slices, no I/O. @@ -54,12 +60,22 @@ var builtinAllowedSymbols = []string{ "io.ReadCloser", // io.Reader — interface type; no side effects. "io.Reader", + // math.MaxInt64 — integer constant; no side effects. + "math.MaxInt64", + // math.MinInt64 — integer constant; no side effects. + "math.MinInt64", // os.O_RDONLY — read-only file flag constant; cannot open files by itself. "os.O_RDONLY", // strconv.Atoi — string-to-int conversion; pure function, no I/O. "strconv.Atoi", + // strconv.ErrRange — sentinel error value for overflow; pure constant. + "strconv.ErrRange", + // strconv.NumError — error type for numeric conversion failures; pure type. + "strconv.NumError", // strconv.ParseInt — string-to-int conversion with base/bit-size; pure function, no I/O. "strconv.ParseInt", + // strings.TrimSpace — removes leading/trailing whitespace; pure function. + "strings.TrimSpace", } // permanentlyBanned lists packages that may never be imported by builtin 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..12a69867 --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml @@ -0,0 +1,8 @@ +# Derived from uutils test_test.rs — missing closing bracket +description: "[ without closing ] gives exit code 2 and error" +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..7bde175e --- /dev/null +++ b/tests/scenarios/cmd/test/errors/unary_expected.yaml @@ -0,0 +1,8 @@ +# Derived from uutils test_test.rs — error cases +description: "test reports error for ambiguous -o as first of two args" +input: + script: |+ + test -o arg +expect: + stderr_contains: ["test:"] + exit_code: 2 diff --git a/tests/scenarios/cmd/test/files/directory.yaml b/tests/scenarios/cmd/test/files/directory.yaml new file mode 100644 index 00000000..905511db --- /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 diff --git a/tests/scenarios/cmd/test/files/existence.yaml b/tests/scenarios/cmd/test/files/existence.yaml new file mode 100644 index 00000000..c573e617 --- /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 diff --git a/tests/scenarios/cmd/test/files/size.yaml b/tests/scenarios/cmd/test/files/size.yaml new file mode 100644 index 00000000..3fa9acc7 --- /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 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..916958da --- /dev/null +++ b/tests/scenarios/cmd/test/integers/invalid.yaml @@ -0,0 +1,8 @@ +# Derived from GNU coreutils test.pl inv-1 — invalid integer +description: "test rejects non-decimal integers with exit code 2" +input: + script: |+ + test 0x0 -eq 00 +expect: + stderr: "test: invalid integer '0x0'\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..76396f9b --- /dev/null +++ b/tests/scenarios/cmd/test/logical/not.yaml @@ -0,0 +1,18 @@ +# 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 +skip_assert_against_bash: true 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/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..44bd52d9 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/special_single_args.yaml @@ -0,0 +1,21 @@ +# 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: $?" +expect: + stdout: | + dash: 0 + ddash: 0 + zero: 0 + flag-f: 0 + stderr: "" + exit_code: 0 +skip_assert_against_bash: true 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 271c7d0331a67480e3cb419d810f20e320420d32 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 12:33:28 +0100 Subject: [PATCH 02/12] Fix test builtin: match bash behavior for -o unary, error formats, and copyright headers Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 13 ++++++++++--- interp/builtins/testcmd/testcmd_gnu_compat_test.go | 8 ++++---- interp/builtins/testcmd/testcmd_pentest_test.go | 10 +++++----- interp/builtins/testcmd/testcmd_test.go | 8 ++++---- interp/builtins/testcmd/testcmd_unix_test.go | 4 ++-- interp/builtins/testcmd/testcmd_windows_test.go | 4 ++-- .../scenarios/cmd/test/bracket/missing_bracket.yaml | 3 ++- tests/scenarios/cmd/test/errors/unary_expected.yaml | 7 +++---- tests/scenarios/cmd/test/integers/invalid.yaml | 3 ++- 9 files changed, 34 insertions(+), 26 deletions(-) diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 6d6a54de..c076a51a 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -153,7 +153,7 @@ func runBracket(ctx context.Context, callCtx *builtins.CallContext, args []strin return builtins.Result{} } if len(args) == 0 || args[len(args)-1] != "]" { - callCtx.Errf("[: missing ']'\n") + callCtx.Errf("[: missing `]'\n") return builtins.Result{Code: exitSyntaxError} } return evaluate(ctx, callCtx, "[", args[:len(args)-1]) @@ -298,6 +298,13 @@ func (p *parser) parsePrimary() bool { 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). @@ -448,7 +455,7 @@ func (p *parser) evalIntCompare(left, op, right string) bool { func (p *parser) parseInt(s string) (int64, bool) { s = strings.TrimSpace(s) if s == "" { - p.callCtx.Errf("%s: invalid integer ''\n", p.cmdName) + p.callCtx.Errf("%s: : integer expression expected\n", p.cmdName) p.err = true return 0, false } @@ -463,7 +470,7 @@ func (p *parser) parseInt(s string) (int64, bool) { } return n, true } - p.callCtx.Errf("%s: invalid integer '%s'\n", p.cmdName, s) + 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_gnu_compat_test.go b/interp/builtins/testcmd/testcmd_gnu_compat_test.go index 12cab65d..700fd7d3 100644 --- a/interp/builtins/testcmd/testcmd_gnu_compat_test.go +++ b/interp/builtins/testcmd/testcmd_gnu_compat_test.go @@ -82,11 +82,11 @@ func TestGNUCompatTestIntegerWithLeadingZeros(t *testing.T) { // TestGNUCompatTestInvalidIntegerHex — 0x0 -eq 00 → error. // -// GNU: test 0x0 -eq 00; echo $? → 2 (stderr: "test: invalid integer '0x0'") +// 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: invalid integer '0x0'\n", stderr) + assert.Equal(t, "test: 0x0: integer expression expected\n", stderr) } // TestGNUCompatTestNegation — ! "" = true. @@ -127,11 +127,11 @@ func TestGNUCompatTestParentheses(t *testing.T) { // TestGNUCompatBracketMissingClose — [ 1 -eq → exit 2 + stderr. // -// GNU: [ 1 -eq; echo $? → 2 (stderr: "[: missing ']'") +// 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) + assert.Equal(t, "[: missing `]'\n", stderr) } // TestGNUCompatTestFileExists — -f on regular file. diff --git a/interp/builtins/testcmd/testcmd_pentest_test.go b/interp/builtins/testcmd/testcmd_pentest_test.go index 47762316..1a6b15c9 100644 --- a/interp/builtins/testcmd/testcmd_pentest_test.go +++ b/interp/builtins/testcmd/testcmd_pentest_test.go @@ -72,19 +72,19 @@ func TestPentestIntOverflowNegative(t *testing.T) { func TestPentestIntEmptyString(t *testing.T) { _, stderr, code := runScript(t, `test "" -eq 0`, "") assert.Equal(t, 2, code) - assert.Contains(t, stderr, "invalid integer") + 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, "invalid integer") + 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, "invalid integer") + assert.Contains(t, stderr, "integer expression expected") } func TestPentestIntOctalNotInterpreted(t *testing.T) { @@ -95,7 +95,7 @@ func TestPentestIntOctalNotInterpreted(t *testing.T) { func TestPentestIntFloatRejected(t *testing.T) { _, stderr, code := runScript(t, `test 3.14 -eq 3`, "") assert.Equal(t, 2, code) - assert.Contains(t, stderr, "invalid integer") + assert.Contains(t, stderr, "integer expression expected") } // --- Special files --- @@ -193,7 +193,7 @@ func TestPentestBracketEmptyBrackets(t *testing.T) { func TestPentestBracketOnlyClose(t *testing.T) { _, stderr, code := runScript(t, `[`, "") assert.Equal(t, 2, code) - assert.Contains(t, stderr, "missing ']'") + assert.Contains(t, stderr, "missing `]'") } // --- Large argument count --- diff --git a/interp/builtins/testcmd/testcmd_test.go b/interp/builtins/testcmd/testcmd_test.go index b4667d6b..cadac0c4 100644 --- a/interp/builtins/testcmd/testcmd_test.go +++ b/interp/builtins/testcmd/testcmd_test.go @@ -164,13 +164,13 @@ func TestTestIntWhitespace(t *testing.T) { func TestTestInvalidInteger(t *testing.T) { _, stderr, code := runScript(t, `test 0x0 -eq 0`, "") assert.Equal(t, 2, code) - assert.Contains(t, stderr, "invalid integer") + 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, "invalid integer '123.45'") + assert.Contains(t, stderr, "123.45: integer expression expected") } // --- Logical operator tests --- @@ -316,13 +316,13 @@ func TestBracketIntCompare(t *testing.T) { func TestBracketMissingClose(t *testing.T) { _, stderr, code := runScript(t, `[ 1 -eq 1`, "") assert.Equal(t, 2, code) - assert.Contains(t, stderr, "missing ']'") + assert.Contains(t, stderr, "missing `]'") } func TestBracketEmpty(t *testing.T) { _, stderr, code := runScript(t, `[`, "") assert.Equal(t, 2, code) - assert.Contains(t, stderr, "missing ']'") + assert.Contains(t, stderr, "missing `]'") } // --- Help tests --- diff --git a/interp/builtins/testcmd/testcmd_unix_test.go b/interp/builtins/testcmd/testcmd_unix_test.go index cc063fe4..d892a90e 100644 --- a/interp/builtins/testcmd/testcmd_unix_test.go +++ b/interp/builtins/testcmd/testcmd_unix_test.go @@ -1,10 +1,10 @@ -//go:build unix - // 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 ( diff --git a/interp/builtins/testcmd/testcmd_windows_test.go b/interp/builtins/testcmd/testcmd_windows_test.go index 1ce33899..69eecd04 100644 --- a/interp/builtins/testcmd/testcmd_windows_test.go +++ b/interp/builtins/testcmd/testcmd_windows_test.go @@ -1,10 +1,10 @@ -//go:build windows - // 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 ( diff --git a/tests/scenarios/cmd/test/bracket/missing_bracket.yaml b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml index 12a69867..65cefd64 100644 --- a/tests/scenarios/cmd/test/bracket/missing_bracket.yaml +++ b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml @@ -1,8 +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" + 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 index 7bde175e..8c3e402b 100644 --- a/tests/scenarios/cmd/test/errors/unary_expected.yaml +++ b/tests/scenarios/cmd/test/errors/unary_expected.yaml @@ -1,8 +1,7 @@ -# Derived from uutils test_test.rs — error cases -description: "test reports error for ambiguous -o as first of two args" +# 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: - stderr_contains: ["test:"] - exit_code: 2 + exit_code: 1 diff --git a/tests/scenarios/cmd/test/integers/invalid.yaml b/tests/scenarios/cmd/test/integers/invalid.yaml index 916958da..a9df07df 100644 --- a/tests/scenarios/cmd/test/integers/invalid.yaml +++ b/tests/scenarios/cmd/test/integers/invalid.yaml @@ -1,8 +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: invalid integer '0x0'\n" + stderr: "test: 0x0: integer expression expected\n" exit_code: 2 From f2565a012bc2cf5acb8998d46927c2704ef92418 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 12:36:34 +0100 Subject: [PATCH 03/12] Fix test builtin: handle lone '(' as string and reject empty file operands Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 8 +++++++- interp/builtins/testcmd/testcmd_test.go | 16 ++++++++++++++++ .../scenarios/cmd/test/files/empty_operand.yaml | 17 +++++++++++++++++ .../cmd/test/strings/special_single_args.yaml | 3 +++ 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/scenarios/cmd/test/files/empty_operand.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index c076a51a..569bb5fb 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -259,7 +259,10 @@ func (p *parser) parsePrimary() bool { cur := p.peek() remaining := len(p.args) - p.pos - if cur == "(" { + // Only treat "(" as grouping when there are enough tokens for a full + // parenthesized expression. A lone "(" with remaining==1 is a bare + // non-empty string per POSIX single-argument rules. + if cur == "(" && remaining > 1 { p.advance() if p.pos >= len(p.args) || p.peek() == ")" { p.callCtx.Errf("%s: missing argument\n", p.cmdName) @@ -388,6 +391,9 @@ func (p *parser) parseBinaryExpr() bool { } func (p *parser) evalFileTest(op, path string) bool { + if path == "" { + return false + } switch op { case "-h", "-L": info, err := p.callCtx.LstatFile(p.ctx, path) diff --git a/interp/builtins/testcmd/testcmd_test.go b/interp/builtins/testcmd/testcmd_test.go index cadac0c4..5e69998a 100644 --- a/interp/builtins/testcmd/testcmd_test.go +++ b/interp/builtins/testcmd/testcmd_test.go @@ -325,6 +325,22 @@ func TestBracketEmpty(t *testing.T) { 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) { 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/strings/special_single_args.yaml b/tests/scenarios/cmd/test/strings/special_single_args.yaml index 44bd52d9..286999db 100644 --- a/tests/scenarios/cmd/test/strings/special_single_args.yaml +++ b/tests/scenarios/cmd/test/strings/special_single_args.yaml @@ -10,12 +10,15 @@ input: 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 skip_assert_against_bash: true From 93878bda199288a74bbefa0d3ba75efc8110c9f1 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 12:51:55 +0100 Subject: [PATCH 04/12] Use real access checks for test -r/-w/-x via sandbox AccessFile callback Co-Authored-By: Claude Opus 4.6 --- interp/allowed_paths.go | 27 +++++++++++++++++++++++++++ interp/builtins/builtins.go | 4 ++++ interp/builtins/testcmd/testcmd.go | 12 ++++++++++++ interp/portable_unix.go | 6 ++++++ interp/portable_windows.go | 8 ++++++++ interp/runner_exec.go | 3 +++ 6 files changed, 60 insertions(+) diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index 8ff0728c..adeefe41 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -77,6 +77,33 @@ func (s *pathSandbox) resolve(absPath string) (*os.Root, string, bool) { return nil, "", false } +// access checks whether the resolved path is accessible with the given mode. +// The mode uses Unix semantics: 0x04 = read, 0x02 = write, 0x01 = execute. +func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) error { + absPath := toAbs(path, HandlerCtx(ctx).Dir) + + if s == nil { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + for _, ar := range s.roots { + rel, err := filepath.Rel(ar.absPath, absPath) + if err != nil { + continue + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + continue + } + // First verify the file exists through os.Root (safe resolution). + if _, err := ar.root.Stat(rel); err != nil { + return err + } + // Check actual access on the resolved real path. + realPath := filepath.Join(ar.absPath, rel) + return checkAccess(realPath, mode) + } + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} +} + // toAbs resolves path against cwd when it is not already absolute. func toAbs(path, cwd string) string { if filepath.IsAbs(path) { diff --git a/interp/builtins/builtins.go b/interp/builtins/builtins.go index 3a49599b..c6216ad7 100644 --- a/interp/builtins/builtins.go +++ b/interp/builtins/builtins.go @@ -38,6 +38,10 @@ type CallContext struct { // LstatFile returns file info within the shell's path restrictions (does not follow symlinks). LstatFile func(ctx context.Context, path string) (fs.FileInfo, error) + // AccessFile checks whether the file at path is accessible with the given mode + // within the shell's path restrictions. Mode: 0x04=read, 0x02=write, 0x01=execute. + AccessFile func(ctx context.Context, path string, mode uint32) error + // PortableErr normalizes an OS error to a POSIX-style message. PortableErr func(err error) string } diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 569bb5fb..f7ff8eb4 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -394,6 +394,18 @@ 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": info, err := p.callCtx.LstatFile(p.ctx, path) diff --git a/interp/portable_unix.go b/interp/portable_unix.go index e1a920b5..e8e1cfc7 100644 --- a/interp/portable_unix.go +++ b/interp/portable_unix.go @@ -15,3 +15,9 @@ import ( func isErrIsDirectory(err error) bool { return errors.Is(err, syscall.EISDIR) } + +// checkAccess uses the access(2) syscall to test real uid/gid permissions. +// mode: 0x04 = R_OK, 0x02 = W_OK, 0x01 = X_OK. +func checkAccess(path string, mode uint32) error { + return syscall.Access(path, mode) +} diff --git a/interp/portable_windows.go b/interp/portable_windows.go index cca10fbf..539f92c3 100644 --- a/interp/portable_windows.go +++ b/interp/portable_windows.go @@ -7,6 +7,7 @@ package interp import ( "errors" + "os" "syscall" ) @@ -19,3 +20,10 @@ func isErrIsDirectory(err error) bool { } return false } + +// checkAccess on Windows falls back to checking file existence since Windows +// does not have a direct equivalent of the Unix access(2) syscall. +func checkAccess(path string, mode uint32) error { + _, err := os.Stat(path) + return err +} diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 71781c7c..38530e22 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -219,6 +219,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { LstatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { return r.sandbox.lstat(r.handlerCtx(ctx, todoPos), path) }, + AccessFile: func(ctx context.Context, path string, mode uint32) error { + return r.sandbox.access(r.handlerCtx(ctx, todoPos), path, mode) + }, PortableErr: portableErrMsg, } if r.stdin != nil { // do not assign a typed nil into the io.Reader interface From bfdd72b49447f7876d51598586ddcfc13a93ee39 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 12:56:18 +0100 Subject: [PATCH 05/12] Fix access checks to go through os.Root sandbox instead of raw syscall.Access Co-Authored-By: Claude Opus 4.6 --- interp/allowed_paths.go | 34 +++++++++++++++++++++++++++------- interp/portable_unix.go | 6 ------ interp/portable_windows.go | 8 -------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index adeefe41..aacb3189 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -78,7 +78,8 @@ func (s *pathSandbox) resolve(absPath string) (*os.Root, string, bool) { } // access checks whether the resolved path is accessible with the given mode. -// The mode uses Unix semantics: 0x04 = read, 0x02 = write, 0x01 = execute. +// All operations go through os.Root to stay within the sandbox. +// Mode: 0x04 = read, 0x02 = write, 0x01 = execute. func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) error { absPath := toAbs(path, HandlerCtx(ctx).Dir) @@ -93,13 +94,32 @@ func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) erro if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { continue } - // First verify the file exists through os.Root (safe resolution). - if _, err := ar.root.Stat(rel); err != nil { - return err + + // Check read access by attempting to open through os.Root. + if mode&0x04 != 0 { + f, err := ar.root.Open(rel) + if err != nil { + return err + } + f.Close() } - // Check actual access on the resolved real path. - realPath := filepath.Join(ar.absPath, rel) - return checkAccess(realPath, mode) + + // For write and execute, use mode bits from os.Root.Stat. + // The sandbox is read-only so -w is informational only. + if mode&0x03 != 0 { + info, err := ar.root.Stat(rel) + if err != nil { + return err + } + perm := info.Mode().Perm() + if mode&0x02 != 0 && perm&0222 == 0 { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + if mode&0x01 != 0 && perm&0111 == 0 { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + } + return nil } return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} } diff --git a/interp/portable_unix.go b/interp/portable_unix.go index e8e1cfc7..e1a920b5 100644 --- a/interp/portable_unix.go +++ b/interp/portable_unix.go @@ -15,9 +15,3 @@ import ( func isErrIsDirectory(err error) bool { return errors.Is(err, syscall.EISDIR) } - -// checkAccess uses the access(2) syscall to test real uid/gid permissions. -// mode: 0x04 = R_OK, 0x02 = W_OK, 0x01 = X_OK. -func checkAccess(path string, mode uint32) error { - return syscall.Access(path, mode) -} diff --git a/interp/portable_windows.go b/interp/portable_windows.go index 539f92c3..cca10fbf 100644 --- a/interp/portable_windows.go +++ b/interp/portable_windows.go @@ -7,7 +7,6 @@ package interp import ( "errors" - "os" "syscall" ) @@ -20,10 +19,3 @@ func isErrIsDirectory(err error) bool { } return false } - -// checkAccess on Windows falls back to checking file existence since Windows -// does not have a direct equivalent of the Unix access(2) syscall. -func checkAccess(path string, mode uint32) error { - _, err := os.Stat(path) - return err -} From 4fe21622b22006672ba713ec9d5630ca1860b02d Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 15:34:52 +0100 Subject: [PATCH 06/12] Fix !/( literal parsing in binary expressions and add parser depth limit Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 27 +++++++++++++--- .../builtins/testcmd/testcmd_pentest_test.go | 32 +++++++++++++++++++ .../cmd/test/logical/special_operands.yaml | 23 +++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 tests/scenarios/cmd/test/logical/special_operands.yaml diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index f7ff8eb4..7c69f40e 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -167,8 +167,11 @@ type parser struct { 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} @@ -233,6 +236,8 @@ func (p *parser) parseAnd() bool { // 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 @@ -240,6 +245,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]) { + return p.parsePrimary() + } p.advance() return !p.parseNot() } @@ -259,10 +269,18 @@ func (p *parser) parsePrimary() bool { cur := p.peek() remaining := len(p.args) - p.pos - // Only treat "(" as grouping when there are enough tokens for a full - // parenthesized expression. A lone "(" with remaining==1 is a bare - // non-empty string per POSIX single-argument rules. - if cur == "(" && remaining > 1 { + // 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) @@ -270,6 +288,7 @@ func (p *parser) parsePrimary() bool { return false } result := p.parseOr() + p.depth-- if p.err { return false } diff --git a/interp/builtins/testcmd/testcmd_pentest_test.go b/interp/builtins/testcmd/testcmd_pentest_test.go index 1a6b15c9..d3b8aefa 100644 --- a/interp/builtins/testcmd/testcmd_pentest_test.go +++ b/interp/builtins/testcmd/testcmd_pentest_test.go @@ -227,3 +227,35 @@ 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 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/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 From 2b69741760b5f1c018dda14c9a68336fea4b2287 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 17:30:15 +0100 Subject: [PATCH 07/12] Address review comments: effective access checks and pentest coverage - Fix -w/-x to check effective UID/GID permission class (owner/group/other) instead of any-class mode bits. Uses syscall.Stat_t on Unix; falls back to any-class bits on Windows. - Add pentest tests for 1000+ deep nesting, null bytes in file paths, 100KB string arguments, and 100KB file paths. Co-Authored-By: Claude Opus 4.6 --- interp/allowed_paths.go | 9 ++-- .../builtins/testcmd/testcmd_pentest_test.go | 53 +++++++++++++++++++ interp/portable_unix.go | 47 ++++++++++++++++ interp/portable_windows.go | 15 ++++++ 4 files changed, 119 insertions(+), 5 deletions(-) diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index aacb3189..f081a295 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -106,16 +106,15 @@ func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) erro // For write and execute, use mode bits from os.Root.Stat. // The sandbox is read-only so -w is informational only. + // effectiveHasPerm checks the permission class (owner/group/other) + // that applies to the current process's effective UID/GID on Unix, + // rather than the union of all classes. if mode&0x03 != 0 { info, err := ar.root.Stat(rel) if err != nil { return err } - perm := info.Mode().Perm() - if mode&0x02 != 0 && perm&0222 == 0 { - return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} - } - if mode&0x01 != 0 && perm&0111 == 0 { + if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) { return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} } } diff --git a/interp/builtins/testcmd/testcmd_pentest_test.go b/interp/builtins/testcmd/testcmd_pentest_test.go index d3b8aefa..9520a6b3 100644 --- a/interp/builtins/testcmd/testcmd_pentest_test.go +++ b/interp/builtins/testcmd/testcmd_pentest_test.go @@ -244,6 +244,59 @@ func TestPentestDeepNesting(t *testing.T) { 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 $?`, "") diff --git a/interp/portable_unix.go b/interp/portable_unix.go index e1a920b5..d8ed5c28 100644 --- a/interp/portable_unix.go +++ b/interp/portable_unix.go @@ -9,9 +9,56 @@ package interp import ( "errors" + "io/fs" + "os" "syscall" ) func isErrIsDirectory(err error) bool { return errors.Is(err, syscall.EISDIR) } + +// effectiveHasPerm checks whether the current process has the requested +// permission (writeMask or execMask, each a 3-bit pattern like 0222 or 0111) +// by inspecting the file's owner/group/other permission class that applies to +// the effective UID and GID of the running process. +// +// On Unix this uses the Stat_t from info.Sys() to determine the owning +// UID/GID and then selects the owner, group, or other permission bits +// accordingly. If the type assertion fails (should not happen in practice), +// it falls back to checking any-class bits. +func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWrite, checkExec bool) bool { + perm := info.Mode().Perm() + + // Determine which permission class applies to the current process. + // Default to "other" bits and narrow down if we have Stat_t. + ownerBits := fs.FileMode(0007) // other bits by default + if st, ok := info.Sys().(*syscall.Stat_t); ok { + uid := os.Getuid() + gid := os.Getgid() + switch { + case uid == 0: + // root can read/write anything; for execute, any x bit suffices. + ownerBits = 0777 + case int(st.Uid) == uid: + ownerBits = 0700 + case int(st.Gid) == gid: + ownerBits = 0070 + default: + ownerBits = 0007 + } + } + + if checkWrite { + // Intersect the write mask with the applicable owner bits. + if perm&writeMask&ownerBits == 0 { + return false + } + } + if checkExec { + if perm&execMask&ownerBits == 0 { + return false + } + } + return true +} diff --git a/interp/portable_windows.go b/interp/portable_windows.go index cca10fbf..71f0c12f 100644 --- a/interp/portable_windows.go +++ b/interp/portable_windows.go @@ -7,6 +7,7 @@ package interp import ( "errors" + "io/fs" "syscall" ) @@ -19,3 +20,17 @@ func isErrIsDirectory(err error) bool { } return false } + +// effectiveHasPerm checks whether the current process has the requested +// permission on Windows. Windows does not use Unix UID/GID permission classes, +// so we fall back to checking any-class bits (0222 / 0111) as before. +func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWrite, checkExec bool) bool { + perm := info.Mode().Perm() + if checkWrite && perm&writeMask == 0 { + return false + } + if checkExec && perm&execMask == 0 { + return false + } + return true +} From e62eecd8e9e6693f8d7c0dd6a293e2e6642e484e Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 18:44:57 +0100 Subject: [PATCH 08/12] Address security audit review comments - S1: Add depth limit to parseNot() recursion via existing depth counter - S2: Fix TOCTOU in access() by using f.Stat() on open fd instead of separate ar.root.Stat() call; wrap errors with portablePathError() - S3: Check supplementary groups in effectiveHasPerm via os.Getgroups() - S4: Wrap raw OS errors with portablePathError() in access() (done as part of S2 fix) - Simplify boolean expression in portable_windows.go (Datadog code quality) Co-Authored-By: Claude Opus 4.6 --- interp/allowed_paths.go | 29 ++++++++++++++++++++--------- interp/builtins/testcmd/testcmd.go | 10 +++++++++- interp/portable_unix.go | 10 ++++++++++ interp/portable_windows.go | 5 +---- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index f081a295..d8458813 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -95,24 +95,35 @@ func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) erro continue } - // Check read access by attempting to open through os.Root. - if mode&0x04 != 0 { - f, err := ar.root.Open(rel) - if err != nil { - return err + // Open through os.Root once. This checks read access and gives + // us a file descriptor for an atomic Stat (no TOCTOU window). + f, err := ar.root.Open(rel) + if err != nil { + if mode&0x04 != 0 { + return portablePathError(err) + } + // Read not requested; fall back to Stat for write/execute. + info, serr := ar.root.Stat(rel) + if serr != nil { + return portablePathError(serr) + } + if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} } - f.Close() + return nil } + defer f.Close() - // For write and execute, use mode bits from os.Root.Stat. + // For write and execute, use mode bits from f.Stat() on the + // open fd — atomic, no TOCTOU window. // The sandbox is read-only so -w is informational only. // effectiveHasPerm checks the permission class (owner/group/other) // that applies to the current process's effective UID/GID on Unix, // rather than the union of all classes. if mode&0x03 != 0 { - info, err := ar.root.Stat(rel) + info, err := f.Stat() if err != nil { - return err + return portablePathError(err) } if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) { return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 7c69f40e..7b67182d 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -250,8 +250,16 @@ func (p *parser) parseNot() bool { 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() - return !p.parseNot() + result := !p.parseNot() + p.depth-- + return result } return p.parsePrimary() } diff --git a/interp/portable_unix.go b/interp/portable_unix.go index d8ed5c28..371266fb 100644 --- a/interp/portable_unix.go +++ b/interp/portable_unix.go @@ -46,6 +46,16 @@ func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWr ownerBits = 0070 default: ownerBits = 0007 + // Check supplementary groups — the process may belong to + // additional groups beyond the primary GID. + if groups, err := os.Getgroups(); err == nil { + for _, g := range groups { + if int(st.Gid) == g { + ownerBits = 0070 + break + } + } + } } } diff --git a/interp/portable_windows.go b/interp/portable_windows.go index 71f0c12f..7233b4de 100644 --- a/interp/portable_windows.go +++ b/interp/portable_windows.go @@ -29,8 +29,5 @@ func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWr if checkWrite && perm&writeMask == 0 { return false } - if checkExec && perm&execMask == 0 { - return false - } - return true + return !(checkExec && perm&execMask == 0) } From 2c0395ccb173aa9ec8e35718a24f34b6913c5696 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 18:58:00 +0100 Subject: [PATCH 09/12] Fix build: use MakeFlags instead of Run in testcmd Command structs The builtins.Command struct uses MakeFlags (not Run). Updated test and [ command registrations to use builtins.NoFlags(handler) matching the pattern used by all other builtins. Co-Authored-By: Claude Opus 4.6 --- interp/builtins/testcmd/testcmd.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 7b67182d..848959fc 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -80,10 +80,10 @@ import ( ) // Cmd is the "test" builtin command registration. -var Cmd = builtins.Command{Name: "test", Run: runTest} +var Cmd = builtins.Command{Name: "test", MakeFlags: builtins.NoFlags(runTest)} // BracketCmd is the "[" builtin command registration. -var BracketCmd = builtins.Command{Name: "[", Run: runBracket} +var BracketCmd = builtins.Command{Name: "[", MakeFlags: builtins.NoFlags(runBracket)} const helpText = `Usage: test EXPRESSION or: [ EXPRESSION ] From b0db425e2837acfb47f4def9cac7a9206be99e05 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 19:17:50 +0100 Subject: [PATCH 10/12] Address code review warnings: Windows directory access + nil guards - access(): fall through to Stat path when Open() fails on a directory (isErrIsDirectory), fixing test -r on Windows - evalFileTest(): add nil guards for StatFile/LstatFile callbacks, matching the existing AccessFile nil check pattern Co-Authored-By: Claude Opus 4.6 --- interp/allowed_paths.go | 4 ++-- interp/builtins/testcmd/testcmd.go | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index d8458813..716828e0 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -99,10 +99,10 @@ func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) erro // us a file descriptor for an atomic Stat (no TOCTOU window). f, err := ar.root.Open(rel) if err != nil { - if mode&0x04 != 0 { + if mode&0x04 != 0 && !isErrIsDirectory(err) { return portablePathError(err) } - // Read not requested; fall back to Stat for write/execute. + // Read not requested, or target is a directory; fall back to Stat. info, serr := ar.root.Stat(rel) if serr != nil { return portablePathError(serr) diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index 848959fc..fb34e9d7 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -435,12 +435,18 @@ func (p *parser) evalFileTest(op, path string) bool { 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 From d1587a0f3e0b392f493a79c5b2bcd4d2d931c945 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 19:42:49 +0100 Subject: [PATCH 11/12] Address code review: nil guard, explicit Close, fix YAML bash skips Co-Authored-By: Claude Opus 4.6 --- interp/allowed_paths.go | 4 +++- interp/builtins/testcmd/testcmd.go | 3 +++ tests/scenarios/cmd/test/files/directory.yaml | 2 +- tests/scenarios/cmd/test/files/existence.yaml | 2 +- tests/scenarios/cmd/test/files/size.yaml | 2 +- tests/scenarios/cmd/test/logical/not.yaml | 1 - tests/scenarios/cmd/test/strings/special_single_args.yaml | 1 - 7 files changed, 9 insertions(+), 6 deletions(-) diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index 716828e0..af78819c 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -112,7 +112,6 @@ func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) erro } return nil } - defer f.Close() // For write and execute, use mode bits from f.Stat() on the // open fd — atomic, no TOCTOU window. @@ -123,12 +122,15 @@ func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) erro if mode&0x03 != 0 { info, err := f.Stat() if err != nil { + f.Close() return portablePathError(err) } if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) { + f.Close() return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} } } + f.Close() return nil } return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go index fb34e9d7..65da9c9b 100644 --- a/interp/builtins/testcmd/testcmd.go +++ b/interp/builtins/testcmd/testcmd.go @@ -529,6 +529,9 @@ func (p *parser) parseInt(s string) (int64, bool) { } 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) diff --git a/tests/scenarios/cmd/test/files/directory.yaml b/tests/scenarios/cmd/test/files/directory.yaml index 905511db..5c51546c 100644 --- a/tests/scenarios/cmd/test/files/directory.yaml +++ b/tests/scenarios/cmd/test/files/directory.yaml @@ -17,4 +17,4 @@ expect: file: 1 stderr: "" exit_code: 0 -skip_assert_against_bash: true +skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/files/existence.yaml b/tests/scenarios/cmd/test/files/existence.yaml index c573e617..96cb2a1b 100644 --- a/tests/scenarios/cmd/test/files/existence.yaml +++ b/tests/scenarios/cmd/test/files/existence.yaml @@ -27,4 +27,4 @@ expect: nofile: 1 stderr: "" exit_code: 0 -skip_assert_against_bash: true +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 index 3fa9acc7..59c31781 100644 --- a/tests/scenarios/cmd/test/files/size.yaml +++ b/tests/scenarios/cmd/test/files/size.yaml @@ -22,4 +22,4 @@ expect: missing: 1 stderr: "" exit_code: 0 -skip_assert_against_bash: true +skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/logical/not.yaml b/tests/scenarios/cmd/test/logical/not.yaml index 76396f9b..90c087c0 100644 --- a/tests/scenarios/cmd/test/logical/not.yaml +++ b/tests/scenarios/cmd/test/logical/not.yaml @@ -15,4 +15,3 @@ expect: not-file: 0 stderr: "" exit_code: 0 -skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/test/strings/special_single_args.yaml b/tests/scenarios/cmd/test/strings/special_single_args.yaml index 286999db..372d6ef6 100644 --- a/tests/scenarios/cmd/test/strings/special_single_args.yaml +++ b/tests/scenarios/cmd/test/strings/special_single_args.yaml @@ -21,4 +21,3 @@ expect: paren: 0 stderr: "" exit_code: 0 -skip_assert_against_bash: true From 619d20692a179fa2ea39602ba470d85c0c6630c1 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 19:48:24 +0100 Subject: [PATCH 12/12] Remove pflag symbols from allowlist per PR #27 MakeFlags refactor Co-Authored-By: Claude Opus 4.6 --- tests/import_allowlist_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/import_allowlist_test.go b/tests/import_allowlist_test.go index 0521d223..82ff4c6f 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -46,10 +46,6 @@ var builtinAllowedSymbols = []string{ "io/fs.ModeNamedPipe", // fs.ModeSymlink — file mode bit constant; no side effects. "io/fs.ModeSymlink", - // pflag.ContinueOnError — flag parse-error mode constant; no side effects. - "github.com/spf13/pflag.ContinueOnError", - // pflag.NewFlagSet — CLI flag parsing; operates only on string slices, no I/O. - "github.com/spf13/pflag.NewFlagSet", // io.Copy — stream data between reader and writer; builtins receive sandboxed streams. "io.Copy", // io.EOF — sentinel error value; pure constant.