Add path_within_root containment guard to slot-navigation goto-definition disk read - #143
Conversation
…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
There was a problem hiding this comment.
🔄 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): afterfile_exists_cached, before thelocate_slot_in_viewdisk read,continueso a later in-root candidate can still resolve; uses the in-scopeconfig.rootfromget_cached_config(), consistent with the blade-var rename flow. ✅ - The negative test writes the card view to disk first (
slot_navigation_containment.rs:97) — soNoneis 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 lexicalstarts_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 intests/mod.rs. ✅
📋 Non-blocking follow-ups
- Containment guard runs after
file_exists_cached, which alreadystats 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 ispub; 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
|
Addressed the blocker from review: tightened the positive control 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):
|
There was a problem hiding this comment.
✅ 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, thetests/mod.rsregistration, and the newslot_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, beforelocate_slot_in_view,continueon failure (main.rs:13657). - #2 ✅ Uses the in-scope
config.rootfromget_cached_config(), matching the blade-var rename pattern. (Minor: the AC's parenthetical citesmain.rs:19163, but the realpath_within_rootcall in that flow is19191— 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
Nonewhen 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.rspattern (LspService::new→inner(), seeds config, assertsNonefor an out-of-root view that exists on disk). - #6 ✅
slot_variable_resolutionuntouched, suite green.
- #1 ✅ Guard sits inside the candidate loop, after
- Tests verified honest: the negative test writes the out-of-root card view to disk first, so
Noneis caused by the guard alone, not a missing file; the#[cfg(unix)]symlink test genuinely discriminates a canonicalizing guard from a lexicalstarts_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_rootis canonicalization-based (real containment), and the guard precedes theread_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), andcreate_view_location_from_salsa(main.rs:13535) returnLocationLinks for out-of-root paths with only afile_exists_cachedcheck. Lower severity than #130 (these hand a navigation target to the client; they don'tread_to_stringcontent), but it's the same containment boundary and worth closing for consistency. (general — untouched flows, outside this PR's diff) locate_slot_in_viewispuband 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 aroot) 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_cachedso it isn't an existence oracle on out-of-root paths — is open as #145. Not re-filed.)
Ready for @mikebronner to merge.
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
…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
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>
…-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>
…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>
…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>
…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>
…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>
…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>
…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
…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
Summary
Implements #130 — adds a
path_within_rootcontainment 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: aloadViewsFrom(__DIR__ . '/../../etc', 'ns')-style namespace can resolve a component view to an absolute path that escapes the project root, and the handler would otherwisestd::fs::read_to_stringit with no containment check.Changes
path_within_root(&path, &config.root)check inside thefor path in possible_pathsloop increate_slot_location(laravel-lsp/src/main.rs), placed afterfile_exists_cachedand beforelocate_slot_in_view, withcontinueon failure so the loop may still return a valid in-root candidate.config.rootfrom the in-scopeget_cached_config()result, consistent with the blade-var rename pattern.laravel-lsp/src/tests/slot_navigation_containment.rs(modeled onfolio_cursor_containment.rs) and registered it intests/mod.rs.Acceptance Criteria
path_within_root(&path, &config.root)guard added inside thefor path in possible_pathsloop increate_slot_location, afterfile_exists_cachedand beforelocate_slot_in_view, withcontinueon failureconfig.rootfrom theget_cached_config()result already in scope, consistent with the blade-var rename patternloadViewsFrom(...)-style namespace that resolves outside the project root is skipped without callinglocate_slot_in_view; goto-definition returnsNonewhen all candidates fail containmentlaravel-lsp/src/tests/(slot_navigation_containment.rs) followingfolio_cursor_containment.rs: builds a server viaLspService::new, seeds component config, assertscreate_slot_locationreturnsNonefor an out-of-root view path that exists on diskslot_variable_resolutiontests remain greenTest Plan
cargo test slot_— 19 + 9 slot tests pass, including the 3 new containment tests and existingslot_variable_resolutiontests1743+290passed, 0 failed)cargo fmt --checkclean,cargo clippy --all-targetscleanNone, in-root →Some, under-root symlink to outside target →None(proves canonicalization)Fixes #130