fix: mask sensitive .env values in LSP output - #348
Conversation
Watson-Branch: #344
A Laravel `.env` routinely holds `APP_KEY`, `DB_PASSWORD`, `MAIL_PASSWORD`
and third-party API tokens. Four surfaces echoed those values: env
completion, `.env` hover, `config('...')` completion, and the on-disk
warm-start cache. The first three render into popups most likely to be on
screen during a screen-share or a recording; the fourth wrote plaintext to
a long-lived file outside the project.
All four now consult one predicate,
`completion_display::is_sensitive_env_name` — case-insensitive, matching
whole `_`-delimited segments against KEY, SECRET, PASSWORD, TOKEN,
CREDENTIAL, PRIVATE, AUTH, PWD. Segment matching rather than substring, so
`AUTHOR_NAME` and `TOKENIZE_INPUT` keep their values.
- Completion and hover show a shared redaction string in place of the
value; the source file and the variable name still render. Redaction
precedes the existing `(empty)` display, so a blank credential never
reads as "not set".
- The config resolver redacts only dotenv-sourced values. A literal
default is already visible in the PHP file being edited, the `${VAR}`
placeholder has no value, and a plain config literal is never checked.
- The cache stores a matched name with an empty value rather than
dropping the key, and `CACHE_VERSION` moves to 6 so a pre-fix cache
already holding plaintext is rejected and rebuilt.
The #342 tests move with the policy: their declared control is renamed to
a name outside the pattern, and the shadowing test now asserts the
redacted rendering it must produce.
Watson-Branch: #344
There was a problem hiding this comment.
🔄 Changes Requested
All nine acceptance criteria are met — I verified each one, and the work behind them is genuinely good. But the review turned up a fifth surface that echoes .env values in plaintext, on the default configuration, and it is the same disclosure class this issue exists to close. A hard security defect blocks regardless of where it lives (§4e), so it has to be shut before this merges.
Issues Found
🔴 A .env-sourced DB password is written to stderr at the default log level
laravel-lsp/src/database.rs:1258
if let Some(env_value) = self.resolve_env(&env_var) {
info!("🗄️ → resolved from .env: {}", env_value);Reached from parse_env_setting(&block, "password", "") at database.rs:1095, on the mainline parse_database_config() path — routine for any Laravel project with a config/database.php.
Why this is live, not theoretical: main.rs:26839 sets EnvFilter::new("info,salsa=warn") when RUST_LOG is unset, writing to std::io::stderr (main.rs:26843). info! therefore fires out of the box, into the stream Zed surfaces in a visible log panel. That is precisely the screen-share/recording exposure this issue's own text names as the threat model.
What makes it unambiguous: fifteen lines later, the same function already refuses to do this —
info!("🗄️ password: {}", if password.is_empty() { "(empty)" } else { "(set)" }); // :1110-1117
info!("🗄️ url: {}", mask_url_password(u)); // :1121The masking convention for this exact value already exists in this exact function. Line 1258 simply bypasses it and prints the secret in the clear first.
Precedent — this is the second time in this repo. PR #294 was the identical shape: translation_lookup.rs had a path_within_root guard on one sibling call site that never reached the others. Same lesson landing twice: a guard that exists somewhere in the file is not a guard on the file.
🔴 Same class, second site — close both together
laravel-lsp/src/database.rs:1423
debug!("🗄️ resolve_env({}): {:?}", key, result);Gated behind debug! rather than info!, so it is dormant under the default filter — but it is the same helper (resolve_env, called for password at :1095) and the same plaintext disclosure, one RUST_LOG=debug away. RUST_LOG=debug is an ordinary troubleshooting step, and troubleshooting output is exactly what gets pasted into a bug report.
I swept the class rather than handing you one site at a time. grep for every resolve_env / read_env_value caller and every log statement carrying an env value across database.rs and config.rs returns exactly these two sites — the class is bounded and closeable in one pass. Please fix both, and mirror the convention already at :1110-1117 (log (set)/(empty), or route through the existing mask_url_password-style masking) rather than introducing a third spelling.
A regression test that asserts the captured log output for a .env-sourced password contains no plaintext would pin this the way the four LSP surfaces are already pinned.
What's Good
This is strong work, and I want to be specific about why:
- One predicate, genuinely shared.
is_sensitive_env_nameatcompletion_display.rs:63-68matches the AC's spec verbatim, and all four surfaces route through it — no per-site reimplementation. Theenv_display_valuehelper collapsing bothenv()regex branches means they structurally cannot diverge, which is better than testing that they currently don't. - The predicate's fixtures are fully pinned — all five positives and all seven substring-but-not-segment negatives (
PASSKEYBOARD_LAYOUT,AUTHOR_NAME,SECRETARY_ID, …) atcompletion_display/tests.rs:242-297. - The tests are honest. They drive real entry points (
completion(),hover_for_env,get_all_config_keys, a genuineCacheManagersave/load), the leak assertion serializes the wholeCompletionResponse— closing theinsertText/label/sortTextescape hatch — and the cache assertion reads the deserialized struct, not the bytes, closing the reversible-encoding hatch.a_pre_bump_cache_holding_plaintext_is_rejectedeven derives the version field from the real written JSON instead of hardcoding it. - The modified #342 tests were not weakened. I scrutinised the
-16lines specifically. RenamingAWS_SECRET_DECLARED→AWS_SECRETARIAT_REGIONwas necessary — the old name'sSECRETsegment would now be legitimately redacted, destroying that test's original purpose — and the rename preserves the typed-prefix property the sibling tests depend on. Correct call, well commented. CACHE_VERSION5 → 6 with a compile-time floor (const _: () = assert!(CACHE_VERSION >= 6)) so the bump cannot be silently walked back. That is a nice touch the AC did not ask for.- You disclosed the two awkward findings yourself rather than letting them pass quietly. Both check out exactly as you described them — I verified each independently. That candour is worth more to this review than a clean-looking PR body.
📋 Non-blocking follow-ups
Both are unrelated to the coherent unit; neither clears the tracking gate, so both are noted, not tracked.
- The predicate misses keyword-concatenated names —
APIKEY,SECRETKEY,APITOKENall returnfalse, sincesplit('_')never yields a segment equal toKEY/SECRET/TOKEN.completion_display.rs:64. Disposition: Noted — not tracked. This is not a defect against the contract: the AC pins the predicate exactly, segment-matching was chosen deliberately to avoid over-redactingAUTHOR_NAME/TOKENIZE_INPUT, and I do not amend acceptance criteria. Flagging it so @mikebronner can decide whether the policy should widen later — a decision, not a bug. - Surface 4's stated rationale is moot in the current tree.
register_cached_env_varswrites the Salsaenv_variablesmap (salsa_impl.rs:9103-9117), but all four display surfaces readget_all_parsed_env_vars/get_parsed_env_var, which walk live-parsedsalsa_env_filesonly (salsa_impl.rs:11581-11640); the legacy getters have no caller outsidesalsa_impl.rs. So the AC's "surfaces 1-3 can still show the name plus the redaction string immediately on warm start" describes a capability that does not exist. Disposition: Noted — not tracked. Pre-existing, untouched by this diff, and the requirement — never write plaintext to disk — is correctly implemented regardless. Your decision to pin the claim where it is observable, instead of writing a test asserting about code that never runs, was the right one.
Please close the two database.rs sites and re-request review. Everything else here is ready.
Review of the four-surface redaction found a fifth surface: the server log.
`parse_env_setting` logged the value it resolved from `.env` at `info!`, which
fires under the default `EnvFilter("info,salsa=warn")` into stderr — the stream
Zed renders in a visible log panel, and the one `docs/troubleshooting.md` asks
users to paste into bug reports. On the mainline `parse_database_config()` path
that value is the project's DB password. `resolve_env` logged the same value
again at `debug!`, one `RUST_LOG=debug` away.
Both sites now route through `mask_env_value_for_log`, which consults the same
`completion_display::is_sensitive_env_name` predicate the other four surfaces
use. A matched name renders `(set)` — the spelling `parse_database_config`
already prints for the resolved password fifteen lines below the leak — and an
unmatched name logs its value unchanged, so `DB_HOST` and `DB_DATABASE` keep
the diagnostic these lines exist for.
The neighbouring value logs are deliberately untouched. The connection summary
masks by field: the password unconditionally, the URL through
`mask_url_password`. That is stronger than the name heuristic for the one field
that is a secret by construction, and the `using default:` line echoes a
literal written in the PHP file the developer is editing.
Three tests drive the real entry points — `parse_database_config`,
`parse_env_setting`, `resolve_env` — with a thread-scoped subscriber capturing
the log, and assert the plaintext is absent, the masked line is present, and an
ordinary value still logs in full. Two matched keyword categories cross the
gate, and the config-key test proves the predicate reads the env var's name,
not the config key's.
The new log tests scoped their subscriber per call with `tracing::subscriber::with_default`. That reads tidier and is wrong here: `tracing` caches a callsite's `Interest` the first time that callsite is reached, so an ordinary test touching `resolve_env` on another thread — with no subscriber in place — caches "never" for that macro, and every later capture silently misses its lines. It turned the suite red on the macOS runner while ubuntu, windows and local runs stayed green, with the resolver's own two lines absent from a capture that held their neighbours. The subscriber is now installed once, globally, so every callsite registers against a real subscriber and `set_global_default` rebuilds the interest cache. Isolation moves to the buffer, which is thread-local: a test that is not capturing writes into `None` and drops its output, exactly as it did when no subscriber existed at all.
|
Both blockers are fixed in The two The class, swept independently. I greped every Tests. Three, driving One thing worth your attention. The first push was green locally, on ubuntu and on windows, and red on macOS. The cause was the test capture, not the fix: Doc sweep. The "four surfaces" enumerations in |
There was a problem hiding this comment.
🔄 Changes Requested
Both blockers from the last round are genuinely closed — c471711 routes both log sites through mask_env_value_for_log, which delegates to the same is_sensitive_env_name rather than inventing a third spelling, and its doc comment's "no (empty) arm" claim checks out (config.rs:204 filters empties to None before they can reach it). All nine acceptance criteria are met. CI is green on all three runners.
Two things still block, and the first is the same lesson landing a third time.
Issues Found
🔴 A URL-shaped .env value bypasses the name predicate — plaintext DB credential at the default log level
laravel-lsp/src/database.rs:1283 (the line this PR modified)
The predicate is name-based, exactly as AC #2 specifies. But Laravel's stock config/database.php ships 'url' => env('DATABASE_URL') (and env('REDIS_URL') for redis), and those values are mysql://user:hunter2@host/db — the password is inside the value, and DATABASE_URL splits to DATABASE / URL, matching none of the eight segments.
I traced the path rather than assuming it: parse_optional_setting(&block, "url") at :1137 → parse_env_setting(block, key, "") at :1260 → your new info! at :1283. mask_env_value_for_log("DATABASE_URL", …) returns the value untouched, and main.rs:26839 sets EnvFilter::new("info,salsa=warn") when RUST_LOG is unset — so this fires out of the box, into Zed's visible log panel. Same screen-share exposure #344 exists to close.
What makes this unambiguous: twenty-six lines later, at database.rs:1147, the same value is printed correctly —
info!("🗄️ url: {}", mask_url_password(u)); // :1147mask_url_password already exists at :482, already handles exactly this shape, and is already applied to this exact setting. Line :1283 prints it in the clear first.
This is the third round of one pattern. PR #294: path_within_root guarded one sibling call site and not the others. Last round on this PR: masking existed at :1110-1117 and not at :1258. Now: mask_url_password exists at :1147 and not at :1283. A guard that exists somewhere in the file is not a guard on the file.
Scope — please fix the class, not the line. The same gap is on all four AC surfaces, because they share the predicate: env completion (main.rs:26719), hover (:20283), config('…') (:14986), and the disk cache (:7741) each render or persist a DATABASE_URL in full. The cache one is worst — plaintext credentials written to a long-lived file on disk.
I am not asking you to amend AC #2's predicate; it's Mike's contract and it stays exactly as written. Add a value-shape gate alongside the name gate — route a value through mask_url_password (or the equivalent) when it parses as a URL carrying credentials, independent of whether its name matched. Name-based and shape-based redaction are complementary, and the AC caps neither.
A test with a DATABASE_URL=mysql://user:hunter2@host/db fixture, asserting hunter2 is absent from all four serialized surfaces and from captured log output, pins it the way the name-based cases are already pinned.
🔴 The (bool) env(...) branch is unreachable — the new doc comment and test narrative both claim otherwise
laravel-lsp/src/main.rs:14956-14969 (branch), :14979-14984 (the doc comment this PR added)
env_pattern at :14934 is unanchored, so it matches the env('API_TOKEN') substring inside (bool) env('API_TOKEN') and returns at :14941 before bool_env_pattern is ever consulted. The second branch cannot execute.
The redaction behaviour is still correct — both branches call env_display_value, so nothing leaks. What's wrong is the claim written on top of it. Your new doc comment states "Both env() regex branches above route through this — a fix applied to only one of them leaves the other echoing credentials," and env_value_redaction.rs:327-336 describes its fixture as exercising the two spellings as separate branches. Deleting the env_display_value call from the bool_env_pattern block would fail no test. AC #5's "both regex branches" coverage therefore isn't real — it reads as satisfied while only one path is driven.
Two lenses found this independently. Either make the second branch reachable (anchor env_pattern, or test bool_env_pattern directly), or delete the dead branch and correct the comment and the test's doc to say so. Don't leave a comment asserting a safety property the code can't have.
🔴 Two in-PR test assertions weaker than they read
tests/env_value_redaction.rs:532-540—!loaded.has_cached_data()is near-vacuous for this fixture:has_cached_data()inspects onlyvendor_scan/app_scan/laravel_config, neverenv_vars, and the test populates none of those. It would pass with the version check removed entirely. The neighbouringloaded.get_env_vars().is_none()is the assertion doing the real work — keep that one and drop or fix the vacuous one.tests/env_value_redaction.rs:502-541— the pre-bump-rejection fixture plants onlyPASSWORD_NAME, so AC #7's "unaffected after aCACHE_VERSION-forced rescan" half is only established by combining two tests' fixtures. Plant a plain variable alongside it and assert it survives the forced rescan.
What's Good
- The predicate is exactly right.
completion_display.rs:63-68implements AC #2 verbatim, andeq_ignore_ascii_caseon_-split segments makesPASSKEYBOARD_LAYOUTandPWDLESS_LOGINfall out correctly by construction rather than by fixture luck. All nineteen named fixtures are pinned atcompletion_display/tests.rs:243-297. env_display_valuecollapses bothenv()spellings into one helper — even though one branch turns out to be dead, the structure is the right instinct: the surfaces can't diverge because there's only one place to change.const _: () = assert!(CACHE_VERSION >= 6);is better than what the AC asked for. The AC wanted a bump; you made walking it back a build failure, and cited the existingcompletion_displayprecedent for the technique. That's the right kind of divergence.- The
e18ce7atest-infra fix is genuinely good engineering. Diagnosing a one-runner-in-three flake down totracing's per-callsiteInterestcache, then moving isolation from the subscriber to a thread-local buffer, is a correct and non-obvious call — and the commit message explains whywith_defaultis the tempting wrong answer. That reasoning is worth keeping. - Redaction precedence is right where it matters —
main.rs:26714computessensitivebefore the(empty)branch, soDB_PASSWORD=can't leak set-vs-unset.
📋 Non-blocking follow-ups
- None.
Please address the above and re-request review.
The name predicate cannot see a secret that lives in the value. Laravel's
stock `config/database.php` ships `'url' => env('DATABASE_URL')`, and
`DATABASE_URL` splits to `DATABASE` / `URL` — matching none of the eight
sensitive segments — while its value is `mysql://user:hunter2@host/db`.
So the password reached all four display surfaces, the server log at the
default `info` filter, and the on-disk cache in full.
Add a second, complementary gate that reads the value's shape:
- `mask_url_credentials` moves out of `database.rs` (where it was
`mask_url_password`, already applied to the assembled `url` log line)
into `completion_display`, beside `is_sensitive_env_name`. One home for
the whole policy, so the two halves cannot drift into two spellings.
It returns `Cow`, borrowing on the fail-open paths.
- All five consumers now apply both gates: env completion, `.env` hover,
`config('…')` completion, the warm-start cache, and
`mask_env_value_for_log`, whose unmatched arm is the masker rather than
the raw value.
- A matched *name* still drops its value entirely; a matched *shape* masks
the credential and keeps the host, port and database, which is the whole
reason anyone reads that value.
Also closes two review findings that ride the same code:
- The `bool_env_pattern` arm of `resolve_env_value` was unreachable —
`env_pattern` is unanchored, so it matches the `env('X')` substring of a
`(bool) env('X')` cast and returns first. Deleted, with the doc comment
and the test narrative that both claimed "two branches" corrected. The
cast spelling still resolves and still redacts, through the one arm.
- Two cache assertions were weaker than they read. `!has_cached_data()`
inspects only the vendor/app/config sections and would pass with the
version check deleted; the pre-bump fixture planted no ordinary variable,
so AC #7's "unaffected after a forced rescan" was only established by
combining two tests. Now one test plants both, asserts the old file is
dropped, and drives the real rescan.
Every new assertion was mutation-verified: reverting the gate at each of
the five sites, deleting the version check, and un-masking the helper each
redden exactly the test that names them.
Refs #344
|
All three blockers fixed in 1. The URL-shaped value bypassed the name predicate. You asked for a value-shape gate beside the name gate, and for the class rather than the line. I enumerated every consumer of the parsed-dotenv map instead of trusting the AC's "four surfaces". Two more exist — 2. The 3. Both weak assertions. Mutation-verified, ten mutations, every one red with the named test actually running: the gate reverted at each of the five sites ( The Doc sweep on everything this round falsified: the No non-blocking follow-ups were listed, so nothing else was in scope. |
There was a problem hiding this comment.
✅ Approved
All nine acceptance criteria are met, CI is green on all three runners, and the disclosure class #344 exists to close is closed. I re-verified round 3 against the source at a55f156 rather than reading the round-2 report — the three blockers I raised last round are genuinely shut, and the class sweep behind them holds.
What I checked, and how
I went looking for a fourth round of the same pattern — a guard that exists somewhere in the file but not on the file. It is not there.
The class, enumerated independently. I traced every consumer of the parsed-dotenv map rather than trusting the PR body's table:
| Site | Verdict |
|---|---|
main.rs:26723 — env completion |
both gates |
main.rs:20293 — hover_for_env |
both gates, and is_commented is checked first, so # DB_PASSWORD=… renders no value at all |
main.rs:14921 — resolve_env_value |
both gates |
main.rs:7741 — cache writer |
both gates |
database.rs:1263 (info!) / :1429 (debug!) |
both gates |
main.rs:16176 — goto, :18013 — the env() diagnostic |
name / position / existence only; nothing to leak |
salsa_impl.rs:11565 — APP_LOCALE |
internal resolution, never displayed |
Two invariants the PR asserts in prose, verified rather than believed. extract_config_value (main.rs:14917) is the only path from the env_vars map into ConfigKeyCompletion — I traced all eight env_vars references in main.rs, not just the ones near the change. And resolve_php_value (database.rs:1356) performs no env lookup, which is what makes the using default: log exemption sound rather than merely plausible.
The (bool) env(...) deletion holds for every input, not just the tested one. bool_env_pattern was \(bool\)\s* prefixed to env_pattern's own shape, so any string matching the second matches the first, which is tried first. Behaviour is identical across all three outcomes — dotenv hit, literal default, ${VAR} placeholder. Deleting the arm is a stronger close on AC #5's concern than testing a path that cannot execute, and the cast spelling keeps its fixture through the surviving arm.
The no-further-bump argument for gate 2 is correct. There is no config-key cache — CachedLaravelConfig holds view paths and namespaces only — so CACHE_VERSION 6 was never released without the value-shape gate. The compile-time floor (const _: () = assert!(CACHE_VERSION >= 6)) is a nice touch: the bump cannot be quietly walked back.
Gate 2 traced by hand on the shapes that matter. redis://:password@host:6379 — the no-username Redis form — masks correctly (redis://:***@host:6379), which the find(':')-before-@ logic is not obviously right about until you walk it.
The tests. assert_no_secret_leak sweeps four secrets over the whole serialized response, URL_SECRET now among them — so every pre-existing surface test doubles as a gate-2 guard. Each surface additionally asserts the masked form is present, which is precisely what stops an implementation that merely drops the value from passing a leak check while losing the diagnostic. The cache assertion is on the deserialized struct, not the bytes. The exemptions each have a fixture, including the one that proves the predicate reads the env var's name and never the config key's.
Two non-blocking notes
Neither changes the verdict; both are filed rather than buried here.
-
mask_url_credentialsleaves the tail of an@-bearing password visible.postgres://user:p@ssword@host/db→postgres://user:***@ssword@host/db. The doc comment calls this out deliberately and it is the pre-existingmask_url_passwordbehaviour, so it is not a regression — but taking the last@before the first/after the credentials is a strictly better parse for the exact case this issue exists to close. An unencoded@is malformed per RFC 3986, which is why this is a follow-up and not a blocker. -
The warm-start env cache has no display consumer. You found this and reported it honestly rather than testing dead code, which was the right call. It does mean AC #6's phrase "surfaces 1-3 can still show the name plus the redaction string immediately on warm start" describes a capability the code does not have — the AC's rationale was already false when it was written. Its testable half is fully met, so this is not an AC dispute; it is a latent question about whether
register_cached_env_varsshould be wired up or deleted.
One thing worth carrying forward
The tracing Interest-caching trap from e18ce7a — a scoped with_default lets an ordinary test reach the callsite first with no subscriber, cache Interest::never(), and silently empty every later capture, which is why the first push was green on Linux and Windows and red on macOS only. Install the subscriber once, globally, and isolate through a thread-local buffer. That will bite the next person who writes a log assertion in this repo, and the reason it bites is invisible from the failure.
Good work. The value-shape gate living next to the name gate — one file holding both halves of one policy — is the structural answer to a lesson this repo has now learned three times.
|
The two non-blocking notes from the approval now have homes, so nothing is buried in a review body:
Nothing here blocks the merge. |
… separator Two fixes the merge with main surfaced. `hover_for_env_declaration` rendered the value raw. `hover_for_env` — the same card for the reverse direction — drops a value whose *name* matches `is_sensitive_env_name` and masks URL credentials otherwise (#344, #348). That the value is already on screen in the buffer is not a reason to skip the guards: this is LSP output like any other surface those issues swept. Both guards now apply, with a test for each arm. `env_value_redaction` (new on main in #348) compared a completion detail against a literal `config/app.php`. The detail renders a display path with the platform separator, so Windows produced `config\app.php` and reddened the whole matrix job on a difference that has nothing to do with redaction. The expectation is built from `Path::join` instead; the assertion still pins the redaction string itself. Watson-Branch: #341
The config completion detail and documentation lines render a project-relative file label. #336 replaced its hardcoded `format!("config/{group}.php")` with a `strip_prefix` + `to_string_lossy`, so the label picked up the host separator and read `config\\app.php` on Windows alone. That regression landed on main three minutes before #348 merged the assertion that reads the label, so neither PR's CI ever ran the pair. This branch is the first to, which is why it is fixed here rather than in a branch of its own. Routed through `with_forward_slashes`, the helper the sibling route label already uses. The out-of-root fallback stays the full path: a module config dir may sit outside the root, where a bare `app.php` would not say which module declared the key. Three tests, each mutation-verified to redden alone.
…s disk reads (#357) * chore: start work on #349 Watson-Branch: #349 * fix: 🐛 count and cache the completion path's config read `completion_locale` resolved `app.locale` out of `config/app.php` through `config_lookup::resolve_value`, a free function in another module. Two consequences, both closed here. It bypassed `TranslationCache::disk_reads`, so the cache-hit regression tests could not observe that read at all — and their fixture writes no `config/app.php`, so both requests failed to resolve it identically and the assertion held whether the read was cached or repeated. The test could not fail. And it re-read the file on every completion request, twice over when the chain ran on to `app.fallback_locale`. `TranslationCache` now owns a per-instance config cache keyed by config file path, read through `ensure_config` and counted like every other read. Absence is cached too: a project with no `config/app.php` is probed once, not once per keystroke boundary. `resolve_value` itself is untouched — the cache is a wrapper at the call site, so `hover_for_config` keeps reading fresh. A cache obliges invalidation, so both edit paths now evict it: the watched-files handler for external create/change/delete, and `execute_salsa_update` for an in-editor edit, which is the only notice the actor gets for a file open in Zed. Fixes #349 * fix: 🐛 normalize separators on the watched-files config gate The arm classifying a watched file as config tested the raw path for the substring `/config/`, so on Windows — where the path reads `C:\proj\config\app.php` — it never matched and the whole arm was dead. Issue #292's shape, two lines above its own warning about `/Commands/`. Nothing exercised it until the config cache landed: the file-existence eviction and `invalidate_config_cache` it also guards were silently skipped on Windows, and the new invalidation inherited that. Routed through `with_forward_slashes`, the same helper `execute_salsa_update`'s config arm already uses, so both gates are one predicate rather than two spellings that can drift apart. * fix: 🐛 normalize the config completion label's path separators The config completion detail and documentation lines render a project-relative file label. #336 replaced its hardcoded `format!("config/{group}.php")` with a `strip_prefix` + `to_string_lossy`, so the label picked up the host separator and read `config\\app.php` on Windows alone. That regression landed on main three minutes before #348 merged the assertion that reads the label, so neither PR's CI ever ran the pair. This branch is the first to, which is why it is fixed here rather than in a branch of its own. Routed through `with_forward_slashes`, the helper the sibling route label already uses. The out-of-root fallback stays the full path: a module config dir may sit outside the root, where a bare `app.php` would not say which module declared the key. Three tests, each mutation-verified to redden alone.
Removing the `env_vars` section without bumping `CACHE_VERSION` creates an upgrade path nothing else covers. A cache written by the pre-#356, post-#348 binary is already at version 6 and still carries a populated `"env_vars"` object, so the version-rejection guard never fires on it — the key simply has to be ignored. The test plants exactly that file: a real `save()` produces a current cache with a Laravel config and a middleware entry, then the `"env_vars"` object is grafted back on as JSON with the version left untouched. Loading it must succeed with no forced rescan and both surviving sections intact. Mutation-verified: adding `#[serde(deny_unknown_fields)]` to `LspCache` turns it red. Refs #356
…delete it (#359) * chore: start work on #356 Watson-Branch: #356 * refactor: ♻️ remove the consumerless warm-start env-var cache The warm-start path read the cached env map and registered it with the Salsa actor, and nothing ever read it back: `get_env_variable` and `get_env_variable_names` had no caller outside `salsa_impl.rs`, and every client-visible surface — env completion, `.env` hover, `config('…')` completion, the `env()` diagnostic — walks the registered `.env` sources through `get_all_parsed_env_vars` instead. The cache write, the registration, and the `CACHE_VERSION` bump all ran; the product was never collected. Take option B from #356: delete the plumbing rather than build four fallback read paths, each needing its own redaction proof. This also stops writing a long-lived file of `.env` variable names outside the project directory, so the on-disk exposure surface shrinks to nothing. Removed: - `SalsaHandle::{register_cached_env_vars, get_env_variable, get_env_variable_names, register_env_variables}`, their `SalsaRequest` variants, match arms and actor handlers, plus the actor's `env_variables` field and the `EnvVariableData` type. - `CachedEnvVars`, `LspCache::env_vars`, and `CacheManager::{get_env_vars, set_env_vars}`. - The `main.rs` warm-start registration and the cache-write loop that built `CachedEnvVars` from `get_all_parsed_env_vars()`. Kept deliberately: - `is_sensitive_env_name` / `mask_url_credentials` — shared with the live display surfaces and the database logger, which are untouched. - `CACHE_VERSION` at 6 and its top-level `const _: () = assert!(…)` floor. The v5 caches holding `.env` plaintext are still on disk, so the rejection guard still has a job. The version is deliberately not bumped: a v6 file written before this change carries an `env_vars` key that serde now ignores, and forcing a rescan to delete an inert key buys nothing. Test surgery, per the acceptance criteria: - `the_reloaded_cache_registers_cleanly_on_a_cold_server` and `the_disk_cache_keeps_sensitive_names_but_never_their_values` are gone — both exercised only the deleted APIs. - `a_pre_bump_cache_holding_plaintext_is_rejected_and_rescanned` is rewritten as `a_pre_bump_cache_is_dropped_wholesale_and_the_rescan_restores_it` against the cached Laravel config. Renamed because the old name would now be a false claim: the test asserts nothing about plaintext. Both original disciplines survive — the planted version is derived from the file's own `version` field, and the fixture goes through a real `CacheManager::load`/`.save()` round trip. It also gains a second planted section so "dropped wholesale" is checked, not assumed. - The module doc now describes three client-rendered surfaces, not four. Doc sweep beyond `src/`: `CLAUDE.md` named `EnvVariableData` twice, in the Salsa component table and as the `*Data`-suffix example. Both now name the live `ParsedEnvVarData`. `populate_cache_from_salsa`'s docstring no longer claims to cache env. Refs #356 * test: ✅ pin that a stale env_vars cache key loads harmlessly Removing the `env_vars` section without bumping `CACHE_VERSION` creates an upgrade path nothing else covers. A cache written by the pre-#356, post-#348 binary is already at version 6 and still carries a populated `"env_vars"` object, so the version-rejection guard never fires on it — the key simply has to be ignored. The test plants exactly that file: a real `save()` produces a current cache with a Laravel config and a middleware entry, then the `"env_vars"` object is grafted back on as JSON with the version left untouched. Loading it must succeed with no forced rescan and both surviving sections intact. Mutation-verified: adding `#[serde(deny_unknown_fields)]` to `LspCache` turns it red. Refs #356
* chore: start work on #341 Watson-Branch: #341 * feat: ✨ Hover and go-to-definition on keys in .env buffers Both handlers returned before any dispatch on a `.env` buffer: `goto_definition` gated on `.php`, `hover` on `.blade.php`/`.php`. Four other env features already classified files through the shared `env_key_locator::is_env_file_name` gate; these two now join them, and branch to a dedicated env path ahead of the PHP pattern index rather than falling through it into `Ok(None)`. - hover on a key renders its name, effective value, declaring file (via the existing `.env`/`.env.local`/`.env.example` priority ladder) and consumer count. Commented-out and not-defined states mirror `hover_for_env`; the not-defined card keeps its count, which is the point of hovering a key the project cannot resolve. - go-to-definition jumps to the consuming `env('KEY')` call sites — every one of them, as a `Scalar` for a single hit and an `Array` otherwise. A key with no consumers returns `None` where hover still renders. - `enumerate_commented_keys_in_source` finds a key on a `#` line, which `enumerate_keys_in_source` classifies as "not a declaration". Both delegate to the same `parse_key_declaration`, and stay separate so a commented line cannot win the code lens's first-match-wins race. - the reference-count phrasing and the de-duplicated location lookup move into `reference_count_label` / `reference_locations`, so a key's lens count and its hover count come from one call and cannot disagree. `textDocument/references` deliberately keeps its `.php`/`.blade.php` gate: the reference code lens stays the only "find consumers" entry point for env keys, as it is for config and translation keys. Pinned by a test. Docs: `environment.md` claimed hover and go-to-definition "only ever run on `.php` and `.blade.php`", which this change falsifies — corrected, with the surrounding argument (a real `.sh` gets nothing) left intact. Watson-Branch: #341 * fix: 🔒 redact secrets in the .env-buffer hover card, and unpin a path separator Two fixes the merge with main surfaced. `hover_for_env_declaration` rendered the value raw. `hover_for_env` — the same card for the reverse direction — drops a value whose *name* matches `is_sensitive_env_name` and masks URL credentials otherwise (#344, #348). That the value is already on screen in the buffer is not a reason to skip the guards: this is LSP output like any other surface those issues swept. Both guards now apply, with a test for each arm. `env_value_redaction` (new on main in #348) compared a completion detail against a literal `config/app.php`. The detail renders a display path with the platform separator, so Windows produced `config\app.php` and reddened the whole matrix job on a difference that has nothing to do with redaction. The expectation is built from `Path::join` instead; the assertion still pins the redaction string itself. Watson-Branch: #341 * refactor: ♻️ read `.env` comment classification from one rule `enumerate_commented_keys_in_source` re-implemented the trim → `trim_start_matches('#')` → trim classification `parse_env_source` already does, and its doc comment asked the two copies to stay in step. An invariant held by a comment is an invariant that drifts: change one side (require a space after `#`, say) and cursor hit-testing silently disagrees with what Salsa calls commented. `commented_declaration_body` is now the one definition, and every reader of a `.env` line classifies through it — Salsa's `parse_env_source`, the declaration parser that must reject exactly those lines, the commented-key enumeration that parses what they hide, and the inline-comment tokenizer. The rule is unchanged, so no behaviour moves: same `#` run, same whitespace, same body. Direct tests pin it — the marker run, the non-comment shapes, and the suffix property the column arithmetic rests on. Watson-Branch: #341 * fix: 🐛 hover the `.env` declaration under the cursor, not one found by name `env_key_at_position` resolved the cursor to an exact line in an exact file, then `hover_for_env_declaration` threw that away and asked `get_parsed_env_var` — a name-keyed, priority-merged lookup — what state the key was in. Every declaration inside one file carries that file's priority, so two declarations of one key always tie, and the tie kept the textually-first line. The cursor played no part. Both directions were wrong for the ordinary habit of commenting an old value out beside the live one: - `# APP_NAME=old` above `APP_NAME=new` — hovering the live line rendered *(commented out)*, a live declaration reported as disabled. - `APP_NAME=new` above `# APP_NAME=old` — hovering the comment rendered the live value with no commented-out marker at all. The state now comes from the cursor: `env_key_at_position` returns which enumeration matched, and a commented declaration needs no lookup at all — it is the line under the cursor, in this buffer, with no value in effect and its own file to link. The priority ladder still resolves an active declaration's value and declaring file, as the criteria ask. The tie-break itself was the mechanism, so it is fixed where it lives: at equal priority an active declaration now outranks a commented one, in both env merges, through one shared `env_var_supersedes`. A comment is not a declaration competing for the key. A commented declaration in a higher-priority file still wins — switching a key off in `.env` is how a project disables it — and hovering an active declaration it outranks now reports no value in effect rather than quoting one the application never sees. Tests: the fixture pair Holmes asked for (one file, one key, active and commented, hovered on each line, both orderings), the reverse `env('KEY')` direction and the whole-table merge on the same fixture, the commented card's own source link, and the outranked-by-a-comment case. Three thin assertions sharpened: the priority test now spans all three rungs and its middle tier alone, the zero-consumer card asserts value and source link (its fixture key matched the sensitive-name pattern, so it never carried a value at all), and the commented card asserts the value is absent. Watson-Branch: #341 * test: ✅ pin the env merge's first-wins tie on both call sites `env_var_supersedes`'s equal-priority arm has four input rows. The two mixing a comment with a live line were pinned; two *active* declarations of one key were not. Mutating that arm's `&&` to `||` therefore left the whole suite green while first-wins silently became last-wins — and `enumerate_keys_in_source` only ever resolves the cursor onto the first `KEY=` line, so hovering that line would have rendered the second declaration's value. A card describing a line other than the one under the cursor: the defect the previous commit closed, reached from the other side. The fixture for it already existed. It asserted only that the first line resolved *something*, never which declaration answered; it now asserts the first declaration's own value is on the card and the second one's is not. The table merge gets the same tie pinned as well. It already carried the comment-loses row, so leaving first-wins on the by-name side alone would have left one of two call sites of the shared rule free to drift. Mutation-verified live, all three forms the arm can take: - `||` (first-wins becomes last-wins) — both new assertions red, and only those two, which is what made this a gap rather than incidental coverage. - `false` (the pre-fix always-first-wins) — the three comment-loses tests red, the new pair green, so they discriminate on their own row. - `true` (always last-wins) — the comment-below test and both new assertions red. Row four, two commented declarations, stays unpinned deliberately: the commented branch is cursor-derived and renders no value, so no output tells the two apart. Refs #341 * fix: 🔒 render `.env` text as text, not as markdown, in cards and panels A `.env` key is everything before the first `=`, with no charset restriction, and the hover card rendered it as a bolded markdown header. A line spelling `[Update your credentials here](https://evil.example)=1` therefore put a live clickable link inside the card, and an `` variant needed no click at all. `.env.example` ships in public repositories, so opening an unfamiliar project is the whole of the reach. Close it at the renderer rather than at the four call sites that are known-unsafe today, so callers that do not exist yet are safe too. The class is "unconstrained `.env` text reaching markdown unescaped", and sweeping it found three renderer sites, not the two the review named: - `hover::render`'s bold header — the in-PR hole; - `hover::render`'s code fence, which a value carrying three backticks closes early (pre-existing, and it hits `hover_for_env` identically); - `CompletionDoc::render`, which has both shapes and is fed a raw key and a raw value by the `completion` handler's `.env` branch. `markdown_safety::escape_inline` defers its escape set to `char::is_ascii_punctuation`, whose ranges are exactly CommonMark's escapable set — a spec constant rather than a hand-listed set of "the characters that can start a construct", which is the enumeration that goes stale. `fenced_block` sizes each fence one backtick longer than the longest run inside its content. Rendered output is unchanged: CommonMark renders `\.` as `.`. The wire text gains backslashes, which is why 33 assertions across nine test files move to the escaped form — that spread is the sweep, showing which surfaces route through these two renderers. Field contracts are now explicit in both structs, so the boundary reads as a decision rather than an oversight: `header` is plain text and the renderer escapes it; `detail`, `description` and `summary` are markdown-bearing by design and a call site putting untrusted text there owns the escaping. Watson-Branch: #341 * test: ✅ make three env-navigation claims assert what their comments promise All three are prose that described a stronger test than the code under it. The module doc said every test drives the real `hover` / `goto_definition` handler "not the helpers underneath". Three of the 21 do the opposite, on purpose: the reverse direction has no `.env`-buffer handler to drive, and the two table-merge tests read the Salsa layer because that is where the shared merge rule's second call site lives. Scope the claim to the navigation tests and name the exceptions. `hover_in_a_lower_priority_file_shows_the_winning_value` promised to "name `.env` as the declaring file" and asserted three values and no link, so a mutation that resolved the right value through the ladder and then linked the *queried* file survived it. It now rules out both other rungs by name, since `.env` is a substring of each. Verified live: with `hover_for_env_declaration` linking `path` instead of `var.source_file`, the test reddens. `contains("0 references")` is also satisfied by `"10 references"`, and `contains("1 reference")` by `"1 references"`. No live mutation escaped, because no fixture reaches a double-digit count — this closes the shape rather than waiting for the fixture that makes it real. All six count assertions now route through one helper that builds the label from `code_lens::reference_count_label`, so the pluralisation rule cannot drift, and pins both ends of the match to a non-alphanumeric boundary. The helper has its own test for the two escapes above. Adds the fixture the review asked for: a key spelling a markdown link and one spelling an image, plus a value carrying a fence, driven through the real handler. Each was mutation-verified against the renderer change it guards, including on the reverse direction's call site. Watson-Branch: #341 * fix: 🔒 escape the `.env` value the completion panel shows Last round closed "unconstrained `.env` text reaching markdown unescaped" at both renderers, and wrote the boundary down: `header` is plain text the renderer escapes for every caller, `summary` is markdown-bearing and its call site owns the escaping. The `.env` branch of `completion` is the call site that hands `summary` untrusted text, and it escaped nothing. The round that documented the contract left the one site that violates it. A `.env` value has no charset restriction, any more than a key does. `SUPPORT_NOTICE=[Update your credentials here](https://evil.example)` therefore renders a live clickable link in the completion documentation panel, and the `` variant is fetched with no click at all. That is the reach the key had through the hover header, one field over, in the popup most likely to be open while the `.env` file is the one on screen. The escape wraps the whole `summary` expression, not its untrusted arm. All three arms here are plain text meant to render as themselves, so escaping the field rather than one branch of it means a fourth arm added later cannot reopen this. Two test helpers modelled the panel with an unescaped value and move to the escaped form. `assert_no_process_var_leak` now strips backslashes before its whole-response substring search: the summary is escaped, so a leaked value carrying punctuation no longer spells the raw needle there, and the "any field" reach that assertion claims would have quietly stopped covering it. That strip is not what catches the restored `std::env::vars()` loop — `label` and `detail` are plain text and still do — it is what stops the summary becoming a blind spot. The module doc now states the guarantee per field: the renderers cover the header and the code fence, and every other field is verbatim by design. Watson-Branch: #341 * fix: ✅ pin the config label the way `main` now guarantees it The `main` merge reddened `the_config_completion_response_carries_no_dotenv_secret` on Windows alone: the test expected `config\app.php` and got `config/app.php`. The test was right until this merge and is wrong after it. #336 moved the config label from a hardcoded `format!("config/{group}.php")` to `strip_prefix`, which picked up the platform separator; the commit now on `main` fixes that by routing the label through `with_forward_slashes`, because it is user-visible text that must not change shape with the host OS. `config_source_label`'s own doc names the old Windows rendering as the bug. So `config_app_display()` — which built the expectation with `Path::join(...).display()` — encoded the pre-fix behaviour, and its comment ("Windows displays `config\app.php`, which is correct for Windows") now asserts the opposite of what production guarantees. Replaced with a literal `CONFIG_APP_LABEL`. The literal is the stronger assertion, not merely the passing one: the helper mirrored the production logic, so if the normalization were ever dropped the expectation would pick up the native separator alongside it and stay green on Windows — the only platform where that regression is visible. A literal cannot follow it. Mutation: setting the constant to `config\app.php` reddens the test with exactly the CI failure inverted (`left` and `right` swapped), so the assertion discriminates on separator shape rather than merely being reached. Swept the class — `config_app_display` had no other call site, and no sibling test in `src/tests` or `tests/` builds a user-visible expectation through `display()`/`to_string_lossy()`. Watson-Branch: #341 * test: ✅ restore the "any field" reach of both env leak searches Round 5 fixed `assert_no_process_var_leak` to strip backslashes before searching, because this PR's `escape_inline` on `CompletionDoc::summary` spells a punctuated value `s3cr3t\-value\-set…` there. The reasoning was written at that one assertion and carried nowhere else, so two sibling searches were left hunting a needle the panel no longer spells: - `env_completion_system_leak.rs`, the shadowing test's standalone check (the site Holmes flagged); - `env_value_redaction.rs`'s `assert_no_secret_leak`, the same helper shape in another file, blind across all four of its needles — every one carries hyphens. Both files now name the strip as `searchable()` so the reasoning reaches every call site instead of one, and both helper docs' "any field" claim is true again. Proven live, not by inspection. A summary-only leak (the `.env` completion summary made to answer from the real value, `detail` left redacted) went entirely unnoticed by both searches before this change — the tests failed only later, on a panel-equality assertion. After it, each fails on the leak assertion itself: `env_value_redaction.rs:193` and `env_completion_system_leak.rs:488`. The strip is itself unexercised by a green suite — a leak has to exist before the escaping can hide one — so deleting it would have degraded silently, the same shape one level up. Each file now pins it at its own definition, over its own needles, and each fixture reddens alone when the strip is removed. Their first assertion fails if a needle ever loses its ASCII punctuation, which is what stops the second going vacuous. Scope checked and left alone: the hover surface renders values through `fenced_block`, which lengthens the fence and never backslashes content, so its raw-needle assertions still match; `escape_inline` reaches only the two headers and this one summary. Every other negative assertion in the tree either searches a field this PR does not escape or uses a needle with no ASCII punctuation, which escaping leaves byte-identical. Watson-Branch: #341
`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
…inment guard (#366) * chore: start work on #364 Watson-Branch: #364 * refactor: ♻️ give the actor a constructor its tests can reach `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. * fix: 🔒 put the containment guard on the read, not on its callers `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 * fix: 🔒 gate the loader on the module that owns the path 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.
Summary
Implements #344 — one policy, one home for it, every surface that echoes a
.envvalue. The policy has two gates: the AC's name predicate, and a value-shape gate added in review round 2 for the credential a name cannot see.Changes
completion_display::is_sensitive_env_name: case-insensitive, splits on_, matches a whole segment against KEY, SECRET, PASSWORD, TOKEN, CREDENTIAL, PRIVATE, AUTH, PWD. Segment matching, never substring, soAUTHOR_NAMEandTOKENIZE_INPUTkeep their values.REDACTED_ENV_VALUEis the one string every client-rendered surface prints.DATABASE_URLsplits toDATABASE/URLand matches no segment, yet stock Laravel'sconfig/database.phpreads'url' => env('DATABASE_URL')and fills it withmysql://user:hunter2@host/db.completion_display::mask_url_credentialsmasks the password and keeps the host, port and database — the whole reason anyone reads that value. It isdatabase.rs's ownmask_url_password, moved next to the name gate so the two halves of one policy live in one file and cannot become two spellings. ReturnsCow, borrowing on every fail-open path..envhover,config('…')completion, the warm-start disk cache, andmask_env_value_for_log— whose unmatched arm is now the masker rather than the raw value.detailto(from .env)and the panel to the redaction string, before the existing empty-value branch, soDB_PASSWORD=renders redacted and never(empty). An unmatched name renders its value through gate 2..envhover: a matched name replaces the code block with the redaction note; an unmatched name keeps its code block, masked. The source link survives; the commented-out and not-found paths are untouched.config('…')completion:resolve_env_valueapplies both gates before the value reachesConfigKeyCompletion(the AC's "or redact before it's stored there" option — no signature churn, and the two render sites cannot diverge). Only the dotenv-sourced path is touched: a literal default is already visible in the PHP file,${VAR}has no value, and a plain config literal is never checked — even when the config key would match.is_commentedfilter so# MAIL_PASSWORD=…is redacted too; every other value is written through gate 2, so no credential reaches the file under either gate.CACHE_VERSION5 → 6, with a compile-time floor (const _: () = assert!(CACHE_VERSION >= 6)) so the bump cannot be walked back and re-admit a plaintext cache. No further bump for gate 2: v6 has never been released.database.rslogged the value resolved from.envatinfo!, which fires under the defaultEnvFilter("info,salsa=warn")into the stderr panel Zed shows anddocs/troubleshooting.mdasks users to paste into bug reports;resolve_envlogged it again atdebug!. Both route throughmask_env_value_for_log—(set)for a matched name, the masked URL for a matched shape, the value in full forDB_HOST.(bool) env(...)branch is deleted.env_patternis unanchored, so it matches theenv('X')substring of a(bool) env('X')cast and returns beforebool_env_patternis consulted — the second arm could never run. Two arms that cannot both execute are not redundancy; they are a place for a fix to land in one and not the other, plus a doc comment and a test narrative claiming a safety property the code could not have. The cast spelling still resolves and still redacts, through the one arm, and keeps its fixture.AWS_SECRET_DECLARED→AWS_SECRETARIAT_REGION(still shares the typed prefix, no longer matches the pattern), and the shadowing test asserts the redacted rendering its secret-shaped name must produce.The class sweep behind gate 2
The class is "a
.envvalue reaching a display surface with a credential still in it." Every consumer of the parsed-dotenv map was enumerated, not just the AC's four:completion()env branch,hover_for_env,resolve_env_value, the cache writermask_env_value_for_log(info!+debug!indatabase.rs)create_env_location_from_salsa(main.rs:16173)env()diagnostic (main.rs:18010)The connection summary's neighbouring lines are still deliberately untouched:
password:masks unconditionally by field,url:routes through the same masker, anddriver:/host:/database:/username:are the connection metadata Laravel's ownconfig:showprints.using default: …echoes the literal in the PHP file being edited — surface 3's exemption.One finding worth your attention
The warm-start cache has no display consumer.
register_cached_env_varswrites the Salsa actor'senv_variablesmap;get_env_variable/get_env_variable_nameshave no caller outsidesalsa_impl, and all four client surfaces readget_all_parsed_env_vars/get_parsed_env_var, which walk registered.envsources only. So surface 4's redaction is about the plaintext on disk, not about what a warm start renders — the AC's "shows the name plus the redaction string immediately on warm start" describes a capability the code does not have today. Rather than test code that never runs, the cache-parity claim is pinned where it is observable: a matched name with an empty value (exactly what a cache read yields) renders redacted, not(empty).Acceptance Criteria
DB_PASSWORD,API_TOKEN) through all four(bool)spelling is still driven by a fixture through the surviving branchis_commentedirrelevant,CACHE_VERSIONbumpedCACHE_VERSION-forced rescan (now one test, planting both variables)completion(),hover_for_env,get_all_config_keys+ theconfig('…')completion response, a realCacheManagersave/load), assert over the whole serialized response, and assert the deserialized cache struct rather than its bytescargo clippy --all-targets --all-features -p laravel-lsp -- -D warningsandcargo test --all-featurescleanTest Plan
-D warnings,cargo fmtcleanresolve_env_value, in the cache writer, and inmask_env_value_for_log(twice — theinfo!test and thedebug!test); delete theCACHE_VERSIONcheck; make the masker echo the password instead of***; make its fail-open path reallocate instead of borrowing. A tenth confirms the survivingenv()arm is load-bearing for both spellings: droppingenv_display_valuefrom it reddens the config testDATABASE_URLfixture crosses all five surfaces. The completion leak sweep now includes its password, so every existing surface test asserts against it over the whole serialized response, and each surface additionally asserts the masked form is present — a test that merely dropped the value would otherwise pass!has_cached_data()inspected only the vendor/app/config sections and would have passed with the version check deleted — dropped;get_env_vars().is_none()is the assertion that discriminates. The pre-bump fixture now plants an ordinary variable beside the secret, asserts the whole file is dropped, then drives the real rescan and asserts the ordinary value returns unchanged while the secret comes back emptyparse_database_config,parse_env_setting,resolve_env— and assert the plaintext is absent, the masked line is present (pinned to the line that owns the value, not a bare(set)search the pre-existingpassword: (set)summary would satisfy), and an ordinaryDB_HOSTvalue still logs in fulltracing: the subscriber is installed once, globally, because a scopedwith_defaultlets an ordinary test reachresolve_envfirst with no subscriber, cacheInterest::never()for that callsite, and silently empty every later capture. That is what turned the first push red on macOS only (e18ce7a); isolation lives in a thread-local buffer, so a non-capturing test drops its output exactly as beforebool_env_patterncomment and the test narrative that named "two branches",mask_url_password's three references, theCACHE_VERSIONv6 note andCachedEnvVars's doc (both said "by name" only), and the module docs incompletion_display.rsandtests/env_value_redaction.rs. Two invariants asserted in new prose were grepped before being written:env_display_valuehas exactly one caller, andresolve_env_valueis the only route from the dotenv map intoConfigKeyCompletionFixes #344