Add the fail-closed path_within_root guard to ComposerAutoload::resolve (PSR-4 FQCN → file resolution containment) - #225
Merged
mikebronner merged 2 commits intoJun 18, 2026
Conversation
…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
marked this pull request as ready for review
June 18, 2026 16:50
There was a problem hiding this comment.
✅ Approved
Review Summary
- Adds the fail-closed
path_within_rootguard toComposerAutoload::resolve, closing the PSR-4 FQCN → file out-of-root read primitive (issue #222, containment lineage #130 → … → #218 → #222). - All six acceptance criteria met. AC #2/#3 are met by a deliberate divergence from the literal wording, and it's a strict improvement — see below.
- CI green (LSP test/fmt/clippy, Analyze, wasm/clippy). Guard verified to sit at
composer_autoload.rs:103, beforecandidate.exists()at line 106, withcontinueto the nextsource_root— so no out-of-root path is everstat'd-and-returned.
Acceptance criteria
- AC #1 — guard in the resolve loop: ✅
if !path_within_root(&candidate, &self.project_root) { continue; }atcomposer_autoload.rs:103, after candidate construction (l.93) and beforecandidate.exists()(l.106). - AC #2 — root threaded into
resolve: ✅ by divergence — the PR took the AC's blessed alternative (store on the struct,project_root: PathBufat l.48, populated in the soleloadconstructor at l.211) rather than a per-call&Pathparameter.for_projectinherits viaload. The doc comment argues this is safer ("resolution is bound to exactly the root the PSR-4 mappings were resolved against — a caller can't handresolvea mismatched root"). Nothing the AC cared about is dropped; the guard runs against the correct root on every path. - AC #3 — update call sites: ✅ by divergence — because the root lives on the struct,
resolve's signature is unchanged, so the two callers inclass_locator.rs(find_php_class_file,find_php_class_file_in_app_or_vendor) need no edit.grepconfirms those are the onlyresolvecall sites; both already passroottofor_project, which is exactly where the binding now lives. The AC's intent — every resolution path guarded against the right root — is fully satisfied. - AC #4 — three containment tests: ✅ All present in
tests/composer_autoload_containment.rsand discriminating: each negative writes the escaping target to disk and asserts via precondition that the candidate canonicalizes to it, so aNonecan only come from the guard (would returnSome(out-of-root)if the guard were removed). Negative..-FQCN (l.49), positive control (l.90),#[cfg(unix)]under-root-symlink (l.119, with the extra!starts_with(root)precondition). - AC #5 —
tests/mod.rs: ✅mod composer_autoload_containment;added alphabetically (l.13). - AC #6 — no regression: ✅ Only the new guard changes behaviour; existing tests resolve in-root files that pass the guard. CI confirms.
What's Good
- The struct-stored-root choice is the right call and well-justified in the doc comment — it makes a mismatched-root bug structurally impossible rather than relying on every caller to pass the right root.
- Tests are genuinely honest: the precondition
.unwrap()on the canonicalized candidate is self-enforcing — if the fixture were misbuilt the test panics rather than passing for the wrong reason. project_rootstored uncanonicalized is correct and documented —path_within_rootcanonicalizes both sides, so the macOS/var→/private/varcase is handled.
📋 Non-blocking follow-ups
resolve_namespace_dirsis the next unguarded surface in this lineage —composer_autoload.rs:123-154. It buildsdir = source_root.join(&rel)and returns it on a baredir.is_dir()(l.146-147) with nopath_within_rootguard and no access toself.project_root. Two downstream legs then touch the result out-of-root:salsa_impl.rs:2355(dir.join(class).with_extension("php"); file.exists()) andmain.rs:12576→scan_dir(WalkDir::new(dir).follow_links(true), no containment on discovered paths). (A third leg,resolve_component_existing_fileinmain.rs, does guard post-stat.) Same defense-in-depth class as this issue, low practical risk under the LSP threat model — filed as its own anchor per the lineage's one-surface-per-issue pattern (issue opened by me, linked below). A test for an out-of-rootsource_rootmapping value (vs...in the FQCN) belongs there too — that vector is already covered by this PR's guard at runtime but is not separately pinned.
Ready for @mikebronner to merge.
8 tasks
mikebronner
deleted the
fix/222-add-the-fail-closed-pathwithinroot-guard-to-compos
branch
June 18, 2026 17:16
mikebronner
added a commit
that referenced
this pull request
Sep 8, 2026
…#382) Version 1.13.0 failed to compile for any no_std consumer. Commit 9f25466 added a bounded range to hold the transitive resolution at 1.12.0, as an explicitly temporary measure. Lokathor/tinyvec#226 merged on 2026-09-04 and closed issue #225. Version 1.13.1 published two minutes later, and 1.13.2 followed. The call in src/tinyvec.rs is now qualified as alloc::vec![...]. The constraint is removed outright, not relaxed to a floor. A floor would keep a permanent phantom direct dependency on a crate this project never uses. It buys nothing over cargo's max-version selection, since 1.13.0 is unreachable once the upper bound is gone. Verified from a regenerated lockfile rather than from the manifest edit: a fresh resolution selects tinyvec 1.13.2, reached only through sqlx -> sqlx-postgres -> stringprep -> unicode-normalization. Fixes: #377
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements #222 — closes the last unguarded FS-touching resolver in the
path_within_rootcontainment lineage (#130 → #143 → #148 → #194 → #199 → #201 → #214 → #218).ComposerAutoload::resolvemapped a PSR-4 FQCN to a candidate file by splitting the post-prefix remainder on\andPathBuf::push-ing each segment onto the mappedsource_root, then returned the candidate on a barecandidate.exists()— with no containment guard.push/joinappends a..segment literally, andsource_rootderives from a PSR-4 mapping value incomposer.json/vendor/composer/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 thenstat'd and returned — an out-of-root read primitive. This is the higher-priority branch inclass_locator.rs(it runs before the heuristicfind_php_class_file_by_fqcnthat #218/PR #221 guarded).Changes
composer_autoload.rs— gate every candidate inresolvewith the fail-closedpath_within_root(&candidate, &self.project_root)guard before the on-diskexists()check; a candidate that canonicalizes outside the root (or can't be proven in-root) is skipped to the nextsource_root.project_rootstored on the struct at construction (inload, whichfor_projectfeeds) — see the design note below.tests/composer_autoload_containment.rs(new) — follows thefqcn_resolution_containment.rsconvention; wired intotests/mod.rs.Design note — project root threading (AC bullets 2 & 3)
AC bullet 2 offered two options: a new
&Pathparameter onresolve, or storing the root onComposerAutoloadat construction. I chose struct storage because:ComposerAutoloadis a per-project, process-cached (&'static) struct whose PSR-4 source roots are already computed relative to the project root. Binding the guard to the stored root meansresolvealways checks against exactly the root its data was loaded for; a caller can't pass a mismatched root (which the parameter approach permits).resolveunit tests.Consequently AC bullet 3 (update call sites) is satisfied by construction, not by signature change: the root flows into
resolveviaload/for_project, both of which already receiveproject_root. I verified the onlyComposerAutoload::resolvecallers arefind_php_class_file(class_locator.rs:42) andfind_php_class_file_in_app_or_vendor(:79) — themain.rs/command_disk_cache.resolve(...)calls are a different command-index method.Acceptance Criteria
resolve, gate each candidate with the fail-closedpath_within_root(&candidate, &project_root)guard beforeexists(), skipping to the nextsource_rootonfalseresolve— stored on theComposerAutoloadstruct at construction (the struct option of bullet 2)load/for_project); confirmed no otherComposerAutoload::resolvecallers existtests/composer_autoload_containment.rswith:..-escaping PSR-4 FQCN →None, with a precondition asserting the file exists outside rootSome(path)#[cfg(unix)]negative: under-root symlink in the PSR-4 source path resolving outside root →None, precondition confirms the candidate canonicalizes outside roottests/mod.rsupdated withmod composer_autoload_containment;composer_autoload/tests.rsunit tests orclass_locator_and_properties.rsintegration testsTest Plan
cargo fmtclean,cargo checkclean,cargo clippy --all-targetsclean#[cfg(unix)]symlink case)composer_autoloadunit tests (10) +class_locatortests pass unchangedintegration_tests.rsbinary (env-file / route /test-projectfixture tests) reproduce on cleanmainand are unrelated to this changeFixes #222