Repository navigation
Route the external-PHP disk loader through the path_within_root containment guard - #366
Conversation
Watson-Branch: #364
The blockAC bullet 1 orders the containment guard before the loader touches The evidence
That branch reads Why the tension is irreducibleThe two orderings differ for exactly one path: client-pushed, present in
Telling the two apart needs a lexical containment test next to the canonical one. AC bullet 2 forbids that: no Options
Recommendation: 3. It is the only option that fails closed on the out-of-root case and keeps #361. Each guard answers the question its own branch asks: the ownership branch serves client text and needs containment without a disk probe, the disk branch reads real bytes and needs proof. Bullet 2 bans the lexical guard as a substitution for the read guard. Here it is an addition to a branch the read guard never covered. If you reject the waiver, I will take option 1 and pin the #361 narrowing with a test and a comment. One correction to the ACBullets 1 and 3 name a field |
|
Take option 3, with one correction: the ownership branch uses Your analysis is right, and I verified every claim against the tree. The ownership fast path at I also confirmed the hazard is not theoretical. Why the lexical guard is the wrong instrument
The ownership branch performs no read. It does emit. So the lexical guard is banned for this branch by the module's documented contract, not just by AC bullet 2. Why
|
`SalsaActor`'s struct literal lived inside the `std::thread::spawn` closure, so no test could ever hold an actor and drive `&mut self` methods against it. Lift it into `SalsaActor::new`; `spawn` keeps the threading, `new` owns the fields. No behaviour change. Prerequisite for #364, whose acceptance criteria require assertions on `files` and `external_php_text` — neither of which any `SalsaHandle` message exposes.
`ensure_external_php_source_loaded` read file metadata and content straight from disk and registered the result as a `SourceFile` that `handle_blade_backing_class_resolution` then emits as a goto-definition target — with no containment check of its own. Every caller pre-vets its paths today, so nothing escapes; the hazard is that the guard lives in the callers, and a new read site does not inherit the guard its neighbours carry. That shape has already become a security fix three times here (#294, #348 rounds 1 and 2). The guard splits by branch, because the branches ask different questions: - The client-ownership fast path reads no disk but emits the path, so it takes `path_within_root_emit_safe` — refuses out-of-root on the lexical pre-gate with no stat probe (#145), still admits a genuinely-absent in-root buffer (#361). - The disk branch reads real bytes, so it takes the fail-closed `canonical_within_root_registration`, and both filesystem calls go through the verified canonical path it returns rather than re-deriving one that a swapped symlink could redirect. Root unknown short-circuits first, before any state is read or mutated. Three test harnesses registered the backend's root but never the actor's `config_root`; `register_project_files` does not set it. Production always registers config first, so this was fixture drift, not a behaviour change — corrected in all three. Fixes #364
Round-1 review of my own guard: gating `ensure_external_php_source_loaded` against `config_root` alone silently dropped every backing class inside a module symlinked in from a composer path repository. `expand_module_dirs` admits that layout on purpose (`config.rs:1180`) and `livewire_namespaces::contained_class_path` gates its registrations against the owning module for exactly this reason (`livewire_namespaces.rs:205`) — so the paths were minted legally and then refused at the read. There is no "component not found" diagnostic, so the only symptom was goto and hover quietly doing nothing. Both branches now gate against `config::owning_module(&self.module_dirs, path)`, falling back to the root. This is not a relaxation: for a module path it TIGHTENS the guard, because a candidate lexically under a module must canonicalize inside THAT module — one reaching into a sibling module or into bare `app/` is refused despite being in-root. `owning_module` collapses `..` before its prefix test, so a traversing path cannot elect itself a laxer gate. With no modules configured the gate is the root and behaviour is unchanged. Five regression tests, each verified to fail under the mutation it pins: the symlinked module loading, the escape out of an elected module gate, the reach back into `app/`, the traversal, and the no-modules case.
`main` now carries the #364 containment guard (#366), which the ownership release work predates. Two conflicts, both mechanical: - `salsa_impl.rs`: main extracted the actor's struct literal into `SalsaActor::new`; this branch added an `external_php_open_buffers` field to that literal. Resolved by taking `SalsaActor::new` and carrying the new field into the constructor. - `tests/mod.rs`: two modules registered on the same line. Kept both. The merge then failed five of the seven ownership-release tests. `backend_for` primed the backend's `root_path` but never the ACTOR's `config_root`, and the guard from #364 fails closed without one — so every load returned `None` for want of a root rather than for the reason the test was about. `register_project_files` does not set `config_root`; only `register_config_files` and the cached-config request do, and production always registers config first. The same fixture drift was corrected in three other harnesses when #364 landed; this is the fourth. Verified: 3616 tests pass, clippy clean, fmt clean.
Summary
SalsaActor::ensure_external_php_source_loadedread file metadata and content straight from disk and registered the result as a SalsaSourceFile, whichhandle_blade_backing_class_resolutionmaps intoBladeBackingResolutionData::files— the goto-definition target list. It carried no containment guard of its own.Nothing escapes today: render-index candidates come from the project's own directory walk, and Livewire candidates come from
livewire_resolver::resolve_component, which gates every path segment throughnaming::is_safe_path_segment. The hazard is structural — the guard lived in the callers, and a new read site does not inherit the guard its neighbours carry. That exact shape has already become a security fix three times in this repo (#294, #348 round 1, #348 round 2).The guard now lives at the primitive.
Fixes #364
The guard, split by branch
The two branches ask different questions, so they take different guards. The split is the AC bullet 2 waiver granted in review — the read branch keeps the full fail-closed guard, and the emit-safe guard is an addition to a branch the read guard never covered.
path_within_root_emit_safe. Its lexical pre-gate refuses an out-of-root candidate with nostatprobe (Run slot-navigation containment guard before file_exists_cached to close out-of-root existence oracle #145). ItsNonearm admits a genuinely absent in-root path — the unsaved-buffer case Component-member navigation: follow-ups from #335 #361 exists to protect — while refusing a dangling under-root symlink and every non-NotFoundlstat error. The fail-closed read guard cannot serve this branch: it refuses exactly the momentarily-absent buffer, which would reintroduce Component-member navigation: follow-ups from #335 #361. There is a test that proves this.canonical_within_root_registration. Documented for precisely this shape: a path minted from discovered source data and then read. Keeps the lexical pre-gate, fails closed on anything it cannot canonicalize.config::expand_module_dirsadmits a module directory whose real target sits outside the project on purpose — that is the composer path-repository layout — andlivewire_namespaces::contained_class_pathgates the registrations that mint these paths against the same owning module so they survive. Gating this read against the root alone dropped every backing class inside such a module, silently. The swap does not loosen the guard; for a module path it tightens it, because a candidate lexically under a module must canonicalize inside that module, so one reaching into a sibling module or into bareapp/is refused despite being in-root.config::owning_modulecollapses..before its prefix test, so a traversing path cannot elect itself a laxer gate, and with nomodules.pathsconfigured it never matches — the gate is exactly the root and behaviour is unchanged.real, the verified canonical path the guard returns — never re-derived frompath, so a symlink swapped between guard and read cannot hand back a target the guard never approved.pathstays the key forself.filesandexternal_php_textso caller lookups still resolve.Tests
Every rejection test below was verified to fail with the guards removed; both positive tests still pass without them, so they pin non-regression rather than the guard.
salsa_impl/tests.rs— direct-actor, asserting the internal maps (AC bullets 3, 6, 7). NoSalsaHandlemessage exposesfilesorexternal_php_text, soSalsaActor::newwas extracted fromspawn(first commit, no behaviour change) to let the in-crate test module hold an actor.loader_refuses_an_out_of_root_candidate_and_caches_nothingfiles/external_php_textentryloader_refuses_a_candidate_when_the_root_is_unknownconfig_rootshort-circuit — proved by loading the same path once the root is setloader_refuses_an_under_root_symlink_that_escapesretargeting_a_symlink_out_of_root_cannot_disturb_the_cached_loada_client_pushed_out_of_root_path_is_refused_even_when_cacheda_client_pushed_in_root_buffer_still_answers_while_its_file_is_absenta_backing_class_inside_a_symlinked_module_still_loadsa_path_escaping_its_own_module_is_still_refuseda_module_path_reaching_back_into_the_app_is_refuseda_traversing_path_cannot_elect_a_module_as_its_gate..is collapsed before the gate is chosenwithout_modules_the_gate_is_the_project_rootsrc/tests/external_php_loader_containment.rs— through the real pipeline (AC bullets 4, 5, 8). Candidates enter viaSalsaHandle::blade_backing_class_resolution, the same entry pointmain.rsuses, and travelblade_backing_class_filesrather than being handed to the loader directly. Each rejection test asserts the fixture's canonicalized path really is outside the root first, so it cannot pass vacuously. The positive test compares the resolved source against bytes read back off disk, not a literal duplicated on both sides.Fixture correction
Three test harnesses set the backend's
root_pathbut never the actor'sconfig_root.handle_register_project_filesdoes not set it — onlyhandle_register_config_filesand theRegisterCachedConfigrequest do. Production always registers config first (main.rs:8229for the mid-session root change,main.rs:23644for startup, withRegisterCachedConfigcovering the warm-cache branch), so this was fixture drift, not a behaviour change:src/tests/render_index_and_buffer_integrity.rssrc/tests/component_ancestor_navigation.rssrc/tests/component_member_navigation.rsSibling-site audit (AC bullet 9)
Every site in
salsa_impl.rsthat reads file content/metadata from disk, or reads/writesself.files, with its containment status.Direct filesystem reads
2057,2076TranslationCache::ensure_file/ensure_dirpath_within_rootimmediately above each (#248)2120TranslationCache::ensure_configconfig_value, whose only caller iscompletion_locale, which passes the two literals"app.locale"/"app.fallback_locale".groupis therefore always"app"; no project-derived text reachesconfig_group_files2537TranslationCache::ensure_providervendor/andapp/Providers/. This PR follows its doc-comment precedent10099,10110ensure_external_php_source_loaded11360ensure_file_registered11936handle_resolve_facade_receiver_atclass_filecomes fromclass_locator::find_php_class_file_in_app_or_vendor, which appliespath_within_rootto every candidate (class_locator.rs:405) and revalidates cache hits with it (:163,:234)self.filesaccesses2009,2014,2035,2052areTranslationCache::files(aHashMap<PathBuf, LangFile>), a different map fromSalsaActor::files; its read is guarded at2057. On the actor:9146RemoveFilehandler9816,9823handle_update_file9997,10013ensure_blade_source_registered10076,10103,10118,10122,1012810133,10154handle_get_php_assignments,handle_get_document_symbols10209,11594,11687,11899,11976ensure_file_registered11359ensure_file_registeredensure_file_registered— resolved as contained, with a commentAll five call sites (
handle_get_patterns,handle_find_magic_member_references,hover_for_magic_member,handle_resolve_facade_receiver_at,handle_magic_member_rename_data) pass the request's owntextDocument.uri— the document the client already has open and is asking about. That is not a path minted by joining project-derived text onto a directory, so there is no traversal to fence: the client supplied the path, holds the file open, anddid_open/did_changealready register arbitrary client paths throughhandle_update_file. The text read is parsed for the answer; the path itself is never emitted as a new navigation target.Guarding it would also be a behaviour change rather than a hardening — a file legitimately open outside the workspace root would stop answering hover and goto entirely. The rationale is recorded as a doc comment on the function, following the
ensure_providerprecedent.Review round 1 — the module-gate regression
The first push gated against
config_rootalone. That silently dropped every backing class inside a module symlinked in from a composer path repository — the exact silent-failure modelivewire_namespaces.rs:205records having already been made and fixed one module over. Caught by adversarial review, reproduced with a failing test, fixed by the owning-module gate described above.Every containment test was then verified against two mutations — reverting the gate to root-only, and removing both guards. That sweep also caught one new test passing under both mutations: the traversal case had been built on a symlinked module directory, where the OS resolves an interior
..against the link's target, so the escape landed nowhere andmetadata()failed regardless of any guard. Rebuilt on a real directory so the escape target is genuinely readable; it now fails when the guard is removed.Verification
cargo test— 3609 passed, 0 failedcargo clippy --all-targets— cleancargo fmt --check— clean