Reject out-of-root candidates lexically before canonicalize() in resolve_component_path - #197
Conversation
…ore canonicalize `path_within_root_lexical` canonicalized the candidate first and only fell back to the lexical check, so an out-of-root candidate that exists on disk was `stat`/`realpath`-probed before being rejected — an existence oracle on paths outside the project root (#145). The probe lives in `resolve_component_path`'s `retain` filter, the real oracle Holmes traced when PR #159's inert `create_slot_location` reorder was dropped. Gate the helper on a lexical `starts_with` that never canonicalizes the candidate: a path under neither the root as given nor its canonicalized form is refused without a probe. Canonicalizing the *root* (a trusted in-root path) is not an oracle and preserves the macOS `/var`->`/private/var` symlinked-root tolerance. A lexically-in-root candidate is still canonicalized to reject symlink escapes, and speculative not-yet-created candidates are still admitted — results are unchanged in every reachable case, only the out-of-root disk probe is removed. Add a regression test that proves the lexical reject precedes canonicalize: an out-of-root symlink that would canonicalize back inside the root is rejected, which fails under the old canonicalize-first order. Fixes #145
There was a problem hiding this comment.
✅ Approved
Review Summary
- Reviewed the re-scoped #145: closing the out-of-root existence oracle at the
canonicalize()probe insideresolve_component_path'sretaincontainment filter. Two files changed (path_containment.rslogic+test,salsa_impl.rscomment-only). - The rewrite of
path_within_root_lexical(path_containment.rs:84) now gates on a purely lexicalnormalize_path+starts_withcheck before anycanonicalize()of the candidate; an out-of-root candidate returnsfalsewith nostat/realpathprobe of it. ✅ AC #1 - A lexically-in-root candidate is still canonicalized (
canonical_containment(path, root).unwrap_or(true), line 103) to reject symlink escapes, and thecanonicalize(root)branch in the gate preserves the macOS/var→/private/vartolerance — canonicalizing the trusted root is not an oracle. Behaviour is identical to the old helper for every reachable in-root/speculative/escape/dangling case; the only delta is that an out-of-root symlink resolving back inside is now (correctly) refused — a containment improvement, not a downgrade. ✅ AC #2, #4 - New regression test
lexical_reject_precedes_canonicalize_for_out_of_root_candidate(line 187) builds an out-of-root symlink that would canonicalize back inside the root and asserts it's rejected — this fails under the old canonicalize-first order and passes under the new lexical-first order, so it genuinely proves the ordering rather than merely assertingNone. ✅ AC #3 - Hardened the shared helper in
path_containment(no private copy); confirmed exactly one production caller (salsa_impl.rs:2955), so the contract change can't regress a hidden consumer. #161 merged. ✅ AC #6 - PR #159's inert
create_slot_locationreorder isCLOSED/dropped, not merged. ✅ AC #7 - CI all green — LSP test/fmt/clippy, extension wasm/fmt/clippy, CodeQL. ✅ AC #5
Verified by four blind lens reviewers (AC / correctness / security / test-honesty) plus my own trace of the old-vs-new logic and the caller set. Every acceptance criterion is met; no defect found in the diff.
📋 Non-blocking follow-ups
- None.
Ready for @mikebronner to merge.
There was a problem hiding this comment.
🔄 Changes Requested
The implementation is correct and the oracle is genuinely closed — I traced every candidate case and the lexical gate provably precedes any candidate canonicalize(). What's missing is test coverage on two new, security-relevant branches this PR introduced, both of which implement guarantees AC #2 explicitly names. They're a silent-regression risk (one of them on macOS — your own platform), and they live on lines this PR wrote.
Issues Found
1. The new macOS /var→/private/var symlinked-root tolerance branch has no test. — laravel-lsp/src/path_containment.rs:91-95
The old lexical path never canonicalized the root; this PR adds a brand-new fallback:
let lexically_in_root = normalized.starts_with(root)
|| root.canonicalize().map(|real_root| normalized.starts_with(&real_root)).unwrap_or(false);That root.canonicalize() leg is the only thing that admits an in-root candidate when root is given as /var/... but the candidate carries the resolved /private/var/... prefix — exactly the macOS dev-machine case AC #2 says must be preserved. No test exercises it (every /var→/private/var reference in the file is a doc comment; grep confirms). If that branch is ever dropped or inverted, in-root component goto-definition silently breaks on macOS and the suite stays green. Add a test: real dir + symlink-dir pointing at it, pass the symlink path as root and a candidate under the real path, assert path_within_root_lexical admits it. The idiom mirrors your existing lexical_reject_precedes_canonicalize_for_out_of_root_candidate.
2. The in-root symlink-escape rejection (AC #2's #55/#134 "no containment downgrade") has no direct test on path_within_root_lexical. — laravel-lsp/src/path_containment.rs:103
canonical_containment(path, root).unwrap_or(true) is what rejects a lexically-in-root symlink whose real target escapes the root. The only fail-closed symlink test, guard_is_fail_closed_on_dangling_under_root_symlink (:229), asserts path_within_root — not path_within_root_lexical. So the "still canonicalized to reject symlink escapes" guarantee for the lexical guard is asserted by reasoning only. Add a test: an under-root symlink pointing outside the root, assert path_within_root_lexical refuses it. ~15 lines, same idiom.
What's Good
lexical_reject_precedes_canonicalize_for_out_of_root_candidateis an excellent differential test — building an out-of-root symlink that canonicalizes back inside the root is exactly the construction that distinguishes lexical-first from canonicalize-first ordering. AC #3 ("distinct from merely assertingNone") is fully and convincingly met.- The oracle is truly closed: an out-of-root candidate returns at
:96beforecanonical_containmentis ever reached, and only the root is canonicalized in the gate — no candidate probe. Verified across every case (exists/speculative × in-root/out-of-root/symlink-escape/macOS). - Fail-closed
path_within_rootuntouched; all read/emit callers (main.rs,slot_navigation.rs) still use it. No containment downgrade. - AC #6/#7 handled cleanly — shared helper hardened in
path_containment(no private copy), inert PR #159 correctly closed,salsa_impl.rschange is comment-only with theretainuntouched. - Docs and the
salsa_impl.rscomment are thorough and accurate.
📋 Non-blocking follow-ups
- None.
Add the two regression tests and re-request review — this is otherwise ready.
…symlink-escape Adds the two regression tests Holmes flagged in his review of PR #197 — both on branches this PR introduced in path_within_root_lexical and both backing guarantees AC #2 names explicitly: - lexical_admits_in_root_candidate_under_symlinked_root: exercises the new root.canonicalize() leg of the lexical gate (path_containment.rs:91-95) — the macOS /var→/private/var symlinked-root tolerance. Root is passed as a symlink, the candidate carries the resolved prefix, and a precondition proves the first starts_with(root) leg fails so only the root-canonicalize leg can admit it. Guards against silent in-root goto-definition breakage on macOS. - lexical_refuses_in_root_symlink_escaping_the_root: asserts the in-root symlink-escape rejection (canonical_containment at :103, the #55/#134 no-downgrade guarantee) directly on path_within_root_lexical, not only on the fail-closed path_within_root. Both mirror the existing #[cfg(unix)] symlink-test idiom. Refs #145 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed both review blockers — added the two regression tests on the new branches in
Both mirror the existing |
There was a problem hiding this comment.
✅ Approved
Review Summary
Closes the out-of-root existence oracle in slot/component navigation. path_within_root_lexical now rejects an out-of-root candidate by a lexical normalize_path + starts_with check before any canonicalize() syscall touches the candidate (path_containment.rs:84-104) — while still canonicalizing lexically-in-root candidates to reject symlink escapes (#55/#134) and preserving the macOS /var→/private/var root-symlink tolerance via a root.canonicalize() leg on the trusted root (which is not an oracle).
- All 7 acceptance criteria met. #161 landed and the helper is hardened in the shared
path_containmentmodule with no private copy (AC #6);main.rsis untouched, so the inert PR #159 reorder is absent (AC #7); CI green — LSP test/fmt/clippy, extension, CodeQL (AC #5). - Tests are meaningful, not tautological.
lexical_reject_precedes_canonicalize_for_out_of_root_candidateis genuinely discriminating — its preconditions build an out-of-root symlink that would canonicalize back inside the root, so afalseresult can only come from the lexical reject firing first (it returnstrueunder the old canonicalize-first order).lexical_admits_in_root_candidate_under_symlinked_rootpins the load-bearingroot.canonicalize()leg — its precondition forces the first lexical leg to fail, so deletingpath_containment.rs:92-95breaks the test.lexical_refuses_in_root_symlink_escaping_the_rootguards the no-downgrade #55/#134 path directly on the lexical entry point.
Reviewed via four blind lens reviewers (AC / correctness / security / test-honesty) plus adversarial verification of every blocker-class finding. Two candidate test-honesty blockers and two in-PR notes were all refuted against the tree — the "would pass under old code" framing doesn't make a regression guard non-meaningful, and the double-root.canonicalize() is a short-circuited, dentry-cached non-issue.
📋 Non-blocking follow-ups
resolve_component_file(main.rs:18375) returns a path guarded only by the lexicalpath_within_root_lexicalfilter (insideresolve_component_path), then read server-side viastd::fs::read_to_string(main.rs:22963,:23029) — unlike its siblingresolve_component_existing_file(main.rs:13878), which applies the fail-closedpath_within_rootbefore returning. Not currently exploitable (the lexical filter rejects existing out-of-root symlink-escapers;file_exists_cacheddrops dangling ones), but the fail-closed containment invariant isn't uniform across FS-touching resolution paths. Defense-in-depth — the next sibling in the #130→#143→#148→#194 chain. Tracked as a new issue (filed below; #194, the active sibling, is In Progress so I didn't fold this into it mid-build).
Ready for @mikebronner to merge.
Summary
Implements #145 (re-scoped). Closes the real out-of-root existence oracle in
slot/component navigation: the
canonicalize()syscall insideresolve_component_path'sretaincontainment filter (salsa_impl.rs), whichprobed every candidate — including out-of-root ones — on disk.
This supersedes the dropped, inert reorder in closed PR #159. Holmes's escalation
established that PR #159's
create_slot_locationloop reorder was inert (itscandidates are already filtered by
resolve_component_pathupstream); the actualprobe is one layer up, in the
retain'scanonicalize(). This PR targets that.Changes
path_containment.rs— hardened the sharedpath_within_root_lexical(the Consolidate the triplicated path_within_root containment helper into one shared module #161 helper used by the candidate filter): it now gates on a lexical
starts_withcheck that never canonicalizes the candidate. An out-of-rootcandidate is refused with no
stat/realpathprobe — closing the oracle.that fails, against the root's canonicalized form. Canonicalizing the
root (a trusted in-root path) is not an oracle and preserves the macOS
/var→/private/varsymlinked-root tolerance.symlink escapes (feat: rename — Blade variable rename (scope-aware + cross-file from controller) #55/harden: make path_within_root fail-closed for security-guard callers (dangling-symlink leg) #134); speculative not-yet-created candidates are still
admitted. Results are identical to the old helper in every reachable case —
only the out-of-root disk probe is removed (no downgrade of containment).
path_containment.rstests — addedlexical_reject_precedes_canonicalize_for_out_of_root_candidate,which builds an out-of-root symlink that would canonicalize back inside the
root and asserts it is rejected. It passes only when the lexical reject fires
before canonicalize; it fails under the old canonicalize-first order.
salsa_impl.rs— updated the candidate-filter comment to describe thelexical-first (candidate-never-canonicalized) behaviour.
Acceptance Criteria
canonicalize()syscall on it — nostat/realpathprobe for a path outsidethe root
escapes — preserves the macOS
/var→/private/vartolerance and feat: rename — Blade variable rename (scope-aware + cross-file from controller) #55/harden: make path_within_root fail-closed for security-guard callers (dangling-symlink leg) #134behaviour; a reordering, not a containment downgrade
reject taken before
canonicalize), distinct from merely assertingNonecontainment tests stay green (1853 lib + 80 integration tests pass)
cargo test,cargo fmt --check,cargo clippy --all-targetscleanpath_within_root_lexicalinpath_containment, no private copycreate_slot_locationreorder (PR Run slot-navigation containment guard before file_exists_cached to close out-of-root existence oracle #159) is dropped, not mergedTest Plan
cargo test --all-featuresgreen (1853 lib, 80integration, 344 binary) with CI fixtures (
.env+composer update) in placecargo fmt --checkclean;cargo clippy --all-targets -- -D warningsclean(CI-exact)
Fixes #145
Review follow-up (Holmes, 2026-06-17 —
CHANGES_REQUESTED)Added the two missing regression tests Holmes flagged — both on branches this
PR introduced in
path_within_root_lexical, both backing guarantees AC #2 names:lexical_admits_in_root_candidate_under_symlinked_root— exercises theroot.canonicalize()leg of the lexical gate (:91-95), the macOS/var→/private/varsymlinked-root tolerance. A precondition proves thefirst
starts_with(root)leg fails, so only the root-canonicalize leg canadmit the candidate.
lexical_refuses_in_root_symlink_escaping_the_root— asserts the in-rootsymlink-escape rejection (
canonical_containmentat:103, the feat: rename — Blade variable rename (scope-aware + cross-file from controller) #55/harden: make path_within_root fail-closed for security-guard callers (dangling-symlink leg) #134no-downgrade guarantee) directly on
path_within_root_lexical, not just onthe fail-closed
path_within_root.CI green (LSP test/fmt/clippy, extension, CodeQL). No production code changed.