diff --git a/CHANGELOG.md b/CHANGELOG.md
index 785d770..bf6383e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,45 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
+- **`sourcemaps inject` refuses a build that pins its own script hashes** (Subresource Integrity),
+ exit 20. Injecting appends bytes to every `.js`, so a 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, the page loaded and executed **nothing**
+ after injecting, where before it ran clean. Angular's `subresourceIntegrity: true` is the same
+ mechanism, and Angular/esbuild/Deno users drive this binary directly — the JS bundler plugins
+ carry their own copy of this guard, but they only cover vite and rollup.
+
+ 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();
+
+ let err = inject_paths(&[dir.path().to_path_buf()], &[], false, true).unwrap_err();
+ assert!(
+ format!("{err:#}").contains("Subresource Integrity"),
+ "{err:#}"
+ );
+ assert_eq!(
+ crate::error::classify(&anyhow::Error::new(err)),
+ crate::exit_code::ExitCode::ConfigInvalid
+ );
+ // A preview of a run that would refuse IS a refusal — and nothing is written either way.
+ assert_eq!(
+ std::fs::read_to_string(dir.path().join("main.js")).unwrap(),
+ "console.log(1)\n"
+ );
+ }
+
+ /// Re-running `inject` is documented as a no-op, and that has to stay true on a build that
+ /// pins its hashes: the supported workflow for such a build is `--allow-sri` once (the build
+ /// then recomputes them), and any later run — a second CI job, an upload step that re-injects,
+ /// a retry — must not start failing. The guard refuses only over a bundle it would REWRITE.
+ #[test]
+ fn a_second_run_over_an_already_stamped_pinned_build_is_still_a_no_op() {
+ 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();
+
+ // 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:
+ /// the guard produced an absolute path while the exclusion matched components of the path as
+ /// typed, so `inject ./dist --exclude 'polyfills*.js'` — the README's own example — refused the
+ /// run instead of excluding the file.
+ #[test]
+ fn a_relative_root_excludes_and_guards_the_same_as_an_absolute_one() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("polyfills.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 _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
@@ -476,6 +979,7 @@ mod tests {
&[dir.path().to_path_buf()],
&["**/node_modules/**".to_string()],
false,
+ false,
)
.unwrap();
@@ -507,6 +1011,7 @@ mod tests {
&[dir.path().to_path_buf()],
&["polyfills.js".to_string(), "**/legacy-*.js".to_string()],
false,
+ false,
)
.unwrap();
@@ -529,6 +1034,7 @@ mod tests {
&[dir.path().to_path_buf()],
&["nothing/**".to_string()],
false,
+ false,
)
.unwrap();
assert_eq!(stats.js_injected, 1);
@@ -538,12 +1044,19 @@ mod tests {
&[dir.path().to_path_buf()],
&["[unclosed".to_string()],
false,
+ false,
)
.unwrap_err();
assert!(
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.
@@ -557,6 +1070,7 @@ mod tests {
let stats = inject_paths(
&[dir.path().to_path_buf()],
&["vendor/**".to_string()],
+ false,
true,
)
.unwrap();
@@ -587,7 +1101,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 +1147,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 +1178,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 +1235,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 +1260,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 +1286,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 +1305,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 +1319,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 +1345,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 +1367,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 +1385,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 +1394,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 +1407,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 +1431,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 +1450,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 +1489,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 +1507,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 +1520,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 +1532,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 +1548,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 +1587,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 +1622,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 +1643,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 +1670,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..24e8539
--- /dev/null
+++ b/src/inject/sri.rs
@@ -0,0 +1,644 @@
+//! 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()], &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"));
+ }
+
+ /// 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(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
+ /// 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(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"));
+ }
+
+ /// 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")],
+ &targets_of(&[dir.path()])
+ ),
+ Vec::new()
+ );
+ }
+
+ /// 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"
+ );
+ }
+ }
+
+ /// 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
+ /// 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 a_root_relative_public_path_resolves_by_file_name() {
+ let dir = tempfile::tempdir().unwrap();
+ write(dir.path(), "main.js", "1");
+ write(
+ dir.path(),
+ "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(),
+ "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#""#,
+ );
+
+ let mut found = pinned(dir.path());
+ found.sort();
+ let mut want = vec![dir.path().join("a.js"), dir.path().join("b.js")];
+ want.sort();
+ assert_eq!(found, want);
+ }
+
+ /// A comment spanning lines is still a comment (the `(?s)` flag), and `preload` counts as well
+ /// as `modulepreload`.
+ #[test]
+ fn multiline_comments_and_both_preload_rels() {
+ let dir = tempfile::tempdir().unwrap();
+ write(dir.path(), "commented.js", "1");
+ write(dir.path(), "preloaded.js", "2");
+ write(
+ dir.path(),
+ "index.html",
+ "\n ",
+ );
+
+ assert_eq!(pinned(dir.path()), vec![dir.path().join("preloaded.js")]);
+ }
+}