Skip to content

feat(sourcemaps): refuse to inject into an SRI build (re-targeted to main) - #49

Merged
krassx merged 4 commits into
mainfrom
fix/sri-guard-to-main
Sep 18, 2026
Merged

krassx merged 4 commits into
mainfrom
fix/sri-guard-to-main

Conversation

@krassx

@krassx krassx commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Re-open of #46, which never reached main. #46 targeted feat/inject-exclude; #45 (that branch → main) merged ~20 seconds earlier, so #46 merged into a branch that had already been consumed. main today has --exclude but NOT the SRI guard — the SEV1 fix. Same two commits, cherry-picked onto current main.

The guard

Injecting appends bytes to every .js, so a Subresource Integrity hash the HTML already carries stops matching and the browser refuses to run the script. Measured on a real webpack 5.111 + webpack-subresource-integrity build in Chromium 151: before injecting the app ran clean; after it, the entry script was blocked and the page executed nothing.

sourcemaps inject now scans the HTML under the given paths before writing anything and exits 20, naming the page, the pinned script and the ways out (stamp earlier, --exclude, or --allow-sri).

What the adversarial review of the first draft changed

One list (collect_targets) now decides both what gets stamped and what the guard can refuse over, which closed two SEV1s and five more findings by construction:

  • A CDN publicPath build shipped blank — the canonical SRI deployment (hash locally, serve from a CDN) puts an absolute URL in the HTML, which the guard dropped as "somebody else's file" while stamping the very bytes it pinned. URLs are now matched literally, then by file name.
  • A page under a dot-directory or node_modules was skipped by the guard but its target stamped by the walk (VitePress docs/.vitepress/dist → blank page) — and a test asserted that skip as correct.
  • ./dist roots breaking exclusions, a file argument refused over someone else's pin, refusals naming nonexistent paths, stale pages and symlinked dirs refusing, overlapping roots disagreeing.

Also: absolute/cwd-relative --exclude patterns, empty pattern now exit 20, .htm/.xhtml read, no depth cap, ten mutants caught, and the three chdir tests serialized (set_current_dir is process-global).

Gates on this branch: cargo fmt --check, clippy --all-targets -D warnings (0), all unit tests + suites, e2e_flows.py ALL PASS.

🤖 Generated with Claude Code

…ipt hashes

Injecting appends bytes to every `.js`, so a Subresource Integrity hash the HTML already carries
stops matching and the browser refuses to run the script. Measured on a real webpack +
webpack-subresource-integrity build in Chromium 151: before injecting the app ran clean; after it,
`window.__ran` was false and the console carried "Failed to find a valid digest in the 'integrity'
attribute … The resource has been blocked." The page loads and executes NOTHING — strictly worse
than shipping no debug-ids at all.

`@bugsee/bundler-plugin-core` already refuses this, but it only wires vite and rollup. Angular 17+,
esbuild and Deno users drive this binary directly, and Angular's `subresourceIntegrity: true` is
exactly the config that breaks — an adversarial review of the JS-side guard is what surfaced the
gap. So the check belongs here too.

`inject` now scans the HTML under the paths it was given, before writing anything, and exits 20 with
the page, the pinned script and what to do about it. Verified on that same webpack build: exit 20,
`md5` of both bundles unchanged; `--allow-sri` proceeds and both change.

Detected: `<script integrity src=…>` and `<link rel=modulepreload|preload integrity href=…>`, quoted
or bare, in any attribute order, with a whitespace-padded URL (browsers strip it). Deliberately NOT
flagged, because each would be a silent loss of symbolication for a build we were never going to
break: a CDN script, a non-JS target, a path outside the output directory (containment is compared
by path COMPONENT, so `dist-2` is not inside `dist`), an empty `integrity`, `data-integrity` /
`data-src`, an inline script, a commented-out tag, and `rel=prefetch` — a failed prefetch is
discarded, not fatal. An `--exclude`d file does not count either: one we will not touch cannot have
its hash invalidated, and `--exclude` and the guard now share one matcher so they cannot disagree.

Six mutants caught (guard bypassed, exclusions ignored, containment dropped, empty integrity
counted, comments scanned, rel filter dropped). The tests also caught a real bug in the first draft:
the dot-directory filter was applied to the walk ROOT, so pointing `inject` at `.next`, `.nuxt` or
`.output` scanned nothing at all.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…ets stamped

An adversarial review of #45 and #46 found two SEV1s and six SEV2/3s, and they all came from one
mistake: the SRI guard computed its own idea of which files were at risk while the walk computed its
own idea of which files to stamp, so the two disagreed in both directions.

