Skip to content

Add path_within_root containment guard to slot-navigation goto-definition disk read - #143

Merged
mikebronner merged 3 commits into
mainfrom
fix/130-add-pathwithinroot-containment-guard-to-slot-navig
Jun 15, 2026
Merged

mikebronner merged 3 commits into
mainfrom
fix/130-add-pathwithinroot-containment-guard-to-slot-navig

Conversation

@mikebronner

@mikebronner mikebronner commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #130 — adds a path_within_root containment guard to the slot-navigation goto-definition flow so all goto-definition filesystem reads enforce the same containment boundary the Folio cursor and blade-var rename flows already do. Defense-in-depth: a loadViewsFrom(__DIR__ . '/../../etc', 'ns')-style namespace can resolve a component view to an absolute path that escapes the project root, and the handler would otherwise std::fs::read_to_string it with no containment check.

Changes

  • Added a path_within_root(&path, &config.root) check inside the for path in possible_paths loop in create_slot_location (laravel-lsp/src/main.rs), placed after file_exists_cached and before locate_slot_in_view, with continue on failure so the loop may still return a valid in-root candidate.
  • Guard uses config.root from the in-scope get_cached_config() result, consistent with the blade-var rename pattern.
  • Added laravel-lsp/src/tests/slot_navigation_containment.rs (modeled on folio_cursor_containment.rs) and registered it in tests/mod.rs.

Acceptance Criteria

  • path_within_root(&path, &config.root) guard added inside the for path in possible_paths loop in create_slot_location, after file_exists_cached and before locate_slot_in_view, with continue on failure
  • Guard uses config.root from the get_cached_config() result already in scope, consistent with the blade-var rename pattern
  • A component path produced by a loadViewsFrom(...)-style namespace that resolves outside the project root is skipped without calling locate_slot_in_view; goto-definition returns None when all candidates fail containment
  • Normal slot goto-definition for a component view inside the project root continues to return the correct file, line, and column (no regression)
  • Test file added under laravel-lsp/src/tests/ (slot_navigation_containment.rs) following folio_cursor_containment.rs: builds a server via LspService::new, seeds component config, asserts create_slot_location returns None for an out-of-root view path that exists on disk
  • Existing slot_variable_resolution tests remain green

Test Plan

  • cargo test slot_ — 19 + 9 slot tests pass, including the 3 new containment tests and existing slot_variable_resolution tests
  • Full unit/lib suite green (1743 + 290 passed, 0 failed)
  • cargo fmt --check clean, cargo clippy --all-targets clean
  • New tests cover the change: out-of-root → None, in-root → Some, under-root symlink to outside target → None (proves canonicalization)

Note: 8 pre-existing integration_tests failures depend on a test-project fixture (.env, composer.json) absent from CI/clone — verified identical on main (72 passed / 8 failed), unrelated to this change.

Fixes #130

…in_root

Add a path_within_root containment check inside create_slot_location's
candidate loop, after file_exists_cached and before locate_slot_in_view. A
loadViewsFrom-style namespace can resolve a component view to an absolute path
that escapes the project root; without the guard the handler would
std::fs::read_to_string an out-of-root file. Uses continue so a later in-root
candidate can still resolve, matching the Folio cursor and blade-var rename
flows that already enforce the same boundary.

Adds tests/slot_navigation_containment.rs (modeled on
folio_cursor_containment.rs): out-of-root view returns None, in-root view still
resolves, and an under-root symlink to an outside target is refused by the
canonicalize-based guard.

Fixes #130
@mikebronner
mikebronner marked this pull request as ready for review June 15, 2026 15:41

@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.

🔄 Changes Requested

Strong, surgical PR — the guard is placed exactly where the AC asks (after file_exists_cached, before locate_slot_in_view, continue on failure), and CI is green (LSP test/fmt/clippy ✅). One in-PR change before I can approve.

Issues Found

1. Positive-control test asserts only is_some() — it doesn't verify "the correct file, line, and column" (AC #4). — laravel-lsp/src/tests/slot_navigation_containment.rs:134

