From bf31564ab3c1be2761f6e7d6dd7ce311c4847d20 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 18 Sep 2026 18:15:47 +0500 Subject: [PATCH 1/4] feat(sourcemaps): refuse to inject into a build that pins its own script hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: `"#, + ) + .unwrap(); + + let err = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("Subresource Integrity"), "{message}"); + assert!(message.contains("index.html"), "{message}"); + assert!(message.contains("--allow-sri"), "{message}"); + assert_eq!( + crate::error::classify(&anyhow::Error::new(err)), + crate::exit_code::ExitCode::ConfigInvalid + ); + // Nothing was written: the refusal leaves the build exactly as the bundler emitted it. + assert_eq!( + std::fs::read_to_string(dir.path().join("main.js")).unwrap(), + "console.log(1)\n" + ); + } + + /// The escape hatch, for a build that recomputes its hashes after this runs. + #[test] + fn allow_sri_proceeds_anyway() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("main.js"), "console.log(1)\n").unwrap(); + std::fs::write( + dir.path().join("index.html"), + r#""#, + ) + .unwrap(); + + let stats = inject_paths(&[dir.path().to_path_buf()], &[], true, false).unwrap(); + assert_eq!(stats.js_injected, 1); + assert!(std::fs::read_to_string(dir.path().join("main.js")) + .unwrap() + .contains("debugId=")); + } + + /// A pinned file we were never going to touch is not a reason to refuse: `--exclude` already + /// takes it out of the run, so the hash it pins still matches. + #[test] + fn a_pinned_script_that_is_excluded_does_not_refuse_the_run() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("vendor")).unwrap(); + std::fs::write(dir.path().join("vendor/pinned.js"), "console.log(1)\n").unwrap(); + std::fs::write(dir.path().join("app.js"), "console.log(2)\n").unwrap(); + std::fs::write( + dir.path().join("index.html"), + r#""#, + ) + .unwrap(); + + let stats = inject_paths( + &[dir.path().to_path_buf()], + &["vendor/**".to_string()], + false, + false, + ) + .unwrap(); + assert_eq!((stats.js_injected, stats.js_excluded), (1, 1)); + assert!( + !std::fs::read_to_string(dir.path().join("vendor/pinned.js")) + .unwrap() + .contains("debugId=") + ); + } + + /// A dry run reports the refusal too — it is the diagnostic that explains WHY nothing happens. + #[test] + fn a_dry_run_refuses_a_pinned_build_as_well() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("main.js"), "console.log(1)\n").unwrap(); + std::fs::write( + dir.path().join("index.html"), + r#""#, + ) + .unwrap(); + + assert!(inject_paths(&[dir.path().to_path_buf()], &[], false, true).is_err()); + } + /// `--exclude` keeps `inject` out of parts of a build output it should not rewrite. Measured /// need: a stock `next build` with browser source maps on has 39 JS files and 12 maps, and a /// Nuxt `.output/server/node_modules` holds 22 `.mjs` — vendored third-party code inside the @@ -476,6 +625,7 @@ mod tests { &[dir.path().to_path_buf()], &["**/node_modules/**".to_string()], false, + false, ) .unwrap(); @@ -507,6 +657,7 @@ mod tests { &[dir.path().to_path_buf()], &["polyfills.js".to_string(), "**/legacy-*.js".to_string()], false, + false, ) .unwrap(); @@ -529,6 +680,7 @@ mod tests { &[dir.path().to_path_buf()], &["nothing/**".to_string()], false, + false, ) .unwrap(); assert_eq!(stats.js_injected, 1); @@ -538,6 +690,7 @@ mod tests { &[dir.path().to_path_buf()], &["[unclosed".to_string()], false, + false, ) .unwrap_err(); assert!( @@ -557,6 +710,7 @@ mod tests { let stats = inject_paths( &[dir.path().to_path_buf()], &["vendor/**".to_string()], + false, true, ) .unwrap(); @@ -587,7 +741,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("app.js"), "console.log(1)\n").unwrap(); std::fs::write(dir.path().join("app.js.map"), map).unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); read_debug_id(&dir.path().join("app.js.map")) .unwrap() .unwrap() @@ -633,11 +787,11 @@ mod tests { r#"{"version":3,"sources":["a.ts"],"mappings":"AAAA"}"#, ) .unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); // The bundler re-emits the JS (no stub) but leaves the stamped map. std::fs::write(&js, "console.log(1)\n").unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let bundle_id = existing_debug_id(&std::fs::read_to_string(&js).unwrap()).unwrap(); let map_json: serde_json::Value = @@ -664,7 +818,7 @@ mod tests { eprintln!("skipping: running with permission to read a 000 file"); return; } - let result = inject_paths(&[dir.path().to_path_buf()], &[], false); + let result = inject_paths(&[dir.path().to_path_buf()], &[], false, false); std::fs::set_permissions(&map, std::fs::Permissions::from_mode(0o644)).unwrap(); assert!(result.is_err()); assert_eq!(std::fs::read_to_string(&js).unwrap(), "console.log(1)\n"); @@ -721,13 +875,13 @@ mod tests { ) .unwrap(); - let first = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let first = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let map_json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&map).unwrap()).unwrap(); assert_eq!(first.maps_updated, 1); assert_eq!(map_json["debug_id"], id); assert_eq!(map_json["debugId"], id); - let second = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let second = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!(second.maps_updated, 0); } @@ -746,10 +900,10 @@ mod tests { let map = dir.path().join("shared.map"); std::fs::write(&map, r#"{"version":3,"mappings":""}"#).unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let after_first = std::fs::read(&map).unwrap(); for _ in 0..2 { - let stats = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!(stats.maps_updated, 0); assert_eq!(std::fs::read(&map).unwrap(), after_first); } @@ -772,13 +926,13 @@ mod tests { r#"{"version":3,"sources":["a.ts"],"mappings":"AAEA"}"#, ) .unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let first = read_debug_id(&map).unwrap().unwrap(); // Rebuild: JS untouched on disk, map re-emitted with shifted mappings and no id. let moved = r#"{"version":3,"sources":["a.ts"],"mappings":"AAKA"}"#; std::fs::write(&map, moved).unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let expected = compute_debug_id_with_map(bundle.as_bytes(), Some(moved.as_bytes())).to_string(); @@ -791,7 +945,7 @@ mod tests { assert_eq!(stats.maps_updated, 1); // And it settles: nothing changes on the next run. - let again = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let again = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!((again.js_restamped, again.maps_updated), (0, 0)); assert_eq!(std::fs::read_to_string(&js).unwrap(), js_after); } @@ -805,12 +959,12 @@ mod tests { let original_map = r#"{"version":3,"sources":["a.ts"],"mappings":"AAEA"}"#; std::fs::write(&js, "console.log(1)\n").unwrap(); std::fs::write(&map, original_map).unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let js_stamped = std::fs::read_to_string(&js).unwrap(); let id = read_debug_id(&map).unwrap().unwrap(); std::fs::write(&map, original_map).unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!(stats.js_restamped, 0); assert_eq!(std::fs::read_to_string(&js).unwrap(), js_stamped); assert_eq!(read_debug_id(&map).unwrap().unwrap(), id); @@ -831,7 +985,7 @@ mod tests { r#"{"version":3,"mappings":"AAKA"}"#, ) .unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!(stats.js_restamped, 0); // Never re-keyed: the id stays; only our runtime registration is added. assert_eq!( @@ -853,11 +1007,11 @@ mod tests { let map = dir.path().join("app.js.map"); std::fs::write(&js, "console.log(1)\n").unwrap(); std::fs::write(&map, r#"{"version":3,"mappings":"AAEA"}"#).unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let js_stamped = std::fs::read_to_string(&js).unwrap(); std::fs::write(&map, "not json").unwrap(); - assert!(inject_paths(&[dir.path().to_path_buf()], &[], false).is_err()); + assert!(inject_paths(&[dir.path().to_path_buf()], &[], false, false).is_err()); assert_eq!(std::fs::read_to_string(&js).unwrap(), js_stamped); } @@ -871,7 +1025,7 @@ mod tests { let map = dir.path().join("app.js.map"); std::fs::write(&js, "console.log(1)\n").unwrap(); std::fs::write(&map, r#"{"version":3,"mappings":"AAEA"}"#).unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let js_stamped = std::fs::read_to_string(&js).unwrap(); let id = read_debug_id(&map).unwrap().unwrap(); @@ -880,7 +1034,7 @@ mod tests { format!(r#"{{"version":3,"mappings":"AAEA","debugId":"{id}"}}"#), ) .unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!(stats.js_restamped, 0); assert_eq!(std::fs::read_to_string(&js).unwrap(), js_stamped); assert_eq!(read_debug_id(&map).unwrap().unwrap(), id); @@ -893,11 +1047,11 @@ mod tests { let map = dir.path().join("app.js.map"); std::fs::write(&js, "console.log(1)\n").unwrap(); std::fs::write(&map, r#"{"version":3,"mappings":"AAEA"}"#).unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let js_stamped = std::fs::read_to_string(&js).unwrap(); std::fs::write(&map, r#"{"version":3,"mappings":"AAKA"}"#).unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], true).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, true).unwrap(); assert_eq!((stats.js_restamped, stats.maps_updated), (1, 1)); assert_eq!(std::fs::read_to_string(&js).unwrap(), js_stamped); assert_eq!(read_debug_id(&map).unwrap(), None); @@ -917,7 +1071,7 @@ mod tests { r#"{"version":3,"mappings":"","debug_id":"11111111-1111-5111-8111-111111111111","debugId":"11111111-1111-5111-8111-111111111111"}"#, ) .unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], true).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, true).unwrap(); assert_eq!((stats.js_injected, stats.maps_updated), (1, 1)); } @@ -936,7 +1090,7 @@ mod tests { } let map = dir.path().join("shared.map"); std::fs::write(&map, r#"{"version":3,"mappings":""}"#).unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let z = existing_debug_id(&std::fs::read_to_string(dir.path().join("z.js")).unwrap()).unwrap(); assert_eq!(read_debug_id(&map).unwrap().unwrap(), z); @@ -975,7 +1129,7 @@ mod tests { let (js, map, id) = rollup_bundle(dir.path()); let before = std::fs::read_to_string(&js).unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let after = std::fs::read_to_string(&js).unwrap(); assert_eq!(after, format!("{before}{}", runtime_registration(&id))); @@ -993,7 +1147,7 @@ mod tests { ); // Idempotent: the registration is found, nothing is appended again. - let again = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let again = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!(std::fs::read_to_string(&js).unwrap(), after); assert_eq!( (again.js_registered, again.js_already, again.maps_updated), @@ -1006,7 +1160,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (js, _map, _id) = rollup_bundle(dir.path()); let before = std::fs::read_to_string(&js).unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], true).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, true).unwrap(); assert_eq!(stats.js_registered, 1); assert_eq!(std::fs::read_to_string(&js).unwrap(), before); } @@ -1018,10 +1172,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let js = dir.path().join("app.js"); std::fs::write(&js, "console.log(1)\n").unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let stamped = std::fs::read_to_string(&js).unwrap(); for _ in 0..2 { - let stats = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!(stats.js_registered, 0); } assert_eq!(std::fs::read_to_string(&js).unwrap(), stamped); @@ -1034,11 +1188,11 @@ mod tests { fn a_registered_foreign_id_is_not_rekeyed_when_its_map_comes_back_without_one() { let dir = tempfile::tempdir().unwrap(); let (js, map, id) = rollup_bundle(dir.path()); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let registered = std::fs::read_to_string(&js).unwrap(); std::fs::write(&map, r#"{"version":3,"mappings":"AAKA"}"#).unwrap(); - let stats = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let stats = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!((stats.js_restamped, stats.js_registered), (0, 0)); assert_eq!(std::fs::read_to_string(&js).unwrap(), registered); assert_eq!(read_debug_id(&map).unwrap().unwrap(), id); @@ -1073,7 +1227,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let js = dir.path().join("app.js"); std::fs::write(&js, "console.log(1)\n").unwrap(); - inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); let injected = std::fs::read_to_string(&js).unwrap(); assert_eq!( existing_debug_id(&injected).unwrap(), @@ -1108,7 +1262,7 @@ mod tests { std::fs::write(&js, "console.log('hi')\n//# sourceMappingURL=app.js.map\n").unwrap(); std::fs::write(&map, r#"{"version":3,"sources":[],"mappings":""}"#).unwrap(); - let s1 = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let s1 = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!(s1.js_injected, 1); assert_eq!(s1.maps_updated, 1); @@ -1129,7 +1283,7 @@ mod tests { ); // Re-running is a no-op (idempotent). - let s2 = inject_paths(&[dir.path().to_path_buf()], &[], false).unwrap(); + let s2 = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); assert_eq!(s2.js_injected, 0, "already injected"); assert_eq!(s2.js_already, 1); assert_eq!( @@ -1156,7 +1310,7 @@ mod tests { let js_before = std::fs::read_to_string(&js).unwrap(); let map_before = std::fs::read_to_string(&map).unwrap(); - let s = inject_paths(&[dir.path().to_path_buf()], &[], true).unwrap(); + let s = inject_paths(&[dir.path().to_path_buf()], &[], false, true).unwrap(); assert_eq!(s.js_injected, 1); // The map WOULD have changed (no debug_id yet), so the intent is tallied // even though nothing is written to disk in dry-run. diff --git a/src/inject/sri.rs b/src/inject/sri.rs new file mode 100644 index 0000000..f6706c9 --- /dev/null +++ b/src/inject/sri.rs @@ -0,0 +1,372 @@ +//! Refuse to stamp a build that pins its own script hashes (Subresource Integrity). +//! +//! `inject` appends the debug-id comment and the runtime registration to every `.js` it finds. A +//! build that computed SRI hashes during emit — `webpack-subresource-integrity`, Angular's +//! `subresourceIntegrity: true` — has already written a hash of the PRE-stamp bytes into its HTML, +//! so the browser refuses the script and the page runs NOTHING. +//! +//! Measured (bugsee-javascript, 2026-09-18) on a real webpack 5.111 build served over HTTP and +//! loaded in Chromium 151: before inject the app ran clean; after it, `window.__ran` was false and +//! the console carried "Failed to find a valid digest in the 'integrity' attribute for resource +//! '…/main..js' … The resource has been blocked." `index.html` was byte-identical; only the +//! JS grew, 114 → 472 bytes. +//! +//! The JS bundler plugins carry the same guard, but they only cover vite and rollup: Angular 17+, +//! esbuild and Deno users drive this binary directly, and Angular is exactly the config this +//! protects against. + +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; + +use std::sync::LazyLock; + +use regex::Regex; + +/// One `", + ); + + let found = find_pinned_scripts(&[dir.path().to_path_buf()]); + assert_eq!(found.len(), 1); + assert_eq!(found[0].script, dir.path().join("main.abc123.js")); + assert_eq!(found[0].html, dir.path().join("index.html")); + } + + /// A failed `modulepreload` poisons the module map, so the later `import()` fails with it. + /// Angular's builder and the Vite SRI plugins emit these. + #[test] + fn a_pinned_modulepreload_counts_but_a_prefetch_does_not() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "chunk.js", "1"); + write(dir.path(), "later.js", "2"); + write( + dir.path(), + "index.html", + r#" + "#, + ); + + assert_eq!(pinned(dir.path()), vec![dir.path().join("chunk.js")]); + } + + /// Quoting, attribute order, root-relative paths, `.mjs`/`.cjs`, nested pages — one pass over + /// the shapes bundlers actually emit. + #[test] + fn it_reads_the_shapes_bundlers_emit() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "a.mjs", "1"); + write(dir.path(), "assets/b.cjs", "2"); + write(dir.path(), "assets/c.js", "3"); + write( + dir.path(), + "index.html", + r#" + "#, + ); + write( + dir.path(), + "nested/page.html", + r#""#, + ); + + let mut found = pinned(dir.path()); + found.sort(); + let mut want = vec![ + dir.path().join("a.mjs"), + dir.path().join("assets/b.cjs"), + dir.path().join("assets/c.js"), + ]; + want.sort(); + assert_eq!(found, want); + } + + /// Everything that must NOT stop a build. Each one would be a silent loss of symbolication for + /// a user we were never going to break. + #[test] + fn it_does_not_fire_on_anything_we_would_not_break() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "main.js", "1"); + write(dir.path(), "app.css", "body{}"); + write(dir.path(), "app.wasm", "\0asm"); + write(dir.path(), "../sibling.js", "1"); + write( + dir.path(), + "index.html", + r#" + + + + + + + + "#, + ); + + assert_eq!(pinned(dir.path()), Vec::::new()); + } + + /// A sibling directory sharing a prefix is NOT inside the output, and neither is a parent. + #[test] + fn containment_is_by_path_component_not_by_prefix() { + let root = tempfile::tempdir().unwrap(); + let out = root.path().join("dist"); + write(root.path(), "dist-2/main.js", "1"); + write(root.path(), "vendor.js", "2"); + write( + &out, + "index.html", + r#" + "#, + ); + + assert_eq!(find_pinned_scripts(&[out]), Vec::new()); + } + + /// Pointed at a FILE (`inject dist/app.js`), the scan still sees the page beside it — that is + /// the invocation a hand-written build script uses. + #[test] + fn a_file_argument_still_scans_its_directory() { + let dir = tempfile::tempdir().unwrap(); + let js = write(dir.path(), "app.js", "1"); + write( + dir.path(), + "index.html", + r#""#, + ); + + let found = find_pinned_scripts(&[js]); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].script, dir.path().join("app.js")); + } + + /// No HTML, an unreadable page, and a missing directory are all "nothing to report" — this + /// guard must never be the thing that fails a run. + #[test] + fn it_is_silent_when_there_is_nothing_to_find() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "index.js", "1"); + assert_eq!(pinned(dir.path()), Vec::::new()); + assert_eq!( + find_pinned_scripts(&[dir.path().join("does-not-exist")]), + Vec::new() + ); + } + + /// `node_modules` and dot-directories inside a build output are not the app's pages. + #[test] + fn it_skips_node_modules_and_dot_directories() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "main.js", "1"); + write( + dir.path(), + "node_modules/pkg/demo.html", + r#""#, + ); + write( + dir.path(), + ".cache/page.html", + r#""#, + ); + + assert_eq!(pinned(dir.path()), Vec::::new()); + } +} From 1a158e562b4e586e6239c2ae73ffa36a1e182308 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 18 Sep 2026 18:39:26 +0500 Subject: [PATCH 2/4] fix(sourcemaps): the guard and the walk must share one list of what gets stamped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 23 +++- README.md | 24 +++- src/inject/mod.rs | 247 ++++++++++++++++++++++++++++++++--- src/inject/sri.rs | 324 +++++++++++++++++++++++++++++++++++++--------- 4 files changed, 532 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18aeec..29d8cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,17 +16,30 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). carry their own copy of this guard, but they only cover vite and rollup. Detected: `"#, + ) + .unwrap(); + + let _serialized = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir.path().parent().unwrap()).unwrap(); + let name = dir + .path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + let result = ["./", ""] + .into_iter() + .try_fold(Vec::new(), |mut acc, prefix| { + let root = PathBuf::from(format!("{prefix}{name}")); + inject_paths(&[root], &["polyfills*.js".to_string()], false, true).map(|stats| { + acc.push(stats.js_excluded); + acc + }) + }); + std::env::set_current_dir(cwd).unwrap(); + + assert_eq!( + result.unwrap(), + vec![1, 1], + "both spellings must exclude it" + ); + } + + /// An absolute `--exclude` pattern is documented to work, and did not when the root was + /// relative — the matcher only ever saw the path as typed. + #[test] + fn an_absolute_exclude_pattern_works_whatever_the_root_looks_like() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("vendor")).unwrap(); + std::fs::write(dir.path().join("vendor/v.js"), "console.log(1)\n").unwrap(); + std::fs::write(dir.path().join("app.js"), "console.log(2)\n").unwrap(); + let _serialized = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir.path().parent().unwrap()).unwrap(); + let name = dir + .path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + // Built AFTER the chdir, from the resolved working directory: on macOS the fixture lives + // under a `/var` symlink whose real name is `/private/var`, and the absolute path a user in + // that directory would type is the resolved one. + let absolute = format!( + "{}/vendor/**", + std::env::current_dir() + .unwrap() + .join(&name) + .to_string_lossy() + ); + let stats = inject_paths(&[PathBuf::from(name)], &[absolute], false, true); + std::env::set_current_dir(cwd).unwrap(); + + assert_eq!(stats.unwrap().js_excluded, 1); + } + + /// A pattern written the way the user sees their own tree — `dist/vendor/**` from the directory + /// above — must work. The roots are absolutized before the walk, so nothing would match without + /// also trying the path relative to the current directory. + #[test] + fn an_exclude_pattern_relative_to_the_current_directory_works() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("vendor")).unwrap(); + std::fs::write(dir.path().join("vendor/v.js"), "console.log(1)\n").unwrap(); + std::fs::write(dir.path().join("app.js"), "console.log(2)\n").unwrap(); + + let _serialized = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir.path().parent().unwrap()).unwrap(); + let name = dir + .path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + let stats = inject_paths( + &[PathBuf::from(&name)], + &[format!("{name}/vendor/**")], + false, + true, + ); + std::env::set_current_dir(cwd).unwrap(); + + assert_eq!(stats.unwrap().js_excluded, 1); + } + + /// An empty pattern matches nothing, which is the same failure mode as a malformed one: the + /// caller believes a file is protected when it is not. + #[test] + fn an_empty_exclude_pattern_is_a_configuration_error() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("app.js"), "console.log(1)\n").unwrap(); + + let err = + inject_paths(&[dir.path().to_path_buf()], &["".to_string()], false, true).unwrap_err(); + assert!(format!("{err:#}").contains("empty pattern"), "{err:#}"); + assert_eq!( + crate::error::classify(&anyhow::Error::new(err)), + crate::exit_code::ExitCode::ConfigInvalid + ); + } + + /// Overlapping roots are a supported invocation (`inject a a/b`), and the exclusion must hold + /// for the file whichever root reached it — the guard and the walk share one list precisely so + /// they cannot answer differently. + #[test] + fn overlapping_roots_agree_about_an_excluded_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("b")).unwrap(); + std::fs::write(dir.path().join("b/x.js"), "console.log(1)\n").unwrap(); + + let stats = inject_paths( + &[dir.path().to_path_buf(), dir.path().join("b")], + &["b/**".to_string()], + false, + false, + ) + .unwrap(); + + assert_eq!((stats.js_injected, stats.js_excluded), (0, 1)); + assert!(!std::fs::read_to_string(dir.path().join("b/x.js")) + .unwrap() + .contains("debugId=")); + } + /// `--exclude` keeps `inject` out of parts of a build output it should not rewrite. Measured /// need: a stock `next build` with browser source maps on has 39 JS files and 12 maps, and a /// Nuxt `.output/server/node_modules` holds 22 `.mjs` — vendored third-party code inside the diff --git a/src/inject/sri.rs b/src/inject/sri.rs index f6706c9..5a9a6de 100644 --- a/src/inject/sri.rs +++ b/src/inject/sri.rs @@ -47,8 +47,10 @@ static SRC_ATTR: LazyLock = LazyLock::new(|| Regex::new(r#"(?i)[\s"'<]src\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)"#).unwrap()); static HREF_ATTR: LazyLock = LazyLock::new(|| Regex::new(r#"(?i)[\s"'<]href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)"#).unwrap()); -static ABSOLUTE_URL: LazyLock = - LazyLock::new(|| Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*:").unwrap()); +// `https:`, `//cdn…` — the origin half of a URL, which has no counterpart on disk. What follows it +// still can: `publicPath` pointing at a CDN is the canonical SRI deployment. +static ABSOLUTE_URL_PREFIX: LazyLock = + LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*:)?//").unwrap()); fn unquote(value: &str) -> &str { let bytes = value.as_bytes(); @@ -62,32 +64,61 @@ fn unquote(value: &str) -> &str { } } -/// The file a `src`/`href` names, when it is one we could stamp: same-origin, and inside `root`. -fn resolve_local_script(root: &Path, html_dir: &Path, url: &str) -> Option { - // A URL with a scheme, or protocol-relative, is somebody else's file (a CDN): its bytes are not - // ours to change, so stamping cannot invalidate its hash. - if url.starts_with("//") || ABSOLUTE_URL.is_match(url) { - return None; - } +/// The file a `src`/`href` names, resolved against the files this run would actually stamp. +/// +/// `targets` is the whole point: the only hash we can invalidate is one belonging to a file we are +/// going to rewrite. Matching against the file system instead produced both kinds of error — a stale +/// page pinning a deleted bundle refused the run, while a page whose URL did not resolve literally +/// (a CDN `publicPath`) sailed through and the build shipped blank. +fn resolve_pinned_target( + roots: &[PathBuf], + targets: &BTreeSet, + html_dir: &Path, + url: &str, +) -> Option { // Browsers strip surrounding whitespace from a URL attribute, so `src=" main.js "` loads it. - let path_part = url.split(['?', '#']).next().unwrap_or("").trim(); + let trimmed = url.trim(); + let path_part = if let Some(rest) = ABSOLUTE_URL_PREFIX.find(trimmed) { + // `https://cdn.example.com/assets/main.abc.js` — webpack's `output.publicPath` pointing at a + // CDN is the CANONICAL SRI deployment (hash the local bytes, serve them from the CDN), and + // those bytes are the ones sitting in the output directory. Keep the path, drop the origin. + let after_scheme = &trimmed[rest.end()..]; + after_scheme.split_once('/').map(|(_, p)| p).unwrap_or("") + } else { + trimmed + }; + let path_part = path_part.split(['?', '#']).next().unwrap_or("").trim(); if path_part.is_empty() { return None; } - let candidate = if let Some(rooted) = path_part.strip_prefix('/') { - // A root-relative `/assets/app.js` is served from the output root. - root.join(rooted) + + // 1. Where the URL literally points, relative to the page (or to the root when root-relative). + let literal = if let Some(rooted) = path_part.strip_prefix('/') { + roots + .iter() + .map(|r| lexical_normalize(&r.join(rooted))) + .collect::>() } else { - html_dir.join(path_part) + vec![lexical_normalize(&html_dir.join(path_part))] }; - let full = normalize(&candidate); - let base = normalize(root); - // `starts_with` on COMPONENTS, so `dist-2` is not read as inside `dist`. - full.starts_with(&base).then_some(full) + if let Some(hit) = literal.into_iter().find(|p| targets.contains(p)) { + return Some(hit); + } + + // 2. Otherwise by file name. A `publicPath` — `/static/`, `/_next/`, a CDN origin — puts a + // prefix in the URL that has no counterpart on disk, so the literal path resolves to nothing + // while the pinned bytes are very much in the output. Bundler file names carry a content + // hash, so a collision is unlikely; and refusing a build we would not have broken costs + // symbolication, while missing one ships a page that loads nothing. + let name = Path::new(path_part).file_name()?; + targets + .iter() + .find(|t| t.file_name() == Some(name)) + .cloned() } -/// Lexical `..`/`.` resolution — the file may not exist yet, so `canonicalize` is not an option. -fn normalize(path: &Path) -> PathBuf { +/// Lexical `..`/`.` resolution — the path may not exist, so `canonicalize` is not an option. +pub fn lexical_normalize(path: &Path) -> PathBuf { let mut out = PathBuf::new(); for component in path.components() { match component { @@ -101,21 +132,14 @@ fn normalize(path: &Path) -> PathBuf { out } -fn is_js(path: &Path) -> bool { - matches!( - path.extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("js") | Some("cjs") | Some("mjs") - ) -} - -/// Every script under `roots` whose hash an HTML page there pins. +/// Every file this run would stamp whose hash an HTML page under `roots` pins. /// /// Empty means stamping is safe as far as SRI is concerned. Never fails on a path problem: that is /// the caller's to report. -pub fn find_pinned_scripts(roots: &[PathBuf]) -> Vec { +pub fn find_pinned_scripts(roots: &[PathBuf], targets: &BTreeSet) -> Vec { + if targets.is_empty() { + return Vec::new(); + } let mut found = BTreeSet::new(); for root in roots { // A file argument (`inject dist/app.js`) has no HTML of its own; scan its directory. @@ -124,19 +148,12 @@ pub fn find_pinned_scripts(roots: &[PathBuf]) -> Vec { } else { root.clone() }; + // No directory filter and no depth cap: the WALK has none either, so a page under + // `.vitepress/dist` or `node_modules` pins a file we really are going to stamp. (Skipping + // them was a false negative the first draft shipped — it stamped what such a page pinned.) for entry in walkdir::WalkDir::new(&base) - .max_depth(8) .sort_by_file_name() .into_iter() - .filter_entry(|e| { - // `e.depth() > 0`: the filter must not judge the ROOT the caller named. A build - // output legitimately lives in a dot-directory (`.next`, `.nuxt`, `.output`, and a - // `tempfile` fixture), and excluding it would silently scan nothing at all. - let name = e.file_name().to_string_lossy(); - !(e.depth() > 0 - && e.file_type().is_dir() - && (name == "node_modules" || name.starts_with('.'))) - }) .filter_map(Result::ok) { let page = entry.path(); @@ -179,11 +196,9 @@ pub fn find_pinned_scripts(roots: &[PathBuf]) -> Vec { let Some(url) = url_attr.captures(tag).and_then(|c| c.get(1)) else { continue; }; - let Some(script) = resolve_local_script(&base, html_dir, unquote(url.as_str())) - else { - continue; - }; - if is_js(&script) { + if let Some(script) = + resolve_pinned_target(roots, targets, html_dir, unquote(url.as_str())) + { found.insert(PinnedScript { html: page.to_path_buf(), script, @@ -206,13 +221,38 @@ mod tests { full } - fn pinned(dir: &Path) -> Vec { - find_pinned_scripts(&[dir.to_path_buf()]) + /// The files an `inject` over `dir` would stamp — every `.js`/`.cjs`/`.mjs` under it. In + /// production this list comes from the same walk that does the stamping, which is the point: + /// the guard can only refuse over a file that is really going to be rewritten. + fn targets_of(dirs: &[&Path]) -> BTreeSet { + dirs.iter() + .flat_map(|dir| { + walkdir::WalkDir::new(dir) + .into_iter() + .filter_map(Result::ok) + .filter(|e| e.file_type().is_file()) + .map(|e| e.path().to_path_buf()) + }) + .filter(|p| { + matches!( + p.extension().and_then(|e| e.to_str()), + Some("js") | Some("cjs") | Some("mjs") + ) + }) + .collect() + } + + fn pinned_in(dir: &Path, targets: &BTreeSet) -> Vec { + find_pinned_scripts(&[dir.to_path_buf()], targets) .into_iter() .map(|p| p.script) .collect() } + fn pinned(dir: &Path) -> Vec { + pinned_in(dir, &targets_of(&[dir])) + } + /// The shape that breaks: webpack-subresource-integrity + html-webpack-plugin. #[test] fn a_pinned_entry_script_is_found() { @@ -224,7 +264,7 @@ mod tests { "", ); - let found = find_pinned_scripts(&[dir.path().to_path_buf()]); + let found = find_pinned_scripts(&[dir.path().to_path_buf()], &targets_of(&[dir.path()])); assert_eq!(found.len(), 1); assert_eq!(found[0].script, dir.path().join("main.abc123.js")); assert_eq!(found[0].html, dir.path().join("index.html")); @@ -318,7 +358,10 @@ mod tests { "#, ); - assert_eq!(find_pinned_scripts(&[out]), Vec::new()); + assert_eq!( + find_pinned_scripts(std::slice::from_ref(&out), &targets_of(&[&out])), + Vec::new() + ); } /// Pointed at a FILE (`inject dist/app.js`), the scan still sees the page beside it — that is @@ -333,7 +376,7 @@ mod tests { r#""#, ); - let found = find_pinned_scripts(&[js]); + let found = find_pinned_scripts(std::slice::from_ref(&js), &targets_of(&[dir.path()])); assert_eq!(found.len(), 1, "{found:?}"); assert_eq!(found[0].script, dir.path().join("app.js")); } @@ -346,27 +389,190 @@ mod tests { write(dir.path(), "index.js", "1"); assert_eq!(pinned(dir.path()), Vec::::new()); assert_eq!( - find_pinned_scripts(&[dir.path().join("does-not-exist")]), + find_pinned_scripts( + &[dir.path().join("does-not-exist")], + &targets_of(&[dir.path()]) + ), Vec::new() ); } - /// `node_modules` and dot-directories inside a build output are not the app's pages. + /// A page under a dot-directory or `node_modules` still pins a file the walk WILL stamp — and + /// the walk descends both. The first draft skipped them here and shipped that false negative: + /// a VitePress build (`docs/.vitepress/dist/index.html`) had its entry stamped and went blank. + #[test] + fn a_page_under_a_dot_directory_or_node_modules_still_counts() { + for nested in [".vitepress/dist/index.html", "node_modules/pkg/index.html"] { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "main.js", "1"); + write( + dir.path(), + nested, + r#""#, + ); + + assert_eq!( + pinned(dir.path()), + vec![dir.path().join("main.js")], + "page at {nested} was not seen" + ); + } + } + + /// `output.publicPath` pointing at a CDN is the CANONICAL SRI deployment — hash the local bytes, + /// serve them from the CDN — so the URL carries an origin that exists nowhere on disk while the + /// pinned bytes are the ones in the output directory. Dropping every absolute URL as "somebody + /// else's file" shipped a blank page for exactly the setup SRI exists for. + #[test] + fn a_cdn_public_path_still_resolves_to_the_local_file() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "main.abc123.js", "1"); + write( + dir.path(), + "index.html", + r#""#, + ); + + assert_eq!(pinned(dir.path()), vec![dir.path().join("main.abc123.js")]); + } + + /// The same shape one level down: a root-relative `publicPath` (`/static/`, `/_next/`) prefixes + /// the URL with a directory that does not exist under the output root. #[test] - fn it_skips_node_modules_and_dot_directories() { + fn a_root_relative_public_path_resolves_by_file_name() { let dir = tempfile::tempdir().unwrap(); write(dir.path(), "main.js", "1"); write( dir.path(), - "node_modules/pkg/demo.html", - r#""#, + "index.html", + r#""#, ); + + assert_eq!(pinned(dir.path()), vec![dir.path().join("main.js")]); + } + + /// The literal path wins over the file-name fallback: a build with `app.js` in two directories + /// must resolve to the one the page actually points at, or the refusal names the wrong file and + /// `--exclude`ing that file would not lift it. + #[test] + fn the_literal_path_decides_when_two_bundles_share_a_name() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "en/app.js", "1"); + write(dir.path(), "fr/app.js", "2"); write( dir.path(), - ".cache/page.html", - r#""#, + "fr/index.html", + r#""#, + ); + + assert_eq!(pinned(dir.path()), vec![dir.path().join("fr/app.js")]); + } + + /// …but a CDN script that is NOT part of this build stays ignored: nothing we stamp bears that + /// name, so nothing we do can invalidate its hash. + #[test] + fn a_third_party_cdn_script_is_still_ignored() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "main.js", "1"); + write( + dir.path(), + "index.html", + r#""#, ); assert_eq!(pinned(dir.path()), Vec::::new()); } + + /// Only a file this run would STAMP can have its hash invalidated. A page pinning a bundle that + /// no longer exists (a stale `index.html` from an earlier build), or one the caller excluded, or + /// one outside the output entirely, must not stop the run. + #[test] + fn a_pin_on_something_we_will_not_stamp_is_ignored() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "main.js", "1"); + write(dir.path(), "vendor/pinned.js", "2"); + write( + dir.path(), + "index.html", + r#" + "#, + ); + + // `vendor/pinned.js` is real but NOT in the target list — the caller excluded it. + let targets: BTreeSet = [dir.path().join("main.js")].into_iter().collect(); + assert_eq!(pinned_in(dir.path(), &targets), Vec::::new()); + } + + /// A file argument stamps ONE file, so only a pin on that file can matter. Passing the unpinned + /// bundle explicitly is the most direct way to follow the error's own advice. + #[test] + fn a_file_argument_only_counts_pins_on_that_file() { + let dir = tempfile::tempdir().unwrap(); + let app = write(dir.path(), "app.js", "1"); + write(dir.path(), "main.js", "2"); + write( + dir.path(), + "index.html", + r#""#, + ); + + let targets: BTreeSet = [app.clone()].into_iter().collect(); + assert_eq!( + find_pinned_scripts(std::slice::from_ref(&app), &targets), + Vec::new(), + "only main.js is pinned, and only app.js would be stamped" + ); + + // …and a pin on the file we ARE stamping still counts. + write( + dir.path(), + "index.html", + r#""#, + ); + assert_eq!( + find_pinned_scripts(std::slice::from_ref(&app), &targets).len(), + 1 + ); + } + + /// `.htm` and `.xhtml` are pages too, and a deep page is still a page: the walk that stamps has + /// no depth limit, so neither can this. + #[test] + fn it_reads_htm_and_xhtml_and_deep_pages() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "a.js", "1"); + write(dir.path(), "b.js", "2"); + write( + dir.path(), + "legacy.htm", + r#""#, + ); + write( + dir.path(), + "a/b/c/d/e/f/g/h/i/deep.xhtml", + r#""#, + ) + .unwrap(); + + // First run: the caller accepts the breakage and recomputes hashes afterwards. + let first = inject_paths(&[dir.path().to_path_buf()], &[], true, false).unwrap(); + assert_eq!(first.js_injected, 1); + let after_first = std::fs::read(dir.path().join("main.js")).unwrap(); + + // Second run, WITHOUT --allow-sri: nothing to rewrite, so nothing to break. + let second = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap(); + assert_eq!((second.js_injected, second.js_already), (0, 1)); + assert_eq!( + std::fs::read(dir.path().join("main.js")).unwrap(), + after_first, + "a no-op run must not touch the bundle" + ); + } + + /// …and it still refuses when the re-run WOULD rewrite: a regenerated map re-keys the bundle, + /// which changes its bytes and breaks the pinned hash exactly as a first stamp would. + #[test] + fn a_re_run_that_would_restamp_is_still_refused() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("main.js"), "console.log(1)\n").unwrap(); + std::fs::write( + dir.path().join("main.js.map"), + br#"{"version":3,"sources":["a.ts"],"names":[],"mappings":"AAAA"}"#, + ) + .unwrap(); + std::fs::write( + dir.path().join("index.html"), + r#""#, + ) + .unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], true, false).unwrap(); + + // The bundler re-emits the map with different content and no id (webpack `[contenthash]` + // keeps the JS file it considers unchanged) — the next run re-keys the bundle. + std::fs::write( + dir.path().join("main.js.map"), + br#"{"version":3,"sources":["a.ts"],"names":[],"mappings":"AACA"}"#, + ) + .unwrap(); + + let err = inject_paths(&[dir.path().to_path_buf()], &[], false, false).unwrap_err(); + assert!( + format!("{err:#}").contains("Subresource Integrity"), + "{err:#}" + ); + } + + /// The guard's "would this be rewritten?" answer must match what `inject_one` actually does, or + /// the two drift apart again — which is the whole class of bug this design exists to prevent. + #[test] + fn would_rewrite_agrees_with_what_inject_actually_writes() { + let cases: [(&str, &str, &[u8]); 4] = [ + ("fresh", "console.log(1)\n", br#"{"version":3,"mappings":"AAAA"}"#), + ( + "foreign id", + "console.log(1)\n//# debugId=11111111-1111-1111-1111-111111111111\n", + br#"{"version":3,"debug_id":"11111111-1111-1111-1111-111111111111","mappings":"AAAA"}"#, + ), + ("no map", "console.log(2)\n", b""), + ("empty", "", br#"{"version":3,"mappings":"AAAA"}"#), + ]; + for (label, js, map) in cases { + for second_pass in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let js_path = dir.path().join("a.js"); + std::fs::write(&js_path, js).unwrap(); + if !map.is_empty() { + std::fs::write(dir.path().join("a.js.map"), map).unwrap(); + } + if second_pass { + inject_paths(&[dir.path().to_path_buf()], &[], true, false).unwrap(); + } + + let predicted = would_rewrite(&js_path).unwrap(); + let before = std::fs::read(&js_path).unwrap(); + inject_paths(&[dir.path().to_path_buf()], &[], true, false).unwrap(); + let actually = std::fs::read(&js_path).unwrap() != before; + + assert_eq!( + predicted, actually, + "{label} (second_pass={second_pass}): guard said {predicted}, inject did {actually}" + ); + } + } } /// A relative root (`./dist`, `../dist`) must behave exactly like the absolute one. It did not: @@ -906,6 +1051,12 @@ mod tests { format!("{err:#}").contains("--exclude"), "the message must name the flag: {err:#}" ); + // …with the exit code integrators are documented not to fall back on. Asserting only the + // message left the contract to chance: the classification is what a CI script reads. + assert_eq!( + crate::error::classify(&anyhow::Error::new(err)), + crate::exit_code::ExitCode::ConfigInvalid + ); } /// The dry run reports what it WOULD skip without writing anything. diff --git a/src/inject/sri.rs b/src/inject/sri.rs index 5a9a6de..24e8539 100644 --- a/src/inject/sri.rs +++ b/src/inject/sri.rs @@ -132,6 +132,41 @@ pub fn lexical_normalize(path: &Path) -> PathBuf { out } +/// Pages sitting DIRECTLY in `dir` (not recursive, and bounded). +/// +/// Only consulted when the path we were given holds no pages of its own: the standard Vite/webpack +/// layout is `dist/index.html` + `dist/assets/*.js`, so `inject dist/assets` (or one bundle by path) +/// would otherwise stamp a file whose hash the page one level up pins. The bound matters because +/// that parent can be a directory nobody meant us to read — `/tmp` with a hundred thousand entries. +fn pages_directly_in(dir: Option<&Path>) -> Vec { + const MAX_ENTRIES: usize = 2_000; + let Some(dir) = dir else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut pages: Vec = entries + .take(MAX_ENTRIES) + .filter_map(Result::ok) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file()) && is_html(&e.path())) + .map(|e| e.path()) + .collect(); + pages.sort(); + pages +} + +/// A file name a browser would load as a page. +fn is_html(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("html") | Some("htm") | Some("xhtml") + ) +} + /// Every file this run would stamp whose hash an HTML page under `roots` pins. /// /// Empty means stamping is safe as far as SRI is concerned. Never fails on a path problem: that is @@ -148,28 +183,27 @@ pub fn find_pinned_scripts(roots: &[PathBuf], targets: &BTreeSet) -> Ve } else { root.clone() }; - // No directory filter and no depth cap: the WALK has none either, so a page under - // `.vitepress/dist` or `node_modules` pins a file we really are going to stamp. (Skipping - // them was a false negative the first draft shipped — it stamped what such a page pinned.) - for entry in walkdir::WalkDir::new(&base) + // Everything under the root, plus the pages sitting DIRECTLY in its parent. The standard + // Vite/webpack layout is `dist/index.html` + `dist/assets/*.js`, so `inject dist/assets` + // (or a single bundle by path) would otherwise stamp a file whose hash the page one level + // up pins — a build we break while reporting success. One level, not recursive: enough for + // that layout without wandering off into a parent tree we were not pointed at. + // + // No directory filter and no depth cap below the root: the WALK has none either, so a page + // under `.vitepress/dist` or `node_modules` pins a file we really are going to stamp. + // (Skipping those was a false negative the first draft shipped.) + let mut pages: Vec = walkdir::WalkDir::new(&base) .sort_by_file_name() .into_iter() .filter_map(Result::ok) - { - let page = entry.path(); - if !entry.file_type().is_file() { - continue; - } - let is_html = matches!( - page.extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("html") | Some("htm") | Some("xhtml") - ); - if !is_html { - continue; - } + .filter(|e| e.file_type().is_file() && is_html(e.path())) + .map(|e| e.path().to_path_buf()) + .collect(); + if pages.is_empty() { + pages = pages_directly_in(base.parent()); + } + for page in pages { + let page = page.as_path(); let Ok(source) = std::fs::read_to_string(page) else { continue; // a page we cannot read is not ours to fail the run over }; @@ -419,6 +453,38 @@ mod tests { } } + /// The standard Vite/webpack layout keeps the page one level ABOVE the bundles + /// (`dist/index.html` + `dist/assets/*.js`), so pointing `inject` at the assets directory — or + /// at one bundle by path — used to miss the pin entirely and stamp the file anyway. + #[test] + fn a_page_one_level_above_the_given_path_still_counts() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "assets/main.abc.js", "1"); + write( + dir.path(), + "index.html", + r#""#, + ); + + let assets = dir.path().join("assets"); + let targets = targets_of(&[&assets]); + assert_eq!( + find_pinned_scripts(std::slice::from_ref(&assets), &targets) + .into_iter() + .map(|p| p.script) + .collect::>(), + vec![dir.path().join("assets/main.abc.js")], + "a page in the parent directory pins a file we would stamp" + ); + + // …and the same when a single bundle is named by path. + let one = dir.path().join("assets/main.abc.js"); + assert_eq!( + find_pinned_scripts(std::slice::from_ref(&one), &targets).len(), + 1 + ); + } + /// `output.publicPath` pointing at a CDN is the CANONICAL SRI deployment — hash the local bytes, /// serve them from the CDN — so the URL carries an origin that exists nowhere on disk while the /// pinned bytes are the ones in the output directory. Dropping every absolute URL as "somebody From 9727cd17177af33f90157683362dfdc9c20574cf Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 18 Sep 2026 20:28:04 +0500 Subject: [PATCH 4/4] docs(changelog): the exclude-matching line was garbled, and three claims had drifted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d8cc4..bf6383e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,13 +21,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). bytes it names; that is the canonical SRI deployment (hash locally, serve from a CDN) and treating every absolute URL as somebody else's file would have missed it entirely. - The guard refuses only over a file this run would really stamp, sharing one list with the walk + The guard refuses only over a file this run would really REWRITE — so re-running `inject` stays a + no-op, including on a build stamped once with `--allow-sri` — and it shares one list with the walk that does the stamping: an `--exclude`d file, a stale page pinning a deleted bundle, a pin on something outside the output, and a file argument naming an unpinned bundle all proceed. Also not - flagged: a genuine third-party CDN script, a non-JS target, an empty `integrity`, + flagged: a third-party CDN script (unless it shares a file name with one of your bundles, in + which case it is treated as yours — the safer error), a non-JS target, an empty `integrity`, `data-integrity`/`data-src`, an inline script, a commented-out tag, and `rel=prefetch` (a failed prefetch is discarded, not fatal). + Pages are read from anywhere under the given paths, plus any sitting directly in a given path's + parent (the usual layout is `dist/index.html` beside `dist/assets/*.js`). `--dry-run` refuses too: + the preview of a run that would refuse is a refusal, and it says why. + It 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. @@ -36,10 +42,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **`sourcemaps inject --exclude `** (repeatable) — leave part of a build output alone. A stock `next build` with browser source maps on has 39 JS files and 12 maps, and a Nuxt `.output/server/node_modules` holds 22 vendored `.mjs`; `--exclude '**/node_modules/**'` keeps - `inject` out of third-party code inside the build output. Matched against the path relative to - the absolute path, the path relative to the current directory, and the path relative to each - walked root, so `dist/vendor/**`, `vendor/**` and an absolute path all work whether the root is - passed as `dist`, `./dist` or absolute. An unparseable OR EMPTY pattern is a configuration error + `inject` out of third-party code inside the build output. Matched against the absolute path, the path + relative to the current directory, and the path relative to each walked root, so + `dist/vendor/**`, `vendor/**` and an absolute path all work whether the root is passed as `dist`, + `./dist` or absolute. `*` crosses `/` (globset's default). An unparseable OR EMPTY pattern is a configuration error (exit 20), never a silent "matches nothing" that would rewrite the files you meant to protect. `js_excluded` is reported in the completion log.