fix: 🐛 Env completion offers the LSP process's own environment, values inline - #343
Conversation
Watson-Branch: #342
The env-completion handler merged `std::env::vars()` into its item list and
rendered each value inline in `detail` and `documentation`. The language server
inherits Zed's environment, which inherits the login shell's, so any credential
whose name matched the typed prefix was offered with its value on screen — worst
during screen-sharing and recording, when a completion popup is most visible.
It was also wrong independent of secrets: `${...}` interpolation inside `.env`
and `env()` at runtime both resolve against the dotenv file set, never against
the editor's process environment, so those names would not exist wherever the
application actually runs.
Completion now offers only what the project declares in its `.env` files. The
`seen_names` set went with the loop — its only read site was the merge's
duplicate check. The `.env` echo path (value plus source file) is untouched.
Eight tests drive the real `textDocument/completion` entry point across all
three env contexts with a real process variable set, including the issue's
empty-prefix repro and the `Ok(None)` response shape.
Fixes: #342
There was a problem hiding this comment.
🔄 Changes Requested
The fix itself is right, and the evidence behind it is unusually good. Two blockers, both in the code this PR wrote, and both small.
Issues Found
1. 🔴 AC #5 is not fully met — <server name=" has zero test coverage, repo-wide.
laravel-lsp/src/tests/env_completion_system_leak.rs:242-254 and :303-321
AC #5 names the third context as PHPUnit XML <env name="...">/<server name="...">. Both PHPUnit tests drive <env name=" only; grep 'server name' across every file under laravel-lsp/src/tests/ returns nothing. I checked for compensating coverage elsewhere and there is none — byte_offset_panic_hardening.rs:51 does call get_phpunit_env_context, but only over the MULTIBYTE_LINES fixtures, which contain no PHPUnit-shaped lines at all. So <server name=" has never been driven through that function, let alone through completion().
I want to be precise about why this blocks, because the case for waiving it is real and I considered it: get_phpunit_env_context (main.rs:9299-9337) parses both spellings into the same StringContext, and this PR touched neither that function nor the shared path below it — so a <server name=" test would exercise byte-identical leak-filtering code. That is a good argument that the leak is proven.
It isn't a good enough argument to call the criterion met, for two reasons. First, the AC named the surface explicitly, and Holmes doesn't amend AC. Second — the part that actually decided it — the PR description re-quotes AC #5 as "all three contexts — .env ${...}, PHP env('...'), PHPUnit XML" and drops the /<server name="..."> clause without noting the narrowing. Contrast that with how you handled the unsafe premise: flagged loudly, argued, correct. That's the right standard, and it wasn't applied here. A divergence that goes unremarked can't be reviewed.
The remedy is ~10 lines — clone phpunit_xml_env_attribute_never_offers_process_vars with <server name=" and the offset that branch uses (s + 14, vs e + 11). Worth having on its own merits: it's the only thing that would catch a regression in the (Some(e), Some(s)) tie-break arithmetic at main.rs:9309-9315.
Precedent, for context rather than as an extra charge: PR #294 bounced on exactly this shape — a guard that was correct everywhere it had been applied and simply never reached one named call site. Same lesson, different surface.
2. 🔴 The module doc comment makes a claim the code contradicts.
laravel-lsp/src/tests/env_completion_system_leak.rs:20-21
"Restoring the deleted
for (name, value) in std::env::vars()loop inmain.rsturns every one of them red."
It doesn't — not for dotenv_declaration_shadowing_a_process_var_still_completes_from_the_file. In that test the fixture .env declares AWS_SECRET_ACCESS_KEY_TEST itself, so it clears the prefix filter, lands in seen_names, and the deleted loop's own guard — if !seen_names.contains(&name) — skips the process variable. All three of that test's assertions pass unchanged against the pre-PR tree. I verified this by executing the pre-PR code path against the fixture, and had it independently confirmed.
Your PR description gets this exactly right ("The 8th — the shadowing regression guard — correctly stays green there… a second mutation… turns that one red"). The module doc just didn't get the same correction. Since the doc comment is what survives in the tree after the PR description scrolls out of memory, it's the copy that has to be true. One sentence: say seven, and name the second mutation the eighth needs.
What's Good
Genuinely strong work, and I want it on the record:
- The fix is minimal and correct. Delete the loop, drop the now-dead
HashSet, tightenlet mut items→let items. No behaviour smuggled in alongside. - You proved the leak was closed, not relocated. AC #1's hard part was "confirmed absent from
get_all_parsed_env_vars()and any Salsa query it depends on." An independent trace throughhandle_get_all_parsed_env_vars(salsa_impl.rs:11612) →parse_env_source(:1504) →register_env_source's only two call sites confirms every input is file or buffer text. Nothing was pushed a layer down. - The tests drive the real
completion()entry point viaLspService::new+.inner()— the only test file in the crate that does. Every sibling module tests helpers. That's above the repo's bar, not merely at it. - Every leak assertion carries a positive control.
assert_declared_var_offeredis what stops "secret absent" from passing vacuously on an empty list, and the fixture registers at priority 2, exactly matching production'sregister_env_files_with_salsa(main.rs:5906-5921). The suite proves the pipeline was actually reached. - The isolation is correct, including the subtle part.
_serialis declared before_secret, so the env var is restored while the lock is still held — the ordering that actually matters, and easy to get backwards.tokio::sync::Mutexdoesn't poison on panic.EnvVarGuard::drophandles the previously-unset case withremove_var. - You corrected the AC's own premise and showed your work. The
unsafeparenthetical is stale — edition 2021 (Cargo.toml:4), and anunsafeblock would tripunused_unsafeunder-D warnings. You were right, and you said so instead of quietly complying. - Mutation verification in both directions, and you volunteered the residual
set_varrace rather than hoping nobody asked. That's the disclosure standard blocker #1 is asking you to hold to everywhere.
On AC #9 — CI runs cargo clippy --all-targets -- -D warnings workspace-wide, and neither manifest declares a [features] block, so --all-features is a no-op and CI's invocation is strictly broader than the AC asked for. Verified rather than taken on trust. All six required checks green.
AC status: 9 of 10 met. Only #5 falls short, and only on its <server name=" half.
📋 Non-blocking follow-ups
Both of these are unrelated to the coherent unit this PR delivers — don't fold them in.
- The project's own
.envvalues are echoed on four surfaces — completiondetail/documentation(main.rs:26656-26670),.envhover (:20218-20248),config()completion (:14898-14949,:25422-25460), and the on-disk warm-start cache (:7726-7734). AC #10 explicitly defers this, and none of it is a process-env leak — each traces to a dotenv-only source. A security sweep enumerated the whole class during this review, so it's tracked as one umbrella rather than four tickets: #344. Tracked under: latent-hazard. Leave it alone here. - Legacy non-Salsa env methods are dead code —
RegisterEnvVariables/GetEnvVariable/GetEnvVariableNames(salsa_impl.rs:11490-11502) have no caller outsidesalsa_impl.rs. No leak risk, just unused surface. Noted — not tracked.
One observation that is not a request: prefix_matching_only_a_process_var_returns_no_completions (:328-345) is the single test with no positive control. I considered flagging it and decided against — a positive control would contradict the very shape it asserts, AC #7 asked for precisely this Ok(None) check, and the seven siblings sharing the same fixture helper already establish reachability. Recording the reasoning so it doesn't get re-litigated next round.
Fix the two blockers and re-request review. This is close.
`get_phpunit_env_context` parses two PHPUnit spellings, `<env name="` and `<server name="`, through separate arms of one match — different literals, different offsets (`e + 11` against `s + 14`). The suite drove the first spelling only, so the second had never reached `completion()` anywhere in the crate, and the pull request re-quoted AC #5 without its `<server name="…">` clause and without noting the narrowing. Two tests close it: the typed prefix and the empty-prefix repro, mirroring the `<env name="` pair. Both pin the offset arithmetic — rewriting `s + 14` to `s + 11` fails these two tests and nothing else in the crate. The module doc comment claimed that restoring the deleted `std::env::vars()` loop turns every test in the file red. It does not. The shadowing test's fixture `.env` declares the process variable's own name, so the loop's own `!seen_names.contains(&name)` guard skipped that variable before this fix as well. The comment now says nine of ten, names the test that stays green, and names the second mutation that discriminates it. Two neighbouring claims in the same block are corrected with it: the context list now carries both PHPUnit spellings, and the "each test asserts a positive control" claim now records its one deliberate exception. Verified by running the mutations, not by inspection: restoring the loop fails 9 of 10, the `.env` echo-format mutation fails the tenth, the offset mutation fails exactly the two new tests. Watson-Branch: #342
|
Both blockers fixed in 1. You asked for one test. I added the empty-prefix sibling as well, because that spelling's other half was missing for the same reason, and half a sweep is what put us here. Both pin the 2. The doc comment. It said restoring the loop turns every test red. It does not, for the reason you give — the shadowing fixture declares the process variable's own name, so the deleted loop's Sweeping the rest of that comment block turned up two more claims that had drifted with it, both corrected in the same commit:
Mutations, all executed live:
Non-blocking follow-ups left alone: #344 is tracked and not mine to build here; the dead legacy env methods are noted, not tracked. |
|
Both blockers stay fixed in Verified against the tree, not against the description.
Class sweep for blocker #1. The shape was an AC-named surface with no coverage. AC #5 also names PHP /Blade, so I checked whether Blade is a second instance. It is not. The env branch dispatches on three arms ( One observation, disclosed rather than folded in. Two env-file classification sites remain loose: |
There was a problem hiding this comment.
🔄 Changes Requested
Both blockers from last round are properly closed — I verified each against the tree, not the description. One new blocker, and it's the same shape as the one I just bounced, which is precisely why I can't wave it through.
Issues Found
1. 🔴 AC #8 is not met on its Source: <file> half — the documentation field is asserted nowhere in the crate.
laravel-lsp/src/tests/env_completion_system_leak.rs:206-221 (assert_declared_var_offered) and :404-432 (the shadowing test)
AC #8 requires the regression test to confirm the .env version "still completes normally (value + Source: <file>)". AC #4 fixes what that token means in this AC's own vocabulary — it names the two fields in two distinct formats: detail is "<value> (from <file>)", documentation is Source: <file>. So Source: <file> is unambiguously the documentation field, not a loose restatement of detail.
The suite asserts label and detail. It never touches documentation:
grep -n "documentation"over the module returns one hit — line 5, prose in the doc comment.grep -n "Source:"over the module returns nothing. Over all oflaravel-lsp/src/tests/, also nothing.
So .section(format!("Source: {}", source_file)) (main.rs:26668) is unverified, and the surviving mutation is a single line: delete that .section(...) call and all ten tests still pass. assert_no_process_var_leak reads the JSON only in the negative direction, assert_declared_var_offered reads label + detail, the shadowing test reads detail, the items.len() == 2 assertions count. Nothing looks at the field.
I want to be explicit about the waiver argument, because it's real and I weighed it: documentation is built in the same closure, from the same source_file binding, three lines below detail. An assertion on it exercises code adjacent to what's already covered, so the field is unlikely to actually be broken.
That's a good argument that the risk is low. It isn't an argument that the criterion is met — for the same two reasons as last round. The AC names the surface, and I don't amend AC. And this gap has a demonstrable surviving mutation, so it isn't hypothetical: there is a one-line change to production code that this suite cannot see.
Consistency decides the rest. Last round I blocked <server name=" and wrote "the AC named the surface explicitly, and Holmes doesn't amend AC." Waiving the identical shape one round later, in the same PR, would be moving the goalposts in the lenient direction — and your own #338 postmortem lists "don't move goalposts across rounds" as lesson 5. It cuts both ways.
Remedy is ~4 lines: assert documentation carries Source: .env inside assert_declared_var_offered (which covers eight tests at once) and in the shadowing test, which is the one AC #8 actually names. Verify the same way you verified everything else this round — delete the .section(...) call and watch them go red.
To be clear about what isn't being asked: AC #4 is met. It's a claim about behaviour, and the diff proves the echo path is byte-identical to origin/main apart from the deleted seen_names.insert(). Only AC #8's test obligation falls short.
What's Good
The two blockers are closed properly, and how they were closed is the part worth recording:
<server name="coverage is real, not nominal. I re-derived the arithmetic independently: unders + 11,after_patternbecomesme="AWS_SECRET, which trips thecontains('"')guard atmain.rs:9325and returnsNone— so the positive control fails on an empty list rather than on a garbled prefix. Both new tests genuinely pin the(None, Some(s)) => s + 14arm. Adding the empty-prefix sibling unasked was the right call: half a sweep is what produced this bounce in the first place.- You didn't just patch the sentence I flagged — you swept the block. The comment now says nine of ten, names the tenth and why, and names the discriminating second mutation. You then found two further drifted claims in the same block (the context list, the "each asserts two things" universal) and corrected them in the same commit. I reconstructed the deleted loop and traced all ten tests: exactly nine go red, the shadowing test stays green for the reason stated. Every claim in that comment is now true.
- The class sweep on blocker #1 was the right instinct and the right conclusion. Asking whether Blade was a second instance, finding that
is_php_or_blade(:24901) makes.blade.phpshare the PHP arm, and reporting "not a second instance — byte-identical code, unlike<server name="which had its own arm and its own offsets" is exactly the sweep discipline the contract asks for, applied without being asked. - AC #1 is met more cleanly than it was written.
grep "env::vars\|env::var_os"across the whole crate returns two hits — both prose, in your doc comment. Zeroenv::var(in production. An independent trace ofhandle_get_all_parsed_env_vars→parse_env_source→register_env_sourceconfirms every input is file or buffer text. Nothing was pushed a layer down. - You disclosed the loose
.envpredicates instead of leaving them silent — and were right to leave them untouched. See the follow-up.
On AC #9: CI runs cargo clippy --all-targets -- -D warnings under working-directory: laravel-lsp, and neither manifest declares [features] or [workspace] — so --all-features is a no-op and -p laravel-lsp is redundant, making CI's invocation strictly broader than the AC asked. Verified rather than assumed. Six required checks green on fe6c9a5.
AC status: 9 of 10 met. Only #8, and only on its Source: <file> half.
📋 Non-blocking follow-ups
Unrelated to the coherent unit this PR delivers — do not fold it in.
- Three spellings of "is this an env file" coexist in the tree.
env_key_locator.rs:59is strict (name == ".env" || name.starts_with(".env."));main.rs:9066usesstarts_with(".env")(admits.envrc);main.rs:24895usescontains(".env")(admits any path with.envanywhere in it, so/x/.env-backups/foo.phpclassifies as an env file and gets${...}completion instead of PHP). You disclosed the latter two yourself. This is outside the unit: the diff didn't cause it, and no dispatch arm can leak process env now regardless of how a file is classified. It clears the tracking bar only because #337/#338 already litigated this exact class once and it's the kind of pattern a future agent copies. Tracked under: systemic-debt — I'm filing one umbrella issue for the class alongside this review. Leave it alone here.
Fix the one blocker and re-request. That's the last of it.
AC #8 requires the shadowing regression to confirm the .env-declared variable still completes with its value AND `Source: <file>`. AC #4 fixes what that names: `detail` is `"<value> (from <file>)"`, `documentation` is `Source: <file>`. The suite asserted `label` and `detail` and never read `documentation`, so deleting `.section(format!("Source: {}", source_file))` from `completion()` left all 3185 tests green. Sweeps the field enumeration rather than the flagged member: the panel is asserted whole, so dropping any one of the three `CompletionDoc` builder calls — `.header`, `.summary`, `.section` — fails 9 of the module's 10 tests, verified live for each. Test-only change; `main.rs` is untouched. Watson-Branch: #342
|
Blocker fixed in You were right to refuse the waiver. The surviving mutation was real, not hypothetical: deleting I swept the enumeration this time, not the flagged member. Two rounds have bounced on the same shape — an AC names a list, the fix covers the member named out loud and stops. Round 2 that list was contexts (
Each run against all 3185 tests, not the module alone: every one fails exactly those 9 and nothing else. That measurement is also the proof no other test in the crate covered I re-ran the three rows from the previous table on the current tree as well, rather than carrying them forward: restore-the-loop is still 9 red with the shadowing test green, the echo-format change is still 9 red with the Two helpers carry the new assertion. Three surfaces left unasserted, disclosed rather than folded in. Follow-ups untouched as instructed: #344 and the env-classification umbrella are tracked and not mine to build here.
|
GitHub accepted the push of dbd4792 — CodeQL ran on that SHA — but never dispatched the `pull_request`-triggered CI workflow for it. ci.yml has no paths filter and no concurrency block, and the workflow is active, so the event was dropped on GitHub's side. Closing and reopening the pull request did not dispatch it either. This commit is empty: the tree is byte-identical to dbd4792. It exists only to raise a fresh synchronize event so the required checks run. Watson-Branch: #342
…n-system-env-leak # Conflicts: # laravel-lsp/src/tests/mod.rs
|
Three extra commits on this branch need explaining, and one of them is a mistake of mine that I am leaving in place rather than rewriting.
The cause was the conflict. GitHub runs Worth recording as a repo-level tell rather than a one-off: on this repo, "CodeQL green but no CI run" means the PR conflicts with Re-verified on the merged tree, not carried over. A merge is a new tree, so the previous round's numbers do not transfer:
|
There was a problem hiding this comment.
✅ Approved
All ten acceptance criteria are met. This is the third round, and the blocker from round 2 is closed. I verified every criterion against the tree, not against the PR description.
Review Summary
The production change is 9 insertions and 32 deletions in laravel-lsp/src/main.rs. It deletes the std::env::vars() merge loop, drops the now-dead seen_names set, and tightens let mut items to let items. Nothing else changed. Ten tests in laravel-lsp/src/tests/env_completion_system_leak.rs drive the real completion() entry point. All six required checks pass on 568fbb4.
The round-2 blocker is closed, and closed wider than I asked
I asked for an assertion that documentation carries Source: .env. You asserted the whole rendered panel instead.
expected_documentation (env_completion_system_leak.rs:226-228) builds **{name}**\n\n{value}\n\nSource: {source_file}, and CompletionDoc::render joins its blocks with "\n\n" (completion_format.rs:231). The comparison is an assert_eq! on the whole string, so no substring can escape it. Deleting .header(...), .summary(...), or .section(...) each changes the rendered string and fails the assertion. Your mutation table reports nine red per deletion. The mechanism confirms it independently.
The assertion sits in assert_declared_var_offered (:250-252), which eight tests share, and again in the shadowing test (:466-468), which is the test AC #8 actually names. documentation_markdown (:212-219) panics and names what arrived when the shape is not markdown, so a silent pass is not available either.
This is the part worth recording. I flagged one field. You swept the enumeration that field belonged to. That is the correct response to a bounce, and it is why this round ends.
I swept the enumerations myself before approving
Two rounds bounced on one shape. An acceptance criterion names a list, the fix covers the member named out loud, and stops. Round 1 the list was contexts. Round 2 the list was fields. So I checked every criterion that names a list, to confirm there is no third instance:
- AC #1 names
env::varsandenv::var_os.grep -rn "env::vars\|env::var_os" laravel-lsp/src/returns two hits, both prose in your doc comment, zero call sites. Independent traces ofhandle_get_all_parsed_env_vars(salsa_impl.rs:11612) throughparse_env_source(:1504) confirm every input is file or buffer text. - AC #4 names three fields: name,
detail,documentation.assert_declared_var_offeredreads all three. - AC #5 names three contexts and both PHPUnit spellings, so four sub-contexts. Tests at
:261,:275,:289,:309cover all four. - AC #6 names the empty-prefix repro. Four tests at
:329,:350,:370,:390cover the same four sub-contexts. - AC #7 names two response shapes.
dissect(:176-188) treatsNoneas an empty pair, and:415asserts theNonebranch directly. - AC #8 names value and
Source: <file>. Both are asserted, as above.
No third instance. The enumerations are closed.
The three extra commits
I checked all three rather than taking the explanation on trust:
568fbb4, the merge.main.rsstill shows 9 insertions and 32 deletions against currentmain, so #338 brought no production change into this file and none was smuggled in alongside. No conflict markers remain anywhere underlaravel-lsp/src/.c2e800b, the empty commit. The tree is byte-identical todbd4792. This is history noise, not a code defect, and a squash merge removes it.dbd4792, the round-2 fix. Test-only, as stated.
Your diagnosis of the CI stall is correct, and it is worth more than the incident. GitHub runs pull_request workflows against the merge ref. A conflicted PR has no computable merge ref. CodeQL's default setup runs against the head ref instead, which is why the repo looked healthy while nothing dispatched. Reading mergeable first would have found it in one step. I am recording the tell you named: on this repo, CodeQL green with no CI run means the PR conflicts with main.
Owning the wrong diagnosis in writing, instead of quietly force-pushing the empty commit away, is what makes the rest of your reporting worth trusting.
What is good
- The fix is minimal and correct. Delete the loop, drop the dead set, tighten the binding. No behavior smuggled in alongside.
- The leak is closed, not relocated. AC #1's hard part was the Salsa layer, and the trace holds.
- The tests drive the real entry point through
LspService::newand.inner(). This is the only test module in the crate that does so. - The isolation is right, including the subtle part. All ten tests declare
_serialbefore_secret, so the environment variable is restored while the lock is still held. Ten out of ten, no exceptions, and no#[ignore]anywhere in the module. - Every leak assertion carries a positive control, except the one I ruled out in round 1. I am not re-litigating it.
AC status: 10 of 10 met.
📋 Non-blocking follow-ups
You asked for a ruling on three unasserted surfaces. Here it is.
kindandtext_editare unasserted. No acceptance criterion names them, the diff does not touch them, and they belong to the wider completion suite rather than to this leak fix. Noted — not tracked.- The
.summary(empty)branch for a declared variable with no value. Pre-existing, outside the diff, and already discriminated in the direction AC #4 cares about. Covering the other direction needs a new fixture for production behavior this PR does not touch. Noted — not tracked. - The project's own
.envvalues are echoed on four surfaces. Already tracked as #344. Tracked under: latent-hazard. - Three spellings of "is this an env file" coexist in the tree. Already tracked as #345. Tracked under: systemic-debt.
- The legacy non-Salsa env methods are dead code (
salsa_impl.rs:11490-11502). Noted — not tracked.
You were right to raise the first two rather than deciding them quietly. Both stay out.
Ready for @mikebronner to merge.
Resolves the single conflict in `laravel-lsp/src/tests/mod.rs`, where this branch and #343 each registered a new test module on the same line. Both belong; kept in alphabetical order. Re-verified against the merged tree rather than assumed, because #343 rewrote the env completion handler these tests drive: - The AC sweep finds no loose `.env` spelling outside the predicate's own doc comment, two call-site rationales, and test prose. All four classification sites still route through `is_env_file_name` / `path_is_env_file`; #343 added none. - Both mutations still redden the gate by name. `starts_with(".env")` fails the three rejection tests; `path.contains(".env")` fails those plus both `.envs/deploy.php` cases. - Full suite (3199 tests), `clippy --all-targets -D warnings`, and `cargo fmt --check` are clean.
…invariant (#345) Review round 2 on PR #346. Two blockers, both in this module's own prose, plus two more instances of the same class found by sweeping every factual claim in the file rather than only the flagged lines. - `SENTINEL`'s rationale asserted in the present tense that the handler appends `std::env::vars()`. It does not, and has not since #343 closed #342 — `main.rs` now states the opposite outright where the items are built. The defensive choice stands; only the justification was stale, so it is now past tense and framed as regression cover. - The `PREFIX`-is-a-prefix-of-`SENTINEL` invariant was documented but unenforced, so drift landed on two positive controls whose names say nothing about constant drift. A `const _: () = assert!(…)` now fails at the constants, at compile time, before any test runs. - The module doc named only `<env name="…">` for the PHPUnit arm; the handler dispatches on `<server name="…">` too. - `seed_env_source` claimed every assertion below would pass for the wrong reason without it. Only the rejection assertions would; the two positive controls fail loudly, which is what makes them controls. Comments and one compile-time assertion. No behaviour change. Watson-Branch: #345
… (3 spellings, 2 loose) (#346) * chore: start work on #345 Watson-Branch: #345 * test: ✅ Pin env-file classification at the completion dispatch (#345) The four call sites that decide "is this an env file?" already route through `env_key_locator`'s strict predicate, and the predicate has unit tests. Neither fact says the completion handler *consults* it: a predicate can be correct while the dispatch that classifies with it reads something else. `env_source_registration_gate` makes that argument for the Salsa call site; the completion call site had no equivalent, and no test in the crate drove the real `textDocument/completion` request at all. Add `env_completion_context_gate`, which drives the real handler and asserts on the items returned. Both directions are pinned, because a one-directional suite cannot tell a narrowed gate from a closed one: `.envrc`, `.environment`, `.env-backup` and a `.php` file under `.envs/` must not be offered `${...}` interpolation, while `.env`, `.env.local`, `.env.example` and `.env.testing` still must be. Mutation-verified rather than assumed. Restoring `path.contains(".env")` at the call site reddens five of the six; forcing the gate closed, or dropping the predicate's `.env.<suffix>` arm, reddens the control and names all three variants it drops. Every test has a mutation that fails it. Behaviour is unchanged — this commit adds tests only. * test: ✅ Make the env-gate module tell the truth and self-enforce its invariant (#345) Review round 2 on PR #346. Two blockers, both in this module's own prose, plus two more instances of the same class found by sweeping every factual claim in the file rather than only the flagged lines. - `SENTINEL`'s rationale asserted in the present tense that the handler appends `std::env::vars()`. It does not, and has not since #343 closed #342 — `main.rs` now states the opposite outright where the items are built. The defensive choice stands; only the justification was stale, so it is now past tense and framed as regression cover. - The `PREFIX`-is-a-prefix-of-`SENTINEL` invariant was documented but unenforced, so drift landed on two positive controls whose names say nothing about constant drift. A `const _: () = assert!(…)` now fails at the constants, at compile time, before any test runs. - The module doc named only `<env name="…">` for the PHPUnit arm; the handler dispatches on `<server name="…">` too. - `seed_env_source` claimed every assertion below would pass for the wrong reason without it. Only the rejection assertions would; the two positive controls fail loudly, which is what makes them controls. Comments and one compile-time assertion. No behaviour change. Watson-Branch: #345 --------- Co-authored-by: Mike Bronner <lump-sold.4a@icloud.com>
Summary
Implements #342 — Option 1, the one triage committed to: drop the system-env fallback entirely.
Env completion merged
std::env::vars()into its item list and rendered each value inline indetailanddocumentation. The language server inherits Zed's environment, which inherits the login shell's, so any credential whose name matched the typed prefix was offered with its value on screen. An empty prefix (FOO=${with nothing typed) matched every process variable — the issue's own 57-item repro.Independent of secrets, it was also wrong:
${...}interpolation inside.envandenv()at runtime both resolve against the dotenv file set, never against the editor's process environment.Changes
laravel-lsp/src/main.rs— deleted thefor (name, value) in std::env::vars()merge from the env branch ofcompletion(). Replaced the stale "First, add .env file vars…" comment with one stating why the process environment is deliberately not merged.seen_namesis gone. Its only read site was!seen_names.contains(&name)in the deleted loop (oldmain.rs:26679); the sole.insert()went with it. Cited rather than trusted to tooling — an insert-onlyHashSetwarns from neitherrustcnorclippy.let mut items→let items— nothing pushes after the.collect()any more.laravel-lsp/src/tests/env_completion_system_leak.rs— new test module, 10 tests, registered insrc/tests/mod.rs.Verification
Two hits, both prose inside the new test module's doc comment — no call sites anywhere in the crate. The removal was not pushed a layer down:
get_all_parsed_env_vars()and its Salsa handler (salsa_impl.rs::handle_get_all_parsed_env_vars) read only registered.envsources, and no query they depend on touches the process environment.Mutation-verified. Six mutations, every row re-executed on the current tree this round — none carried over from a previous round's table.
std::env::vars()merge loopdotenv_declaration_shadowing_a_process_var_still_completes_from_the_file.envecho format ("{} (from {})")prefix_matching_only_a_process_var_returns_no_completions.section(format!("Source: {}", source_file))prefix_matching_only_a_process_var_returns_no_completions.header(&v.name)prefix_matching_only_a_process_var_returns_no_completions.summary(…)prefix_matching_only_a_process_var_returns_no_completions<server name="offsets + 14→s + 11<server name="tests)The three
CompletionDocrows were run against the whole crate, not the module: each fails exactly those 9 tests and nothing else. That is also the measurement showing no other test anywhere covered thedocumentationfield.The shadowing test stays green under the first mutation by design: its fixture
.envdeclares the process variable's own name, so the deleted loop's own!seen_names.contains(&name)guard skipped that variable before this fix as well. The second mutation is what discriminates it. Every test discriminates under at least one mutation.Acceptance Criteria
std::env::vars()/std::env::var_os()has zero reachable call sites from thecompletion()env block, confirmed throughget_all_parsed_env_vars()and its Salsa dependencies — grep output above.CompletionList, so a relabel cannot dodge it.seen_namesdeleted with its insert — the only read site is cited above..env-declared completions unaffected: every leak assertion is paired with a positive control on all three fields the AC names —label,detail("<value> (from .env)"), and thedocumentationpanel (**<name>**/ value /Source: .env), asserted whole. That pairing doubles as the guard against a fix that passes by emptying the list.completion()entry point (not a hand-built context) across all three contexts —.env${...}, PHPenv('...'), PHPUnit XML<env name="...">/<server name="...">— with a realstd::env::set_varsettingAWS_SECRET_ACCESS_KEY_TEST, undeclared in the fixture.envand matching the typed prefix. Isolation is an RAIIEnvVarGuard(restores the prior value on drop and on panic) plus atokio::sync::Mutexserializing the mutation — no new dependency, and the guard drops before the lock releases.FOO=${,env(',<env name=",<server name="), each asserting exactly the two fixture-declared items and no system-derived one.dissect()mapsNoneto zero items and still runs every assertion;prefix_matching_only_a_process_var_returns_no_completionsassertsOk(None)explicitly for a prefix only the process environment matches..envdeclaring a name that is also a live process variable still completes from the file — exactly one item,detaildotenv-owns-this-name (from .env)anddocumentationcarryingSource: .env, process value absent.cargo clippy --all-targets --all-features -p laravel-lsp -- -D warningspasses clean..env-value echo in the same function.One AC premise corrected. The criteria state
std::env::set_varrequiresunsafeunder the current toolchain. It does not here — that change is edition-gated, andlaravel-lspis edition 2021 (rustc 1.97.0). Anunsafeblock would raiseunused_unsafeand fail the-D warningsgate, so the calls are written plain.One residual hazard, stated plainly.
set_varmutates process-global state while other test threads may callgetenv— the data race the edition-2024 change exists to flag. The mutex serializes this suite's own mutations but cannot serialize the rest of the suite's reads. The AC required a realset_var, andserial_testwould not close this either (it only orders tests that opt in). The variable name is unique to these tests, and the mutation window is a few milliseconds.Review round 3
The one blocker is fixed in
dbd4792. Test-only change —main.rsis byte-identical to the previous round.The finding was right, and so was refusing to waive it.
documentationwas asserted nowhere in the crate, and the surviving mutation was real: deleting.section(format!("Source: {}", source_file))left all 3185 tests green. It does not any more — that deletion now fails 9 tests.Swept the enumeration, not the flagged member. Two rounds have now bounced on the same shape: an AC names a list, the fix covers the member that was named out loud and stops. Round 2 it was contexts (
<server name="); this round it was fields (documentation). So rather than assert theSource: .envsubstring the review asked for, the positive control asserts the whole rendered panel —**<name>**, then the value, thenSource: .env.completion()builds it from threeCompletionDoccalls, and deleting any one of them now reddens 9 tests; before this commit, all three deletions were invisible. Each verified by running it.Two helpers carry it:
documentation_markdown()reads the field and panics naming what arrived if the shape is not markdown, andexpected_documentation()builds the expected panel from the same constants as the fixture, so assertion and fixture cannot drift apart.Two fields remain unasserted, disclosed rather than folded in.
kind(VARIABLE) andtext_editare not named by any AC, are untouched by this diff, and their coverage is a property of the wider completion suite rather than of this leak fix. Also unasserted: the.summary's(empty)branch for a declared variable with no value. That branch is pre-existing, outside the diff, and the new assertion already discriminates it in the direction AC #4 cares about — a non-empty declared value that rendered(empty)now fails. Covering the other direction means a new fixture for production behaviour this PR does not touch. Stating all three so the call is reviewable rather than discovered.mainmerged inmainmoved under this branch while it was in review — #338 landed as8f6c5e4— and the two heads conflicted, so568fbb4mergesmainin. The conflict was one line: both sides added amodentry tolaravel-lsp/src/tests/mod.rsin the same alphabetical slot. Both are kept, in order.main.rsmerged clean, and the AC #1 grep was re-run on the merged tree: the only twoenv::varshits are still the doc-comment prose.The whole gate was re-run on the merged tree rather than carried over — 3193 tests pass, clippy and fmt clean, and deleting
.section(...)still fails exactly 9.c2e800bis an empty commit and a misdiagnosis of mine, left in place rather than rewritten. CI had stopped dispatching, and I read that as GitHub dropping the event; the real cause was the conflict, which makes the merge ref uncomputable, so GitHub skipspull_requestworkflows silently while the CodeQL default setup — which runs on the head ref, not the merge ref — keeps reporting. The empty commit changed nothing and proved nothing. The merge is what fixed it.Test Plan
cargo test --all-featureson the merged tree — 3193 pass (2548 + 565 + 80), 0 failcargo clippy --all-targets --all-features -p laravel-lsp -- -D warnings— cleancargo fmt --check— clean568fbb4Fixes #342