Skip to content

Gate discovered paths in follow_links(true) directory walks against the project root - #229

Merged
mikebronner merged 2 commits into
mainfrom
fix/228-gate-discovered-paths-follow-links-symlink-escape-containment
Jun 18, 2026
Merged

mikebronner merged 2 commits into
mainfrom
fix/228-gate-discovered-paths-follow-links-symlink-escape-containment

Conversation

@mikebronner

@mikebronner mikebronner commented Jun 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #228 — closes the discovered-path leg of the path_within_root containment lineage (#130 → … → #226). PR #227 gated the walk root of scan_dir but deferred gating the paths discovered under WalkDir::follow_links(true): a symlink encountered inside an in-root directory whose target escapes the project root was still followed and its files emitted as candidates / read.

Changes

  • New gate path_within_root_walk_entry(path, root) in path_containment.rs — fail-closed, delegates to path_within_root (canonicalize-both semantics); takes &Path so the module keeps no walkdir coupling. Documented as a fourth named entry point alongside the existing three.
  • scan_dir (component_completion.rs) gains a distinct root: &Path containment param (separate from display_root) and filters every discovered entry through the gate after the cheap type/extension filters, before emitting it. Callers updated: scan_anonymous_dir, scan_class_dir, collect_flux_components, the six get_all_blade_components call sites in main.rs, and the existing unit tests.
  • Deferred-scope comment removed and replaced with the now-applied gate rationale.
  • main.rs follow_links(true) audit (per-site):
    • controllers_dir — gated: it read_to_strings each discovered path (an out-of-root read primitive).
    • view_path, package_path, livewire_path, base_dir — documented, gate not needed: discovered paths become display/relative completion strings only, never read/opened or resolved to an FS primitive at the site (navigation resolves by name through independently containment-gated resolvers).

Acceptance Criteria

  • Shared walk-entry gate added to path_containment.rs (path_within_root_walk_entry), fail-closed, mirroring path_within_root.
  • scan_dir accepts the project root and filters each discovered entry through the gate; all callers updated to supply the root.
  • The deferred-scope comment in component_completion.rs is removed once the gate is applied.
  • Each follow_links(true) walk site in main.rs audited per-site — gated where discovered paths are read primitives (controllers_dir), documented where they don't reach an FS primitive (the four completion walks).
  • #[cfg(unix)] negative-case test: an under-root symlink whose target exists outside the root → escaping file NOT emitted, with a precondition assertion the target resolves outside the root.
  • Positive-control test: an in-root symlink and an ordinary subdirectory still yield candidates (no over-refusal).
  • New tests live under laravel-lsp/src/tests/scan_dir_containment.rs per the lineage convention; gate logic also unit-tested directly in path_containment.rs.

Test Plan

  • cargo test — lib (1898) and binary/src/tests (437) suites green, incl. 4 new scan_dir_containment integration tests + 4 new walk_entry gate unit tests.
  • cargo fmt --check clean.
  • cargo clippy --all-targets -- -D warnings clean (matches CI).
  • The 8 tests/integration_tests.rs failures seen locally are environmental only (gitignored test-project/.env and vendor/); CI bootstraps both (cp .env.example, composer update) before testing.

Fixes #228

mikebronner and others added 2 commits June 18, 2026 12:23
… the project root.

scan_dir walks with WalkDir::follow_links(true), so a symlink inside an
in-root directory whose target escapes the project root was still followed
and its files emitted as completion candidates — the discovered-path leg
PR #227 (issue #226) deferred. Add path_within_root_walk_entry, a fail-closed
canonicalize-based gate mirroring path_within_root, and filter every entry
scan_dir emits through it.

