Skip to content

Add the fail-closed path_within_root guard to resolve_component_file for containment-invariant uniformity - #203

Merged
mikebronner merged 4 commits into
mainfrom
fix/199-add-the-fail-closed-pathwithinroot-guard-to-resolv
Jun 18, 2026
Merged

mikebronner merged 4 commits into
mainfrom
fix/199-add-the-fail-closed-pathwithinroot-guard-to-resolv

Conversation

@mikebronner

@mikebronner mikebronner commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #199 — extends the fail-closed containment invariant across two
FS-touching surfaces so it holds uniformly (the #130 → #143 → #148 → #194 chain):

  1. Read/resolve seam — resolve_component_file (anonymous Blade component
    hover / prop completion) gains the containment guard mirroring the sibling
    resolve_component_existing_file.
  2. Write/create seam — FileAction::build_code_action refuses to offer any
    file-create quick-fix (View, BladeComponent, Livewire, Inertia, …) whose target
    escapes the project root — including the second file the multi-file types
    emit (Livewire view, component PHP class).

Changes

  • main.rs — resolve_component_file: after file_exists_cached, refuse any
    candidate whose real path escapes config.root (if !path_within_root(&path, &config.root) { continue; }), mirroring resolve_component_existing_file.
  • main.rs — build_code_action (write/create seam, AC refactor: 👽️ Update diagnostic source from 'laravel-lsp' to 'laravel'. #5): before any
    ResourceOp::Create is constructed, return None if target_path escapes the
    known project root. Uses path_within_root_lexical (see deviation note below).
  • main.rs — build_code_action multi-file sibling guards (round 2, Holmes review):
    Livewire and BladeComponentWithClass each emit a second ResourceOp::Create
    (view_uri / class_uri) derived from self.name — an independent diagnostic
    field. Because PathBuf::join/push of an absolute segment replaces the base, a
    forged name (e.g. /etc/passwd) escaped the root past the target_path check.
    Each sibling path is now guarded with path_within_root_lexical + return None,
    and the guard comment claiming every create materialises only target_path is
    corrected.
  • No consumer changes — every guard lives in the resolver / builder.
  • New test file src/tests/code_action_create_containment.rs (write/create
    seam, incl. multi-file sibling-path coverage) and reframed
    component_file_navigation_containment.rs (resolve seam).

Acceptance Criteria

Escalation resolution — Option 1 (AC #3/#4 reframe)

Per the adjudicated dispute (Mike: Option 1), the resolve-seam negative tests
now assert the invariant — an out-of-root / symlink-escaping component never
resolves — not the new guard's reject branch in isolation. Through the public
API that reject branch is unreachable today: the upstream path_within_root_lexical
filter in resolve_component_path canonicalizes lexically-in-root candidates and
drops both negative-test candidates before the loop runs, so each negative None
comes from that upstream filter, with the resolver guard as the documented innermost
backstop. The false "only path_within_root catches" premise and the misleading
assert messages are corrected.

⚠️ AC #5 primitive deviation — approved in review

AC #5 says "add a path_within_root backstop." Implemented with
path_within_root_lexical instead, because path_within_root is fail-closed
on un-canonicalizable paths
(canonical_containment(...).unwrap_or(false)), and a
create target never exists yet — path.canonicalize() always fails for it. Using
path_within_root would refuse every create, including legitimate in-root ones
(the AC #6 positive control would fail). path_within_root_lexical is the
purpose-built primitive for a speculative emitted path: it 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 does exist. Holmes approved
this choice in review (same class as the #3/#4 call); the AC wording should be
amended to name path_within_root_lexical.

Test Plan

  • cargo test --all-features green: 1855 + 364 + 80 = 2299, 0 failures
    (the 364 reflects the +4 multi-file sibling-path tests added this round), after
    bootstrapping the gitignored test-project/.env + composer fixtures exactly as CI does.
  • New multi-file sibling-path escape tests (Livewire view, component class) +
    their positive controls pass; each negative would return Some/resolve if its
    guard were removed — none is vacuous.
  • cargo fmt --check clean; cargo clippy --all-targets --all-features -- -D warnings clean.

Fixes #199

mikebronner and others added 2 commits June 17, 2026 11:24
…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
mikebronner marked this pull request as ready for review June 17, 2026 18:37
@mr-sherlock-holmes

Copy link
Copy Markdown

@mikebronner — escalating #199 / PR #203. The guard implementation is correct, but the acceptance criteria I wrote in the #145 follow-up are contradicted by the codebase, which makes two of them unsatisfiable. This is a contract dispute, not a code-quality bounce — your call. I ran four blind lens reviews and verified the crux against path_containment.rs myself.

What's solid ✅

The problem 🔴 (AC #3 + #4 — verified in path_containment.rs)

AC #3's symlink test says "both file_exists_cached and the lexical guard pass, only path_within_root (canonicalization) catches the escape." That premise is false. path_within_root_lexical (path_containment.rs:103) canonicalizes any lexically-in-root candidate via canonical_containment and catches the symlink escape itself (→ Some(false)). So the upstream filter in resolve_component_path (salsa_impl.rs:2955, paths.retain(|p| path_within_root_lexical(p, &self.root))) drops both negative-test candidates before resolve_component_file's loop runs:

  • out-of-root dir → lexically out → dropped by the retain.
  • under-root symlink → lexically in, but canonicalized-out by the lexical filter → dropped by the retain.

Net: the new guard's reject branch is unreachable through the public API — existing escapes die at the upstream filter, speculative paths die at file_exists_cached. Both negative tests pass vacuously (they'd pass identically if the guard were deleted), so AC #4 ("the guard is exercised by the new tests") is not met for the reject branch and can't be, and the tests' comments/assert messages misattribute the None to the new guard — an in-PR test-honesty defect either way.

Options

  1. Ship the guard as defense-in-depth; fix the AC + reframe the negative tests honestly. Keep the guard (it completes the chain's "fail-closed on every FS-touching path" invariant). Correct AC fix: 🐛 Fix middleware definition navigation. #3/fix: 🐛 Fixed route parsing and definition navigation. #4: the negative tests assert the invariant (an out-of-root / symlink-escaping component never resolves), with the new guard documented as the innermost backstop; drop the false "only path_within_root catches" claim and the unreachable reject-branch-coverage requirement; Watson fixes the misleading assert messages. — pros: keeps the uniformity invariant, smallest change, honest tests; cons: the guard is redundant with the upstream filter for all reachable inputs today, its reject branch ships with no direct test (covered only by the upstream filter + path_containment.rs unit tests), and it relaxes an AC.
  2. Add a test seam so the reject branch is genuinely reachable & tested. Extract the per-candidate file_exists + path_within_root check (or inject a candidate list) so a test can feed a lexically-in-root-but-canonically-out existing candidate straight to the guard and assert the continue fires. — pros: AC fix: 🐛 Fixed route parsing and definition navigation. #4 met literally, real reject-branch coverage; cons: a test-only seam in production code purely to exercise an otherwise-unreachable branch, breaks pattern parity with the sibling resolvers, more churn.
  3. Drop the guard; close Add the fail-closed path_within_root guard to resolve_component_file for containment-invariant uniformity #199 as redundant. path_within_root_lexical already canonicalizes and catches every escape that reaches this resolver, so the guard is dead-on-arrival for all reachable inputs. — pros: no redundant/untestable code; cons: breaks the chain's deliberate "don't rely on the upstream filter staying correct" defense-in-depth design, and discards correct, already-reviewed work.

Recommendation: Option 1

The guard is right, and the whole point of the #130 → #143 → #148 → #194 → #199 chain is a self-contained fail-closed backstop on every resolver — keep it. The fault is the AC I wrote: it assumed path_within_root_lexical is purely lexical, but it canonicalizes in-root candidates and already catches symlink escapes. Correct the AC, reframe the negatives as invariant tests with the guard as the documented backstop, and fix the assert messages. Option 2 adds production seams to test an unreachable branch (cure worse than the disease); Option 3 throws away a legitimate backstop.

Context: AC #1, #2 met; AC #3/#4 unsatisfiable as written; CI fully green; 0 prior change rounds.

@mikebronner

Copy link
Copy Markdown
Contributor Author

Option 1

…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>
@dr-john-h-watson

Copy link
Copy Markdown

Resumed after Mike's Option 1 decision. Done in this PR:

AC #3/#4 reframe (Option 1). The resolve-seam negative tests now assert the invariant (an out-of-root / symlink-escaping component never resolves), with resolve_component_file's guard documented as the innermost backstop whose reject branch is unreachable via the public API today — the upstream path_within_root_lexical filter in resolve_component_path catches both negatives first. Dropped the false "only path_within_root catches" premise and corrected the misleading assert messages. The guard itself is unchanged (AC #1/#2 already met).

AC #5/#6 (write/create seam, folded in after the original review). Added a containment backstop at the top of FileAction::build_code_action: if the project root is known and target_path escapes it, return None rather than constructing an out-of-root ResourceOp::Create. Covers every create action type (View, BladeComponent, Livewire, Inertia, …) in one place. New code_action_create_containment.rs: out-of-root → None, in-root positive control → action offered, interior-.. escape → None, #[cfg(unix)] under-root-symlink-escape → None.

⚠️ Heads-up — AC #5 primitive deviation (same class as the #3/#4 dispute). AC #5 names path_within_root, but that's fail-closed on un-canonicalizable paths (canonical_containment(...).unwrap_or(false)), and a create target never exists yet — path.canonicalize() always fails for it. Using path_within_root would refuse every create, including legitimate in-root ones, and the AC #6 positive control would fail. I implemented with path_within_root_lexical — the purpose-built primitive for a speculative emitted path (refuses out-of-root + interior-.. escapes, admits a not-yet-created in-root target, still canonicalizes to catch symlink escapes when the target exists). Flagging rather than silently following an AC whose named primitive contradicts the requirement; same Option-1-style call as #3/#4 if you'd rather adjust the AC wording.

CI: cargo fmt/clippy/test all green locally on both the branch tip (2295 tests) and the merge-with-main version (2309 tests), fixtures bootstrapped the CI way. Note the branch is based on the pre-#205 main (the earlier rebase was unwound to avoid a force-push of the two already-pushed commits); CI tests the merge with current main, which I verified passes.

@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

One blocker. The resolve_component_file side is solid and the Option-1 reframe landed correctly — but the new build_code_action backstop (AC #5/#6) only guards one of the two files that the multi-file create actions emit, so a forged diagnostic can still coax an out-of-root create. That's the exact containment-escape this whole chain exists to close, on code this PR added.

🔴 Issues Found

The write-seam guard checks only self.target_path; Livewire and BladeComponentWithClass each emit a second, unguarded create. (main.rs:2495–2499)

The guard validates path_within_root_lexical(&self.target_path, root) and nothing else. But:

  • Livewire (main.rs:2502–2570) creates two files — file_uri (from target_path, guarded ✅) and view_uri from self.get_livewire_view_path(root) (:2505–2506, unguarded ResourceOp::Create). get_livewire_view_path (:2454) is self.name.replace('.', "/") joined under resources/views/livewire/ — and PathBuf::join of an absolute segment replaces the base, so name = "/etc/passwd" yields /etc/passwd.blade.php.
  • BladeComponentWithClass (main.rs:2571–2640) creates two files — file_uri (guarded ✅) and class_uri from self.get_component_class_path(root) (:2574–2575, unguarded). get_component_class_path (:2369) PathBuf::push-es each self.name.split('.') segment under app/View/Components — and push of an absolute-looking segment replaces the whole path, so name = "/etc/passwd" yields /etc/passwd.php.

Why this is reachable, not theoretical. FileAction::from_diagnostic (main.rs:2143) extracts target_path (from extract_expected_path — the "Expected at:" line) and name (from extract_name_from_diagnostic — the quoted component) as two independent fields. Nothing couples them. A forged/malformed laravel diagnostic — the precise threat model your own guard comment (:2476–2479) and the test-file doc cite — can set an in-root target_path (guard passes) and name = "/etc/passwd" at the same time. Result: the guard offers the action, and the editor materialises an out-of-root file via the second ResourceOp::Create.

Root cause is the guard's own premise. The comment at main.rs:2478 asserts "Every create action (View, BladeComponent, Livewire, Inertia, …) materialises self.target_path." That's false for exactly the two multi-file types above — they materialise target_path plus a name-derived sibling. AC #5 names all four types ("no file-create action (View, BladeComponent, Livewire, Inertia) can be issued for a target path outside the project root"); the guard currently satisfies that intent only for the single-file types (View, anonymous BladeComponent), not the multi-file ones.

Fix

Guard every path a create action emits, not just target_path. Concretely: in the Livewire and BladeComponentWithClass branches, after computing view_path / class_path, run the same path_within_root_lexical(&path, root) check and return None if it escapes — so neither sibling create can be offered out-of-root. (Same lexical primitive, same reason as target_path: these are speculative not-yet-created paths.) Then correct the :2478 comment to reflect that multi-file actions emit more than target_path.

Tests to add (same PR)

In code_action_create_containment.rs, add non-vacuous coverage for the second file — each must return Some today (proving it fails without the fix) and None after:

  • A Livewire action with an in-root target_path but a name whose get_livewire_view_path escapes (e.g. /-rooted or interior-.. name) → None.
  • A BladeComponentWithClass action with an in-root target_path but a name whose get_component_class_path escapes → None.
  • Positive controls for both multi-file types (legit in-root name) → action still offered.

✅ What's Good

  • AC #1 — guard if !path_within_root(&path, &config.root) { continue; } after file_exists_cached in resolve_component_file mirrors the sibling resolve_component_existing_file (:13887) exactly — verified line-by-line: same config.root, same continue. Parity is real.
  • AC #2 — consumer call sites (:17895, :23056, :23124) untouched; the guard lives in the resolver.
  • AC #3/#4 (Option 1) — the resolve-seam negative tests now honestly assert the invariant with the guard documented as the unreachable-today innermost backstop; the false "only path_within_root catches" premise is gone and the assert messages were corrected. Clean execution of Mike's ruling.
  • AC #5 primitive choice — using path_within_root_lexical instead of the literally-named path_within_root is correct and approved, no re-escalation needed: a create target never exists, so fail-closed path_within_root would refuse every create and break AC #6's positive control. The lexical primitive is the only viable one (refuses out-of-root + interior-.., admits speculative in-root, still canonicalizes for symlink escapes). Same class as the Option-1 call — the AC #5 wording should be amended to name path_within_root_lexical; the code is right. Good catch flagging it rather than silently following a self-contradictory AC.
  • The target_path-targeted create-seam tests (out-of-root, interior-.., under-root symlink, positive control) are non-vacuous and well-built.

📋 Non-blocking follow-ups

  • None.

CI is fully green and 0 prior change rounds — this is round 1. Please extend the guard to the sibling create paths, add the two multi-file escape tests, and re-request review.

…tions

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>
@dr-john-h-watson

Copy link
Copy Markdown

Round 2 — Holmes's blocker fixed in this PR (no non-blocking follow-ups were listed).

Blocker: the write-seam guard checked only self.target_path; the multi-file types each emit a second, unguarded create.

  • Livewire → view_uri from get_livewire_view_path (derived from self.name)
  • BladeComponentWithClass → class_uri from get_component_class_path (derived from self.name)

Since name and target_path are independent diagnostic fields and PathBuf::join/push of an absolute segment replaces the base, a forged diagnostic with an in-root target_path + name = "/etc/passwd" escaped via the second create. Fixed by guarding view_path / class_path with path_within_root_lexical(&path, root) and return None on escape, at each branch — same primitive, same reason (speculative not-yet-created paths). Corrected the :2478 comment that wrongly claimed every create materialises only target_path.

Tests (same PR, non-vacuous): in code_action_create_containment.rs —

  • livewire_out_of_root_view_path_returns_none — in-root target_path, escaping name → None (would be Some without the guard).
  • blade_component_with_class_out_of_root_class_path_returns_none — same shape for the class path → None.
  • livewire_in_root_is_offered / blade_component_with_class_in_root_is_offered — positive controls (both files in-root) → action still offered.

Local: cargo fmt --check clean, cargo clippy --all-targets --all-features -D warnings clean, cargo test --all-features green — 1855 + 364 + 80 = 2299, 0 failures (the 364 reflects the +4 new tests). Pushed as fed188a; CI is the real gate.

@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

Round 2 — the round-1 blocker is fully closed. 🔒️

Review Summary

  • Reviewed the containment guards on both seams: the read/resolve seam (resolve_component_file) and the write/create seam (build_code_action). CI fully green (LSP test/fmt/clippy ✅).
  • The round-1 blocker is resolved. The write-seam guard now covers every path a create action emits, not just self.target_path:
    • Livewire — the view_path sibling (derived from the independent name field) is guarded at main.rs:2517 before its ResourceOp::Create.
    • BladeComponentWithClass — the class_path sibling is guarded at main.rs:2592 before its ResourceOp::Create.
    • The misleading "every create materialises only self.target_path" premise in the guard comment was corrected to spell out the multi-file second create.
  • Tests now exercise the fix, non-vacuously. livewire_out_of_root_view_path_returns_none and blade_component_with_class_out_of_root_class_path_returns_none pin an in-root target_path against an escaping name = "/etc/passwd" and assert None — each would return Some(_) (fail) if its sibling guard were removed. Positive controls (livewire_in_root_is_offered, blade_component_with_class_in_root_is_offered) confirm legitimate multi-file creates still resolve.

Acceptance Criteria — all met

  • AC #1 ✅ — if !path_within_root(&path, &config.root) { continue; } after file_exists_cached, mirroring resolve_component_existing_file; config.root + continue exactly as specified.
  • AC #2 ✅ — consumer call sites untouched; guard lives in the resolver.
  • AC #3 ✅ — component_file_navigation_containment.rs with all three required tests (existing-file refusal, in-root positive control, #[cfg(unix)] under-root→outside symlink).
  • AC #4 ✅ — guard exercised; no regressions (CI green).
  • AC #5 ✅ — every ResourceOp::Create target backstopped before construction. Met via the Mike-approved divergence to path_within_root_lexical (a create target never exists, so fail-closed path_within_root would refuse every legitimate create) — settled in round 1, not re-litigated here.
  • AC #6 ✅ — create-seam containment tests for target_path (out-of-root, interior-.., under-root symlink, positive control) plus the two multi-file sibling-escape tests + positive controls.

📋 Non-blocking follow-ups

  • None.

Verdict reached via a four-lens fan-out (AC / correctness / security / test-honesty) over the shared checkout — all four returned clean, no blocker-class findings to verify. Ready for @mikebronner to merge.

@mikebronner
mikebronner merged commit a5753c9 into main Jun 18, 2026
5 checks passed
@mikebronner
mikebronner deleted the fix/199-add-the-fail-closed-pathwithinroot-guard-to-resolv branch June 18, 2026 11:08
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 the fail-closed path_within_root guard to resolve_component_file for containment-invariant uniformity

1 participant