harden: canonicalize paths in is_in_routes_dir to survive symlinked project roots - #137
Conversation
The routes-dir gate compared `path` against `root/routes` with a purely textual `Path::starts_with`. When the stored project root and a per-request file path resolve through different symlink states (e.g. macOS `/tmp` → `/private/tmp`), that returns a false negative and silently skips the declaration-fallback walk for a real conventional route file. Canonicalize both sides before the component-wise prefix check, mirroring the sibling helper `path_within_root`, and fall back to the textual check when either side can't be canonicalized — no panic, no behaviour change for in-memory paths. Adds tests for symlinked-root resolution, the canonical out-of-routes case, and graceful fallback on a missing path. Fixes: #122
There was a problem hiding this comment.
🔄 Changes Requested
The production change is correct and the symlink case is well-covered. One in-PR test issue blocks: a new test doesn't exercise the branch its own comment claims, and the realistic fallback case goes untested. Fanned out four blind read-only lenses (AC / correctness / security / test-honesty) over the checkout, then adversarially verified the blocker against the tree.
Issues Found
🔴 falls_back_to_textual_when_path_missing tests (Err, Err), not the (Err, Ok) its comment claims — and the case that matters goes uncovered. (laravel-lsp/src/tests/routes_dir_gate.rs:96-107)
The comment at :99-100 says "Use a real tempdir root so only path fails canonicalization." But the test never creates root/routes/ on disk — it only sets root = tmp.path() and joins routes/does_not_exist.php. So routes_dir.canonicalize() also fails (there is no routes/ dir), and the production match (path.canonicalize(), routes_dir.canonicalize()) hits the (Err, Err) tuple via the _ arm — not (Err, Ok). Adversarially verified against the tree (UPHELD).
Two consequences:
- The comment is factually wrong about what the test exercises.
- The branch that actually matters is exercised by no test:
pathcan't be canonicalized butroutes_dircan — a brand-new route file still in the editor buffer, not yet saved to disk, sitting inside a realroutes/directory. That's exactly the "the file doesn't exist yet" case your own production doc comment calls out. As written, all three "missing" assertions collapse onto the same(Err, Err)fallback, so the representative(Err, Ok)arm is dark.
Fix: add std::fs::create_dir_all(root.join("routes")).unwrap(); to the test (mirroring rejects_real_path_outside_routes_dir at :88). That makes routes_dir.canonicalize() succeed, genuinely drives the (Err, Ok) arm, makes the comment true, and covers the realistic scenario. Optionally add a second assertion that a missing path outside routes/ returns false, to pin the false-negative side too.
What's Good
- ✅ The production change is right: canonicalize both sides, fall back to the component-wise
Path::starts_with(not a string prefix) when either side can't resolve — exactly mirroring the siblingpath_within_root, as AC1 asks. The_arm catches every non-(Ok,Ok)tuple, so AC3 (no panic on canonicalize failure) holds. - ✅
matches_through_symlinked_root(:55-80) is genuinely load-bearing — the in-test sanity assert at:78proves the textual check alone fails, so the final assert can only pass through canonicalization. Revert the fix and this test flips RED. That's the real regression guard, and it's honest. - ✅
rejects_real_path_outside_routes_dir(:82-94) correctly drives the(Ok, Ok)canonical branch with real on-disk paths and assertsfalse. - ✅ Signature unchanged, no call-site changes in
classify_with_decl_fallback. Test module correctly wired (tests/mod.rs:19); CI green (LSP test/fmt/clippy).
Heads-up — AC text is stale (not blocking, no action needed in the PR)
AC2 quotes the signature as fn is_in_routes_dir(path: &Path) -> bool, but the real signature — before and after this PR — is fn is_in_routes_dir(root: Option<&Path>, path: &Path) -> bool. The PR correctly preserved it, so AC2's intent (don't change the signature, don't touch call sites) is met; the quote is an authoring carry-over from #122's prose, not a contract dispute. Likewise AC1's path_within_root line reference points at 18708, but the function lives at 18733. Flagging for transparency — the PR did the right thing; only the AC wording is imprecise.
📋 Non-blocking follow-ups
- None.
(Watson: the only fix here is the in-PR test above — not a separable follow-up.)
Please address the test above and re-request review.
falls_back_to_textual_when_path_missing never created root/routes/ on disk, so routes_dir.canonicalize() also failed and the test hit the (Err, Err) arm — not the (Err, Ok) arm its comment claimed. Create the routes/ dir so routes_dir canonicalizes while the missing path does not, genuinely exercising the realistic unsaved-buffer case. Add a false-negative assertion for a missing path outside routes/. Addresses Holmes review on #122.
|
Addressed the review blocker: |
There was a problem hiding this comment.
✅ Approved
Review Summary
- PR #137:
is_in_routes_dirnow canonicalizes bothpathandroot/routesbefore the prefix check, mirroring the sibling helperpath_within_root(main.rs:18733) — exactly the symlinked-root hardening #122 asked for. 🎯 - All 6 acceptance criteria satisfied; CI green (LSP test/fmt/clippy + analyze jobs all pass).
- Reviewed via fan-out: 4 blind lenses (AC / correctness / security / test-honesty) + adversarial verification of the one in-PR correctness claim.
Acceptance criteria
- ✅ AC #1 — try-canonicalize-both-sides / textual-fallback pattern, matching
path_within_root(main.rs:18255-18264). ⚠️ AC #2 — requirement met, but the AC text mis-quotes the signature. The AC quotesfn is_in_routes_dir(path: &Path) -> bool; the real signature is and always wasfn is_in_routes_dir(root: Option<&Path>, path: &Path) -> bool(two args,rootfirst). The PR leaves that real signature unchanged and touches no call site — the sole call atmain.rs:18150still passes(root, file_path). So AC #2's requirement — "signature stays, no call-site changes" — is satisfied; only the signature quoted in the AC was inaccurate (it dropped therootparam). Flagging for the record so the AC wording (and themain.rs:18708line ref, which is actually18733) can be corrected — this is an AC-text typo, not a code defect, and not an implementation divergence.- ✅ AC #3 — the
_arm catches every canonicalize failure with a symmetric textual check, no panic (main.rs:18262). - ✅ AC #4 —
matches_through_symlinked_rootis a genuine discriminator: itsassert!(!route_file.starts_with(link_root.join("routes")))sanity line proves the old textual code returnedfalse, and the canonical branch is what flips it totrue(routes_dir_gate.rs:55-80). - ✅ AC #5 —
rejects_real_path_outside_routes_direxercises the on-disk(Ok,Ok)branch for a rejection — coverage no pre-existing test had (routes_dir_gate.rs:82-94). - ✅ AC #6 —
falls_back_to_textual_when_path_missinggenuinely drives the(Err, Ok)arm and asserts no panic plus correct true/false results (routes_dir_gate.rs:96-118).
What's good
- The fallback arm uses the original
path/routes_dir(not the canonical bindings), keeping it byte-identical to the pre-PR behaviour — no regression for the in-memory/unsaved-buffer paths the existing tests rely on. - Doc comment spells out the symlink failure mode and the fallback rationale precisely.
- Tests pin the behaviour change, not just compilation — the symlink sanity assert is exactly the right way to prove the canonical branch is load-bearing.
Adversarial verification
- One in-PR correctness claim surfaced (the
(Ok, Err)fallback sub-case could leave a symlink false-negative). Refuted: the fallback compares rawpathvs rawroot/routessymmetrically — identical to the pre-PR expression — and(Ok, Err)is structurally unreachable for a file genuinely insideroutes/(a path that canonicalizes lives on disk, so its parentroutes/canonicalizes too → the(Ok,Ok)arm). Dropped as a false positive.
📋 Non-blocking follow-ups
- None.
Ready for @mikebronner to merge.
Summary
Implements #122 — hardens the route-dir gate
is_in_routes_dirso it survives symlinked project roots, applying the same try-canonicalize / fall-back-to-textual mitigation the sibling helperpath_within_rootalready uses.The gate compared
pathagainstroot.join("routes")with a purely textualPath::starts_with.rootis stored once (an earlierdid_open) whilepatharrives per-request, so the two can resolve through different symlink states — e.g. macOS/tmp→/private/tmp. The raw check then returns a false negative and silently skips the declaration-fallback walk for a real route file. Canonicalizing both sides closes that gap.Changes
is_in_routes_dirnow canonicalizes bothpathand the joinedroutes/dir before the component-wise prefix check; falls back to the existing textual check when either side can't be canonicalized (missing file, permission error) — no panic, identical behaviour for in-memory paths not yet on disk.path_within_rootcross-reference.tests/routes_dir_gate.rs.The issue's AC was written against
fn is_in_routes_dir(path: &Path) -> boolwith a "check for aroutescomponent" body. PR #120 (the #98 fix this follows up) since refactored the function tofn is_in_routes_dir(root: Option<&Path>, path: &Path) -> booldoingpath.starts_with(r.join("routes")). I implemented the AC's clear intent — thepath_within_rootcanonicalization mitigation — against the live code, which maps even more naturally onto the current root-vs-path comparison (both sides canonicalized, exactly aspath_within_rootdoes). The current 2-arg signature is unchanged, so AC #2's "no call-site changes inclassify_with_decl_fallback" intent is honoured.Acceptance Criteria
is_in_routes_dircanonicalizes paths before the prefix check, using the same try-canonicalize / fall-back-to-textual pattern aspath_within_root— applied to bothpathandroot/routes(the live code compares againstroot/routes, not a bareroutescomponent)classify_with_decl_fallback(kept the live(root, path)signature rather than the AC's stale(path)one — see reconciliation above)canonicalize()fails, falls back to the textual prefix check — no panic, no regression (covered byfalls_back_to_textual_when_path_missing+ the 5 pre-existing tests)routes/returnstrue(matches_through_symlinked_root, viastd::os::unix::fs::symlinkin a tempdir)routes/component returnsfalse(rejects_real_path_outside_routes_direxercises the canonical branch;rejects_*cover the textual branch)falls_back_to_textual_when_path_missing)Test Plan
cargo checkclean;cargo clippyclean on touched codecargo fmtappliedroutes_dir_gatetests) passtests/integration_tests.rscases fail in a fresh clone (env/vendorfixtures are gitignored —test-project/.env,composer install). Verified identical on pristinemain— pre-existing and unrelated to this change; CI with full fixture setup is the real gate.Fixes #122