AC #4 requires that in-root slot goto-definition "continues to return the correct file, line, and column (no regression)." in_root_component_view_still_resolves asserts only result.is_some() — it verifies none of the three. The repo's own reference containment test sets the bar here: folio_cursor_containment.rs:94 asserts the exact value (assert_eq!(result, Some("contact".to_string()), …)). The slot test should match that precedent.

Concretely: create_slot_location returns Some(...) built from locate_slot_in_view(&path, &slot.name).unwrap_or((0, 0)) (main.rs:13665). With your CARD_VIEW fixture ({{ $title }} on line 1), a correct resolution lands the jump on line 1 — so destructure the returned GotoDefinitionResponse::Link and assert target_range.start.line == 1 and that target_uri points at the seeded card view. That turns "something resolved" into "it resolved to the right place," which is what AC #4 actually asks for, and closes the unwrap_or((0,0)) degenerate-Some gap.

(To be clear on why this is the only blocker: I adversarially checked two candidates and both were refuted — the test is genuinely honest (it can't pass on a broken guard, because the fixture guarantees a real match), and the path_within_root canonicalize guard is not lexically bypassable — any ..-escaping path normalizes to a leading-ParentDir sequence that can't starts_with the root. This is purely about test precision matching the AC and the folio pattern.)

What's Good

  • Guard placement is exactly per AC (main.rs:13657): after file_exists_cached, before the locate_slot_in_view disk read, continue so a later in-root candidate can still resolve; uses the in-scope config.root from get_cached_config(), consistent with the blade-var rename flow. ✅
  • The negative test writes the card view to disk first (slot_navigation_containment.rs:97) — so None is caused by the containment guard alone and not a missing file. That's exactly what AC #5 demands, and it's the difference between a real test and a vacuous one. ✅
  • The #[cfg(unix)] symlink test is the discriminating case — namespace lexically under the root, physically resolving outside — which proves the guard canonicalizes rather than doing a lexical starts_with. Matches the folio/routes_dir_gate convention. ✅
  • Positive control proves the harness can produce Some, so the negative tests can't be vacuously passing. Module registered correctly in tests/mod.rs. ✅

📋 Non-blocking follow-ups

  • Containment guard runs after file_exists_cached, which already stats disk on arbitrary candidate paths (main.rs:13647 → file_exists_cached ~main.rs:6920) — an existence-oracle on out-of-root paths. Reversing the order (containment first, then existence) would close it. (general — unchanged loop order, outside this PR's intent)
  • locate_slot_in_view (slot_navigation.rs:264) has no internal containment check and is pub; the invariant lives only at this one call site, so a future caller could re-open the traversal hole. Internalizing the check (or having it take a root) would make the invariant un-violable. (general — outside this PR's diff)

(Watson: these are general observations outside this PR's diff — spin each out as a tracked issue; fold one in only if it's genuinely trivial and adjacent. None get dropped.)

Please tighten the positive control per #1 and re-request review.

…e control

The positive-control test only asserted `is_some()`, which also passes when
`locate_slot_in_view` finds nothing and `create_slot_location` falls back to
the `(0, 0)` degenerate range. Destructure the `GotoDefinitionResponse::Link`
and assert the target uri is the seeded card view and the jump lands on the
`{{ $title }}` usage (line 1, column 7), matching AC #4 and the
`folio_cursor_containment.rs` precedent.

Refs #130
@dr-john-h-watson

Copy link
Copy Markdown

Addressed the blocker from review: tightened the positive control in_root_component_view_still_resolves to assert the exact file, line, and column (AC #4) instead of bare is_some() — it now destructures the GotoDefinitionResponse::Link, asserts target_uri is the seeded card view, and pins the jump to line 1, character 7 (the {{ $title }} usage), closing the locate_slot_in_view(...).unwrap_or((0, 0)) degenerate-Some gap. Matches the folio_cursor_containment.rs precedent. cargo test slot_, cargo fmt --check, and cargo clippy --all-targets all clean.

Non-blocking follow-ups (both general observations about code outside this PR's diff — spun out as tracked issues, neither trivial/adjacent to a test-only change):

@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

The round-1 blocker is fixed. The positive control no longer settles for is_some() — in_root_component_view_still_resolves now destructures GotoDefinitionResponse::Link, asserts exactly one link, and pins the target URI (the seeded card view), line 1, and column 7 (slot_navigation_containment.rs:148-163). The (0,0) degenerate-Some from a failed locate_slot_in_view would now fail the line assertion — which is precisely what AC #4 asks for, matching the folio_cursor_containment.rs precedent. CI is green (LSP test/fmt/clippy ✅).

Review Summary

  • Reviewed the full diff (3 files: the 10-line guard in main.rs, the tests/mod.rs registration, and the new slot_navigation_containment.rs) against all six acceptance criteria, with blind correctness / security / test-honesty lenses over the checkout.
  • All six AC met:
    • #1 ✅ Guard sits inside the candidate loop, after file_exists_cached, before locate_slot_in_view, continue on failure (main.rs:13657).
    • #2 ✅ Uses the in-scope config.root from get_cached_config(), matching the blade-var rename pattern. (Minor: the AC's parenthetical cites main.rs:19163, but the real path_within_root call in that flow is 19191 — 19163 is an error string. Stale citation in the AC text only; the implementation is correct, so it doesn't change anything.)
    • #3 ✅ Out-of-root namespace path is skipped without a disk read; returns None when all candidates fail containment (slot_navigation_containment.rs:88).
    • #4 ✅ In-root resolution returns the correct file, line, AND column — no regression.
    • #5 ✅ Test file follows the folio_cursor_containment.rs pattern (LspService::new → inner(), seeds config, asserts None for an out-of-root view that exists on disk).
    • #6 ✅ slot_variable_resolution untouched, suite green.
  • Tests verified honest: the negative test writes the out-of-root card view to disk first, so None is caused by the guard alone, not a missing file; the #[cfg(unix)] symlink test genuinely discriminates a canonicalizing guard from a lexical starts_with (link is lexically under-root, physically outside) — matching the folio convention; and none of the three pass if the guard is removed. path_within_root is canonicalization-based (real containment), and the guard precedes the read_to_string, so the arbitrary-read primitive #130 targeted is genuinely closed.

📋 Non-blocking follow-ups

  • Other goto-definition flows surface out-of-root navigation targets without a containment guard — create_component_location_from_salsa/resolve_component_existing_file (main.rs:13575), create_directive_location_from_salsa (main.rs:13722), and create_view_location_from_salsa (main.rs:13535) return LocationLinks for out-of-root paths with only a file_exists_cached check. Lower severity than #130 (these hand a navigation target to the client; they don't read_to_string content), but it's the same containment boundary and worth closing for consistency. (general — untouched flows, outside this PR's diff)
  • locate_slot_in_view is pub and carries no internal containment check (slot_navigation.rs:264) — the invariant now lives only at this one call site, so a future caller could re-open the traversal hole. Internalizing the check (or having it take a root) would make the invariant un-violable. (general — outside this PR's diff)
  • (Already tracked: my round-1 existence-oracle observation — running the guard before file_exists_cached so it isn't an existence oracle on out-of-root paths — is open as #145. Not re-filed.)

Ready for @mikebronner to merge.

@mikebronner
mikebronner merged commit 9630128 into main Jun 15, 2026
5 checks passed
@mikebronner
mikebronner deleted the fix/130-add-pathwithinroot-containment-guard-to-slot-navig branch June 15, 2026 17:56
mikebronner added a commit that referenced this pull request Jun 15, 2026
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 added a commit that referenced this pull request Jun 15, 2026
…o callers can't reopen the traversal hole (#154)

* chore: start work on #149

* Internalize root-containment guard into locate_slot_in_view (#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 added a commit that referenced this pull request Jun 17, 2026
Extend the path_within_root containment guard to the
<livewire:ns::component> Blade-tag goto-definition flow
(create_livewire_location_from_salsa), the last unguarded sibling in the
#130 -> #143 -> #148 containment-guard chain. A component_namespaces
entry pointing at an absolute out-of-root path (e.g. ['x' => '/etc'])
could otherwise hand the LSP client a navigation LocationLink that
escapes the project root. The guard fetches get_cached_config() after
the file_exists_cached check and refuses any path that fails
path_within_root, matching the view/component/directive/slot flows.

Also gate the diagnostic-validation .exists() probes (the view() loop
and the @extends/@include loop) behind path_within_root_lexical so an
out-of-root namespace can no longer make diagnostics stat-probe files
outside the project tree, while still reporting genuinely-missing
in-root views.

Adds the livewire_tag_navigation_containment test module covering
out-of-root -> None, in-root -> Some, and under-root-symlink-to-outside
-> None.

Fixes: #194

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mikebronner added a commit that referenced this pull request Jun 17, 2026
…-definition flow (create_livewire_location_from_salsa) (#198)

* chore: start work on #194

* fix: 🔒️ Guard livewire-tag goto-definition against out-of-root paths.

Extend the path_within_root containment guard to the
<livewire:ns::component> Blade-tag goto-definition flow
(create_livewire_location_from_salsa), the last unguarded sibling in the
#130 -> #143 -> #148 containment-guard chain. A component_namespaces
entry pointing at an absolute out-of-root path (e.g. ['x' => '/etc'])
could otherwise hand the LSP client a navigation LocationLink that
escapes the project root. The guard fetches get_cached_config() after
the file_exists_cached check and refuses any path that fails
path_within_root, matching the view/component/directive/slot flows.

Also gate the diagnostic-validation .exists() probes (the view() loop
and the @extends/@include loop) behind path_within_root_lexical so an
out-of-root namespace can no longer make diagnostics stat-probe files
outside the project tree, while still reporting genuinely-missing
in-root views.

Adds the livewire_tag_navigation_containment test module covering
out-of-root -> None, in-root -> Some, and under-root-symlink-to-outside
-> None.

Fixes: #194

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: ✅ Cover diagnostic-loop containment for the <livewire>-tag guard.

Add the view_diagnostic_containment test module covering the secondary
diagnostic-validation surface from #194 (Holmes review of PR #198). The
view() and @extends/@include loops now filter out-of-root view candidates
before the .exists() probe — a real, observable behaviour change that
previously shipped with zero test coverage.

Extract the shared filter+exists decision, which was duplicated inline in
both loops, into the any_in_root_candidate_exists free helper so the fork is
directly testable, mirroring the crate::is_in_routes_dir test style in
routes_dir_gate.rs. No behaviour change — both loops make the identical
decision through the named helper.

The four tests pin: an out-of-root candidate that exists on disk is treated
as absent (diagnostic fires), an in-root candidate that exists is found (no
false positive), a speculative not-yet-created in-root candidate still
reports absent (lexical containment kept so the missing-view diagnostic is
unbroken), and an existing out-of-root candidate cannot mask a missing
in-root one.

Refs: #194

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: 📝 Correct lexical-vs-fail-closed rationale on diagnostic guard

The doc comment on `any_in_root_candidate_exists` and the
`view_diagnostic_containment` test comments claimed the tests "pin the
exact fork" between `path_within_root_lexical` and the fail-closed
`path_within_root`, asserting a fail-closed guard "would break the
missing-view diagnostic entirely." That distinction does not exist: for
this helper's boolean output the two policies are equivalent — a missing
in-root candidate yields `false` either way (lexical keeps it and
`.exists()` is false; fail-closed drops it so `.any()` runs over an
empty set), so the "View file not found" diagnostic fires under both.

Rewrite the helper doc, the test module doc, and the
`speculative_in_root_candidate_reports_missing` comment to state the
honest rationale: lexical is chosen for consistency with the
navigation-side filter (`salsa_impl.rs`, #156) and to avoid a wasted
stat on a missing in-root path — not because it changes any diagnostic
outcome. No behavior change.

Addresses Holmes's review of #194 (PR #198).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
mikebronner added a commit that referenced this pull request Jun 17, 2026
…ile.

Mirror the sibling resolve_component_existing_file: after the
file_exists_cached check, refuse any candidate whose real path escapes
config.root before returning it. resolve_component_path already drops
out-of-root candidates with the lexical path_within_root_lexical filter,
so this is defense-in-depth — it makes the fail-closed containment
invariant hold uniformly across every FS-touching component resolver
(the #130 → #143 → #148 → #194 chain).

Add component_file_navigation_containment.rs pinning the invariant at the
resolve_component_file boundary: out-of-root and under-root-symlink
escapes return None, in-root files still resolve.

Fixes: #199

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mikebronner added a commit that referenced this pull request Jun 18, 2026
…root targets

Add a containment backstop at the file-create seam (FileAction::build_code_action,
issue #199 AC #5/#6): refuse to offer any create quick-fix (View, BladeComponent,
Livewire, Inertia, …) whose target_path escapes the project root, returning None
instead of constructing an out-of-root ResourceOp::Create. Completes the
"fail-closed containment on every FS-touching path" sweep (#130 → #143 → #148 →
#194) across the third surface — the write seam, alongside the read/resolve paths.

The guard uses path_within_root_lexical, NOT the fail-closed path_within_root the
sibling read paths use: a create target never exists yet, so path.canonicalize()
always fails for it and the fail-closed guard would refuse *every* create,
including legitimate in-root ones. The lexical guard refuses out-of-root and
interior-`..` escapes while admitting a not-yet-created in-root target, and still
canonicalizes to catch symlink escapes when the target exists. (AC #5 named
path_within_root; flagged on the PR — same class of AC defect as the #3/#4
dispute resolved via Option 1.)

Add code_action_create_containment.rs: out-of-root, interior-`..`, in-root
positive control, and under-root symlink-escape cases.

Reframe the resolve-seam negative tests per the #199 escalation (Option 1): they
assert the *invariant* (an out-of-root / symlink-escaping component never
resolves), with the resolve_component_file guard documented as the innermost
backstop whose reject branch is unreachable via the public API today — the
upstream path_within_root_lexical filter catches both negatives first. Drop the
false "only path_within_root catches" claim and correct the misleading assert
messages.

Fixes: #199

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mikebronner added a commit that referenced this pull request Jun 18, 2026
…for containment-invariant uniformity (#203)

* chore: start work on #199

* fix: 🔒️ Add fail-closed path_within_root guard to resolve_component_file.

Mirror the sibling resolve_component_existing_file: after the
file_exists_cached check, refuse any candidate whose real path escapes
config.root before returning it. resolve_component_path already drops
out-of-root candidates with the lexical path_within_root_lexical filter,
so this is defense-in-depth — it makes the fail-closed containment
invariant hold uniformly across every FS-touching component resolver
(the #130 → #143 → #148 → #194 chain).

Add component_file_navigation_containment.rs pinning the invariant at the
resolve_component_file boundary: out-of-root and under-root-symlink
escapes return None, in-root files still resolve.

Fixes: #199

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: 🔒️ Guard the build_code_action write/create seam against out-of-root targets

Add a containment backstop at the file-create seam (FileAction::build_code_action,
issue #199 AC #5/#6): refuse to offer any create quick-fix (View, BladeComponent,
Livewire, Inertia, …) whose target_path escapes the project root, returning None
instead of constructing an out-of-root ResourceOp::Create. Completes the
"fail-closed containment on every FS-touching path" sweep (#130 → #143 → #148 →
#194) across the third surface — the write seam, alongside the read/resolve paths.

The guard uses path_within_root_lexical, NOT the fail-closed path_within_root the
sibling read paths use: a create target never exists yet, so path.canonicalize()
always fails for it and the fail-closed guard would refuse *every* create,
including legitimate in-root ones. The lexical guard refuses out-of-root and
interior-`..` escapes while admitting a not-yet-created in-root target, and still
canonicalizes to catch symlink escapes when the target exists. (AC #5 named
path_within_root; flagged on the PR — same class of AC defect as the #3/#4
dispute resolved via Option 1.)

Add code_action_create_containment.rs: out-of-root, interior-`..`, in-root
positive control, and under-root symlink-escape cases.

Reframe the resolve-seam negative tests per the #199 escalation (Option 1): they
assert the *invariant* (an out-of-root / symlink-escaping component never
resolves), with the resolve_component_file guard documented as the innermost
backstop whose reject branch is unreachable via the public API today — the
upstream path_within_root_lexical filter catches both negatives first. Drop the
false "only path_within_root catches" claim and correct the misleading assert
messages.

Fixes: #199

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: 🔒️ Guard sibling create paths in build_code_action multi-file actions

The write/create-seam backstop (issue #199) only validated `self.target_path`,
but the multi-file action types each emit a SECOND `ResourceOp::Create`:

- `Livewire` → a Blade view at `get_livewire_view_path`
- `BladeComponentWithClass` → a PHP class at `get_component_class_path`

Both paths are derived from `self.name` — an independent diagnostic field, not
coupled to `target_path`. Because `PathBuf::join`/`push` of an absolute-looking
segment *replaces* the base, a forged diagnostic with an in-root `target_path`
and `name = "/etc/passwd"` slipped past the guard and materialised a file outside
the project root via the second create — the exact containment escape this chain
exists to close.

Guard each sibling path with `path_within_root_lexical(&path, root)` and return
`None` on escape, and correct the guard comment that wrongly asserted every
create materialises only `target_path`.

Adds non-vacuous multi-file coverage in `code_action_create_containment.rs`: a
`Livewire` / `BladeComponentWithClass` action with an in-root `target_path` but an
escaping `name`-derived view/class path returns `None` (each would be `Some`
without the guard), plus in-root positive controls for both types.

Refs #199

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
mikebronner added a commit that referenced this pull request Jun 18, 2026
…ath_within_root guard

find_php_class_file_by_fqcn splits an FQCN on `\`, filters only empty
segments, then PathBuf::join's each into a candidate path. join does not
resolve `..` on a non-absolute segment — it appends it literally — so an
FQCN carrying `..` segments yields a path like
`<root>/app/../../etc/secret.php` that path.exists()/the read then stats,
a read primitive that can escape the project root. The same holds for a
candidate whose path crosses an under-root symlink resolving outside root.

Gate every candidate with the fail-closed path_within_root guard before
the on-disk check, in both the app (!search_vendor) and vendor
(search_vendor) branches — extending the path_within_root containment
lineage (#130 → #143 → #148 → #194 → #199 → #201 → #214) to the last
FS-touching resolver that lacked it. A candidate that canonicalizes
outside root, or can't be proven in-root, is skipped.

Add fqcn_resolution_containment.rs: app/vendor `..`-escape negatives, an
under-root-symlink escape negative (#[cfg(unix)]), and in-root positive
controls. Tests drive the public entry points (find_php_class_file /
find_php_class_file_in_app_or_vendor), matching class_locator's existing
test style and keeping the heuristic helper private.

Fixes #218

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mikebronner added a commit that referenced this pull request Jun 18, 2026
…fqcn (FQCN → file resolution containment) (#221)

* chore: start work on #218

* 🔒 fix(class_locator): contain FQCN→file resolution with fail-closed path_within_root guard

find_php_class_file_by_fqcn splits an FQCN on `\`, filters only empty
segments, then PathBuf::join's each into a candidate path. join does not
resolve `..` on a non-absolute segment — it appends it literally — so an
FQCN carrying `..` segments yields a path like
`<root>/app/../../etc/secret.php` that path.exists()/the read then stats,
a read primitive that can escape the project root. The same holds for a
candidate whose path crosses an under-root symlink resolving outside root.

Gate every candidate with the fail-closed path_within_root guard before
the on-disk check, in both the app (!search_vendor) and vendor
(search_vendor) branches — extending the path_within_root containment
lineage (#130 → #143 → #148 → #194 → #199 → #201 → #214) to the last
FS-touching resolver that lacked it. A candidate that canonicalizes
outside root, or can't be proven in-root, is skipped.

Add fqcn_resolution_containment.rs: app/vendor `..`-escape negatives, an
under-root-symlink escape negative (#[cfg(unix)]), and in-root positive
controls. Tests drive the public entry points (find_php_class_file /
find_php_class_file_in_app_or_vendor), matching class_locator's existing
test style and keeping the heuristic helper private.

Fixes #218

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
mikebronner added a commit that referenced this pull request Jun 18, 2026
…root guard

`ComposerAutoload::resolve` mapped a PSR-4 FQCN to a candidate file by
splitting the post-prefix remainder on `\` and `PathBuf::push`-ing each
segment onto the mapped `source_root`, then returned the candidate on a
bare `candidate.exists()` — with no containment guard. `push`/`join`
appends a `..` segment literally, and `source_root` derives from a PSR-4
mapping value in composer.json / installed.json, so a `..`-bearing FQCN
(or a mapping / under-root symlink pointing outside the tree) yielded a
candidate that escaped the project root and was then stat'd and returned:
an out-of-root read primitive.

`resolve` is the higher-priority branch in `class_locator.rs` (it runs
before the heuristic `find_php_class_file_by_fqcn` that #218/PR #221
guarded), yet was the one FS-touching resolver in the lineage
(#130 → #143 → #148 → #194 → #199 → #201 → #214 → #218) with no guard.

Gate every candidate with the fail-closed `path_within_root` guard before
the on-disk check. The project root is stored on `ComposerAutoload` at
construction (`load`/`for_project` both already receive it) rather than
threaded per-call, so resolution is bound to exactly the root the PSR-4
mappings were resolved against and no caller can pass a mismatched root —
keeping the two `class_locator.rs` call sites and the existing unit tests
unchanged.

Add `tests/composer_autoload_containment.rs`: a `..`-escaping FQCN → None
(with an out-of-root precondition so None can only be the guard), an
in-root positive control → Some, and a `#[cfg(unix)]` under-root-symlink
escape → None.

Fixes #222
mikebronner added a commit that referenced this pull request Jun 18, 2026
…ve (PSR-4 FQCN → file resolution containment) (#225)

* chore: start work on #222

* 🔒️ fix(composer_autoload): gate resolve with fail-closed path_within_root guard

`ComposerAutoload::resolve` mapped a PSR-4 FQCN to a candidate file by
splitting the post-prefix remainder on `\` and `PathBuf::push`-ing each
segment onto the mapped `source_root`, then returned the candidate on a
bare `candidate.exists()` — with no containment guard. `push`/`join`
appends a `..` segment literally, and `source_root` derives from a PSR-4
mapping value in composer.json / installed.json, so a `..`-bearing FQCN
(or a mapping / under-root symlink pointing outside the tree) yielded a
candidate that escaped the project root and was then stat'd and returned:
an out-of-root read primitive.

`resolve` is the higher-priority branch in `class_locator.rs` (it runs
before the heuristic `find_php_class_file_by_fqcn` that #218/PR #221
guarded), yet was the one FS-touching resolver in the lineage
(#130 → #143 → #148 → #194 → #199 → #201 → #214 → #218) with no guard.

Gate every candidate with the fail-closed `path_within_root` guard before
the on-disk check. The project root is stored on `ComposerAutoload` at
construction (`load`/`for_project` both already receive it) rather than
threaded per-call, so resolution is bound to exactly the root the PSR-4
mappings were resolved against and no caller can pass a mismatched root —
keeping the two `class_locator.rs` call sites and the existing unit tests
unchanged.

Add `tests/composer_autoload_containment.rs`: a `..`-escaping FQCN → None
(with an out-of-root precondition so None can only be the guard), an
in-root positive control → Some, and a `#[cfg(unix)]` under-root-symlink
escape → None.

Fixes #222
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.

Add path_within_root containment guard to slot-navigation goto-definition disk read

1 participant