diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index 50dce290..af78819c 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -77,6 +77,65 @@ func (s *pathSandbox) resolve(absPath string) (*os.Root, string, bool) { return nil, "", false } +// access checks whether the resolved path is accessible with the given mode. +// All operations go through os.Root to stay within the sandbox. +// Mode: 0x04 = read, 0x02 = write, 0x01 = execute. +func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) error { + absPath := toAbs(path, HandlerCtx(ctx).Dir) + + if s == nil { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + for _, ar := range s.roots { + rel, err := filepath.Rel(ar.absPath, absPath) + if err != nil { + continue + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + continue + } + + // Open through os.Root once. This checks read access and gives + // us a file descriptor for an atomic Stat (no TOCTOU window). + f, err := ar.root.Open(rel) + if err != nil { + if mode&0x04 != 0 && !isErrIsDirectory(err) { + return portablePathError(err) + } + // Read not requested, or target is a directory; fall back to Stat. + info, serr := ar.root.Stat(rel) + if serr != nil { + return portablePathError(serr) + } + if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + return nil + } + + // For write and execute, use mode bits from f.Stat() on the + // open fd — atomic, no TOCTOU window. + // The sandbox is read-only so -w is informational only. + // effectiveHasPerm checks the permission class (owner/group/other) + // that applies to the current process's effective UID/GID on Unix, + // rather than the union of all classes. + if mode&0x03 != 0 { + info, err := f.Stat() + if err != nil { + f.Close() + return portablePathError(err) + } + if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) { + f.Close() + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + } + f.Close() + return nil + } + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} +} + // toAbs resolves path against cwd when it is not already absolute. func toAbs(path, cwd string) string { if filepath.IsAbs(path) { @@ -133,6 +192,40 @@ func (s *pathSandbox) readDir(ctx context.Context, path string) ([]fs.DirEntry, return entries, nil } +// stat implements the restricted stat policy. The file is resolved through +// os.Root for atomic path validation, matching the open policy. +func (s *pathSandbox) stat(ctx context.Context, path string) (fs.FileInfo, error) { + absPath := toAbs(path, HandlerCtx(ctx).Dir) + + root, relPath, ok := s.resolve(absPath) + if !ok { + return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission} + } + + info, err := root.Stat(relPath) + if err != nil { + return nil, portablePathError(err) + } + return info, nil +} + +// lstat implements the restricted lstat policy. Unlike stat, it does not +// follow symlinks — it returns information about the link itself. +func (s *pathSandbox) lstat(ctx context.Context, path string) (fs.FileInfo, error) { + absPath := toAbs(path, HandlerCtx(ctx).Dir) + + root, relPath, ok := s.resolve(absPath) + if !ok { + return nil, &os.PathError{Op: "lstat", Path: path, Err: os.ErrPermission} + } + + info, err := root.Lstat(relPath) + if err != nil { + return nil, portablePathError(err) + } + return info, nil +} + // Close releases all os.Root file descriptors. It is safe to call multiple times. func (s *pathSandbox) Close() error { if s == nil { diff --git a/interp/builtins/builtins.go b/interp/builtins/builtins.go index c7c93caf..f60721ce 100644 --- a/interp/builtins/builtins.go +++ b/interp/builtins/builtins.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "io" + "io/fs" "os" "github.com/spf13/pflag" @@ -80,6 +81,16 @@ type CallContext struct { // OpenFile opens a file within the shell's path restrictions. OpenFile func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) + // StatFile returns file info within the shell's path restrictions (follows symlinks). + StatFile func(ctx context.Context, path string) (fs.FileInfo, error) + + // LstatFile returns file info within the shell's path restrictions (does not follow symlinks). + LstatFile func(ctx context.Context, path string) (fs.FileInfo, error) + + // AccessFile checks whether the file at path is accessible with the given mode + // within the shell's path restrictions. Mode: 0x04=read, 0x02=write, 0x01=execute. + AccessFile func(ctx context.Context, path string, mode uint32) error + // PortableErr normalizes an OS error to a POSIX-style message. PortableErr func(err error) string } diff --git a/interp/builtins/testcmd/testcmd.go b/interp/builtins/testcmd/testcmd.go new file mode 100644 index 00000000..65da9c9b --- /dev/null +++ b/interp/builtins/testcmd/testcmd.go @@ -0,0 +1,557 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package testcmd implements the POSIX test and [ builtin commands. +// +// Usage: +// +// test EXPRESSION +// [ EXPRESSION ] +// +// Evaluate a conditional expression and exit with status 0 (true) or 1 (false). +// The [ form requires a closing ] as the last argument. +// +// Exit codes: +// +// 0 — expression evaluates to true +// 1 — expression evaluates to false +// 2 — syntax/usage error +// +// Supported operators: +// +// File tests (unary): +// +// -e FILE FILE exists +// -f FILE FILE exists and is a regular file +// -d FILE FILE exists and is a directory +// -s FILE FILE exists and has size greater than zero +// -r FILE FILE exists and is readable +// -w FILE FILE exists and is writable +// -x FILE FILE exists and is executable +// -h FILE FILE exists and is a symbolic link +// -L FILE FILE exists and is a symbolic link (same as -h) +// -p FILE FILE exists and is a named pipe (FIFO) +// +// File comparison (binary): +// +// FILE1 -nt FILE2 FILE1 is newer (modification time) than FILE2 +// FILE1 -ot FILE2 FILE1 is older (modification time) than FILE2 +// +// String tests (unary): +// +// -z STRING length of STRING is zero +// -n STRING length of STRING is non-zero +// STRING STRING is non-empty (equivalent to -n STRING) +// +// String comparison (binary): +// +// S1 = S2 strings are equal +// S1 != S2 strings are not equal +// S1 < S2 S1 sorts before S2 (lexicographic) +// S1 > S2 S1 sorts after S2 (lexicographic) +// +// Integer comparison (binary): +// +// N1 -eq N2 integers are equal +// N1 -ne N2 integers are not equal +// N1 -lt N2 N1 is less than N2 +// N1 -le N2 N1 is less than or equal to N2 +// N1 -gt N2 N1 is greater than N2 +// N1 -ge N2 N1 is greater than or equal to N2 +// +// Logical operators: +// +// ! EXPR EXPR is false +// EXPR1 -a EXPR2 both EXPR1 and EXPR2 are true +// EXPR1 -o EXPR2 either EXPR1 or EXPR2 is true +// ( EXPR ) grouping (parentheses must be shell-escaped) +package testcmd + +import ( + "context" + "io/fs" + "math" + "strconv" + "strings" + + "github.com/DataDog/rshell/interp/builtins" +) + +// Cmd is the "test" builtin command registration. +var Cmd = builtins.Command{Name: "test", MakeFlags: builtins.NoFlags(runTest)} + +// BracketCmd is the "[" builtin command registration. +var BracketCmd = builtins.Command{Name: "[", MakeFlags: builtins.NoFlags(runBracket)} + +const helpText = `Usage: test EXPRESSION + or: [ EXPRESSION ] + +Evaluate conditional expression. + +Exit status: + 0 if EXPRESSION is true, + 1 if EXPRESSION is false, + 2 if an error occurred. + +File tests: + -e FILE FILE exists + -f FILE FILE is a regular file + -d FILE FILE is a directory + -s FILE FILE has size > 0 + -r FILE FILE is readable + -w FILE FILE is writable + -x FILE FILE is executable + -h FILE FILE is a symbolic link + -L FILE FILE is a symbolic link (same as -h) + -p FILE FILE is a named pipe + +File comparison: + FILE1 -nt FILE2 FILE1 is newer than FILE2 + FILE1 -ot FILE2 FILE1 is older than FILE2 + +String tests: + -z STRING STRING has zero length + -n STRING STRING has non-zero length + STRING STRING is non-empty + +String comparison: + S1 = S2 strings are equal + S1 != S2 strings are not equal + S1 < S2 S1 sorts before S2 + S1 > S2 S1 sorts after S2 + +Integer comparison: + N1 -eq N2 N1 equals N2 + N1 -ne N2 N1 is not equal to N2 + N1 -lt N2 N1 is less than N2 + N1 -le N2 N1 is less or equal to N2 + N1 -gt N2 N1 is greater than N2 + N1 -ge N2 N1 is greater or equal to N2 + +Logical: + ! EXPR EXPR is false + EXPR1 -a EXPR2 both true + EXPR1 -o EXPR2 either true + ( EXPR ) grouping +` + +const exitSyntaxError = 2 + +func runTest(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) == 1 && args[0] == "--help" { + callCtx.Out(helpText) + return builtins.Result{} + } + return evaluate(ctx, callCtx, "test", args) +} + +func runBracket(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) == 1 && args[0] == "--help" { + callCtx.Out(helpText) + return builtins.Result{} + } + if len(args) == 0 || args[len(args)-1] != "]" { + callCtx.Errf("[: missing `]'\n") + return builtins.Result{Code: exitSyntaxError} + } + return evaluate(ctx, callCtx, "[", args[:len(args)-1]) +} + +// parser holds the state for recursive-descent parsing of test expressions. +type parser struct { + ctx context.Context + callCtx *builtins.CallContext + cmdName string + args []string + pos int + err bool + depth int +} + +const maxParenDepth = 128 + +func evaluate(ctx context.Context, callCtx *builtins.CallContext, cmdName string, args []string) builtins.Result { + if len(args) == 0 { + return builtins.Result{Code: 1} + } + + p := &parser{ + ctx: ctx, + callCtx: callCtx, + cmdName: cmdName, + args: args, + } + + result := p.parseOr() + + if p.err { + return builtins.Result{Code: exitSyntaxError} + } + if p.pos < len(p.args) { + p.callCtx.Errf("%s: extra argument '%s'\n", p.cmdName, p.args[p.pos]) + return builtins.Result{Code: exitSyntaxError} + } + if result { + return builtins.Result{} + } + return builtins.Result{Code: 1} +} + +func (p *parser) peek() string { + if p.pos >= len(p.args) { + return "" + } + return p.args[p.pos] +} + +func (p *parser) advance() string { + s := p.args[p.pos] + p.pos++ + return s +} + +// parseOr handles EXPR1 -o EXPR2 (lowest precedence). +func (p *parser) parseOr() bool { + left := p.parseAnd() + for !p.err && p.ctx.Err() == nil && p.pos < len(p.args) && p.peek() == "-o" { + p.advance() + right := p.parseAnd() + left = left || right + } + return left +} + +// parseAnd handles EXPR1 -a EXPR2. +func (p *parser) parseAnd() bool { + left := p.parseNot() + for !p.err && p.ctx.Err() == nil && p.pos < len(p.args) && p.peek() == "-a" { + p.advance() + right := p.parseNot() + left = left && right + } + return left +} + +// parseNot handles ! EXPR. When ! is the last remaining token, it is +// treated as a non-empty string per POSIX single-argument rules. +// When ! is followed by a binary operator (e.g., "! = !"), it is treated +// as a literal string operand, not negation. +func (p *parser) parseNot() bool { + if p.pos < len(p.args) && p.peek() == "!" { + remaining := len(p.args) - p.pos + if remaining == 1 { + p.advance() + return true + } + // If "!" is followed by a binary operator, treat it as a literal + // operand (fall through to parsePrimary for binary expression). + if remaining >= 3 && isBinaryOp(p.args[p.pos+1]) { + return p.parsePrimary() + } + if p.depth >= maxParenDepth { + p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) + p.err = true + return false + } + p.depth++ + p.advance() + result := !p.parseNot() + p.depth-- + return result + } + return p.parsePrimary() +} + +// parsePrimary handles parenthesized expressions, unary operators, binary +// operators, and bare strings. It uses lookahead to implement the POSIX +// disambiguation rules (e.g., with 1 remaining arg, everything is a string). +func (p *parser) parsePrimary() bool { + if p.err || p.pos >= len(p.args) { + p.callCtx.Errf("%s: missing argument\n", p.cmdName) + p.err = true + return false + } + + cur := p.peek() + remaining := len(p.args) - p.pos + + // Only treat "(" as grouping when there are enough tokens and it is not + // used as a literal operand in a binary expression. A lone "(" with + // remaining==1 is a bare non-empty string per POSIX single-argument rules. + // When "(" is followed by a binary operator (e.g., "(" = "("), treat it + // as a literal string operand. + if cur == "(" && remaining > 1 && !(remaining >= 3 && isBinaryOp(p.args[p.pos+1])) { + if p.depth >= maxParenDepth { + p.callCtx.Errf("%s: expression too deeply nested\n", p.cmdName) + p.err = true + return false + } + p.depth++ + p.advance() + if p.pos >= len(p.args) || p.peek() == ")" { + p.callCtx.Errf("%s: missing argument\n", p.cmdName) + p.err = true + return false + } + result := p.parseOr() + p.depth-- + if p.err { + return false + } + if p.pos >= len(p.args) || p.peek() != ")" { + p.callCtx.Errf("%s: missing ')'\n", p.cmdName) + p.err = true + return false + } + p.advance() + return result + } + + // POSIX disambiguation: if there are at least 3 remaining tokens and + // the second one is a binary operator, parse as a binary expression. + // This must be checked before unary operators to handle cases like + // "test -f = -f" (string comparison, not file test). + if remaining >= 3 { + op := p.args[p.pos+1] + if isBinaryOp(op) { + return p.parseBinaryExpr() + } + } + + // With 2+ remaining tokens, check for unary operators. + if remaining >= 2 { + if isUnaryFileOp(cur) { + return p.parseUnaryFileOp() + } + if cur == "-z" || cur == "-n" { + return p.parseUnaryStringOp() + } + // -o as unary tests whether a shell option is set. + // This restricted shell has no options, so always false. + if cur == "-o" { + p.advance() // consume -o + p.advance() // consume operand + return false + } + } + + // Single token or unrecognised: treat as bare string (true if non-empty). + p.advance() + return cur != "" +} + +func isBinaryOp(op string) bool { + switch op { + case "=", "==", "!=", "<", ">", + "-eq", "-ne", "-lt", "-le", "-gt", "-ge", + "-nt", "-ot": + return true + } + return false +} + +func isUnaryFileOp(op string) bool { + switch op { + case "-e", "-f", "-d", "-s", "-r", "-w", "-x", "-h", "-L", "-p": + return true + } + return false +} + +func (p *parser) consumeUnaryOperand(op string) (string, bool) { + if p.pos >= len(p.args) { + p.callCtx.Errf("%s: '%s': unary operator expected\n", p.cmdName, op) + p.err = true + return "", false + } + return p.advance(), true +} + +func (p *parser) parseUnaryFileOp() bool { + op := p.advance() + operand, ok := p.consumeUnaryOperand(op) + if !ok { + return false + } + return p.evalFileTest(op, operand) +} + +func (p *parser) parseUnaryStringOp() bool { + op := p.advance() + s, ok := p.consumeUnaryOperand(op) + if !ok { + return false + } + return (op == "-z") == (s == "") +} + +func (p *parser) parseBinaryExpr() bool { + left := p.advance() + op := p.advance() + if p.pos >= len(p.args) { + p.callCtx.Errf("%s: missing argument after '%s'\n", p.cmdName, op) + p.err = true + return false + } + right := p.advance() + + switch op { + case "=", "==": + return left == right + case "!=": + return left != right + case "<": + return left < right + case ">": + return left > right + case "-eq", "-ne", "-lt", "-le", "-gt", "-ge": + return p.evalIntCompare(left, op, right) + case "-nt", "-ot": + return p.evalFileCompare(left, op, right) + default: + p.callCtx.Errf("%s: unknown binary operator '%s'\n", p.cmdName, op) + p.err = true + return false + } +} + +func (p *parser) evalFileTest(op, path string) bool { + if path == "" { + return false + } + // For -r/-w/-x, use real access checks instead of mode bits. + if p.callCtx.AccessFile != nil { + switch op { + case "-r": + return p.callCtx.AccessFile(p.ctx, path, 0x04) == nil + case "-w": + return p.callCtx.AccessFile(p.ctx, path, 0x02) == nil + case "-x": + return p.callCtx.AccessFile(p.ctx, path, 0x01) == nil + } + } + + switch op { + case "-h", "-L": + if p.callCtx.LstatFile == nil { + return false + } + info, err := p.callCtx.LstatFile(p.ctx, path) + if err != nil { + return false + } + return info.Mode()&fs.ModeSymlink != 0 + default: + if p.callCtx.StatFile == nil { + return false + } + info, err := p.callCtx.StatFile(p.ctx, path) + if err != nil { + return false + } + return evalFileInfo(op, info) + } +} + +func evalFileInfo(op string, info fs.FileInfo) bool { + switch op { + case "-e": + return true + case "-f": + return info.Mode().IsRegular() + case "-d": + return info.IsDir() + case "-s": + return info.Size() > 0 + case "-r": + return info.Mode().Perm()&0444 != 0 + case "-w": + return info.Mode().Perm()&0222 != 0 + case "-x": + return info.Mode().Perm()&0111 != 0 + case "-p": + return info.Mode()&fs.ModeNamedPipe != 0 + } + return false +} + +func (p *parser) evalIntCompare(left, op, right string) bool { + l, ok := p.parseInt(left) + if !ok { + return false + } + r, ok := p.parseInt(right) + if !ok { + return false + } + switch op { + case "-eq": + return l == r + case "-ne": + return l != r + case "-lt": + return l < r + case "-le": + return l <= r + case "-gt": + return l > r + case "-ge": + return l >= r + } + return false +} + +func (p *parser) parseInt(s string) (int64, bool) { + s = strings.TrimSpace(s) + if s == "" { + p.callCtx.Errf("%s: : integer expression expected\n", p.cmdName) + p.err = true + return 0, false + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + // Check for overflow — clamp to boundaries like GNU test. + if numErr, ok := err.(*strconv.NumError); ok && numErr.Err == strconv.ErrRange { + if s[0] == '-' { + n = math.MinInt64 + } else { + n = math.MaxInt64 + } + return n, true + } + p.callCtx.Errf("%s: %s: integer expression expected\n", p.cmdName, s) + p.err = true + return 0, false + } + return n, true +} + +func (p *parser) evalFileCompare(left, op, right string) bool { + if p.callCtx.StatFile == nil { + return false + } + leftInfo, leftErr := p.callCtx.StatFile(p.ctx, left) + rightInfo, rightErr := p.callCtx.StatFile(p.ctx, right) + + switch op { + case "-nt": + if leftErr != nil { + return false + } + if rightErr != nil { + return true + } + return leftInfo.ModTime().After(rightInfo.ModTime()) + case "-ot": + if rightErr != nil { + return false + } + if leftErr != nil { + return true + } + return leftInfo.ModTime().Before(rightInfo.ModTime()) + } + return false +} diff --git a/interp/builtins/testcmd/testcmd_gnu_compat_test.go b/interp/builtins/testcmd/testcmd_gnu_compat_test.go new file mode 100644 index 00000000..700fd7d3 --- /dev/null +++ b/interp/builtins/testcmd/testcmd_gnu_compat_test.go @@ -0,0 +1,165 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package testcmd_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestGNUCompatTestEmptyIsFalse — no arguments = false. +// +// GNU: test; echo $? → 1 +func TestGNUCompatTestEmptyIsFalse(t *testing.T) { + stdout, stderr, code := runScript(t, "test; echo $?", "") + assert.Equal(t, "1\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestZeroLengthEmpty — -z "" = true. +// +// GNU: test -z ""; echo $? → 0 +func TestGNUCompatTestZeroLengthEmpty(t *testing.T) { + stdout, stderr, code := runScript(t, `test -z ""; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestNonZeroLength — -n "hello" = true. +// +// GNU: test -n "hello"; echo $? → 0 +func TestGNUCompatTestNonZeroLength(t *testing.T) { + stdout, stderr, code := runScript(t, `test -n "hello"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestStringEquality — "t" = "t". +// +// GNU: test "t" = "t"; echo $? → 0 +func TestGNUCompatTestStringEquality(t *testing.T) { + stdout, _, code := runScript(t, `test "t" = "t"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestStringInequality — "t" = "f" → false. +// +// GNU: test "t" = "f"; echo $? → 1 +func TestGNUCompatTestStringInequality(t *testing.T) { + stdout, _, code := runScript(t, `test "t" = "f"; echo $?`, "") + assert.Equal(t, "1\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestIntegerEquality — 9 -eq 9. +// +// GNU: test 9 -eq 9; echo $? → 0 +func TestGNUCompatTestIntegerEquality(t *testing.T) { + stdout, _, code := runScript(t, `test 9 -eq 9; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestIntegerWithLeadingZeros — 0 -eq 00. +// +// GNU: test 0 -eq 00; echo $? → 0 +func TestGNUCompatTestIntegerWithLeadingZeros(t *testing.T) { + stdout, _, code := runScript(t, `test 0 -eq 00; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestInvalidIntegerHex — 0x0 -eq 00 → error. +// +// GNU: test 0x0 -eq 00; echo $? → 2 (stderr: "test: 0x0: integer expression expected") +func TestGNUCompatTestInvalidIntegerHex(t *testing.T) { + _, stderr, code := runScript(t, `test 0x0 -eq 00`, "") + assert.Equal(t, 2, code) + assert.Equal(t, "test: 0x0: integer expression expected\n", stderr) +} + +// TestGNUCompatTestNegation — ! "" = true. +// +// GNU: test ! ""; echo $? → 0 +func TestGNUCompatTestNegation(t *testing.T) { + stdout, _, code := runScript(t, `test ! ""; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestAndOperator — "t" -a "t" = true. +// +// GNU: test "t" -a "t"; echo $? → 0 +func TestGNUCompatTestAndOperator(t *testing.T) { + stdout, _, code := runScript(t, `test "t" -a "t"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestOrOperator — "" -o "t" = true. +// +// GNU: test "" -o "t"; echo $? → 0 +func TestGNUCompatTestOrOperator(t *testing.T) { + stdout, _, code := runScript(t, `test "" -o "t"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestParentheses — ( "hello" ). +// +// GNU: test '(' "hello" ')'; echo $? → 0 +func TestGNUCompatTestParentheses(t *testing.T) { + stdout, _, code := runScript(t, `test '(' "hello" ')'; echo $?`, "") + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatBracketMissingClose — [ 1 -eq → exit 2 + stderr. +// +// GNU: [ 1 -eq; echo $? → 2 (stderr: "[: missing `]'") +func TestGNUCompatBracketMissingClose(t *testing.T) { + _, stderr, code := runScript(t, `[ 1 -eq`, "") + assert.Equal(t, 2, code) + assert.Equal(t, "[: missing `]'\n", stderr) +} + +// TestGNUCompatTestFileExists — -f on regular file. +// +// GNU: test -f file.txt; echo $? → 0 +func TestGNUCompatTestFileExists(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "content\n") + stdout, _, code := cmdRun(t, `test -f file.txt; echo $?`, dir) + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestNtOt — file modification time comparison. +// +// GNU: test newer -nt older; echo $? → 0 +// GNU: test older -ot newer; echo $? → 0 +func TestGNUCompatTestNtOt(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "older.txt", "old") + os.Chtimes(filepath.Join(dir, "older.txt"), time.Now().Add(-2*time.Hour), time.Now().Add(-2*time.Hour)) + writeFile(t, dir, "newer.txt", "new") + + stdout, _, code := cmdRun(t, `test newer.txt -nt older.txt; echo $?`, dir) + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) + + stdout, _, code = cmdRun(t, `test older.txt -ot newer.txt; echo $?`, dir) + assert.Equal(t, "0\n", stdout) + assert.Equal(t, 0, code) +} diff --git a/interp/builtins/testcmd/testcmd_pentest_test.go b/interp/builtins/testcmd/testcmd_pentest_test.go new file mode 100644 index 00000000..9520a6b3 --- /dev/null +++ b/interp/builtins/testcmd/testcmd_pentest_test.go @@ -0,0 +1,314 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package testcmd_test + +import ( + "context" + "math" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +func mustNotHang(t *testing.T, f func()) { + t.Helper() + done := make(chan struct{}) + go func() { + defer close(done) + f() + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("test timed out — possible hang") + } +} + +// --- Integer edge cases --- + +func TestPentestIntZero(t *testing.T) { + stdout, _, _ := runScript(t, `test 0 -eq 0; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntOne(t *testing.T) { + stdout, _, _ := runScript(t, `test 1 -eq 1; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntMaxInt32(t *testing.T) { + max32 := strconv.Itoa(math.MaxInt32) + stdout, _, _ := runScript(t, `test `+max32+` -eq `+max32+`; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntMaxInt64(t *testing.T) { + max64 := strconv.FormatInt(math.MaxInt64, 10) + stdout, _, _ := runScript(t, `test `+max64+` -eq `+max64+`; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntOverflowPositive(t *testing.T) { + huge := "99999999999999999999" + stdout, _, _ := runScript(t, `test `+huge+` -gt 0; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntOverflowNegative(t *testing.T) { + huge := "-99999999999999999999" + stdout, _, _ := runScript(t, `test `+huge+` -lt 0; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntEmptyString(t *testing.T) { + _, stderr, code := runScript(t, `test "" -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +func TestPentestIntWhitespaceOnly(t *testing.T) { + _, stderr, code := runScript(t, `test " " -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +func TestPentestIntHexRejected(t *testing.T) { + _, stderr, code := runScript(t, `test 0x10 -eq 16`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +func TestPentestIntOctalNotInterpreted(t *testing.T) { + stdout, _, _ := runScript(t, `test 010 -eq 10; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntFloatRejected(t *testing.T) { + _, stderr, code := runScript(t, `test 3.14 -eq 3`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +// --- Special files --- + +func TestPentestDevNull(t *testing.T) { + mustNotHang(t, func() { + _, _, code := runScript(t, `test -f `+os.DevNull, "", interp.AllowedPaths([]string{filepath.Dir(os.DevNull)})) + _ = code + }) +} + +func TestPentestDevNullExists(t *testing.T) { + mustNotHang(t, func() { + _, _, code := runScript(t, `test -e `+os.DevNull, "", interp.AllowedPaths([]string{filepath.Dir(os.DevNull)})) + _ = code + }) +} + +// --- Path edge cases --- + +func TestPentestNonexistentFile(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -f /absolutely/nonexistent`, dir) + assert.Equal(t, 1, code) +} + +func TestPentestEmptyFilename(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -f ""`, dir) + assert.Equal(t, 1, code) +} + +func TestPentestDirectoryAsFile(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -f .`, dir) + assert.Equal(t, 1, code) +} + +func TestPentestPathTraversal(t *testing.T) { + dir := t.TempDir() + _, _, code := runScript(t, `test -e ../../../etc/passwd`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) +} + +func TestPentestDoubleSlashes(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "data") + _, _, code := cmdRun(t, `test -f .//f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestPentestFlagLikeFilename(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "-f", "data") + _, _, code := cmdRun(t, `test -e -- -f`, dir) + _ = code +} + +// --- Flag and argument injection --- + +func TestPentestUnknownFlag(t *testing.T) { + _, _, code := runScript(t, `test --no-such-flag`, "") + assert.Equal(t, 0, code) +} + +func TestPentestDoubleDash(t *testing.T) { + _, _, code := runScript(t, `test --`, "") + assert.Equal(t, 0, code) +} + +func TestPentestMultipleStdinArgs(t *testing.T) { + _, _, code := runScript(t, `test "-" = "-"`, "") + assert.Equal(t, 0, code) +} + +// --- Context timeout --- + +func TestPentestTimeoutOnFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "data") + mustNotHang(t, func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, _ = runScriptCtx(ctx, t, `test -f f.txt`, dir, interp.AllowedPaths([]string{dir})) + }) +} + +// --- Bracket syntax edge cases --- + +func TestPentestBracketEmptyBrackets(t *testing.T) { + _, stderr, code := runScript(t, `[ ]`, "") + assert.Equal(t, 1, code, "stderr: %s", stderr) +} + +func TestPentestBracketOnlyClose(t *testing.T) { + _, stderr, code := runScript(t, `[`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing `]'") +} + +// --- Large argument count --- + +func TestPentestManyArguments(t *testing.T) { + mustNotHang(t, func() { + _, _, code := runScript(t, `test "a" -a "b" -a "c" -a "d" -a "e" -a "f"`, "") + assert.Equal(t, 0, code) + }) +} + +// --- Precedence / complex expressions --- + +func TestPentestPrecedenceAndOr(t *testing.T) { + stdout, _, _ := runScript(t, `test " " -o "" -a ""; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestNestedParentheses(t *testing.T) { + stdout, _, _ := runScript(t, `test '(' '(' "a" = "a" ')' ')'; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestPentestMissingCloseParen(t *testing.T) { + _, stderr, code := runScript(t, `test '(' "a"`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing ')'") +} + +func TestPentestEmptyParens(t *testing.T) { + _, _, code := runScript(t, `test '(' ')'`, "") + assert.Equal(t, 2, code) +} + +func TestPentestDeepNesting(t *testing.T) { + // Build a deeply nested expression: ( ( ( ... "x" ... ) ) ) + var script string + depth := 200 + for i := 0; i < depth; i++ { + script += "'(' " + } + script += `"x"` + for i := 0; i < depth; i++ { + script += " ')'" + } + _, stderr, code := runScript(t, "test "+script, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "too deeply nested") +} + +func TestPentestDeepNesting1000(t *testing.T) { + // 1000+ levels of nesting should be rejected by depth limit, not stack overflow. + var script string + depth := 1000 + for i := 0; i < depth; i++ { + script += "'(' " + } + script += `"x"` + for i := 0; i < depth; i++ { + script += " ')'" + } + mustNotHang(t, func() { + _, stderr, code := runScript(t, "test "+script, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "too deeply nested") + }) +} + +func TestPentestNullByteInFilePath(t *testing.T) { + // Null bytes in file paths should not bypass sandbox checks. + dir := t.TempDir() + // test -f with a path containing a null byte + _, _, code := cmdRun(t, "test -f \"file\x00/../etc/passwd\"", dir) + assert.NotEqual(t, 0, code) +} + +func TestPentestVeryLongString(t *testing.T) { + // Very long string arguments should not cause panics or hangs. + mustNotHang(t, func() { + long := make([]byte, 100*1024) + for i := range long { + long[i] = 'a' + } + script := `test "` + string(long) + `" = "` + string(long) + `"; echo $?` + stdout, _, code := runScript(t, script, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) + }) +} + +func TestPentestVeryLongFilePath(t *testing.T) { + // Very long file path should not cause panics. + dir := t.TempDir() + long := make([]byte, 100*1024) + for i := range long { + long[i] = 'a' + } + mustNotHang(t, func() { + _, _, code := cmdRun(t, `test -f "`+string(long)+`"`, dir) + assert.Equal(t, 1, code) + }) +} + +func TestPentestBangAsStringOperand(t *testing.T) { + // "!" should be treated as a literal string in binary expressions + stdout, stderr, code := runScript(t, `test '!' = '!'; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) + assert.Empty(t, stderr) +} + +func TestPentestParenAsStringOperand(t *testing.T) { + // "(" should be treated as a literal string in binary expressions + stdout, stderr, code := runScript(t, `test '(' = '('; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) + assert.Empty(t, stderr) +} diff --git a/interp/builtins/testcmd/testcmd_test.go b/interp/builtins/testcmd/testcmd_test.go new file mode 100644 index 00000000..5e69998a --- /dev/null +++ b/interp/builtins/testcmd/testcmd_test.go @@ -0,0 +1,507 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package testcmd_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, dir, opts...) +} + +func runScriptCtx(ctx context.Context, t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScriptCtx(ctx, t, script, dir, opts...) +} + +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644) + if err != nil { + t.Fatal(err) + } + return name +} + +// --- String tests --- + +func TestTestEmptyArgs(t *testing.T) { + _, _, code := runScript(t, "test", "") + assert.Equal(t, 1, code) +} + +func TestTestBareString(t *testing.T) { + _, _, code := runScript(t, `test "hello"`, "") + assert.Equal(t, 0, code) +} + +func TestTestEmptyString(t *testing.T) { + _, _, code := runScript(t, `test ""`, "") + assert.Equal(t, 1, code) +} + +func TestTestZeroLength(t *testing.T) { + _, _, code := runScript(t, `test -z ""`, "") + assert.Equal(t, 0, code) +} + +func TestTestZeroLengthNonEmpty(t *testing.T) { + _, _, code := runScript(t, `test -z "hello"`, "") + assert.Equal(t, 1, code) +} + +func TestTestNonZeroLength(t *testing.T) { + _, _, code := runScript(t, `test -n "hello"`, "") + assert.Equal(t, 0, code) +} + +func TestTestNonZeroLengthEmpty(t *testing.T) { + _, _, code := runScript(t, `test -n ""`, "") + assert.Equal(t, 1, code) +} + +func TestTestStringEqual(t *testing.T) { + _, _, code := runScript(t, `test "abc" = "abc"`, "") + assert.Equal(t, 0, code) +} + +func TestTestStringNotEqual(t *testing.T) { + _, _, code := runScript(t, `test "abc" != "def"`, "") + assert.Equal(t, 0, code) +} + +func TestTestStringEqualFalse(t *testing.T) { + _, _, code := runScript(t, `test "abc" = "def"`, "") + assert.Equal(t, 1, code) +} + +func TestTestDoubleEqual(t *testing.T) { + _, _, code := runScript(t, `test "t" == "t"`, "") + assert.Equal(t, 0, code) +} + +func TestTestStringLessThan(t *testing.T) { + stdout, _, code := runScript(t, `test "a" \< "b"; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +func TestTestStringGreaterThan(t *testing.T) { + stdout, _, code := runScript(t, `test "b" \> "a"; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +// --- Integer comparison tests --- + +func TestTestIntEq(t *testing.T) { + _, _, code := runScript(t, `test 9 -eq 9`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntNe(t *testing.T) { + _, _, code := runScript(t, `test 8 -ne 9`, "") + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test 9 -ne 9`, "") + assert.Equal(t, 1, code) +} + +func TestTestIntGt(t *testing.T) { + _, _, code := runScript(t, `test 5 -gt 4`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntLt(t *testing.T) { + _, _, code := runScript(t, `test 4 -lt 5`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntGe(t *testing.T) { + _, _, code := runScript(t, `test 5 -ge 5`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntLe(t *testing.T) { + _, _, code := runScript(t, `test 5 -le 5`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntNegative(t *testing.T) { + _, _, code := runScript(t, `test -1 -gt -2`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntLeadingZero(t *testing.T) { + _, _, code := runScript(t, `test 0 -eq 00`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntWhitespace(t *testing.T) { + stdout, _, _ := runScript(t, `test 0 -eq " 0 "; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestTestInvalidInteger(t *testing.T) { + _, stderr, code := runScript(t, `test 0x0 -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "integer expression expected") +} + +func TestTestFloatRejected(t *testing.T) { + _, stderr, code := runScript(t, `test 123.45 -ge 6`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "123.45: integer expression expected") +} + +// --- Logical operator tests --- + +func TestTestAndTrue(t *testing.T) { + _, _, code := runScript(t, `test "a" -a "b"`, "") + assert.Equal(t, 0, code) +} + +func TestTestAndFalse(t *testing.T) { + _, _, code := runScript(t, `test "" -a "b"`, "") + assert.Equal(t, 1, code) +} + +func TestTestOrTrue(t *testing.T) { + _, _, code := runScript(t, `test "" -o "b"`, "") + assert.Equal(t, 0, code) +} + +func TestTestOrFalse(t *testing.T) { + _, _, code := runScript(t, `test "" -o ""`, "") + assert.Equal(t, 1, code) +} + +func TestTestNot(t *testing.T) { + _, _, code := runScript(t, `test ! ""`, "") + assert.Equal(t, 0, code) +} + +func TestTestNotTrue(t *testing.T) { + _, _, code := runScript(t, `test ! "hello"`, "") + assert.Equal(t, 1, code) +} + +func TestTestDoubleNot(t *testing.T) { + _, _, code := runScript(t, `test ! ! "hello"`, "") + assert.Equal(t, 0, code) +} + +func TestTestParentheses(t *testing.T) { + stdout, _, _ := runScript(t, `test '(' "hello" ')'; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +func TestTestParenthesesEmpty(t *testing.T) { + stdout, _, _ := runScript(t, `test '(' "" ')'; echo $?`, "") + assert.Equal(t, "1\n", stdout) +} + +func TestTestPrecedence(t *testing.T) { + stdout, _, _ := runScript(t, `test " " -o "" -a ""; echo $?`, "") + assert.Equal(t, "0\n", stdout) +} + +// --- File tests --- + +func TestTestFileExists(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -e f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileNotExists(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -e nonexistent`, dir) + assert.Equal(t, 1, code) +} + +func TestTestRegularFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -f f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestDirNotRegular(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -f .`, dir) + assert.Equal(t, 1, code) +} + +func TestTestIsDirectory(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -d .`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileNotDirectory(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -d f.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileSizeGreaterThanZero(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "nonempty.txt", "data") + _, _, code := cmdRun(t, `test -s nonempty.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestEmptyFileSize(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "empty.txt", "") + _, _, code := cmdRun(t, `test -s empty.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileReadable(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -r f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileWritable(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -w f.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileNotExecutable(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "hello") + _, _, code := cmdRun(t, `test -x f.txt`, dir) + assert.Equal(t, 1, code) +} + +// --- Bracket syntax tests --- + +func TestBracketBasic(t *testing.T) { + _, _, code := runScript(t, `[ "hello" ]`, "") + assert.Equal(t, 0, code) +} + +func TestBracketIntCompare(t *testing.T) { + _, _, code := runScript(t, `[ 1 -eq 1 ]`, "") + assert.Equal(t, 0, code) +} + +func TestBracketMissingClose(t *testing.T) { + _, stderr, code := runScript(t, `[ 1 -eq 1`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing `]'") +} + +func TestBracketEmpty(t *testing.T) { + _, stderr, code := runScript(t, `[`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing `]'") +} + +func TestTestLoneParenIsString(t *testing.T) { + _, _, code := runScript(t, `test "("`, "") + assert.Equal(t, 0, code) +} + +func TestTestEmptyFileOperand(t *testing.T) { + _, _, code := runScript(t, `test -e ""`, "") + assert.Equal(t, 1, code) + + _, _, code = runScript(t, `test -d ""`, "") + assert.Equal(t, 1, code) + + _, _, code = runScript(t, `test -f ""`, "") + assert.Equal(t, 1, code) +} + +// --- Help tests --- + +func TestTestHelp(t *testing.T) { + stdout, _, code := runScript(t, `test --help`, "") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") +} + +func TestBracketHelp(t *testing.T) { + stdout, _, code := runScript(t, `[ --help`, "") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage:") +} + +// --- Error cases --- + +func TestTestExtraArgument(t *testing.T) { + _, stderr, code := runScript(t, `test "a" "b" "c" "d" "e"`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "extra argument") +} + +// --- File comparison -nt / -ot tests --- + +func TestTestNewerThan(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "old.txt", "old") + old := filepath.Join(dir, "old.txt") + past := time.Now().Add(-2 * time.Hour) + os.Chtimes(old, past, past) + writeFile(t, dir, "new.txt", "new") + + _, _, code := cmdRun(t, `test new.txt -nt old.txt`, dir) + assert.Equal(t, 0, code) + + _, _, code = cmdRun(t, `test old.txt -nt new.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestOlderThan(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "old.txt", "old") + old := filepath.Join(dir, "old.txt") + past := time.Now().Add(-2 * time.Hour) + os.Chtimes(old, past, past) + writeFile(t, dir, "new.txt", "new") + + _, _, code := cmdRun(t, `test old.txt -ot new.txt`, dir) + assert.Equal(t, 0, code) + + _, _, code = cmdRun(t, `test new.txt -ot old.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestNtMissingFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "exists.txt", "data") + + _, _, code := cmdRun(t, `test exists.txt -nt nonexistent`, dir) + assert.Equal(t, 0, code) + + _, _, code = cmdRun(t, `test nonexistent -nt exists.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestOtMissingFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "exists.txt", "data") + + _, _, code := cmdRun(t, `test nonexistent -ot exists.txt`, dir) + assert.Equal(t, 0, code) + + _, _, code = cmdRun(t, `test exists.txt -ot nonexistent`, dir) + assert.Equal(t, 1, code) +} + +// --- Special single-arg cases --- + +func TestTestSpecialSingleArgs(t *testing.T) { + cases := []string{"-", "--", "-0", "-f", "["} + for _, c := range cases { + t.Run(c, func(t *testing.T) { + stdout, _, _ := runScript(t, `test "`+c+`"; echo $?`, "") + assert.Equal(t, "0\n", stdout) + }) + } +} + +// --- Operator as literal string --- + +func TestTestSoloNot(t *testing.T) { + _, _, code := runScript(t, `test !`, "") + assert.Equal(t, 0, code) +} + +func TestTestSoloOperatorLiteral(t *testing.T) { + _, _, code := runScript(t, `test -a`, "") + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test -o`, "") + assert.Equal(t, 0, code) +} + +// --- Context cancellation --- + +func TestTestContextCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + cancel() + _, _, code := runScriptCtx(ctx, t, `test 1 -eq 1`, "") + // A pre-cancelled context prevents the runner from starting; exit code depends + // on how the runner surfaces the error — just verify it did not succeed. + _ = code +} + +// --- Sandbox enforcement --- + +func TestTestFileOutsideSandbox(t *testing.T) { + dir := t.TempDir() + other := t.TempDir() + writeFile(t, other, "secret.txt", "data") + + _, _, code := runScript(t, `test -f `+filepath.Join(other, "secret.txt"), dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) +} + +// --- Integer overflow clamping --- + +func TestTestIntOverflow(t *testing.T) { + stdout, _, code := runScript(t, `test 99999999999999999999 -gt 0; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +func TestTestIntNegOverflow(t *testing.T) { + stdout, _, code := runScript(t, `test -99999999999999999999 -lt 0; echo $?`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +// --- Shell integration tests --- + +func TestTestWithAndList(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "data") + stdout, _, code := cmdRun(t, `test -f file.txt && echo "yes" || echo "no"`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestInForLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "alpha") + writeFile(t, dir, "b.txt", "beta") + stdout, _, code := cmdRun(t, `for f in a.txt b.txt; do test -f "$f" && echo "$f exists"; done`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a.txt exists\nb.txt exists\n", stdout) +} + +func TestBracketInAndList(t *testing.T) { + stdout, _, code := runScript(t, `[ "hello" = "hello" ] && echo "match"`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "match\n", stdout) +} diff --git a/interp/builtins/testcmd/testcmd_unix_test.go b/interp/builtins/testcmd/testcmd_unix_test.go new file mode 100644 index 00000000..d892a90e --- /dev/null +++ b/interp/builtins/testcmd/testcmd_unix_test.go @@ -0,0 +1,66 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build unix + +package testcmd_test + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +func TestTestSymlink(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "target.txt", "hello") + os.Symlink("target.txt", filepath.Join(dir, "link.txt")) + + _, _, code := runScript(t, `test -h link.txt`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test -L link.txt`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test -h target.txt`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) +} + +func TestTestSymlinkNotRegular(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "target.txt", "hello") + os.Symlink("target.txt", filepath.Join(dir, "link.txt")) + + _, _, code := runScript(t, `test -f link.txt`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) +} + +func TestTestExecutableFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "exec.sh") + os.WriteFile(p, []byte("#!/bin/sh\n"), 0755) + + _, _, code := runScript(t, `test -x exec.sh`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) +} + +func TestTestNamedPipe(t *testing.T) { + dir := t.TempDir() + pipe := filepath.Join(dir, "mypipe") + if err := syscall.Mkfifo(pipe, 0644); err != nil { + t.Skip("cannot create FIFO:", err) + } + + _, _, code := runScript(t, `test -p mypipe`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + + _, _, code = runScript(t, `test -p .`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) +} diff --git a/interp/builtins/testcmd/testcmd_windows_test.go b/interp/builtins/testcmd/testcmd_windows_test.go new file mode 100644 index 00000000..69eecd04 --- /dev/null +++ b/interp/builtins/testcmd/testcmd_windows_test.go @@ -0,0 +1,27 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build windows + +package testcmd_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +func TestTestWindowsReservedNames(t *testing.T) { + dir := t.TempDir() + reserved := []string{"CON", "PRN", "AUX", "NUL", "COM1", "LPT1"} + for _, name := range reserved { + t.Run(name, func(t *testing.T) { + _, _, code := runScript(t, `test -e `+name, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) + }) + } +} diff --git a/interp/portable_unix.go b/interp/portable_unix.go index e1a920b5..371266fb 100644 --- a/interp/portable_unix.go +++ b/interp/portable_unix.go @@ -9,9 +9,66 @@ package interp import ( "errors" + "io/fs" + "os" "syscall" ) func isErrIsDirectory(err error) bool { return errors.Is(err, syscall.EISDIR) } + +// effectiveHasPerm checks whether the current process has the requested +// permission (writeMask or execMask, each a 3-bit pattern like 0222 or 0111) +// by inspecting the file's owner/group/other permission class that applies to +// the effective UID and GID of the running process. +// +// On Unix this uses the Stat_t from info.Sys() to determine the owning +// UID/GID and then selects the owner, group, or other permission bits +// accordingly. If the type assertion fails (should not happen in practice), +// it falls back to checking any-class bits. +func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWrite, checkExec bool) bool { + perm := info.Mode().Perm() + + // Determine which permission class applies to the current process. + // Default to "other" bits and narrow down if we have Stat_t. + ownerBits := fs.FileMode(0007) // other bits by default + if st, ok := info.Sys().(*syscall.Stat_t); ok { + uid := os.Getuid() + gid := os.Getgid() + switch { + case uid == 0: + // root can read/write anything; for execute, any x bit suffices. + ownerBits = 0777 + case int(st.Uid) == uid: + ownerBits = 0700 + case int(st.Gid) == gid: + ownerBits = 0070 + default: + ownerBits = 0007 + // Check supplementary groups — the process may belong to + // additional groups beyond the primary GID. + if groups, err := os.Getgroups(); err == nil { + for _, g := range groups { + if int(st.Gid) == g { + ownerBits = 0070 + break + } + } + } + } + } + + if checkWrite { + // Intersect the write mask with the applicable owner bits. + if perm&writeMask&ownerBits == 0 { + return false + } + } + if checkExec { + if perm&execMask&ownerBits == 0 { + return false + } + } + return true +} diff --git a/interp/portable_windows.go b/interp/portable_windows.go index cca10fbf..7233b4de 100644 --- a/interp/portable_windows.go +++ b/interp/portable_windows.go @@ -7,6 +7,7 @@ package interp import ( "errors" + "io/fs" "syscall" ) @@ -19,3 +20,14 @@ func isErrIsDirectory(err error) bool { } return false } + +// effectiveHasPerm checks whether the current process has the requested +// permission on Windows. Windows does not use Unix UID/GID permission classes, +// so we fall back to checking any-class bits (0222 / 0111) as before. +func effectiveHasPerm(info fs.FileInfo, writeMask, execMask fs.FileMode, checkWrite, checkExec bool) bool { + perm := info.Mode().Perm() + if checkWrite && perm&writeMask == 0 { + return false + } + return !(checkExec && perm&execMask == 0) +} diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 1679eb11..e448728b 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -17,6 +17,7 @@ import ( falsecmd "github.com/DataDog/rshell/interp/builtins/false" "github.com/DataDog/rshell/interp/builtins/head" "github.com/DataDog/rshell/interp/builtins/tail" + "github.com/DataDog/rshell/interp/builtins/testcmd" truecmd "github.com/DataDog/rshell/interp/builtins/true" "github.com/DataDog/rshell/interp/builtins/wc" ) @@ -34,6 +35,8 @@ func registerBuiltins() { falsecmd.Cmd, head.Cmd, tail.Cmd, + testcmd.Cmd, + testcmd.BracketCmd, truecmd.Cmd, wc.Cmd, } { diff --git a/interp/runner_exec.go b/interp/runner_exec.go index ca1df9e8..38530e22 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "io" + "io/fs" "os" "sync" @@ -212,6 +213,15 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { OpenFile: func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) { return r.open(ctx, path, flags, mode, false) }, + StatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { + return r.sandbox.stat(r.handlerCtx(ctx, todoPos), path) + }, + LstatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { + return r.sandbox.lstat(r.handlerCtx(ctx, todoPos), path) + }, + AccessFile: func(ctx context.Context, path string, mode uint32) error { + return r.sandbox.access(r.handlerCtx(ctx, todoPos), path, mode) + }, PortableErr: portableErrMsg, } if r.stdin != nil { // do not assign a typed nil into the io.Reader interface diff --git a/tests/import_allowlist_test.go b/tests/import_allowlist_test.go index 208350dc..82ff4c6f 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -40,6 +40,12 @@ var builtinAllowedSymbols = []string{ "errors.Is", // errors.New — creates a simple error value; no I/O or side effects. "errors.New", + // fs.FileInfo — interface for file metadata; pure type, no side effects. + "io/fs.FileInfo", + // fs.ModeNamedPipe — file mode bit constant; no side effects. + "io/fs.ModeNamedPipe", + // fs.ModeSymlink — file mode bit constant; no side effects. + "io/fs.ModeSymlink", // io.Copy — stream data between reader and writer; builtins receive sandboxed streams. "io.Copy", // io.EOF — sentinel error value; pure constant. @@ -50,6 +56,10 @@ var builtinAllowedSymbols = []string{ "io.ReadCloser", // io.Reader — interface type; no side effects. "io.Reader", + // math.MaxInt64 — integer constant; no side effects. + "math.MaxInt64", + // math.MinInt64 — integer constant; no side effects. + "math.MinInt64", // os.FileInfo — file metadata interface returned by Stat; no I/O side effects. "os.FileInfo", // os.O_RDONLY — read-only file flag constant; cannot open files by itself. @@ -58,10 +68,16 @@ var builtinAllowedSymbols = []string{ "strings.Builder", // strconv.Atoi — string-to-int conversion; pure function, no I/O. "strconv.Atoi", + // strconv.ErrRange — sentinel error value for overflow; pure constant. + "strconv.ErrRange", + // strconv.NumError — error type for numeric conversion failures; pure type. + "strconv.NumError", // strconv.ParseInt — string-to-int conversion with base/bit-size; pure function, no I/O. "strconv.ParseInt", // strconv.FormatInt — int-to-string conversion; pure function, no I/O. "strconv.FormatInt", + // strings.TrimSpace — removes leading/trailing whitespace; pure function. + "strings.TrimSpace", // unicode.Cc — control character category range table; pure data, no I/O. "unicode.Cc", // unicode.Cf — format character category range table; pure data, no I/O. diff --git a/tests/scenarios/cmd/test/bracket/basic.yaml b/tests/scenarios/cmd/test/bracket/basic.yaml new file mode 100644 index 00000000..102573e8 --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/basic.yaml @@ -0,0 +1,17 @@ +# Derived from uutils test_test.rs — bracket syntax tests +description: "[ ] bracket syntax with closing bracket requirement" +input: + script: |+ + [ "hello" ] + echo "basic: $?" + [ 1 -eq 1 ] + echo "eq: $?" + [ 1 -eq 2 ] + echo "ne: $?" +expect: + stdout: | + basic: 0 + eq: 0 + ne: 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/bracket/missing_bracket.yaml b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml new file mode 100644 index 00000000..65cefd64 --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml @@ -0,0 +1,9 @@ +# Derived from uutils test_test.rs — missing closing bracket +description: "[ without closing ] gives exit code 2 and error" +skip_assert_against_bash: true # bash prefixes error with scriptname:line +input: + script: |+ + [ 1 -eq 1 +expect: + stderr: "[: missing `]'\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/test/errors/unary_expected.yaml b/tests/scenarios/cmd/test/errors/unary_expected.yaml new file mode 100644 index 00000000..8c3e402b --- /dev/null +++ b/tests/scenarios/cmd/test/errors/unary_expected.yaml @@ -0,0 +1,7 @@ +# Derived from uutils test_test.rs — -o as unary option test +description: "test -o treats unknown option name as false" +input: + script: |+ + test -o arg +expect: + exit_code: 1 diff --git a/tests/scenarios/cmd/test/files/directory.yaml b/tests/scenarios/cmd/test/files/directory.yaml new file mode 100644 index 00000000..5c51546c --- /dev/null +++ b/tests/scenarios/cmd/test/files/directory.yaml @@ -0,0 +1,20 @@ +# Derived from uutils test_test.rs — test -d on file vs directory +description: "test -d distinguishes directories from regular files" +setup: + files: + - path: afile.txt + content: "x" +input: + allowed_paths: ["$DIR"] + script: |+ + test -d . + echo "dot: $?" + test -d afile.txt + echo "file: $?" +expect: + stdout: | + dot: 0 + file: 1 + stderr: "" + exit_code: 0 +skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/files/empty_operand.yaml b/tests/scenarios/cmd/test/files/empty_operand.yaml new file mode 100644 index 00000000..2a705f07 --- /dev/null +++ b/tests/scenarios/cmd/test/files/empty_operand.yaml @@ -0,0 +1,17 @@ +# Empty filename operand should always return false +description: "test file operators return false for empty operand" +input: + script: |+ + test -e "" + echo "e: $?" + test -f "" + echo "f: $?" + test -d "" + echo "d: $?" +expect: + stdout: | + e: 1 + f: 1 + d: 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/files/existence.yaml b/tests/scenarios/cmd/test/files/existence.yaml new file mode 100644 index 00000000..96cb2a1b --- /dev/null +++ b/tests/scenarios/cmd/test/files/existence.yaml @@ -0,0 +1,30 @@ +# Derived from uutils test_test.rs — file existence and type tests +description: "test -e, -f, -d on existing files and directories" +setup: + files: + - path: regular.txt + content: "hello\n" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + test -e regular.txt + echo "exists: $?" + test -f regular.txt + echo "file: $?" + test -d . + echo "dir: $?" + test -e nonexistent + echo "noexist: $?" + test -f nonexistent + echo "nofile: $?" +expect: + stdout: | + exists: 0 + file: 0 + dir: 0 + noexist: 1 + nofile: 1 + stderr: "" + exit_code: 0 +skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/files/size.yaml b/tests/scenarios/cmd/test/files/size.yaml new file mode 100644 index 00000000..59c31781 --- /dev/null +++ b/tests/scenarios/cmd/test/files/size.yaml @@ -0,0 +1,25 @@ +# Derived from uutils test_test.rs — test -s (size greater than zero) +description: "test -s on empty and non-empty files" +setup: + files: + - path: empty.txt + content: "" + - path: nonempty.txt + content: "data" +input: + allowed_paths: ["$DIR"] + script: |+ + test -s nonempty.txt + echo "nonempty: $?" + test -s empty.txt + echo "empty: $?" + test -s nonexistent + echo "missing: $?" +expect: + stdout: | + nonempty: 0 + empty: 1 + missing: 1 + stderr: "" + exit_code: 0 +skip_assert_against_bash: true # uses allowed_paths which the bash harness does not support diff --git a/tests/scenarios/cmd/test/help/bracket_help.yaml b/tests/scenarios/cmd/test/help/bracket_help.yaml new file mode 100644 index 00000000..53ca164d --- /dev/null +++ b/tests/scenarios/cmd/test/help/bracket_help.yaml @@ -0,0 +1,9 @@ +# Derived from uutils test_test.rs — --help for [ +description: "[ --help prints usage and exits 0" +input: + script: |+ + [ --help +expect: + stdout_contains: ["Usage:"] + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/test/help/test_help.yaml b/tests/scenarios/cmd/test/help/test_help.yaml new file mode 100644 index 00000000..9ef6c66c --- /dev/null +++ b/tests/scenarios/cmd/test/help/test_help.yaml @@ -0,0 +1,9 @@ +# Derived from uutils test_test.rs — --help for test and [ +description: "test --help prints usage and exits 0" +input: + script: |+ + test --help +expect: + stdout_contains: ["Usage:"] + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/test/integers/basic.yaml b/tests/scenarios/cmd/test/integers/basic.yaml new file mode 100644 index 00000000..4a43487a --- /dev/null +++ b/tests/scenarios/cmd/test/integers/basic.yaml @@ -0,0 +1,32 @@ +# Derived from GNU coreutils test.pl eq/gt/lt tests +description: "test integer equality and comparison operators" +input: + script: |+ + test 9 -eq 9 + echo "eq: $?" + test 8 -eq 9 + echo "ne: $?" + test 5 -gt 4 + echo "gt: $?" + test 5 -gt 5 + echo "gt-eq: $?" + test 4 -lt 5 + echo "lt: $?" + test 5 -lt 5 + echo "lt-eq: $?" + test 5 -ge 5 + echo "ge-eq: $?" + test 5 -le 5 + echo "le-eq: $?" +expect: + stdout: | + eq: 0 + ne: 1 + gt: 0 + gt-eq: 1 + lt: 0 + lt-eq: 1 + ge-eq: 0 + le-eq: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integers/invalid.yaml b/tests/scenarios/cmd/test/integers/invalid.yaml new file mode 100644 index 00000000..a9df07df --- /dev/null +++ b/tests/scenarios/cmd/test/integers/invalid.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl inv-1 — invalid integer +description: "test rejects non-decimal integers with exit code 2" +skip_assert_against_bash: true # bash prefixes error with scriptname:line +input: + script: |+ + test 0x0 -eq 00 +expect: + stderr: "test: 0x0: integer expression expected\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/test/integers/negative.yaml b/tests/scenarios/cmd/test/integers/negative.yaml new file mode 100644 index 00000000..daabcf11 --- /dev/null +++ b/tests/scenarios/cmd/test/integers/negative.yaml @@ -0,0 +1,17 @@ +# Derived from GNU coreutils test.pl — negative integer tests +description: "test integer comparison with negative numbers" +input: + script: |+ + test -1 -gt -2 + echo "neg-gt: $?" + test -1 -lt -2 + echo "neg-lt: $?" + test 0 -eq 00 + echo "leading-zero: $?" +expect: + stdout: | + neg-gt: 0 + neg-lt: 1 + leading-zero: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/and_or.yaml b/tests/scenarios/cmd/test/logical/and_or.yaml new file mode 100644 index 00000000..803392b7 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/and_or.yaml @@ -0,0 +1,26 @@ +# Derived from GNU coreutils test.pl and-1..and-4, or-1..or-4 tests +description: "test logical AND (-a) and OR (-o) operators" +input: + script: |+ + test "t" -a "t" + echo "and-tt: $?" + test "" -a "t" + echo "and-ft: $?" + test "t" -a "" + echo "and-tf: $?" + test "t" -o "t" + echo "or-tt: $?" + test "" -o "t" + echo "or-ft: $?" + test "" -o "" + echo "or-ff: $?" +expect: + stdout: | + and-tt: 0 + and-ft: 1 + and-tf: 1 + or-tt: 0 + or-ft: 0 + or-ff: 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/not.yaml b/tests/scenarios/cmd/test/logical/not.yaml new file mode 100644 index 00000000..90c087c0 --- /dev/null +++ b/tests/scenarios/cmd/test/logical/not.yaml @@ -0,0 +1,17 @@ +# Derived from GNU coreutils test.pl — negation with ! +description: "test negation operator !" +input: + script: |+ + test ! "" + echo "not-empty: $?" + test ! "hello" + echo "not-str: $?" + test ! -f /nonexistent + echo "not-file: $?" +expect: + stdout: | + not-empty: 0 + not-str: 1 + not-file: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/parentheses.yaml b/tests/scenarios/cmd/test/logical/parentheses.yaml new file mode 100644 index 00000000..beec0ccf --- /dev/null +++ b/tests/scenarios/cmd/test/logical/parentheses.yaml @@ -0,0 +1,17 @@ +# Derived from GNU coreutils test.pl paren tests +description: "test parenthesized expressions" +input: + script: |+ + test '(' "hello" ')' + echo "paren-str: $?" + test '(' "" ')' + echo "paren-empty: $?" + test '(' "a" = "a" ')' + echo "paren-eq: $?" +expect: + stdout: | + paren-str: 0 + paren-empty: 1 + paren-eq: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logical/special_operands.yaml b/tests/scenarios/cmd/test/logical/special_operands.yaml new file mode 100644 index 00000000..6a9c9c4c --- /dev/null +++ b/tests/scenarios/cmd/test/logical/special_operands.yaml @@ -0,0 +1,23 @@ +# Edge cases where !, (, ) appear as literal operands in binary expressions +description: "test treats !, ( as literal string operands in binary expressions" +input: + script: |+ + test '!' = '!' + echo "bang-eq-bang: $?" + test '!' != 'x' + echo "bang-neq-x: $?" + test '(' = '(' + echo "paren-eq-paren: $?" + test '(' != ')' + echo "paren-neq-close: $?" + test ')' = ')' + echo "close-eq-close: $?" +expect: + stdout: | + bang-eq-bang: 0 + bang-neq-x: 0 + paren-eq-paren: 0 + paren-neq-close: 0 + close-eq-close: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/bare_string.yaml b/tests/scenarios/cmd/test/strings/bare_string.yaml new file mode 100644 index 00000000..17b6bc0c --- /dev/null +++ b/tests/scenarios/cmd/test/strings/bare_string.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl test 1c — bare string is true +description: "test with a non-empty string returns true" +input: + script: |+ + test "any-string" +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/comparison.yaml b/tests/scenarios/cmd/test/strings/comparison.yaml new file mode 100644 index 00000000..2cf40148 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/comparison.yaml @@ -0,0 +1,20 @@ +# Derived from GNU coreutils test.pl less-collate and greater-collate tests +description: "test string lexicographic comparison with < and >" +input: + script: |+ + test "a" \< "b" + echo "lt: $?" + test "a" \< "a" + echo "lt-eq: $?" + test "b" \> "a" + echo "gt: $?" + test "a" \> "a" + echo "gt-eq: $?" +expect: + stdout: | + lt: 0 + lt-eq: 1 + gt: 0 + gt-eq: 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/empty_string.yaml b/tests/scenarios/cmd/test/strings/empty_string.yaml new file mode 100644 index 00000000..f26628ba --- /dev/null +++ b/tests/scenarios/cmd/test/strings/empty_string.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl test 1e — empty string is false +description: "test with empty string argument returns false" +input: + script: |+ + test "" +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/cmd/test/strings/empty_test.yaml b/tests/scenarios/cmd/test/strings/empty_test.yaml new file mode 100644 index 00000000..29d262da --- /dev/null +++ b/tests/scenarios/cmd/test/strings/empty_test.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl — empty test returns false +description: "test with no arguments returns false (exit 1)" +input: + script: |+ + test +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/cmd/test/strings/equality.yaml b/tests/scenarios/cmd/test/strings/equality.yaml new file mode 100644 index 00000000..f05e7c4e --- /dev/null +++ b/tests/scenarios/cmd/test/strings/equality.yaml @@ -0,0 +1,20 @@ +# Derived from GNU coreutils test.pl streq tests +description: "test string equality and inequality" +input: + script: |+ + test "t" = "t" + echo "eq-same: $?" + test "t" = "f" + echo "eq-diff: $?" + test "t" != "t" + echo "ne-same: $?" + test "t" != "f" + echo "ne-diff: $?" +expect: + stdout: | + eq-same: 0 + eq-diff: 1 + ne-same: 1 + ne-diff: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/n_nonempty.yaml b/tests/scenarios/cmd/test/strings/n_nonempty.yaml new file mode 100644 index 00000000..e9d0bddf --- /dev/null +++ b/tests/scenarios/cmd/test/strings/n_nonempty.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl test 1d — -n on non-empty string +description: "test -n on a non-empty string returns true" +input: + script: |+ + test -n "any-string" +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/special_single_args.yaml b/tests/scenarios/cmd/test/strings/special_single_args.yaml new file mode 100644 index 00000000..372d6ef6 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/special_single_args.yaml @@ -0,0 +1,23 @@ +# Derived from GNU coreutils test.pl — special single-arg cases +description: "test treats various special tokens as non-empty strings" +input: + script: |+ + test "-" + echo "dash: $?" + test "--" + echo "ddash: $?" + test "-0" + echo "zero: $?" + test "-f" + echo "flag-f: $?" + test "(" + echo "paren: $?" +expect: + stdout: | + dash: 0 + ddash: 0 + zero: 0 + flag-f: 0 + paren: 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/z_empty.yaml b/tests/scenarios/cmd/test/strings/z_empty.yaml new file mode 100644 index 00000000..e3bbb20c --- /dev/null +++ b/tests/scenarios/cmd/test/strings/z_empty.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl test 1b — -z on empty string +description: "test -z on empty string returns true" +input: + script: |+ + test -z "" +expect: + stdout: "" + stderr: "" + exit_code: 0