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/AGENTS.md b/AGENTS.md index 14c3e257..7eec0ce0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,11 +21,14 @@ 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). - 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/SHELL_COMMANDS.md b/SHELL_COMMANDS.md deleted file mode 100644 index 618f420f..00000000 --- a/SHELL_COMMANDS.md +++ /dev/null @@ -1,14 +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. | -| `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..0cbac9db 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -5,13 +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) -- ✅ `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 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/executable_unix.go b/interp/builtins/test/executable_unix.go new file mode 100644 index 00000000..2cb26544 --- /dev/null +++ b/interp/builtins/test/executable_unix.go @@ -0,0 +1,15 @@ +// 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 + +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..6d2b0a9c --- /dev/null +++ b/interp/builtins/test/executable_windows.go @@ -0,0 +1,32 @@ +// 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 + +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; 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/interp/builtins/test/test.go b/interp/builtins/test/test.go new file mode 100644 index 00000000..b4e1094f --- /dev/null +++ b/interp/builtins/test/test.go @@ -0,0 +1,454 @@ +// 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 implements the test/[ builtin — 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 test + +import ( + "context" + "os" + "strconv" + + "github.com/DataDog/rshell/interp/builtins" +) + +func init() { + builtins.Register("test", runTest) + builtins.Register("[", runBracket) +} + +func runTest(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + return run(ctx, callCtx, args, false) +} + +func runBracket(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + return run(ctx, callCtx, args, true) +} + +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 builtins.Result{Code: 2} + } + args = args[:len(args)-1] + } + + // No arguments: false. + if len(args) == 0 { + return builtins.Result{Code: 1} + } + + p := &testParser{rem: args} + expr := p.classicTest() + if p.err != "" { + callCtx.Errf("%s: %s\n", name, p.err) + return builtins.Result{Code: 2} + } + if len(p.rem) > 0 { + callCtx.Errf("%s: too many arguments\n", name) + return builtins.Result{Code: 2} + } + + 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} +} + +// --- 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() + + // 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} + } + + // 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() + 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 --- + +// 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 != "", 0 + case testUnary: + return evalUnary(ctx, callCtx, name, e) + case testBinary: + return evalBinary(ctx, callCtx, name, e) + case testParen: + return evalTest(ctx, callCtx, name, e.x) + } + return false, 0 +} + +func evalUnary(ctx context.Context, callCtx *builtins.CallContext, name string, e testUnary) (bool, uint8) { + switch e.op { + case "!": + 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 != "", 0 + } + return evalTest(ctx, callCtx, name, e.x) + case "-z": + if w, ok := e.x.(testWord); ok { + return w.val == "", 0 + } + ok, code := evalTest(ctx, callCtx, name, e.x) + if code != 0 { + return false, code + } + return !ok, 0 + case "-e", "-f", "-d", "-s", "-r", "-w", "-x": + w, ok := e.x.(testWord) + if !ok { + return false, 0 + } + fi, err := callCtx.StatFile(ctx, w.val) + if err != nil { + return false, 0 + } + return evalFileStat(e.op, fi), 0 + case "-L", "-h": + w, ok := e.x.(testWord) + if !ok { + return false, 0 + } + fi, err := callCtx.LstatFile(ctx, w.val) + if err != nil { + return false, 0 + } + return fi.Mode()&os.ModeSymlink != 0, 0 + } + return false, 0 +} + +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 isExecutable(fi) + } + return false +} + +func evalBinary(ctx context.Context, callCtx *builtins.CallContext, name string, e testBinary) (bool, uint8) { + switch e.op { + case "-a": + 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": + 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), 0 + case "!=": + return wordVal(e.x) != wordVal(e.y), 0 + case "-eq", "-ne", "-lt", "-gt", "-le", "-ge": + return evalIntCmp(callCtx, name, e.op, wordVal(e.x), wordVal(e.y)) + case "-nt": + return evalNt(ctx, callCtx, wordVal(e.x), wordVal(e.y)), 0 + case "-ot": + return evalNt(ctx, callCtx, wordVal(e.y), wordVal(e.x)), 0 + case "-ef": + return evalEf(ctx, callCtx, wordVal(e.x), wordVal(e.y)), 0 + } + return false, 0 +} + +func wordVal(e testExpr) string { + if w, ok := e.(testWord); ok { + return w.val + } + return "" +} + +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 errB != nil { + callCtx.Errf("%s: %s: integer expression expected\n", name, b) + return false, 2 + } + switch op { + case "-eq": + return ai == bi, 0 + case "-ne": + return ai != bi, 0 + case "-lt": + return ai < bi, 0 + case "-gt": + return ai > bi, 0 + case "-le": + return ai <= bi, 0 + case "-ge": + return ai >= bi, 0 + } + return false, 0 +} + +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 { + return false + } + return fiA.ModTime().After(fiB.ModTime()) +} + +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 { + return false + } + return os.SameFile(fiA, fiB) +} 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/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..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" ) @@ -216,6 +217,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..d488a939 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -39,9 +39,14 @@ var builtinAllowedSymbols = []string{ "io.NopCloser", "io.ReadCloser", "io.Reader", + "os.FileInfo", + "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/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/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/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/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/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/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/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_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/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/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/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/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/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/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/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_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/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/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/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/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/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 new file mode 100644 index 00000000..b7414c1a --- /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 + content: "#!/bin/sh" + chmod: 0755 +input: + allowed_paths: ["$DIR"] + script: |+ + test -x script && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 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/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/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/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/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/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/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.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/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/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/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/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 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/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 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 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 {