1use crate::cmd::SriAlgorithm;
10use crate::error::{PathErrorExt, SsgError};
11use crate::plugin::{Plugin, PluginContext};
12use sha2::{Digest, Sha256};
13use std::{
14 collections::HashMap,
15 fs,
16 path::{Path, PathBuf},
17};
18
19mod html_refs;
20mod js_minify;
21
22use html_refs::rewrite_asset_refs;
23pub use js_minify::minify_js;
24
25#[derive(Debug, Clone, Copy)]
38pub struct FingerprintPlugin;
39
40impl Plugin for FingerprintPlugin {
41 fn name(&self) -> &'static str {
42 "fingerprint"
43 }
44
45 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
46 if !ctx.site_dir.exists() {
47 return Ok(());
48 }
49
50 let all_assets = collect_assets(&ctx.site_dir)?;
51 if all_assets.is_empty() {
52 return Ok(());
53 }
54
55 let (css_files, non_css): (Vec<_>, Vec<_>) = all_assets
74 .into_iter()
75 .partition(|p| p.extension().is_some_and(|e| e == "css"));
76
77 let sri_algorithm = ctx
80 .config
81 .as_ref()
82 .map_or_else(SriAlgorithm::default, |c| c.security.sri_algorithm);
83
84 let mut manifest =
85 fingerprint_assets(&non_css, &ctx.site_dir, sri_algorithm)?;
86
87 for css_path in &css_files {
88 rewrite_css_urls_inplace(css_path, &ctx.site_dir, &manifest)?;
89 }
90
91 let css_manifest =
92 fingerprint_assets(&css_files, &ctx.site_dir, sri_algorithm)?;
93 manifest.extend(css_manifest);
94
95 rewrite_html_references(&ctx.site_dir, &manifest)?;
96
97 log::info!(
98 "[fingerprint] Processed {} asset(s) across {} CSS + {} other",
99 manifest.len(),
100 css_files.len(),
101 manifest.len() - css_files.len()
102 );
103 Ok(())
104 }
105}
106
107fn fingerprint_assets(
109 assets: &[PathBuf],
110 site_dir: &Path,
111 sri_algorithm: SriAlgorithm,
112) -> Result<HashMap<String, AssetInfo>, SsgError> {
113 let mut manifest = HashMap::new();
114
115 for asset_path in assets {
116 let info = fingerprint_file(asset_path, site_dir, sri_algorithm)?;
117 let _ = manifest.insert(info.0, info.1);
118 }
119
120 Ok(manifest)
121}
122
123fn fingerprint_file(
125 asset_path: &Path,
126 site_dir: &Path,
127 sri_algorithm: SriAlgorithm,
128) -> Result<(String, AssetInfo), SsgError> {
129 let mut content = fs::read(asset_path).with_path(asset_path)?;
130 let ext = asset_path
131 .extension()
132 .unwrap_or_default()
133 .to_string_lossy()
134 .to_string();
135 let mut minified = false;
136
137 if ext == "css" {
138 if let Ok(css_str) = std::str::from_utf8(&content) {
139 content = minify_css(css_str).into_bytes();
140 minified = true;
141 }
142 } else if ext == "js" || ext == "mjs" {
143 if let Ok(js_str) = std::str::from_utf8(&content) {
144 content = minify_js(js_str).into_bytes();
145 minified = true;
146 }
147 }
148
149 let hash = sha256_hex(&content);
150 let short_hash = &hash[..8];
151
152 let stem = asset_path.file_stem().unwrap_or_default().to_string_lossy();
153 let new_name = format!("{stem}.{short_hash}.{ext}");
154 let new_path = asset_path.with_file_name(&new_name);
155
156 let sri = sri_algorithm.integrity(&content);
157
158 if minified {
159 fs::write(&new_path, &content).with_path(&new_path)?;
160 } else {
161 let _ = fs::copy(asset_path, &new_path).with_path(asset_path)?;
162 }
163
164 if new_path != asset_path {
173 fail_point!("assets::remove-original", |_| Err(SsgError::Validation {
178 field: "assets".to_string(),
179 message: "injected: assets::remove-original".to_string(),
180 }));
181 fs::remove_file(asset_path).with_path(asset_path)?;
182 }
183
184 let rel_old = asset_path
185 .strip_prefix(site_dir)
186 .unwrap_or(asset_path)
187 .to_string_lossy()
188 .replace('\\', "/");
189 let rel_new = new_path
190 .strip_prefix(site_dir)
191 .unwrap_or(&new_path)
192 .to_string_lossy()
193 .replace('\\', "/");
194
195 Ok((
196 rel_old,
197 AssetInfo {
198 fingerprinted: rel_new,
199 sri,
200 },
201 ))
202}
203
204fn rewrite_html_references(
206 site_dir: &Path,
207 manifest: &HashMap<String, AssetInfo>,
208) -> Result<(), SsgError> {
209 let html_files = collect_html_files(site_dir)?;
210 for html_path in &html_files {
211 let html = fs::read_to_string(html_path).with_path(html_path)?;
212 let rewritten = rewrite_asset_refs(&html, manifest)?;
213 if rewritten != html {
214 fs::write(html_path, rewritten).with_path(html_path)?;
215 }
216 }
217 Ok(())
218}
219
220#[derive(Debug, Clone)]
221struct AssetInfo {
222 fingerprinted: String,
223 sri: String,
224}
225
226fn rewrite_css_urls(
250 css: &str,
251 css_path: &Path,
252 site_dir: &Path,
253 manifest: &HashMap<String, AssetInfo>,
254) -> String {
255 let css_dir = css_path.parent().unwrap_or(css_path);
256 let mut out = String::with_capacity(css.len());
257 let mut remaining = css;
258
259 while let Some(idx) = remaining.find("url(") {
260 out.push_str(&remaining[..idx]);
261 let after_open = &remaining[idx + 4..]; let Some(close_idx) = after_open.find(')') else {
263 out.push_str("url(");
265 out.push_str(after_open);
266 return out;
267 };
268 let raw = &after_open[..close_idx];
269 let rest = &after_open[close_idx + 1..];
270
271 let trimmed = raw.trim();
273 let (quote, inner) = if let Some(s) = trimmed.strip_prefix('"') {
274 ('"', s.strip_suffix('"').unwrap_or(s))
275 } else if let Some(s) = trimmed.strip_prefix('\'') {
276 ('\'', s.strip_suffix('\'').unwrap_or(s))
277 } else {
278 ('\0', trimmed)
279 };
280
281 let (url, suffix) = if let Some(i) = inner.find(['?', '#']) {
283 (&inner[..i], &inner[i..])
284 } else {
285 (inner, "")
286 };
287
288 let resolved = resolve_css_url(url, css_dir, site_dir);
289 let hit = resolved.and_then(|key| manifest.get(&key).map(|i| (key, i)));
290
291 out.push_str("url(");
292 if let Some((_, info)) = hit {
293 let new_url = format!("/{}{}", info.fingerprinted, suffix);
295 if quote != '\0' {
296 out.push(quote);
297 }
298 out.push_str(&new_url);
299 if quote != '\0' {
300 out.push(quote);
301 }
302 } else {
303 out.push_str(raw);
305 }
306 out.push(')');
307
308 remaining = rest;
309 }
310
311 out.push_str(remaining);
312 out
313}
314
315fn resolve_css_url(
320 url: &str,
321 css_dir: &Path,
322 site_dir: &Path,
323) -> Option<String> {
324 let trimmed = url.trim();
325 if trimmed.is_empty()
326 || trimmed.starts_with("data:")
327 || trimmed.starts_with("http://")
328 || trimmed.starts_with("https://")
329 || trimmed.starts_with("//")
330 {
331 return None;
332 }
333
334 let relative = if let Some(stripped) = trimmed.strip_prefix('/') {
339 PathBuf::from(stripped)
340 } else {
341 css_dir.strip_prefix(site_dir).ok()?.join(trimmed)
342 };
343 let rel = commons::path::safe_join(Path::new(""), relative).ok()?;
345 Some(rel.to_string_lossy().replace('\\', "/"))
346}
347
348fn rewrite_css_urls_inplace(
351 css_path: &Path,
352 site_dir: &Path,
353 manifest: &HashMap<String, AssetInfo>,
354) -> Result<(), SsgError> {
355 let css = fs::read_to_string(css_path).with_path(css_path)?;
356 let rewritten = rewrite_css_urls(&css, css_path, site_dir, manifest);
357 if rewritten != css {
358 fs::write(css_path, rewritten).with_path(css_path)?;
359 }
360 Ok(())
361}
362
363fn sha256_hex(data: &[u8]) -> String {
373 let mut hasher = Sha256::new();
374 hasher.update(data);
375 let bytes = hasher.finalize();
376 let mut s = String::with_capacity(64);
377 for b in bytes {
378 use std::fmt::Write as _;
379 let _ = write!(s, "{b:02x}");
380 }
381 s
382}
383
384const fn in_value_context(block_stack: &[bool]) -> bool {
393 matches!(block_stack.last(), Some(false))
394}
395
396#[must_use]
405pub fn minify_css(css: &str) -> String {
406 let mut result = String::with_capacity(css.len());
407 let mut chars = css.chars().peekable();
408 let mut in_comment = false;
409 let mut in_string = None;
410
411 while let Some(ch) = chars.next() {
412 if in_comment {
413 if ch == '*' && chars.peek() == Some(&'/') {
414 let _ = chars.next();
415 in_comment = false;
416 }
417 continue;
418 }
419
420 if let Some(quote) = in_string {
421 result.push(ch);
422 if ch == quote {
423 let mut backslashes = 0;
424 let mut temp = result.len() as isize - 2;
425 while temp >= 0 && result.as_bytes()[temp as usize] == b'\\' {
426 backslashes += 1;
427 temp -= 1;
428 }
429 if backslashes % 2 == 0 {
430 in_string = None;
431 }
432 }
433 continue;
434 }
435
436 if ch == '/' && chars.peek() == Some(&'*') {
437 let _ = chars.next();
438 in_comment = true;
439 continue;
440 }
441
442 if ch == '\'' || ch == '"' {
443 in_string = Some(ch);
444 result.push(ch);
445 continue;
446 }
447
448 if ch.is_whitespace() {
449 result.push(' ');
450 continue;
451 }
452
453 result.push(ch);
454 }
455
456 let mut clean = String::with_capacity(result.len());
457 let chars: Vec<char> = result.chars().collect();
458 let mut i = 0;
459 let mut in_string: Option<char> = None;
464 let mut block_stack: Vec<bool> = Vec::new();
494 let mut prelude_is_at_rule = false;
496 while i < chars.len() {
497 let ch = chars[i];
498 if in_string.is_none() {
499 match ch {
500 '@' if !in_value_context(&block_stack) => {
501 prelude_is_at_rule = true;
502 }
503 '{' => {
504 block_stack.push(prelude_is_at_rule);
505 prelude_is_at_rule = false;
506 }
507 '}' => {
508 let _ = block_stack.pop();
509 prelude_is_at_rule = false;
510 }
511 ';' if !in_value_context(&block_stack) => {
512 prelude_is_at_rule = false;
513 }
514 _ => {}
515 }
516 }
517 let in_block = in_value_context(&block_stack);
518
519 if let Some(q) = in_string {
520 clean.push(ch);
521 if ch == q {
523 let mut back = 0usize;
524 let mut k = i;
525 while k > 0 && chars[k - 1] == '\\' {
526 back += 1;
527 k -= 1;
528 }
529 if back.is_multiple_of(2) {
530 in_string = None;
531 }
532 }
533 i += 1;
534 continue;
535 }
536 if ch == '\'' || ch == '"' {
537 in_string = Some(ch);
538 clean.push(ch);
539 i += 1;
540 continue;
541 }
542
543 if ch == ' ' {
544 let mut j = i;
553 while j < chars.len() && chars[j] == ' ' {
554 j += 1;
555 }
556 let prev = clean.chars().next_back();
557 let next = chars.get(j).copied();
558 i = j;
559 if !in_block {
560 let separator = |c: Option<char>| {
566 matches!(c, Some('{' | '}' | ',' | '>' | '~' | '+' | ';'))
567 || c.is_none()
568 };
569 if !separator(prev) && !separator(next) && prev != Some(' ') {
570 clean.push(' ');
571 }
572 continue;
573 }
574
575 let joins_prelude_tokens = match (prev, next) {
605 (Some(')'), Some(n)) => n.is_ascii_alphabetic(),
606 (Some(p), Some('(')) => p.is_ascii_alphabetic(),
607 _ => false,
608 };
609
610 let math_operator_boundary = matches!(
617 (prev, next),
618 (Some(')'), Some('+' | '-')) | (Some('+' | '-'), Some('('))
619 );
620
621 let is_needed = joins_prelude_tokens
622 || math_operator_boundary
623 || (prev.is_some_and(is_css_word_char)
624 && next.is_some_and(is_css_word_char));
625 if is_needed {
626 clean.push(' ');
627 }
628 continue;
629 }
630 clean.push(ch);
631 i += 1;
632 }
633
634 clean.trim().to_string()
635}
636
637fn is_css_word_char(c: char) -> bool {
641 c.is_alphanumeric()
642 || matches!(c, '-' | '+' | '_' | '#' | '.' | '@' | '%' | '*' | '$')
643}
644
645const FINGERPRINTED_EXTENSIONS: &[&str] = &[
652 "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
653 "woff", "woff2", "ttf", "otf",
654];
655
656const UNFINGERPRINTED_DIRS: &[&str] = &["_islands"];
669
670fn collect_assets(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
671 let all = crate::walk::walk_files_multi(dir, FINGERPRINTED_EXTENSIONS)?;
672 Ok(all
673 .into_iter()
674 .filter(|path| {
675 !path.components().any(|c| {
676 UNFINGERPRINTED_DIRS
677 .iter()
678 .any(|d| c.as_os_str() == std::ffi::OsStr::new(d))
679 })
680 })
681 .collect())
682}
683
684fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
685 crate::walk::walk_files(dir, "html")
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use tempfile::tempdir;
692
693 #[test]
694 fn test_minify_css() {
695 let input = "body {\n color: red;\n background-color: #ffffff; /* comment */\n}";
696 let expected = "body{color:red;background-color:#ffffff;}";
697 assert_eq!(minify_css(input), expected);
698 }
699
700 #[test]
701 fn test_minify_js() {
702 let input = "const x = 5; // comment\n/* multi\ncomment */\nconst y = 10;\nconsole.log(x + y);";
703 let expected = "const x=5;const y=10;console.log(x+y);";
704 assert_eq!(minify_js(input), expected);
705 }
706
707 #[test]
708 fn test_sha256_hex_deterministic() {
709 let h1 = sha256_hex(b"hello");
710 let h2 = sha256_hex(b"hello");
711 assert_eq!(h1, h2);
712 assert_eq!(h1.len(), 64);
714 }
715
716 #[test]
717 fn test_sha256_hex_known_vectors() {
718 assert_eq!(
721 sha256_hex(b""),
722 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
723 );
724 assert_eq!(
726 sha256_hex(b"abc"),
727 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
728 );
729 }
730
731 #[test]
732 fn test_sri_default_algorithm_known_vector() {
733 assert_eq!(
737 SriAlgorithm::default().integrity(b""),
738 "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
739 );
740 }
741
742 #[test]
743 fn test_sri_sha256_override_known_vector() {
744 assert_eq!(
746 SriAlgorithm::Sha256.integrity(b""),
747 "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
748 );
749 }
750
751 #[test]
752 fn test_sha256_hex_varies() {
753 let h1 = sha256_hex(b"hello");
754 let h2 = sha256_hex(b"world");
755 assert_ne!(h1, h2);
756 }
757
758 #[test]
759 #[serial_test::parallel(assets_failpoint)]
760 fn test_fingerprint_plugin() {
761 let dir = tempdir().unwrap();
762 let site = dir.path().join("site");
763 fs::create_dir_all(&site).unwrap();
764
765 fs::write(site.join("style.css"), "body { color: red; }").unwrap();
767
768 let html = r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#;
770 fs::write(site.join("index.html"), html).unwrap();
771
772 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
773 FingerprintPlugin.after_compile(&ctx).unwrap();
774
775 assert!(!site.join("style.css").exists());
777
778 let entries: Vec<_> = fs::read_dir(&site)
780 .unwrap()
781 .filter_map(Result::ok)
782 .filter(|e| {
783 e.path()
784 .file_name()
785 .unwrap()
786 .to_string_lossy()
787 .starts_with("style.")
788 && e.path().extension().is_some_and(|e| e == "css")
789 })
790 .collect();
791 assert_eq!(entries.len(), 1);
792
793 let output = fs::read_to_string(site.join("index.html")).unwrap();
796 assert!(output.contains("integrity=\"sha384-"));
797 assert!(output.contains("crossorigin=\"anonymous\""));
798 assert!(!output.contains("href=\"style.css\""));
799 }
800
801 #[test]
802 #[serial_test::parallel(assets_failpoint)]
803 fn default_sri_is_sha384_with_exact_known_vector() {
804 let dir = tempdir().unwrap();
809 let site = dir.path().join("site");
810 fs::create_dir_all(&site).unwrap();
811 fs::write(site.join("app.js"), "console.log(1);").unwrap();
812 fs::write(
813 site.join("index.html"),
814 r#"<html><head><script src="app.js"></script></head></html>"#,
815 )
816 .unwrap();
817
818 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
819 FingerprintPlugin.after_compile(&ctx).unwrap();
820
821 let html = fs::read_to_string(site.join("index.html")).unwrap();
822 assert!(
823 html.contains(
824 "integrity=\"sha384-JawyHuhqEMFMvdtX+VHylbI0hfJp2F7nvwFVRqqfuOoK5oW7TG/7V11Zs7zeFWIE\""
825 ),
826 "expected exact SHA-384 SRI vector; got: {html}"
827 );
828 }
829
830 #[test]
831 #[serial_test::parallel(assets_failpoint)]
832 fn sri_algorithm_config_override_emits_sha256() {
833 use crate::cmd::{SecurityConfig, SsgConfig};
837
838 let dir = tempdir().unwrap();
839 let site = dir.path().join("site");
840 fs::create_dir_all(&site).unwrap();
841 fs::write(site.join("app.js"), "console.log(1);").unwrap();
842 fs::write(
843 site.join("index.html"),
844 r#"<html><head><script src="app.js"></script></head></html>"#,
845 )
846 .unwrap();
847
848 let config = SsgConfig::builder()
849 .security(SecurityConfig {
850 sri_algorithm: SriAlgorithm::Sha256,
851 })
852 .build()
853 .unwrap();
854 let ctx = PluginContext::with_config(
855 dir.path(),
856 dir.path(),
857 &site,
858 dir.path(),
859 config,
860 );
861 FingerprintPlugin.after_compile(&ctx).unwrap();
862
863 let html = fs::read_to_string(site.join("index.html")).unwrap();
864 assert!(
865 html.contains(
866 "integrity=\"sha256-NcFG924SlHfGQGG8hFEeEJDz1NgFlxPmZj3Us1sfdkI=\""
867 ),
868 "expected exact SHA-256 SRI vector; got: {html}"
869 );
870 assert!(!html.contains("sha384-"), "override must win: {html}");
871 }
872
873 #[test]
874 fn name_returns_static_fingerprint_identifier() {
875 assert_eq!(FingerprintPlugin.name(), "fingerprint");
876 }
877
878 #[test]
879 fn after_compile_missing_site_dir_returns_ok() {
880 let dir = tempdir().unwrap();
882 let missing = dir.path().join("missing");
883 let ctx =
884 PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
885 FingerprintPlugin.after_compile(&ctx).unwrap();
886 assert!(!missing.exists());
887 }
888
889 #[test]
890 fn after_compile_no_assets_short_circuits() {
891 let dir = tempdir().unwrap();
894 let site = dir.path().join("site");
895 fs::create_dir_all(&site).unwrap();
896 fs::write(site.join("index.html"), "<p></p>").unwrap();
897
898 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
899 FingerprintPlugin.after_compile(&ctx).unwrap();
900 assert_eq!(
902 fs::read_to_string(site.join("index.html")).unwrap(),
903 "<p></p>"
904 );
905 }
906
907 #[test]
908 #[serial_test::parallel(assets_failpoint)]
909 fn after_compile_fingerprint_absolute_path_href() {
910 let dir = tempdir().unwrap();
913 let site = dir.path().join("site");
914 fs::create_dir_all(&site).unwrap();
915 fs::write(site.join("app.js"), "console.log(1);").unwrap();
916 fs::write(
917 site.join("index.html"),
918 r#"<html><head><script src="/app.js"></script></head></html>"#,
919 )
920 .unwrap();
921
922 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
923 FingerprintPlugin.after_compile(&ctx).unwrap();
924 let html = fs::read_to_string(site.join("index.html")).unwrap();
925 assert!(html.contains("integrity=\"sha384-"));
927 }
928
929 #[test]
930 fn collect_assets_picks_up_fingerprintable_extensions() {
931 let dir = tempdir().unwrap();
935 fs::write(dir.path().join("a.css"), "").unwrap();
936 fs::write(dir.path().join("b.js"), "").unwrap();
937 fs::write(dir.path().join("c.html"), "").unwrap();
938 fs::write(dir.path().join("d.png"), "").unwrap();
939 fs::write(dir.path().join("e.woff2"), "").unwrap();
940 fs::write(dir.path().join("f.txt"), "").unwrap();
941 let files = collect_assets(dir.path()).unwrap();
942 assert_eq!(files.len(), 4);
944 }
945
946 #[test]
947 fn collect_assets_recurses_into_subdirectories() {
948 let dir = tempdir().unwrap();
949 let nested = dir.path().join("vendor");
950 fs::create_dir(&nested).unwrap();
951 fs::write(dir.path().join("top.css"), "").unwrap();
952 fs::write(nested.join("lib.js"), "").unwrap();
953 let files = collect_assets(dir.path()).unwrap();
954 assert_eq!(files.len(), 2);
955 }
956
957 #[test]
958 fn collect_html_files_filters_non_html() {
959 let dir = tempdir().unwrap();
960 fs::write(dir.path().join("a.html"), "").unwrap();
961 fs::write(dir.path().join("b.css"), "").unwrap();
962 let files = collect_html_files(dir.path()).unwrap();
963 assert_eq!(files.len(), 1);
964 }
965
966 #[test]
967 fn sha256_hex_produces_64_hex_chars() {
968 assert_eq!(sha256_hex(b"abc").len(), 64);
969 assert_eq!(sha256_hex(b"").len(), 64);
970 }
971
972 #[test]
973 fn sri_integrity_is_nonempty_for_input() {
974 assert!(
975 !SriAlgorithm::default().integrity(b"hello").is_empty(),
976 "should not be empty"
977 );
978 }
979
980 #[test]
981 fn sri_integrity_payload_lengths_per_algorithm() {
982 assert_eq!(SriAlgorithm::Sha384.integrity(b"hello").len(), 7 + 64);
984 assert_eq!(SriAlgorithm::Sha256.integrity(b"hello").len(), 7 + 44);
986 assert_eq!(SriAlgorithm::Sha512.integrity(b"hello").len(), 7 + 88);
988 }
989
990 #[test]
991 fn rewrite_asset_refs_adds_sri_only_to_link_and_script() {
992 let mut manifest = HashMap::new();
993 for (name, fp) in [
994 ("style.css", "style.abc12345.css"),
995 ("app.js", "app.0badf00d.js"),
996 ("logo.png", "logo.c0ffee00.png"),
997 ] {
998 let _ = manifest.insert(
999 name.to_string(),
1000 AssetInfo {
1001 fingerprinted: fp.to_string(),
1002 sri: format!("sha384-{name}"),
1003 },
1004 );
1005 }
1006 let html = concat!(
1007 r#"<link rel="stylesheet" href="/style.css">"#,
1008 r#"<script src="/app.js" defer></script>"#,
1009 r#"<meta property="og:image" content="/logo.png">"#,
1010 r#"<img src="/logo.png" alt="">"#,
1011 r#"<script type="application/ld+json">{"image":"/logo.png","url":"/app.js"}</script>"#,
1012 );
1013 let out = rewrite_asset_refs(html, &manifest).unwrap();
1014 assert!(out.contains(
1015 r#"<link rel="stylesheet" href="/style.abc12345.css" integrity="sha384-style.css" crossorigin="anonymous">"#
1016 ), "{out}");
1017 assert!(out.contains(
1018 r#"<script src="/app.0badf00d.js" defer integrity="sha384-app.js" crossorigin="anonymous"></script>"#
1019 ), "{out}");
1020 assert!(
1021 out.contains(
1022 r#"<meta property="og:image" content="/logo.c0ffee00.png">"#
1023 ),
1024 "{out}"
1025 );
1026 assert!(
1027 out.contains(r#"<img src="/logo.c0ffee00.png" alt="">"#),
1028 "{out}"
1029 );
1030 assert_eq!(out.matches("integrity=").count(), 2, "{out}");
1031 let ld = out
1032 .split(r#"<script type="application/ld+json">"#)
1033 .nth(1)
1034 .and_then(|s| s.split("</script>").next())
1035 .expect("ld+json block");
1036 let value: serde_json::Value =
1037 serde_json::from_str(ld).expect("JSON-LD still parses");
1038 assert_eq!(value["image"], "/logo.c0ffee00.png");
1039 assert_eq!(value["url"], "/app.0badf00d.js");
1040 }
1041
1042 #[test]
1043 fn test_rewrite_asset_refs() {
1044 let mut manifest = HashMap::new();
1045 let _ = manifest.insert(
1046 "style.css".to_string(),
1047 AssetInfo {
1048 fingerprinted: "style.abc12345.css".to_string(),
1049 sri: "sha384-xyz".to_string(),
1050 },
1051 );
1052
1053 let html = r#"<link rel="stylesheet" href="style.css">"#;
1054 let result = rewrite_asset_refs(html, &manifest).unwrap();
1055 assert!(result.contains("style.abc12345.css"));
1056 assert!(result.contains("integrity=\"sha384-xyz\""));
1057 }
1058
1059 fn css_manifest() -> HashMap<String, AssetInfo> {
1062 let mut m = HashMap::new();
1063 let _ = m.insert(
1064 "images/logo.png".to_string(),
1065 AssetInfo {
1066 fingerprinted: "images/logo.deadbeef.png".to_string(),
1067 sri: String::new(),
1068 },
1069 );
1070 let _ = m.insert(
1071 "fonts/sans.woff2".to_string(),
1072 AssetInfo {
1073 fingerprinted: "fonts/sans.cafef00d.woff2".to_string(),
1074 sri: String::new(),
1075 },
1076 );
1077 m
1078 }
1079
1080 #[test]
1081 fn rewrite_css_urls_handles_absolute_path() {
1082 let dir = tempdir().unwrap();
1083 let css_path = dir.path().join("assets/style.css");
1084 let css = "body { background: url(/images/logo.png); }";
1085 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1086 assert!(out.contains("url(/images/logo.deadbeef.png)"));
1087 assert!(!out.contains("logo.png)"));
1088 }
1089
1090 #[test]
1091 fn rewrite_css_urls_handles_relative_path() {
1092 let dir = tempdir().unwrap();
1093 let css_path = dir.path().join("assets/style.css");
1094 let css = "body { background: url(../images/logo.png); }";
1095 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1096 assert!(out.contains("url(/images/logo.deadbeef.png)"));
1097 }
1098
1099 #[test]
1100 fn rewrite_css_urls_handles_double_quotes() {
1101 let dir = tempdir().unwrap();
1102 let css_path = dir.path().join("style.css");
1103 let css = r#"body { background: url("/images/logo.png"); }"#;
1104 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1105 assert!(out.contains(r#"url("/images/logo.deadbeef.png")"#));
1106 }
1107
1108 #[test]
1109 fn rewrite_css_urls_handles_single_quotes() {
1110 let dir = tempdir().unwrap();
1111 let css_path = dir.path().join("style.css");
1112 let css = "body { background: url('/images/logo.png'); }";
1113 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1114 assert!(out.contains("url('/images/logo.deadbeef.png')"));
1115 }
1116
1117 #[test]
1118 fn rewrite_css_urls_preserves_query_and_fragment() {
1119 let dir = tempdir().unwrap();
1120 let css_path = dir.path().join("style.css");
1121 let css = "@font-face { src: url(/fonts/sans.woff2?v=1#hint); }";
1122 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1123 assert!(out.contains("/fonts/sans.cafef00d.woff2?v=1#hint"));
1124 }
1125
1126 #[test]
1127 fn rewrite_css_urls_skips_external_and_data_urls() {
1128 let dir = tempdir().unwrap();
1129 let css_path = dir.path().join("style.css");
1130 let css = r#"
1131 a { background: url(https://cdn.example.com/x.png); }
1132 b { background: url(//cdn.example.com/y.png); }
1133 c { background: url(data:image/svg+xml,<svg/>); }
1134 "#;
1135 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1136 assert!(out.contains("https://cdn.example.com/x.png"));
1138 assert!(out.contains("//cdn.example.com/y.png"));
1139 assert!(out.contains("data:image/svg+xml"));
1140 }
1141
1142 #[test]
1143 fn rewrite_css_urls_no_change_when_url_not_in_manifest() {
1144 let dir = tempdir().unwrap();
1145 let css_path = dir.path().join("style.css");
1146 let css = "body { background: url(/images/missing.png); }";
1147 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1148 assert_eq!(out, css);
1149 }
1150
1151 #[test]
1152 fn rewrite_css_urls_unterminated_url_does_not_panic() {
1153 let dir = tempdir().unwrap();
1154 let css_path = dir.path().join("style.css");
1155 let css = "body { background: url(/images/logo.png";
1156 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1157 assert!(!out.is_empty(), "should not be empty");
1158 }
1159
1160 #[test]
1161 #[serial_test::parallel(assets_failpoint)]
1162 fn after_compile_rewrites_css_url_to_fingerprinted_image() {
1163 let dir = tempdir().unwrap();
1167 let site = dir.path().join("site");
1168 fs::create_dir_all(site.join("images")).unwrap();
1169 let png_bytes: &[u8] = &[
1171 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00,
1172 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
1173 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89,
1174 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63,
1175 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4,
1176 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60,
1177 0x82,
1178 ];
1179 fs::write(site.join("images/logo.png"), png_bytes).unwrap();
1180 fs::write(
1181 site.join("style.css"),
1182 "body { background: url(/images/logo.png); }",
1183 )
1184 .unwrap();
1185 fs::write(
1186 site.join("index.html"),
1187 r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#,
1188 )
1189 .unwrap();
1190
1191 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1192 FingerprintPlugin.after_compile(&ctx).unwrap();
1193
1194 let mut css_text = None;
1197 for entry in fs::read_dir(&site).unwrap().flatten() {
1198 let p = entry.path();
1199 if p.extension().is_some_and(|e| e == "css") {
1200 css_text = Some(fs::read_to_string(&p).unwrap());
1201 }
1202 }
1203 let css_text = css_text.expect("renamed CSS file present");
1204 assert!(
1205 css_text.contains("/images/logo."),
1206 "rewritten CSS should reference renamed PNG: {css_text}"
1207 );
1208 assert!(css_text.contains(".png"), "still ends in .png: {css_text}");
1209 assert!(
1212 !css_text.contains("/images/logo.png)"),
1213 "must no longer point at the un-fingerprinted PNG: {css_text}"
1214 );
1215 }
1216
1217 #[test]
1218 fn test_fingerprint_file_missing_returns_io_error() {
1219 let dir = tempdir().unwrap();
1220 let missing = dir.path().join("missing.css");
1221 let res =
1222 fingerprint_file(&missing, dir.path(), SriAlgorithm::default());
1223 assert!(res.is_err());
1224 let err = res.unwrap_err();
1225 let debug = format!("{err:?}");
1228 assert!(debug.contains("Io"));
1229 assert!(debug.contains("missing.css"));
1230 }
1231
1232 #[test]
1233 fn test_rewrite_css_urls_inplace_missing_returns_io_error() {
1234 let dir = tempdir().unwrap();
1235 let missing = dir.path().join("missing.css");
1236 let manifest = HashMap::new();
1237 let res = rewrite_css_urls_inplace(&missing, dir.path(), &manifest);
1238 assert!(res.is_err());
1239 let err = res.unwrap_err();
1240 let debug = format!("{err:?}");
1242 assert!(debug.contains("Io"));
1243 assert!(debug.contains("missing.css"));
1244 }
1245
1246 #[test]
1263 fn minified_css_stays_valid() {
1264 let corpus = [
1267 "h1 { font-size: clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem); }",
1268 "a { width: calc(100% - 2rem); height: calc(10px*2); }",
1269 "p { margin: 0 -1px 0 -1px; }",
1270 "@media (min-width: 48rem) and (max-width: 64rem) { .a { color: red } }",
1271 ".g { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }",
1272 ".s { background: url(\"a b.png\"); content: \"a { b } c\"; }",
1273 ":root { --x: 1px; --y: calc(var(--x) + 2px); }",
1274 "@supports (display: grid) { .h { display: grid } }",
1275 ".t { transition: color .2s ease-in-out, background .3s; }",
1276 "@font-face { font-family: \"X\"; src: url(x.woff2) format(\"woff2\"); }",
1277 ".n:not(.a, .b) > .c ~ .d + .e { color: #fff }",
1278 ".u { inset: 0 auto auto 0; aspect-ratio: 16 / 9; }",
1279 ];
1280
1281 for css in corpus {
1282 let out = minify_css(css);
1283 assert_balanced(&out, css);
1284 assert_math_operators_keep_their_spaces(&out, css);
1285 }
1286 }
1287
1288 fn assert_balanced(out: &str, src: &str) {
1290 let (mut braces, mut parens, mut brackets) = (0i32, 0i32, 0i32);
1291 let mut quote: Option<char> = None;
1292 let mut prev = '\0';
1293 for c in out.chars() {
1294 if let Some(q) = quote {
1295 if c == q && prev != '\\' {
1296 quote = None;
1297 }
1298 } else {
1299 match c {
1300 '"' | '\'' => quote = Some(c),
1301 '{' => braces += 1,
1302 '}' => braces -= 1,
1303 '(' => parens += 1,
1304 ')' => parens -= 1,
1305 '[' => brackets += 1,
1306 ']' => brackets -= 1,
1307 _ => {}
1308 }
1309 assert!(
1310 braces >= 0 && parens >= 0 && brackets >= 0,
1311 "unbalanced delimiter in\n in: {src}\n out: {out}"
1312 );
1313 }
1314 prev = c;
1315 }
1316 assert!(
1317 braces == 0 && parens == 0 && brackets == 0 && quote.is_none(),
1318 "unclosed delimiter in\n in: {src}\n out: {out}"
1319 );
1320 }
1321
1322 fn assert_math_operators_keep_their_spaces(out: &str, src: &str) {
1327 for func in ["calc(", "clamp(", "min(", "max("] {
1328 let mut from = 0;
1329 while let Some(at) = out[from..].find(func) {
1330 let open = from + at + func.len() - 1;
1331 let mut depth = 0i32;
1332 let mut close = open;
1333 for (i, c) in out[open..].char_indices() {
1334 match c {
1335 '(' => depth += 1,
1336 ')' => {
1337 depth -= 1;
1338 if depth == 0 {
1339 close = open + i;
1340 break;
1341 }
1342 }
1343 _ => {}
1344 }
1345 }
1346 let body: Vec<char> = out[open + 1..close].chars().collect();
1347 for (i, &c) in body.iter().enumerate() {
1348 if c != '+' && c != '-' {
1349 continue;
1350 }
1351 let prev =
1354 body[..i].iter().rev().find(|c| !c.is_whitespace());
1355 if !matches!(prev, Some(p) if p.is_alphanumeric() || *p == '%' || *p == ')')
1356 {
1357 continue;
1358 }
1359 assert!(
1360 body.get(i.wrapping_sub(1)).is_some_and(|c| c.is_whitespace())
1361 && body.get(i + 1).is_some_and(|c| c.is_whitespace()),
1362 "`{c}` lost the whitespace that makes it an operator in {func}…)\n in: {src}\n out: {out}"
1363 );
1364 }
1365 from = close.max(from + at + 1);
1366 }
1367 }
1368 }
1369
1370 #[test]
1381 fn minify_css_preserves_at_rule_preludes() {
1382 let cases = [
1383 "@media (min-width: 40rem)",
1384 "@media screen and (min-width: 40rem)",
1385 "@media (prefers-color-scheme: dark)",
1386 "@media (min-width: 40rem) and (max-width: 60rem)",
1387 "@supports (display: grid)",
1388 "@media (color-gamut: p3)",
1389 "@media not all and (monochrome)",
1390 ];
1391 let mut broken = Vec::new();
1392 for prelude in cases {
1393 let out = minify_css(&format!("{prelude} {{ .a {{color:red}} }}"));
1394 if !out.starts_with(prelude) {
1395 broken.push(format!("`{prelude}` -> `{out}`"));
1396 }
1397 }
1398 assert!(
1399 broken.is_empty(),
1400 "at-rule preludes mangled:\n {}",
1401 broken.join("\n ")
1402 );
1403 }
1404
1405 #[test]
1409 fn minify_css_is_faithful_on_selectors_from_the_published_themes() {
1410 let corpus = [
1413 "#ssg-search-btn, #ssg-search-btn *",
1414 ".prose a[href^=\"http\"]::after",
1415 ":root:not([data-theme=\"light\"])",
1416 ".card:hover .card-title",
1417 "nav[aria-label] ul li a",
1418 ".tmux-pane .pane-content .tree-node",
1419 "html[data-theme=\"dark\"] .btn-primary:focus-visible",
1420 ".a > .b ~ .c + .d",
1421 "li:nth-child(2n + 1) > span",
1422 ];
1423 let mut broken = Vec::new();
1424 for sel in corpus {
1425 let out = minify_css(&format!("{sel} {{color:red}}"));
1426 let got = out.trim_end_matches("{color:red}");
1427 let want: String = sel
1428 .replace(" > ", ">")
1429 .replace(" ~ ", "~")
1430 .replace(" + ", "+")
1431 .replace(", ", ",");
1432 if got != want {
1433 broken.push(format!(
1434 "`{sel}`\n -> `{got}`\n want `{want}`"
1435 ));
1436 }
1437 }
1438 assert!(
1439 broken.is_empty(),
1440 "theme selectors changed:\n {}",
1441 broken.join("\n ")
1442 );
1443 }
1444
1445 #[test]
1446 fn minify_css_preserves_every_descendant_combinator() {
1447 let cases = [
1448 ("#a *", "universal descendant"),
1449 ("[data-x] [data-y]", "attribute then attribute"),
1450 (":not(.a) .b", "functional pseudo then class"),
1451 (".a :hover", "descendant pseudo-class"),
1452 (".a ::before", "descendant pseudo-element"),
1453 ("a[href] span", "attribute then element"),
1454 ("li:nth-child(2) a", "functional pseudo then element"),
1455 ("* html .a", "universal first"),
1456 (".a .b", "class then class"),
1457 (".a *:focus", "universal with pseudo"),
1458 ];
1459 let mut broken = Vec::new();
1460 for (sel, label) in cases {
1461 let out = minify_css(&format!("{sel} {{color:red}}"));
1462 let got = out.trim_end_matches("{color:red}");
1463 if got != sel {
1464 broken.push(format!("{label}: `{sel}` -> `{got}`"));
1465 }
1466 }
1467 assert!(
1468 broken.is_empty(),
1469 "these selectors did not survive minification:\n {}",
1470 broken.join("\n ")
1471 );
1472 }
1473
1474 #[test]
1485 fn minify_css_keeps_the_space_before_a_universal_selector() {
1486 let out = minify_css("#btn, #btn * { color: red !important; }");
1487 assert!(
1488 out.contains("#btn *"),
1489 "the descendant combinator must survive: {out}"
1490 );
1491 assert!(
1492 !out.contains("#btn*"),
1493 "must not produce the selector-eating form: {out}"
1494 );
1495 }
1496
1497 #[test]
1501 fn minify_css_leaves_calc_with_a_star_valid() {
1502 let out = minify_css(".a { width: calc(2px * 3); }");
1503 assert!(
1504 out.contains("calc(2px * 3)") || out.contains("calc(2px*3)"),
1505 "calc must stay valid either way: {out}"
1506 );
1507 }
1508
1509 #[test]
1514 fn minify_css_is_idempotent() {
1515 let corpus = [
1516 "h1 { font-size: clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem); }",
1517 "a { width: calc(100% - 2rem); }",
1518 "p { margin: 0 -1px; }",
1519 "/* c */ .x { color: red } /* d */",
1520 ".s { content: \"a { b } c\"; }",
1521 ];
1522 for css in corpus {
1523 let once = minify_css(css);
1524 let twice = minify_css(&once);
1525 assert_eq!(once, twice, "not idempotent for: {css}");
1526 }
1527 }
1528
1529 #[test]
1531 fn minify_js_preserves_whitespace_inside_string_literals() {
1532 let js = "var m = \"0px 0px -10% 0px\";\nvar c = 'x - y';\nvar e = 'a \\' b';\nvar t = ` a\n b`;";
1533 let out = minify_js(js);
1534 assert!(out.contains("\"0px 0px -10% 0px\""), "{out}");
1535 assert!(out.contains("'x - y'"), "{out}");
1536 assert!(out.contains("'a \\' b'"), "{out}");
1537 assert!(out.contains("` a\n b`"), "{out}");
1538 assert_eq!(minify_js(&out), out, "idempotent");
1539 }
1540
1541 #[test]
1543 fn minify_js_is_idempotent() {
1544 let corpus = [
1545 "const a = 1; // trailing\nconst b = a / 2;",
1546 "let s = \"a // not a comment\";",
1547 "function f() { return 1 /* mid */ + 2; }",
1548 "const re = /ab+c/g;",
1549 ];
1550 for js in corpus {
1551 let once = minify_js(js);
1552 let twice = minify_js(&once);
1553 assert_eq!(once, twice, "not idempotent for: {js}");
1554 }
1555 }
1556
1557 #[test]
1559 fn minify_css_preserves_string_literals_verbatim() {
1560 let out = minify_css(".a::after { content: \" two spaces \"; }");
1561 assert!(
1562 out.contains("\" two spaces \""),
1563 "string literal was rewritten: {out}"
1564 );
1565 }
1566
1567 #[test]
1568 fn minify_css_keeps_whitespace_around_plus_in_math() {
1569 let css = "h1 { font-size: clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem); }";
1573 let out = minify_css(css);
1574 assert!(
1575 out.contains("1.75rem + 1.6vw"),
1576 "whitespace around `+` was dropped: {out}"
1577 );
1578 }
1579
1580 #[test]
1581 fn minify_css_keeps_math_whitespace_after_a_run_of_spaces() {
1582 let css = "h1 { font-size: clamp(1.25rem, 1.125rem + 0.5556vw, 1.625rem); }";
1586 let out = minify_css(css);
1587 assert!(out.contains("1.125rem + 0.5556vw"), "got: {out}");
1588 assert!(!out.contains("rem+"), "space before `+` dropped: {out}");
1589 let css = "a { width: calc(100% - 2rem); }";
1590 assert!(minify_css(css).contains("100% - 2rem"));
1591 }
1592
1593 #[test]
1594 fn minify_css_keeps_list_separator_after_a_run_of_spaces() {
1595 let out = minify_css("p { margin: 0 -1px; }");
1596 assert!(out.contains("0 -1px"), "got: {out}");
1597 let out = minify_css(
1598 "@media (min-width: 1px) and (max-width: 2px) { a { b: c } }",
1599 );
1600 assert!(out.contains(") and ("), "got: {out}");
1601 }
1602
1603 #[test]
1604 fn minify_css_keeps_whitespace_around_minus_in_math() {
1605 let css = "a { width: calc(100% - 2rem); }";
1606 let out = minify_css(css);
1607 assert!(out.contains("100% - 2rem"), "got: {out}");
1608 }
1609
1610 mod math_whitespace_properties {
1611 use super::minify_css;
1612 use proptest::prelude::*;
1613
1614 proptest! {
1615 #![proptest_config(ProptestConfig::with_cases(1000))]
1616
1617 #[test]
1621 fn clamp_operator_spacing_survives_whitespace_runs(
1622 a in 0u16..100, b in 0u16..100, c in 0u16..100, d in 0u16..100,
1623 op in prop::sample::select(vec!['+', '-']),
1624 w1 in " {1,4}", w2 in " {1,4}", w3 in " {1,4}",
1625 ) {
1626 let css = format!(
1627 "h1 {{ font-size: clamp({a}rem,{w1}{b}rem{w2}{op}{w3}{c}vw, {d}rem); }}"
1628 );
1629 let out = minify_css(&css);
1630 let expected = format!("{b}rem {op} {c}vw");
1631 prop_assert!(out.contains(&expected), "got: {}", out);
1632 prop_assert!(out.contains(&format!("rem {op} ")), "got: {}", out);
1633 prop_assert_eq!(minify_css(&out), out);
1634 }
1635 }
1636 }
1637
1638 #[test]
1639 fn minify_css_still_collapses_ordinary_whitespace() {
1640 let out = minify_css("body { color : red ; }");
1641 assert!(!out.contains(" "), "double space survived: {out}");
1642 assert!(out.contains("red"), "got: {out}");
1643 }
1644
1645 #[test]
1646 fn minify_css_keeps_space_before_negative_value_in_a_list() {
1647 let out = minify_css("p { margin: 0 -1px; }");
1649 assert!(out.contains("0 -1px"), "got: {out}");
1650 }
1651
1652 #[test]
1653 fn minify_css_handles_escaped_quote_inside_string() {
1654 let input = "a{content:\"x\\\"y\";}";
1657 let out = minify_css(input);
1658 assert!(out.contains("\"x\\\"y\""));
1659 }
1660
1661 #[test]
1662 fn minify_js_handles_escaped_quote_inside_string() {
1663 let input = "const s = \"a\\\"b\";";
1664 let out = minify_js(input);
1665 assert!(out.contains("\"a\\\"b\""));
1666 }
1667
1668 #[test]
1669 fn minify_js_preserves_division_operator() {
1670 assert_eq!(minify_js("const x = a / b;"), "const x=a/b;");
1672 }
1673
1674 #[test]
1675 fn minify_js_leading_comment_produces_leading_newline_branch() {
1676 assert_eq!(minify_js("// c\nvar x = 1;"), "var x=1;");
1680 }
1681
1682 #[test]
1683 fn minify_css_handles_leading_and_trailing_whitespace() {
1684 assert_eq!(minify_css(" body { color: red; } "), "body{color:red;}");
1687 }
1688
1689 #[test]
1690 fn minify_js_trailing_space_after_word_char_is_dropped() {
1691 assert_eq!(minify_js(" var x = 1 "), "var x=1");
1695 }
1696
1697 #[test]
1702 fn resolve_css_url_relative_css_dir_hits_curdir_and_escape() {
1703 let site = Path::new("/abs/site");
1707 let out = resolve_css_url("img.png", Path::new("./css"), site);
1708 assert!(out.is_none());
1709 }
1710
1711 #[test]
1712 fn resolve_css_url_keeps_dotted_names_and_inside_backtracks() {
1713 let site = Path::new("/abs/site");
1714 let css_dir = site.join("css");
1715 assert_eq!(
1716 resolve_css_url("../img/a..b.png", &css_dir, site).as_deref(),
1717 Some("img/a..b.png")
1718 );
1719 assert_eq!(
1720 resolve_css_url("/img/./x/../logo.svg", &css_dir, site).as_deref(),
1721 Some("img/logo.svg")
1722 );
1723 }
1724
1725 #[test]
1726 fn resolve_css_url_refuses_climbing_out_even_to_come_back() {
1727 let site = Path::new("/abs/site");
1730 let css_dir = site.join("css");
1731 for url in ["../../x.png", "../../site/x.png", "/../site/x.png"] {
1732 assert_eq!(resolve_css_url(url, &css_dir, site), None, "{url}");
1733 }
1734 }
1735
1736 #[test]
1737 fn resolve_css_url_rejects_paths_escaping_site_dir() {
1738 let dir = tempdir().unwrap();
1739 let site = dir.path();
1740 let css_dir = site.join("css");
1741 let out = resolve_css_url("/../../etc/passwd", &css_dir, site);
1742 assert!(out.is_none());
1743 }
1744
1745 #[test]
1750 fn fingerprint_file_write_fails_when_new_path_squatted_by_dir() {
1751 let dir = tempdir().unwrap();
1755 let css_path = dir.path().join("style.css");
1756 let css = "body { color: red; }";
1757 fs::write(&css_path, css).unwrap();
1758 let hash = sha256_hex(minify_css(css).as_bytes());
1759 let squat = dir.path().join(format!("style.{}.css", &hash[..8]));
1760 fs::create_dir_all(squat.join("keep")).unwrap();
1761
1762 let res =
1763 fingerprint_file(&css_path, dir.path(), SriAlgorithm::default());
1764 assert!(res.is_err());
1765 }
1766
1767 #[test]
1768 fn fingerprint_file_rename_fails_when_new_path_is_nonempty_dir() {
1769 let dir = tempdir().unwrap();
1772 let png_path = dir.path().join("img.png");
1773 fs::write(&png_path, b"png-bytes").unwrap();
1774 let hash = sha256_hex(b"png-bytes");
1775 let squat = dir.path().join(format!("img.{}.png", &hash[..8]));
1776 fs::create_dir_all(squat.join("keep")).unwrap();
1777
1778 let res =
1779 fingerprint_file(&png_path, dir.path(), SriAlgorithm::default());
1780 assert!(res.is_err());
1781 }
1782
1783 #[test]
1784 fn fingerprint_file_non_utf8_css_is_renamed_not_minified() {
1785 let dir = tempdir().unwrap();
1786 let css_path = dir.path().join("bin.css");
1787 fs::write(&css_path, [0xFF, 0xFE, 0x00, 0x9F]).unwrap();
1788 let (rel_old, info) =
1789 fingerprint_file(&css_path, dir.path(), SriAlgorithm::default())
1790 .unwrap();
1791 assert_eq!(rel_old, "bin.css");
1792 assert!(info.fingerprinted.ends_with(".css"));
1793 assert!(!css_path.exists(), "original renamed away");
1794 }
1795
1796 #[test]
1797 fn fingerprint_file_non_utf8_js_is_renamed_not_minified() {
1798 let dir = tempdir().unwrap();
1799 let js_path = dir.path().join("bin.js");
1800 fs::write(&js_path, [0xFF, 0xFE, 0x00, 0x9F]).unwrap();
1801 let (rel_old, info) =
1802 fingerprint_file(&js_path, dir.path(), SriAlgorithm::default())
1803 .unwrap();
1804 assert_eq!(rel_old, "bin.js");
1805 assert!(info.fingerprinted.ends_with(".js"));
1806 }
1807
1808 fn plugin_ctx(root: &Path, site: &Path) -> PluginContext {
1813 PluginContext::new(root, root, site, root)
1814 }
1815
1816 #[test]
1817 #[cfg(unix)]
1818 fn after_compile_fails_when_site_has_unreadable_subdir() {
1819 use std::os::unix::fs::PermissionsExt;
1820 let dir = tempdir().unwrap();
1821 let site = dir.path().join("site");
1822 let locked = site.join("locked");
1823 fs::create_dir_all(&locked).unwrap();
1824 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1825 .unwrap();
1826
1827 let res =
1828 FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1829
1830 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1831 if let Err(e) = res {
1833 assert!(!format!("{e}").is_empty(), "should not be empty");
1834 }
1835 }
1836
1837 #[test]
1838 fn after_compile_propagates_non_css_fingerprint_error() {
1839 let dir = tempdir().unwrap();
1841 let site = dir.path().join("site");
1842 fs::create_dir_all(&site).unwrap();
1843 fs::write(site.join("img.png"), b"png-bytes").unwrap();
1844 let hash = sha256_hex(b"png-bytes");
1845 let squat = site.join(format!("img.{}.png", &hash[..8]));
1846 fs::create_dir_all(squat.join("keep")).unwrap();
1847
1848 let err = FingerprintPlugin
1849 .after_compile(&plugin_ctx(dir.path(), &site))
1850 .unwrap_err();
1851 assert!(!format!("{err}").is_empty(), "should not be empty");
1852 }
1853
1854 #[test]
1855 fn after_compile_propagates_css_fingerprint_error() {
1856 let dir = tempdir().unwrap();
1859 let site = dir.path().join("site");
1860 fs::create_dir_all(&site).unwrap();
1861 let css = "body { color: blue; }";
1862 fs::write(site.join("style.css"), css).unwrap();
1863 let hash = sha256_hex(minify_css(css).as_bytes());
1864 let squat = site.join(format!("style.{}.css", &hash[..8]));
1865 fs::create_dir_all(squat.join("keep")).unwrap();
1866
1867 let err = FingerprintPlugin
1868 .after_compile(&plugin_ctx(dir.path(), &site))
1869 .unwrap_err();
1870 assert!(!format!("{err}").is_empty(), "should not be empty");
1871 }
1872
1873 #[test]
1874 #[cfg(unix)]
1875 fn after_compile_propagates_unreadable_css_error() {
1876 use std::os::unix::fs::PermissionsExt;
1877 let dir = tempdir().unwrap();
1878 let site = dir.path().join("site");
1879 fs::create_dir_all(&site).unwrap();
1880 let css_path = site.join("style.css");
1881 fs::write(&css_path, "body{}").unwrap();
1882 fs::set_permissions(&css_path, fs::Permissions::from_mode(0o000))
1883 .unwrap();
1884
1885 let res =
1886 FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1887
1888 let _ =
1889 fs::set_permissions(&css_path, fs::Permissions::from_mode(0o644));
1890 if let Err(e) = res {
1891 assert!(!format!("{e}").is_empty(), "should not be empty");
1892 }
1893 }
1894
1895 #[test]
1896 #[cfg(unix)]
1897 fn after_compile_fails_when_html_is_unreadable() {
1898 use std::os::unix::fs::PermissionsExt;
1899 let dir = tempdir().unwrap();
1900 let site = dir.path().join("site");
1901 fs::create_dir_all(&site).unwrap();
1902 fs::write(site.join("img.png"), b"png-bytes").unwrap();
1903 let html = site.join("index.html");
1904 fs::write(&html, "<img src=\"/img.png\">").unwrap();
1905 fs::set_permissions(&html, fs::Permissions::from_mode(0o000)).unwrap();
1906
1907 let res =
1908 FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1909
1910 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1911 if let Err(e) = res {
1912 assert!(!format!("{e}").is_empty(), "should not be empty");
1913 }
1914 }
1915
1916 #[test]
1917 #[cfg(unix)]
1918 fn after_compile_fails_when_html_is_readonly() {
1919 use std::os::unix::fs::PermissionsExt;
1920 let dir = tempdir().unwrap();
1921 let site = dir.path().join("site");
1922 fs::create_dir_all(&site).unwrap();
1923 fs::write(site.join("img.png"), b"png-bytes").unwrap();
1924 let html = site.join("index.html");
1925 fs::write(&html, "<img src=\"/img.png\">").unwrap();
1926 fs::set_permissions(&html, fs::Permissions::from_mode(0o444)).unwrap();
1927
1928 let res =
1929 FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1930
1931 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1932 if let Err(e) = res {
1933 assert!(!format!("{e}").is_empty(), "should not be empty");
1934 }
1935 }
1936
1937 #[test]
1938 #[cfg(unix)]
1939 fn rewrite_html_references_fails_on_unreadable_subdir() {
1940 use std::os::unix::fs::PermissionsExt;
1941 let dir = tempdir().unwrap();
1942 let site = dir.path().join("site");
1943 let locked = site.join("locked");
1944 fs::create_dir_all(&locked).unwrap();
1945 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1946 .unwrap();
1947
1948 let res = rewrite_html_references(&site, &HashMap::new());
1949
1950 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1951 if let Err(e) = res {
1952 assert!(!format!("{e}").is_empty(), "should not be empty");
1953 }
1954 }
1955
1956 #[test]
1957 #[cfg(unix)]
1958 fn rewrite_css_urls_inplace_write_error_on_readonly_css() {
1959 use std::os::unix::fs::PermissionsExt;
1960 let dir = tempdir().unwrap();
1961 let site = dir.path();
1962 let css_path = site.join("style.css");
1963 fs::write(&css_path, "a{background:url(/img.png);}").unwrap();
1964 let mut manifest = HashMap::new();
1965 let _ = manifest.insert(
1966 "img.png".to_string(),
1967 AssetInfo {
1968 fingerprinted: "img.deadbeef.png".to_string(),
1969 sri: "sha384-x".to_string(),
1970 },
1971 );
1972 fs::set_permissions(&css_path, fs::Permissions::from_mode(0o444))
1973 .unwrap();
1974
1975 let res = rewrite_css_urls_inplace(&css_path, site, &manifest);
1976
1977 let _ =
1978 fs::set_permissions(&css_path, fs::Permissions::from_mode(0o644));
1979 if let Err(e) = res {
1980 assert!(!format!("{e}").is_empty(), "should not be empty");
1981 }
1982 }
1983
1984 #[test]
1985 fn fingerprint_assets_propagates_missing_file_error() {
1986 let dir = tempdir().unwrap();
1987 let missing = vec![dir.path().join("nope.css")];
1988 let res =
1989 fingerprint_assets(&missing, dir.path(), SriAlgorithm::default());
1990 assert!(res.is_err());
1991 }
1992}
1993
1994#[cfg(all(test, feature = "test-fault-injection"))]
2005mod fault_tests {
2006 use super::*;
2007 use tempfile::tempdir;
2008
2009 struct FailGuard(&'static str);
2011
2012 impl Drop for FailGuard {
2013 fn drop(&mut self) {
2014 let _ = fail::cfg(self.0, "off");
2015 }
2016 }
2017
2018 #[test]
2019 #[serial_test::serial(assets_failpoint)]
2020 fn remove_original_failpoint_propagates() {
2021 let _guard = FailGuard("assets::remove-original");
2022 fail::cfg("assets::remove-original", "return")
2023 .expect("activate failpoint");
2024
2025 let dir = tempdir().unwrap();
2026 let css_path = dir.path().join("style.css");
2027 fs::write(&css_path, "body { color: red; }").unwrap();
2028
2029 let err =
2030 fingerprint_file(&css_path, dir.path(), SriAlgorithm::default())
2031 .expect_err("injected removal failure must propagate");
2032 assert!(
2033 format!("{err:?}").contains("injected: assets::remove-original")
2034 );
2035 assert!(css_path.exists());
2039 }
2040}