Skip to content

Internalize the root-containment invariant into locate_slot_in_view so callers can't reopen the traversal hole - #154

Merged
mikebronner merged 2 commits into
mainfrom
fix/149-internalize-the-root-containment-invariant-into-lo
Jun 15, 2026
Merged

mikebronner merged 2 commits into
mainfrom
fix/149-internalize-the-root-containment-invariant-into-lo

Conversation

@mikebronner

@mikebronner mikebronner commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #149. Internalizes the project-root containment invariant into locate_slot_in_view so the safety check no longer depends on every caller remembering to pre-guard.

Follow-up from Holmes's review of #130 (PR #143): #143 closed the slot-navigation traversal hole with a path_within_root guard at the single create_slot_location call site, but locate_slot_in_view itself was pub and read from disk with no internal check. A future caller that forgot the pre-check would silently reopen the same out-of-root read.

Changes

  • locate_slot_in_view signature extended with a root: &Path parameter.
  • It now calls a local path_within_root(view_path, root) and returns None immediately for out-of-root paths — no read_to_string occurs for an out-of-root view.
  • The helper mirrors main.rs::path_within_root (canonicalize both sides, textual-prefix fallback). The laravel_lsp library crate can't reach the binary's private fn, and the repo already duplicates this logic (main.rs + an inline copy in salsa_impl.rs), so a local helper is the minimal faithful implementation. A crate-wide extraction would be scope creep beyond this defense-in-depth issue.
  • The create_slot_location call site passes &config.root as the third argument.
  • The existing call-site guard is intentionally kept: it still prevents building a LocationLink to an out-of-root path (the unwrap_or((0, 0)) fallback would otherwise leak it). The new internal check is defense-in-depth.

Acceptance Criteria

  • locate_slot_in_view signature extended to accept a root: &Path parameter
  • locate_slot_in_view calls path_within_root(view_path, root) before std::fs::read_to_string and returns None immediately for out-of-root paths — no disk access occurs
  • The call site in create_slot_location (main.rs) passes &config.root as the third argument; compiles without the old two-argument form
  • Unit test asserts locate_slot_in_view returns None for a path outside root (even though the file exists on disk)
  • Unit test asserts locate_slot_in_view returns the correct Some((line, col)) for a valid in-root path containing the named slot variable
  • All existing slot_navigation tests remain green
  • cargo fmt --check passes and cargo clippy --all-targets emits no new warnings

Test Plan

  • cargo test slot_navigation — 20 unit tests (incl. 2 new) + 3 slot_navigation_containment integration tests pass
  • cargo fmt --check clean
  • cargo clippy --all-targets — no new warnings

Fixes #149

locate_slot_in_view was pub and read from disk with no containment check, so
the safety invariant lived entirely at the single create_slot_location call
site (#143). A future caller that forgot the pre-check would silently reopen
the out-of-root read that #130 closed.

Extend the signature with root: &Path, check path_within_root before
read_to_string, and return None for out-of-root paths — no disk access occurs.
The local helper mirrors main.rs::path_within_root (canonicalize both sides,
textual fallback); the library crate can't call the binary's private fn, and
the repo already duplicates this logic (main.rs + salsa_impl.rs).

The existing call-site guard stays: it still prevents building a LocationLink
to an out-of-root path (the unwrap_or((0,0)) fallback would otherwise leak it).
This internal check is defense-in-depth so the invariant holds regardless of
how many call sites exist.

Add unit tests for the out-of-root (file exists on disk) and in-root cases.

Fixes #149
@mikebronner
mikebronner marked this pull request as ready for review June 15, 2026 19:38

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Approved

Review Summary

  • Internalizes the project-root containment invariant into locate_slot_in_view: the new root: &Path param drives an early path_within_root guard that short-circuits to None before std::fs::read_to_string (slot_navigation.rs:274-277), so an out-of-root view never hits the disk. The single call site in create_slot_location is updated to pass &config.root (main.rs:13708).
  • Every acceptance criterion is met (7/7). Spot-checked the test arithmetic: VIEW_WITH_SLOT puts $title at (1, 7) — 4 spaces + {{ = 7 columns — matching the asserted Some((1, 7)). The out-of-root test writes the file to disk first, so its None can only come from the guard, not a missing file. Honest tests.
  • CI green: LSP test/fmt/clippy, Extension wasm/fmt/clippy, and both CodeQL analyses all pass.
  • Bonus: an integration test (slot_navigation_containment.rs::under_root_symlink_to_outside_target_returns_none) covers the symlink-escape case via the canonicalize-based (Ok, Ok) arm — the discriminating test a purely-lexical guard would fail.

What's Good

  • The new internal guard is real defense-in-depth, not a replacement for the outer call-site check: that outer guard is still load-bearing because .unwrap_or((0, 0)) would otherwise emit a LocationLink to an out-of-root URI. Keeping it is correct, and the PR body says so explicitly.
  • The local path_within_root faithfully mirrors the merged main.rs twin (canonicalize both sides + lexical fallback), exactly as the AC required.
  • Doc comments cite the originating issues (#55, #130, #149) and explain why the invariant is internalized — future-caller-proofing.

I ran the security concerns about the _ => view_path.starts_with(root) fallback (symlink escape, TOCTOU, comment wording) through adversarial verification. All refuted: the symlink-escape window is not constructible — canonicalizing view_path = <root>/x must validate <root>, so root.canonicalize() can't fail while view_path.canonicalize() succeeds; and in the genuine fallback arm the path doesn't resolve, so read_to_string fails and yields None — no out-of-root content is ever read. The TOCTOU and fallback-wording are exact mirrors of the established main.rs pattern (repo convention), and the fail-closed hardening is already separately tracked in #134.

📋 Non-blocking follow-ups

  • Consolidate the now-triplicated path_within_root containment logic — main.rs:18847, slot_navigation.rs:287 (this PR), and the inline copy in salsa_impl.rs:2950-2953 — into one shared helper. — laravel-lsp/src/slot_navigation.rs:287 — A security-critical canonicalization check copied across three sites can drift; any future hardening (e.g. the fail-closed work in #134) would otherwise have to be applied three times. The duplication here was AC-sanctioned ("mirror main.rs") and outside this PR's remit, so it's a follow-up, not a blocker.

Ready for @mikebronner to merge.

@mikebronner
mikebronner merged commit b9417ba into main Jun 15, 2026
5 checks passed
@mikebronner
mikebronner deleted the fix/149-internalize-the-root-containment-invariant-into-lo branch June 15, 2026 20:36
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.

Internalize the root-containment invariant into locate_slot_in_view so callers can't reopen the traversal hole

1 participant