diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 5209162a4..ae487b56b 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -24,6 +24,9 @@ jobs: - pkg: ./builtins/tests/cat/ name: cat corpus_path: builtins/tests/cat + - pkg: ./builtins/tests/cd/ + name: cd + corpus_path: builtins/tests/cd - pkg: ./builtins/tests/wc/ name: wc corpus_path: builtins/tests/wc diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 65be70e31..4c8703300 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -9,6 +9,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `break` — exit the innermost `for` loop - ✅ `cat [-AbeEnstTuv] [FILE]...` — concatenate files to stdout; supports line numbering, blank squeezing, and non-printing character display +- ✅ `cd [-L|-P] [DIR]` — change the working directory; bare `cd` uses `$HOME`, `cd -` switches to `$OLDPWD` and prints the raw OLDPWD value; `-P` resolves symlinks physically (capped at 40 hops); failure leaves `$PWD`/`$OLDPWD` untouched; subshell scope mirrors bash (`(cd …)` does not leak); `$CDPATH` is intentionally not implemented (prevents environment-variable path injection); paths beginning with `//` are normalised to `/` by `filepath.Clean` (POSIX allows this; bash preserves `//` but rshell intentionally diverges since the cleaned path is required for sandbox validation) - ✅ `continue` — skip to the next iteration of the innermost `for` loop - ✅ `cut [-b LIST|-c LIST|-f LIST] [-d DELIM] [-s] [-n] [--complement] [--output-delimiter=STRING] [FILE]...` — remove sections from each line of files - ✅ `echo [-neE] [ARG]...` — write arguments to stdout; `-n` suppresses trailing newline, `-e` enables backslash escapes, `-E` disables them (default) diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index 34a1a53ae..5d02f4ec5 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -39,6 +39,28 @@ var builtinPerCommandSymbols = map[string][]string{ "io.ReadCloser", // 🟢 interface type; no side effects. "os.O_RDONLY", // 🟢 read-only file flag constant; cannot open files by itself. }, + "cd": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "errors.As", // 🟢 error type assertion; pure function, no I/O. + "errors.Is", // 🟢 error comparison; pure function, no I/O. + "errors.New", // 🟢 creates a simple error value; pure function, no I/O. + "io/fs.ErrNotExist", // 🟢 sentinel error value; pure constant. + "io/fs.ErrPermission", // 🟢 sentinel error value; pure constant. + "io/fs.ModeSymlink", // 🟢 file mode bit constant for symlinks; pure constant. + "os.PathError", // 🟢 error type for filesystem path errors; pure type, no I/O. + "path/filepath.Clean", // 🟢 normalises a path string; pure function, no I/O. + "path/filepath.Dir", // 🟢 returns the directory component of a path; pure function, no I/O. + "path/filepath.IsAbs", // 🟢 reports whether a path is absolute; pure function, no I/O. + "path/filepath.Join", // 🟢 joins path components; pure function, no I/O. + "path/filepath.Separator", // 🟢 OS path separator constant ('/' or '\\'); pure constant, no I/O. + "path/filepath.ToSlash", // 🟢 converts OS path separators to forward slashes; pure function, no I/O. + "path/filepath.VolumeName", // 🟢 returns the volume prefix of a path (e.g. "C:" on Windows, "" on Unix); pure function, no I/O. + "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. + "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. + "strings.IndexByte", // 🟢 finds byte in string; pure function, no I/O. + "strings.Split", // 🟢 splits a string by separator into a slice; pure function, no I/O. + "strings.ToUpper", // 🟢 converts string to uppercase; pure function, no I/O. + }, "continue": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. }, @@ -448,6 +470,8 @@ var builtinAllowedSymbols = []string{ "io.WriteString", // 🟠 writes a string to a writer; no filesystem access, delegates to Write. "io.Writer", // 🟢 interface type for writing; no side effects. "io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects. + "io/fs.ErrNotExist", // 🟢 sentinel error value for "not exists"; pure constant. + "io/fs.ErrPermission", // 🟢 sentinel error value for "permission denied"; pure constant. "io/fs.FileInfo", // 🟢 interface type for file information; no side effects. "io/fs.FileMode", // 🟢 file permission bits type; pure type. "io/fs.ModeCharDevice", // 🟢 file mode bit constant for character devices; pure constant. @@ -523,6 +547,7 @@ var builtinAllowedSymbols = []string{ "strings.ReplaceAll", // 🟢 replaces all occurrences of a substring; pure function, no I/O. "strings.Split", // 🟢 splits a string by separator into a slice; pure function, no I/O. "strings.ToLower", // 🟢 converts string to lowercase; pure function, no I/O. + "strings.ToUpper", // 🟢 converts string to uppercase; pure function, no I/O. "strings.TrimPrefix", // 🟢 removes a leading prefix from a string; pure function, no I/O. "strings.TrimSpace", // 🟢 removes leading/trailing whitespace; pure function. "syscall.ByHandleFileInformation", // 🟢 Windows file info struct for extracting nlink; read-only type, no I/O. diff --git a/builtins/builtins.go b/builtins/builtins.go index 38063ddaf..728a95168 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -180,6 +180,11 @@ type CallContext struct { // Used by builtins that need to compute absolute paths for sub-operations. WorkDir func() string + // LookupVar returns the value of a shell variable and whether it is set. + // Used by cd to read $HOME and $OLDPWD; the value is the empty string and + // ok is false when the variable has never been assigned. + LookupVar func(name string) (value string, ok bool) + // HostPrefix returns the configured host-mount prefix used by // container-style sandboxes to translate host-absolute paths // (e.g. /var/log/pods/...) into the prefixed paths the sandbox can @@ -251,6 +256,26 @@ type Result struct { // ContinueN > 0 means continue from N enclosing loops. ContinueN int + + // NewWorkDir, when non-empty, is an absolute, validated path that the + // runner adopts as the shell's new working directory before continuing. + // Set by the "cd" builtin; the runner is responsible for updating r.Dir, + // $PWD, and $OLDPWD. The builtin must validate accessibility (e.g. via + // callCtx.StatFile) before signalling a change. + // + // SECURITY: The runner performs a defense-in-depth sandbox re-validation + // of NewWorkDir before applying it (see runner_exec.go). Builtins MUST + // also call callCtx.StatFile on the target path before returning this + // field so the sandbox validates the path at the builtin level; the + // runner re-check is a safety net for future builtins that may forget + // the prior-StatFile step. + // + // Side effect: when NewWorkDir is non-empty and Code is 0, the runner + // suppresses the inline-assignment restore of $PWD and $OLDPWD after + // the command returns (matching bash semantics for `OLDPWD=X cd -`). + // Any future builtin returning a non-empty NewWorkDir inherits this + // behaviour. + NewWorkDir string } var registry = map[string]HandlerFunc{} diff --git a/builtins/cd/builtin_cd_pentest_test.go b/builtins/cd/builtin_cd_pentest_test.go new file mode 100644 index 000000000..caad13b7c --- /dev/null +++ b/builtins/cd/builtin_cd_pentest_test.go @@ -0,0 +1,224 @@ +// 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. + +// This file holds adversarial tests for the cd builtin. Each test +// targets a specific abuse pattern (oversized inputs, sandbox escape, +// flag injection, infinite-source-style loops) and asserts that cd +// fails fast — a non-zero exit and a finite return time. + +package cd_test + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +// runWithDeadline runs script under a 5-second timeout and asserts the +// runner returned in time, regardless of the exit code. +func runWithDeadline(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + type result struct { + stdout, stderr string + code int + } + done := make(chan result, 1) + go func() { + so, se, c := runScriptCtx(ctx, t, script, dir, opts...) + done <- result{so, se, c} + }() + select { + case r := <-done: + return r.stdout, r.stderr, r.code + case <-time.After(10 * time.Second): + t.Fatal("cd did not return within deadline") + return "", "", 0 + } +} + +// --- Path length pentest --- + +func TestPentestCdAbsoluteOversizePath(t *testing.T) { + dir := t.TempDir() + huge := "/" + strings.Repeat("a", 70000) + _, stderr, code := runWithDeadline(t, "cd "+huge, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "path too long") +} + +func TestPentestCdHomeOversize(t *testing.T) { + dir := t.TempDir() + huge := "/" + strings.Repeat("h", 70000) + _, stderr, code := runWithDeadline(t, "cd", dir, + interp.AllowedPaths([]string{dir}), + interp.Env("HOME="+huge)) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "path too long") +} + +func TestPentestCdOldpwdOversize(t *testing.T) { + dir := t.TempDir() + huge := "/" + strings.Repeat("o", 70000) + _, stderr, code := runWithDeadline(t, "cd -", dir, + interp.AllowedPaths([]string{dir}), + interp.Env("OLDPWD="+huge)) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "path too long") +} + +// --- Sandbox escape attempts --- + +func TestPentestCdToHostRoot(t *testing.T) { + if runtime.GOOS == "windows" { + // On Windows "/" is the root of the current drive, which on the + // CI runner is the same drive as the temp dir — filepath.Clean + // can normalise it inside the allowed sandbox prefix and the + // cd succeeds. The Unix notion of a single host root that is + // always outside a sandboxed temp dir does not apply. + t.Skip("Unix-only: \"/\" semantics differ on Windows (per-drive root)") + } + allowed := t.TempDir() + // "/" exists outside the allowed path; sandbox must reject. + _, stderr, code := runWithDeadline(t, "cd /", allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cd:") +} + +func TestPentestCdParentTraversalEscape(t *testing.T) { + allowed := t.TempDir() + // ../.. shouldn't be able to escape the sandbox even with logical + // path interpretation. filepath.Clean reduces the literal path; the + // sandbox then rejects it. + _, stderr, code := runWithDeadline(t, "cd ../../../../../../etc", allowed, + interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cd:") +} + +func TestPentestCdSymlinkEscape(t *testing.T) { + allowed := t.TempDir() + other := t.TempDir() + link := filepath.Join(allowed, "escape") + require.NoError(t, os.Symlink(other, link)) + // -P would resolve through the link to a directory outside the + // sandbox; the sandbox-aware ReadlinkFile/StatFile must refuse the + // off-sandbox final component. + _, stderr, code := runWithDeadline(t, "cd -P escape", allowed, + interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cd:") +} + +// --- Flag injection / pflag rejection --- + +func TestPentestCdRandomFlags(t *testing.T) { + dir := t.TempDir() + for _, flag := range []string{"-Z", "--no-such-flag", "-PL=oops", "--logical=maybe"} { + _, stderr, code := runWithDeadline(t, "cd "+flag, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code, "flag=%q", flag) + assert.Contains(t, stderr, "cd:", "flag=%q", flag) + } +} + +func TestPentestCdFlagDashAfterDoubleDash(t *testing.T) { + dir := t.TempDir() + makeDir(t, dir, "real") + link := filepath.Join(dir, "-funny") + require.NoError(t, os.Symlink("real", link)) + // `cd -- -funny` must accept the dash-prefixed dir name as a path + // rather than trying to parse it as a flag. + stdout, _, code := runWithDeadline(t, "cd -- -funny\necho \"$PWD\"", dir, + interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "-funny") +} + +// --- Stat target hardening --- + +func TestPentestCdToDevNull(t *testing.T) { + dir := t.TempDir() + // /dev/null is allowed by sandbox but is a character device, not a + // directory — cd must reject with "not a directory". + _, stderr, code := runWithDeadline(t, "cd "+os.DevNull, dir, + interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cd:") +} + +func TestPentestCdNonexistentParentNoCrash(t *testing.T) { + dir := t.TempDir() + _, _, code := runWithDeadline(t, "cd /nonexistent/deeply/nested/path", dir, + interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) +} + +// --- Repeated mass invocation (FD / leak check) --- + +func TestPentestCdRepeated(t *testing.T) { + dir := t.TempDir() + makeDir(t, dir, "sub") + var sb strings.Builder + for i := 0; i < 200; i++ { + sb.WriteString("cd sub\ncd ..\n") + } + stdout, stderr, code := runWithDeadline(t, sb.String(), dir, + interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code, "stderr=%q", stderr) + assert.Equal(t, "", stdout) +} + +// --- Cancellation under loop --- + +func TestPentestCdInLoopCancellation(t *testing.T) { + dir := t.TempDir() + makeDir(t, dir, "sub") + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + script := "for i in 1 2 3 4 5 6 7 8 9 10; do cd sub; cd ..; done" + done := make(chan struct{}) + go func() { + defer close(done) + _, _, _ = runScriptCtx(ctx, t, script, dir, interp.AllowedPaths([]string{dir})) + }() + select { + case <-done: + // returned; that's all we need to assert. + case <-time.After(5 * time.Second): + t.Fatal("cd loop hung past cancellation") + } +} + +// --- Execute-permission check --- + +// TestPentestCdNoExecutePermission verifies that AccessFile is consulted and +// that cd fails with "permission denied" when the target directory exists +// (StatFile succeeds) but lacks execute/search permission for the current +// user. This exercises the callCtx.AccessFile(ctx, absPath, 0x01) path that +// would otherwise be invisible to StatFile alone. +func TestPentestCdNoExecutePermission(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not use POSIX execute bits — chmod(000) has no effect") + } + dir := t.TempDir() + locked := makeDir(t, dir, "locked") + require.NoError(t, os.Chmod(locked, 0o000)) + // Restore permissions so t.TempDir cleanup can remove the directory. + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + _, stderr, code := runWithDeadline(t, "cd locked", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cd:") + assert.Contains(t, stderr, "Permission denied") +} diff --git a/builtins/cd/cd.go b/builtins/cd/cd.go new file mode 100644 index 000000000..9727caf02 --- /dev/null +++ b/builtins/cd/cd.go @@ -0,0 +1,691 @@ +// 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 cd implements the cd builtin command. +// +// cd — change the working directory +// +// Usage: cd [-L|-P] [DIR] +// +// Change the shell's working directory to DIR. With no argument, cd +// changes to $HOME. The special argument "-" switches to $OLDPWD and +// prints the raw OLDPWD value to stdout. +// +// Accepted flags: +// +// -L, --logical +// Treat symbolic links logically: the literal path is preserved in +// $PWD even when components are symlinks. This is the default. +// +// -P, --physical +// Resolve symbolic links physically: each symlink in DIR is followed +// so that $PWD reflects the real underlying directory. Resolution is +// bounded by maxSymlinkHops to defeat circular-symlink loops. +// +// -h, --help +// Print usage to stdout and exit 0. +// +// Behaviour notes: +// +// cd updates two shell variables on success: it copies the previous +// working directory into $OLDPWD and writes the new directory into +// $PWD. On failure, neither the runner directory nor those variables +// are touched. +// +// `cd -` changes to $OLDPWD and echoes the raw OLDPWD value to stdout +// (bash always prints the original OLDPWD string, even under -P, even +// when it is the empty string — in which case a bare newline is emitted). +// If $OLDPWD is unset the command fails with a non-zero exit. If +// $OLDPWD is set but empty, cd - stays in place, prints a bare newline, +// and updates $OLDPWD to the current directory. +// +// Path validation goes exclusively through callCtx.StatFile, +// callCtx.LstatFile, and callCtx.ReadlinkFile, all of which honour +// the AllowedPaths sandbox. (LstatFile and ReadlinkFile are used only +// during -P symlink resolution; StatFile is used for all modes.) +// Targets outside the sandbox are rejected with a "permission denied" +// error from the AllowedPaths sandbox (not "no such file or directory"), +// so scripts can distinguish a denied path from a missing one. +// +// Exit codes: +// +// 0 Directory was changed successfully. +// 1 Argument missing/invalid, target not found, target not a directory, +// $HOME or $OLDPWD unset when needed, or symlink loop detected. +// +// Memory safety: +// +// Inputs are bounded: paths longer than maxPathBytes are rejected +// without ever being passed to the sandbox or to filepath.Clean. The +// -P symlink walk is capped at maxSymlinkHops to prevent infinite +// loops on circular symlinks. +package cd + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/DataDog/rshell/builtins" +) + +// Cmd is the cd builtin command descriptor. +var Cmd = builtins.Command{ + Name: "cd", + Description: "change the working directory", + MakeFlags: registerFlags, +} + +// maxPathBytes caps the length of any path cd will look at. The bound is +// generous (well above the historic PATH_MAX of 4096) but finite, so +// runaway concatenation of $HOME, $OLDPWD, or symlink targets cannot tie +// the sandbox up in arbitrarily long resolution work. +const maxPathBytes = 1 << 16 // 64 KiB + +// maxSymlinkHops caps the number of symlink expansions performed during +// `cd -P`. Capped at 40 to prevent unbounded work on deep or circular +// symlink chains while remaining well above any realistic directory tree depth. +const maxSymlinkHops = 40 + +// cdMode encodes the current -L vs -P selection. Using a typed enum (vs. +// a magic string) keeps the precedence rule below readable. +type cdMode int + +const ( + modeUnset cdMode = iota + modeLogical + modePhysical +) + +// reservedWindowsNames lists the basenames Windows treats as reserved +// device files. Touching any of these by name can hang processes (RULES.md +// cross-platform requirement). The check applies only on Windows; other +// platforms allow these as ordinary directory names. +var reservedWindowsNames = map[string]struct{}{ + "CON": {}, "PRN": {}, "AUX": {}, "NUL": {}, + "COM1": {}, "COM2": {}, "COM3": {}, "COM4": {}, "COM5": {}, "COM6": {}, "COM7": {}, "COM8": {}, "COM9": {}, + "LPT1": {}, "LPT2": {}, "LPT3": {}, "LPT4": {}, "LPT5": {}, "LPT6": {}, "LPT7": {}, "LPT8": {}, "LPT9": {}, +} + +// isReservedWindowsPath reports whether any segment of path is a Windows +// reserved device name. Returns false on non-Windows platforms. +func isReservedWindowsPath(path string) bool { + if runtime.GOOS != "windows" { + return false + } + for _, seg := range strings.Split(filepath.ToSlash(path), "/") { + if seg == "" { + continue + } + // Reserved names are matched without the extension and case-insensitively. + base := seg + if i := strings.IndexByte(base, '.'); i > 0 { + base = base[:i] + } + if _, ok := reservedWindowsNames[strings.ToUpper(base)]; ok { + return true + } + } + return false +} + +func registerFlags(flags *builtins.FlagSet) builtins.HandlerFunc { + // lastMode records which of -L / -P was parsed most recently. + // pflag's Visit iterates in lexical order, so we use a custom flag + // value that remembers parse order. The pointer is shared between + // both flag values. + var lastMode cdMode + flags.VarP(newLPFlag(&lastMode, modeLogical), "logical", "L", "follow symbolic links logically (default)") + flags.VarP(newLPFlag(&lastMode, modePhysical), "physical", "P", "resolve symbolic links to their targets") + flags.Lookup("logical").NoOptDefVal = "true" + flags.Lookup("physical").NoOptDefVal = "true" + // Bash rejects `cd sub -P` (too many arguments). Disable interspersed + // parsing so positional arguments are never re-interpreted as flags. + // Without this, pflag would parse `cd sub -P` as `cd -P sub`, silently + // changing behaviour compared to bash. + flags.SetInterspersed(false) + // -e is a bash extension: with -P, exit non-zero if the physical path + // cannot be determined. Without -P it is a no-op. We accept but ignore + // it so scripts using `cd -e dir` or `cd -Pe dir` do not fail with + // "unknown flag". Matches bash ≥ 4.0 behaviour. + _ = flags.BoolP("_e", "e", false, "(ignored) bash compat: exit non-zero if physical path cannot be determined") + if f := flags.Lookup("_e"); f != nil { + f.Hidden = true + } + help := flags.BoolP("help", "h", false, "print usage and exit") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *help { + callCtx.Out("Usage: cd [-L|-P] [DIR]\n") + callCtx.Out("Change the shell working directory.\n\n") + flags.SetOutput(callCtx.Stdout) + flags.PrintDefaults() + return builtins.Result{} + } + + // -L is the default; if both are given the last-on-the-command-line + // wins, matching bash. lastMode tracks which flag appeared last. + usePhysical := lastMode == modePhysical + + if len(args) > 1 { + callCtx.Errf("cd: too many arguments\n") + return builtins.Result{Code: 1} + } + + // currentDir returns the runner's current working directory. + // Used for no-op cases where bash still rotates OLDPWD. + currentDir := func() string { + if callCtx.WorkDir == nil { + return "" + } + return callCtx.WorkDir() + } + + var ( + target string + // displayOverride, when non-empty, is used as the error-message + // label instead of the derived target path. + displayOverride string + // printValue holds the value to print when cd - succeeds. + // Bash prints the raw OLDPWD verbatim (preserving trailing + // slashes etc. that filepath.Clean would strip), followed by a + // newline — even when OLDPWD is the empty string (one bare + // newline). printDash tracks whether to print at all. + printValue string + printDash bool + ) + switch { + case len(args) == 0: + home, ok := lookupVar(callCtx, "HOME") + if !ok { + callCtx.Errf("cd: HOME not set\n") + return builtins.Result{Code: 1} + } + // Bash distinguishes unset (error) from set-but-empty. When HOME + // is set but empty, `cd` stays in place AND updates OLDPWD to the + // current directory (same as a no-move cd). Route through the + // normal stat+NewWorkDir path so applyNewWorkDir fires. + if home == "" { + target = currentDir() + } else { + target = home + } + case args[0] == "-": + oldpwd, ok := lookupVar(callCtx, "OLDPWD") + if !ok { + callCtx.Errf("cd: OLDPWD not set\n") + return builtins.Result{Code: 1} + } + // Bash always prints OLDPWD (even "") followed by a newline on + // successful cd -. Empty-but-set OLDPWD: stay in place and + // update OLDPWD. Route through the normal path so applyNewWorkDir + // fires. + // Use oldpwd as the display value in error messages so that + // errors read "cd: /no/exist: No such file or directory" (matching + // bash) rather than "cd: -: ...". When OLDPWD is empty-but-set, + // fall back to "-" so that any error message shows "cd: -: ..." + // rather than exposing the runner's cwd path. + if oldpwd == "" { + if usePhysical { + // bash -P fails for an empty OLDPWD: it treats "" as a + // path to resolve physically, which fails. Without -P, + // bash accepts "" and stays in place (exit 0). + // Note: `display` is not yet initialised here (it is set + // after the switch); use "" to match bash's message: + // "cd: : No such file or directory" (empty-path label). + // bash emits "cd: : No such file or directory" (capital N) for + // this case — match exactly so stderr is byte-for-byte stable. + callCtx.Errf("cd: %s: No such file or directory\n", "") + return builtins.Result{Code: 1} + } + displayOverride = "-" + target = currentDir() + } else { + displayOverride = oldpwd + target = oldpwd + printValue = oldpwd + } + printDash = true + default: + target = args[0] + } + + if target == "" { + // cd "" or currentDir() returned "" (no working dir set): stay + // in place and update OLDPWD to cwd. But if cwd itself is + // unknown, there is nothing to stat — return no-op. + // + // NOTE: if this is the cd - path (printDash == true) and + // currentDir() returns "", we return early without printing the + // bare newline. In production the runner always wires WorkDir, so + // currentDir() is never empty on the hot path. For embedded use + // (hand-constructed CallContext with WorkDir == nil), the + // bare-newline print is intentionally skipped because we have no + // cwd to validate against — bash semantics can't apply without a + // working directory. + cwd := currentDir() + if cwd == "" { + return builtins.Result{} + } + target = cwd + } + if len(target) > maxPathBytes { + callCtx.Errf("cd: path too long\n") + return builtins.Result{Code: 1} + } + // Windows reserved device names (CON, NUL, COM1, ...) hang on + // Stat — refuse them up front. RULES.md cross-platform mandate. + if isReservedWindowsPath(target) { + callCtx.Errf("cd: %s: No such file or directory\n", target) + return builtins.Result{Code: 1} + } + + // Resolve to absolute against the current working directory. We use + // the displayed value (the caller's argument) for error messages so + // that bash-style "cd: foo: No such file or directory" is preserved. + display := target + if displayOverride != "" { + display = displayOverride + } + absPath := target + if !filepath.IsAbs(absPath) { + cwd := "" + if callCtx.WorkDir != nil { + cwd = callCtx.WorkDir() + } + // In logical mode use filepath.Join which cleans the path + // lexically (collapsing `..` etc.). In physical mode we must + // NOT clean before symlink resolution: bash resolves symlinks + // first and then applies `..` to the *real* directory, so + // `cd -P link/..` lands in the parent of the symlink's target, + // not the parent of the symlink itself. filepath.Join (which + // calls filepath.Clean internally) would collapse `link/..` to + // `.` before we ever see `link`, producing the wrong parent. + // Physical-mode cleaning is deferred to resolvePhysical. + if usePhysical { + if cwd == "" { + // No working directory: cannot resolve a relative path + // in physical mode. Fail fast here so resolvePhysical + // is not called with a non-absolute path (violating its + // documented contract that absPath must be absolute). + // The StatFile gate would catch this too, but explicit + // rejection is cleaner and gives a more accurate error. + callCtx.Errf("cd: %s: No such file or directory\n", display) + return builtins.Result{Code: 1} + } else { + // Raw concatenation: cwd + separator + target, preserving + // `..` for the component-by-component resolver. + absPath = cwd + string(filepath.Separator) + target + } + } else { + absPath = filepath.Join(cwd, absPath) + } + } + if !usePhysical { + // filepath.Clean collapses double-slash prefixes (//foo → /foo). + // POSIX allows implementations to treat a path beginning with exactly + // "//" as implementation-defined — bash preserves "//foo" in $PWD. + // rshell intentionally diverges here: the cleaned path is required + // for sandbox validation and the double-slash form is vanishingly rare + // in practice. This divergence is documented in SHELL_FEATURES.md. + absPath = filepath.Clean(absPath) + } + + if usePhysical { + resolved, err := resolvePhysical(ctx, callCtx, absPath) + if err != nil { + callCtx.Errf("cd: %s: %s\n", display, formatErr(callCtx, err)) + return builtins.Result{Code: 1} + } + absPath = resolved + } + + if len(absPath) > maxPathBytes { + callCtx.Errf("cd: %s: path too long\n", display) + return builtins.Result{Code: 1} + } + + if callCtx.StatFile == nil { + // StatFile is nil only when a CallContext is hand-constructed + // without wiring it — unit tests that exercise internal helpers + // or library users that build a bare CallContext. In production + // the runner always sets StatFile to a non-nil closure + // (runner_exec.go wraps r.sandbox.Stat). Note: if r.sandbox + // itself is nil (no AllowedPaths configured), the closure is + // non-nil but panics on call; that case is not guarded here — + // a nil sandbox in production is a misconfiguration and should + // surface as a panic rather than a silent no-op. + callCtx.Errf("cd: %s: stat not available\n", display) + return builtins.Result{Code: 1} + } + info, err := callCtx.StatFile(ctx, absPath) + if err != nil { + callCtx.Errf("cd: %s: %s\n", display, formatErr(callCtx, err)) + return builtins.Result{Code: 1} + } + if !info.IsDir() { + callCtx.Errf("cd: %s: Not a directory\n", display) + return builtins.Result{Code: 1} + } + + // On Unix, entering a directory requires execute (search) permission. + // StatFile can succeed even when the user lacks that permission, so + // check access explicitly via AccessFile. This is skipped on Windows + // because Windows has no POSIX execute bits for directories — the + // sandbox always denies X_OK checks on Windows (see + // allowedpaths.accessCheck), so applying the check here would break + // every cd on Windows. On Unix, AccessFile is nil only for embedded + // use (hand-constructed CallContext); the runner always wires it. + if callCtx.AccessFile != nil && runtime.GOOS != "windows" { + // 0x01 = X_OK (execute/search permission) + if err := callCtx.AccessFile(ctx, absPath, 0x01); err != nil { + callCtx.Errf("cd: %s: %s\n", display, formatErr(callCtx, err)) + return builtins.Result{Code: 1} + } + } + + if printDash { + callCtx.Out(printValue) + callCtx.Out("\n") + } + + return builtins.Result{NewWorkDir: absPath} + } +} + +// resolvePhysical resolves every symlink in absPath component by component, +// mirroring bash's `cd -P` behaviour. bash says: for physical mode, resolve +// each symlink in the path before applying `..`, so `cd -P link/..` lands in +// the *real* parent of the link's target, not in the lexical parent of the +// link itself. +// +// The algorithm walks the path left-to-right, accumulating a "resolved so +// far" prefix. For each component: +// - `..` pops the last segment of the resolved prefix (after any symlinks +// at that prefix are already resolved). +// - any other component is appended, and if the resulting path is a +// symlink its target is substituted (and the walk restarts at the new +// leaf, still bounded by maxSymlinkHops). +// +// All filesystem access goes through callCtx.LstatFile and +// callCtx.ReadlinkFile, both of which honour the AllowedPaths sandbox. +// Ancestors that fall outside the sandbox return a permission error from +// LstatFile; we treat that as "this prefix is opaque — not a symlink" and +// advance resolved without following, preserving the correct semantics for +// paths within the sandbox while remaining bounded. +// +// Bounded by maxSymlinkHops total symlink hops; ctx is checked between +// hops so cancellation is honoured. +// +// absPath must be absolute (filepath.IsAbs true); it may contain `..` +// components that have not been cleaned. +func resolvePhysical(ctx context.Context, callCtx *builtins.CallContext, absPath string) (string, error) { + if callCtx.LstatFile == nil || callCtx.ReadlinkFile == nil { + return filepath.Clean(absPath), nil + } + + // Split into components, skipping empty segments. + parts := strings.Split(filepath.ToSlash(absPath), "/") + // resolved accumulates the canonical prefix built so far. + // Start with the root (volume root on Windows, "/" on Unix). + volName := filepath.VolumeName(absPath) + resolved := volName + string(filepath.Separator) + // volNameParts holds the non-empty components that make up the volume name + // after filepath.ToSlash normalisation. For a regular Windows drive "C:" + // the set is {"C:"}. For a UNC path "\\\\server\\share", ToSlash produces + // "//server/share" and the set is {"server", "share"}. The loop below + // skips any segment in this set so that UNC host/share components are not + // treated as ordinary path components. On Unix volName is "" so the set + // is empty and there is no overhead. + volNameParts := make(map[string]bool) + for _, vp := range strings.Split(filepath.ToSlash(volName), "/") { + if vp != "" { + volNameParts[vp] = true + } + } + hops := 0 + + for i := 0; i < len(parts); i++ { + seg := parts[i] + // Skip empty segments, ".", and Windows volume-prefix components + // (e.g. drive "C:" or UNC host+share "server","share") that are + // already absorbed into resolved. volNameParts is built from the + // ToSlash-split volume name so it matches both C: paths and UNC + // paths correctly regardless of path separator used. + // NOTE: volNameParts is keyed on the exact volume-name string (e.g. + // "C:"), so a directory literally named "C:" inside the path would also + // be skipped. Such names are invalid on Windows, so this is acceptable; + // even if it occurred, the final StatFile call rejects any incorrectly + // resolved path before it is adopted. + if seg == "" || seg == "." || volNameParts[seg] { + continue + } + if seg == ".." { + // Pop the last segment off the resolved prefix (move to parent). + parent := filepath.Dir(resolved) + if parent != resolved { // guard against popping past root + resolved = parent + } + continue + } + + // Append this segment and check if it is a symlink. + candidate := filepath.Join(resolved, seg) + if len(candidate) > maxPathBytes { + return "", errors.New("path too long") + } + if err := ctx.Err(); err != nil { + return "", err + } + info, err := callCtx.LstatFile(ctx, candidate) + if err != nil { + // Sandbox boundary or path outside AllowedPaths: treat as + // a non-symlink regular entry. SECURITY: setting + // resolved = candidate here does NOT grant access to this + // path — it only influences how subsequent ".." components + // are resolved. The mandatory StatFile call at the end of + // the cd handler is the actual access-control gate and will + // reject any out-of-sandbox final target. + // NOTE: this branch fires ONLY for out-of-sandbox paths. + // sandbox.LstatFile returns the raw os.ErrPermission sentinel + // (not PortablePathError-wrapped) ONLY when s.resolve() fails + // (i.e. the path is outside AllowedPaths) — see sandbox.go:Lstat. + // Real filesystem permission errors (e.g. EACCES on a mode-000 + // symlink inside the sandbox) go through PortablePathError which + // wraps with errors.New("permission denied") — that does NOT + // satisfy errors.Is(_, fs.ErrPermission) and is therefore + // propagated via the "return \"\", err" path below, causing cd + // to fail with "permission denied" as bash would. + // So this skip is correctly scoped to out-of-sandbox intermediate + // components only. + // For any intermediate component outside the sandbox we + // simply advance resolved without following it. + if errors.Is(err, fs.ErrPermission) { + resolved = candidate + continue + } + // Propagate any other error (not-found, etc.). + return "", err + } + if info.Mode()&fs.ModeSymlink == 0 { + // Regular entry: advance resolved. + resolved = candidate + continue + } + // Symlink: follow it, count the hop. + hops++ + if hops > maxSymlinkHops { + return "", errors.New("too many levels of symbolic links") + } + target, err := callCtx.ReadlinkFile(ctx, candidate) + if err != nil { + return "", err + } + if len(target) > maxPathBytes { + return "", errors.New("path too long") + } + var newBase string + if filepath.IsAbs(target) { + // Container-style sandboxes mount the host filesystem under a + // prefix (e.g. /mnt/host). Symlink targets stored on disk are + // often host-absolute paths without that prefix, so apply + // HostPrefix when set — otherwise the resolved path would not + // be reachable through the sandbox (mirrors pwd -P's handling). + cleanedTarget := filepath.Clean(target) + if callCtx.HostPrefix != nil { + if hp := callCtx.HostPrefix(); hp != "" { + sep := string(filepath.Separator) + if !strings.HasPrefix(cleanedTarget, hp+sep) && cleanedTarget != hp { + cleanedTarget = filepath.Join(hp, cleanedTarget) + } + } + } + newBase = cleanedTarget + } else { + // Relative symlink target is relative to the directory + // containing the symlink (= resolved, not candidate). + newBase = filepath.Join(resolved, target) + } + // Guard against a HostPrefix or resolved prefix pushing newBase + // over the size limit even when target itself was within bounds. + if len(newBase) > maxPathBytes { + return "", errors.New("path too long") + } + // Prepend the symlink's target to the remaining components and + // restart the walk so we re-resolve any symlinks within it. + rest := parts[i+1:] + newParts := strings.Split(filepath.ToSlash(newBase), "/") + parts = append(newParts, rest...) + i = -1 // will be incremented to 0 by the loop + // Reset resolved to the volume root so the new absolute path + // starting from newBase's root is walked from scratch. + // Also update volName so the volume-prefix skip (seg == volName) + // stays in sync when the symlink target is on a different Windows + // volume (e.g. resolving across drive letters). + volName = filepath.VolumeName(newBase) + resolved = volName + string(filepath.Separator) + // Rebuild volNameParts for the new volume (needed when an absolute + // symlink target is on a different Windows volume, e.g. cross-drive). + volNameParts = make(map[string]bool) + for _, vp := range strings.Split(filepath.ToSlash(volName), "/") { + if vp != "" { + volNameParts[vp] = true + } + } + } + return filepath.Clean(resolved), nil +} + +// lookupVar reads name from the caller's shell environment. When LookupVar +// is not wired (older callers, tests that build CallContext directly) the +// variable is reported as unset rather than panicking. +func lookupVar(callCtx *builtins.CallContext, name string) (string, bool) { + if callCtx.LookupVar == nil { + return "", false + } + return callCtx.LookupVar(name) +} + +// formatErr maps a sandbox/IO error onto a stable, bash-compatible message +// suitable for "cd: : " formatting. The sandbox returns +// *os.PathError values whose String() form includes the syscall name and +// path (e.g. "statat foo: no such file or directory"); we strip those so +// that callers can format the path themselves and the message is stable +// across platforms. +// +// Error messages are capitalised to match bash 5.2 output +// (e.g. "No such file or directory", "Permission denied"). +// PortableErrMsg returns lowercase; we capitalise the first letter here so +// cd messages are consistent with bash without changing the project-wide +// PortableErrMsg convention used by other builtins. +func formatErr(callCtx *builtins.CallContext, err error) string { + if err == nil { + return "" + } + var pe *os.PathError + if errors.As(err, &pe) { + if callCtx.PortableErr != nil { + if msg := callCtx.PortableErr(pe.Err); msg != "" { + return capitalizeFirst(msg) + } + } + return capitalizeFirst(pe.Err.Error()) + } + if errors.Is(err, fs.ErrNotExist) { + return "No such file or directory" + } + if errors.Is(err, fs.ErrPermission) { + return "Permission denied" + } + if callCtx.PortableErr != nil { + if msg := callCtx.PortableErr(err); msg != "" { + return capitalizeFirst(msg) + } + } + return capitalizeFirst(err.Error()) +} + +// capitalizeFirst returns s with its first ASCII letter uppercased. +// This is used to match bash error-message capitalisation (e.g. "No such file +// or directory" vs the project-wide PortableErrMsg convention of lowercase). +func capitalizeFirst(s string) string { + if s == "" { + return s + } + b := []byte(s) + if b[0] >= 'a' && b[0] <= 'z' { + b[0] -= 32 + } + return string(b) +} + +// lpFlag is a boolean-shaped pflag.Value that writes its own mode into a +// shared *cdMode each time pflag invokes Set. This is how cd matches +// bash's "last one wins" rule for -L/-P: pflag's own Visit iterates in +// lexical order and so cannot tell us which of -L or -P was supplied last +// on the command line. +type lpFlag struct { + last *cdMode + mode cdMode +} + +func newLPFlag(last *cdMode, mode cdMode) *lpFlag { + return &lpFlag{last: last, mode: mode} +} + +// String reports whether this flag is currently the active one. pflag +// only consults this for `--help` formatting; the runtime decision +// reads `*f.last` directly. +func (f *lpFlag) String() string { + if f.last != nil && *f.last == f.mode { + return "true" + } + return "false" +} + +func (f *lpFlag) Set(s string) error { + switch s { + case "true", "": + // Only "this flag is now active" should update last-wins precedence; + // `--logical=false` does not mean the user wants logical handling. + *f.last = f.mode + case "false": + // Explicit deactivation: if this flag was the active one, clear it. + if *f.last == f.mode { + *f.last = modeUnset + } + default: + return errors.New("invalid boolean value: " + s) + } + return nil +} + +func (f *lpFlag) Type() string { return "bool" } + +func (f *lpFlag) IsBoolFlag() bool { return true } diff --git a/builtins/cd/cd_gnu_compat_test.go b/builtins/cd/cd_gnu_compat_test.go new file mode 100644 index 000000000..fd4734592 --- /dev/null +++ b/builtins/cd/cd_gnu_compat_test.go @@ -0,0 +1,118 @@ +// 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. + +// Reference outputs in this file describe the expected rshell behaviour for +// GNU bash 5.2-compatible scenarios. Where rshell matches GNU bash 5.2 +// (captured on Linux), the per-test comment documents the exact bash +// invocation. Where rshell intentionally diverges from bash (e.g. the +// shell-name prefix is dropped, error-message capitalisation may differ), +// the per-test comment explains the divergence so the test remains +// unambiguous. + +package cd_test + +import ( + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +// TestGNUCompatCdAbsolute — bash: `cd /tmp/dir; printf '%s\n' "$PWD"` prints +// the absolute target with a trailing newline. +func TestGNUCompatCdAbsolute(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("script embeds Windows path with backslashes that the shell parser strips as escapes") + } + dir := t.TempDir() + sub := makeDir(t, dir, "sub") + stdout, _, code := cmdRun(t, "cd "+sub+"\nprintf '%s\\n' \"$PWD\"", dir) + assert.Equal(t, 0, code) + assert.Equal(t, sub+"\n", stdout) +} + +// TestGNUCompatCdDashPrints — bash: `cd a; cd b; cd -` prints the directory +// it's switching back to (`a`) on stdout, with a trailing newline. +func TestGNUCompatCdDashPrints(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("script embeds Windows path with backslashes that the shell parser strips as escapes") + } + dir := t.TempDir() + a := makeDir(t, dir, "a") + b := makeDir(t, dir, "b") + stdout, _, code := cmdRun(t, "cd "+a+"\ncd "+b+"\ncd -", dir) + assert.Equal(t, 0, code) + assert.Equal(t, a+"\n", stdout) +} + +// TestGNUCompatCdMissing — bash: `cd doesnotexist` prints a single error +// line beginning "bash: cd: doesnotexist:" with exit 1. We deliberately +// drop the shell-name prefix and use the canonical "cd:" form so the +// message format is stable across embeddings. +// rshell matches bash's capitalisation ("No such file or directory"). +func TestGNUCompatCdMissing(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cd doesnotexist", dir) + assert.Equal(t, 1, code) + // rshell matches bash 5.2: capital N in "No such file or directory". + // (bash drops the capitalisation from PortableErrMsg; cd.formatErr capitalises.) + assert.Equal(t, "cd: doesnotexist: No such file or directory\n", stderr) +} + +// TestGNUCompatCdNotADir — bash: `cd file.txt` (regular file) prints +// "bash: cd: file.txt: Not a directory" with exit 1. We use the same +// capitalisation as bash (capital N) and drop the shell prefix. +func TestGNUCompatCdNotADir(t *testing.T) { + dir := t.TempDir() + makeFile(t, dir, "file.txt", "x") + _, stderr, code := cmdRun(t, "cd file.txt", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "cd: file.txt: Not a directory\n", stderr) +} + +// TestGNUCompatCdNoArgsHome — bash: `cd; printf '%s\n' "$PWD"` prints the +// HOME path. With our restricted shell we have to set HOME explicitly via +// the Env option. +func TestGNUCompatCdNoArgsHome(t *testing.T) { + dir := t.TempDir() + home := makeDir(t, dir, "myhome") + stdout, _, code := runScript(t, + "cd\nprintf '%s\\n' \"$PWD\"", dir, + interp.AllowedPaths([]string{dir}), + interp.Env("HOME="+home)) + assert.Equal(t, 0, code) + assert.Equal(t, home+"\n", stdout) +} + +// TestGNUCompatCdLeavesPwdOnFailure — bash leaves $PWD untouched when cd +// fails. We capture the same behaviour via an ok marker so the test does +// not depend on absolute path text. +func TestGNUCompatCdLeavesPwdOnFailure(t *testing.T) { + dir := t.TempDir() + good := makeDir(t, dir, "good") + script := strings.Join([]string{ + "cd " + good, + "BEFORE=\"$PWD\"", + "cd does-not-exist", + "[ \"$PWD\" = \"$BEFORE\" ] && printf 'ok\\n'", + }, "\n") + stdout, _, _ := cmdRun(t, script, dir) + assert.Equal(t, "ok\n", stdout) +} + +// TestGNUCompatCdRelativeJoin — bash joins relative paths against $PWD +// before resolving. Capturing the exact join output keeps drift visible +// if filepath.Clean ever changes its behaviour. +func TestGNUCompatCdRelativeJoin(t *testing.T) { + dir := t.TempDir() + makeDir(t, dir, "a/b") + stdout, _, code := cmdRun(t, "cd a\ncd b\nprintf '%s\\n' \"$PWD\"", dir) + assert.Equal(t, 0, code) + assert.Equal(t, filepath.Join(dir, "a", "b")+"\n", stdout) +} diff --git a/builtins/cd/cd_internal_test.go b/builtins/cd/cd_internal_test.go new file mode 100644 index 000000000..d2b104ab8 --- /dev/null +++ b/builtins/cd/cd_internal_test.go @@ -0,0 +1,295 @@ +// 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 cd + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/builtins" +) + +// --- lpFlag --- + +func TestLPFlagSetTrue(t *testing.T) { + var last cdMode + f := newLPFlag(&last, modePhysical) + assert.NoError(t, f.Set("")) + assert.Equal(t, modePhysical, last) + assert.Equal(t, "true", f.String()) + + assert.NoError(t, f.Set("true")) + assert.Equal(t, "true", f.String()) +} + +func TestLPFlagSetFalseDoesNotUpdateLast(t *testing.T) { + var last cdMode + f := newLPFlag(&last, modeLogical) + assert.NoError(t, f.Set("false")) + // Setting the flag to false does not claim last-wins precedence — + // `--logical=false` is not a request for logical handling. + assert.Equal(t, modeUnset, last) + assert.Equal(t, "false", f.String()) +} + +func TestLPFlagSetFalseClearsActive(t *testing.T) { + var last cdMode + f := newLPFlag(&last, modePhysical) + assert.NoError(t, f.Set("true")) + assert.Equal(t, modePhysical, last) + assert.NoError(t, f.Set("false")) + // Explicit deactivation of the active flag returns to default. + assert.Equal(t, modeUnset, last) +} + +func TestLPFlagSetInvalid(t *testing.T) { + var last cdMode + f := newLPFlag(&last, modeLogical) + err := f.Set("not-a-bool") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid boolean value") +} + +func TestLPFlagType(t *testing.T) { + var last cdMode + f := newLPFlag(&last, modeLogical) + assert.Equal(t, "bool", f.Type()) + assert.True(t, f.IsBoolFlag()) +} + +func TestIsReservedWindowsPath(t *testing.T) { + if runtime.GOOS != "windows" { + // Non-Windows: function is always false. + assert.False(t, isReservedWindowsPath("CON")) + assert.False(t, isReservedWindowsPath("nul.txt")) + return + } + assert.True(t, isReservedWindowsPath("CON")) + assert.True(t, isReservedWindowsPath("nul")) + assert.True(t, isReservedWindowsPath("Aux.txt")) + assert.True(t, isReservedWindowsPath("dir/COM1/file")) + assert.False(t, isReservedWindowsPath("ordinary")) + assert.False(t, isReservedWindowsPath("CONS")) // not the reserved name +} + +// --- formatErr --- + +func TestFormatErrNil(t *testing.T) { + cc := &builtins.CallContext{} + assert.Equal(t, "", formatErr(cc, nil)) +} + +func TestFormatErrPathErrorWithPortable(t *testing.T) { + cc := &builtins.CallContext{ + PortableErr: func(err error) string { + if errors.Is(err, fs.ErrNotExist) { + return "no such file or directory" + } + return "" + }, + } + pe := &os.PathError{Op: "stat", Path: "x", Err: fs.ErrNotExist} + assert.Equal(t, "No such file or directory", formatErr(cc, pe)) +} + +func TestFormatErrPathErrorWithoutPortable(t *testing.T) { + cc := &builtins.CallContext{} + pe := &os.PathError{Op: "stat", Path: "x", Err: errors.New("custom")} + assert.Equal(t, "Custom", formatErr(cc, pe)) +} + +func TestFormatErrNonPathErrorNotExist(t *testing.T) { + cc := &builtins.CallContext{} + assert.Equal(t, "No such file or directory", formatErr(cc, fs.ErrNotExist)) +} + +func TestFormatErrNonPathErrorPermission(t *testing.T) { + cc := &builtins.CallContext{} + assert.Equal(t, "Permission denied", formatErr(cc, fs.ErrPermission)) +} + +func TestFormatErrPortableFallback(t *testing.T) { + cc := &builtins.CallContext{ + PortableErr: func(err error) string { return "portable: " + err.Error() }, + } + custom := errors.New("plain error") + assert.Equal(t, "Portable: plain error", formatErr(cc, custom)) +} + +func TestFormatErrFinalFallback(t *testing.T) { + cc := &builtins.CallContext{ + PortableErr: func(err error) string { return "" }, + } + custom := errors.New("raw error") + assert.Equal(t, "Raw error", formatErr(cc, custom)) +} + +// --- lookupVar --- + +func TestLookupVarMissingCallback(t *testing.T) { + cc := &builtins.CallContext{} + v, ok := lookupVar(cc, "HOME") + assert.Equal(t, "", v) + assert.False(t, ok) +} + +func TestLookupVarPresent(t *testing.T) { + cc := &builtins.CallContext{ + LookupVar: func(name string) (string, bool) { + if name == "HOME" { + return "/h", true + } + return "", false + }, + } + v, ok := lookupVar(cc, "HOME") + assert.True(t, ok) + assert.Equal(t, "/h", v) +} + +// --- resolvePhysical --- + +func TestResolvePhysicalCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cc := &builtins.CallContext{ + LstatFile: func(_ context.Context, _ string) (fs.FileInfo, error) { return nil, nil }, + ReadlinkFile: func(_ context.Context, _ string) (string, error) { return "", nil }, + } + _, err := resolvePhysical(ctx, cc, "/foo") + assert.ErrorIs(t, err, context.Canceled) +} + +func TestResolvePhysicalNoCallbacks(t *testing.T) { + // Use a platform-correct absolute path so that filepath.Clean returns + // the expected value (on Windows "/abs/path" becomes "\abs\path"). + base := t.TempDir() + p := filepath.Join(base, "abs", "path") + cc := &builtins.CallContext{} // LstatFile / ReadlinkFile both nil + got, err := resolvePhysical(context.Background(), cc, p) + assert.NoError(t, err) + assert.Equal(t, filepath.Clean(p), got) +} + +// fakeInfo is a minimal fs.FileInfo whose only meaningful field is mode; +// resolvePhysical inspects only Mode() to detect symlinks. +type fakeInfo struct{ mode fs.FileMode } + +func (f fakeInfo) Name() string { return "" } +func (f fakeInfo) Size() int64 { return 0 } +func (f fakeInfo) Mode() fs.FileMode { return f.mode } +func (f fakeInfo) ModTime() time.Time { return time.Time{} } +func (f fakeInfo) IsDir() bool { return f.mode.IsDir() } +func (f fakeInfo) Sys() any { return nil } + +func TestResolvePhysicalLoop(t *testing.T) { + // Build absolute, platform-correct symlink keys so the test runs on + // Windows too (filepath.IsAbs("/a") is false on Windows, which would + // otherwise cause resolvePhysical to take the relative-target branch + // and never hit the seeded loop). The values are arbitrary keys — + // only their relative shape matters for the loop. + keyA := filepath.Join(t.TempDir(), "a") + keyB := filepath.Join(filepath.Dir(keyA), "b") + links := map[string]string{keyA: keyB, keyB: keyA} + cc := &builtins.CallContext{ + LstatFile: func(_ context.Context, p string) (fs.FileInfo, error) { + if _, ok := links[p]; ok { + return symlinkInfo(), nil + } + return fakeInfo{}, nil + }, + ReadlinkFile: func(_ context.Context, p string) (string, error) { + return links[p], nil + }, + } + _, err := resolvePhysical(context.Background(), cc, keyA) + assert.Error(t, err) + assert.Contains(t, err.Error(), "too many levels of symbolic links") +} + +func TestResolvePhysicalRelativeTarget(t *testing.T) { + // alias -> sub (relative target). After one hop resolvePhysical should + // land on the sibling "sub", which is a regular dir and ends the walk. + // Use platform-correct absolute paths via t.TempDir so the test runs on + // Windows (where "/alias" is not absolute and filepath.Dir("/alias") + // returns "\\", giving a backslash-only result). + base := t.TempDir() + alias := filepath.Join(base, "alias") + expected := filepath.Join(base, "sub") + cc := &builtins.CallContext{ + LstatFile: func(_ context.Context, p string) (fs.FileInfo, error) { + if p == alias { + return symlinkInfo(), nil + } + return fakeInfo{}, nil + }, + ReadlinkFile: func(_ context.Context, _ string) (string, error) { + return "sub", nil + }, + } + got, err := resolvePhysical(context.Background(), cc, alias) + assert.NoError(t, err) + assert.Equal(t, expected, got) +} + +func TestResolvePhysicalTargetTooLong(t *testing.T) { + // A symlink that resolves to a long absolute path must be rejected + // without further recursion. + long := "/" + stringOfLength(maxPathBytes+1) + cc := &builtins.CallContext{ + LstatFile: func(_ context.Context, _ string) (fs.FileInfo, error) { + return symlinkInfo(), nil + }, + ReadlinkFile: func(_ context.Context, _ string) (string, error) { + return long, nil + }, + } + _, err := resolvePhysical(context.Background(), cc, "/start") + assert.Error(t, err) + assert.Contains(t, err.Error(), "path too long") +} + +func TestResolvePhysicalLstatError(t *testing.T) { + cc := &builtins.CallContext{ + LstatFile: func(_ context.Context, _ string) (fs.FileInfo, error) { + return nil, errors.New("boom") + }, + ReadlinkFile: func(_ context.Context, _ string) (string, error) { return "", nil }, + } + _, err := resolvePhysical(context.Background(), cc, "/p") + assert.Error(t, err) +} + +func TestResolvePhysicalReadlinkError(t *testing.T) { + cc := &builtins.CallContext{ + LstatFile: func(_ context.Context, _ string) (fs.FileInfo, error) { + return symlinkInfo(), nil + }, + ReadlinkFile: func(_ context.Context, _ string) (string, error) { + return "", errors.New("boom") + }, + } + _, err := resolvePhysical(context.Background(), cc, "/p") + assert.Error(t, err) +} + +func symlinkInfo() fs.FileInfo { return fakeInfo{mode: fs.ModeSymlink} } + +func stringOfLength(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = 'a' + } + return string(b) +} diff --git a/builtins/cd/cd_test.go b/builtins/cd/cd_test.go new file mode 100644 index 000000000..4c6f11a83 --- /dev/null +++ b/builtins/cd/cd_test.go @@ -0,0 +1,553 @@ +// 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 cd_test + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +// skipIfWindowsBackslashScript skips the test on Windows when the script +// embeds an absolute path that contains backslashes. The shell parser +// treats backslash as an escape character in unquoted words, so a Windows +// path like "C:\\Users\\foo" is parsed as "C:Usersfoo" and the cd target +// no longer matches the directory created on disk. The cd builtin itself +// is OS-agnostic; only the test scaffolding (interpolating raw paths into +// scripts) is incompatible with Windows. +func skipIfWindowsBackslashScript(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("script embeds Windows path with backslashes that the shell parser strips as escapes") + } +} + +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...) +} + +// cmdRun runs a script with AllowedPaths anchored at dir. +func cmdRun(t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return runScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +// makeDir creates an absolute subdirectory under base and returns its path. +func makeDir(t *testing.T, base, rel string) string { + t.Helper() + abs := filepath.Join(base, rel) + require.NoError(t, os.MkdirAll(abs, 0o755)) + return abs +} + +// makeFile creates a regular file at base/rel and returns its absolute path. +func makeFile(t *testing.T, base, rel, content string) string { + t.Helper() + abs := filepath.Join(base, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755)) + require.NoError(t, os.WriteFile(abs, []byte(content), 0o644)) + return abs +} + +// --- Basic positional argument --- + +func TestCdAbsoluteDir(t *testing.T) { + skipIfWindowsBackslashScript(t) + dir := t.TempDir() + sub := makeDir(t, dir, "sub") + stdout, stderr, code := cmdRun(t, "cd "+sub+"\necho $PWD", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stderr) + assert.Equal(t, sub+"\n", stdout) +} + +func TestCdRelativeDir(t *testing.T) { + dir := t.TempDir() + sub := makeDir(t, dir, "child") + stdout, _, code := cmdRun(t, "cd child\necho $PWD", dir) + assert.Equal(t, 0, code) + assert.Equal(t, sub+"\n", stdout) +} + +func TestCdRelativeDotDot(t *testing.T) { + dir := t.TempDir() + makeDir(t, dir, "a/b") + script := "cd a\ncd b\ncd ..\necho $PWD" + stdout, _, code := cmdRun(t, script, dir) + assert.Equal(t, 0, code) + assert.Equal(t, filepath.Join(dir, "a")+"\n", stdout) +} + +func TestCdUpdatesPwdAndOldpwd(t *testing.T) { + skipIfWindowsBackslashScript(t) + dir := t.TempDir() + sub := makeDir(t, dir, "sub") + script := "cd " + sub + "\necho PWD=$PWD\necho OLDPWD=$OLDPWD" + stdout, _, code := cmdRun(t, script, dir) + assert.Equal(t, 0, code) + expected := "PWD=" + sub + "\nOLDPWD=" + dir + "\n" + assert.Equal(t, expected, stdout) +} + +// --- cd - --- + +func TestCdDashSwitchesAndPrints(t *testing.T) { + skipIfWindowsBackslashScript(t) + dir := t.TempDir() + sub := makeDir(t, dir, "sub") + script := "cd " + sub + "\ncd -" + stdout, _, code := cmdRun(t, script, dir) + assert.Equal(t, 0, code) + assert.Equal(t, dir+"\n", stdout) +} + +func TestCdDashWithoutOldpwd(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cd -", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "cd: OLDPWD not set\n", stderr) +} + +// TestCdDashErrorShowsOLDPWD verifies that when cd - fails because OLDPWD +// points to a non-existent directory, the error message shows the OLDPWD +// value (matching bash: "cd: /no/exist: No such file or directory") rather +// than the literal dash ("cd: -: ..."). +func TestCdDashErrorShowsOLDPWD(t *testing.T) { + skipIfWindowsBackslashScript(t) + dir := t.TempDir() + // OLDPWD set to a path that does not exist + script := "OLDPWD=/no/exist/at/all cd -" + _, stderr, code := cmdRun(t, script, dir) + assert.Equal(t, 1, code) + // Error must name the OLDPWD path, not the literal "-" + assert.Contains(t, stderr, "/no/exist/at/all", "error should show the OLDPWD path") + assert.NotContains(t, stderr, "cd: -:", "error should not use the literal dash") +} + +func TestCdDashSetsOldpwdToCurrent(t *testing.T) { + skipIfWindowsBackslashScript(t) + dir := t.TempDir() + sub := makeDir(t, dir, "sub") + script := "cd " + sub + "\ncd -\necho OLDPWD=$OLDPWD\necho PWD=$PWD" + stdout, _, code := cmdRun(t, script, dir) + assert.Equal(t, 0, code) + // `cd -` prints the destination (the previous OLDPWD = dir). + expected := dir + "\nOLDPWD=" + sub + "\nPWD=" + dir + "\n" + assert.Equal(t, expected, stdout) +} + +// TestCdInlineAssignmentSurvivesRestore covers `OLDPWD=X cd -`. Bash's +// inline assignments are restored after the command returns, but cd's +// own update to PWD/OLDPWD must survive the restore — otherwise the +// inline value would overwrite cd's intended bookkeeping. Regression +// test for the case where the inline-restore loop in interp/runner_exec +// reverted cd's update. +func TestCdInlineAssignmentSurvivesRestore(t *testing.T) { + skipIfWindowsBackslashScript(t) + dir := t.TempDir() + a := makeDir(t, dir, "a") + b := makeDir(t, dir, "b") + script := "cd " + a + "\ncd " + b + "\nOLDPWD=" + dir + " cd -\necho OLDPWD=$OLDPWD\necho PWD=$PWD" + stdout, _, code := cmdRun(t, script, dir) + assert.Equal(t, 0, code) + // After the inline `OLDPWD=$dir cd -`, bash sets: + // OLDPWD = $b (cd's update — the directory we left) + // PWD = $dir (the inline OLDPWD we cd'd into) + // not OLDPWD = $a (the pre-inline value that the restore would clobber). + expected := dir + "\nOLDPWD=" + b + "\nPWD=" + dir + "\n" + assert.Equal(t, expected, stdout) +} + +// TestCdInlineEmptyPWDLeavesOLDPWDEmpty verifies that when $PWD is set to +// the empty string before cd (via an inline assignment), bash records an +// empty OLDPWD rather than falling back to the internal runner directory. +// Regression test for applyNewWorkDir distinguishing unset vs empty $PWD. +func TestCdInlineEmptyPWDLeavesOLDPWDEmpty(t *testing.T) { + skipIfWindowsBackslashScript(t) + dir := t.TempDir() + sub := makeDir(t, dir, "sub") + // `PWD="" cd sub` — bash sets OLDPWD="" (the empty inline value), + // not OLDPWD=dir (the internal runner directory). + script := "cd " + dir + "\nPWD='' cd sub\necho OLDPWD=\"$OLDPWD\"" + stdout, _, code := cmdRun(t, script, dir) + assert.Equal(t, 0, code) + _ = sub + assert.Equal(t, "OLDPWD=\n", stdout, "empty PWD assignment should leave OLDPWD empty") +} + +// --- cd with no args (HOME) --- + +func TestCdNoArgsWithHome(t *testing.T) { + dir := t.TempDir() + home := makeDir(t, dir, "myhome") + stdout, _, code := runScript(t, "cd\necho $PWD", dir, + interp.AllowedPaths([]string{dir}), + interp.Env("HOME="+home)) + assert.Equal(t, 0, code) + assert.Equal(t, home+"\n", stdout) +} + +func TestCdNoArgsWithoutHome(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cd", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "cd: HOME not set\n", stderr) +} + +func TestCdNoArgsEmptyHome(t *testing.T) { + // Bash distinguishes unset HOME (error) from set-but-empty + // (no-op success that still updates OLDPWD to the current dir). + // Verify rshell matches bash: HOME="" cd returns 0 with no stderr, + // PWD untouched, and OLDPWD updated to the current directory. + // Verified: docker run --rm debian:bookworm-slim bash -c + // 'cd /tmp; HOME="" cd; echo "exit=$? PWD=$PWD OLDPWD=$OLDPWD"' + // Output: exit=0 PWD=/tmp OLDPWD=/tmp + dir := t.TempDir() + stdout, stderr, code := runScript(t, + `cd; printf '%s\n%s\n' "$PWD" "$OLDPWD"`, dir, + interp.AllowedPaths([]string{dir}), + interp.Env("HOME=")) + assert.Equal(t, 0, code) + assert.Equal(t, "", stderr) + // $PWD unchanged and $OLDPWD updated to the current dir (bash behaviour). + assert.Equal(t, dir+"\n"+dir+"\n", stdout) +} + +// --- Errors --- + +func TestCdMissingDir(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cd does-not-exist", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "cd: does-not-exist: No such file or directory\n", stderr) +} + +func TestCdNotADirectory(t *testing.T) { + dir := t.TempDir() + makeFile(t, dir, "afile", "x") + _, stderr, code := cmdRun(t, "cd afile", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "cd: afile: Not a directory\n", stderr) +} + +func TestCdOutsideAllowedPaths(t *testing.T) { + skipIfWindowsBackslashScript(t) + allowed := t.TempDir() + other := t.TempDir() + _, stderr, code := cmdRun(t, "cd "+other, allowed) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cd: ") + assert.Contains(t, stderr, other) +} + +func TestCdTooManyArgs(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cd a b", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "cd: too many arguments\n", stderr) +} + +// TestCdInterspersedFlagRejected verifies that SetInterspersed(false) +// prevents pflag from reordering "cd sub -P" into "cd -P sub". bash +// treats "sub" as the operand and "-P" as a second operand, producing +// "too many arguments". Without the guard, pflag would parse -P as a +// flag and silently change directories under physical mode. +func TestCdInterspersedFlagRejected(t *testing.T) { + dir := t.TempDir() + sub := makeDir(t, dir, "sub") + _ = sub + // "cd sub -P" should fail with too many arguments, matching bash. + _, stderr, code := cmdRun(t, "cd sub -P", dir) + assert.Equal(t, 1, code, "cd sub -P should fail, not silently cd -P sub") + assert.Contains(t, stderr, "too many arguments") +} + +func TestCdUnknownFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cd --no-such-flag", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cd:") +} + +func TestCdShortRejectFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "cd -X", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cd:") +} + +// TestCdEFlagCompat verifies that the bash compat flag -e is silently accepted +// without producing an error. Bash ≥ 4.0 accepts -e as a modifier for -P (exit +// non-zero if the physical path cannot be determined); rshell accepts but ignores +// it so scripts using `cd -e dir` or `cd -Pe dir` do not break. +func TestCdEFlagCompat(t *testing.T) { + dir := t.TempDir() + sub := makeDir(t, dir, "sub") + _ = sub + // `cd -e sub` should succeed (treat -e as no-op compat flag). + _, stderr, code := cmdRun(t, "cd -e sub", dir) + assert.Equal(t, 0, code, "cd -e sub should succeed (bash compat no-op flag)") + assert.Equal(t, "", stderr) + // `cd -Pe sub` should also succeed. + _, stderr, code = cmdRun(t, "cd -Pe sub", dir) + assert.Equal(t, 0, code, "cd -Pe sub should succeed (bash compat no-op flag)") + assert.Equal(t, "", stderr) +} + +func TestCdEmptyArg(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, `cd ""; echo "$PWD"`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stderr) + assert.Contains(t, stdout, dir) +} + +// --- Help --- + +func TestCdHelp(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "cd --help", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stderr) + assert.Contains(t, stdout, "Usage: cd") + assert.Contains(t, stdout, "--logical") + assert.Contains(t, stdout, "--physical") +} + +func TestCdHelpShort(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, "cd -h", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage: cd") +} + +// --- Failure leaves state unchanged --- + +func TestCdFailureLeavesPwdAndOldpwdUntouched(t *testing.T) { + dir := t.TempDir() + makeDir(t, dir, "good") + script := strings.Join([]string{ + "cd good", + "cd does-not-exist", + "echo PWD=$PWD", + "echo OLDPWD=$OLDPWD", + }, "\n") + stdout, stderr, code := cmdRun(t, script, dir) + // `cd does-not-exist` exits 1 but the script then reports the unchanged state. + // The interpreter ends with the last command's exit code. + assert.Equal(t, 0, code) + assert.Contains(t, stderr, "No such file or directory") + expected := "PWD=" + filepath.Join(dir, "good") + "\nOLDPWD=" + dir + "\n" + assert.Equal(t, expected, stdout) +} + +// --- -L vs -P --- + +func TestCdLogicalDefault(t *testing.T) { + dir := t.TempDir() + target := makeDir(t, dir, "real") + link := filepath.Join(dir, "alias") + require.NoError(t, os.Symlink(target, link)) + stdout, _, code := cmdRun(t, "cd alias\necho $PWD", dir) + assert.Equal(t, 0, code) + // Logical: PWD reflects the alias path, not the target. + assert.Equal(t, filepath.Join(dir, "alias")+"\n", stdout) +} + +func TestCdPhysicalResolvesSymlink(t *testing.T) { + dir := t.TempDir() + target := makeDir(t, dir, "real") + link := filepath.Join(dir, "alias") + require.NoError(t, os.Symlink(target, link)) + stdout, _, code := cmdRun(t, "cd -P alias\necho $PWD", dir) + assert.Equal(t, 0, code) + assert.Equal(t, target+"\n", stdout) +} + +// TestCdPhysicalResolvesIntermediateSymlink covers the case where an +// *intermediate* path component (not the leaf) is a symlink. Bash's +// `cd -P` resolves every symlink, including ancestors, so $PWD reflects +// the canonical real path. Regression test for a bug where the resolver +// only Lstat'd the full path and missed intermediate symlinks because +// the kernel transparently follows them on Lstat. +func TestCdPhysicalResolvesIntermediateSymlink(t *testing.T) { + dir := t.TempDir() + makeDir(t, dir, "real/inside") + link := filepath.Join(dir, "alias") + require.NoError(t, os.Symlink(filepath.Join(dir, "real"), link)) + stdout, _, code := cmdRun(t, "cd -P alias/inside\necho $PWD", dir) + assert.Equal(t, 0, code) + expected := filepath.Join(dir, "real", "inside") + "\n" + assert.Equal(t, expected, stdout) +} + +func TestCdLPLastWins_PWins(t *testing.T) { + dir := t.TempDir() + target := makeDir(t, dir, "real") + link := filepath.Join(dir, "alias") + require.NoError(t, os.Symlink(target, link)) + stdout, _, code := cmdRun(t, "cd -L -P alias\necho $PWD", dir) + assert.Equal(t, 0, code) + assert.Equal(t, target+"\n", stdout) +} + +func TestCdLPLastWins_LWins(t *testing.T) { + dir := t.TempDir() + target := makeDir(t, dir, "real") + link := filepath.Join(dir, "alias") + require.NoError(t, os.Symlink(target, link)) + stdout, _, code := cmdRun(t, "cd -P -L alias\necho $PWD", dir) + assert.Equal(t, 0, code) + assert.Equal(t, filepath.Join(dir, "alias")+"\n", stdout) +} + +// TestCdPhysicalDashPrintsRawOldpwd guards the printValue-before-resolution +// invariant: cd -P - must print the raw OLDPWD string, not the physically +// resolved path. Bash prints the symlink path even under -P. +func TestCdPhysicalDashPrintsRawOldpwd(t *testing.T) { + skipIfWindowsBackslashScript(t) + dir := t.TempDir() + target := makeDir(t, dir, "real") + link := filepath.Join(dir, "alias") + require.NoError(t, os.Symlink(target, link)) + // cd to the symlink (logical), then cd somewhere else, then cd -P -. + // OLDPWD should be the symlink path; cd -P - should print the symlink + // path (raw OLDPWD) while landing in the resolved target. + script := strings.Join([]string{ + "cd " + link, // PWD = .../alias, OLDPWD = dir + "cd ..", // PWD = dir, OLDPWD = .../alias + "cd -P -", // prints OLDPWD (= .../alias), lands in .../alias physical = .../real + "echo PWD=$PWD", + }, "\n") + stdout, _, code := cmdRun(t, script, dir) + assert.Equal(t, 0, code) + // First line is the printed OLDPWD (the symlink path, not the real path). + // Second line is the echo of $PWD after landing. + lines := strings.SplitN(stdout, "\n", 3) + assert.GreaterOrEqual(t, len(lines), 2) + assert.Equal(t, link, lines[0], "cd -P - must print raw OLDPWD (symlink path)") + assert.Equal(t, "PWD="+target, lines[1], "cd -P - must set PWD to the resolved path") +} + +// --- Path-too-long hardening --- + +func TestCdPathTooLong(t *testing.T) { + dir := t.TempDir() + long := "/" + strings.Repeat("a", 70000) + _, stderr, code := cmdRun(t, "cd "+long, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "cd: path too long\n", stderr) +} + +// --- Symlink loop detection --- + +func TestCdPhysicalSymlinkLoop(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a") + b := filepath.Join(dir, "b") + require.NoError(t, os.Symlink(b, a)) + require.NoError(t, os.Symlink(a, b)) + _, stderr, code := cmdRun(t, "cd -P a", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cd: a:") + assert.Contains(t, stderr, "Too many levels of symbolic links") +} + +// --- Sandbox preserved across cd --- + +func TestCdHonorsAllowedPathsAfterChange(t *testing.T) { + allowed := t.TempDir() + makeDir(t, allowed, "sub") + makeFile(t, allowed, "sub/inside.txt", "hello\n") + other := t.TempDir() + makeFile(t, other, "outside.txt", "blocked\n") + + script := strings.Join([]string{ + "cd sub", + "cat inside.txt", + "cat " + filepath.Join(other, "outside.txt"), + }, "\n") + stdout, stderr, code := runScript(t, script, allowed, + interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.Contains(t, stdout, "hello") + assert.Contains(t, stderr, "outside.txt") +} + +// --- Subshell isolation --- + +func TestCdInSubshellDoesNotEscape(t *testing.T) { + dir := t.TempDir() + makeDir(t, dir, "sub") + script := strings.Join([]string{ + "(cd sub; echo INNER=$PWD)", + "echo OUTER=$PWD", + }, "\n") + stdout, _, code := cmdRun(t, script, dir) + assert.Equal(t, 0, code) + expected := "INNER=" + filepath.Join(dir, "sub") + "\nOUTER=" + dir + "\n" + assert.Equal(t, expected, stdout) +} + +// --- Cancellation safety --- + +func TestCdCancelledContext(t *testing.T) { + dir := t.TempDir() + makeDir(t, dir, "sub") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + // A pre-cancelled context must terminate the script quickly and + // without panicking. The runner surfaces context.Canceled as a + // non-ExitStatus error, which testutil maps to exit code 0; the + // only assertion that survives that translation is "did it return". + done := make(chan struct{}) + go func() { + defer close(done) + _, _, _ = runScriptCtx(ctx, t, "cd sub", dir, interp.AllowedPaths([]string{dir})) + }() + select { + case <-done: + // Returned cleanly — no panic, no hang. + case <-time.After(5 * time.Second): + t.Fatal("cd hung after context cancellation") + } +} + +// --- Long (non-cyclic) symlink chain --- + +func TestCdPhysicalLongSymlinkChain(t *testing.T) { + skipIfWindowsBackslashScript(t) + dir := t.TempDir() + target := makeDir(t, dir, "real") + // Build a chain link0 -> link1 -> ... -> link50 -> real, exceeding + // the maxSymlinkHops cap. The chain is acyclic but still must be + // rejected so that cd cannot be made to do unbounded work. + prev := target + for i := 50; i >= 0; i-- { + name := filepath.Join(dir, "link"+strings.Repeat("x", i)) + require.NoError(t, os.Symlink(prev, name)) + prev = name + } + _, stderr, code := cmdRun(t, "cd -P "+prev, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "Too many levels of symbolic links") +} diff --git a/builtins/tests/cd/cd_fuzz_test.go b/builtins/tests/cd/cd_fuzz_test.go new file mode 100644 index 000000000..178c2cb19 --- /dev/null +++ b/builtins/tests/cd/cd_fuzz_test.go @@ -0,0 +1,231 @@ +// 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 cd_test fuzzes the cd builtin from the outside, through the +// shell interpreter. Each Fuzz* function picks a different mode of cd +// (positional path, `cd -`, `cd` with HOME) so the seed corpus is +// focused and a failing input is easy to attribute. A failure is any +// panic, hang, or exit code outside {0, 1}. + +package cd_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + "unicode/utf8" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +// shellSafe filters fuzz inputs that would crash the shell parser before +// the cd builtin sees them. The parser rejects invalid UTF-8 and treats +// embedded NUL/newlines as syntax errors — neither is interesting to the +// cd-command pentest. +// +// Some C1 control characters (e.g. U+0080, encoded 0xC2 0x80) trigger a +// known mvdan.cc/sh/v3 tokenizer quirk inside single/double quotes +// ("reached EOF without closing quote"). We do *not* filter them here: +// the parser surfaces a normal exit-1 error which the harness's +// `code != 0 && code != 1` check tolerates. The seed corpus entry in +// testdata/fuzz/FuzzCdFlags/be32d37903cefe74 exists as a regression to +// confirm cd survives that parse failure without panicking. +func shellSafe(s string) bool { + if strings.ContainsAny(s, "\x00\n€") { + return false + } + return utf8.ValidString(s) +} + +// pathSeeds collects the corpus of path strings exercised by FuzzCdPath. +// Every entry encodes a distinct concern; the comments describe what +// each one is meant to surface so a future maintainer can extend the +// list without reintroducing duplicates. +var pathSeeds = []string{ + // --- Implementation edges --- + "", // empty: bash no-op (stays in $PWD, exit 0) + ".", // self + "..", // parent + "./.", // redundant components + "sub", // simple relative target (must exist for the target dir below) + "sub/", // trailing slash + "./sub", // explicit-relative + "sub/./.", // redundant + "sub/../sub", // round trip + "a/b/c", // nested missing + "-", // dash means OLDPWD; covered separately too + "--", // pflag end-of-flags + "-funny", // dash-prefixed name + " sub", // leading whitespace + "sub\t", // trailing tab + // Note: paths with embedded newlines ("\n") or NULs ("\x00") are + // filtered by shellSafe() before reaching cd — the shell parser + // treats them as syntax errors. They are not useful seeds. + "sub/../../..", // upward escape attempts + "//double", // duplicated separator + "/etc", // absolute outside sandbox + "/dev/null", // device file (must reject as not-a-directory) + + // --- CVE-class long-input probes --- + strings.Repeat("a", 4096), + strings.Repeat("a", 4097), + strings.Repeat("a", 65535), // just under maxPathBytes + strings.Repeat("a", 65536), // exactly at maxPathBytes + strings.Repeat("a", 65537), // just over maxPathBytes (must be rejected) + + // --- Encoding probes --- + "\xff\xfe", // bad UTF-8 + "\xed\xa0\x80", // surrogate half + "\xfc\x80\x80\x80\x80\xaf", // overlong null + "\x7fELF", // ELF magic prefix + "PK\x03\x04", // ZIP magic prefix + + // --- Existing test inputs --- + "sub", "child", "real", "alias", +} + +func FuzzCdPath(f *testing.F) { + for _, s := range pathSeeds { + f.Add(s) + } + + baseDir := f.TempDir() + var counter atomic.Int64 + + f.Fuzz(func(t *testing.T, input string) { + // Cap at 1 MiB; cd's own bound is 64 KiB but Go fuzz strings can + // be up to 1 MiB before we even start. + if len(input) > 1<<20 { + return + } + // Filter inputs that would cause shell parse errors (the fuzzer + // targets the cd builtin, not the parser). + if !shellSafe(input) { + return + } + + dir, cleanup := testutil.FuzzIterDir(t, baseDir, &counter) + defer cleanup() + // Pre-create a real `sub` so that a meaningful fraction of + // inputs exercise the success path. + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // Wrap input in single quotes; cd parses one token. + quoted := "'" + strings.ReplaceAll(input, "'", "'\\''") + "'" + _, _, code := cmdRunCtxFuzz(ctx, t, "cd "+quoted, dir) + if code != 0 && code != 1 { + t.Errorf("cd %q: unexpected exit code %d", input, code) + } + }) +} + +// FuzzCdDash exercises the `cd -` path with arbitrary OLDPWD values via +// the Env runner option. It verifies that no OLDPWD value produces a +// crash or unexpected exit code. +func FuzzCdDash(f *testing.F) { + for _, s := range pathSeeds { + f.Add(s) + } + f.Add("/") + f.Add("/tmp") + + baseDir := f.TempDir() + var counter atomic.Int64 + + f.Fuzz(func(t *testing.T, oldpwd string) { + if len(oldpwd) > 1<<20 { + return + } + if !shellSafe(oldpwd) { + return + } + dir, cleanup := testutil.FuzzIterDir(t, baseDir, &counter) + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, code := cmdRunCtxFuzz(ctx, t, "cd -", dir, interp.Env("OLDPWD="+oldpwd)) + if code != 0 && code != 1 { + t.Errorf("cd - with OLDPWD=%q: unexpected exit code %d", oldpwd, code) + } + }) +} + +// FuzzCdHome exercises the bare-`cd` path with arbitrary HOME values. +func FuzzCdHome(f *testing.F) { + for _, s := range pathSeeds { + f.Add(s) + } + + baseDir := f.TempDir() + var counter atomic.Int64 + + f.Fuzz(func(t *testing.T, home string) { + if len(home) > 1<<20 { + return + } + if !shellSafe(home) { + return + } + dir, cleanup := testutil.FuzzIterDir(t, baseDir, &counter) + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, code := cmdRunCtxFuzz(ctx, t, "cd", dir, interp.Env("HOME="+home)) + if code != 0 && code != 1 { + t.Errorf("cd with HOME=%q: unexpected exit code %d", home, code) + } + }) +} + +// FuzzCdFlags exercises pflag's parsing of arbitrary flag-like first +// tokens. The fuzzer targets unexpected pflag interactions and must not +// produce panics or unexpected exit codes. +func FuzzCdFlags(f *testing.F) { + flagSeeds := []string{ + "-L", "-P", "-LP", "-PL", "-LL", "-PP", + "--logical", "--physical", "--logical=true", "--physical=false", + "--help", "-h", + "--", "---", "-", + "-x", "-PLh", "-help", "--no-such-flag", + "--LOGICAL", // case + "-\x00", // embedded NUL + } + for _, s := range flagSeeds { + f.Add(s) + } + + baseDir := f.TempDir() + var counter atomic.Int64 + + f.Fuzz(func(t *testing.T, flag string) { + if len(flag) > 256 { + return + } + if !shellSafe(flag) { + return + } + dir, cleanup := testutil.FuzzIterDir(t, baseDir, &counter) + defer cleanup() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + quoted := "'" + strings.ReplaceAll(flag, "'", "'\\''") + "'" + _, _, code := cmdRunCtxFuzz(ctx, t, "cd "+quoted+" sub", dir) + if code != 0 && code != 1 { + t.Errorf("cd %q sub: unexpected exit code %d", flag, code) + } + }) +} diff --git a/builtins/tests/cd/helpers_test.go b/builtins/tests/cd/helpers_test.go new file mode 100644 index 000000000..c7932d373 --- /dev/null +++ b/builtins/tests/cd/helpers_test.go @@ -0,0 +1,24 @@ +// 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 cd_test + +import ( + "context" + "testing" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +// cmdRunCtxFuzz runs a script with a context and AllowedPaths anchored at +// dir. Distinct from cmdRunCtx in the unit-test file (which lives in the +// builtins/cd package) to avoid name collisions when the unit and fuzz +// tests share a directory; this helper is owned by the fuzz suite. +func cmdRunCtxFuzz(ctx context.Context, t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + allOpts := append([]interp.RunnerOption{interp.AllowedPaths([]string{dir})}, opts...) + return testutil.RunScriptCtx(ctx, t, script, dir, allOpts...) +} diff --git a/builtins/tests/cd/testdata/fuzz/FuzzCdFlags/be32d37903cefe74 b/builtins/tests/cd/testdata/fuzz/FuzzCdFlags/be32d37903cefe74 new file mode 100644 index 000000000..0cec04833 --- /dev/null +++ b/builtins/tests/cd/testdata/fuzz/FuzzCdFlags/be32d37903cefe74 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("\u0080") diff --git a/interp/api.go b/interp/api.go index 691cea130..922c0871e 100644 --- a/interp/api.go +++ b/interp/api.go @@ -193,6 +193,14 @@ type runnerState struct { // (including concurrent pipe subshells) via pointer, and must be // accessed atomically. globReadDirCount *atomic.Int64 + + // lastCallChangedWorkDir is set by [call] when a builtin (e.g. cd) + // returns a non-empty Result.NewWorkDir, signalling that PWD and + // OLDPWD were updated by [applyNewWorkDir]. The inline-assignment + // restore loop in [cmd] consults this so it does not stomp those + // builtin-side updates with the pre-inline values, matching bash's + // observable behaviour for `OLDPWD=X cd -`. + lastCallChangedWorkDir bool } // A Runner interprets shell programs. It can be reused, but it is not safe for @@ -811,6 +819,11 @@ func (r *Runner) subshell(background bool) *Runner { lastExit: r.lastExit, startTime: r.startTime, globReadDirCount: r.globReadDirCount, + // lastCallChangedWorkDir tracks "did the current call update the + // working dir" — it is a per-call signal, not inherited state. + // Explicitly zero it so a subshell that starts right after a + // successful cd does not inherit a stale true value. + lastCallChangedWorkDir: false, }, } r2.writeEnv = newOverlayEnviron(r.writeEnv, background) diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 504aaa742..490a646a0 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -11,6 +11,7 @@ import ( "github.com/DataDog/rshell/builtins" breakcmd "github.com/DataDog/rshell/builtins/break" "github.com/DataDog/rshell/builtins/cat" + "github.com/DataDog/rshell/builtins/cd" continuecmd "github.com/DataDog/rshell/builtins/continue" "github.com/DataDog/rshell/builtins/cut" "github.com/DataDog/rshell/builtins/echo" @@ -46,6 +47,7 @@ func registerBuiltins() { for _, cmd := range []builtins.Command{ breakcmd.Cmd, cat.Cmd, + cd.Cmd, cut.Cmd, continuecmd.Cmd, echo.Cmd, diff --git a/interp/runner.go b/interp/runner.go index f6a87e4d6..ff7f7ed55 100644 --- a/interp/runner.go +++ b/interp/runner.go @@ -11,6 +11,7 @@ import ( "io" "os" + "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/syntax" "github.com/DataDog/rshell/allowedpaths" @@ -36,6 +37,88 @@ func (r *Runner) errf(format string, a ...any) { fmt.Fprintf(r.stderr, format, a...) } +// applyNewWorkDir is invoked by call() after a builtin (cd) returns a +// non-empty Result.NewWorkDir on a successful exit. It rotates the +// previous directory into $OLDPWD, installs the new directory as r.Dir, +// and refreshes $PWD so that subsequent path resolution and parameter +// expansion reflect the change. +// +// For OLDPWD, bash uses the current shell $PWD variable value (not the +// internal runner directory r.Dir). This matters when scripts assign PWD +// inline before calling cd, e.g. `PWD=/sentinel cd sub` — bash records +// /sentinel as OLDPWD. We therefore read $PWD from the shell variables +// rather than r.Dir. +// +// The distinction between unset and empty $PWD matters: bash uses the +// empty string as-is for OLDPWD when $PWD is set-but-empty (e.g. after +// `PWD="" cd sub`), but falls back to internal bookkeeping only when +// $PWD is truly unset. +// +// The builtin is expected to have already validated newDir against the +// sandbox; this method only performs state mutation. +// +// All three state updates (OLDPWD, r.Dir, PWD) are committed atomically: +// if OLDPWD is written successfully but the subsequent PWD write fails, +// OLDPWD is rolled back to its previous value so that the variable store +// remains consistent. r.Dir is not changed until both variable writes +// succeed, preventing the runner's working directory from diverging from $PWD. +func (r *Runner) applyNewWorkDir(newDir string) { + // Prefer the shell $PWD variable as the old directory to record in + // OLDPWD, matching bash's behaviour for inline PWD assignments. + // Use the ok boolean to distinguish truly-unset $PWD (fall back to + // r.Dir) from set-but-empty $PWD (use the empty string as-is, + // matching bash: `PWD="" cd x` leaves OLDPWD empty). + old, ok := r.lookupVarString("PWD") + if !ok { + // $PWD is truly unset: fall back to internal dir but skip the + // OLDPWD update when that is also empty (runner has no prior dir). + old = r.Dir + } + // Write OLDPWD and PWD atomically: if PWD write fails after OLDPWD + // succeeds, roll back OLDPWD so that the variable store remains + // consistent (no partial update where OLDPWD changed but PWD did not). + // Always set OLDPWD when $PWD was explicitly set (even to empty), + // matching bash: `PWD="" cd sub` sets OLDPWD="". Only skip the + // OLDPWD update when $PWD was unset AND the fallback is also empty. + var prevOLDPWD string + var prevOLDPWDSet bool + wroteOLDPWD := false + if ok || old != "" { + // Capture prior OLDPWD before overwriting it so we can roll back. + prevOLDPWD, prevOLDPWDSet = r.lookupVarString("OLDPWD") + if err := r.setVarErr("OLDPWD", expand.Variable{Set: true, Kind: expand.String, Str: old}); err != nil { + r.errf("OLDPWD: %v\n", err) + r.exit.code = 1 + return + } + wroteOLDPWD = true + } + if err := r.setVarErr("PWD", expand.Variable{Set: true, Kind: expand.String, Str: newDir}); err != nil { + r.errf("PWD: %v\n", err) + r.exit.code = 1 + // Roll back OLDPWD so the variable store stays consistent: + // the cd did not complete, so OLDPWD should be unchanged. + if wroteOLDPWD { + _ = r.setVarErr("OLDPWD", expand.Variable{Set: prevOLDPWDSet, Kind: expand.String, Str: prevOLDPWD}) + } + return + } + // Only update the internal directory after both variable writes succeeded. + r.Dir = newDir +} + +// lookupVarString returns the string value of a shell variable and a +// boolean indicating whether it was set. It is the bridge between +// builtins.CallContext.LookupVar and Runner.lookupVar so the closure does +// not need to be duplicated at every CallContext construction site. +func (r *Runner) lookupVarString(name string) (string, bool) { + vr := r.lookupVar(name) + if !vr.IsSet() { + return "", false + } + return vr.String(), true +} + func (r *Runner) stop(ctx context.Context) bool { if r.exit.exiting { return true diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 57a44b508..ad72ce297 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -116,9 +116,34 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { defer func() { for _, restore := range restores { + // When the builtin updated the working directory (cd + // returning Result.NewWorkDir), it has already written + // fresh values to PWD and OLDPWD via applyNewWorkDir. + // Restoring those to the pre-inline values here would + // silently undo cd's update, which diverges from bash: + // cd /a; cd /b; OLDPWD=/foo cd - + // bash → echo $OLDPWD = /b (cd's update wins) + // Skip PWD/OLDPWD in that case so cd's writes survive. + // + // CONTRACT: this guard fires for ANY builtin returning a + // non-empty Result.NewWorkDir — currently only "cd". + // A future builtin that (a) accepts an inline PWD/OLDPWD + // assignment AND (b) returns NewWorkDir for a different + // purpose would have its inline restore silently suppressed. + // See builtins.Result.NewWorkDir for the full contract. + if r.lastCallChangedWorkDir && (restore.name == "PWD" || restore.name == "OLDPWD") { + continue + } r.setVarRestore(restore.name, restore.vr) } }() + // Reset the workdir-change flag here too (not just at the top of + // call()). If a command-substitution expansion in an inline + // assignment causes a fatal exit, call() is skipped entirely but + // the defer above still fires. Without this reset, the defer + // would read a stale true from a *previous* cd and incorrectly + // skip restoring PWD/OLDPWD from their inline values. + r.lastCallChangedWorkDir = false if r.exit.ok() { r.call(ctx, cm.Args[0].Pos(), fields) } @@ -287,6 +312,8 @@ func (r *Runner) loopStmtsBroken(ctx context.Context, stmts []*syntax.Stmt) bool func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { name := args[0] r.totalCount++ + // Reset per-call workdir-change flag so callers see a fresh value. + r.lastCallChangedWorkDir = false // Evaluate both policy checks upfront so the span tags reflect the // independent facts about the command name regardless of which gate @@ -414,11 +441,16 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { CommandAllowed: func(n string) bool { return r.allowAllCommands || r.allowedCommands[n] }, + LookupVar: r.lookupVarString, } if r.stdin != nil { child.Stdin = r.stdin } result := cmdFn(ctx, child, cmdArgs) + // NewWorkDir is intentionally NOT applied here. This closure + // runs builtins invoked from another builtin (e.g. find -exec) + // against a caller-supplied dir; mutating r.Dir would leak + // per-invocation state into the outer shell. return result.Code, nil } call := &builtins.CallContext{ @@ -493,6 +525,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { CommandAllowed: func(cmdName string) bool { return r.allowAllCommands || r.allowedCommands[cmdName] }, + LookupVar: r.lookupVarString, RunCommand: runCmd, Proc: r.proc, } @@ -504,6 +537,29 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { r.exit.exiting = result.Exiting r.breakEnclosing = result.BreakN r.contnEnclosing = result.ContinueN + if result.Code == 0 && result.NewWorkDir != "" { + // Defense-in-depth: re-validate NewWorkDir against the sandbox + // before applying it. A well-behaved builtin (currently only "cd") + // already calls callCtx.StatFile before returning NewWorkDir, so + // this check is normally redundant and fast (the Stat result is + // not cached, but the path was already opened via os.Root). + // The guard exists to catch future builtins that return NewWorkDir + // without the required prior StatFile call — in that case the + // sandbox rejects the path here instead of silently adopting an + // unvalidated working directory. + // See builtins.Result.NewWorkDir for the full contract. + if r.sandbox != nil { + if _, err := r.sandbox.Stat(result.NewWorkDir, ""); err != nil { + // The builtin returned a path that the sandbox cannot + // validate. Treat this as a command failure. + r.errf("rshell: internal: NewWorkDir %q rejected by sandbox: %v\n", result.NewWorkDir, err) + r.exit.code = 1 + return + } + } + r.applyNewWorkDir(result.NewWorkDir) + r.lastCallChangedWorkDir = true + } return } // Allowed but not known: the default execHandler (noExecHandler) will diff --git a/tests/scenarios/cmd/cd/basic/dot.yaml b/tests/scenarios/cmd/cd/basic/dot.yaml new file mode 100644 index 000000000..c971beadb --- /dev/null +++ b/tests/scenarios/cmd/cd/basic/dot.yaml @@ -0,0 +1,18 @@ +# Derived from bash: cd . stays in the same directory and updates $OLDPWD. +description: cd . stays in the current directory and updates OLDPWD. +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + cd sub + BEFORE="$PWD" + cd . + [ "$PWD" = "$BEFORE" ] && [ "$OLDPWD" = "$BEFORE" ] && echo ok +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/basic/dotdot.yaml b/tests/scenarios/cmd/cd/basic/dotdot.yaml new file mode 100644 index 000000000..34f756cbc --- /dev/null +++ b/tests/scenarios/cmd/cd/basic/dotdot.yaml @@ -0,0 +1,17 @@ +# Derived from GNU bash semantics: cd .. ascends one level. +description: cd .. ascends out of a subdirectory. +setup: + files: + - path: a/b/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + cd a/b + cd .. + echo "$PWD" +expect: + stdout_contains: ["/a"] + stdout_contains_windows: ["\\a"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/basic/double_slash.yaml b/tests/scenarios/cmd/cd/basic/double_slash.yaml new file mode 100644 index 000000000..1dba38b0f --- /dev/null +++ b/tests/scenarios/cmd/cd/basic/double_slash.yaml @@ -0,0 +1,22 @@ +# rshell intentionally normalises //path to /path via filepath.Clean. +# bash preserves the leading // in $PWD (POSIX implementation-defined). +# See SHELL_FEATURES.md for the documented divergence. +skip_assert_against_bash: true +description: cd normalises a leading double-slash to a single slash in $PWD (intentional bash divergence). +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + DIR="$PWD" + cd sub + cd .. + cd "$DIR//sub" + echo "$PWD" +expect: + stdout_contains: ["/sub"] + stdout_contains_windows: ["\\sub"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/basic/empty_arg.yaml b/tests/scenarios/cmd/cd/basic/empty_arg.yaml new file mode 100644 index 000000000..5c5554319 --- /dev/null +++ b/tests/scenarios/cmd/cd/basic/empty_arg.yaml @@ -0,0 +1,18 @@ +# Derived from bash: cd "" stays in $PWD but also updates $OLDPWD to current dir. +description: cd with an empty-string argument is a no-op that still updates OLDPWD. +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + cd sub + BEFORE="$PWD" + cd "" + [ "$PWD" = "$BEFORE" ] && [ "$OLDPWD" = "$BEFORE" ] && echo ok +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/basic/inline_home.yaml b/tests/scenarios/cmd/cd/basic/inline_home.yaml new file mode 100644 index 000000000..61342461d --- /dev/null +++ b/tests/scenarios/cmd/cd/basic/inline_home.yaml @@ -0,0 +1,19 @@ +# Derived from bash: HOME=/path cd uses the inline HOME assignment to navigate. +# The script avoids string-comparing $PWD with a constructed path to stay +# cross-platform (Windows $PWD uses backslashes, "/" separator would mismatch). +# Instead we verify the navigation succeeded by checking for a known file. +description: HOME=/path cd uses the inline HOME assignment to change directories. +setup: + files: + - path: myhome/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + HOME="$PWD/myhome" cd + [ -f keep ] && echo ok +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/basic/oldpwd_set.yaml b/tests/scenarios/cmd/cd/basic/oldpwd_set.yaml new file mode 100644 index 000000000..f63988749 --- /dev/null +++ b/tests/scenarios/cmd/cd/basic/oldpwd_set.yaml @@ -0,0 +1,17 @@ +# Derived from GNU bash: a successful cd records the previous directory in OLDPWD. +description: A successful cd records the previous directory in $OLDPWD. +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + BEFORE="$PWD" + cd sub + [ "$OLDPWD" = "$BEFORE" ] && echo ok +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/basic/relative_dir.yaml b/tests/scenarios/cmd/cd/basic/relative_dir.yaml new file mode 100644 index 000000000..4cdef62a2 --- /dev/null +++ b/tests/scenarios/cmd/cd/basic/relative_dir.yaml @@ -0,0 +1,16 @@ +# Derived from GNU bash test: cd into a relative subdirectory and verify $PWD. +description: cd into a relative subdirectory updates $PWD. +setup: + files: + - path: child/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + cd child + echo "$PWD" +expect: + stdout_contains: ["/child"] + stdout_contains_windows: ["\\child"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/basic/repeated_cd.yaml b/tests/scenarios/cmd/cd/basic/repeated_cd.yaml new file mode 100644 index 000000000..f7a7f464d --- /dev/null +++ b/tests/scenarios/cmd/cd/basic/repeated_cd.yaml @@ -0,0 +1,20 @@ +# Derived from GNU bash: cd remembers only the immediate previous directory. +description: Successive cd calls keep $OLDPWD as the immediate previous directory. +setup: + files: + - path: a/keep + content: "" + - path: b/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + cd a + A_PWD="$PWD" + cd ../b + [ "$OLDPWD" = "$A_PWD" ] && echo ok +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/dash/cd_dash_empty_oldpwd.yaml b/tests/scenarios/cmd/cd/dash/cd_dash_empty_oldpwd.yaml new file mode 100644 index 000000000..03161b090 --- /dev/null +++ b/tests/scenarios/cmd/cd/dash/cd_dash_empty_oldpwd.yaml @@ -0,0 +1,25 @@ +# Derived from bash: cd - with OLDPWD="" prints a bare newline and updates OLDPWD. +# skip_assert_against_bash: the bash harness inherits OLDPWD from the test environment; +# we cannot reliably unset it in the scenario input block, so setting OLDPWD="" may +# still leave bash with the inherited (non-empty) value and cause the bash run to +# cd to an unexpected directory rather than staying in place. +skip_assert_against_bash: true +description: cd - with empty OLDPWD succeeds, prints a bare newline, and updates OLDPWD. +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + cd sub + BEFORE=$PWD + OLDPWD="" + cd - + [ "$OLDPWD" = "$BEFORE" ] && echo "oldpwd_ok" +expect: + stdout: |+ + + oldpwd_ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/dash/cd_dash_physical_empty_oldpwd.yaml b/tests/scenarios/cmd/cd/dash/cd_dash_physical_empty_oldpwd.yaml new file mode 100644 index 000000000..59e4ddf59 --- /dev/null +++ b/tests/scenarios/cmd/cd/dash/cd_dash_physical_empty_oldpwd.yaml @@ -0,0 +1,20 @@ +# Derived from bash: cd -P - with empty OLDPWD fails with "No such file or directory". +# Bash -P treats "" as a path to physically resolve, which fails: +# bash: cd: : No such file or directory (exit 1) +# Without -P, bash (and rshell) treat empty OLDPWD as "stay in place" (exit 0). +skip_assert_against_bash: true # bash harness inherits OLDPWD from env; can't reliably set to "". +description: cd -P - with empty OLDPWD fails with "No such file or directory" (bash compat). +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + cd sub + OLDPWD="" + cd -P - +expect: + stdout: |+ + stderr_contains: ["No such file or directory"] + exit_code: 1 diff --git a/tests/scenarios/cmd/cd/dash/cd_dash_prints.yaml b/tests/scenarios/cmd/cd/dash/cd_dash_prints.yaml new file mode 100644 index 000000000..5667abb86 --- /dev/null +++ b/tests/scenarios/cmd/cd/dash/cd_dash_prints.yaml @@ -0,0 +1,18 @@ +# Derived from POSIX cd -: must print the resolved directory after switching. +description: cd - prints the resolved directory and switches to OLDPWD. +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + BEFORE="$PWD" + cd sub + PRINTED=$(cd -) + [ "$PRINTED" = "$BEFORE" ] && echo ok +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/dash/cd_dash_swaps_pwd.yaml b/tests/scenarios/cmd/cd/dash/cd_dash_swaps_pwd.yaml new file mode 100644 index 000000000..8d357de47 --- /dev/null +++ b/tests/scenarios/cmd/cd/dash/cd_dash_swaps_pwd.yaml @@ -0,0 +1,19 @@ +# Derived from GNU bash: `cd -` swaps PWD and OLDPWD on success. +description: cd - swaps the values of $PWD and $OLDPWD. +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + BEFORE="$PWD" + cd sub + AFTER="$PWD" + cd - >/dev/null + [ "$PWD" = "$BEFORE" ] && [ "$OLDPWD" = "$AFTER" ] && echo ok +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/dash/inline_oldpwd.yaml b/tests/scenarios/cmd/cd/dash/inline_oldpwd.yaml new file mode 100644 index 000000000..84627e94e --- /dev/null +++ b/tests/scenarios/cmd/cd/dash/inline_oldpwd.yaml @@ -0,0 +1,24 @@ +# Derived from bash: OLDPWD=X cd - uses the inline OLDPWD value (not the +# pre-inline one) and cd's own OLDPWD update (to the directory we just left) +# survives the inline-assignment restore — matching bash's observable behaviour. +description: OLDPWD=X cd - uses the inline OLDPWD and cd's OLDPWD update survives the restore. +setup: + files: + - path: a/keep + content: "" + - path: b/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + cd a + cd ../b + B="$PWD" + # inline OLDPWD equals current OLDPWD (a) — navigates back to a, + # sets OLDPWD=B. The inline-assignment restore must NOT revert OLDPWD. + OLDPWD="$OLDPWD" cd - + [ "$OLDPWD" = "$B" ] && echo ok +expect: + stdout_contains: ["ok"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/dash/no_oldpwd.yaml b/tests/scenarios/cmd/cd/dash/no_oldpwd.yaml new file mode 100644 index 000000000..b5520c306 --- /dev/null +++ b/tests/scenarios/cmd/cd/dash/no_oldpwd.yaml @@ -0,0 +1,12 @@ +# Derived from POSIX: cd - without OLDPWD set is an error. +description: cd - errors when OLDPWD is unset. +skip_assert_against_bash: true # bash inherits an OLDPWD from the test harness env. +input: + allowed_paths: ["$DIR"] + script: |+ + cd - +expect: + stdout: |+ + stderr: |+ + cd: OLDPWD not set + exit_code: 1 diff --git a/tests/scenarios/cmd/cd/errors/missing_dir.yaml b/tests/scenarios/cmd/cd/errors/missing_dir.yaml new file mode 100644 index 000000000..c27b8b5eb --- /dev/null +++ b/tests/scenarios/cmd/cd/errors/missing_dir.yaml @@ -0,0 +1,12 @@ +# Derived from GNU bash: cd into a nonexistent directory errors. +description: cd into a nonexistent path reports "No such file or directory". +input: + allowed_paths: ["$DIR"] + script: |+ + cd does-not-exist +expect: + stdout: |+ + # bash prefixes its error with "bash: line N:"; rshell does not. Use a + # substring assertion so the same scenario passes against both. + stderr_contains: ["cd: does-not-exist: No such file or directory"] + exit_code: 1 diff --git a/tests/scenarios/cmd/cd/errors/no_home.yaml b/tests/scenarios/cmd/cd/errors/no_home.yaml new file mode 100644 index 000000000..23fa02b2a --- /dev/null +++ b/tests/scenarios/cmd/cd/errors/no_home.yaml @@ -0,0 +1,12 @@ +# Derived from POSIX cd: with no operand and HOME unset, cd is an error. +description: cd with no argument and HOME unset reports "HOME not set". +skip_assert_against_bash: true # bash inherits HOME from the test harness env. +input: + allowed_paths: ["$DIR"] + script: |+ + cd +expect: + stdout: |+ + stderr: |+ + cd: HOME not set + exit_code: 1 diff --git a/tests/scenarios/cmd/cd/errors/not_a_directory.yaml b/tests/scenarios/cmd/cd/errors/not_a_directory.yaml new file mode 100644 index 000000000..da2167c95 --- /dev/null +++ b/tests/scenarios/cmd/cd/errors/not_a_directory.yaml @@ -0,0 +1,16 @@ +# Derived from POSIX: cd into a regular file is an error. +description: cd into a regular file reports "Not a directory". +setup: + files: + - path: regular.txt + content: "hello\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cd regular.txt +expect: + stdout: |+ + # bash prefixes with the script filename; rshell does not. + # Use a substring assertion so the same scenario passes against both. + stderr_contains: ["cd: regular.txt: Not a directory"] + exit_code: 1 diff --git a/tests/scenarios/cmd/cd/errors/too_many_args.yaml b/tests/scenarios/cmd/cd/errors/too_many_args.yaml new file mode 100644 index 000000000..8640885e0 --- /dev/null +++ b/tests/scenarios/cmd/cd/errors/too_many_args.yaml @@ -0,0 +1,12 @@ +# Derived from POSIX: cd with more than one operand is an error. +description: cd with multiple positional arguments errors. +input: + allowed_paths: ["$DIR"] + script: |+ + cd a b +expect: + stdout: |+ + # bash prefixes its error with "bash: line N:"; rshell does not. Use a + # substring assertion so the same scenario passes against both. + stderr_contains: ["cd: too many arguments"] + exit_code: 1 diff --git a/tests/scenarios/cmd/cd/errors/unknown_flag.yaml b/tests/scenarios/cmd/cd/errors/unknown_flag.yaml new file mode 100644 index 000000000..84111a171 --- /dev/null +++ b/tests/scenarios/cmd/cd/errors/unknown_flag.yaml @@ -0,0 +1,11 @@ +# Hardening: unknown flags must be rejected by pflag. +description: cd rejects unknown long flags. +skip_assert_against_bash: true # bash and pflag emit different error wording. +input: + allowed_paths: ["$DIR"] + script: |+ + cd --no-such-flag +expect: + stdout: |+ + stderr_contains: ["cd:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/cd/hardening/physical_nonexist_intermediate.yaml b/tests/scenarios/cmd/cd/hardening/physical_nonexist_intermediate.yaml new file mode 100644 index 000000000..e635d59dd --- /dev/null +++ b/tests/scenarios/cmd/cd/hardening/physical_nonexist_intermediate.yaml @@ -0,0 +1,38 @@ +# Documents a known bash divergence in cd -P physical mode. +# +# When resolvePhysical encounters an intermediate path component outside +# AllowedPaths, sandbox.LstatFile returns ErrPermission (not ErrNotExist). +# The resolver treats this as "opaque non-symlink" and advances past it. +# This allows a path whose non-existent intermediate component sits outside +# the sandbox to still succeed, provided the final destination is inside +# AllowedPaths. +# +# In bash, the same path fails because bash actually attempts to chdir into +# the non-existent intermediate, getting ENOENT. +# +# This is NOT a sandbox escape: the mandatory StatFile gate at the end of the +# cd handler still validates that the final target is within AllowedPaths. +# The divergence is a deliberate trade-off to support HostPrefix container +# sandboxes where intermediate path components may be outside the sandbox root. +# +# Setup: AllowedPaths=["inside"] (only the subdirectory, not its parent). +# The script builds an absolute path that goes through a non-existent segment +# ("outside") which is outside AllowedPaths, then backs up with ".." to reach +# "inside". bash would fail on the non-existent intermediate; rshell succeeds. +description: cd -P with non-existent out-of-sandbox intermediate succeeds (bash divergence). +skip_assert_against_bash: true # bash fails on the non-existent intermediate; rshell's ErrPermission-skip allows it to succeed (intentional trade-off for HostPrefix support) +setup: + files: + - path: inside/keep + content: "" +input: + allowed_paths: ["inside"] + script: |+ + ROOT="$PWD" + cd -P "${ROOT}/outside/../inside" + echo "ok" +expect: + stdout: |+ + ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/hardening/sandbox_final_target.yaml b/tests/scenarios/cmd/cd/hardening/sandbox_final_target.yaml new file mode 100644 index 000000000..6f97dde11 --- /dev/null +++ b/tests/scenarios/cmd/cd/hardening/sandbox_final_target.yaml @@ -0,0 +1,24 @@ +# Security: even when resolvePhysical treats an out-of-sandbox intermediate +# component as opaque (ErrPermission-skip), the final StatFile gate must +# still reject a target that lies outside AllowedPaths. +# +# This complements physical_nonexist_intermediate.yaml (which shows the +# success case where the final target IS inside AllowedPaths). Here the +# intermediate is the same opaque segment but the final target is ALSO +# outside — proving the ErrPermission-skip does not grant access. +description: cd -P rejects a final target outside AllowedPaths even when intermediate is opaque. +skip_assert_against_bash: true # bash has no AllowedPaths concept +setup: + files: + - path: inside/keep + content: "" +input: + # AllowedPaths set to inside/ only; the parent ($DIR itself) is outside. + allowed_paths: ["inside"] + script: |+ + ROOT="$PWD" + cd -P "${ROOT}/outside" +expect: + stdout: |+ + stderr_contains: ["cd:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/cd/hardening/state_unchanged_on_error.yaml b/tests/scenarios/cmd/cd/hardening/state_unchanged_on_error.yaml new file mode 100644 index 000000000..d3a186bcc --- /dev/null +++ b/tests/scenarios/cmd/cd/hardening/state_unchanged_on_error.yaml @@ -0,0 +1,21 @@ +# Hardening: a failed cd must not mutate $PWD/$OLDPWD. +description: A failed cd leaves $PWD and $OLDPWD untouched. +setup: + files: + - path: good/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + cd good + GOOD="$PWD" + GOOD_OLD="$OLDPWD" + cd does-not-exist + [ "$PWD" = "$GOOD" ] && [ "$OLDPWD" = "$GOOD_OLD" ] && echo ok +expect: + stdout: |+ + ok + # bash prefixes its error with "bash: line N:"; rshell does not. Use a + # substring assertion so the same scenario passes against both. + stderr_contains: ["No such file or directory"] + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/help/help_long.yaml b/tests/scenarios/cmd/cd/help/help_long.yaml new file mode 100644 index 000000000..696b31dae --- /dev/null +++ b/tests/scenarios/cmd/cd/help/help_long.yaml @@ -0,0 +1,11 @@ +# RULES.md mandate: every command must register --help. +description: cd --help prints usage to stdout and exits 0. +skip_assert_against_bash: true # help wording differs across shells. +input: + allowed_paths: ["$DIR"] + script: |+ + cd --help +expect: + stdout_contains: ["Usage: cd", "--logical", "--physical"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/help/help_short.yaml b/tests/scenarios/cmd/cd/help/help_short.yaml new file mode 100644 index 000000000..0ab15c92a --- /dev/null +++ b/tests/scenarios/cmd/cd/help/help_short.yaml @@ -0,0 +1,11 @@ +# RULES.md mandate: every command must register -h alongside --help. +description: cd -h prints usage to stdout and exits 0. +skip_assert_against_bash: true # bash builtin cd does not accept -h. +input: + allowed_paths: ["$DIR"] + script: |+ + cd -h +expect: + stdout_contains: ["Usage: cd"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/sandbox/cdpath_ignored.yaml b/tests/scenarios/cmd/cd/sandbox/cdpath_ignored.yaml new file mode 100644 index 000000000..2e788fa74 --- /dev/null +++ b/tests/scenarios/cmd/cd/sandbox/cdpath_ignored.yaml @@ -0,0 +1,21 @@ +# Security: $CDPATH is intentionally not consulted by cd to prevent +# environment-variable path injection (see SHELL_FEATURES.md). +description: $CDPATH is ignored — cd resolves against $PWD only. +skip_assert_against_bash: true # bash honours CDPATH; rshell intentionally does not +setup: + files: + - path: sub/keep + content: "" + - path: trap/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + CDPATH="$DIR/trap" + cd sub + echo ok +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/sandbox/outside_allowed_paths.yaml b/tests/scenarios/cmd/cd/sandbox/outside_allowed_paths.yaml new file mode 100644 index 000000000..9e21c4be2 --- /dev/null +++ b/tests/scenarios/cmd/cd/sandbox/outside_allowed_paths.yaml @@ -0,0 +1,18 @@ +# Security: cd to a directory outside AllowedPaths must be rejected. +# allowed_paths restricts to the 'sub' subdirectory only; cd .. from sub +# tries to reach the parent, which is outside the sandbox. +description: cd rejects a target directory outside the AllowedPaths sandbox. +skip_assert_against_bash: true # bash has no AllowedPaths concept +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR/sub"] + script: |+ + cd sub + cd .. +expect: + stdout: |+ + stderr_contains: ["cd: "] + exit_code: 1 diff --git a/tests/scenarios/cmd/cd/subshell/subshell_isolation.yaml b/tests/scenarios/cmd/cd/subshell/subshell_isolation.yaml new file mode 100644 index 000000000..9a3bfc0a8 --- /dev/null +++ b/tests/scenarios/cmd/cd/subshell/subshell_isolation.yaml @@ -0,0 +1,17 @@ +# Derived from POSIX shell semantics: cd in a subshell does not affect the parent. +description: cd inside a subshell does not change the parent's $PWD. +setup: + files: + - path: sub/keep + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + PARENT="$PWD" + (cd sub; echo "$PWD") + [ "$PWD" = "$PARENT" ] && echo ok +expect: + stdout_contains: ["/sub", "ok"] + stdout_contains_windows: ["\\sub", "ok"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/symlinks/logical_default.yaml b/tests/scenarios/cmd/cd/symlinks/logical_default.yaml new file mode 100644 index 000000000..a0ff2999a --- /dev/null +++ b/tests/scenarios/cmd/cd/symlinks/logical_default.yaml @@ -0,0 +1,18 @@ +# Derived from POSIX cd: -L keeps the symlink path in $PWD. +description: cd -L (default) preserves the symlink path in $PWD. +setup: + files: + - path: real/keep + content: "" + - path: alias + symlink: real +input: + allowed_paths: ["$DIR"] + script: |+ + cd alias + echo "$PWD" +expect: + stdout_contains: ["/alias"] + stdout_contains_windows: ["\\alias"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/symlinks/lp_last_wins.yaml b/tests/scenarios/cmd/cd/symlinks/lp_last_wins.yaml new file mode 100644 index 000000000..d86b619fb --- /dev/null +++ b/tests/scenarios/cmd/cd/symlinks/lp_last_wins.yaml @@ -0,0 +1,19 @@ +# Derived from bash: when -L and -P are both given, the last flag wins. +# -L -P → -P resolves the symlink; -P -L → -L preserves the symlink path. +description: cd -L -P uses the physical mode (last flag wins) and resolves the symlink. +setup: + files: + - path: real/keep + content: "" + - path: alias + symlink: real +input: + allowed_paths: ["$DIR"] + script: |+ + cd -L -P alias + echo "$PWD" +expect: + stdout_contains: ["/real"] + stdout_contains_windows: ["\\real"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/symlinks/lp_last_wins_l.yaml b/tests/scenarios/cmd/cd/symlinks/lp_last_wins_l.yaml new file mode 100644 index 000000000..926ae7683 --- /dev/null +++ b/tests/scenarios/cmd/cd/symlinks/lp_last_wins_l.yaml @@ -0,0 +1,19 @@ +# Derived from bash: when -L and -P are both given, the last flag wins. +# -P -L → -L preserves the symlink path in $PWD. +description: cd -P -L uses the logical mode (last flag wins) and preserves the symlink path. +setup: + files: + - path: real/keep + content: "" + - path: alias + symlink: real +input: + allowed_paths: ["$DIR"] + script: |+ + cd -P -L alias + echo "$PWD" +expect: + stdout_contains: ["/alias"] + stdout_contains_windows: ["\\alias"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/symlinks/physical.yaml b/tests/scenarios/cmd/cd/symlinks/physical.yaml new file mode 100644 index 000000000..4ff999a2e --- /dev/null +++ b/tests/scenarios/cmd/cd/symlinks/physical.yaml @@ -0,0 +1,18 @@ +# Derived from POSIX cd -P: must resolve symlinks in $PWD. +description: cd -P resolves the symlink in $PWD. +setup: + files: + - path: real/keep + content: "" + - path: alias + symlink: real +input: + allowed_paths: ["$DIR"] + script: |+ + cd -P alias + echo "$PWD" +expect: + stdout_contains: ["/real"] + stdout_contains_windows: ["\\real"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/symlinks/physical_dash.yaml b/tests/scenarios/cmd/cd/symlinks/physical_dash.yaml new file mode 100644 index 000000000..3fb08d6f7 --- /dev/null +++ b/tests/scenarios/cmd/cd/symlinks/physical_dash.yaml @@ -0,0 +1,22 @@ +# Derived from bash: cd -P - prints the raw OLDPWD string (the symlink path), +# not the physically resolved path. -P only affects where PWD is set, not what +# is printed by the cd - form. +description: cd -P - prints raw OLDPWD (symlink path) and sets PWD to physical target. +setup: + files: + - path: real/keep + content: "" + - path: alias + symlink: real +input: + allowed_paths: ["$DIR"] + script: |+ + cd alias + cd .. + cd -P - + echo "PWD=$PWD" +expect: + stdout_contains: ["/alias", "/real"] + stdout_contains_windows: ["\\alias", "\\real"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/symlinks/physical_dotdot.yaml b/tests/scenarios/cmd/cd/symlinks/physical_dotdot.yaml new file mode 100644 index 000000000..400ba58bf --- /dev/null +++ b/tests/scenarios/cmd/cd/symlinks/physical_dotdot.yaml @@ -0,0 +1,26 @@ +# Derived from bash: cd -P resolves symlinks before applying "..". +# With "from/alias" -> "../real" (sibling directory), cd -P alias/.. must +# land in the parent of "real" (i.e. $DIR), not in the parent of "alias" +# (i.e. $DIR/from). bash resolves symlinks first: alias -> ../real = $DIR/real, +# then ".." pops to $DIR. +description: cd -P resolves symlink target before applying "..", landing in the real parent. +setup: + files: + - path: real/keep + content: "" + - path: from/alias + symlink: ../real +input: + allowed_paths: ["$DIR"] + script: |+ + cd from + cd -P alias/.. + # The result should be $DIR (parent of real), not $DIR/from (parent of alias). + # We verify by doing cd again into "real" which should succeed if we're in $DIR. + cd real + echo "$PWD" +expect: + stdout_contains: ["/real"] + stdout_contains_windows: ["\\real"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/cd/symlinks/physical_intermediate.yaml b/tests/scenarios/cmd/cd/symlinks/physical_intermediate.yaml new file mode 100644 index 000000000..fd452c552 --- /dev/null +++ b/tests/scenarios/cmd/cd/symlinks/physical_intermediate.yaml @@ -0,0 +1,19 @@ +# Derived from bash: cd -P resolves intermediate symlinks, not just the leaf. +# dir/alias -> dir/real, so cd -P alias/inside lands in real/inside. +description: cd -P resolves an intermediate symlink component and sets PWD to the canonical path. +setup: + files: + - path: real/inside/keep + content: "" + - path: alias + symlink: real +input: + allowed_paths: ["$DIR"] + script: |+ + cd -P alias/inside + echo "$PWD" +expect: + stdout_contains: ["/real/inside"] + stdout_contains_windows: ["\\real\\inside"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/help/restricted.yaml b/tests/scenarios/cmd/help/restricted.yaml index 8ad88835c..a6ed36791 100644 --- a/tests/scenarios/cmd/help/restricted.yaml +++ b/tests/scenarios/cmd/help/restricted.yaml @@ -6,7 +6,7 @@ input: help expect: stdout: |+ - rshell (dev) — 2 of 29 commands enabled + rshell (dev) — 2 of 30 commands enabled Features: commands Registered commands run inside the interpreter; no unregistered commands without an external handler. @@ -28,7 +28,7 @@ expect: echo write arguments to stdout help display help for features and commands - Disabled commands: [, break, cat, continue, cut, exit, false, find, grep, head, ip, ls, ping, + Disabled commands: [, break, cat, cd, continue, cut, exit, false, find, grep, head, ip, ls, ping, printf, ps, pwd, sed, sort, ss, strings, tail, test, tr, true, uname, uniq, wc Run 'help ' for more information on a specific topic. diff --git a/tests/scenarios/cmd/help/restricted_all_flag.yaml b/tests/scenarios/cmd/help/restricted_all_flag.yaml index 2ed89a69a..02942e32d 100644 --- a/tests/scenarios/cmd/help/restricted_all_flag.yaml +++ b/tests/scenarios/cmd/help/restricted_all_flag.yaml @@ -6,7 +6,7 @@ input: help --all expect: stdout: |+ - rshell (dev) — 2 of 29 commands enabled + rshell (dev) — 2 of 30 commands enabled Features: commands Registered commands run inside the interpreter; no unregistered commands without an external handler. @@ -32,6 +32,7 @@ expect: [ evaluate conditional expression break exit from a loop cat concatenate and print files + cd change the working directory continue continue a loop iteration cut remove sections from each line exit exit the shell diff --git a/tests/scenarios/cmd/help/unrestricted.yaml b/tests/scenarios/cmd/help/unrestricted.yaml index eef3e620d..4d423d0a8 100644 --- a/tests/scenarios/cmd/help/unrestricted.yaml +++ b/tests/scenarios/cmd/help/unrestricted.yaml @@ -5,7 +5,7 @@ input: help expect: stdout: |+ - rshell (dev) — All 29 commands available + rshell (dev) — All 30 commands available Features: commands Registered commands run inside the interpreter; no unregistered commands without an external handler. @@ -27,6 +27,7 @@ expect: [ evaluate conditional expression break exit from a loop cat concatenate and print files + cd change the working directory continue continue a loop iteration cut remove sections from each line echo write arguments to stdout diff --git a/tests/scenarios/cmd/help/unrestricted_all_flag.yaml b/tests/scenarios/cmd/help/unrestricted_all_flag.yaml index 667b39e1f..d070743ac 100644 --- a/tests/scenarios/cmd/help/unrestricted_all_flag.yaml +++ b/tests/scenarios/cmd/help/unrestricted_all_flag.yaml @@ -5,7 +5,7 @@ input: help --all expect: stdout: |+ - rshell (dev) — All 29 commands available + rshell (dev) — All 30 commands available Features: commands Registered commands run inside the interpreter; no unregistered commands without an external handler. @@ -27,6 +27,7 @@ expect: [ evaluate conditional expression break exit from a loop cat concatenate and print files + cd change the working directory continue continue a loop iteration cut remove sections from each line echo write arguments to stdout diff --git a/tests/scenarios/shell/blocked_commands/builtins_and_features/cd.yaml b/tests/scenarios/shell/blocked_commands/builtins_and_features/cd.yaml deleted file mode 100644 index 31ba87235..000000000 --- a/tests/scenarios/shell/blocked_commands/builtins_and_features/cd.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# skip: feature is intentionally blocked in the restricted shell -skip_assert_against_bash: true -description: Cd builtin is intentionally blocked in the restricted shell. -input: - script: |+ - cd /tmp -expect: - stdout: "" - stderr: |+ - rshell: cd: unknown command - Run 'help' to see available commands. - exit_code: 127