**SEV1 — a CDN `publicPath` build shipped blank.** `output.publicPath: 'https://cdn…/'` with
webpack-subresource-integrity is the CANONICAL SRI deployment: hash the local bytes, serve them from
a CDN. The HTML then carries an absolute URL, which the guard dropped as "somebody else's file" —
while the very bytes it pinned sat in `dist/` and were stamped. A URL is now matched literally
first, then by file name, so the origin prefix (or `/static/`, `/_next/`) no longer hides it.
Verified on a real webpack build with a CDN publicPath: exit 20, both bundles byte-identical.

**SEV1 — a page under a dot-directory or `node_modules` was skipped by the guard but its target was
stamped by the walk.** A VitePress build (`docs/.vitepress/dist/index.html`) had its entry stamped
and went blank. Worse, a test asserted that skip as desired behaviour. The guard now walks exactly
what the stamping walks — no directory filter, no depth cap. Verified on the VitePress shape.

The restructure: one `collect_targets` list is built once, used for stamping, and handed to the
guard, which refuses only over a file in it. That closes, by construction:

- a `./dist` or `../dist` root turning a correctly `--exclude`d pinned file into a refusal (the
  README's own `--exclude 'polyfills*.js'` example),
- `inject dist/polyfills.js` being refused because some OTHER file is pinned,
- a refusal naming a path that does not exist (`dist/static/main.js` under a `/static/` publicPath),
- a stale page pinning a deleted bundle, or a symlinked directory, refusing the run,
- overlapping roots (`inject a a/b`) disagreeing about an excluded file — the walk stamped what the
  guard had excluded.

Also from the review: roots are absolutized, so an absolute `--exclude` pattern works with a relative
root; a pattern is tried relative to the current directory too (`dist/vendor/**`); an EMPTY pattern
is now a configuration error rather than a silent match-nothing; `.htm`/`.xhtml` count as pages; and
six surviving mutants got tests (`preload` vs `modulepreload`, `.htm`/`.xhtml`, multi-line comments,
absolute exclude patterns, deep pages, root-relative resolution). Ten mutants caught now; the three
tests that chdir take a lock, because `set_current_dir` is process-global and the harness threads.

Docs say plainly what the guard cannot see: SRI that never reaches the emitted HTML — a manifest
consumed by a server template, a page rendered at request time (Next.js `experimental.sri`), or HTML
written outside the directory it was pointed at.

Regression checked against origin/main: a plain build with no SRI and no --exclude stamps exactly as
before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Comment thread src/inject/mod.rs
Comment on lines +149 to +159
// Relative to the CURRENT DIRECTORY as well: `--exclude 'dist/vendor/**'` is how a user thinks
// about their own build, and the roots are absolutized before the walk, so without this the
// pattern would match nothing at all.
if let Ok(cwd) = std::env::current_dir() {
if path
.strip_prefix(sri::lexical_normalize(&cwd))
.is_ok_and(|rel| matcher.is_match(rel))
{
return true;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--help drift: --exclude now also matches a pattern relative to the current directory (this new fallback), in addition to the walked root and the full/absolute path. But the clap doc comment on exclude in src/cli/sourcemaps.rs (line 23) still says only "Matched against the path relative to each walked root and against the full path" — it wasn't updated in this change, even though is_excluded's matching semantics changed here. Per CLAUDE.md, --help must stay in sync with behavior in the same change. Suggest updating that doc comment to mention the current-directory-relative match too (the README's "Other defaults" section already got this treatment — the clap doc comment did not).

@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown

Code review

This adds an SRI (Subresource Integrity) pre-flight guard to sourcemaps inject: it now scans HTML under the given paths, resolves pinned <script>/<link rel=modulepreload|preload> targets against the exact set of files this run would stamp, and refuses (exit 20, Error::ConfigInvalid) unless --allow-sri is passed. It also fixes a real bug where a relative --exclude root or pattern silently failed to match (now tried against the absolute path, the cwd-relative path, and each walked root). The refactor consolidates the walk into one shared collect_targets list so the guard and the actual stamping can't disagree about which files are in scope — a good design choice, and it's backed by thorough unit tests (CDN publicPath resolution, overlapping roots, .htm/.xhtml, comments, data-integrity false-positive avoidance, etc.). Exit code choice (ConfigInvalid/20) is consistent with the existing bad---exclude-glob precedent, and no stdout/wire-shape/daemonizing/Cargo.toml files are touched.

Findings: 1 inline (0 blocking).

  • --help drift: the --exclude matching semantics changed (added a current-directory-relative fallback) but the clap doc comment on exclude in src/cli/sourcemaps.rs wasn't updated to describe it, even though the README was. See inline comment on src/inject/mod.rs.

Non-blocking — recommend merging after a one-line doc-comment fix for the finding above.

…st stay a no-op

A review of the whole 0.7.11 delta found the guard refusing over files it would never rewrite.

`--help` promises "re-running on already-injected files is a no-op", and the documented workflow for
a build that pins its hashes is `--allow-sri` once, then let the build recompute them. Any later run
without the flag — a second CI job, an upload step that re-injects, a retry after a flaky step —
then failed with exit 20 while changing nothing. A 0.7.10 user with that pipeline would break on
upgrade.

The guard now filters the pinned scripts through `would_rewrite`, which mirrors `inject_one`'s
decision: a fresh bundle, a re-key (regenerated map), or a foreign id still missing our registration
all count; an already-stamped bundle with an unchanged map does not. A test drives both functions
over the same fixtures and asserts they agree, so they cannot drift apart — which is the exact class
of bug the shared-target-list design exists to prevent.

Also from the same review:

- **The guard was blind to a page one level ABOVE the given path.** The standard Vite/webpack layout
  is `dist/index.html` beside `dist/assets/*.js`, so `inject dist/assets` (or one bundle by path)
  stamped the pinned file and exited 0. Pages directly in the parent are now read too — only when
  the given path holds no pages of its own, and bounded to 2000 entries, because that parent can be
  a directory nobody meant us to read. (`/tmp` with thousands of entries: the unbounded first
  attempt took the inject tests from 0.07 s to 23 s.)
- **The "exit 20 for a bad `--exclude` glob" contract was asserted nowhere** — the test checked only
  that the message named the flag, so the classification a CI script actually reads was left to
  chance. Same for the dry-run refusal, which asserted a bare `is_err()`.
- Docs: `--exclude`'s `--help` did not mention the cwd-relative match, the CHANGELOG line about it
  was garbled ("relative to the absolute path"), and neither said that a CDN script sharing a file
  name with one of your bundles IS refused, or that `--dry-run` refuses.

Five mutants caught (filter removed, filter always-on, registration case dropped, parent scan
removed, parent scan emptied).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…ims had drifted

The review of the 0.7.11 delta caught all four: "Matched against the path relative to the absolute
path" (nonsense — README had it right), "refuses only over a file this run would really stamp"
(now: would REWRITE, which is what makes a re-run a no-op), "not flagged: a genuine third-party CDN
script" (unqualified — one sharing a file name with your bundle IS refused), and nothing saying
where pages are read from or that --dry-run refuses.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@krassx

krassx commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Review of the full 0.7.11 delta — findings fixed here (ca116b2, 9727cd1)

A read-only adversarial pass over everything since v0.7.10 (this PR plus #45, #47, #48, composed on one integration branch). Its SEV1 belongs to #48 and is fixed there; everything else on this PR:

# Finding Status
SEV2 Re-running inject on an already-stamped SRI build exited 20, while --help promises re-running is a no-op. The documented workflow (--allow-sri once, build recomputes hashes) then broke on any later run — a second CI job, a retry — and a 0.7.10 user with that pipeline would break on upgrade Fixed — the guard filters pinned scripts through would_rewrite, and a test drives it and inject_one over the same fixtures asserting they agree, so they cannot drift. Verified on the real webpack+SRI build: --allow-sri → re-run exits 0, js_already_injected=2, bytes identical
SEV3 Blind to a page one level ABOVE the given path — dist/index.html beside dist/assets/*.js is the standard layout, so inject dist/assets stamped the pinned file and exited 0 Fixed — pages directly in the parent are read too, but only when the given path holds none of its own, and bounded to 2000 entries. (The unbounded first attempt read /tmp and took the inject tests from 0.07 s to 23 s.) Verified: both the assets-subdir and single-bundle-by-path shapes now exit 20
SEV3 The "exit 20 for a bad --exclude glob" contract was asserted nowhere — the test checked only that the message named the flag, so the classification a CI script reads was left to chance; a mutant swapping it to InputInvalid survived Fixed — both that test and the dry-run refusal (which asserted a bare is_err()) now assert the exit classification
SEV3 --dry-run refuses while the same release makes upload --dry-run tolerant; undocumented Documented — the preview of a run that would refuse is a refusal, and it says why
SEV3 Doc drift: CHANGELOG's exclude line was garbled ("relative to the absolute path"), --exclude's --help omitted the cwd-relative match, "not flagged: a genuine third-party CDN script" was unqualified (one sharing a file name with your bundle IS refused), "refuses only over a file it would stamp" → REWRITE Fixed

Five mutants caught for the new logic (filter removed, filter always-on, registration case dropped, parent scan removed, parent scan emptied).

Gates: cargo fmt --check, clippy --all-targets -D warnings (0), all unit tests + suites, e2e_flows.py ALL PASS.

🤖 Generated with Claude Code

@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown

Code review

Adds sourcemaps inject --allow-sri and a default-on guard that scans HTML under the injected paths for integrity-pinned <script>/<link> tags and refuses (exit 20) if injection would rewrite a pinned bundle's bytes. The guard reuses the same target list (collect_targets) that drives the actual stamping, so it can't disagree with the walk about what gets touched — the failure mode the PR description says the earlier draft had.

I checked this against every category in scope: --help/doc-comment drift (the new allow_sri field has a proper /// doc comment, and the CLI synopsis in README/CHANGELOG was updated to match), stdout purity (no println!/stray stdout added — only tracing and pre-existing test eprintln!), exit codes (Error::ConfigInvalid → 20, reusing the existing code rather than repurposing anything), wire shapes (none touched — inject has no stdout JSON contract), Cargo.toml table ordering (no Cargo.toml change in this diff), MSRV (LazyLock is the only new-ish API, stable since 1.80, well under the declared 1.88 floor), daemonizing (not touched), cross-platform (no Unix-only APIs in the new code; the one cfg(unix) test block is pre-existing and untouched in substance), and panics on untrusted input (all new .unwrap()s are on compile-time-constant regex literals, not disk/network data).

The implementation itself is unusually well defended: would_rewrite mirrors inject_one's actual write decision and is pinned together by a dedicated cross-checking test (would_rewrite_agrees_with_what_inject_actually_writes), and the path-resolution logic (literal-then-by-filename, CDN publicPath, root-relative URLs, dot-directories, file-argument invocations) is covered by pointed regression tests for each of the failure modes called out in the PR description.

Findings: None.

Looks sound — happy to merge as is.

Comment thread src/inject/sri.rs
.map(|e| e.path().to_path_buf())
.collect();
if pages.is_empty() {
pages = pages_directly_in(base.parent());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs (README/CHANGELOG) promise "pages are read from anywhere under the given paths, plus any sitting directly in a given path's parent" — but the implementation only falls back to the parent when the recursive scan under root finds zero pages (if pages.is_empty()), i.e. it's an "or", not a "plus"/union.

Concretely: inject dist/widget where dist/widget/ contains widget.js plus some unrelated HTML that pins nothing (a demo page, a bundle-analyzer report, a stray doc page copied into the output), while the real entry page dist/index.html (one level up) is the one that pins widget.js's hash — the guard will silently miss it and let the build ship broken, which is exactly the class of SEV1 this PR exists to close.

Consider unioning the parent-level pages in rather than only using them when the under-root scan is empty.

@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown

Code review

This adds an SRI (Subresource Integrity) guard to sourcemaps inject: before stamping any .js, it scans HTML under the given paths for integrity-pinned <script>/<link rel=modulepreload|preload> tags and refuses (exit 20, existing ConfigInvalid mapping — not a new/repurposed code) unless --allow-sri is passed. It shares one target list (collect_targets) between the walk that stamps and the guard that refuses, which is the right design to keep the two from disagreeing. --help/README/CHANGELOG are updated in step with the new --allow-sri flag and the reworked --exclude doc comment; stdout purity, exit-code contract, Cargo.toml ordering, and MSRV (the new std::sync::LazyLock usage needs 1.80, well under the declared 1.88 floor) all check out. Test coverage is thorough, including a test that pins would_rewrite's prediction against what inject_one actually writes.

Findings: 1 inline (0 blocking).

  • src/inject/sri.rs:203 — the parent-directory fallback for pages is gated on the under-root scan finding zero HTML pages, but the docs promise it runs in addition ("plus any sitting directly in the parent"). If the given root contains any unrelated HTML that pins nothing, the real pinning page one level up is never scanned, silently missing a build the guard exists to catch.

Not blocking — it requires a specific layout (unrelated HTML inside the given root while the actual pinning page sits in the parent) and the rest of the guard's design and tests are solid. Worth fixing before/soon after merge, but I'd merge as is if timing matters and follow up.

@krassx
krassx merged commit 5056df8 into main Sep 18, 2026
20 checks passed
@krassx
krassx deleted the fix/sri-guard-to-main branch September 18, 2026 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant