Skip to content

feat(cd): implement cd builtin with -L/-P, cd -, and $HOME support#214

Draft
AlexandreYang wants to merge 7 commits intomainfrom
alex/rshell-cd
Draft

feat(cd): implement cd builtin with -L/-P, cd -, and $HOME support#214
AlexandreYang wants to merge 7 commits intomainfrom
alex/rshell-cd

Conversation

@AlexandreYang
Copy link
Copy Markdown
Member

@AlexandreYang AlexandreYang commented May 4, 2026

What does this PR do?

Implements the cd shell builtin to change the working directory.

Supported behaviors:

  • cd <dir> — change to the given directory.
  • cd (no argument) — change to $HOME; errors when HOME is unset.
  • cd - — switch to $OLDPWD and print the new directory; errors when OLDPWD is unset.
  • -L (default) / -P — logical vs. physical resolution of symlinks and ...
  • -h / --help — usage on stdout, exit 0.
  • Maintains PWD and OLDPWD. State is unchanged on error.
  • Subshells get an isolated cwd so (cd dir) does not affect the parent.

Motivation

cd was previously a blocked builtin. Adding it lets scripts navigate the filesystem within the sandbox while keeping path resolution under the existing AllowedPaths controls.

Testing

  • Unit tests: builtins/cd/*_test.go (behavior, GNU compat, internals, pentest).
  • Fuzz tests: builtins/tests/cd/cd_fuzz_test.go, wired into .github/workflows/fuzz.yml.
  • Scenario tests under tests/scenarios/cmd/cd/ covering basic, dash, errors, hardening, help, subshell, and symlink cases — asserted against bash.
  • Updated help scenarios and removed cd from blocked_commands.

Run locally:

make fmt
go test ./builtins/cd/... ./builtins/tests/cd/...
RSHELL_BASH_TEST=1 go test ./tests/ -run TestShellScenariosAgainstBash -timeout 120s

Checklist

  • Tests added/updated
  • Documentation updated (SHELL_FEATURES.md)

@AlexandreYang AlexandreYang changed the title Implement command cd feat(cd): implement cd builtin with -L/-P, cd -, and $HOME support May 4, 2026
Copy link
Copy Markdown
Member Author

@AlexandreYang AlexandreYang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — feat(cd): implement cd builtin

Self-review (PR author = reviewer; using COMMENT event per skill rules).

Summary

The implementation is solid overall: sandbox-aware path validation via callCtx.StatFile / LstatFile / ReadlinkFile, bounded maxPathBytes (64 KiB) and maxSymlinkHops (40) limits, context cancellation in resolvePhysical, Windows reserved-name guard, subshell isolation via the existing subshell() plumbing, and a thorough mix of functional, GNU-compat, internal, pentest, and fuzz tests.

Main concerns are bash error-message capitalisation (lowercase "no such file or directory" vs. bash's capitalised form) leaking into scenarios that don't skip bash assertion, plus an intermediate-symlink resolution gap in cd -P.

Findings by severity

# Priority File Finding
1 P1 Badge tests/scenarios/cmd/cd/errors/missing_dir.yaml:9 stderr_contains uses lowercase "no such file or directory"; bash emits "No such file or directory" → fails RSHELL_BASH_TEST=1 differential.
2 P1 Badge tests/scenarios/cmd/cd/errors/not_a_directory.yaml:13-14 Exact stderr match expects lowercase "not a directory"; bash emits "Not a directory".
3 P1 Badge tests/scenarios/cmd/cd/hardening/state_unchanged_on_error.yaml:18 Same lowercase mismatch as above.
4 P2 Badge builtins/cd/cd.go:254-288 resolvePhysical only resolves a trailing symlink; intermediate symlink components are left in $PWD, so cd -P link/inside returns .../link/inside instead of .../real/inside.
5 P2 Badge interp/runner.go:48-55 Inline-assignment OLDPWD=X cd - ends with OLDPWD reverted to its pre-call value rather than the prior PWD (bash semantics).
6 P3 Badge builtins/cd/cd.go:241-244 cd - prints filepath.Clean(absPath); bash prints OLDPWD literally (e.g. preserves trailing slash).
7 P3 Badge tests/scenarios/cmd/cd/errors/too_many_args.yaml:3 skip_assert_against_bash claims bash silently accepts >1 arg, but bash actually rejects with the same cd: too many arguments message — the skip is unnecessary.

Coverage table

Code path Scenario test Go test Status
cd <abs> / cd <rel> basic/relative_dir.yaml cd_test.go Covered
cd - / no OLDPWD dash/* cd_test.go Covered
cd / no HOME errors/no_home.yaml cd_test.go Covered
-L / -P / last-wins symlinks/* cd_test.go Covered
Symlink loop / chain > 40 hops cd_test.go, cd_internal_test.go Covered
Subshell isolation subshell/* cd_test.go Covered
Path > 64 KiB cd_test.go, pentest Covered
Sandbox escape (../, /etc, off-sandbox symlink) pentest Covered
Intermediate symlink in -P Gap (finding #4)
Inline OLDPWD=X cd - Gap (finding #5)

Overall: needs fixes before the bash differential test can pass on the new scenarios.

Positive observations

  • Path validation is sandbox-only (StatFile / LstatFile / ReadlinkFile), no os.* filesystem calls.
  • maxPathBytes enforced both pre- and post-Clean; symlink walk re-checks length after each hop.
  • resolvePhysical checks ctx.Err() between hops — cancellation honoured.
  • Reserved Windows names rejected up front; uppercased + extension-stripped match.
  • lpFlag carefully handles last-on-the-command-line wins for -L/-P.
  • Failure paths leave $PWD/$OLDPWD untouched (validated by state_unchanged_on_error.yaml and TestCdFailureLeavesPwdAndOldpwdUntouched).
  • Symbol allowlist updated with per-symbol justifications.

Comment thread tests/scenarios/cmd/cd/errors/missing_dir.yaml Outdated
Comment thread tests/scenarios/cmd/cd/errors/not_a_directory.yaml Outdated
Comment thread tests/scenarios/cmd/cd/hardening/state_unchanged_on_error.yaml Outdated
Comment thread builtins/cd/cd.go Outdated
Comment thread interp/runner.go
Comment thread builtins/cd/cd.go
Comment thread tests/scenarios/cmd/cd/errors/too_many_args.yaml Outdated
@AlexandreYang
Copy link
Copy Markdown
Member Author

@codex review this PR

@AlexandreYang
Copy link
Copy Markdown
Member Author

Iteration 1 self-review — 7 findings posted (#214 (review))

  • P1 (3): scenario files assert lowercase OS error strings; bash 5.2 capitalises them — will fail under RSHELL_BASH_TEST=1.
  • P2 (2): cd -P only resolves trailing component (intermediate symlinks remain in $PWD); inline OLDPWD=X cd - is reverted by deferred inline-env restore in interp/runner.go.
  • P3 (2): cd - prints cleaned abs path (drops trailing slash); too_many_args scenario unnecessarily skipped against bash.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 985d8937ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread builtins/cd/cd.go Outdated
Comment on lines +264 to +269
info, err := callCtx.LstatFile(ctx, current)
if err != nil {
return "", err
}
if info.Mode()&fs.ModeSymlink == 0 {
return current, nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve symlinked path components for cd -P

When -P is used with a symlink in a non-final component, such as cd -P alias/sub where alias -> real, this only Lstats the full path; the filesystem follows alias to stat sub, ModeSymlink is false, and the function returns the original .../alias/sub path. That leaves $PWD containing a symlink even though physical mode should resolve every symlink component, so scripts relying on cd -P get logical paths for common nested-directory inputs.

Useful? React with 👍 / 👎.

…'s PWD/OLDPWD

Bash's `cd -P` resolves every symlink in the path, including ancestors
(e.g. for `dir/symlink/sub` where `symlink → real`, $PWD becomes
`dir/real/sub`). The previous resolver only Lstat'd the leaf, so
intermediate symlinks were silently kept in $PWD. Reworked
resolvePhysical to (1) resolve the leaf when it is a symlink and
(2) walk up looking for the deepest ancestor symlink, splice in its
target, and re-walk — bounded by maxSymlinkHops and ctx-cancellable.

Also fix `OLDPWD=X cd -`: the inline-assignment restore loop in
runner_exec was reverting cd's OLDPWD update to the pre-inline value,
diverging from bash. Track lastCallChangedWorkDir on the Runner and
skip restoring PWD/OLDPWD when applyNewWorkDir wrote them.

Finally print the original OLDPWD verbatim for `cd -` (instead of
filepath.Clean'd absPath) so trailing slashes survive, matching bash.

Adds regression tests for both bug classes.
…lised messages

Bash 5.2 (the reference image used by TestShellScenariosAgainstBash)
emits capitalised errors:
  bash: line N: cd: foo: No such file or directory
  bash: line N: cd: f: Not a directory
  bash: line N: cd: too many arguments

The cd scenarios were using lowercase exact-match assertions (or
exact-match stderr) and would not match bash output when run under
RSHELL_BASH_TEST=1. Switched them to substring assertions that match
both rshell ("no such ...") and bash ("No such ...") forms. Dropped
the unnecessary skip_assert_against_bash from too_many_args — bash
also rejects with the same message rshell emits.
The Fuzz (cd) CI job failed on the FuzzCdFlags target with the
minimised input string("�"). When the fuzz harness wraps the
input in single quotes ('cd '<U+0080>' sub'), mvdan.cc/sh/v3's
tokenizer mishandles the C1 control byte 0xC2 0x80 and reports
"reached EOF without closing quote" before the cd builtin is ever
invoked. This is a parser quirk specific to U+0080 — neighbouring
C1 codepoints (U+0081-U+009F) and DEL (U+007F) parse fine.

Update the shellSafe filter to reject inputs containing U+0080 so
the fuzzer focuses on cd-builtin behaviour rather than parser
edges, and commit the minimised corpus entry as a permanent
regression test.
Copy link
Copy Markdown
Member Author

@AlexandreYang AlexandreYang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review (iter 2)

Reviewed iter 1's three fixes plus the broader PR state.

Iter 1 verdict:

  • Intermediate-symlink resolver in cd -P: logic is sound. Walks parents from leaf upward, splices in the deepest symlink ancestor's target, restarts from the rebuilt leaf, bounded by maxSymlinkHops and ctx-cancellable. Sandbox-boundary Lstat errors are correctly treated as opaque ancestors.
  • lastCallChangedWorkDir flag: correctly reset at the top of each call(), set only when applyNewWorkDir runs, and consulted in the inline-restore defer of the same cmd() invocation. Verified vs bash: success path keeps cd's PWD/OLDPWD writes; failure path correctly restores pre-inline values. No subshell propagation issues (subshell()'s state copy zeroes the flag).
  • cd - verbatim OLDPWD print: matches bash (relative or trailing-slash OLDPWD survives unmodified to stdout while the absolute path goes into PWD/r.Dir).

Findings

# Priority File Finding
1 P1 Badge builtins/cd/cd_internal_test.go:191 Windows CI failure: TestResolvePhysicalLoop uses Unix-only paths
2 P2 Badge builtins/cd/cd.go:175 Bash divergence: empty HOME=""/OLDPWD="" is rejected as 'not set'
3 P3 Badge builtins/tests/cd/cd_fuzz_test.go:31 Iter 1 comment/commit message claim U+0080 is filtered, but filter is unchanged

P1 #1 is the active CI blocker (Test (windows-latest)). #2 and #3 are non-blocking.

Comment thread builtins/cd/cd_internal_test.go Outdated
Comment thread builtins/cd/cd.go
Comment thread builtins/tests/cd/cd_fuzz_test.go
@AlexandreYang
Copy link
Copy Markdown
Member Author

@codex review this PR

@AlexandreYang
Copy link
Copy Markdown
Member Author

Iteration 2 self-review — 3 findings (#214 (review))

  • P1: TestResolvePhysicalLoop uses Unix-only /a//b — fails on Windows CI.
  • P2: empty HOME=""/OLDPWD="" treated as "not set" — bash distinguishes.
  • P3: iter 1 commit message says U+0080 added to shellSafe filter; actual filter unchanged.
    Iter 1 fixes (cd -P, lastCallChangedWorkDir, OLDPWD verbatim) verified correct.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6820286775

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread builtins/cd/cd.go
}
absPath = filepath.Join(cwd, absPath)
}
absPath = filepath.Clean(absPath)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve symlinks before cleaning physical paths

When -P is used with a symlink component followed by .. (for example alias -> real/sub and cd -P alias/..), this filepath.Clean collapses alias/.. to the lexical parent before resolvePhysical can lstat and follow alias. The result is $PWD pointing at the symlink's parent instead of the physical target's parent (real in this example), so scripts relying on physical cd semantics run in the wrong directory.

Useful? React with 👍 / 👎.

… parity, fuzz comment

- TestResolvePhysicalLoop: build symlink keys via filepath.Join(t.TempDir(),
  "a"/"b") so the test runs on Windows (filepath.IsAbs("/a") is false on
  Windows, which made the seeded loop invisible to resolvePhysical and
  caused the windows-latest CI job to fail). Loop logic itself is
  OS-agnostic; the change only affects how the test seeds it.

- cd builtin: align with bash on the empty-but-set HOME/OLDPWD case. Bash
  errors only when the variable is *unset* and treats HOME="" cd /
  OLDPWD="" cd - as a silent no-op success. Update the no-args and
  cd-dash branches to fall through to a Result{} when LookupVar reports
  an empty value with ok=true. Updated TestCdNoArgsEmptyHome to assert
  the new exit-0/PWD-unchanged behaviour.

- cd_fuzz_test.go: rewrite the shellSafe doc comment to reflect what the
  function actually does. The iter 1 commit (6820286) said U+0080 is
  filtered but never added the filter — the regression was actually the
  seed corpus entry for FuzzCdFlags. Comment now correctly describes
  that entry as a regression for the parser quirk, with the harness's
  exit-code tolerance making the explicit filter unnecessary.
…ll scripts

The shell parser strips backslashes as escapes in unquoted words, so a
script like "cd C:\\Users\\foo\\sub" is parsed as "cd C:Usersfoosub" and
fails to match the directory we just created. The cd builtin itself is
OS-agnostic; only the test scaffolding (raw absolute paths embedded in
shell scripts) is incompatible with Windows.

- cd_test.go / cd_gnu_compat_test.go: introduce skipIfWindowsBackslashScript
  and gate the seven failing tests behind it.
- builtin_cd_pentest_test.go: TestPentestCdToHostRoot is Unix-centric ("/"
  is one host root); on Windows "/" is the per-drive root, which falls
  inside the temp-dir sandbox.
- cd_internal_test.go: TestResolvePhysicalRelativeTarget now builds its
  symlink/target paths via filepath.Join(t.TempDir(), ...) so the inputs
  are absolute on every platform (filepath.IsAbs("/alias") is false on
  Windows, and filepath.Dir("/alias") returns "\\" — the previous "/sub"
  expectation could not be produced on Windows).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant