One walk, one anchor: a PATH search has a single cwd and a single verdict
A PATH search is one traversal, from one anchor, yielding one answer: the
anchor is a SearchCwd no call site may improvise, and the 126/127 verdict
travels out of the same walk that produced the resolution. A textbook
application of structural-bug-prevention
shape 1 — the path authorised ≠ the path used — realised in
core/src/path/which.rs, core/src/runtime/command/{identity,vet}.rs.
Context
At the REPL, cd into a directory and name a file that lives there. It is not
on PATH, so the honest answer is command not found, 127. What came back was
build.ps1: permission denied, 126 — a diagnosis about executability, for a
file no walk had resolved, with CreateProcess never called. Three independent
defects composed:
- Two anchors.
identity::walk_pathandpolicy_namesanchored relativePATHentries toctx.dir— thewithin [dir: …]override alone, unbound in a plain REPL — whilevet::check_existenceanchored its own probe toctx.cwd_chain(), the override or thecd-mutated cwd. The choice was per-call-site because the parameter was a bareOption<&Path>. - Two walks. The verdict was computed by a second traversal
(
file_exists_on_path) independent of the one that producedresolved. Any divergence between them — anchor, search list, timing — presents as “onPATHbut lacking+x”, because that is the only shape in which walk one misses and walk two hits. - An empty
PATHelement. The user’s WindowsPATHended in;. Split, that is an empty entry, whichanchor_to_cwdfolded to the cwd — putting every file of the current directory on the search list of the second walk.
A fourth, independent: windows_command_candidates built %PATHEXT%
candidates with Path::with_extension, which replaces a suffix rather than
appending one, so build.ps1 also matched build.exe.
Decision
SearchCwd<'a>is the anchor type, and everyPATH-walking entry point takes it. Its constructors are few and named for provenance:Context::search_cwd(thecwd_chainprecedence, for core runtime),Resolver::search_cwd(for a consumer already minted from a context),SearchCwd::of(a front end holdingShell::cwd),SearchCwd::nowhere(no shell). There is no constructor from a loose option, soctx.dirno longer typechecks where a walk wants an anchor.- The verdict is a projection of the walk.
path::searchreturnsPathSearch::{Executable, FoundNotExecutable, Missing};walk_pathcalls it once and stores it on theCommandIdentity(Nonefor a non-bare head, whichPATHnever searched).vet::check_existencetakes no context at all — it pattern-matches the stored verdict.file_exists_on_pathis gone, so there is no second walk left to disagree with the first. - An empty
PATHelement never means the cwd — uniformly, notcfg(windows)-gated.path_dirsdrops it, on the element as written, before anchoring. A user who wants the cwd searched writes., whichanchor_to_cwdhonours deliberately. %PATHEXT%appends, never replaces.with_appended_suffixconcatenates on theOsStr;build.ps1yieldsbuild.ps1,build.ps1.EXE, …
The POSIX divergence, stated plainly
POSIX reads an empty PATH element as the working directory. ral does not,
on any platform. That is a deliberate break with a forty-year-old
implicit-.-on-PATH foot-gun, in the spirit of the repository’s golden rule.
On Windows a trailing ; is ubiquitous environmental noise no user authored as
a request; honouring it would make every file of every directory the shell
stands in a command — and off Unix, where is_executable_file accepts any
regular file, that is literally every file. The rule is one rule, and . says
what . means.
Consequences
- Freshness is unchanged where it was argued. The executable half keeps
LOCATED’s memo and negative TTL; the presence half stays uncached, and runs only when the walk missed — the error path, and bare names of bundled tools with no host twin. One stat perPATHentry, the same cost the deleted probe paid. - The memo keys differently, and more correctly. A runtime walk in a plain
REPL used to key
cwd: None; it now keys the shell’s cwd, so eachcdstarts fresh entries. Correctness over reuse — the anchor is part of the question. - Policy identity closes.
policy_names’ host-PATHbaseline is still its own traversal, because it asks a different question, but it now anchors where the dispatch walk anchors. The grant gate and vet judge the same resolved binary.absolutizemoved tocwd_chainfor the same reason. - Completion narrows on Windows. With empty entries dropped,
commands_on_pathno longer offers every file of the cwd as a command. - The 126 refusal names the file it found, since the walk kept it.
Path::with_extensionremains available to any future contributor; what is pinned is the behaviour, bywindows_tests::pathext_appends_never_replaces. That fix is deliberately lighter-touch than the other three — a private helper, a test, and an arguing doc, rather than a candidate-iterator type that would be ceremony with one consumer.
See also
structural-bug-prevention, capabilities, runtime, grant.
Cite: core/src/path/which.rs (SearchCwd, path_dirs, search,
PathSearch, windows_command_candidates), core/src/path/resolver.rs
(Resolver::search_cwd), core/src/types/shell/context.rs
(Context::search_cwd), core/src/runtime/command/identity.rs (walk_path,
policy_names, absolutize), core/src/runtime/command/vet.rs
(check_existence).