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#"