From 584dbadefa6a3b9bd1662d44a35b121e2d4a159e Mon Sep 17 00:00:00 2001 From: "datadog-prod-us1-5[bot]" <266081015+datadog-prod-us1-5[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:39:08 +0000 Subject: [PATCH] Implement POSIX test and [ builtin commands Co-authored-by: AlexandreYang <49917914+AlexandreYang@users.noreply.github.com> --- SHELL_COMMANDS.md | 1 + interp/allowed_paths.go | 32 + interp/builtin_test_gnu_compat_test.go | 261 +++++++++ interp/builtin_test_pentest_test.go | 205 +++++++ interp/builtins/builtins.go | 9 +- interp/builtins/test/test.go | 554 ++++++++++++++++++ interp/builtins/test/test_test.go | 411 +++++++++++++ interp/builtins/test/test_unix_test.go | 75 +++ interp/runner_exec.go | 14 + tests/import_allowlist_test.go | 11 + tests/scenarios/cmd/test/bracket/basic.yaml | 11 + .../cmd/test/bracket/missing_bracket.yaml | 8 + .../cmd/test/errors/extra_argument.yaml | 8 + .../cmd/test/errors/invalid_integer.yaml | 8 + .../cmd/test/errors/unary_expected.yaml | 8 + .../cmd/test/files/existence_type.yaml | 18 + tests/scenarios/cmd/test/files/size.yaml | 18 + .../scenarios/cmd/test/help/bracket_help.yaml | 7 + tests/scenarios/cmd/test/help/test_help.yaml | 7 + .../cmd/test/integers/basic_comparison.yaml | 17 + .../scenarios/cmd/test/integers/negative.yaml | 14 + .../scenarios/cmd/test/integers/overflow.yaml | 9 + .../cmd/test/integers/whitespace.yaml | 10 + tests/scenarios/cmd/test/logic/and_or.yaml | 16 + .../cmd/test/logic/complex_parens.yaml | 8 + tests/scenarios/cmd/test/logic/not.yaml | 12 + .../scenarios/cmd/test/logic/parentheses.yaml | 12 + .../scenarios/cmd/test/logic/precedence.yaml | 8 + .../cmd/test/strings/double_equal.yaml | 10 + .../cmd/test/strings/empty_args.yaml | 9 + .../cmd/test/strings/empty_string.yaml | 9 + .../scenarios/cmd/test/strings/equality.yaml | 14 + .../cmd/test/strings/less_greater.yaml | 12 + tests/scenarios/cmd/test/strings/n_and_z.yaml | 12 + .../cmd/test/strings/nonempty_string.yaml | 9 + .../cmd/test/strings/special_literals.yaml | 12 + 36 files changed, 1858 insertions(+), 1 deletion(-) create mode 100644 interp/builtin_test_gnu_compat_test.go create mode 100644 interp/builtin_test_pentest_test.go create mode 100644 interp/builtins/test/test.go create mode 100644 interp/builtins/test/test_test.go create mode 100644 interp/builtins/test/test_unix_test.go create mode 100644 tests/scenarios/cmd/test/bracket/basic.yaml create mode 100644 tests/scenarios/cmd/test/bracket/missing_bracket.yaml create mode 100644 tests/scenarios/cmd/test/errors/extra_argument.yaml create mode 100644 tests/scenarios/cmd/test/errors/invalid_integer.yaml create mode 100644 tests/scenarios/cmd/test/errors/unary_expected.yaml create mode 100644 tests/scenarios/cmd/test/files/existence_type.yaml create mode 100644 tests/scenarios/cmd/test/files/size.yaml create mode 100644 tests/scenarios/cmd/test/help/bracket_help.yaml create mode 100644 tests/scenarios/cmd/test/help/test_help.yaml create mode 100644 tests/scenarios/cmd/test/integers/basic_comparison.yaml create mode 100644 tests/scenarios/cmd/test/integers/negative.yaml create mode 100644 tests/scenarios/cmd/test/integers/overflow.yaml create mode 100644 tests/scenarios/cmd/test/integers/whitespace.yaml create mode 100644 tests/scenarios/cmd/test/logic/and_or.yaml create mode 100644 tests/scenarios/cmd/test/logic/complex_parens.yaml create mode 100644 tests/scenarios/cmd/test/logic/not.yaml create mode 100644 tests/scenarios/cmd/test/logic/parentheses.yaml create mode 100644 tests/scenarios/cmd/test/logic/precedence.yaml create mode 100644 tests/scenarios/cmd/test/strings/double_equal.yaml create mode 100644 tests/scenarios/cmd/test/strings/empty_args.yaml create mode 100644 tests/scenarios/cmd/test/strings/empty_string.yaml create mode 100644 tests/scenarios/cmd/test/strings/equality.yaml create mode 100644 tests/scenarios/cmd/test/strings/less_greater.yaml create mode 100644 tests/scenarios/cmd/test/strings/n_and_z.yaml create mode 100644 tests/scenarios/cmd/test/strings/nonempty_string.yaml create mode 100644 tests/scenarios/cmd/test/strings/special_literals.yaml diff --git a/SHELL_COMMANDS.md b/SHELL_COMMANDS.md index 618f420f..312d829c 100644 --- a/SHELL_COMMANDS.md +++ b/SHELL_COMMANDS.md @@ -9,6 +9,7 @@ Short reference for builtin commands | `echo [ARG ...]` | none | Print arguments separated by spaces, then newline. | | `cat [FILE ...]` | `-` (read stdin) | Print files; with no args, read stdin. | | `head [FILE ...]` | `-n N` (lines), `-c N` (bytes), `-q`/`--quiet`/`--silent` (no headers), `-v` (force headers) | Print first 10 lines of each FILE; with no FILE or `-`, read stdin. | +| `test EXPR` / `[ EXPR ]` | `-n`/`-z` (string), `=`/`!=`/`<`/`>` (string cmp), `-eq`/`-ne`/`-gt`/`-ge`/`-lt`/`-le` (int cmp), `-e`/`-f`/`-d`/`-s`/`-r`/`-w`/`-x`/`-L` (file), `!`/`-a`/`-o` (logic) | Evaluate conditional expression; exit 0 (true) or 1 (false). | | `exit [N]` | `N` (status code) | Exit the shell with `N` (default: last status). | | `break [N]` | `N` (loop levels) | Break current loop, or `N` enclosing loops. | | `continue [N]` | `N` (loop levels) | Continue current loop, or `N` enclosing loops. | diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go index 50dce290..cd316f37 100644 --- a/interp/allowed_paths.go +++ b/interp/allowed_paths.go @@ -107,6 +107,38 @@ func (s *pathSandbox) open(ctx context.Context, path string, flag int, perm os.F return f, nil } +// stat returns file information following symlinks, restricted to allowed paths. +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 returns file information without following symlinks, restricted to allowed paths. +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 +} + // readDir implements the restricted directory-read policy. func (s *pathSandbox) readDir(ctx context.Context, path string) ([]fs.DirEntry, error) { absPath := toAbs(path, HandlerCtx(ctx).Dir) diff --git a/interp/builtin_test_gnu_compat_test.go b/interp/builtin_test_gnu_compat_test.go new file mode 100644 index 00000000..1e1e0914 --- /dev/null +++ b/interp/builtin_test_gnu_compat_test.go @@ -0,0 +1,261 @@ +// 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 interp_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +func setupTestDir(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) + } + return dir +} + +func testCmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +// TestGNUCompatTestStringEmpty — empty string is false. +// GNU test: test ''; echo $? → exit 1 +func TestGNUCompatTestStringEmpty(t *testing.T) { + _, _, code := runScript(t, `test ""`, "") + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestStringNonEmpty — non-empty string is true. +// GNU test: test 'hello'; echo $? → exit 0 +func TestGNUCompatTestStringNonEmpty(t *testing.T) { + _, _, code := runScript(t, `test "hello"`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestNoArgs — no arguments returns false. +// GNU test: test; echo $? → exit 1 +func TestGNUCompatTestNoArgs(t *testing.T) { + _, _, code := runScript(t, `test`, "") + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestStrEq — string equality with =. +// GNU test: test t = t; echo $? → exit 0 +func TestGNUCompatTestStrEq(t *testing.T) { + _, _, code := runScript(t, `test "t" = "t"`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestStrEqFail — string equality fails. +// GNU test: test t = f; echo $? → exit 1 +func TestGNUCompatTestStrEqFail(t *testing.T) { + _, _, code := runScript(t, `test "t" = "f"`, "") + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestStrNe — string inequality. +// GNU test: test t != f; echo $? → exit 0 +func TestGNUCompatTestStrNe(t *testing.T) { + _, _, code := runScript(t, `test "t" != "f"`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestStrDoubleEq — == as alias for =. +// GNU test: test t == t; echo $? → exit 0 +func TestGNUCompatTestStrDoubleEq(t *testing.T) { + _, _, code := runScript(t, `test "t" == "t"`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestIntEq — integer equality. +// GNU test: test 9 -eq 9; echo $? → exit 0 +func TestGNUCompatTestIntEq(t *testing.T) { + _, _, code := runScript(t, `test 9 -eq 9`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestIntLeadingZero — leading zeros don't trigger octal. +// GNU test: test 0 -eq 00; echo $? → exit 0 +func TestGNUCompatTestIntLeadingZero(t *testing.T) { + _, _, code := runScript(t, `test 0 -eq 00`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestIntWhitespace — whitespace around integer operands. +// GNU test: test 0 -eq ' 0 '; echo $? → exit 0 +func TestGNUCompatTestIntWhitespace(t *testing.T) { + _, _, code := runScript(t, `test 0 -eq " 0 "`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestIntNegative — negative integers. +// GNU test: test -1 -gt -2; echo $? → exit 0 +func TestGNUCompatTestIntNegative(t *testing.T) { + _, _, code := runScript(t, `test -1 -gt -2`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestHexInvalid — hex is invalid. +// GNU test: test 0x0 -eq 00; echo $? → exit 2, stderr: "invalid integer '0x0'" +func TestGNUCompatTestHexInvalid(t *testing.T) { + _, stderr, code := runScript(t, `test 0x0 -eq 00`, "") + assert.Equal(t, 2, code) + assert.Equal(t, "test: invalid integer '0x0'\n", stderr) +} + +// TestGNUCompatTestNotEmpty — ! negates empty string. +// GNU test: test ! ''; echo $? → exit 0 +func TestGNUCompatTestNotEmpty(t *testing.T) { + _, _, code := runScript(t, `test ! ""`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestAndBothTrue — -a with both true. +// GNU test: test t -a t; echo $? → exit 0 +func TestGNUCompatTestAndBothTrue(t *testing.T) { + _, _, code := runScript(t, `test "t" -a "t"`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestOrBothFalse — -o with both false. +// GNU test: test '' -o ''; echo $? → exit 1 +func TestGNUCompatTestOrBothFalse(t *testing.T) { + _, _, code := runScript(t, `test "" -o ""`, "") + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestParenEmpty — parenthesized empty string is false. +// GNU test: test '(' '' ')'; echo $? → exit 1 +func TestGNUCompatTestParenEmpty(t *testing.T) { + _, _, code := runScript(t, `test "(" "" ")"`, "") + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestParenNonEmpty — parenthesized non-empty string is true. +// GNU test: test '(' '(' ')'; echo $? → exit 0 +func TestGNUCompatTestParenNonEmpty(t *testing.T) { + _, _, code := runScript(t, `test "(" "(" ")"`, "") + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestBracketMissing — [ without ] gives error. +// GNU [: [ 1 -eq; echo $? → exit 2, stderr: "[: missing ']'" +func TestGNUCompatTestBracketMissing(t *testing.T) { + _, stderr, code := runScript(t, `[ 1 -eq`, "") + assert.Equal(t, 2, code) + assert.Equal(t, "[: missing ']'\n", stderr) +} + +// TestGNUCompatTestLessCollate — < for string comparison. +// GNU test: test 'a' '<' 'b'; echo $? → exit 0 +func TestGNUCompatTestLessCollate(t *testing.T) { + _, _, code := runScript(t, `test "a" \< "b"`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test "a" \< "a"`, "") + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestGreaterCollate — > for string comparison. +// GNU test: test 'b' '>' 'a'; echo $? → exit 0 +func TestGNUCompatTestGreaterCollate(t *testing.T) { + _, _, code := runScript(t, `test "b" \> "a"`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test "a" \> "a"`, "") + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestUnaryDiag — unary operator expected diagnostic. +// GNU test: test -o arg; echo $? → exit 2, stderr: "test: '-o': unary operator expected" +func TestGNUCompatTestUnaryDiag(t *testing.T) { + _, stderr, code := runScript(t, `test -o arg`, "") + assert.Equal(t, 2, code) + assert.Equal(t, "test: '-o': unary operator expected\n", stderr) +} + +// TestGNUCompatTestFileExists — -e on an existing file. +// GNU test: test -e file.txt; echo $? → exit 0 +func TestGNUCompatTestFileExists(t *testing.T) { + dir := setupTestDir(t, map[string]string{"file.txt": "data"}) + _, _, code := testCmdRun(t, `test -e file.txt`, dir) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestFileNotExists — -e on a nonexistent file. +// GNU test: test -e nonexistent; echo $? → exit 1 +func TestGNUCompatTestFileNotExists(t *testing.T) { + dir := t.TempDir() + _, _, code := testCmdRun(t, `test -e nonexistent`, dir) + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestFileRegular — -f on a regular file. +// GNU test: test -f file.txt; echo $? → exit 0 +func TestGNUCompatTestFileRegular(t *testing.T) { + dir := setupTestDir(t, map[string]string{"file.txt": "data"}) + _, _, code := testCmdRun(t, `test -f file.txt`, dir) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestFileDir — -d on a directory. +// GNU test: test -d .; echo $? → exit 0 +func TestGNUCompatTestFileDir(t *testing.T) { + dir := t.TempDir() + _, _, code := testCmdRun(t, `test -d .`, dir) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestFileNtMissing — -nt with one missing file. +// GNU test: test file -nt missing; echo $? → exit 0 +func TestGNUCompatTestFileNtMissing(t *testing.T) { + dir := setupTestDir(t, map[string]string{"file.txt": "data"}) + _, _, code := testCmdRun(t, `test file.txt -nt missing`, dir) + assert.Equal(t, 0, code) + _, _, code = testCmdRun(t, `test missing -nt file.txt`, dir) + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestFileOtMissing — -ot with one missing file. +// GNU test: test missing -ot file; echo $? → exit 0 +func TestGNUCompatTestFileOtMissing(t *testing.T) { + dir := setupTestDir(t, map[string]string{"file.txt": "data"}) + _, _, code := testCmdRun(t, `test missing -ot file.txt`, dir) + assert.Equal(t, 0, code) + _, _, code = testCmdRun(t, `test file.txt -ot missing`, dir) + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestFileEfSelf — -ef on same file. +// GNU test: test file -ef file; echo $? → exit 0 +func TestGNUCompatTestFileEfSelf(t *testing.T) { + dir := setupTestDir(t, map[string]string{"file.txt": "data"}) + _, _, code := testCmdRun(t, `test file.txt -ef file.txt`, dir) + assert.Equal(t, 0, code) +} + +// TestGNUCompatTestFileEfDifferent — -ef on different files. +// GNU test: test file1 -ef file2; echo $? → exit 1 +func TestGNUCompatTestFileEfDifferent(t *testing.T) { + dir := setupTestDir(t, map[string]string{"a.txt": "a", "b.txt": "b"}) + _, _, code := testCmdRun(t, `test a.txt -ef b.txt`, dir) + assert.Equal(t, 1, code) +} + +// TestGNUCompatTestFileEfMissing — -ef with missing files. +// GNU test: test missing1 -ef missing2; echo $? → exit 1 +func TestGNUCompatTestFileEfMissing(t *testing.T) { + dir := t.TempDir() + _, _, code := testCmdRun(t, `test missing1 -ef missing2`, dir) + assert.Equal(t, 1, code) +} diff --git a/interp/builtin_test_pentest_test.go b/interp/builtin_test_pentest_test.go new file mode 100644 index 00000000..a008fa9a --- /dev/null +++ b/interp/builtin_test_pentest_test.go @@ -0,0 +1,205 @@ +// 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 interp_test + +import ( + "math" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +func testPentestRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +// --- Integer edge cases --- + +func TestTestPentestIntEqZero(t *testing.T) { + _, _, code := runScript(t, `test 0 -eq 0`, "") + assert.Equal(t, 0, code) +} + +func TestTestPentestIntMaxInt32(t *testing.T) { + s := strconv.FormatInt(math.MaxInt32, 10) + _, _, code := runScript(t, `test `+s+` -eq `+s, "") + assert.Equal(t, 0, code) +} + +func TestTestPentestIntMaxInt64(t *testing.T) { + s := strconv.FormatInt(math.MaxInt64, 10) + _, _, code := runScript(t, `test `+s+` -eq `+s, "") + assert.Equal(t, 0, code) +} + +func TestTestPentestIntMaxInt64PlusOne(t *testing.T) { + _, stderr, code := runScript(t, `test 9223372036854775808 -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +func TestTestPentestIntHugeOverflow(t *testing.T) { + _, stderr, code := runScript(t, `test 99999999999999999999 -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +func TestTestPentestIntMinInt64MinusOne(t *testing.T) { + _, stderr, code := runScript(t, `test -9223372036854775809 -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +func TestTestPentestIntEmptyString(t *testing.T) { + _, stderr, code := runScript(t, `test "" -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +func TestTestPentestIntWhitespaceOnly(t *testing.T) { + _, stderr, code := runScript(t, `test " " -eq 0`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +func TestTestPentestIntFloat(t *testing.T) { + _, stderr, code := runScript(t, `test 3.14 -eq 3`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +func TestTestPentestIntHex(t *testing.T) { + _, stderr, code := runScript(t, `test 0xff -eq 255`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +// --- Path and filename edge cases --- + +func TestTestPentestNonexistentFile(t *testing.T) { + dir := t.TempDir() + _, _, code := testPentestRun(t, `test -e nonexistent_file`, dir) + assert.Equal(t, 1, code) +} + +func TestTestPentestEmptyFilename(t *testing.T) { + dir := t.TempDir() + _, _, code := testPentestRun(t, `test -e ""`, dir) + assert.Equal(t, 1, code) +} + +func TestTestPentestDirectoryAsRegularFile(t *testing.T) { + dir := t.TempDir() + _, _, code := testPentestRun(t, `test -f .`, dir) + assert.Equal(t, 1, code) +} + +func TestTestPentestOutsideSandbox(t *testing.T) { + dir := t.TempDir() + _, _, code := testPentestRun(t, `test -e /etc/passwd`, dir) + assert.Equal(t, 1, code) +} + +func TestTestPentestPathTraversal(t *testing.T) { + dir := t.TempDir() + _, _, code := testPentestRun(t, `test -e ../../../etc/passwd`, dir) + assert.Equal(t, 1, code) +} + +func TestTestPentestDoubleSlashes(t *testing.T) { + dir := setupTestDir(t, map[string]string{"file.txt": "data"}) + _, _, code := testPentestRun(t, `test -e .//file.txt`, dir) + assert.Equal(t, 0, code) +} + +// --- Flag and argument edge cases --- + +func TestTestPentestSoloFlagLikeString(t *testing.T) { + _, _, code := runScript(t, `test "--no-such-flag"`, "") + assert.Equal(t, 0, code) +} + +func TestTestPentestUnknownTwoArgOperator(t *testing.T) { + _, stderr, code := runScript(t, `test --follow file`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "unary operator expected") +} + +func TestTestPentestDoubleDashEquals(t *testing.T) { + _, _, code := runScript(t, `test "--" "=" "--"`, "") + assert.Equal(t, 0, code) +} + +// --- Bracket edge cases --- + +func TestTestPentestBracketNoArgs(t *testing.T) { + _, stderr, code := runScript(t, `[`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing ']'") +} + +func TestTestPentestBracketOnlyClose(t *testing.T) { + _, _, code := runScript(t, `[ ]`, "") + assert.Equal(t, 1, code) +} + +func TestTestPentestBracketMultipleClose(t *testing.T) { + _, _, code := runScript(t, `[ "]" = "]" ]`, "") + assert.Equal(t, 0, code) +} + +// --- Deeply nested expressions --- + +func TestTestPentestDeeplyNestedNot(t *testing.T) { + _, _, code := runScript(t, `test ! ! ! ! ! ""`, "") + assert.Equal(t, 0, code) +} + +func TestTestPentestDeeplyNestedParens(t *testing.T) { + _, _, code := runScript(t, `test "(" "(" "(" "hello" ")" ")" ")"`, "") + assert.Equal(t, 0, code) +} + +// --- Behavior matching --- + +func TestTestPentestSoloAnd(t *testing.T) { + _, _, code := runScript(t, `test "-a"`, "") + assert.Equal(t, 0, code) +} + +func TestTestPentestSoloOr(t *testing.T) { + _, _, code := runScript(t, `test "-o"`, "") + assert.Equal(t, 0, code) +} + +func TestTestPentestNtBothMissing(t *testing.T) { + dir := t.TempDir() + _, _, code := testPentestRun(t, `test miss1 -nt miss2`, dir) + assert.Equal(t, 1, code) +} + +func TestTestPentestOtBothMissing(t *testing.T) { + dir := t.TempDir() + _, _, code := testPentestRun(t, `test miss1 -ot miss2`, dir) + assert.Equal(t, 1, code) +} + +func TestTestPentestEfBothMissing(t *testing.T) { + dir := t.TempDir() + _, _, code := testPentestRun(t, `test miss1 -ef miss2`, dir) + assert.Equal(t, 1, code) +} + +func TestTestPentestVariableExpansion(t *testing.T) { + stdout, _, code := runScript(t, `x="-z"; test $x "" && echo yes`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + diff --git a/interp/builtins/builtins.go b/interp/builtins/builtins.go index 05c92e1f..fe661a66 100644 --- a/interp/builtins/builtins.go +++ b/interp/builtins/builtins.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "io" + "io/fs" "os" ) @@ -31,6 +32,13 @@ 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 information within the shell's path restrictions. + // It follows symlinks (like os.Stat). + StatFile func(ctx context.Context, path string) (fs.FileInfo, error) + + // LstatFile returns file information without following symlinks. + LstatFile func(ctx context.Context, path string) (fs.FileInfo, error) + // PortableErr normalizes an OS error to a POSIX-style message. PortableErr func(err error) string } @@ -81,4 +89,3 @@ func Lookup(name string) (HandlerFunc, bool) { fn, ok := registry[name] return fn, ok } - diff --git a/interp/builtins/test/test.go b/interp/builtins/test/test.go new file mode 100644 index 00000000..d801de8f --- /dev/null +++ b/interp/builtins/test/test.go @@ -0,0 +1,554 @@ +// 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 and [ builtin commands. +// +// test — evaluate conditional expression +// +// Usage: +// +// test EXPRESSION +// [ EXPRESSION ] +// +// Evaluate EXPRESSION and exit with status 0 (true) or 1 (false). +// With no EXPRESSION, test returns 1 (false). The [ form requires +// a closing ] as the last argument. +// +// String operators: +// +// -n STRING True if string length is non-zero. +// -z STRING True if string length is zero. +// STRING True if string is not empty (1-argument form). +// S1 = S2 True if strings are identical. +// S1 == S2 True if strings are identical (alias for =). +// S1 != S2 True if strings differ. +// S1 < S2 True if S1 sorts before S2 lexicographically. +// S1 > S2 True if S1 sorts after S2 lexicographically. +// +// Integer comparison operators: +// +// N1 -eq N2 True if integers are equal. +// N1 -ne N2 True if integers are not equal. +// N1 -gt N2 True if N1 is greater than N2. +// N1 -ge N2 True if N1 is greater than or equal to N2. +// N1 -lt N2 True if N1 is less than N2. +// N1 -le N2 True if N1 is less than or equal to N2. +// +// File test operators: +// +// -e FILE True if file exists. +// -f FILE True if file exists and is a regular file. +// -d FILE True if file exists and is a directory. +// -s FILE True if file exists and has size greater than zero. +// -r FILE True if file exists and is readable. +// -w FILE True if file exists and is writable. +// -x FILE True if file exists and is executable. +// -L FILE True if file is a symbolic link. +// -h FILE True if file is a symbolic link (alias for -L). +// -b FILE True if file is a block special file. +// -c FILE True if file is a character special file. +// -p FILE True if file is a named pipe (FIFO). +// -S FILE True if file is a socket. +// -g FILE True if file has the set-group-ID bit set. +// -u FILE True if file has the set-user-ID bit set. +// -k FILE True if file has the sticky bit set. +// FILE1 -nt FILE2 True if FILE1 is newer than FILE2. +// FILE1 -ot FILE2 True if FILE1 is older than FILE2. +// FILE1 -ef FILE2 True if FILE1 and FILE2 refer to the same device and inode. +// +// Logical operators: +// +// ! EXPRESSION Logical NOT. +// EXPR1 -a EXPR2 Logical AND. +// EXPR1 -o EXPR2 Logical OR. +// ( EXPRESSION ) Grouping for precedence. +// +// Exit codes: +// +// 0 Expression evaluated to true. +// 1 Expression evaluated to false or expression is missing. +// 2 An error occurred (syntax error, invalid argument, etc.). +package test + +import ( + "context" + "os" + "strconv" + "strings" + + "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 { + if len(args) == 1 && args[0] == "--help" { + printHelp(callCtx, "test") + return builtins.Result{} + } + return eval(ctx, callCtx, "test", args) +} + +func runBracket(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) == 0 || args[len(args)-1] != "]" { + callCtx.Errf("[: missing ']'\n") + return builtins.Result{Code: 2} + } + inner := args[:len(args)-1] + if len(inner) == 1 && inner[0] == "--help" { + printHelp(callCtx, "[") + return builtins.Result{} + } + return eval(ctx, callCtx, "[", inner) +} + +func printHelp(callCtx *builtins.CallContext, cmdName string) { + if cmdName == "[" { + callCtx.Out("Usage: [ EXPRESSION ]\n") + } else { + callCtx.Out("Usage: test EXPRESSION\n") + } + callCtx.Out("Evaluate conditional expression.\n\n") + callCtx.Out("String operators:\n") + callCtx.Out(" -n STRING true if string length is non-zero\n") + callCtx.Out(" -z STRING true if string length is zero\n") + callCtx.Out(" S1 = S2 true if strings are equal\n") + callCtx.Out(" S1 != S2 true if strings differ\n") + callCtx.Out(" S1 < S2 true if S1 sorts before S2\n") + callCtx.Out(" S1 > S2 true if S1 sorts after S2\n\n") + callCtx.Out("Integer operators:\n") + callCtx.Out(" N1 -eq N2 true if integers are equal\n") + callCtx.Out(" N1 -ne N2 true if integers are not equal\n") + callCtx.Out(" N1 -gt N2 true if N1 > N2\n") + callCtx.Out(" N1 -ge N2 true if N1 >= N2\n") + callCtx.Out(" N1 -lt N2 true if N1 < N2\n") + callCtx.Out(" N1 -le N2 true if N1 <= N2\n\n") + callCtx.Out("File operators:\n") + callCtx.Out(" -e FILE true if file exists\n") + callCtx.Out(" -f FILE true if file is a regular file\n") + callCtx.Out(" -d FILE true if file is a directory\n") + callCtx.Out(" -s FILE true if file has size > 0\n") + callCtx.Out(" -r FILE true if file is readable\n") + callCtx.Out(" -w FILE true if file is writable\n") + callCtx.Out(" -x FILE true if file is executable\n") + callCtx.Out(" -L/-h FILE true if file is a symbolic link\n") + callCtx.Out(" FILE1 -nt FILE2 true if FILE1 is newer\n") + callCtx.Out(" FILE1 -ot FILE2 true if FILE1 is older\n") + callCtx.Out(" FILE1 -ef FILE2 true if same file\n\n") + callCtx.Out("Logical operators:\n") + callCtx.Out(" ! EXPR logical NOT\n") + callCtx.Out(" EXPR1 -a EXPR2 logical AND\n") + callCtx.Out(" EXPR1 -o EXPR2 logical OR\n") + callCtx.Out(" ( EXPR ) grouping\n") +} + +// eval evaluates a test expression and returns the result. +// It uses the POSIX algorithm for 0–4 arguments and falls back to +// recursive descent for 5+ arguments, matching GNU coreutils behavior. +func eval(ctx context.Context, callCtx *builtins.CallContext, cmdName string, args []string) builtins.Result { + e := &evaluator{ + ctx: ctx, + callCtx: callCtx, + cmdName: cmdName, + } + + var result bool + var err error + + switch { + case len(args) == 0: + return builtins.Result{Code: 1} + case len(args) <= 4: + result, err = e.posixEval(args) + default: + p := &parser{args: args} + result, err = e.orExpr(p) + if err == nil && !p.done() { + err = testError("extra argument '" + p.peek() + "'") + } + } + + if err != nil { + callCtx.Errf("%s: %s\n", cmdName, err) + return builtins.Result{Code: 2} + } + if result { + return builtins.Result{} + } + return builtins.Result{Code: 1} +} + +// testError is a simple error type that avoids importing fmt or errors. +type testError string + +func (e testError) Error() string { return string(e) } + +// parser tracks position within a token slice for recursive descent parsing. +type parser struct { + args []string + pos int +} + +func (p *parser) done() bool { return p.pos >= len(p.args) } +func (p *parser) peek() string { return p.args[p.pos] } +func (p *parser) remaining() int { return len(p.args) - p.pos } +func (p *parser) advance() string { s := p.args[p.pos]; p.pos++; return s } + +// evaluator holds the context needed for expression evaluation. +type evaluator struct { + ctx context.Context + callCtx *builtins.CallContext + cmdName string +} + +// --- POSIX algorithm for 0-4 arguments --- + +func (e *evaluator) posixEval(args []string) (bool, error) { + switch len(args) { + case 0: + return false, nil + case 1: + return args[0] != "", nil + case 2: + return e.posixTwoArgs(args) + case 3: + return e.posixThreeArgs(args) + case 4: + return e.posixFourArgs(args) + } + return false, nil +} + +func (e *evaluator) posixTwoArgs(args []string) (bool, error) { + if args[0] == "!" { + return args[1] == "", nil + } + if isUnaryOp(args[0]) { + return e.evalUnary(args[0], args[1]) + } + return false, testError("'" + args[0] + "': unary operator expected") +} + +func (e *evaluator) posixThreeArgs(args []string) (bool, error) { + if isBinaryOp(args[1]) { + return e.evalBinary(args[0], args[1], args[2]) + } + if args[0] == "!" { + result, err := e.posixTwoArgs(args[1:]) + if err != nil { + return false, err + } + return !result, nil + } + if args[0] == "(" && args[2] == ")" { + return args[1] != "", nil + } + p := &parser{args: args} + result, err := e.orExpr(p) + if err == nil && !p.done() { + err = testError("extra argument '" + p.peek() + "'") + } + return result, err +} + +func (e *evaluator) posixFourArgs(args []string) (bool, error) { + if args[0] == "!" { + result, err := e.posixThreeArgs(args[1:]) + if err != nil { + return false, err + } + return !result, nil + } + if args[0] == "(" && args[3] == ")" { + return e.posixTwoArgs(args[1:3]) + } + p := &parser{args: args} + result, err := e.orExpr(p) + if err == nil && !p.done() { + err = testError("extra argument '" + p.peek() + "'") + } + return result, err +} + +// --- Recursive descent parser for 5+ arguments --- + +func (e *evaluator) orExpr(p *parser) (bool, error) { + result, err := e.andExpr(p) + if err != nil { + return false, err + } + for !p.done() && p.peek() == "-o" { + p.advance() + right, err := e.andExpr(p) + if err != nil { + return false, err + } + result = result || right + } + return result, nil +} + +func (e *evaluator) andExpr(p *parser) (bool, error) { + result, err := e.notExpr(p) + if err != nil { + return false, err + } + for !p.done() && p.peek() == "-a" { + p.advance() + right, err := e.notExpr(p) + if err != nil { + return false, err + } + result = result && right + } + return result, nil +} + +func (e *evaluator) notExpr(p *parser) (bool, error) { + if !p.done() && p.peek() == "!" { + p.advance() + result, err := e.notExpr(p) + if err != nil { + return false, err + } + return !result, nil + } + return e.primary(p) +} + +func (e *evaluator) primary(p *parser) (bool, error) { + if p.done() { + return false, testError("missing argument after '" + e.cmdName + "'") + } + + tok := p.peek() + + if tok == "(" { + p.advance() + if p.done() { + return false, testError("missing argument after '('") + } + result, err := e.orExpr(p) + if err != nil { + return false, err + } + if p.done() || p.peek() != ")" { + return false, testError("missing ')'") + } + p.advance() + return result, nil + } + + if p.remaining() >= 3 && isBinaryOp(p.args[p.pos+1]) { + left := p.advance() + op := p.advance() + right := p.advance() + return e.evalBinary(left, op, right) + } + + if isUnaryOp(tok) && p.remaining() >= 2 { + op := p.advance() + operand := p.advance() + return e.evalUnary(op, operand) + } + + p.advance() + return tok != "", nil +} + +// --- Operator classification --- + +var unaryOps = map[string]bool{ + "-n": true, "-z": true, + "-e": true, "-f": true, "-d": true, "-s": true, + "-r": true, "-w": true, "-x": true, + "-L": true, "-h": true, + "-b": true, "-c": true, "-p": true, "-S": true, + "-g": true, "-u": true, "-k": true, +} + +func isUnaryOp(s string) bool { return unaryOps[s] } + +var binaryOps = map[string]bool{ + "=": true, "==": true, "!=": true, "<": true, ">": true, + "-eq": true, "-ne": true, "-gt": true, "-ge": true, "-lt": true, "-le": true, + "-nt": true, "-ot": true, "-ef": true, +} + +func isBinaryOp(s string) bool { return binaryOps[s] } + +// --- Unary evaluation --- + +func (e *evaluator) statFile(path string) (os.FileInfo, error) { + if path == "" { + return nil, testError("empty path") + } + return e.callCtx.StatFile(e.ctx, path) +} + +func (e *evaluator) lstatFile(path string) (os.FileInfo, error) { + if path == "" { + return nil, testError("empty path") + } + return e.callCtx.LstatFile(e.ctx, path) +} + +func (e *evaluator) evalUnary(op, operand string) (bool, error) { + switch op { + case "-n": + return operand != "", nil + case "-z": + return operand == "", nil + + case "-e": + _, err := e.statFile(operand) + return err == nil, nil + case "-f": + info, err := e.statFile(operand) + return err == nil && info.Mode().IsRegular(), nil + case "-d": + info, err := e.statFile(operand) + return err == nil && info.IsDir(), nil + case "-s": + info, err := e.statFile(operand) + return err == nil && info.Size() > 0, nil + case "-r": + info, err := e.statFile(operand) + return err == nil && info.Mode().Perm()&0444 != 0, nil + case "-w": + info, err := e.statFile(operand) + return err == nil && info.Mode().Perm()&0222 != 0, nil + case "-x": + info, err := e.statFile(operand) + return err == nil && info.Mode().Perm()&0111 != 0, nil + case "-L", "-h": + info, err := e.lstatFile(operand) + return err == nil && info.Mode()&os.ModeSymlink != 0, nil + case "-b": + info, err := e.statFile(operand) + return err == nil && info.Mode()&os.ModeDevice != 0 && info.Mode()&os.ModeCharDevice == 0, nil + case "-c": + info, err := e.statFile(operand) + return err == nil && info.Mode()&os.ModeCharDevice != 0, nil + case "-p": + info, err := e.statFile(operand) + return err == nil && info.Mode()&os.ModeNamedPipe != 0, nil + case "-S": + info, err := e.statFile(operand) + return err == nil && info.Mode()&os.ModeSocket != 0, nil + case "-g": + info, err := e.statFile(operand) + return err == nil && info.Mode()&os.ModeSetgid != 0, nil + case "-u": + info, err := e.statFile(operand) + return err == nil && info.Mode()&os.ModeSetuid != 0, nil + case "-k": + info, err := e.statFile(operand) + return err == nil && info.Mode()&os.ModeSticky != 0, nil + } + return false, testError("'" + op + "': unary operator expected") +} + +// --- Binary evaluation --- + +func (e *evaluator) evalBinary(left, op, right string) (bool, error) { + switch op { + case "=", "==": + return left == right, nil + case "!=": + return left != right, nil + case "<": + return left < right, nil + case ">": + return left > right, nil + + case "-eq", "-ne", "-gt", "-ge", "-lt", "-le": + return e.evalIntCmp(left, op, right) + + case "-nt": + return e.evalNt(left, right) + case "-ot": + return e.evalOt(left, right) + case "-ef": + return e.evalEf(left, right) + } + return false, testError("'" + op + "': binary operator expected") +} + +func (e *evaluator) evalIntCmp(left, op, right string) (bool, error) { + l, err := parseTestInt(left) + if err != nil { + return false, testError("invalid integer '" + left + "'") + } + r, err := parseTestInt(right) + if err != nil { + return false, testError("invalid integer '" + right + "'") + } + switch op { + case "-eq": + return l == r, nil + case "-ne": + return l != r, nil + case "-gt": + return l > r, nil + case "-ge": + return l >= r, nil + case "-lt": + return l < r, nil + case "-le": + return l <= r, nil + } + return false, nil +} + +func parseTestInt(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, testError("empty") + } + return strconv.ParseInt(s, 10, 64) +} + +func (e *evaluator) evalNt(left, right string) (bool, error) { + li, lerr := e.callCtx.StatFile(e.ctx, left) + ri, rerr := e.callCtx.StatFile(e.ctx, right) + if lerr != nil && rerr != nil { + return false, nil + } + if lerr != nil { + return false, nil + } + if rerr != nil { + return true, nil + } + return li.ModTime().After(ri.ModTime()), nil +} + +func (e *evaluator) evalOt(left, right string) (bool, error) { + li, lerr := e.callCtx.StatFile(e.ctx, left) + ri, rerr := e.callCtx.StatFile(e.ctx, right) + if lerr != nil && rerr != nil { + return false, nil + } + if lerr != nil { + return true, nil + } + if rerr != nil { + return false, nil + } + return li.ModTime().Before(ri.ModTime()), nil +} + +func (e *evaluator) evalEf(left, right string) (bool, error) { + li, lerr := e.callCtx.StatFile(e.ctx, left) + ri, rerr := e.callCtx.StatFile(e.ctx, right) + if lerr != nil || rerr != nil { + return false, nil + } + return os.SameFile(li, ri), nil +} diff --git a/interp/builtins/test/test_test.go b/interp/builtins/test/test_test.go new file mode 100644 index 00000000..9d3e2846 --- /dev/null +++ b/interp/builtins/test/test_test.go @@ -0,0 +1,411 @@ +// 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" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" + _ "github.com/DataDog/rshell/interp/builtins/test" + "mvdan.cc/sh/v3/syntax" +) + +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return runScriptCtx(context.Background(), t, script, dir, opts...) +} + +func runScriptCtx(ctx context.Context, 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(ctx, prog) + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else if ctx.Err() == nil { + t.Fatalf("unexpected error: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +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() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + return name +} + +// --- Zero/one-argument forms --- + +func TestTestNoArgs(t *testing.T) { + _, _, code := runScript(t, "test", "") + assert.Equal(t, 1, code) +} + +func TestTestEmptyString(t *testing.T) { + _, _, code := runScript(t, `test ""`, "") + assert.Equal(t, 1, code) +} + +func TestTestNonEmptyString(t *testing.T) { + _, _, code := runScript(t, `test "hello"`, "") + assert.Equal(t, 0, code) +} + +func TestTestDash(t *testing.T) { + _, _, code := runScript(t, `test "-"`, "") + assert.Equal(t, 0, code) +} + +func TestTestDoubleDash(t *testing.T) { + _, _, code := runScript(t, `test "--"`, "") + assert.Equal(t, 0, code) +} + +// --- String operators --- + +func TestTestStringN(t *testing.T) { + _, _, code := runScript(t, `test -n "abc"`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test -n ""`, "") + assert.Equal(t, 1, code) +} + +func TestTestStringZ(t *testing.T) { + _, _, code := runScript(t, `test -z ""`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test -z "abc"`, "") + assert.Equal(t, 1, code) +} + +func TestTestStringEqual(t *testing.T) { + _, _, code := runScript(t, `test "foo" = "foo"`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test "foo" = "bar"`, "") + assert.Equal(t, 1, code) +} + +func TestTestStringDoubleEqual(t *testing.T) { + _, _, code := runScript(t, `test "foo" == "foo"`, "") + assert.Equal(t, 0, code) +} + +func TestTestStringNotEqual(t *testing.T) { + _, _, code := runScript(t, `test "foo" != "bar"`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test "foo" != "foo"`, "") + assert.Equal(t, 1, code) +} + +func TestTestStringLessThan(t *testing.T) { + stdout, _, code := runScript(t, `test "a" \< "b" && echo yes`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestStringGreaterThan(t *testing.T) { + stdout, _, code := runScript(t, `test "b" \> "a" && echo yes`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +// --- Integer operators --- + +func TestTestIntEq(t *testing.T) { + _, _, code := runScript(t, `test 9 -eq 9`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test 8 -eq 9`, "") + assert.Equal(t, 1, code) +} + +func TestTestIntNe(t *testing.T) { + _, _, code := runScript(t, `test 0 -ne 1`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntGt(t *testing.T) { + _, _, code := runScript(t, `test 5 -gt 4`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test 5 -gt 5`, "") + assert.Equal(t, 1, code) +} + +func TestTestIntGe(t *testing.T) { + _, _, code := runScript(t, `test 5 -ge 5`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntLt(t *testing.T) { + _, _, code := runScript(t, `test 4 -lt 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 TestTestIntWhitespace(t *testing.T) { + _, _, code := runScript(t, `test 42 -eq " 42 "`, "") + assert.Equal(t, 0, code) +} + +func TestTestIntInvalid(t *testing.T) { + _, stderr, code := runScript(t, `test 123.45 -ge 6`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +func TestTestIntLeadingZeros(t *testing.T) { + _, _, code := runScript(t, `test 0 -eq 00`, "") + assert.Equal(t, 0, code) +} + +// --- Logical operators --- + +func TestTestNot(t *testing.T) { + _, _, code := runScript(t, `test ! ""`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test ! "hello"`, "") + assert.Equal(t, 1, code) +} + +func TestTestAnd(t *testing.T) { + _, _, code := runScript(t, `test "a" -a "b"`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test "" -a "b"`, "") + assert.Equal(t, 1, code) +} + +func TestTestOr(t *testing.T) { + _, _, code := runScript(t, `test "" -o "b"`, "") + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test "" -o ""`, "") + assert.Equal(t, 1, code) +} + +func TestTestParentheses(t *testing.T) { + stdout, _, code := runScript(t, `test "(" "hello" ")" && echo yes`, "") + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestNotAndPrecedence(t *testing.T) { + _, _, code := runScript(t, `test ! "" -a ""`, "") + assert.Equal(t, 0, code) +} + +func TestTestNotOrPrecedence(t *testing.T) { + _, _, code := runScript(t, `test ! "a" -o "b"`, "") + assert.Equal(t, 1, code) +} + +// --- File operators --- + +func TestTestFileExists(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "exists.txt", "content") + _, _, code := cmdRun(t, `test -e exists.txt`, dir) + assert.Equal(t, 0, code) + _, _, code = cmdRun(t, `test -e nonexistent`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileRegular(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "content") + _, _, code := cmdRun(t, `test -f file.txt`, dir) + assert.Equal(t, 0, code) + _, _, code = cmdRun(t, `test -f .`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileDirectory(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -d .`, dir) + assert.Equal(t, 0, code) + writeFile(t, dir, "file.txt", "content") + _, _, code = cmdRun(t, `test -d file.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileSize(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "nonempty.txt", "data") + writeFile(t, dir, "empty.txt", "") + _, _, code := cmdRun(t, `test -s nonempty.txt`, dir) + assert.Equal(t, 0, code) + _, _, code = cmdRun(t, `test -s empty.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileReadable(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "readable.txt", "data") + _, _, code := cmdRun(t, `test -r readable.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileNonexistentNotReadable(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -r nonexistent`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileNewerOlder(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "older.txt", "old") + writeFile(t, dir, "newer.txt", "new") + // newer.txt may or may not be strictly newer (same-second resolution). + // At minimum, file -nt missing should be true. + _, _, code := cmdRun(t, `test newer.txt -nt nonexistent`, dir) + assert.Equal(t, 0, code) + _, _, code = cmdRun(t, `test nonexistent -nt newer.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestFileSameFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "content") + _, _, code := cmdRun(t, `test file.txt -ef file.txt`, dir) + assert.Equal(t, 0, code) +} + +func TestTestFileNonexistentEf(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "content") + _, _, code := cmdRun(t, `test file.txt -ef nonexistent`, dir) + assert.Equal(t, 1, code) +} + +// --- Bracket syntax --- + +func TestBracketBasic(t *testing.T) { + _, _, code := runScript(t, `[ 1 -eq 1 ]`, "") + assert.Equal(t, 0, code) +} + +func TestBracketFailure(t *testing.T) { + _, _, code := runScript(t, `[ 1 -eq 2 ]`, "") + assert.Equal(t, 1, code) +} + +func TestBracketMissingClose(t *testing.T) { + _, stderr, code := runScript(t, `[ 1 -eq 2`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing ']'") +} + +func TestBracketEmpty(t *testing.T) { + _, stderr, code := runScript(t, `[`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "missing ']'") +} + +// --- Help --- + +func TestTestHelp(t *testing.T) { + stdout, _, code := runScript(t, `test --help`, "") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage: test") +} + +func TestBracketHelp(t *testing.T) { + stdout, _, code := runScript(t, `[ --help ]`, "") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage: [") +} + +// --- Error cases --- + +func TestTestUnaryExpected(t *testing.T) { + _, stderr, code := runScript(t, `test -o arg`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "unary operator expected") +} + +func TestTestExtraArg(t *testing.T) { + _, stderr, code := runScript(t, `test "a" "b" "c" "d" "e"`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "extra argument") +} + +func TestTestHexNotOctal(t *testing.T) { + _, stderr, code := runScript(t, `test 0x0 -eq 00`, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid integer") +} + +// --- Combined expressions --- + +func TestTestFileAndString(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "data") + stdout, _, code := cmdRun(t, `test -f file.txt -a -n "hello" && echo yes`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "yes\n", stdout) +} + +func TestTestIfConstruct(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "data") + stdout, _, code := cmdRun(t, `[ -f file.txt ] && echo found`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "found\n", stdout) +} + +func TestTestBinaryInThreeArgForm(t *testing.T) { + _, _, code := runScript(t, `test "-f" "=" "a"`, "") + assert.Equal(t, 1, code) +} + +func TestTestSoloNotIsTrue(t *testing.T) { + _, _, code := runScript(t, `test "!"`, "") + assert.Equal(t, 0, code) +} + +func TestTestDoubleNotIsFalse(t *testing.T) { + _, _, code := runScript(t, `test "!" "!"`, "") + assert.Equal(t, 1, code) +} + +func TestTestOutsideSandbox(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `test -e /etc/passwd`, dir) + assert.Equal(t, 1, code) +} diff --git a/interp/builtins/test/test_unix_test.go b/interp/builtins/test/test_unix_test.go new file mode 100644 index 00000000..cb05b16e --- /dev/null +++ b/interp/builtins/test/test_unix_test.go @@ -0,0 +1,75 @@ +// 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 test_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +func TestTestSymlinkL(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "target.txt", "data") + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link"))) + _, _, code := cmdRun(t, `test -L link`, dir) + assert.Equal(t, 0, code) + _, _, code = cmdRun(t, `test -L target.txt`, dir) + assert.Equal(t, 1, code) +} + +func TestTestSymlinkH(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "target.txt", "data") + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link"))) + _, _, code := cmdRun(t, `test -h link`, dir) + assert.Equal(t, 0, code) +} + +func TestTestDanglingSymlink(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Symlink("nonexistent", filepath.Join(dir, "broken"))) + _, _, code := cmdRun(t, `test -L broken`, dir) + assert.Equal(t, 0, code) + _, _, code = cmdRun(t, `test -e broken`, dir) + assert.Equal(t, 1, code) +} + +func TestTestEfSymlink(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "file.txt", "data") + require.NoError(t, os.Symlink("file.txt", filepath.Join(dir, "link"))) + _, _, code := cmdRun(t, `test file.txt -ef link`, dir) + assert.Equal(t, 0, code) +} + +func TestTestDevNull(t *testing.T) { + dir := t.TempDir() + _, _, code := runScript(t, `test -e /dev/null`, dir, interp.AllowedPaths([]string{dir, "/dev"})) + assert.Equal(t, 0, code) + _, _, code = runScript(t, `test -c /dev/null`, dir, interp.AllowedPaths([]string{dir, "/dev"})) + assert.Equal(t, 0, code) +} + +func TestTestPermissionBits(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "noexec.txt") + require.NoError(t, os.WriteFile(f, []byte("data"), 0644)) + _, _, code := cmdRun(t, `test -x noexec.txt`, dir) + assert.Equal(t, 1, code) + + exec := filepath.Join(dir, "exec.sh") + require.NoError(t, os.WriteFile(exec, []byte("#!/bin/sh"), 0755)) + _, _, code = cmdRun(t, `test -x exec.sh`, dir) + assert.Equal(t, 0, code) +} diff --git a/interp/runner_exec.go b/interp/runner_exec.go index e767ec5e..0d5016fe 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "io" + "io/fs" "os" "sync" @@ -23,6 +24,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 +218,18 @@ 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) { + if r.sandbox == nil { + return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission} + } + return r.sandbox.stat(r.handlerCtx(ctx, todoPos), path) + }, + LstatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { + if r.sandbox == nil { + return nil, &os.PathError{Op: "lstat", Path: path, Err: os.ErrPermission} + } + return r.sandbox.lstat(r.handlerCtx(ctx, todoPos), path) + }, PortableErr: portableErrMsg, } if r.stdin != nil { // do not assign a typed nil into the io.Reader interface diff --git a/tests/import_allowlist_test.go b/tests/import_allowlist_test.go index 4df9afd3..c1f449a6 100644 --- a/tests/import_allowlist_test.go +++ b/tests/import_allowlist_test.go @@ -39,9 +39,20 @@ var builtinAllowedSymbols = []string{ "io.NopCloser", "io.ReadCloser", "io.Reader", + "os.FileInfo", + "os.ModeCharDevice", + "os.ModeDevice", + "os.ModeNamedPipe", + "os.ModeSetgid", + "os.ModeSetuid", + "os.ModeSocket", + "os.ModeSticky", + "os.ModeSymlink", "os.O_RDONLY", + "os.SameFile", "strconv.Atoi", "strconv.ParseInt", + "strings.TrimSpace", } // permanentlyBanned lists packages that may never be imported by builtin diff --git a/tests/scenarios/cmd/test/bracket/basic.yaml b/tests/scenarios/cmd/test/bracket/basic.yaml new file mode 100644 index 00000000..06f1e9ab --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/basic.yaml @@ -0,0 +1,11 @@ +# Derived from uutils test_bracket_syntax_success and test_bracket_syntax_failure +description: "[ ] bracket syntax basic usage" +input: + script: |+ + [ 1 -eq 1 ] && echo eq + [ 1 -eq 2 ] || echo neq + [ "hello" = "hello" ] && echo str +expect: + stdout: "eq\nneq\nstr\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/bracket/missing_bracket.yaml b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml new file mode 100644 index 00000000..b99223c1 --- /dev/null +++ b/tests/scenarios/cmd/test/bracket/missing_bracket.yaml @@ -0,0 +1,8 @@ +# Derived from uutils test_bracket_syntax_missing_right_bracket +description: "[ without closing ] produces error with exit code 2" +input: + script: |+ + [ 1 -eq 2 +expect: + stderr: "[: missing ']'\n" + exit_code: 2 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..64318ecb --- /dev/null +++ b/tests/scenarios/cmd/test/errors/extra_argument.yaml @@ -0,0 +1,8 @@ +# Derived from uutils test_erroneous_parenthesized_expression +description: extra argument after valid expression produces error +input: + script: |+ + test "a" "!=" "(" "b" "-a" "b" ")" "!=" "c" +expect: + stderr_contains: ["extra argument"] + exit_code: 2 diff --git a/tests/scenarios/cmd/test/errors/invalid_integer.yaml b/tests/scenarios/cmd/test/errors/invalid_integer.yaml new file mode 100644 index 00000000..d30602c4 --- /dev/null +++ b/tests/scenarios/cmd/test/errors/invalid_integer.yaml @@ -0,0 +1,8 @@ +# Derived from uutils test_float_inequality_is_error and GNU coreutils test.pl inv-1 +description: invalid integers produce exit code 2 with error message +input: + script: |+ + test 123.45 -ge 6 +expect: + stderr: "test: invalid integer '123.45'\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..7a8625b9 --- /dev/null +++ b/tests/scenarios/cmd/test/errors/unary_expected.yaml @@ -0,0 +1,8 @@ +# Derived from GNU coreutils test-diag.pl — unary operator expected +description: invalid unary operator in two-arg form produces error +input: + script: |+ + test -o arg +expect: + stderr: "test: '-o': unary operator expected\n" + exit_code: 2 diff --git a/tests/scenarios/cmd/test/files/existence_type.yaml b/tests/scenarios/cmd/test/files/existence_type.yaml new file mode 100644 index 00000000..3cda3bdf --- /dev/null +++ b/tests/scenarios/cmd/test/files/existence_type.yaml @@ -0,0 +1,18 @@ +# Derived from uutils test_file_exists, test_file_exists_and_is_regular, test_directory +description: file existence and type tests +setup: + files: + - path: regular.txt + content: "hello" +input: + allowed_paths: ["$DIR"] + script: |+ + test -e regular.txt && echo exists + test -e nonexistent || echo noexist + test -f regular.txt && echo regular + test -d . && echo dir + test -d regular.txt || echo notdir +expect: + stdout: "exists\nnoexist\nregular\ndir\nnotdir\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/files/size.yaml b/tests/scenarios/cmd/test/files/size.yaml new file mode 100644 index 00000000..695b0c8f --- /dev/null +++ b/tests/scenarios/cmd/test/files/size.yaml @@ -0,0 +1,18 @@ +# Derived from uutils test_is_not_empty and test_empty_file_is_not_not_empty +description: -s tests for file size greater than zero +setup: + files: + - path: nonempty.txt + content: "data" + - path: empty.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + test -s nonempty.txt && echo has_size + test -s empty.txt || echo empty + test -s nonexistent || echo missing +expect: + stdout: "has_size\nempty\nmissing\n" + stderr: "" + exit_code: 0 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..5285328d --- /dev/null +++ b/tests/scenarios/cmd/test/help/bracket_help.yaml @@ -0,0 +1,7 @@ +description: "[ --help ] prints usage information" +input: + script: |+ + [ --help ] +expect: + stdout_contains: ["Usage: ["] + exit_code: 0 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..3b933252 --- /dev/null +++ b/tests/scenarios/cmd/test/help/test_help.yaml @@ -0,0 +1,7 @@ +description: --help prints usage information +input: + script: |+ + test --help +expect: + stdout_contains: ["Usage: test"] + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integers/basic_comparison.yaml b/tests/scenarios/cmd/test/integers/basic_comparison.yaml new file mode 100644 index 00000000..6b048564 --- /dev/null +++ b/tests/scenarios/cmd/test/integers/basic_comparison.yaml @@ -0,0 +1,17 @@ +# Derived from GNU coreutils test.pl eq-1..5 and uutils test_some_int_compares +description: integer comparison operators +input: + script: |+ + test 9 -eq 9 && echo eq1 + test 0 -eq 0 && echo eq2 + test 0 -eq 00 && echo eq3 + test 8 -eq 9 || echo neq + test 0 -ne 1 && echo ne1 + test 421 -lt 3720 && echo lt1 + test 0 -le 0 && echo le1 + test 11 -gt 10 && echo gt1 + test 1024 -ge 512 && echo ge1 +expect: + stdout: "eq1\neq2\neq3\nneq\nne1\nlt1\nle1\ngt1\nge1\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integers/negative.yaml b/tests/scenarios/cmd/test/integers/negative.yaml new file mode 100644 index 00000000..ebf4d57b --- /dev/null +++ b/tests/scenarios/cmd/test/integers/negative.yaml @@ -0,0 +1,14 @@ +# Derived from uutils test_negative_int_compare +description: negative integer comparisons +input: + script: |+ + test -1 -eq -1 && echo eq + test -1 -ne -2 && echo ne + test -3720 -lt -421 && echo lt + test -10 -le -10 && echo le + test -21 -gt -22 && echo gt + test -128 -ge -256 && echo ge +expect: + stdout: "eq\nne\nlt\nle\ngt\nge\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/integers/overflow.yaml b/tests/scenarios/cmd/test/integers/overflow.yaml new file mode 100644 index 00000000..43f124a3 --- /dev/null +++ b/tests/scenarios/cmd/test/integers/overflow.yaml @@ -0,0 +1,9 @@ +# Derived from uutils test_long_integer — overflow integers +description: integer overflow values produce errors +skip_assert_against_bash: true +input: + script: |+ + test 18446744073709551616 -eq 0 +expect: + stderr_contains: ["invalid integer"] + exit_code: 2 diff --git a/tests/scenarios/cmd/test/integers/whitespace.yaml b/tests/scenarios/cmd/test/integers/whitespace.yaml new file mode 100644 index 00000000..5956249e --- /dev/null +++ b/tests/scenarios/cmd/test/integers/whitespace.yaml @@ -0,0 +1,10 @@ +# Derived from uutils test_integer_whitespace_stripping +description: whitespace is stripped from integer operands +input: + script: |+ + test 42 -eq " 42 " && echo ws1 + test " 42" -eq 42 && echo ws2 +expect: + stdout: "ws1\nws2\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/and_or.yaml b/tests/scenarios/cmd/test/logic/and_or.yaml new file mode 100644 index 00000000..cc23b7ba --- /dev/null +++ b/tests/scenarios/cmd/test/logic/and_or.yaml @@ -0,0 +1,16 @@ +# Derived from GNU coreutils test.pl and-1..4 and or-1..4 +description: logical AND and OR operators +input: + script: |+ + test "t" -a "t" && echo and1 + test "" -a "t" || echo and2 + test "t" -a "" || echo and3 + test "" -a "" || echo and4 + test "t" -o "t" && echo or1 + test "" -o "t" && echo or2 + test "t" -o "" && echo or3 + test "" -o "" || echo or4 +expect: + stdout: "and1\nand2\nand3\nand4\nor1\nor2\nor3\nor4\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/complex_parens.yaml b/tests/scenarios/cmd/test/logic/complex_parens.yaml new file mode 100644 index 00000000..2f7828ab --- /dev/null +++ b/tests/scenarios/cmd/test/logic/complex_parens.yaml @@ -0,0 +1,8 @@ +# Derived from uutils test_complicated_parenthesized_expression +description: complex nested parenthesized expression +input: + script: |+ + test "(" "(" "!" "(" "a" "=" "b" ")" "-o" "c" "=" "d" ")" "-a" "(" "q" "!=" "r" ")" ")" && echo yes +expect: + stdout: "yes\n" + 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..31927829 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/not.yaml @@ -0,0 +1,12 @@ +# Derived from yash test-p.tst negation tests +description: logical NOT operator +input: + script: |+ + test ! "" && echo not1 + test ! "A" || echo not2 + test ! -n "" && echo not3 + test ! -n "x" || echo not4 +expect: + stdout: "not1\nnot2\nnot3\nnot4\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..99f6a216 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/parentheses.yaml @@ -0,0 +1,12 @@ +# Derived from GNU coreutils test.pl paren-1..5 +description: parenthesized expressions +input: + script: |+ + test "(" "hello" ")" && echo p1 + test "(" "" ")" || echo p2 + test "(" "!" ")" && echo p3 + test "(" "-a" ")" && echo p4 +expect: + stdout: "p1\np2\np3\np4\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/logic/precedence.yaml b/tests/scenarios/cmd/test/logic/precedence.yaml new file mode 100644 index 00000000..54e116a8 --- /dev/null +++ b/tests/scenarios/cmd/test/logic/precedence.yaml @@ -0,0 +1,8 @@ +# Derived from uutils test_op_precedence_and_or_1 +description: -a has higher precedence than -o +input: + script: |+ + test " " -o "" -a "" && echo yes +expect: + stdout: "yes\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/double_equal.yaml b/tests/scenarios/cmd/test/strings/double_equal.yaml new file mode 100644 index 00000000..cf0c3669 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/double_equal.yaml @@ -0,0 +1,10 @@ +# Derived from uutils test_double_equal_is_string_comparison_op +description: == operator is alias for = +input: + script: |+ + test "t" == "t" && echo eq + test "t" == "f" || echo neq +expect: + stdout: "eq\nneq\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/empty_args.yaml b/tests/scenarios/cmd/test/strings/empty_args.yaml new file mode 100644 index 00000000..9300c172 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/empty_args.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl 1a — empty args = false +description: test with no arguments returns false +input: + script: |+ + test +expect: + stdout: "" + stderr: "" + exit_code: 1 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..28400a23 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/empty_string.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl 1e — empty string is false +description: test with empty string returns false +input: + script: |+ + test "" && echo yes || echo no +expect: + stdout: "no\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/equality.yaml b/tests/scenarios/cmd/test/strings/equality.yaml new file mode 100644 index 00000000..74853627 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/equality.yaml @@ -0,0 +1,14 @@ +# Derived from GNU coreutils test.pl streq-1..6 and strne-1..6 +description: string equality and inequality operators +input: + script: |+ + test "t" = "t" && echo eq1 + test "t" = "f" || echo eq2 + test "t" != "t" || echo ne1 + test "t" != "f" && echo ne2 + test "!" = "!" && echo eq3 + test "=" = "=" && echo eq4 +expect: + stdout: "eq1\neq2\nne1\nne2\neq3\neq4\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/less_greater.yaml b/tests/scenarios/cmd/test/strings/less_greater.yaml new file mode 100644 index 00000000..519aa6e1 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/less_greater.yaml @@ -0,0 +1,12 @@ +# Derived from GNU coreutils test.pl less-collate and greater-collate +description: lexicographic string comparison with < and > +input: + script: |+ + test "a" \< "b" && echo lt1 + test "a" \< "a" || echo lt2 + test "b" \> "a" && echo gt1 + test "a" \> "a" || echo gt2 +expect: + stdout: "lt1\nlt2\ngt1\ngt2\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/n_and_z.yaml b/tests/scenarios/cmd/test/strings/n_and_z.yaml new file mode 100644 index 00000000..4a4be4c3 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/n_and_z.yaml @@ -0,0 +1,12 @@ +# Derived from GNU coreutils test.pl 1b, 1d — -n and -z operators +description: -n returns true for non-empty, -z returns true for empty +input: + script: |+ + test -z "" && echo z_empty + test -z "abc" || echo z_notempty + test -n "abc" && echo n_notempty + test -n "" || echo n_empty +expect: + stdout: "z_empty\nz_notempty\nn_notempty\nn_empty\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/nonempty_string.yaml b/tests/scenarios/cmd/test/strings/nonempty_string.yaml new file mode 100644 index 00000000..4e9ec8c5 --- /dev/null +++ b/tests/scenarios/cmd/test/strings/nonempty_string.yaml @@ -0,0 +1,9 @@ +# Derived from GNU coreutils test.pl 1c — non-empty string is true +description: test with non-empty string returns true +input: + script: |+ + test "any-string" && echo yes +expect: + stdout: "yes\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/test/strings/special_literals.yaml b/tests/scenarios/cmd/test/strings/special_literals.yaml new file mode 100644 index 00000000..301670ee --- /dev/null +++ b/tests/scenarios/cmd/test/strings/special_literals.yaml @@ -0,0 +1,12 @@ +# Derived from GNU coreutils test.pl 1f..1k — special strings as literals +description: special strings treated as non-empty literals in one-arg form +input: + script: |+ + test "-" && echo dash + test "--" && echo ddash + test "-0" && echo zero + test "-f" && echo f +expect: + stdout: "dash\nddash\nzero\nf\n" + stderr: "" + exit_code: 0