Audit the five follow_links(true) walk sites in main.rs: gate controllers_dir
(it read_to_string's each discovered path — an out-of-root read primitive);
document view_path, package_path, livewire_path and base_dir, whose discovered
paths become display/relative completion strings only and never reach an FS
primitive at the site (navigation resolves by name through independently
containment-gated resolvers).

Fixes: #228

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mikebronner
mikebronner marked this pull request as ready for review June 18, 2026 19:40

@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

  • Reviewed PR #229: adds path_within_root_walk_entry (a fail-closed walk-entry gate) to path_containment.rs, wires it into scan_dir + all callers (scan_anonymous_dir, scan_class_dir, collect_flux_components), gates the controllers_dir walk in main.rs, removes the deferred-scope comment, and audits the four remaining follow_links(true) sites with per-site "no gate needed" justifications.
  • All 7 acceptance criteria met (7/7). Notable deliberate divergence on AC #1: the gate accepts &Path rather than a walkdir::DirEntry, keeping path_containment free of a walkdir dependency. Nothing the criterion cared about is dropped — it still mirrors path_within_root's fail-closed semantics exactly — and it's the cleaner module boundary.
  • Containment logic is sound: canonical_containment canonicalizes both sides and uses component-wise Path::starts_with (immune to the /root-sibling prefix attack); every error path (canonicalize failure, vanished root, non-canonical root) refuses — fail-closed. The gate is applied to the discovered entry, which canonicalize() resolves through the symlink to its real target, so it gates the right path.
  • Tests verified: the #[cfg(unix)] negative cases place a real under-root symlink whose target lives outside the root, run the real walk, and assert the escaping file is dropped — each with the required precondition that the target exists outside root, plus an in-root sentinel proving the walk actually ran. Positive controls confirm in-root symlinks/subdirs still yield candidates (no over-refusal). New tests registered in tests/mod.rs. CI green (LSP test/fmt/clippy).

Verification notes

  • I checked a flagged "out-of-root absolute path disclosure" at the package_path walk (main.rs:~12497): refuted. display_path is only built inside if let Ok(relative) = path.strip_prefix(package_path), and WalkDir's follow_links(true) yields the nominal traversal path (<package_path>/<symlink>/<file>), not the resolved target — so the displayed string is always in-root and no out-of-root path leaks. The "no gate needed" audit comment at this site is correct.
  • The canonicalize-then-read TOCTOU window at the controllers_dir gate is the lineage's established, accepted pattern (every guard shares it) and is explicitly within the documented threat model — not a defect, and a strict improvement over the prior ungated read.

What's good

  • Clean per-site audit discipline: each of the five follow_links(true) sites is either gated (with a read primitive) or documented with a concrete reason why discovered paths never reach an FS primitive. That's exactly the invariant-class closure #228 asked for.
  • Honest, non-tautological tests — they exercise the real walk and prove the gate fired (target-exists precondition + in-root sentinel), not hand-rolled path math.

📋 Non-blocking follow-ups

  • The controllers_dir walk in main.rs (~10994–11008) is a confirmed gated site with a real out-of-root read primitive (read_to_string), but has no dedicated per-site integration test — only the gate function is unit-tested and only scan_dir is integration-tested. The lineage convention is a test per gated site; AC #5/#6's "at minimum scan_dir" wording lets this pass, but a #[cfg(unix)] negative+positive test mirroring scan_dir_containment.rs for the controllers walk would close it. — laravel-lsp/src/main.rs:11005 — keeps the lineage's "tested at every gated site" invariant genuinely true. (Tracked as a follow-up issue.)

Ready for @mikebronner to merge.

@mikebronner
mikebronner merged commit 996b43d into main Jun 18, 2026
5 checks passed
@mikebronner
mikebronner deleted the fix/228-gate-discovered-paths-follow-links-symlink-escape-containment branch June 18, 2026 20:11
mikebronner added a commit that referenced this pull request Jun 18, 2026
…s gate

Cover the discovered-path containment gate on the `controllers_dir`
`follow_links(true)` walk in `check_controller_view_variable` (main.rs) — the
confirmed gated site PR #229 introduced but left without a dedicated
end-to-end test (issue #230, Holmes's review follow-up).

Mirrors tests/scan_dir_containment.rs:
- #[cfg(unix)] negative: a controller reached through an under-root symlink
  whose target escapes the project root is NOT read, with precondition
  assertions that the target resolves outside root plus an in-root sentinel
  proving the walk still runs.
- #[cfg(unix)] positive control: an in-root symlink (target inside root) is
  still read — the gate does not over-refuse.
- positive control: an ordinary nested in-root subdir controller is still read.

Verified discriminating: neutralizing the gate fails the negative case.

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

* chore: start work on #230

* test: ✅ add per-site containment test for controllers_dir follow_links gate

Cover the discovered-path containment gate on the `controllers_dir`
`follow_links(true)` walk in `check_controller_view_variable` (main.rs) — the
confirmed gated site PR #229 introduced but left without a dedicated
end-to-end test (issue #230, Holmes's review follow-up).

Mirrors tests/scan_dir_containment.rs:
- #[cfg(unix)] negative: a controller reached through an under-root symlink
  whose target escapes the project root is NOT read, with precondition
  assertions that the target resolves outside root plus an in-root sentinel
  proving the walk still runs.
- #[cfg(unix)] positive control: an in-root symlink (target inside root) is
  still read — the gate does not over-refuse.
- positive control: an ordinary nested in-root subdir controller is still read.

Verified discriminating: neutralizing the gate fails the negative case.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

Gate discovered paths in follow_links(true) directory walks against the project root (symlink-escape containment, scan_dir + siblings)

1 participant