From 755a7935a8de1da6deb2c21f91a4a8ec8c5f677a Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Mon, 9 Mar 2026 23:22:47 +0100 Subject: [PATCH 1/9] Implement test/[ builtin with file, string, integer, and logic operators Co-Authored-By: Claude Opus 4.6 --- SHELL_COMMANDS.md | 1 + SHELL_FEATURES.md | 1 + interp/allowed_paths.go | 34 ++ interp/builtins/builtins.go | 6 + interp/builtins/test.go | 402 +++++++++++++++++ interp/builtins/tests/test/test_test.go | 408 ++++++++++++++++++ interp/runner.go | 22 + interp/runner_exec.go | 6 + tests/import_allowlist_test.go | 3 + tests/scenarios/cmd/test/bracket/basic.yaml | 9 + .../cmd/test/bracket/missing_bracket.yaml | 9 + .../cmd/test/edge_cases/empty_word.yaml | 9 + .../cmd/test/edge_cases/no_args.yaml | 9 + .../cmd/test/edge_cases/single_word.yaml | 9 + .../cmd/test/errors/missing_operand.yaml | 10 + .../cmd/test/file_tests/d_directory.yaml | 13 + .../cmd/test/file_tests/e_exists.yaml | 13 + .../cmd/test/file_tests/e_not_exists.yaml | 9 + .../cmd/test/file_tests/f_directory.yaml | 13 + .../cmd/test/file_tests/f_regular.yaml | 13 + .../cmd/test/file_tests/s_empty.yaml | 13 + .../cmd/test/file_tests/s_nonempty.yaml | 13 + .../cmd/test/file_tests/symlink.yaml | 15 + .../scenarios/cmd/test/integer_tests/eq.yaml | 9 + .../scenarios/cmd/test/integer_tests/ge.yaml | 9 + .../scenarios/cmd/test/integer_tests/gt.yaml | 9 + .../scenarios/cmd/test/integer_tests/le.yaml | 9 + .../scenarios/cmd/test/integer_tests/lt.yaml | 9 + .../scenarios/cmd/test/integer_tests/ne.yaml | 9 + tests/scenarios/cmd/test/logic/and.yaml | 9 + tests/scenarios/cmd/test/logic/not.yaml | 9 + tests/scenarios/cmd/test/logic/or.yaml | 9 + .../scenarios/cmd/test/logic/parentheses.yaml | 9 + .../cmd/test/string_tests/equal.yaml | 9 + .../cmd/test/string_tests/n_empty.yaml | 9 + .../cmd/test/string_tests/n_nonempty.yaml | 9 + .../cmd/test/string_tests/not_equal.yaml | 9 + .../cmd/test/string_tests/z_empty.yaml | 9 + .../cmd/test/string_tests/z_nonempty.yaml | 9 + 39 files changed, 1184 insertions(+) create mode 100644 interp/builtins/test.go create mode 100644 interp/builtins/tests/test/test_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/edge_cases/empty_word.yaml create mode 100644 tests/scenarios/cmd/test/edge_cases/no_args.yaml create mode 100644 tests/scenarios/cmd/test/edge_cases/single_word.yaml create mode 100644 tests/scenarios/cmd/test/errors/missing_operand.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/d_directory.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/e_exists.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/e_not_exists.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/f_directory.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/f_regular.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/s_empty.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/s_nonempty.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/symlink.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/eq.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/ge.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/gt.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/le.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/lt.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/ne.yaml create mode 100644 tests/scenarios/cmd/test/logic/and.yaml create mode 100644 tests/scenarios/cmd/test/logic/not.yaml create mode 100644 tests/scenarios/cmd/test/logic/or.yaml create mode 100644 tests/scenarios/cmd/test/logic/parentheses.yaml create mode 100644 tests/scenarios/cmd/test/string_tests/equal.yaml create mode 100644 tests/scenarios/cmd/test/string_tests/n_empty.yaml create mode 100644 tests/scenarios/cmd/test/string_tests/n_nonempty.yaml create mode 100644 tests/scenarios/cmd/test/string_tests/not_equal.yaml create mode 100644 tests/scenarios/cmd/test/string_tests/z_empty.yaml create mode 100644 tests/scenarios/cmd/test/string_tests/z_nonempty.yaml diff --git a/SHELL_COMMANDS.md b/SHELL_COMMANDS.md index 618f420f..534e0e78 100644 --- a/SHELL_COMMANDS.md +++ b/SHELL_COMMANDS.md @@ -9,6 +9,7 @@ Short reference for builtin commands | `echo [ARG ...]` | none | Print arguments separated by spaces, then newline. | | `cat [FILE ...]` | `-` (read stdin) | Print files; with no args, read stdin. | | `head [FILE ...]` | `-n N` (lines), `-c N` (bytes), `-q`/`--quiet`/`--silent` (no headers), `-v` (force headers) | Print first 10 lines of each FILE; with no FILE or `-`, read stdin. | +| `test EXPR` / `[ EXPR ]` | `-e`/`-f`/`-d`/`-s`/`-r`/`-w`/`-x`/`-L` (file tests), `-n`/`-z`/`=`/`!=` (strings), `-eq`/`-ne`/`-lt`/`-gt`/`-le`/`-ge` (integers), `-nt`/`-ot`/`-ef` (file comparison), `!`/`-a`/`-o` (logic) | Evaluate conditional expression; exit 0 (true) or 1 (false). | | `exit [N]` | `N` (status code) | Exit the shell with `N` (default: last status). | | `break [N]` | `N` (loop levels) | Break current loop, or `N` enclosing loops. | | `continue [N]` | `N` (loop levels) | Continue current loop, or `N` enclosing loops. | diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index dab78334..f93a0bad 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -10,6 +10,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `true` — exits with code 0 - ✅ `false` — exits with code 1 - ✅ `exit [N]` — exits with code N (default: last exit code) +- ✅ `test EXPR` / `[ EXPR ]` — evaluate conditional expressions (file tests, string/integer comparisons, logical operators) - ✅ `break [N]` / `continue [N]` — loop control - ❌ All other commands — return exit code 127 with `: not found` unless an ExecHandler is configured diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index 50dce290..e0a88f86 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 returns a FileInfo for the given path. The path is resolved through the +// sandbox the same way as open(). +func (s *pathSandbox) stat(ctx context.Context, path string) (os.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} + } + + fi, err := root.Stat(relPath) + if err != nil { + return nil, portablePathError(err) + } + return fi, nil +} + +// lstat returns a FileInfo for the given path without following symlinks. +// The path is resolved through the sandbox the same way as open(). +func (s *pathSandbox) lstat(ctx context.Context, path string) (os.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} + } + + fi, err := root.Lstat(relPath) + if err != nil { + return nil, portablePathError(err) + } + return fi, 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 05c92e1f..48a54d61 100644 --- a/interp/builtins/builtins.go +++ b/interp/builtins/builtins.go @@ -31,6 +31,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. + StatFile func(ctx context.Context, path string) (os.FileInfo, error) + + // LstatFile returns file info without following symlinks. + LstatFile func(ctx context.Context, path string) (os.FileInfo, error) + // PortableErr normalizes an OS error to a POSIX-style message. PortableErr func(err error) string } diff --git a/interp/builtins/test.go b/interp/builtins/test.go new file mode 100644 index 00000000..57a0ccdd --- /dev/null +++ b/interp/builtins/test.go @@ -0,0 +1,402 @@ +// 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 builtins implements safe shell builtin commands. +// +// test / [ — evaluate conditional expressions +// +// Usage: test EXPRESSION +// [ EXPRESSION ] +// +// Evaluate a conditional expression EXPRESSION and exit with status 0 (true) +// or 1 (false). With no arguments, test exits with status 1. +// +// Supported operators: +// +// File tests: +// -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 a size greater than zero +// -r FILE FILE exists and read permission is granted +// -w FILE FILE exists and write permission is granted +// -x FILE FILE exists and execute permission is granted +// -L FILE FILE exists and is a symbolic link (same as -h) +// -h FILE FILE exists and is a symbolic link (same as -L) +// +// File comparisons: +// FILE1 -nt FILE2 FILE1 is newer (modification date) than FILE2 +// FILE1 -ot FILE2 FILE1 is older than FILE2 +// FILE1 -ef FILE2 FILE1 and FILE2 refer to the same device and inode +// +// String tests: +// -n STRING STRING has non-zero length +// -z STRING STRING has zero length +// STRING1 = STRING2 the strings are equal +// STRING1 == STRING2 the strings are equal +// STRING1 != STRING2 the strings are not equal +// +// Integer comparisons: +// INT1 -eq INT2 INT1 is equal to INT2 +// INT1 -ne INT2 INT1 is not equal to INT2 +// INT1 -lt INT2 INT1 is less than INT2 +// INT1 -gt INT2 INT1 is greater than INT2 +// INT1 -le INT2 INT1 is less than or equal to INT2 +// INT1 -ge INT2 INT1 is greater than or equal to INT2 +// +// Logical operators: +// ! EXPRESSION EXPRESSION is false +// EXPRESSION -a EXPRESSION both expressions are true +// EXPRESSION -o EXPRESSION either expression is true +// ( EXPRESSION ) grouping +// +// Exit codes: +// +// 0 Expression is true. +// 1 Expression is false or missing. +// 2 Syntax or usage error. +package builtins + +import ( + "context" + "os" + "strconv" +) + +func init() { + register("test", builtinTestCmd) + register("[", builtinBracket) +} + +func builtinTestCmd(ctx context.Context, callCtx *CallContext, args []string) Result { + return testRun(ctx, callCtx, args, false) +} + +func builtinBracket(ctx context.Context, callCtx *CallContext, args []string) Result { + return testRun(ctx, callCtx, args, true) +} + +func testRun(ctx context.Context, callCtx *CallContext, args []string, isBracket bool) Result { + name := "test" + if isBracket { + name = "[" + if len(args) == 0 || args[len(args)-1] != "]" { + callCtx.Errf("[: missing `]'\n") + return Result{Code: 2} + } + args = args[:len(args)-1] + } + + // No arguments: false. + if len(args) == 0 { + return Result{Code: 1} + } + + p := &testParser{rem: args} + expr := p.classicTest() + if p.err != "" { + callCtx.Errf("%s: %s\n", name, p.err) + return Result{Code: 2} + } + if len(p.rem) > 0 { + callCtx.Errf("%s: extra argument '%s'\n", name, p.rem[0]) + return Result{Code: 2} + } + + if evalTest(ctx, callCtx, expr) { + return Result{} + } + return Result{Code: 1} +} + +// --- Expression AST --- + +type testExpr interface{ testExpr() } + +type testWord struct{ val string } +type testUnary struct { + op string + x testExpr +} +type testBinary struct { + op string + x, y testExpr +} +type testParen struct{ x testExpr } + +func (testWord) testExpr() {} +func (testUnary) testExpr() {} +func (testBinary) testExpr() {} +func (testParen) testExpr() {} + +// --- Parser --- + +// testParser implements recursive descent parsing for classic test expressions, +// following the same algorithm as mvdan/sh. +type testParser struct { + rem []string + err string +} + +func (p *testParser) next() string { + if len(p.rem) == 0 { + return "" + } + s := p.rem[0] + p.rem = p.rem[1:] + return s +} + +func (p *testParser) peek() string { + if len(p.rem) == 0 { + return "" + } + return p.rem[0] +} + +func (p *testParser) hasMore() bool { + return len(p.rem) > 0 +} + +// classicTest parses a full test expression handling -o (OR) at the lowest precedence. +func (p *testParser) classicTest() testExpr { + return p.testOrExpr() +} + +func (p *testParser) testOrExpr() testExpr { + left := p.testAndExpr() + for p.err == "" && p.hasMore() && p.peek() == "-o" { + p.next() // consume -o + right := p.testAndExpr() + left = testBinary{op: "-o", x: left, y: right} + } + return left +} + +func (p *testParser) testAndExpr() testExpr { + left := p.testExprBase() + for p.err == "" && p.hasMore() && p.peek() == "-a" { + p.next() // consume -a + right := p.testExprBase() + left = testBinary{op: "-a", x: left, y: right} + } + return left +} + +var testUnaryOps = map[string]bool{ + "-e": true, "-f": true, "-d": true, "-s": true, + "-r": true, "-w": true, "-x": true, + "-L": true, "-h": true, + "-n": true, "-z": true, +} + +var testBinaryOps = map[string]bool{ + "=": true, "==": true, "!=": true, + "-eq": true, "-ne": true, "-lt": true, "-gt": true, "-le": true, "-ge": true, + "-nt": true, "-ot": true, "-ef": true, +} + +func (p *testParser) testExprBase() testExpr { + if p.err != "" { + return testWord{} + } + + if !p.hasMore() { + p.err = "missing argument after operator" + return testWord{} + } + + s := p.peek() + + // Negation. + if s == "!" { + p.next() + return testUnary{op: "!", x: p.testExprBase()} + } + + // Parenthesized group. + if s == "(" { + p.next() + expr := p.classicTest() + if !p.hasMore() || p.peek() != ")" { + p.err = "missing ')'" + return testWord{} + } + p.next() + return testParen{x: expr} + } + + // Unary operator. + if testUnaryOps[s] { + p.next() + if !p.hasMore() { + p.err = "missing argument after '" + s + "'" + return testWord{} + } + arg := p.next() + return testUnary{op: s, x: testWord{val: arg}} + } + + // Consume first word. + p.next() + + // Check for binary operator. + if p.hasMore() && testBinaryOps[p.peek()] { + op := p.next() + if !p.hasMore() { + p.err = "missing argument after '" + op + "'" + return testWord{} + } + rhs := p.next() + return testBinary{op: op, x: testWord{val: s}, y: testWord{val: rhs}} + } + + // Plain word: non-empty string is true. + return testWord{val: s} +} + +// --- Evaluator --- + +func evalTest(ctx context.Context, callCtx *CallContext, expr testExpr) bool { + switch e := expr.(type) { + case testWord: + return e.val != "" + case testUnary: + return evalUnary(ctx, callCtx, e) + case testBinary: + return evalBinary(ctx, callCtx, e) + case testParen: + return evalTest(ctx, callCtx, e.x) + } + return false +} + +func evalUnary(ctx context.Context, callCtx *CallContext, e testUnary) bool { + switch e.op { + case "!": + return !evalTest(ctx, callCtx, e.x) + case "-n": + if w, ok := e.x.(testWord); ok { + return w.val != "" + } + return evalTest(ctx, callCtx, e.x) + case "-z": + if w, ok := e.x.(testWord); ok { + return w.val == "" + } + return !evalTest(ctx, callCtx, e.x) + case "-e", "-f", "-d", "-s", "-r", "-w", "-x": + w, ok := e.x.(testWord) + if !ok { + return false + } + fi, err := callCtx.StatFile(ctx, w.val) + if err != nil { + return false + } + return evalFileStat(e.op, fi) + case "-L", "-h": + w, ok := e.x.(testWord) + if !ok { + return false + } + fi, err := callCtx.LstatFile(ctx, w.val) + if err != nil { + return false + } + return fi.Mode()&os.ModeSymlink != 0 + } + return false +} + +func evalFileStat(op string, fi os.FileInfo) bool { + switch op { + case "-e": + return true // stat succeeded + case "-f": + return fi.Mode().IsRegular() + case "-d": + return fi.IsDir() + case "-s": + return fi.Size() > 0 + case "-r": + return fi.Mode().Perm()&0444 != 0 + case "-w": + return fi.Mode().Perm()&0222 != 0 + case "-x": + return fi.Mode().Perm()&0111 != 0 + } + return false +} + +func evalBinary(ctx context.Context, callCtx *CallContext, e testBinary) bool { + switch e.op { + case "-a": + return evalTest(ctx, callCtx, e.x) && evalTest(ctx, callCtx, e.y) + case "-o": + return evalTest(ctx, callCtx, e.x) || evalTest(ctx, callCtx, e.y) + case "=", "==": + return wordVal(e.x) == wordVal(e.y) + case "!=": + return wordVal(e.x) != wordVal(e.y) + case "-eq", "-ne", "-lt", "-gt", "-le", "-ge": + return evalIntCmp(e.op, wordVal(e.x), wordVal(e.y)) + case "-nt": + return evalNt(ctx, callCtx, wordVal(e.x), wordVal(e.y)) + case "-ot": + return evalNt(ctx, callCtx, wordVal(e.y), wordVal(e.x)) + case "-ef": + return evalEf(ctx, callCtx, wordVal(e.x), wordVal(e.y)) + } + return false +} + +func wordVal(e testExpr) string { + if w, ok := e.(testWord); ok { + return w.val + } + return "" +} + +func evalIntCmp(op, a, b string) bool { + ai, errA := strconv.ParseInt(a, 10, 64) + bi, errB := strconv.ParseInt(b, 10, 64) + if errA != nil || errB != nil { + return false + } + switch op { + case "-eq": + return ai == bi + case "-ne": + return ai != bi + case "-lt": + return ai < bi + case "-gt": + return ai > bi + case "-le": + return ai <= bi + case "-ge": + return ai >= bi + } + return false +} + +func evalNt(ctx context.Context, callCtx *CallContext, a, b string) bool { + fiA, errA := callCtx.StatFile(ctx, a) + fiB, errB := callCtx.StatFile(ctx, b) + if errA != nil || errB != nil { + return false + } + return fiA.ModTime().After(fiB.ModTime()) +} + +func evalEf(ctx context.Context, callCtx *CallContext, a, b string) bool { + fiA, errA := callCtx.StatFile(ctx, a) + fiB, errB := callCtx.StatFile(ctx, b) + if errA != nil || errB != nil { + return false + } + return os.SameFile(fiA, fiB) +} diff --git a/interp/builtins/tests/test/test_test.go b/interp/builtins/tests/test/test_test.go new file mode 100644 index 00000000..6301a409 --- /dev/null +++ b/interp/builtins/tests/test/test_test.go @@ -0,0 +1,408 @@ +// 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 test_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/interp" +) + +// runScript runs a shell script and returns stdout, stderr, and the exit code. +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(script), "") + require.NoError(t, err) + + var outBuf, errBuf bytes.Buffer + allOpts := append([]interp.RunnerOption{interp.StdIO(nil, &outBuf, &errBuf)}, opts...) + runner, err := interp.New(allOpts...) + require.NoError(t, err) + defer runner.Close() + + if dir != "" { + runner.Dir = dir + } + + err = runner.Run(context.Background(), prog) + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected error: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +// cmdRun runs a test command with AllowedPaths set to dir. +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +// writeFile creates a file in dir with the given content. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) +} + +// --- String tests --- + +func TestTestStringNonEmpty(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test -n "hello" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestStringEmpty(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test -n "" && echo yes || echo no`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "no\n", stdout) +} + +func TestTestStringZeroEmpty(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test -z "" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestStringZeroNonEmpty(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test -z "hello" && echo yes || echo no`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "no\n", stdout) +} + +func TestTestStringEqual(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test "abc" = "abc" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestStringDoubleEqual(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test "abc" == "abc" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestStringNotEqual(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test "abc" != "xyz" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +// --- Integer tests --- + +func TestTestIntEq(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test 5 -eq 5 && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestIntNe(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test 5 -ne 3 && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestIntLt(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test 3 -lt 5 && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestIntGt(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test 5 -gt 3 && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestIntLe(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test 3 -le 3 && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestIntGe(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test 5 -ge 5 && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestIntEqFalse(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test 5 -eq 3`, dir) + assert.Equal(t, 1, code) +} + +func TestTestIntNonNumeric(t *testing.T) { + dir := t.TempDir() + // Non-numeric comparisons fail (return false/1 not error). + _, _, code := cmdRun(t, `test abc -eq 3`, dir) + assert.Equal(t, 1, code) +} + +// --- File tests --- + +func TestTestFileExists(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello") + stdout, _, code := cmdRun(t, `test -e file.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileNotExists(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -e nonexistent`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileRegular(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello") + stdout, _, code := cmdRun(t, `test -f file.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + stdout, _, code := cmdRun(t, `test -d subdir && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileNonEmptySize(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello") + stdout, _, code := cmdRun(t, `test -s file.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileEmptySize(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, "file.txt", "hello") + stdout, _, code := cmdRun(t, `test -r file.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileExecutable(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "script.sh", "#!/bin/sh") + require.NoError(t, os.Chmod(filepath.Join(dir, "script.sh"), 0755)) + stdout, _, code := cmdRun(t, `test -x script.sh && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileSymlink(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "target.txt", "hello") + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) + stdout, _, code := cmdRun(t, `test -L link.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileSymlinkH(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "target.txt", "hello") + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) + stdout, _, code := cmdRun(t, `test -h link.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileNotSymlink(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "regular.txt", "hello") + _, _, code := cmdRun(t, `test -L regular.txt`, dir) + assert.Equal(t, 1, code) +} + +// --- File comparisons --- + +func TestTestFileNewerThan(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "old.txt", "old") + // Ensure different mod times. + past := time.Now().Add(-2 * time.Second) + require.NoError(t, os.Chtimes(filepath.Join(dir, "old.txt"), past, past)) + writeFile(t, dir, "new.txt", "new") + stdout, _, code := cmdRun(t, `test new.txt -nt old.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileOlderThan(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "old.txt", "old") + past := time.Now().Add(-2 * time.Second) + require.NoError(t, os.Chtimes(filepath.Join(dir, "old.txt"), past, past)) + writeFile(t, dir, "new.txt", "new") + stdout, _, code := cmdRun(t, `test old.txt -ot new.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileSameFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello") + stdout, _, code := cmdRun(t, `test file.txt -ef file.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileDifferentFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "hello") + writeFile(t, dir, "b.txt", "hello") + _, _, code := cmdRun(t, `test a.txt -ef b.txt`, dir) + assert.Equal(t, 1, code) +} + +// --- Logic --- + +func TestTestNot(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test ! -z "hello" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestAnd(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test -n "a" -a -n "b" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestOr(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test -z "a" -o -n "b" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +// --- Bracket syntax --- + +func TestBracketBasic(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `[ -n "hello" ] && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestBracketMissingClosing(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `[ -n "hello"`, dir) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "[: missing `]'") +} + +func TestBracketEmpty(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `[ ]`, dir) + assert.Equal(t, 1, code) +} + +// --- Edge cases --- + +func TestTestNoArgs(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test`, dir) + assert.Equal(t, 1, code) +} + +func TestTestSingleWord(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `test hello && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestEmptyString(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test ""`, dir) + assert.Equal(t, 1, code) +} + +func TestTestMissingOperand(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `test "a" =`, dir) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "test:") +} + +func TestTestExtraArgument(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `test "a" = "b" extra`, dir) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "test:") +} + +func TestTestWithVariable(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `X=hello; test -n "$X" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestWithAndOr(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello") + stdout, _, code := cmdRun(t, `test -f file.txt && echo exists || echo missing`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "exists\n", stdout) +} + +func TestTestOutsideAllowedPaths(t *testing.T) { + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret"), 0644)) + secretPath := strings.ReplaceAll(filepath.Join(secret, "secret.txt"), `\`, `/`) + // test -e should return false for files outside allowed paths. + _, _, code := runScript(t, "test -e "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) +} diff --git a/interp/runner.go b/interp/runner.go index a49c4421..9e91a3a9 100644 --- a/interp/runner.go +++ b/interp/runner.go @@ -45,6 +45,28 @@ func (r *Runner) stop(ctx context.Context) bool { return false } +func (r *Runner) stat(ctx context.Context, path string) (os.FileInfo, error) { + fi, err := r.sandbox.stat(r.handlerCtx(ctx, todoPos), path) + if err != nil { + if pe, ok := err.(*os.PathError); ok { + return nil, portablePathError(pe) + } + return nil, err + } + return fi, nil +} + +func (r *Runner) lstat(ctx context.Context, path string) (os.FileInfo, error) { + fi, err := r.sandbox.lstat(r.handlerCtx(ctx, todoPos), path) + if err != nil { + if pe, ok := err.(*os.PathError); ok { + return nil, portablePathError(pe) + } + return nil, err + } + return fi, nil +} + func (r *Runner) open(ctx context.Context, path string, flags int, mode os.FileMode, print bool) (io.ReadWriteCloser, error) { f, err := r.openHandler(r.handlerCtx(ctx, todoPos), path, flags, mode) // TODO: support wrapped PathError returned from openHandler. diff --git a/interp/runner_exec.go b/interp/runner_exec.go index e767ec5e..5861a9f2 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -216,6 +216,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) (os.FileInfo, error) { + return r.stat(ctx, path) + }, + LstatFile: func(ctx context.Context, path string) (os.FileInfo, error) { + return r.lstat(ctx, 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 4df9afd3..8c70159e 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -39,7 +39,10 @@ var builtinAllowedSymbols = []string{ "io.NopCloser", "io.ReadCloser", "io.Reader", + "os.FileInfo", + "os.ModeSymlink", "os.O_RDONLY", + "os.SameFile", "strconv.Atoi", "strconv.ParseInt", } diff --git a/tests/scenarios/cmd/test/bracket/basic.yaml b/tests/scenarios/cmd/test/bracket/basic.yaml new file mode 100644 index 00000000..0d01e5db --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/basic.yaml @@ -0,0 +1,9 @@ +description: "[ ] bracket syntax works." +input: + allowed_paths: ["$DIR"] + script: |+ + [ -n "hello" ] && echo yes +expect: + stdout: "yes\n" + 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..14eeab04 --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml @@ -0,0 +1,9 @@ +description: "[ without closing ] exits with code 2." +input: + allowed_paths: ["$DIR"] + script: |+ + [ -n "hello" +expect: + stdout: "" + stderr_contains: ["[: missing"] + exit_code: 2 diff --git a/tests/scenarios/cmd/test/edge_cases/empty_word.yaml b/tests/scenarios/cmd/test/edge_cases/empty_word.yaml new file mode 100644 index 00000000..6abdd8a5 --- /dev/null +++ b/tests/scenarios/cmd/test/edge_cases/empty_word.yaml @@ -0,0 +1,9 @@ +description: test with a single empty word exits 1 (false). +input: + allowed_paths: ["$DIR"] + script: |+ + test "" && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/edge_cases/no_args.yaml b/tests/scenarios/cmd/test/edge_cases/no_args.yaml new file mode 100644 index 00000000..b0070473 --- /dev/null +++ b/tests/scenarios/cmd/test/edge_cases/no_args.yaml @@ -0,0 +1,9 @@ +description: test with no arguments exits 1 (false). +input: + allowed_paths: ["$DIR"] + script: |+ + test +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/cmd/test/edge_cases/single_word.yaml b/tests/scenarios/cmd/test/edge_cases/single_word.yaml new file mode 100644 index 00000000..deeb2991 --- /dev/null +++ b/tests/scenarios/cmd/test/edge_cases/single_word.yaml @@ -0,0 +1,9 @@ +description: test with a single non-empty word exits 0 (true). +input: + allowed_paths: ["$DIR"] + script: |+ + test hello && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/errors/missing_operand.yaml b/tests/scenarios/cmd/test/errors/missing_operand.yaml new file mode 100644 index 00000000..5699e005 --- /dev/null +++ b/tests/scenarios/cmd/test/errors/missing_operand.yaml @@ -0,0 +1,10 @@ +description: test with missing operand after binary operator exits 2. +skip_assert_against_bash: true +input: + allowed_paths: ["$DIR"] + script: |+ + test "a" = +expect: + stdout: "" + stderr: "test: missing argument after '='\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/test/file_tests/d_directory.yaml b/tests/scenarios/cmd/test/file_tests/d_directory.yaml new file mode 100644 index 00000000..807ca513 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/d_directory.yaml @@ -0,0 +1,13 @@ +description: test -d returns 0 for a directory. +setup: + files: + - path: subdir/placeholder.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + test -d subdir && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/e_exists.yaml b/tests/scenarios/cmd/test/file_tests/e_exists.yaml new file mode 100644 index 00000000..09466df2 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/e_exists.yaml @@ -0,0 +1,13 @@ +description: test -e returns 0 when file exists. +setup: + files: + - path: file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test -e file.txt && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/e_not_exists.yaml b/tests/scenarios/cmd/test/file_tests/e_not_exists.yaml new file mode 100644 index 00000000..a3a7cf70 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/e_not_exists.yaml @@ -0,0 +1,9 @@ +description: test -e returns 1 when file does not exist. +input: + allowed_paths: ["$DIR"] + script: |+ + test -e nonexistent && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/f_directory.yaml b/tests/scenarios/cmd/test/file_tests/f_directory.yaml new file mode 100644 index 00000000..c0d33336 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/f_directory.yaml @@ -0,0 +1,13 @@ +description: test -f returns 1 for a directory. +setup: + files: + - path: subdir/placeholder.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + test -f subdir && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/f_regular.yaml b/tests/scenarios/cmd/test/file_tests/f_regular.yaml new file mode 100644 index 00000000..37f9600e --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/f_regular.yaml @@ -0,0 +1,13 @@ +description: test -f returns 0 for a regular file. +setup: + files: + - path: file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test -f file.txt && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/s_empty.yaml b/tests/scenarios/cmd/test/file_tests/s_empty.yaml new file mode 100644 index 00000000..e97b7747 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/s_empty.yaml @@ -0,0 +1,13 @@ +description: test -s returns 1 for an empty file. +setup: + files: + - path: empty.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + test -s empty.txt && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/s_nonempty.yaml b/tests/scenarios/cmd/test/file_tests/s_nonempty.yaml new file mode 100644 index 00000000..981213bb --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/s_nonempty.yaml @@ -0,0 +1,13 @@ +description: test -s returns 0 for a file with non-zero size. +setup: + files: + - path: file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test -s file.txt && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/symlink.yaml b/tests/scenarios/cmd/test/file_tests/symlink.yaml new file mode 100644 index 00000000..15943168 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/symlink.yaml @@ -0,0 +1,15 @@ +description: test -L returns 0 for a symbolic link. +setup: + files: + - path: target.txt + content: "hello" + - path: link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + test -L link.txt && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/eq.yaml b/tests/scenarios/cmd/test/integer_tests/eq.yaml new file mode 100644 index 00000000..cb3a2c80 --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/eq.yaml @@ -0,0 +1,9 @@ +description: test -eq returns 0 when integers are equal. +input: + allowed_paths: ["$DIR"] + script: |+ + test 5 -eq 5 && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/ge.yaml b/tests/scenarios/cmd/test/integer_tests/ge.yaml new file mode 100644 index 00000000..5a280d7f --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/ge.yaml @@ -0,0 +1,9 @@ +description: test -ge returns 0 when first integer is greater than or equal. +input: + allowed_paths: ["$DIR"] + script: |+ + test 5 -ge 5 && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/gt.yaml b/tests/scenarios/cmd/test/integer_tests/gt.yaml new file mode 100644 index 00000000..0e643e90 --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/gt.yaml @@ -0,0 +1,9 @@ +description: test -gt returns 0 when first integer is greater than second. +input: + allowed_paths: ["$DIR"] + script: |+ + test 5 -gt 3 && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/le.yaml b/tests/scenarios/cmd/test/integer_tests/le.yaml new file mode 100644 index 00000000..90ad480b --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/le.yaml @@ -0,0 +1,9 @@ +description: test -le returns 0 when first integer is less than or equal. +input: + allowed_paths: ["$DIR"] + script: |+ + test 3 -le 3 && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/lt.yaml b/tests/scenarios/cmd/test/integer_tests/lt.yaml new file mode 100644 index 00000000..74f47370 --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/lt.yaml @@ -0,0 +1,9 @@ +description: test -lt returns 0 when first integer is less than second. +input: + allowed_paths: ["$DIR"] + script: |+ + test 3 -lt 5 && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/ne.yaml b/tests/scenarios/cmd/test/integer_tests/ne.yaml new file mode 100644 index 00000000..d97f96c0 --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/ne.yaml @@ -0,0 +1,9 @@ +description: test -ne returns 0 when integers are not equal. +input: + allowed_paths: ["$DIR"] + script: |+ + test 5 -ne 3 && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/and.yaml b/tests/scenarios/cmd/test/logic/and.yaml new file mode 100644 index 00000000..77947022 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/and.yaml @@ -0,0 +1,9 @@ +description: test -a evaluates both expressions. +input: + allowed_paths: ["$DIR"] + script: |+ + test -n "a" -a -n "b" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/not.yaml b/tests/scenarios/cmd/test/logic/not.yaml new file mode 100644 index 00000000..bc360fd4 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/not.yaml @@ -0,0 +1,9 @@ +description: test ! negates the expression. +input: + allowed_paths: ["$DIR"] + script: |+ + test ! -z "hello" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/or.yaml b/tests/scenarios/cmd/test/logic/or.yaml new file mode 100644 index 00000000..8ba077a4 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/or.yaml @@ -0,0 +1,9 @@ +description: test -o evaluates either expression. +input: + allowed_paths: ["$DIR"] + script: |+ + test -z "a" -o -n "b" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/parentheses.yaml b/tests/scenarios/cmd/test/logic/parentheses.yaml new file mode 100644 index 00000000..08e35654 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/parentheses.yaml @@ -0,0 +1,9 @@ +description: test with parentheses for grouping. +input: + allowed_paths: ["$DIR"] + script: |+ + test '(' -n "hello" ')' && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/string_tests/equal.yaml b/tests/scenarios/cmd/test/string_tests/equal.yaml new file mode 100644 index 00000000..4497d603 --- /dev/null +++ b/tests/scenarios/cmd/test/string_tests/equal.yaml @@ -0,0 +1,9 @@ +description: test string equality with = operator. +input: + allowed_paths: ["$DIR"] + script: |+ + test "abc" = "abc" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/string_tests/n_empty.yaml b/tests/scenarios/cmd/test/string_tests/n_empty.yaml new file mode 100644 index 00000000..30b8a693 --- /dev/null +++ b/tests/scenarios/cmd/test/string_tests/n_empty.yaml @@ -0,0 +1,9 @@ +description: test -n returns 1 for empty string. +input: + allowed_paths: ["$DIR"] + script: |+ + test -n "" && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/string_tests/n_nonempty.yaml b/tests/scenarios/cmd/test/string_tests/n_nonempty.yaml new file mode 100644 index 00000000..99c16e7f --- /dev/null +++ b/tests/scenarios/cmd/test/string_tests/n_nonempty.yaml @@ -0,0 +1,9 @@ +description: test -n returns 0 for non-empty string. +input: + allowed_paths: ["$DIR"] + script: |+ + test -n "hello" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/string_tests/not_equal.yaml b/tests/scenarios/cmd/test/string_tests/not_equal.yaml new file mode 100644 index 00000000..1e7e5e48 --- /dev/null +++ b/tests/scenarios/cmd/test/string_tests/not_equal.yaml @@ -0,0 +1,9 @@ +description: test string inequality with != operator. +input: + allowed_paths: ["$DIR"] + script: |+ + test "abc" != "xyz" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/string_tests/z_empty.yaml b/tests/scenarios/cmd/test/string_tests/z_empty.yaml new file mode 100644 index 00000000..ea343225 --- /dev/null +++ b/tests/scenarios/cmd/test/string_tests/z_empty.yaml @@ -0,0 +1,9 @@ +description: test -z returns 0 for empty string. +input: + allowed_paths: ["$DIR"] + script: |+ + test -z "" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/string_tests/z_nonempty.yaml b/tests/scenarios/cmd/test/string_tests/z_nonempty.yaml new file mode 100644 index 00000000..746a441e --- /dev/null +++ b/tests/scenarios/cmd/test/string_tests/z_nonempty.yaml @@ -0,0 +1,9 @@ +description: test -z returns 1 for non-empty string. +input: + allowed_paths: ["$DIR"] + script: |+ + test -z "hello" && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 From 3dac4190959b149ad67d3a405e8dcec37d42666a Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Mon, 9 Mar 2026 23:45:12 +0100 Subject: [PATCH 2/9] Move test builtin to sub-package to match project structure Co-Authored-By: Claude Opus 4.6 --- interp/builtins/{ => test}/test.go | 47 +++++++++++++++--------------- interp/runner_exec.go | 1 + 2 files changed, 25 insertions(+), 23 deletions(-) rename interp/builtins/{ => test}/test.go (87%) diff --git a/interp/builtins/test.go b/interp/builtins/test/test.go similarity index 87% rename from interp/builtins/test.go rename to interp/builtins/test/test.go index 57a0ccdd..0365e3dc 100644 --- a/interp/builtins/test.go +++ b/interp/builtins/test/test.go @@ -3,12 +3,11 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2026-present Datadog, Inc. -// Package builtins implements safe shell builtin commands. -// -// test / [ — evaluate conditional expressions +// Package test implements the test/[ builtin — evaluate conditional expressions. // // Usage: test EXPRESSION -// [ EXPRESSION ] +// +// [ EXPRESSION ] // // Evaluate a conditional expression EXPRESSION and exit with status 0 (true) // or 1 (false). With no arguments, test exits with status 1. @@ -57,58 +56,60 @@ // 0 Expression is true. // 1 Expression is false or missing. // 2 Syntax or usage error. -package builtins +package test import ( "context" "os" "strconv" + + "github.com/DataDog/rshell/interp/builtins" ) func init() { - register("test", builtinTestCmd) - register("[", builtinBracket) + builtins.Register("test", runTest) + builtins.Register("[", runBracket) } -func builtinTestCmd(ctx context.Context, callCtx *CallContext, args []string) Result { - return testRun(ctx, callCtx, args, false) +func runTest(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + return run(ctx, callCtx, args, false) } -func builtinBracket(ctx context.Context, callCtx *CallContext, args []string) Result { - return testRun(ctx, callCtx, args, true) +func runBracket(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + return run(ctx, callCtx, args, true) } -func testRun(ctx context.Context, callCtx *CallContext, args []string, isBracket bool) Result { +func run(ctx context.Context, callCtx *builtins.CallContext, args []string, isBracket bool) builtins.Result { name := "test" if isBracket { name = "[" if len(args) == 0 || args[len(args)-1] != "]" { callCtx.Errf("[: missing `]'\n") - return Result{Code: 2} + return builtins.Result{Code: 2} } args = args[:len(args)-1] } // No arguments: false. if len(args) == 0 { - return Result{Code: 1} + return builtins.Result{Code: 1} } p := &testParser{rem: args} expr := p.classicTest() if p.err != "" { callCtx.Errf("%s: %s\n", name, p.err) - return Result{Code: 2} + return builtins.Result{Code: 2} } if len(p.rem) > 0 { callCtx.Errf("%s: extra argument '%s'\n", name, p.rem[0]) - return Result{Code: 2} + return builtins.Result{Code: 2} } if evalTest(ctx, callCtx, expr) { - return Result{} + return builtins.Result{} } - return Result{Code: 1} + return builtins.Result{Code: 1} } // --- Expression AST --- @@ -259,7 +260,7 @@ func (p *testParser) testExprBase() testExpr { // --- Evaluator --- -func evalTest(ctx context.Context, callCtx *CallContext, expr testExpr) bool { +func evalTest(ctx context.Context, callCtx *builtins.CallContext, expr testExpr) bool { switch e := expr.(type) { case testWord: return e.val != "" @@ -273,7 +274,7 @@ func evalTest(ctx context.Context, callCtx *CallContext, expr testExpr) bool { return false } -func evalUnary(ctx context.Context, callCtx *CallContext, e testUnary) bool { +func evalUnary(ctx context.Context, callCtx *builtins.CallContext, e testUnary) bool { switch e.op { case "!": return !evalTest(ctx, callCtx, e.x) @@ -331,7 +332,7 @@ func evalFileStat(op string, fi os.FileInfo) bool { return false } -func evalBinary(ctx context.Context, callCtx *CallContext, e testBinary) bool { +func evalBinary(ctx context.Context, callCtx *builtins.CallContext, e testBinary) bool { switch e.op { case "-a": return evalTest(ctx, callCtx, e.x) && evalTest(ctx, callCtx, e.y) @@ -383,7 +384,7 @@ func evalIntCmp(op, a, b string) bool { return false } -func evalNt(ctx context.Context, callCtx *CallContext, a, b string) bool { +func evalNt(ctx context.Context, callCtx *builtins.CallContext, a, b string) bool { fiA, errA := callCtx.StatFile(ctx, a) fiB, errB := callCtx.StatFile(ctx, b) if errA != nil || errB != nil { @@ -392,7 +393,7 @@ func evalNt(ctx context.Context, callCtx *CallContext, a, b string) bool { return fiA.ModTime().After(fiB.ModTime()) } -func evalEf(ctx context.Context, callCtx *CallContext, a, b string) bool { +func evalEf(ctx context.Context, callCtx *builtins.CallContext, a, b string) bool { fiA, errA := callCtx.StatFile(ctx, a) fiB, errB := callCtx.StatFile(ctx, b) if errA != nil || errB != nil { diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 5861a9f2..1972e362 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -23,6 +23,7 @@ import ( _ "github.com/DataDog/rshell/interp/builtins/exit" _ "github.com/DataDog/rshell/interp/builtins/false" _ "github.com/DataDog/rshell/interp/builtins/head" + _ "github.com/DataDog/rshell/interp/builtins/test" _ "github.com/DataDog/rshell/interp/builtins/true" ) From d9202c7675e331803e50dc49b59fc2abe1e7055f Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Mon, 9 Mar 2026 23:59:54 +0100 Subject: [PATCH 3/9] Fix one-arg test semantics, integer error reporting, and migrate tests to scenarios Co-Authored-By: Claude Opus 4.6 --- interp/builtins/test/test.go | 123 ++++-- interp/builtins/test/test_test.go | 116 +++++ interp/builtins/tests/test/test_test.go | 408 ------------------ .../cmd/test/bracket/empty_bracket.yaml | 9 + .../cmd/test/edge_cases/and_or_chain.yaml | 13 + .../test/edge_cases/single_operator_word.yaml | 11 + .../cmd/test/edge_cases/with_variable.yaml | 9 + .../cmd/test/errors/extra_argument.yaml | 9 + .../cmd/test/errors/non_integer.yaml | 9 + .../cmd/test/file_tests/L_not_symlink.yaml | 13 + .../cmd/test/file_tests/h_symlink.yaml | 15 + .../cmd/test/file_tests/r_readable.yaml | 13 + .../cmd/test/file_tests/x_executable.yaml | 14 + .../cmd/test/integer_tests/eq_false.yaml | 9 + .../cmd/test/string_tests/double_equal.yaml | 9 + 15 files changed, 331 insertions(+), 449 deletions(-) create mode 100644 interp/builtins/test/test_test.go delete mode 100644 interp/builtins/tests/test/test_test.go create mode 100644 tests/scenarios/cmd/test/bracket/empty_bracket.yaml create mode 100644 tests/scenarios/cmd/test/edge_cases/and_or_chain.yaml create mode 100644 tests/scenarios/cmd/test/edge_cases/single_operator_word.yaml create mode 100644 tests/scenarios/cmd/test/edge_cases/with_variable.yaml create mode 100644 tests/scenarios/cmd/test/errors/extra_argument.yaml create mode 100644 tests/scenarios/cmd/test/errors/non_integer.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/L_not_symlink.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/h_symlink.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/r_readable.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/x_executable.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/eq_false.yaml create mode 100644 tests/scenarios/cmd/test/string_tests/double_equal.yaml diff --git a/interp/builtins/test/test.go b/interp/builtins/test/test.go index 0365e3dc..dcb35516 100644 --- a/interp/builtins/test/test.go +++ b/interp/builtins/test/test.go @@ -102,11 +102,15 @@ func run(ctx context.Context, callCtx *builtins.CallContext, args []string, isBr return builtins.Result{Code: 2} } if len(p.rem) > 0 { - callCtx.Errf("%s: extra argument '%s'\n", name, p.rem[0]) + callCtx.Errf("%s: too many arguments\n", name) return builtins.Result{Code: 2} } - if evalTest(ctx, callCtx, expr) { + ok, errCode := evalTest(ctx, callCtx, name, expr) + if errCode != 0 { + return builtins.Result{Code: errCode} + } + if ok { return builtins.Result{} } return builtins.Result{Code: 1} @@ -211,6 +215,14 @@ func (p *testParser) testExprBase() testExpr { s := p.peek() + // POSIX one-argument rule: when only one token remains, treat it as a + // plain string regardless of whether it looks like an operator. + // e.g. `test -n` → true (non-empty string), `test !` → true. + if len(p.rem) == 1 { + p.next() + return testWord{val: s} + } + // Negation. if s == "!" { p.next() @@ -260,56 +272,66 @@ func (p *testParser) testExprBase() testExpr { // --- Evaluator --- -func evalTest(ctx context.Context, callCtx *builtins.CallContext, expr testExpr) bool { +// evalTest evaluates a test expression. It returns (result, exitCode) where +// exitCode is non-zero only on evaluation errors (e.g. invalid integer). +func evalTest(ctx context.Context, callCtx *builtins.CallContext, name string, expr testExpr) (bool, uint8) { switch e := expr.(type) { case testWord: - return e.val != "" + return e.val != "", 0 case testUnary: - return evalUnary(ctx, callCtx, e) + return evalUnary(ctx, callCtx, name, e) case testBinary: - return evalBinary(ctx, callCtx, e) + return evalBinary(ctx, callCtx, name, e) case testParen: - return evalTest(ctx, callCtx, e.x) + return evalTest(ctx, callCtx, name, e.x) } - return false + return false, 0 } -func evalUnary(ctx context.Context, callCtx *builtins.CallContext, e testUnary) bool { +func evalUnary(ctx context.Context, callCtx *builtins.CallContext, name string, e testUnary) (bool, uint8) { switch e.op { case "!": - return !evalTest(ctx, callCtx, e.x) + ok, code := evalTest(ctx, callCtx, name, e.x) + if code != 0 { + return false, code + } + return !ok, 0 case "-n": if w, ok := e.x.(testWord); ok { - return w.val != "" + return w.val != "", 0 } - return evalTest(ctx, callCtx, e.x) + return evalTest(ctx, callCtx, name, e.x) case "-z": if w, ok := e.x.(testWord); ok { - return w.val == "" + return w.val == "", 0 + } + ok, code := evalTest(ctx, callCtx, name, e.x) + if code != 0 { + return false, code } - return !evalTest(ctx, callCtx, e.x) + return !ok, 0 case "-e", "-f", "-d", "-s", "-r", "-w", "-x": w, ok := e.x.(testWord) if !ok { - return false + return false, 0 } fi, err := callCtx.StatFile(ctx, w.val) if err != nil { - return false + return false, 0 } - return evalFileStat(e.op, fi) + return evalFileStat(e.op, fi), 0 case "-L", "-h": w, ok := e.x.(testWord) if !ok { - return false + return false, 0 } fi, err := callCtx.LstatFile(ctx, w.val) if err != nil { - return false + return false, 0 } - return fi.Mode()&os.ModeSymlink != 0 + return fi.Mode()&os.ModeSymlink != 0, 0 } - return false + return false, 0 } func evalFileStat(op string, fi os.FileInfo) bool { @@ -332,26 +354,40 @@ func evalFileStat(op string, fi os.FileInfo) bool { return false } -func evalBinary(ctx context.Context, callCtx *builtins.CallContext, e testBinary) bool { +func evalBinary(ctx context.Context, callCtx *builtins.CallContext, name string, e testBinary) (bool, uint8) { switch e.op { case "-a": - return evalTest(ctx, callCtx, e.x) && evalTest(ctx, callCtx, e.y) + ok, code := evalTest(ctx, callCtx, name, e.x) + if code != 0 { + return false, code + } + if !ok { + return false, 0 + } + return evalTest(ctx, callCtx, name, e.y) case "-o": - return evalTest(ctx, callCtx, e.x) || evalTest(ctx, callCtx, e.y) + ok, code := evalTest(ctx, callCtx, name, e.x) + if code != 0 { + return false, code + } + if ok { + return true, 0 + } + return evalTest(ctx, callCtx, name, e.y) case "=", "==": - return wordVal(e.x) == wordVal(e.y) + return wordVal(e.x) == wordVal(e.y), 0 case "!=": - return wordVal(e.x) != wordVal(e.y) + return wordVal(e.x) != wordVal(e.y), 0 case "-eq", "-ne", "-lt", "-gt", "-le", "-ge": - return evalIntCmp(e.op, wordVal(e.x), wordVal(e.y)) + return evalIntCmp(callCtx, name, e.op, wordVal(e.x), wordVal(e.y)) case "-nt": - return evalNt(ctx, callCtx, wordVal(e.x), wordVal(e.y)) + return evalNt(ctx, callCtx, wordVal(e.x), wordVal(e.y)), 0 case "-ot": - return evalNt(ctx, callCtx, wordVal(e.y), wordVal(e.x)) + return evalNt(ctx, callCtx, wordVal(e.y), wordVal(e.x)), 0 case "-ef": - return evalEf(ctx, callCtx, wordVal(e.x), wordVal(e.y)) + return evalEf(ctx, callCtx, wordVal(e.x), wordVal(e.y)), 0 } - return false + return false, 0 } func wordVal(e testExpr) string { @@ -361,27 +397,32 @@ func wordVal(e testExpr) string { return "" } -func evalIntCmp(op, a, b string) bool { +func evalIntCmp(callCtx *builtins.CallContext, name, op, a, b string) (bool, uint8) { ai, errA := strconv.ParseInt(a, 10, 64) + if errA != nil { + callCtx.Errf("%s: %s: integer expression expected\n", name, a) + return false, 2 + } bi, errB := strconv.ParseInt(b, 10, 64) - if errA != nil || errB != nil { - return false + if errB != nil { + callCtx.Errf("%s: %s: integer expression expected\n", name, b) + return false, 2 } switch op { case "-eq": - return ai == bi + return ai == bi, 0 case "-ne": - return ai != bi + return ai != bi, 0 case "-lt": - return ai < bi + return ai < bi, 0 case "-gt": - return ai > bi + return ai > bi, 0 case "-le": - return ai <= bi + return ai <= bi, 0 case "-ge": - return ai >= bi + return ai >= bi, 0 } - return false + return false, 0 } func evalNt(ctx context.Context, callCtx *builtins.CallContext, a, b string) bool { diff --git a/interp/builtins/test/test_test.go b/interp/builtins/test/test_test.go new file mode 100644 index 00000000..78c53c42 --- /dev/null +++ b/interp/builtins/test/test_test.go @@ -0,0 +1,116 @@ +// 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 test_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/interp" +) + +// runScript runs a shell script and returns stdout, stderr, and the exit code. +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(script), "") + require.NoError(t, err) + + var outBuf, errBuf bytes.Buffer + allOpts := append([]interp.RunnerOption{interp.StdIO(nil, &outBuf, &errBuf)}, opts...) + runner, err := interp.New(allOpts...) + require.NoError(t, err) + defer runner.Close() + + if dir != "" { + runner.Dir = dir + } + + err = runner.Run(context.Background(), prog) + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected error: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +// cmdRun runs a test command with AllowedPaths set to dir. +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +// writeFile creates a file in dir with the given content. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) +} + +// Tests below require Go-specific setup that cannot be expressed in YAML scenarios. +// All other test builtin tests live in tests/scenarios/cmd/test/. + +func TestTestFileNewerThan(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "old.txt", "old") + past := time.Now().Add(-2 * time.Second) + require.NoError(t, os.Chtimes(filepath.Join(dir, "old.txt"), past, past)) + writeFile(t, dir, "new.txt", "new") + stdout, _, code := cmdRun(t, `test new.txt -nt old.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileOlderThan(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "old.txt", "old") + past := time.Now().Add(-2 * time.Second) + require.NoError(t, os.Chtimes(filepath.Join(dir, "old.txt"), past, past)) + writeFile(t, dir, "new.txt", "new") + stdout, _, code := cmdRun(t, `test old.txt -ot new.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileSameFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "hello") + stdout, _, code := cmdRun(t, `test file.txt -ef file.txt && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestFileDifferentFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "hello") + writeFile(t, dir, "b.txt", "hello") + _, _, code := cmdRun(t, `test a.txt -ef b.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestOutsideAllowedPaths(t *testing.T) { + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret"), 0644)) + secretPath := strings.ReplaceAll(filepath.Join(secret, "secret.txt"), `\`, `/`) + // test -e should return false for files outside allowed paths. + _, _, code := runScript(t, "test -e "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) +} diff --git a/interp/builtins/tests/test/test_test.go b/interp/builtins/tests/test/test_test.go deleted file mode 100644 index 6301a409..00000000 --- a/interp/builtins/tests/test/test_test.go +++ /dev/null @@ -1,408 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed -// under the Apache License Version 2.0. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2026-present Datadog, Inc. - -package test_test - -import ( - "bytes" - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "mvdan.cc/sh/v3/syntax" - - "github.com/DataDog/rshell/interp" -) - -// runScript runs a shell script and returns stdout, stderr, and the exit code. -func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { - t.Helper() - parser := syntax.NewParser() - prog, err := parser.Parse(strings.NewReader(script), "") - require.NoError(t, err) - - var outBuf, errBuf bytes.Buffer - allOpts := append([]interp.RunnerOption{interp.StdIO(nil, &outBuf, &errBuf)}, opts...) - runner, err := interp.New(allOpts...) - require.NoError(t, err) - defer runner.Close() - - if dir != "" { - runner.Dir = dir - } - - err = runner.Run(context.Background(), prog) - exitCode := 0 - if err != nil { - var es interp.ExitStatus - if errors.As(err, &es) { - exitCode = int(es) - } else { - t.Fatalf("unexpected error: %v", err) - } - } - return outBuf.String(), errBuf.String(), exitCode -} - -// cmdRun runs a test command with AllowedPaths set to dir. -func cmdRun(t *testing.T, script, dir string) (string, string, int) { - t.Helper() - return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) -} - -// writeFile creates a file in dir with the given content. -func writeFile(t *testing.T, dir, name, content string) { - t.Helper() - require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) -} - -// --- String tests --- - -func TestTestStringNonEmpty(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test -n "hello" && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestStringEmpty(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test -n "" && echo yes || echo no`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "no\n", stdout) -} - -func TestTestStringZeroEmpty(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test -z "" && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestStringZeroNonEmpty(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test -z "hello" && echo yes || echo no`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "no\n", stdout) -} - -func TestTestStringEqual(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test "abc" = "abc" && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestStringDoubleEqual(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test "abc" == "abc" && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestStringNotEqual(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test "abc" != "xyz" && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -// --- Integer tests --- - -func TestTestIntEq(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test 5 -eq 5 && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestIntNe(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test 5 -ne 3 && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestIntLt(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test 3 -lt 5 && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestIntGt(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test 5 -gt 3 && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestIntLe(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test 3 -le 3 && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestIntGe(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test 5 -ge 5 && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestIntEqFalse(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test 5 -eq 3`, dir) - assert.Equal(t, 1, code) -} - -func TestTestIntNonNumeric(t *testing.T) { - dir := t.TempDir() - // Non-numeric comparisons fail (return false/1 not error). - _, _, code := cmdRun(t, `test abc -eq 3`, dir) - assert.Equal(t, 1, code) -} - -// --- File tests --- - -func TestTestFileExists(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "hello") - stdout, _, code := cmdRun(t, `test -e file.txt && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileNotExists(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test -e nonexistent`, dir) - assert.Equal(t, 1, code) -} - -func TestTestFileRegular(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "hello") - stdout, _, code := cmdRun(t, `test -f file.txt && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileDirectory(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) - stdout, _, code := cmdRun(t, `test -d subdir && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileNonEmptySize(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "hello") - stdout, _, code := cmdRun(t, `test -s file.txt && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileEmptySize(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, "file.txt", "hello") - stdout, _, code := cmdRun(t, `test -r file.txt && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileExecutable(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "script.sh", "#!/bin/sh") - require.NoError(t, os.Chmod(filepath.Join(dir, "script.sh"), 0755)) - stdout, _, code := cmdRun(t, `test -x script.sh && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileSymlink(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "target.txt", "hello") - require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) - stdout, _, code := cmdRun(t, `test -L link.txt && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileSymlinkH(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "target.txt", "hello") - require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) - stdout, _, code := cmdRun(t, `test -h link.txt && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileNotSymlink(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "regular.txt", "hello") - _, _, code := cmdRun(t, `test -L regular.txt`, dir) - assert.Equal(t, 1, code) -} - -// --- File comparisons --- - -func TestTestFileNewerThan(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "old.txt", "old") - // Ensure different mod times. - past := time.Now().Add(-2 * time.Second) - require.NoError(t, os.Chtimes(filepath.Join(dir, "old.txt"), past, past)) - writeFile(t, dir, "new.txt", "new") - stdout, _, code := cmdRun(t, `test new.txt -nt old.txt && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileOlderThan(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "old.txt", "old") - past := time.Now().Add(-2 * time.Second) - require.NoError(t, os.Chtimes(filepath.Join(dir, "old.txt"), past, past)) - writeFile(t, dir, "new.txt", "new") - stdout, _, code := cmdRun(t, `test old.txt -ot new.txt && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileSameFile(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "hello") - stdout, _, code := cmdRun(t, `test file.txt -ef file.txt && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestFileDifferentFiles(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "a.txt", "hello") - writeFile(t, dir, "b.txt", "hello") - _, _, code := cmdRun(t, `test a.txt -ef b.txt`, dir) - assert.Equal(t, 1, code) -} - -// --- Logic --- - -func TestTestNot(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test ! -z "hello" && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestAnd(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test -n "a" -a -n "b" && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestOr(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test -z "a" -o -n "b" && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -// --- Bracket syntax --- - -func TestBracketBasic(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `[ -n "hello" ] && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestBracketMissingClosing(t *testing.T) { - dir := t.TempDir() - _, stderr, code := cmdRun(t, `[ -n "hello"`, dir) - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "[: missing `]'") -} - -func TestBracketEmpty(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `[ ]`, dir) - assert.Equal(t, 1, code) -} - -// --- Edge cases --- - -func TestTestNoArgs(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test`, dir) - assert.Equal(t, 1, code) -} - -func TestTestSingleWord(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `test hello && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestEmptyString(t *testing.T) { - dir := t.TempDir() - _, _, code := cmdRun(t, `test ""`, dir) - assert.Equal(t, 1, code) -} - -func TestTestMissingOperand(t *testing.T) { - dir := t.TempDir() - _, stderr, code := cmdRun(t, `test "a" =`, dir) - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "test:") -} - -func TestTestExtraArgument(t *testing.T) { - dir := t.TempDir() - _, stderr, code := cmdRun(t, `test "a" = "b" extra`, dir) - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "test:") -} - -func TestTestWithVariable(t *testing.T) { - dir := t.TempDir() - stdout, _, code := cmdRun(t, `X=hello; test -n "$X" && echo yes`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "yes\n", stdout) -} - -func TestTestWithAndOr(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "file.txt", "hello") - stdout, _, code := cmdRun(t, `test -f file.txt && echo exists || echo missing`, dir) - assert.Equal(t, 0, code) - assert.Equal(t, "exists\n", stdout) -} - -func TestTestOutsideAllowedPaths(t *testing.T) { - allowed := t.TempDir() - secret := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret"), 0644)) - secretPath := strings.ReplaceAll(filepath.Join(secret, "secret.txt"), `\`, `/`) - // test -e should return false for files outside allowed paths. - _, _, code := runScript(t, "test -e "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) - assert.Equal(t, 1, code) -} diff --git a/tests/scenarios/cmd/test/bracket/empty_bracket.yaml b/tests/scenarios/cmd/test/bracket/empty_bracket.yaml new file mode 100644 index 00000000..be4fd08d --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/empty_bracket.yaml @@ -0,0 +1,9 @@ +description: empty bracket test [ ] returns 1 (false). +input: + allowed_paths: ["$DIR"] + script: |+ + [ ] +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/cmd/test/edge_cases/and_or_chain.yaml b/tests/scenarios/cmd/test/edge_cases/and_or_chain.yaml new file mode 100644 index 00000000..df17b04a --- /dev/null +++ b/tests/scenarios/cmd/test/edge_cases/and_or_chain.yaml @@ -0,0 +1,13 @@ +description: test combined with && and || for control flow. +setup: + files: + - path: file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test -f file.txt && echo exists || echo missing +expect: + stdout: "exists\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/edge_cases/single_operator_word.yaml b/tests/scenarios/cmd/test/edge_cases/single_operator_word.yaml new file mode 100644 index 00000000..acc0f14f --- /dev/null +++ b/tests/scenarios/cmd/test/edge_cases/single_operator_word.yaml @@ -0,0 +1,11 @@ +description: test with a single operator-like word treats it as a non-empty string (POSIX one-argument rule). +input: + allowed_paths: ["$DIR"] + script: |+ + test -n && echo yes + test ! && echo bang + test -e && echo exists +expect: + stdout: "yes\nbang\nexists\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/edge_cases/with_variable.yaml b/tests/scenarios/cmd/test/edge_cases/with_variable.yaml new file mode 100644 index 00000000..580e38fd --- /dev/null +++ b/tests/scenarios/cmd/test/edge_cases/with_variable.yaml @@ -0,0 +1,9 @@ +description: test works with shell variable expansion. +input: + allowed_paths: ["$DIR"] + script: |+ + X=hello; test -n "$X" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/errors/extra_argument.yaml b/tests/scenarios/cmd/test/errors/extra_argument.yaml new file mode 100644 index 00000000..7eb3b740 --- /dev/null +++ b/tests/scenarios/cmd/test/errors/extra_argument.yaml @@ -0,0 +1,9 @@ +description: test with extra arguments reports an error and exits 2. +input: + allowed_paths: ["$DIR"] + script: |+ + test "a" = "b" extra +expect: + stdout: "" + stderr_contains: ["too many arguments"] + exit_code: 2 diff --git a/tests/scenarios/cmd/test/errors/non_integer.yaml b/tests/scenarios/cmd/test/errors/non_integer.yaml new file mode 100644 index 00000000..f4822d17 --- /dev/null +++ b/tests/scenarios/cmd/test/errors/non_integer.yaml @@ -0,0 +1,9 @@ +description: test with non-integer operands for arithmetic operators reports an error and exits 2. +input: + allowed_paths: ["$DIR"] + script: |+ + test abc -eq 3 +expect: + stdout: "" + stderr_contains: ["integer expression expected"] + exit_code: 2 diff --git a/tests/scenarios/cmd/test/file_tests/L_not_symlink.yaml b/tests/scenarios/cmd/test/file_tests/L_not_symlink.yaml new file mode 100644 index 00000000..c88574c1 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/L_not_symlink.yaml @@ -0,0 +1,13 @@ +description: test -L returns 1 for a regular file (not a symlink). +setup: + files: + - path: regular.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test -L regular.txt +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/cmd/test/file_tests/h_symlink.yaml b/tests/scenarios/cmd/test/file_tests/h_symlink.yaml new file mode 100644 index 00000000..37b819bd --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/h_symlink.yaml @@ -0,0 +1,15 @@ +description: test -h returns 0 for a symbolic link. +setup: + files: + - path: target.txt + content: "hello" + - path: link.txt + symlink: target.txt +input: + allowed_paths: ["$DIR"] + script: |+ + test -h link.txt && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/r_readable.yaml b/tests/scenarios/cmd/test/file_tests/r_readable.yaml new file mode 100644 index 00000000..53d6aa82 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/r_readable.yaml @@ -0,0 +1,13 @@ +description: test -r returns 0 for a readable file. +setup: + files: + - path: file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test -r file.txt && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/x_executable.yaml b/tests/scenarios/cmd/test/file_tests/x_executable.yaml new file mode 100644 index 00000000..90ced419 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/x_executable.yaml @@ -0,0 +1,14 @@ +description: test -x returns 0 for an executable file. +setup: + files: + - path: script.sh + content: "#!/bin/sh" + chmod: 0755 +input: + allowed_paths: ["$DIR"] + script: |+ + test -x script.sh && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/eq_false.yaml b/tests/scenarios/cmd/test/integer_tests/eq_false.yaml new file mode 100644 index 00000000..6671018d --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/eq_false.yaml @@ -0,0 +1,9 @@ +description: test -eq returns 1 when integers are not equal. +input: + allowed_paths: ["$DIR"] + script: |+ + test 5 -eq 3 +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/cmd/test/string_tests/double_equal.yaml b/tests/scenarios/cmd/test/string_tests/double_equal.yaml new file mode 100644 index 00000000..1beb2485 --- /dev/null +++ b/tests/scenarios/cmd/test/string_tests/double_equal.yaml @@ -0,0 +1,9 @@ +description: test == operator compares strings for equality. +input: + allowed_paths: ["$DIR"] + script: |+ + test "abc" == "abc" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 From 4934a45c29e358399cbfb6b8a9286d9be2da4ecc Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 00:01:39 +0100 Subject: [PATCH 4/9] Add guidance to prefer bash compatibility before skipping bash assertions Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 14c3e257..2b07d4cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,9 @@ The shell is supported on Linux, Windows and macOS. ``` RSHELL_BASH_TEST=1 go test ./tests/ -run TestShellScenariosAgainstBash -timeout 120s ``` - The test suite runs all scenarios against `debian:bookworm-slim` (GNU bash + GNU coreutils) and compares output byte-for-byte. Only set `skip_assert_against_bash: true` in a scenario when the behavior intentionally diverges from bash (e.g. sandbox restrictions, blocked commands). + The test suite runs all scenarios against `debian:bookworm-slim` (GNU bash + GNU coreutils) and compares output byte-for-byte. + Only set `skip_assert_against_bash: true` in a scenario when the behavior intentionally diverges from bash (e.g. sandbox restrictions, blocked commands). + Consider making the rshell implementation compatible with bash before setting `skip_assert_against_bash: true`. - In test scenarios, use `expect.stderr` when possible instead of `stderr_contains`. - Test scenarios are asserted against bash by default. Only set `skip_assert_against_bash: true` for features that intentionally diverge from standard bash behavior (e.g. blocked commands, restricted redirects, readonly enforcement). From 85e178865b1d6f5a9042f07de8942c494b158b5c Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 00:06:56 +0100 Subject: [PATCH 5/9] Merge SHELL_COMMANDS.md into SHELL_FEATURES.md builtins table Co-Authored-By: Claude Opus 4.6 --- .../skills/implement-posix-command/SKILL.md | 2 +- SHELL_COMMANDS.md | 15 ------------- SHELL_FEATURES.md | 21 ++++++++++++------- 3 files changed, 14 insertions(+), 24 deletions(-) delete mode 100644 SHELL_COMMANDS.md diff --git a/.claude/skills/implement-posix-command/SKILL.md b/.claude/skills/implement-posix-command/SKILL.md index f4e57406..48de7277 100644 --- a/.claude/skills/implement-posix-command/SKILL.md +++ b/.claude/skills/implement-posix-command/SKILL.md @@ -494,7 +494,7 @@ For any case where behaviour differs from expectation, run the equivalent `gtail **GATE CHECK**: Call TaskList. Step 8 must be `completed` before starting this step. Set this step to `in_progress` now. -Update `SHELL_COMMANDS.md` in the repository root. Add a row for the new command to the reference table, following the existing format: +Update `SHELL_FEATURES.md` in the repository root. Add a row for the new command to the reference table, following the existing format: ``` | `$ARGUMENTS [FILE ...]` | `-x X` (desc), `-y` (desc) | One-sentence description of what the command does. | diff --git a/SHELL_COMMANDS.md b/SHELL_COMMANDS.md deleted file mode 100644 index 534e0e78..00000000 --- a/SHELL_COMMANDS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Shell Commands - -Short reference for builtin commands - -| Command | Options | Short description | -| --- | --- | --- | -| `true` | none | Exit with status `0`. | -| `false` | none | Exit with status `1`. | -| `echo [ARG ...]` | none | Print arguments separated by spaces, then newline. | -| `cat [FILE ...]` | `-` (read stdin) | Print files; with no args, read stdin. | -| `head [FILE ...]` | `-n N` (lines), `-c N` (bytes), `-q`/`--quiet`/`--silent` (no headers), `-v` (force headers) | Print first 10 lines of each FILE; with no FILE or `-`, read stdin. | -| `test EXPR` / `[ EXPR ]` | `-e`/`-f`/`-d`/`-s`/`-r`/`-w`/`-x`/`-L` (file tests), `-n`/`-z`/`=`/`!=` (strings), `-eq`/`-ne`/`-lt`/`-gt`/`-le`/`-ge` (integers), `-nt`/`-ot`/`-ef` (file comparison), `!`/`-a`/`-o` (logic) | Evaluate conditional expression; exit 0 (true) or 1 (false). | -| `exit [N]` | `N` (status code) | Exit the shell with `N` (default: last status). | -| `break [N]` | `N` (loop levels) | Break current loop, or `N` enclosing loops. | -| `continue [N]` | `N` (loop levels) | Continue current loop, or `N` enclosing loops. | diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index f93a0bad..0cbac9db 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -5,14 +5,19 @@ Blocked features are rejected before execution with exit code 2. ## Builtins -- ✅ `echo` — prints arguments separated by spaces, followed by a newline -- ✅ `cat` — reads files or stdin (`-`); respects AllowedPaths -- ✅ `true` — exits with code 0 -- ✅ `false` — exits with code 1 -- ✅ `exit [N]` — exits with code N (default: last exit code) -- ✅ `test EXPR` / `[ EXPR ]` — evaluate conditional expressions (file tests, string/integer comparisons, logical operators) -- ✅ `break [N]` / `continue [N]` — loop control -- ❌ All other commands — return exit code 127 with `: not found` unless an ExecHandler is configured +| Command | Options | Short description | +| --- | --- | --- | +| `true` | none | Exit with status `0`. | +| `false` | none | Exit with status `1`. | +| `echo [ARG ...]` | none | Print arguments separated by spaces, then newline. | +| `cat [FILE ...]` | `-` (read stdin) | Print files; with no args, read stdin. | +| `head [FILE ...]` | `-n N` (lines), `-c N` (bytes), `-q`/`--quiet`/`--silent` (no headers), `-v` (force headers) | Print first 10 lines of each FILE; with no FILE or `-`, read stdin. | +| `test EXPR` / `[ EXPR ]` | `-e`/`-f`/`-d`/`-s`/`-r`/`-w`/`-x`/`-L` (file tests), `-n`/`-z`/`=`/`!=` (strings), `-eq`/`-ne`/`-lt`/`-gt`/`-le`/`-ge` (integers), `-nt`/`-ot`/`-ef` (file comparison), `!`/`-a`/`-o` (logic) | Evaluate conditional expression; exit 0 (true) or 1 (false). | +| `exit [N]` | `N` (status code) | Exit the shell with `N` (default: last status). | +| `break [N]` | `N` (loop levels) | Break current loop, or `N` enclosing loops. | +| `continue [N]` | `N` (loop levels) | Continue current loop, or `N` enclosing loops. | + +All other commands return exit code 127 with `: not found` unless an ExecHandler is configured. ## Variables From 9e6a4911cd58fac8c1a1da40f9dd49a4885879a6 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 00:15:36 +0100 Subject: [PATCH 6/9] Fix test -x on Windows with platform-specific executable check Co-Authored-By: Claude Opus 4.6 --- interp/builtins/test/executable_unix.go | 10 ++++++++++ interp/builtins/test/executable_windows.go | 11 +++++++++++ interp/builtins/test/test.go | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 interp/builtins/test/executable_unix.go create mode 100644 interp/builtins/test/executable_windows.go diff --git a/interp/builtins/test/executable_unix.go b/interp/builtins/test/executable_unix.go new file mode 100644 index 00000000..a894d158 --- /dev/null +++ b/interp/builtins/test/executable_unix.go @@ -0,0 +1,10 @@ +//go:build !windows + +package test + +import "os" + +// isExecutable reports whether the file described by fi has the execute permission bit set. +func isExecutable(fi os.FileInfo) bool { + return fi.Mode().Perm()&0111 != 0 +} diff --git a/interp/builtins/test/executable_windows.go b/interp/builtins/test/executable_windows.go new file mode 100644 index 00000000..1ea0b55b --- /dev/null +++ b/interp/builtins/test/executable_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package test + +import "os" + +// isExecutable reports whether the file described by fi is considered executable on Windows. +// Windows does not have Unix permission bits, so all existing files are treated as executable. +func isExecutable(_ os.FileInfo) bool { + return true +} diff --git a/interp/builtins/test/test.go b/interp/builtins/test/test.go index dcb35516..62275018 100644 --- a/interp/builtins/test/test.go +++ b/interp/builtins/test/test.go @@ -349,7 +349,7 @@ func evalFileStat(op string, fi os.FileInfo) bool { case "-w": return fi.Mode().Perm()&0222 != 0 case "-x": - return fi.Mode().Perm()&0111 != 0 + return isExecutable(fi) } return false } From 144a05629258fbef06f5070e475a4bf775957082 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 00:24:46 +0100 Subject: [PATCH 7/9] Add Windows extension-based test -x, exit_code_windows support, and test scenarios Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 1 + interp/builtins/test/executable_windows.go | 24 +++++++++++++++---- tests/import_allowlist_test.go | 2 ++ .../cmd/test/file_tests/x_bat_extension.yaml | 14 +++++++++++ .../cmd/test/file_tests/x_cmd_extension.yaml | 14 +++++++++++ .../cmd/test/file_tests/x_com_extension.yaml | 14 +++++++++++ .../cmd/test/file_tests/x_exe_extension.yaml | 14 +++++++++++ .../cmd/test/file_tests/x_executable.yaml | 4 ++-- .../cmd/test/file_tests/x_not_executable.yaml | 14 +++++++++++ .../x_sh_not_executable_windows.yaml | 15 ++++++++++++ tests/scenarios_test.go | 7 +++++- 11 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 tests/scenarios/cmd/test/file_tests/x_bat_extension.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/x_cmd_extension.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/x_com_extension.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/x_exe_extension.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/x_not_executable.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/x_sh_not_executable_windows.yaml diff --git a/AGENTS.md b/AGENTS.md index 2b07d4cd..7eec0ce0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,4 +30,5 @@ The shell is supported on Linux, Windows and macOS. - When expected output differs on Windows (e.g. path separators `\` vs `/`), use Windows-specific assertion fields: - `stdout_windows` / `stderr_windows` — override `stdout` / `stderr` on Windows. - `stdout_contains_windows` / `stderr_contains_windows` — override `stdout_contains` / `stderr_contains` on Windows. + - `exit_code_windows` — override `exit_code` on Windows. - If the Windows field is not set, the non-Windows field is used as fallback. diff --git a/interp/builtins/test/executable_windows.go b/interp/builtins/test/executable_windows.go index 1ea0b55b..5583e6b8 100644 --- a/interp/builtins/test/executable_windows.go +++ b/interp/builtins/test/executable_windows.go @@ -2,10 +2,26 @@ package test -import "os" +import ( + "os" + "path/filepath" + "strings" +) + +// windowsExeExts lists extensions that Windows considers executable. +var windowsExeExts = map[string]bool{ + ".exe": true, ".cmd": true, ".bat": true, ".com": true, +} // isExecutable reports whether the file described by fi is considered executable on Windows. -// Windows does not have Unix permission bits, so all existing files are treated as executable. -func isExecutable(_ os.FileInfo) bool { - return true +// Windows does not have Unix permission bits; instead, executability is determined by file extension. +// If the file has no recognizable executable extension, it is conservatively treated as executable +// (matching the behavior of test -x on most Windows environments). +func isExecutable(fi os.FileInfo) bool { + ext := strings.ToLower(filepath.Ext(fi.Name())) + if ext == "" { + // No extension — treat as executable (consistent with Unix behavior for chmod +x files). + return true + } + return windowsExeExts[ext] } diff --git a/tests/import_allowlist_test.go b/tests/import_allowlist_test.go index 8c70159e..d488a939 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -43,8 +43,10 @@ var builtinAllowedSymbols = []string{ "os.ModeSymlink", "os.O_RDONLY", "os.SameFile", + "path/filepath.Ext", "strconv.Atoi", "strconv.ParseInt", + "strings.ToLower", } // permanentlyBanned lists packages that may never be imported by builtin diff --git a/tests/scenarios/cmd/test/file_tests/x_bat_extension.yaml b/tests/scenarios/cmd/test/file_tests/x_bat_extension.yaml new file mode 100644 index 00000000..4f57629a --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/x_bat_extension.yaml @@ -0,0 +1,14 @@ +description: test -x returns 0 for a .bat file on Windows. +setup: + files: + - path: run.bat + content: "@echo off" + chmod: 0755 +input: + allowed_paths: ["$DIR"] + script: |+ + test -x run.bat && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/x_cmd_extension.yaml b/tests/scenarios/cmd/test/file_tests/x_cmd_extension.yaml new file mode 100644 index 00000000..d458ad4c --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/x_cmd_extension.yaml @@ -0,0 +1,14 @@ +description: test -x returns 0 for a .cmd file on Windows. +setup: + files: + - path: run.cmd + content: "@echo off" + chmod: 0755 +input: + allowed_paths: ["$DIR"] + script: |+ + test -x run.cmd && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/x_com_extension.yaml b/tests/scenarios/cmd/test/file_tests/x_com_extension.yaml new file mode 100644 index 00000000..4240b0b4 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/x_com_extension.yaml @@ -0,0 +1,14 @@ +description: test -x returns 0 for a .com file on Windows. +setup: + files: + - path: run.com + content: "binary" + chmod: 0755 +input: + allowed_paths: ["$DIR"] + script: |+ + test -x run.com && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/x_exe_extension.yaml b/tests/scenarios/cmd/test/file_tests/x_exe_extension.yaml new file mode 100644 index 00000000..674b83d8 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/x_exe_extension.yaml @@ -0,0 +1,14 @@ +description: test -x returns 0 for a .exe file on Windows (extension-based check). +setup: + files: + - path: program.exe + content: "binary" + chmod: 0755 +input: + allowed_paths: ["$DIR"] + script: |+ + test -x program.exe && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/x_executable.yaml b/tests/scenarios/cmd/test/file_tests/x_executable.yaml index 90ced419..b7414c1a 100644 --- a/tests/scenarios/cmd/test/file_tests/x_executable.yaml +++ b/tests/scenarios/cmd/test/file_tests/x_executable.yaml @@ -1,13 +1,13 @@ description: test -x returns 0 for an executable file. setup: files: - - path: script.sh + - path: script content: "#!/bin/sh" chmod: 0755 input: allowed_paths: ["$DIR"] script: |+ - test -x script.sh && echo yes + test -x script && echo yes expect: stdout: "yes\n" stderr: "" diff --git a/tests/scenarios/cmd/test/file_tests/x_not_executable.yaml b/tests/scenarios/cmd/test/file_tests/x_not_executable.yaml new file mode 100644 index 00000000..6d94bc11 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/x_not_executable.yaml @@ -0,0 +1,14 @@ +description: test -x returns 1 for a non-executable file. +skip_assert_against_bash: true +setup: + files: + - path: data.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test -x data.txt && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/x_sh_not_executable_windows.yaml b/tests/scenarios/cmd/test/file_tests/x_sh_not_executable_windows.yaml new file mode 100644 index 00000000..e5078978 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/x_sh_not_executable_windows.yaml @@ -0,0 +1,15 @@ +description: test -x returns 1 for a .sh file on Windows (not a Windows executable extension). +setup: + files: + - path: script.sh + content: "#!/bin/sh" + chmod: 0755 +input: + allowed_paths: ["$DIR"] + script: |+ + test -x script.sh && echo yes || echo no +expect: + stdout: "yes\n" + stdout_windows: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios_test.go b/tests/scenarios_test.go index e3240c32..e5cc5dd4 100644 --- a/tests/scenarios_test.go +++ b/tests/scenarios_test.go @@ -75,6 +75,7 @@ type expected struct { StderrContains []string `yaml:"stderr_contains"` StderrContainsWindows []string `yaml:"stderr_contains_windows"` ExitCode int `yaml:"exit_code"` + ExitCodeWindows *int `yaml:"exit_code_windows"` } // discoverScenarioFiles walks the scenarios directory and returns all YAML files @@ -208,7 +209,11 @@ func assertExpectations(t *testing.T, sc scenario, stdout, stderr string, exitCo } } - assert.Equal(t, sc.Expect.ExitCode, exitCode, "exit code mismatch") + expectedExitCode := sc.Expect.ExitCode + if runtime.GOOS == "windows" && sc.Expect.ExitCodeWindows != nil { + expectedExitCode = *sc.Expect.ExitCodeWindows + } + assert.Equal(t, expectedExitCode, exitCode, "exit code mismatch") stdoutContains := sc.Expect.StdoutContains if runtime.GOOS == "windows" && len(sc.Expect.StdoutContainsWindows) > 0 { From 2313cdb65c3f9a29047a2500c8b7dcdbe4d71b22 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 00:28:37 +0100 Subject: [PATCH 8/9] Add copyright headers to platform-specific executable files Co-Authored-By: Claude Opus 4.6 --- interp/builtins/test/executable_unix.go | 5 +++++ interp/builtins/test/executable_windows.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/interp/builtins/test/executable_unix.go b/interp/builtins/test/executable_unix.go index a894d158..2cb26544 100644 --- a/interp/builtins/test/executable_unix.go +++ b/interp/builtins/test/executable_unix.go @@ -1,3 +1,8 @@ +// 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 test diff --git a/interp/builtins/test/executable_windows.go b/interp/builtins/test/executable_windows.go index 5583e6b8..6d2b0a9c 100644 --- a/interp/builtins/test/executable_windows.go +++ b/interp/builtins/test/executable_windows.go @@ -1,3 +1,8 @@ +// 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 test From 30d3b61ef69f9a45bda0a65bba893ece73916180 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 10 Mar 2026 00:43:00 +0100 Subject: [PATCH 9/9] Add 17 test builtin scenarios and fix POSIX 3-arg parsing rule Co-Authored-By: Claude Opus 4.6 --- interp/builtins/test/test.go | 10 ++++++++++ .../scenarios/cmd/test/bracket/with_file_test.yaml | 13 +++++++++++++ tests/scenarios/cmd/test/edge_cases/dash_word.yaml | 9 +++++++++ .../cmd/test/edge_cases/double_dash_word.yaml | 9 +++++++++ .../cmd/test/file_tests/d_not_directory.yaml | 13 +++++++++++++ .../cmd/test/file_tests/e_broken_symlink.yaml | 13 +++++++++++++ .../scenarios/cmd/test/file_tests/f_not_exists.yaml | 9 +++++++++ .../cmd/test/file_tests/h_broken_symlink.yaml | 13 +++++++++++++ .../cmd/test/integer_tests/negative_eq.yaml | 9 +++++++++ .../cmd/test/integer_tests/negative_gt.yaml | 9 +++++++++ .../cmd/test/integer_tests/negative_lt.yaml | 9 +++++++++ .../cmd/test/integer_tests/negative_ne.yaml | 9 +++++++++ tests/scenarios/cmd/test/logic/double_negation.yaml | 9 +++++++++ tests/scenarios/cmd/test/logic/not_file_test.yaml | 13 +++++++++++++ tests/scenarios/cmd/test/logic/not_string.yaml | 9 +++++++++ tests/scenarios/cmd/test/logic/triple_negation.yaml | 9 +++++++++ .../cmd/test/string_tests/equal_empty.yaml | 9 +++++++++ .../cmd/test/string_tests/equal_special_chars.yaml | 9 +++++++++ 18 files changed, 183 insertions(+) create mode 100644 tests/scenarios/cmd/test/bracket/with_file_test.yaml create mode 100644 tests/scenarios/cmd/test/edge_cases/dash_word.yaml create mode 100644 tests/scenarios/cmd/test/edge_cases/double_dash_word.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/d_not_directory.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/e_broken_symlink.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/f_not_exists.yaml create mode 100644 tests/scenarios/cmd/test/file_tests/h_broken_symlink.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/negative_eq.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/negative_gt.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/negative_lt.yaml create mode 100644 tests/scenarios/cmd/test/integer_tests/negative_ne.yaml create mode 100644 tests/scenarios/cmd/test/logic/double_negation.yaml create mode 100644 tests/scenarios/cmd/test/logic/not_file_test.yaml create mode 100644 tests/scenarios/cmd/test/logic/not_string.yaml create mode 100644 tests/scenarios/cmd/test/logic/triple_negation.yaml create mode 100644 tests/scenarios/cmd/test/string_tests/equal_empty.yaml create mode 100644 tests/scenarios/cmd/test/string_tests/equal_special_chars.yaml diff --git a/interp/builtins/test/test.go b/interp/builtins/test/test.go index 62275018..b4e1094f 100644 --- a/interp/builtins/test/test.go +++ b/interp/builtins/test/test.go @@ -223,6 +223,16 @@ func (p *testParser) testExprBase() testExpr { return testWord{val: s} } + // POSIX three-argument rule: when exactly 3 tokens remain and the + // second is a binary primary, parse as binary regardless of first token. + // e.g. `test "!" = "!"` → binary string comparison, not negation. + if len(p.rem) == 3 && testBinaryOps[p.rem[1]] { + p.next() + op := p.next() + rhs := p.next() + return testBinary{op: op, x: testWord{val: s}, y: testWord{val: rhs}} + } + // Negation. if s == "!" { p.next() diff --git a/tests/scenarios/cmd/test/bracket/with_file_test.yaml b/tests/scenarios/cmd/test/bracket/with_file_test.yaml new file mode 100644 index 00000000..c953f928 --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/with_file_test.yaml @@ -0,0 +1,13 @@ +description: "[ ] bracket syntax works with file test operators." +setup: + files: + - path: file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + [ -f file.txt ] && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/edge_cases/dash_word.yaml b/tests/scenarios/cmd/test/edge_cases/dash_word.yaml new file mode 100644 index 00000000..1f0b045b --- /dev/null +++ b/tests/scenarios/cmd/test/edge_cases/dash_word.yaml @@ -0,0 +1,9 @@ +description: "test - treats a single dash as a non-empty string." +input: + allowed_paths: ["$DIR"] + script: |+ + test - && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/edge_cases/double_dash_word.yaml b/tests/scenarios/cmd/test/edge_cases/double_dash_word.yaml new file mode 100644 index 00000000..2965458a --- /dev/null +++ b/tests/scenarios/cmd/test/edge_cases/double_dash_word.yaml @@ -0,0 +1,9 @@ +description: "test -- treats double dash as a non-empty string." +input: + allowed_paths: ["$DIR"] + script: |+ + test -- && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/d_not_directory.yaml b/tests/scenarios/cmd/test/file_tests/d_not_directory.yaml new file mode 100644 index 00000000..21e2842f --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/d_not_directory.yaml @@ -0,0 +1,13 @@ +description: test -d returns 1 for a regular file. +setup: + files: + - path: file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test -d file.txt && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/e_broken_symlink.yaml b/tests/scenarios/cmd/test/file_tests/e_broken_symlink.yaml new file mode 100644 index 00000000..4ce71a98 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/e_broken_symlink.yaml @@ -0,0 +1,13 @@ +description: test -e returns 1 for a broken symlink (stat follows link, target missing). +setup: + files: + - path: broken + symlink: nonexistent_target +input: + allowed_paths: ["$DIR"] + script: |+ + test -e broken && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/f_not_exists.yaml b/tests/scenarios/cmd/test/file_tests/f_not_exists.yaml new file mode 100644 index 00000000..7c5ad1c7 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/f_not_exists.yaml @@ -0,0 +1,9 @@ +description: test -f returns 1 for a nonexistent file. +input: + allowed_paths: ["$DIR"] + script: |+ + test -f nosuchfile && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/file_tests/h_broken_symlink.yaml b/tests/scenarios/cmd/test/file_tests/h_broken_symlink.yaml new file mode 100644 index 00000000..1af8e510 --- /dev/null +++ b/tests/scenarios/cmd/test/file_tests/h_broken_symlink.yaml @@ -0,0 +1,13 @@ +description: test -h returns 0 for a broken symlink (lstat sees the link itself). +setup: + files: + - path: broken + symlink: nonexistent_target +input: + allowed_paths: ["$DIR"] + script: |+ + test -h broken && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/negative_eq.yaml b/tests/scenarios/cmd/test/integer_tests/negative_eq.yaml new file mode 100644 index 00000000..29cddb28 --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/negative_eq.yaml @@ -0,0 +1,9 @@ +description: test -eq works with negative integers. +input: + allowed_paths: ["$DIR"] + script: |+ + test -3 -eq -3 && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/negative_gt.yaml b/tests/scenarios/cmd/test/integer_tests/negative_gt.yaml new file mode 100644 index 00000000..04d2a487 --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/negative_gt.yaml @@ -0,0 +1,9 @@ +description: test -gt works with negative integers. +input: + allowed_paths: ["$DIR"] + script: |+ + test 0 -gt -3 && echo yes; test -3 -gt 0 && echo bad || echo no +expect: + stdout: "yes\nno\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/negative_lt.yaml b/tests/scenarios/cmd/test/integer_tests/negative_lt.yaml new file mode 100644 index 00000000..198553c1 --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/negative_lt.yaml @@ -0,0 +1,9 @@ +description: test -lt works with negative integers. +input: + allowed_paths: ["$DIR"] + script: |+ + test -3 -lt 0 && echo yes; test 0 -lt -3 && echo bad || echo no +expect: + stdout: "yes\nno\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integer_tests/negative_ne.yaml b/tests/scenarios/cmd/test/integer_tests/negative_ne.yaml new file mode 100644 index 00000000..36073397 --- /dev/null +++ b/tests/scenarios/cmd/test/integer_tests/negative_ne.yaml @@ -0,0 +1,9 @@ +description: test -ne works with negative integers. +input: + allowed_paths: ["$DIR"] + script: |+ + test -3 -ne 90 && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/double_negation.yaml b/tests/scenarios/cmd/test/logic/double_negation.yaml new file mode 100644 index 00000000..557214c7 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/double_negation.yaml @@ -0,0 +1,9 @@ +description: test ! ! double negation restores original truth value. +input: + allowed_paths: ["$DIR"] + script: |+ + test ! ! "a" && echo yes; test ! ! "" && echo bad || echo no +expect: + stdout: "yes\nno\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/not_file_test.yaml b/tests/scenarios/cmd/test/logic/not_file_test.yaml new file mode 100644 index 00000000..6049f707 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/not_file_test.yaml @@ -0,0 +1,13 @@ +description: test ! negates file test operators. +setup: + files: + - path: file.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test ! -d file.txt && echo yes; test ! -f file.txt && echo bad || echo no +expect: + stdout: "yes\nno\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/not_string.yaml b/tests/scenarios/cmd/test/logic/not_string.yaml new file mode 100644 index 00000000..c19dcdc2 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/not_string.yaml @@ -0,0 +1,9 @@ +description: test ! negates string truth value. +input: + allowed_paths: ["$DIR"] + script: |+ + test ! "" && echo yes; test ! "A" && echo bad || echo no +expect: + stdout: "yes\nno\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/triple_negation.yaml b/tests/scenarios/cmd/test/logic/triple_negation.yaml new file mode 100644 index 00000000..a9162ab3 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/triple_negation.yaml @@ -0,0 +1,9 @@ +description: test ! ! ! triple negation inverts truth value. +input: + allowed_paths: ["$DIR"] + script: |+ + test ! ! ! "" && echo yes; test ! ! ! "a" && echo bad || echo no +expect: + stdout: "yes\nno\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/string_tests/equal_empty.yaml b/tests/scenarios/cmd/test/string_tests/equal_empty.yaml new file mode 100644 index 00000000..313b6d9f --- /dev/null +++ b/tests/scenarios/cmd/test/string_tests/equal_empty.yaml @@ -0,0 +1,9 @@ +description: test = returns 0 when comparing two empty strings. +input: + allowed_paths: ["$DIR"] + script: |+ + test "" = "" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/string_tests/equal_special_chars.yaml b/tests/scenarios/cmd/test/string_tests/equal_special_chars.yaml new file mode 100644 index 00000000..4732bc97 --- /dev/null +++ b/tests/scenarios/cmd/test/string_tests/equal_special_chars.yaml @@ -0,0 +1,9 @@ +description: test = handles special characters like ! and = as string values. +input: + allowed_paths: ["$DIR"] + script: |+ + test "!" = "!" && echo eq; test "=" = "=" && echo eq2 +expect: + stdout: "eq\neq2\n" + stderr: "" + exit_code: 0