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
19#[derive(Debug, Clone, Copy)]
32pub struct FingerprintPlugin;
33
34impl Plugin for FingerprintPlugin {
35 fn name(&self) -> &'static str {
36 "fingerprint"
37 }
38
39 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
40 if !ctx.site_dir.exists() {
41 return Ok(());
42 }
43
44 let all_assets = collect_assets(&ctx.site_dir)?;
45 if all_assets.is_empty() {
46 return Ok(());
47 }
48
49 let (css_files, non_css): (Vec<_>, Vec<_>) = all_assets
68 .into_iter()
69 .partition(|p| p.extension().is_some_and(|e| e == "css"));
70
71 let sri_algorithm = ctx
74 .config
75 .as_ref()
76 .map_or_else(SriAlgorithm::default, |c| c.security.sri_algorithm);
77
78 let mut manifest =
79 fingerprint_assets(&non_css, &ctx.site_dir, sri_algorithm)?;
80
81 for css_path in &css_files {
82 rewrite_css_urls_inplace(css_path, &ctx.site_dir, &manifest)?;
83 }
84
85 let css_manifest =
86 fingerprint_assets(&css_files, &ctx.site_dir, sri_algorithm)?;
87 manifest.extend(css_manifest);
88
89 rewrite_html_references(&ctx.site_dir, &manifest)?;
90
91 log::info!(
92 "[fingerprint] Processed {} asset(s) across {} CSS + {} other",
93 manifest.len(),
94 css_files.len(),
95 manifest.len() - css_files.len()
96 );
97 Ok(())
98 }
99}
100
101fn fingerprint_assets(
103 assets: &[PathBuf],
104 site_dir: &Path,
105 sri_algorithm: SriAlgorithm,
106) -> Result<HashMap<String, AssetInfo>, SsgError> {
107 let mut manifest = HashMap::new();
108
109 for asset_path in assets {
110 let info = fingerprint_file(asset_path, site_dir, sri_algorithm)?;
111 let _ = manifest.insert(info.0, info.1);
112 }
113
114 Ok(manifest)
115}
116
117fn fingerprint_file(
119 asset_path: &Path,
120 site_dir: &Path,
121 sri_algorithm: SriAlgorithm,
122) -> Result<(String, AssetInfo), SsgError> {
123 let mut content = fs::read(asset_path).with_path(asset_path)?;
124 let ext = asset_path
125 .extension()
126 .unwrap_or_default()
127 .to_string_lossy()
128 .to_string();
129 let mut minified = false;
130
131 if ext == "css" {
132 if let Ok(css_str) = std::str::from_utf8(&content) {
133 content = minify_css(css_str).into_bytes();
134 minified = true;
135 }
136 } else if ext == "js" || ext == "mjs" {
137 if let Ok(js_str) = std::str::from_utf8(&content) {
138 content = minify_js(js_str).into_bytes();
139 minified = true;
140 }
141 }
142
143 let hash = sha256_hex(&content);
144 let short_hash = &hash[..8];
145
146 let stem = asset_path.file_stem().unwrap_or_default().to_string_lossy();
147 let new_name = format!("{stem}.{short_hash}.{ext}");
148 let new_path = asset_path.with_file_name(&new_name);
149
150 let sri = sri_algorithm.integrity(&content);
151
152 if minified {
153 fs::write(&new_path, &content).with_path(&new_path)?;
154 } else {
155 let _ = fs::copy(asset_path, &new_path).with_path(asset_path)?;
156 }
157
158 if new_path != asset_path {
167 fail_point!("assets::remove-original", |_| Err(SsgError::Validation {
172 field: "assets".to_string(),
173 message: "injected: assets::remove-original".to_string(),
174 }));
175 fs::remove_file(asset_path).with_path(asset_path)?;
176 }
177
178 let rel_old = asset_path
179 .strip_prefix(site_dir)
180 .unwrap_or(asset_path)
181 .to_string_lossy()
182 .replace('\\', "/");
183 let rel_new = new_path
184 .strip_prefix(site_dir)
185 .unwrap_or(&new_path)
186 .to_string_lossy()
187 .replace('\\', "/");
188
189 Ok((
190 rel_old,
191 AssetInfo {
192 fingerprinted: rel_new,
193 sri,
194 },
195 ))
196}
197
198fn rewrite_html_references(
200 site_dir: &Path,
201 manifest: &HashMap<String, AssetInfo>,
202) -> Result<(), SsgError> {
203 let html_files = collect_html_files(site_dir)?;
204 for html_path in &html_files {
205 let html = fs::read_to_string(html_path).with_path(html_path)?;
206 let rewritten = rewrite_asset_refs(&html, manifest);
207 if rewritten != html {
208 fs::write(html_path, rewritten).with_path(html_path)?;
209 }
210 }
211 Ok(())
212}
213
214#[derive(Debug, Clone)]
215struct AssetInfo {
216 fingerprinted: String,
217 sri: String,
218}
219
220fn rewrite_css_urls(
244 css: &str,
245 css_path: &Path,
246 site_dir: &Path,
247 manifest: &HashMap<String, AssetInfo>,
248) -> String {
249 let css_dir = css_path.parent().unwrap_or(css_path);
250 let mut out = String::with_capacity(css.len());
251 let mut remaining = css;
252
253 while let Some(idx) = remaining.find("url(") {
254 out.push_str(&remaining[..idx]);
255 let after_open = &remaining[idx + 4..]; let Some(close_idx) = after_open.find(')') else {
257 out.push_str("url(");
259 out.push_str(after_open);
260 return out;
261 };
262 let raw = &after_open[..close_idx];
263 let rest = &after_open[close_idx + 1..];
264
265 let trimmed = raw.trim();
267 let (quote, inner) = if let Some(s) = trimmed.strip_prefix('"') {
268 ('"', s.strip_suffix('"').unwrap_or(s))
269 } else if let Some(s) = trimmed.strip_prefix('\'') {
270 ('\'', s.strip_suffix('\'').unwrap_or(s))
271 } else {
272 ('\0', trimmed)
273 };
274
275 let (url, suffix) = if let Some(i) = inner.find(['?', '#']) {
277 (&inner[..i], &inner[i..])
278 } else {
279 (inner, "")
280 };
281
282 let resolved = resolve_css_url(url, css_dir, site_dir);
283 let hit = resolved.and_then(|key| manifest.get(&key).map(|i| (key, i)));
284
285 out.push_str("url(");
286 if let Some((_, info)) = hit {
287 let new_url = format!("/{}{}", info.fingerprinted, suffix);
289 if quote != '\0' {
290 out.push(quote);
291 }
292 out.push_str(&new_url);
293 if quote != '\0' {
294 out.push(quote);
295 }
296 } else {
297 out.push_str(raw);
299 }
300 out.push(')');
301
302 remaining = rest;
303 }
304
305 out.push_str(remaining);
306 out
307}
308
309fn resolve_css_url(
314 url: &str,
315 css_dir: &Path,
316 site_dir: &Path,
317) -> Option<String> {
318 let trimmed = url.trim();
319 if trimmed.is_empty()
320 || trimmed.starts_with("data:")
321 || trimmed.starts_with("http://")
322 || trimmed.starts_with("https://")
323 || trimmed.starts_with("//")
324 {
325 return None;
326 }
327
328 let candidate = if let Some(stripped) = trimmed.strip_prefix('/') {
330 site_dir.join(stripped)
331 } else {
332 css_dir.join(trimmed)
333 };
334
335 let mut components: Vec<&std::ffi::OsStr> = Vec::new();
339 for c in candidate.components() {
340 match c {
341 std::path::Component::CurDir => {}
342 std::path::Component::ParentDir => {
343 let _ = components.pop();
344 }
345 std::path::Component::Normal(s) => components.push(s),
346 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
347 components.clear();
348 }
349 }
350 }
351 let mut resolved = PathBuf::new();
352 for c in components {
353 resolved.push(c);
354 }
355
356 let site_components: Vec<&std::ffi::OsStr> = site_dir
358 .components()
359 .filter_map(|c| match c {
360 std::path::Component::Normal(s) => Some(s),
361 _ => None,
362 })
363 .collect();
364 let resolved_components: Vec<&std::ffi::OsStr> = resolved
365 .components()
366 .filter_map(|c| match c {
367 std::path::Component::Normal(s) => Some(s),
368 _ => None,
369 })
370 .collect();
371
372 if resolved_components.len() < site_components.len()
373 || resolved_components[..site_components.len()] != site_components[..]
374 {
375 return None;
376 }
377
378 let rel: PathBuf = resolved_components[site_components.len()..]
379 .iter()
380 .collect();
381 Some(rel.to_string_lossy().replace('\\', "/"))
382}
383
384fn rewrite_css_urls_inplace(
387 css_path: &Path,
388 site_dir: &Path,
389 manifest: &HashMap<String, AssetInfo>,
390) -> Result<(), SsgError> {
391 let css = fs::read_to_string(css_path).with_path(css_path)?;
392 let rewritten = rewrite_css_urls(&css, css_path, site_dir, manifest);
393 if rewritten != css {
394 fs::write(css_path, rewritten).with_path(css_path)?;
395 }
396 Ok(())
397}
398
399fn rewrite_asset_refs(
401 html: &str,
402 manifest: &HashMap<String, AssetInfo>,
403) -> String {
404 let mut result = html.to_string();
405 for (old_path, info) in manifest {
406 let old_ref = format!("\"{old_path}\"");
408 let old_ref_slash = format!("\"/{old_path}\"");
409 let new_ref = format!(
410 "\"{}\" integrity=\"{}\" crossorigin=\"anonymous\"",
411 info.fingerprinted, info.sri
412 );
413 let new_ref_slash = format!(
414 "\"/{}\" integrity=\"{}\" crossorigin=\"anonymous\"",
415 info.fingerprinted, info.sri
416 );
417
418 result = result.replace(&old_ref, &new_ref);
419 result = result.replace(&old_ref_slash, &new_ref_slash);
420
421 let old_suffix = format!("/{old_path}\"");
423 let new_suffix = format!(
424 "/{}\" integrity=\"{}\" crossorigin=\"anonymous\"",
425 info.fingerprinted, info.sri
426 );
427 result = result.replace(&old_suffix, &new_suffix);
428 }
429 result
430}
431
432fn sha256_hex(data: &[u8]) -> String {
442 let mut hasher = Sha256::new();
443 hasher.update(data);
444 let bytes = hasher.finalize();
445 let mut s = String::with_capacity(64);
446 for b in bytes {
447 use std::fmt::Write as _;
448 let _ = write!(s, "{b:02x}");
449 }
450 s
451}
452
453const fn in_value_context(block_stack: &[bool]) -> bool {
462 matches!(block_stack.last(), Some(false))
463}
464
465#[must_use]
474pub fn minify_css(css: &str) -> String {
475 let mut result = String::with_capacity(css.len());
476 let mut chars = css.chars().peekable();
477 let mut in_comment = false;
478 let mut in_string = None;
479
480 while let Some(ch) = chars.next() {
481 if in_comment {
482 if ch == '*' && chars.peek() == Some(&'/') {
483 let _ = chars.next();
484 in_comment = false;
485 }
486 continue;
487 }
488
489 if let Some(quote) = in_string {
490 result.push(ch);
491 if ch == quote {
492 let mut backslashes = 0;
493 let mut temp = result.len() as isize - 2;
494 while temp >= 0 && result.as_bytes()[temp as usize] == b'\\' {
495 backslashes += 1;
496 temp -= 1;
497 }
498 if backslashes % 2 == 0 {
499 in_string = None;
500 }
501 }
502 continue;
503 }
504
505 if ch == '/' && chars.peek() == Some(&'*') {
506 let _ = chars.next();
507 in_comment = true;
508 continue;
509 }
510
511 if ch == '\'' || ch == '"' {
512 in_string = Some(ch);
513 result.push(ch);
514 continue;
515 }
516
517 if ch.is_whitespace() {
518 result.push(' ');
519 continue;
520 }
521
522 result.push(ch);
523 }
524
525 let mut clean = String::with_capacity(result.len());
526 let chars: Vec<char> = result.chars().collect();
527 let mut i = 0;
528 let mut in_string: Option<char> = None;
533 let mut block_stack: Vec<bool> = Vec::new();
563 let mut prelude_is_at_rule = false;
565 while i < chars.len() {
566 let ch = chars[i];
567 if in_string.is_none() {
568 match ch {
569 '@' if !in_value_context(&block_stack) => {
570 prelude_is_at_rule = true;
571 }
572 '{' => {
573 block_stack.push(prelude_is_at_rule);
574 prelude_is_at_rule = false;
575 }
576 '}' => {
577 let _ = block_stack.pop();
578 prelude_is_at_rule = false;
579 }
580 ';' if !in_value_context(&block_stack) => {
581 prelude_is_at_rule = false;
582 }
583 _ => {}
584 }
585 }
586 let in_block = in_value_context(&block_stack);
587
588 if let Some(q) = in_string {
589 clean.push(ch);
590 if ch == q {
592 let mut back = 0usize;
593 let mut k = i;
594 while k > 0 && chars[k - 1] == '\\' {
595 back += 1;
596 k -= 1;
597 }
598 if back.is_multiple_of(2) {
599 in_string = None;
600 }
601 }
602 i += 1;
603 continue;
604 }
605 if ch == '\'' || ch == '"' {
606 in_string = Some(ch);
607 clean.push(ch);
608 i += 1;
609 continue;
610 }
611
612 if ch == ' ' {
613 let prev = if i > 0 { Some(chars[i - 1]) } else { None };
614 if !in_block {
615 let mut j = i;
626 while j < chars.len() && chars[j] == ' ' {
627 j += 1;
628 }
629 let next_sel = chars.get(j).copied();
630 let last = clean.chars().next_back();
631 let separator = |c: Option<char>| {
632 matches!(c, Some('{' | '}' | ',' | '>' | '~' | '+' | ';'))
633 || c.is_none()
634 };
635 if !separator(last) && !separator(next_sel) && last != Some(' ')
636 {
637 clean.push(' ');
638 }
639 i = j;
640 continue;
641 }
642 let next = if i + 1 < chars.len() {
643 Some(chars[i + 1])
644 } else {
645 None
646 };
647
648 let joins_prelude_tokens = match (prev, next) {
678 (Some(')'), Some(n)) => n.is_ascii_alphabetic(),
679 (Some(p), Some('(')) => p.is_ascii_alphabetic(),
680 _ => false,
681 };
682
683 let math_operator_boundary = matches!(
690 (prev, next),
691 (Some(')'), Some('+' | '-')) | (Some('+' | '-'), Some('('))
692 );
693
694 let is_needed = joins_prelude_tokens
695 || math_operator_boundary
696 || match (prev, next) {
697 (Some(p), Some(n)) => {
698 let is_p_word = p.is_alphanumeric()
699 || p == '-'
700 || p == '+'
701 || p == '_'
702 || p == '#'
703 || p == '.'
704 || p == '@'
705 || p == '%'
706 || p == '*'
707 || p == '$';
708 let is_n_word = n.is_alphanumeric()
709 || n == '-'
710 || n == '+'
711 || n == '_'
712 || n == '#'
713 || n == '.'
714 || n == '@'
715 || n == '%'
716 || n == '*'
717 || n == '$';
718 is_p_word && is_n_word
719 }
720 _ => false,
721 };
722 if is_needed {
723 clean.push(' ');
724 }
725 } else {
726 clean.push(ch);
727 }
728 i += 1;
729 }
730
731 clean.trim().to_string()
732}
733
734#[must_use]
741pub fn minify_js(js: &str) -> String {
742 let mut result = String::with_capacity(js.len());
743 let mut chars = js.chars().peekable();
744 let mut in_multi_comment = false;
745 let mut in_single_comment = false;
746 let mut in_string = None;
747
748 while let Some(ch) = chars.next() {
749 if in_multi_comment {
750 if ch == '*' && chars.peek() == Some(&'/') {
751 let _ = chars.next();
752 in_multi_comment = false;
753 }
754 continue;
755 }
756
757 if in_single_comment {
758 if ch == '\n' || ch == '\r' {
759 in_single_comment = false;
760 result.push('\n');
761 }
762 continue;
763 }
764
765 if let Some(quote) = in_string {
766 result.push(ch);
767 if ch == quote {
768 let mut backslashes = 0;
769 let mut temp = result.len() as isize - 2;
770 while temp >= 0 && result.as_bytes()[temp as usize] == b'\\' {
771 backslashes += 1;
772 temp -= 1;
773 }
774 if backslashes % 2 == 0 {
775 in_string = None;
776 }
777 }
778 continue;
779 }
780
781 if ch == '/' {
782 if chars.peek() == Some(&'*') {
783 let _ = chars.next();
784 in_multi_comment = true;
785 continue;
786 } else if chars.peek() == Some(&'/') {
787 let _ = chars.next();
788 in_single_comment = true;
789 continue;
790 }
791 }
792
793 if ch == '\'' || ch == '"' || ch == '`' {
794 in_string = Some(ch);
795 result.push(ch);
796 continue;
797 }
798
799 if ch.is_whitespace() {
800 if ch == '\n' || ch == '\r' {
801 if !result.ends_with('\n') && !result.is_empty() {
802 result.push('\n');
803 }
804 } else if !result.ends_with(' ')
805 && !result.ends_with('\n')
806 && !result.is_empty()
807 {
808 result.push(' ');
809 }
810 continue;
811 }
812
813 result.push(ch);
814 }
815
816 let mut clean = String::with_capacity(result.len());
817 let chars: Vec<char> = result.chars().collect();
818 let mut i = 0;
819 while i < chars.len() {
820 let ch = chars[i];
821 if ch == ' ' || ch == '\n' {
822 let prev = if i > 0 { Some(chars[i - 1]) } else { None };
823 let next = if i + 1 < chars.len() {
824 Some(chars[i + 1])
825 } else {
826 None
827 };
828
829 let is_needed = match (prev, next) {
830 (Some(p), Some(n)) => {
831 let is_p_word = p.is_alphanumeric() || p == '_' || p == '$';
832 let is_n_word = n.is_alphanumeric() || n == '_' || n == '$';
833 is_p_word && is_n_word
834 }
835 _ => false,
836 };
837 if is_needed {
838 clean.push(ch);
839 }
840 } else {
841 clean.push(ch);
842 }
843 i += 1;
844 }
845 clean.trim().to_string()
846}
847
848const FINGERPRINTED_EXTENSIONS: &[&str] = &[
855 "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
856 "woff", "woff2", "ttf", "otf",
857];
858
859const UNFINGERPRINTED_DIRS: &[&str] = &["_islands"];
872
873fn collect_assets(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
874 let all = crate::walk::walk_files_multi(dir, FINGERPRINTED_EXTENSIONS)?;
875 Ok(all
876 .into_iter()
877 .filter(|path| {
878 !path.components().any(|c| {
879 UNFINGERPRINTED_DIRS
880 .iter()
881 .any(|d| c.as_os_str() == std::ffi::OsStr::new(d))
882 })
883 })
884 .collect())
885}
886
887fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
888 crate::walk::walk_files(dir, "html")
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894 use tempfile::tempdir;
895
896 #[test]
897 fn test_minify_css() {
898 let input = "body {\n color: red;\n background-color: #ffffff; /* comment */\n}";
899 let expected = "body{color:red;background-color:#ffffff;}";
900 assert_eq!(minify_css(input), expected);
901 }
902
903 #[test]
904 fn test_minify_js() {
905 let input = "const x = 5; // comment\n/* multi\ncomment */\nconst y = 10;\nconsole.log(x + y);";
906 let expected = "const x=5;const y=10;console.log(x+y);";
907 assert_eq!(minify_js(input), expected);
908 }
909
910 #[test]
911 fn test_sha256_hex_deterministic() {
912 let h1 = sha256_hex(b"hello");
913 let h2 = sha256_hex(b"hello");
914 assert_eq!(h1, h2);
915 assert_eq!(h1.len(), 64);
917 }
918
919 #[test]
920 fn test_sha256_hex_known_vectors() {
921 assert_eq!(
924 sha256_hex(b""),
925 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
926 );
927 assert_eq!(
929 sha256_hex(b"abc"),
930 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
931 );
932 }
933
934 #[test]
935 fn test_sri_default_algorithm_known_vector() {
936 assert_eq!(
940 SriAlgorithm::default().integrity(b""),
941 "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
942 );
943 }
944
945 #[test]
946 fn test_sri_sha256_override_known_vector() {
947 assert_eq!(
949 SriAlgorithm::Sha256.integrity(b""),
950 "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
951 );
952 }
953
954 #[test]
955 fn test_sha256_hex_varies() {
956 let h1 = sha256_hex(b"hello");
957 let h2 = sha256_hex(b"world");
958 assert_ne!(h1, h2);
959 }
960
961 #[test]
962 #[serial_test::parallel(assets_failpoint)]
963 fn test_fingerprint_plugin() {
964 let dir = tempdir().unwrap();
965 let site = dir.path().join("site");
966 fs::create_dir_all(&site).unwrap();
967
968 fs::write(site.join("style.css"), "body { color: red; }").unwrap();
970
971 let html = r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#;
973 fs::write(site.join("index.html"), html).unwrap();
974
975 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
976 FingerprintPlugin.after_compile(&ctx).unwrap();
977
978 assert!(!site.join("style.css").exists());
980
981 let entries: Vec<_> = fs::read_dir(&site)
983 .unwrap()
984 .filter_map(Result::ok)
985 .filter(|e| {
986 e.path()
987 .file_name()
988 .unwrap()
989 .to_string_lossy()
990 .starts_with("style.")
991 && e.path().extension().is_some_and(|e| e == "css")
992 })
993 .collect();
994 assert_eq!(entries.len(), 1);
995
996 let output = fs::read_to_string(site.join("index.html")).unwrap();
999 assert!(output.contains("integrity=\"sha384-"));
1000 assert!(output.contains("crossorigin=\"anonymous\""));
1001 assert!(!output.contains("href=\"style.css\""));
1002 }
1003
1004 #[test]
1005 #[serial_test::parallel(assets_failpoint)]
1006 fn default_sri_is_sha384_with_exact_known_vector() {
1007 let dir = tempdir().unwrap();
1012 let site = dir.path().join("site");
1013 fs::create_dir_all(&site).unwrap();
1014 fs::write(site.join("app.js"), "console.log(1);").unwrap();
1015 fs::write(
1016 site.join("index.html"),
1017 r#"<html><head><script src="app.js"></script></head></html>"#,
1018 )
1019 .unwrap();
1020
1021 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1022 FingerprintPlugin.after_compile(&ctx).unwrap();
1023
1024 let html = fs::read_to_string(site.join("index.html")).unwrap();
1025 assert!(
1026 html.contains(
1027 "integrity=\"sha384-JawyHuhqEMFMvdtX+VHylbI0hfJp2F7nvwFVRqqfuOoK5oW7TG/7V11Zs7zeFWIE\""
1028 ),
1029 "expected exact SHA-384 SRI vector; got: {html}"
1030 );
1031 }
1032
1033 #[test]
1034 #[serial_test::parallel(assets_failpoint)]
1035 fn sri_algorithm_config_override_emits_sha256() {
1036 use crate::cmd::{SecurityConfig, SsgConfig};
1040
1041 let dir = tempdir().unwrap();
1042 let site = dir.path().join("site");
1043 fs::create_dir_all(&site).unwrap();
1044 fs::write(site.join("app.js"), "console.log(1);").unwrap();
1045 fs::write(
1046 site.join("index.html"),
1047 r#"<html><head><script src="app.js"></script></head></html>"#,
1048 )
1049 .unwrap();
1050
1051 let config = SsgConfig::builder()
1052 .security(SecurityConfig {
1053 sri_algorithm: SriAlgorithm::Sha256,
1054 })
1055 .build()
1056 .unwrap();
1057 let ctx = PluginContext::with_config(
1058 dir.path(),
1059 dir.path(),
1060 &site,
1061 dir.path(),
1062 config,
1063 );
1064 FingerprintPlugin.after_compile(&ctx).unwrap();
1065
1066 let html = fs::read_to_string(site.join("index.html")).unwrap();
1067 assert!(
1068 html.contains(
1069 "integrity=\"sha256-NcFG924SlHfGQGG8hFEeEJDz1NgFlxPmZj3Us1sfdkI=\""
1070 ),
1071 "expected exact SHA-256 SRI vector; got: {html}"
1072 );
1073 assert!(!html.contains("sha384-"), "override must win: {html}");
1074 }
1075
1076 #[test]
1077 fn name_returns_static_fingerprint_identifier() {
1078 assert_eq!(FingerprintPlugin.name(), "fingerprint");
1079 }
1080
1081 #[test]
1082 fn after_compile_missing_site_dir_returns_ok() {
1083 let dir = tempdir().unwrap();
1085 let missing = dir.path().join("missing");
1086 let ctx =
1087 PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
1088 FingerprintPlugin.after_compile(&ctx).unwrap();
1089 assert!(!missing.exists());
1090 }
1091
1092 #[test]
1093 fn after_compile_no_assets_short_circuits() {
1094 let dir = tempdir().unwrap();
1097 let site = dir.path().join("site");
1098 fs::create_dir_all(&site).unwrap();
1099 fs::write(site.join("index.html"), "<p></p>").unwrap();
1100
1101 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1102 FingerprintPlugin.after_compile(&ctx).unwrap();
1103 assert_eq!(
1105 fs::read_to_string(site.join("index.html")).unwrap(),
1106 "<p></p>"
1107 );
1108 }
1109
1110 #[test]
1111 #[serial_test::parallel(assets_failpoint)]
1112 fn after_compile_fingerprint_absolute_path_href() {
1113 let dir = tempdir().unwrap();
1116 let site = dir.path().join("site");
1117 fs::create_dir_all(&site).unwrap();
1118 fs::write(site.join("app.js"), "console.log(1);").unwrap();
1119 fs::write(
1120 site.join("index.html"),
1121 r#"<html><head><script src="/app.js"></script></head></html>"#,
1122 )
1123 .unwrap();
1124
1125 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1126 FingerprintPlugin.after_compile(&ctx).unwrap();
1127 let html = fs::read_to_string(site.join("index.html")).unwrap();
1128 assert!(html.contains("integrity=\"sha384-"));
1130 }
1131
1132 #[test]
1133 fn collect_assets_picks_up_fingerprintable_extensions() {
1134 let dir = tempdir().unwrap();
1138 fs::write(dir.path().join("a.css"), "").unwrap();
1139 fs::write(dir.path().join("b.js"), "").unwrap();
1140 fs::write(dir.path().join("c.html"), "").unwrap();
1141 fs::write(dir.path().join("d.png"), "").unwrap();
1142 fs::write(dir.path().join("e.woff2"), "").unwrap();
1143 fs::write(dir.path().join("f.txt"), "").unwrap();
1144 let files = collect_assets(dir.path()).unwrap();
1145 assert_eq!(files.len(), 4);
1147 }
1148
1149 #[test]
1150 fn collect_assets_recurses_into_subdirectories() {
1151 let dir = tempdir().unwrap();
1152 let nested = dir.path().join("vendor");
1153 fs::create_dir(&nested).unwrap();
1154 fs::write(dir.path().join("top.css"), "").unwrap();
1155 fs::write(nested.join("lib.js"), "").unwrap();
1156 let files = collect_assets(dir.path()).unwrap();
1157 assert_eq!(files.len(), 2);
1158 }
1159
1160 #[test]
1161 fn collect_html_files_filters_non_html() {
1162 let dir = tempdir().unwrap();
1163 fs::write(dir.path().join("a.html"), "").unwrap();
1164 fs::write(dir.path().join("b.css"), "").unwrap();
1165 let files = collect_html_files(dir.path()).unwrap();
1166 assert_eq!(files.len(), 1);
1167 }
1168
1169 #[test]
1170 fn sha256_hex_produces_64_hex_chars() {
1171 assert_eq!(sha256_hex(b"abc").len(), 64);
1172 assert_eq!(sha256_hex(b"").len(), 64);
1173 }
1174
1175 #[test]
1176 fn sri_integrity_is_nonempty_for_input() {
1177 assert!(!SriAlgorithm::default().integrity(b"hello").is_empty());
1178 }
1179
1180 #[test]
1181 fn sri_integrity_payload_lengths_per_algorithm() {
1182 assert_eq!(SriAlgorithm::Sha384.integrity(b"hello").len(), 7 + 64);
1184 assert_eq!(SriAlgorithm::Sha256.integrity(b"hello").len(), 7 + 44);
1186 assert_eq!(SriAlgorithm::Sha512.integrity(b"hello").len(), 7 + 88);
1188 }
1189
1190 #[test]
1191 fn test_rewrite_asset_refs() {
1192 let mut manifest = HashMap::new();
1193 let _ = manifest.insert(
1194 "style.css".to_string(),
1195 AssetInfo {
1196 fingerprinted: "style.abc12345.css".to_string(),
1197 sri: "sha384-xyz".to_string(),
1198 },
1199 );
1200
1201 let html = r#"<link rel="stylesheet" href="style.css">"#;
1202 let result = rewrite_asset_refs(html, &manifest);
1203 assert!(result.contains("style.abc12345.css"));
1204 assert!(result.contains("integrity=\"sha384-xyz\""));
1205 }
1206
1207 fn css_manifest() -> HashMap<String, AssetInfo> {
1210 let mut m = HashMap::new();
1211 let _ = m.insert(
1212 "images/logo.png".to_string(),
1213 AssetInfo {
1214 fingerprinted: "images/logo.deadbeef.png".to_string(),
1215 sri: String::new(),
1216 },
1217 );
1218 let _ = m.insert(
1219 "fonts/sans.woff2".to_string(),
1220 AssetInfo {
1221 fingerprinted: "fonts/sans.cafef00d.woff2".to_string(),
1222 sri: String::new(),
1223 },
1224 );
1225 m
1226 }
1227
1228 #[test]
1229 fn rewrite_css_urls_handles_absolute_path() {
1230 let dir = tempdir().unwrap();
1231 let css_path = dir.path().join("assets/style.css");
1232 let css = "body { background: url(/images/logo.png); }";
1233 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1234 assert!(out.contains("url(/images/logo.deadbeef.png)"));
1235 assert!(!out.contains("logo.png)"));
1236 }
1237
1238 #[test]
1239 fn rewrite_css_urls_handles_relative_path() {
1240 let dir = tempdir().unwrap();
1241 let css_path = dir.path().join("assets/style.css");
1242 let css = "body { background: url(../images/logo.png); }";
1243 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1244 assert!(out.contains("url(/images/logo.deadbeef.png)"));
1245 }
1246
1247 #[test]
1248 fn rewrite_css_urls_handles_double_quotes() {
1249 let dir = tempdir().unwrap();
1250 let css_path = dir.path().join("style.css");
1251 let css = r#"body { background: url("/images/logo.png"); }"#;
1252 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1253 assert!(out.contains(r#"url("/images/logo.deadbeef.png")"#));
1254 }
1255
1256 #[test]
1257 fn rewrite_css_urls_handles_single_quotes() {
1258 let dir = tempdir().unwrap();
1259 let css_path = dir.path().join("style.css");
1260 let css = "body { background: url('/images/logo.png'); }";
1261 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1262 assert!(out.contains("url('/images/logo.deadbeef.png')"));
1263 }
1264
1265 #[test]
1266 fn rewrite_css_urls_preserves_query_and_fragment() {
1267 let dir = tempdir().unwrap();
1268 let css_path = dir.path().join("style.css");
1269 let css = "@font-face { src: url(/fonts/sans.woff2?v=1#hint); }";
1270 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1271 assert!(out.contains("/fonts/sans.cafef00d.woff2?v=1#hint"));
1272 }
1273
1274 #[test]
1275 fn rewrite_css_urls_skips_external_and_data_urls() {
1276 let dir = tempdir().unwrap();
1277 let css_path = dir.path().join("style.css");
1278 let css = r#"
1279 a { background: url(https://cdn.example.com/x.png); }
1280 b { background: url(//cdn.example.com/y.png); }
1281 c { background: url(data:image/svg+xml,<svg/>); }
1282 "#;
1283 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1284 assert!(out.contains("https://cdn.example.com/x.png"));
1286 assert!(out.contains("//cdn.example.com/y.png"));
1287 assert!(out.contains("data:image/svg+xml"));
1288 }
1289
1290 #[test]
1291 fn rewrite_css_urls_no_change_when_url_not_in_manifest() {
1292 let dir = tempdir().unwrap();
1293 let css_path = dir.path().join("style.css");
1294 let css = "body { background: url(/images/missing.png); }";
1295 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1296 assert_eq!(out, css);
1297 }
1298
1299 #[test]
1300 fn rewrite_css_urls_unterminated_url_does_not_panic() {
1301 let dir = tempdir().unwrap();
1302 let css_path = dir.path().join("style.css");
1303 let css = "body { background: url(/images/logo.png";
1304 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
1305 assert!(!out.is_empty());
1306 }
1307
1308 #[test]
1309 #[serial_test::parallel(assets_failpoint)]
1310 fn after_compile_rewrites_css_url_to_fingerprinted_image() {
1311 let dir = tempdir().unwrap();
1315 let site = dir.path().join("site");
1316 fs::create_dir_all(site.join("images")).unwrap();
1317 let png_bytes: &[u8] = &[
1319 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00,
1320 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
1321 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89,
1322 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63,
1323 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4,
1324 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60,
1325 0x82,
1326 ];
1327 fs::write(site.join("images/logo.png"), png_bytes).unwrap();
1328 fs::write(
1329 site.join("style.css"),
1330 "body { background: url(/images/logo.png); }",
1331 )
1332 .unwrap();
1333 fs::write(
1334 site.join("index.html"),
1335 r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#,
1336 )
1337 .unwrap();
1338
1339 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1340 FingerprintPlugin.after_compile(&ctx).unwrap();
1341
1342 let mut css_text = None;
1345 for entry in fs::read_dir(&site).unwrap().flatten() {
1346 let p = entry.path();
1347 if p.extension().is_some_and(|e| e == "css") {
1348 css_text = Some(fs::read_to_string(&p).unwrap());
1349 }
1350 }
1351 let css_text = css_text.expect("renamed CSS file present");
1352 assert!(
1353 css_text.contains("/images/logo."),
1354 "rewritten CSS should reference renamed PNG: {css_text}"
1355 );
1356 assert!(css_text.contains(".png"), "still ends in .png: {css_text}");
1357 assert!(
1360 !css_text.contains("/images/logo.png)"),
1361 "must no longer point at the un-fingerprinted PNG: {css_text}"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_fingerprint_file_missing_returns_io_error() {
1367 let dir = tempdir().unwrap();
1368 let missing = dir.path().join("missing.css");
1369 let res =
1370 fingerprint_file(&missing, dir.path(), SriAlgorithm::default());
1371 assert!(res.is_err());
1372 let err = res.unwrap_err();
1373 let debug = format!("{err:?}");
1376 assert!(debug.contains("Io"));
1377 assert!(debug.contains("missing.css"));
1378 }
1379
1380 #[test]
1381 fn test_rewrite_css_urls_inplace_missing_returns_io_error() {
1382 let dir = tempdir().unwrap();
1383 let missing = dir.path().join("missing.css");
1384 let manifest = HashMap::new();
1385 let res = rewrite_css_urls_inplace(&missing, dir.path(), &manifest);
1386 assert!(res.is_err());
1387 let err = res.unwrap_err();
1388 let debug = format!("{err:?}");
1390 assert!(debug.contains("Io"));
1391 assert!(debug.contains("missing.css"));
1392 }
1393
1394 #[test]
1411 fn minified_css_stays_valid() {
1412 let corpus = [
1415 "h1 { font-size: clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem); }",
1416 "a { width: calc(100% - 2rem); height: calc(10px*2); }",
1417 "p { margin: 0 -1px 0 -1px; }",
1418 "@media (min-width: 48rem) and (max-width: 64rem) { .a { color: red } }",
1419 ".g { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }",
1420 ".s { background: url(\"a b.png\"); content: \"a { b } c\"; }",
1421 ":root { --x: 1px; --y: calc(var(--x) + 2px); }",
1422 "@supports (display: grid) { .h { display: grid } }",
1423 ".t { transition: color .2s ease-in-out, background .3s; }",
1424 "@font-face { font-family: \"X\"; src: url(x.woff2) format(\"woff2\"); }",
1425 ".n:not(.a, .b) > .c ~ .d + .e { color: #fff }",
1426 ".u { inset: 0 auto auto 0; aspect-ratio: 16 / 9; }",
1427 ];
1428
1429 for css in corpus {
1430 let out = minify_css(css);
1431 assert_balanced(&out, css);
1432 assert_math_operators_keep_their_spaces(&out, css);
1433 }
1434 }
1435
1436 fn assert_balanced(out: &str, src: &str) {
1438 let (mut braces, mut parens, mut brackets) = (0i32, 0i32, 0i32);
1439 let mut quote: Option<char> = None;
1440 let mut prev = '\0';
1441 for c in out.chars() {
1442 if let Some(q) = quote {
1443 if c == q && prev != '\\' {
1444 quote = None;
1445 }
1446 } else {
1447 match c {
1448 '"' | '\'' => quote = Some(c),
1449 '{' => braces += 1,
1450 '}' => braces -= 1,
1451 '(' => parens += 1,
1452 ')' => parens -= 1,
1453 '[' => brackets += 1,
1454 ']' => brackets -= 1,
1455 _ => {}
1456 }
1457 assert!(
1458 braces >= 0 && parens >= 0 && brackets >= 0,
1459 "unbalanced delimiter in\n in: {src}\n out: {out}"
1460 );
1461 }
1462 prev = c;
1463 }
1464 assert!(
1465 braces == 0 && parens == 0 && brackets == 0 && quote.is_none(),
1466 "unclosed delimiter in\n in: {src}\n out: {out}"
1467 );
1468 }
1469
1470 fn assert_math_operators_keep_their_spaces(out: &str, src: &str) {
1475 for func in ["calc(", "clamp(", "min(", "max("] {
1476 let mut from = 0;
1477 while let Some(at) = out[from..].find(func) {
1478 let open = from + at + func.len() - 1;
1479 let mut depth = 0i32;
1480 let mut close = open;
1481 for (i, c) in out[open..].char_indices() {
1482 match c {
1483 '(' => depth += 1,
1484 ')' => {
1485 depth -= 1;
1486 if depth == 0 {
1487 close = open + i;
1488 break;
1489 }
1490 }
1491 _ => {}
1492 }
1493 }
1494 let body: Vec<char> = out[open + 1..close].chars().collect();
1495 for (i, &c) in body.iter().enumerate() {
1496 if c != '+' && c != '-' {
1497 continue;
1498 }
1499 let prev =
1502 body[..i].iter().rev().find(|c| !c.is_whitespace());
1503 if !matches!(prev, Some(p) if p.is_alphanumeric() || *p == '%' || *p == ')')
1504 {
1505 continue;
1506 }
1507 assert!(
1508 body.get(i.wrapping_sub(1)).is_some_and(|c| c.is_whitespace())
1509 && body.get(i + 1).is_some_and(|c| c.is_whitespace()),
1510 "`{c}` lost the whitespace that makes it an operator in {func}…)\n in: {src}\n out: {out}"
1511 );
1512 }
1513 from = close.max(from + at + 1);
1514 }
1515 }
1516 }
1517
1518 #[test]
1529 fn minify_css_preserves_at_rule_preludes() {
1530 let cases = [
1531 "@media (min-width: 40rem)",
1532 "@media screen and (min-width: 40rem)",
1533 "@media (prefers-color-scheme: dark)",
1534 "@media (min-width: 40rem) and (max-width: 60rem)",
1535 "@supports (display: grid)",
1536 "@media (color-gamut: p3)",
1537 "@media not all and (monochrome)",
1538 ];
1539 let mut broken = Vec::new();
1540 for prelude in cases {
1541 let out = minify_css(&format!("{prelude} {{ .a {{color:red}} }}"));
1542 if !out.starts_with(prelude) {
1543 broken.push(format!("`{prelude}` -> `{out}`"));
1544 }
1545 }
1546 assert!(
1547 broken.is_empty(),
1548 "at-rule preludes mangled:\n {}",
1549 broken.join("\n ")
1550 );
1551 }
1552
1553 #[test]
1557 fn minify_css_is_faithful_on_selectors_from_the_published_themes() {
1558 let corpus = [
1561 "#ssg-search-btn, #ssg-search-btn *",
1562 ".prose a[href^=\"http\"]::after",
1563 ":root:not([data-theme=\"light\"])",
1564 ".card:hover .card-title",
1565 "nav[aria-label] ul li a",
1566 ".tmux-pane .pane-content .tree-node",
1567 "html[data-theme=\"dark\"] .btn-primary:focus-visible",
1568 ".a > .b ~ .c + .d",
1569 "li:nth-child(2n + 1) > span",
1570 ];
1571 let mut broken = Vec::new();
1572 for sel in corpus {
1573 let out = minify_css(&format!("{sel} {{color:red}}"));
1574 let got = out.trim_end_matches("{color:red}");
1575 let want: String = sel
1576 .replace(" > ", ">")
1577 .replace(" ~ ", "~")
1578 .replace(" + ", "+")
1579 .replace(", ", ",");
1580 if got != want {
1581 broken.push(format!(
1582 "`{sel}`\n -> `{got}`\n want `{want}`"
1583 ));
1584 }
1585 }
1586 assert!(
1587 broken.is_empty(),
1588 "theme selectors changed:\n {}",
1589 broken.join("\n ")
1590 );
1591 }
1592
1593 #[test]
1594 fn minify_css_preserves_every_descendant_combinator() {
1595 let cases = [
1596 ("#a *", "universal descendant"),
1597 ("[data-x] [data-y]", "attribute then attribute"),
1598 (":not(.a) .b", "functional pseudo then class"),
1599 (".a :hover", "descendant pseudo-class"),
1600 (".a ::before", "descendant pseudo-element"),
1601 ("a[href] span", "attribute then element"),
1602 ("li:nth-child(2) a", "functional pseudo then element"),
1603 ("* html .a", "universal first"),
1604 (".a .b", "class then class"),
1605 (".a *:focus", "universal with pseudo"),
1606 ];
1607 let mut broken = Vec::new();
1608 for (sel, label) in cases {
1609 let out = minify_css(&format!("{sel} {{color:red}}"));
1610 let got = out.trim_end_matches("{color:red}");
1611 if got != sel {
1612 broken.push(format!("{label}: `{sel}` -> `{got}`"));
1613 }
1614 }
1615 assert!(
1616 broken.is_empty(),
1617 "these selectors did not survive minification:\n {}",
1618 broken.join("\n ")
1619 );
1620 }
1621
1622 #[test]
1633 fn minify_css_keeps_the_space_before_a_universal_selector() {
1634 let out = minify_css("#btn, #btn * { color: red !important; }");
1635 assert!(
1636 out.contains("#btn *"),
1637 "the descendant combinator must survive: {out}"
1638 );
1639 assert!(
1640 !out.contains("#btn*"),
1641 "must not produce the selector-eating form: {out}"
1642 );
1643 }
1644
1645 #[test]
1649 fn minify_css_leaves_calc_with_a_star_valid() {
1650 let out = minify_css(".a { width: calc(2px * 3); }");
1651 assert!(
1652 out.contains("calc(2px * 3)") || out.contains("calc(2px*3)"),
1653 "calc must stay valid either way: {out}"
1654 );
1655 }
1656
1657 #[test]
1662 fn minify_css_is_idempotent() {
1663 let corpus = [
1664 "h1 { font-size: clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem); }",
1665 "a { width: calc(100% - 2rem); }",
1666 "p { margin: 0 -1px; }",
1667 "/* c */ .x { color: red } /* d */",
1668 ".s { content: \"a { b } c\"; }",
1669 ];
1670 for css in corpus {
1671 let once = minify_css(css);
1672 let twice = minify_css(&once);
1673 assert_eq!(once, twice, "not idempotent for: {css}");
1674 }
1675 }
1676
1677 #[test]
1679 fn minify_js_is_idempotent() {
1680 let corpus = [
1681 "const a = 1; // trailing\nconst b = a / 2;",
1682 "let s = \"a // not a comment\";",
1683 "function f() { return 1 /* mid */ + 2; }",
1684 "const re = /ab+c/g;",
1685 ];
1686 for js in corpus {
1687 let once = minify_js(js);
1688 let twice = minify_js(&once);
1689 assert_eq!(once, twice, "not idempotent for: {js}");
1690 }
1691 }
1692
1693 #[test]
1695 fn minify_css_preserves_string_literals_verbatim() {
1696 let out = minify_css(".a::after { content: \" two spaces \"; }");
1697 assert!(
1698 out.contains("\" two spaces \""),
1699 "string literal was rewritten: {out}"
1700 );
1701 }
1702
1703 #[test]
1704 fn minify_css_keeps_whitespace_around_plus_in_math() {
1705 let css = "h1 { font-size: clamp(2.07rem, 1.75rem + 1.6vw, 3.13rem); }";
1709 let out = minify_css(css);
1710 assert!(
1711 out.contains("1.75rem + 1.6vw"),
1712 "whitespace around `+` was dropped: {out}"
1713 );
1714 }
1715
1716 #[test]
1717 fn minify_css_keeps_whitespace_around_minus_in_math() {
1718 let css = "a { width: calc(100% - 2rem); }";
1719 let out = minify_css(css);
1720 assert!(out.contains("100% - 2rem"), "got: {out}");
1721 }
1722
1723 #[test]
1724 fn minify_css_still_collapses_ordinary_whitespace() {
1725 let out = minify_css("body { color : red ; }");
1726 assert!(!out.contains(" "), "double space survived: {out}");
1727 assert!(out.contains("red"), "got: {out}");
1728 }
1729
1730 #[test]
1731 fn minify_css_keeps_space_before_negative_value_in_a_list() {
1732 let out = minify_css("p { margin: 0 -1px; }");
1734 assert!(out.contains("0 -1px"), "got: {out}");
1735 }
1736
1737 #[test]
1738 fn minify_css_handles_escaped_quote_inside_string() {
1739 let input = "a{content:\"x\\\"y\";}";
1742 let out = minify_css(input);
1743 assert!(out.contains("\"x\\\"y\""));
1744 }
1745
1746 #[test]
1747 fn minify_js_handles_escaped_quote_inside_string() {
1748 let input = "const s = \"a\\\"b\";";
1749 let out = minify_js(input);
1750 assert!(out.contains("\"a\\\"b\""));
1751 }
1752
1753 #[test]
1754 fn minify_js_preserves_division_operator() {
1755 assert_eq!(minify_js("const x = a / b;"), "const x=a/b;");
1757 }
1758
1759 #[test]
1760 fn minify_js_leading_comment_produces_leading_newline_branch() {
1761 assert_eq!(minify_js("// c\nvar x = 1;"), "var x=1;");
1765 }
1766
1767 #[test]
1768 fn minify_css_handles_leading_and_trailing_whitespace() {
1769 assert_eq!(minify_css(" body { color: red; } "), "body{color:red;}");
1772 }
1773
1774 #[test]
1775 fn minify_js_trailing_space_after_word_char_is_dropped() {
1776 assert_eq!(minify_js(" var x = 1 "), "var x=1");
1780 }
1781
1782 #[test]
1787 fn resolve_css_url_relative_css_dir_hits_curdir_and_escape() {
1788 let site = Path::new("/abs/site");
1792 let out = resolve_css_url("img.png", Path::new("./css"), site);
1793 assert!(out.is_none());
1794 }
1795
1796 #[test]
1797 fn resolve_css_url_rejects_paths_escaping_site_dir() {
1798 let dir = tempdir().unwrap();
1799 let site = dir.path();
1800 let css_dir = site.join("css");
1801 let out = resolve_css_url("/../../etc/passwd", &css_dir, site);
1802 assert!(out.is_none());
1803 }
1804
1805 #[test]
1810 fn fingerprint_file_write_fails_when_new_path_squatted_by_dir() {
1811 let dir = tempdir().unwrap();
1815 let css_path = dir.path().join("style.css");
1816 let css = "body { color: red; }";
1817 fs::write(&css_path, css).unwrap();
1818 let hash = sha256_hex(minify_css(css).as_bytes());
1819 let squat = dir.path().join(format!("style.{}.css", &hash[..8]));
1820 fs::create_dir_all(squat.join("keep")).unwrap();
1821
1822 let res =
1823 fingerprint_file(&css_path, dir.path(), SriAlgorithm::default());
1824 assert!(res.is_err());
1825 }
1826
1827 #[test]
1828 fn fingerprint_file_rename_fails_when_new_path_is_nonempty_dir() {
1829 let dir = tempdir().unwrap();
1832 let png_path = dir.path().join("img.png");
1833 fs::write(&png_path, b"png-bytes").unwrap();
1834 let hash = sha256_hex(b"png-bytes");
1835 let squat = dir.path().join(format!("img.{}.png", &hash[..8]));
1836 fs::create_dir_all(squat.join("keep")).unwrap();
1837
1838 let res =
1839 fingerprint_file(&png_path, dir.path(), SriAlgorithm::default());
1840 assert!(res.is_err());
1841 }
1842
1843 #[test]
1844 fn fingerprint_file_non_utf8_css_is_renamed_not_minified() {
1845 let dir = tempdir().unwrap();
1846 let css_path = dir.path().join("bin.css");
1847 fs::write(&css_path, [0xFF, 0xFE, 0x00, 0x9F]).unwrap();
1848 let (rel_old, info) =
1849 fingerprint_file(&css_path, dir.path(), SriAlgorithm::default())
1850 .unwrap();
1851 assert_eq!(rel_old, "bin.css");
1852 assert!(info.fingerprinted.ends_with(".css"));
1853 assert!(!css_path.exists(), "original renamed away");
1854 }
1855
1856 #[test]
1857 fn fingerprint_file_non_utf8_js_is_renamed_not_minified() {
1858 let dir = tempdir().unwrap();
1859 let js_path = dir.path().join("bin.js");
1860 fs::write(&js_path, [0xFF, 0xFE, 0x00, 0x9F]).unwrap();
1861 let (rel_old, info) =
1862 fingerprint_file(&js_path, dir.path(), SriAlgorithm::default())
1863 .unwrap();
1864 assert_eq!(rel_old, "bin.js");
1865 assert!(info.fingerprinted.ends_with(".js"));
1866 }
1867
1868 fn plugin_ctx(root: &Path, site: &Path) -> PluginContext {
1873 PluginContext::new(root, root, site, root)
1874 }
1875
1876 #[test]
1877 #[cfg(unix)]
1878 fn after_compile_fails_when_site_has_unreadable_subdir() {
1879 use std::os::unix::fs::PermissionsExt;
1880 let dir = tempdir().unwrap();
1881 let site = dir.path().join("site");
1882 let locked = site.join("locked");
1883 fs::create_dir_all(&locked).unwrap();
1884 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1885 .unwrap();
1886
1887 let res =
1888 FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1889
1890 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1891 if let Err(e) = res {
1893 assert!(!format!("{e}").is_empty());
1894 }
1895 }
1896
1897 #[test]
1898 fn after_compile_propagates_non_css_fingerprint_error() {
1899 let dir = tempdir().unwrap();
1901 let site = dir.path().join("site");
1902 fs::create_dir_all(&site).unwrap();
1903 fs::write(site.join("img.png"), b"png-bytes").unwrap();
1904 let hash = sha256_hex(b"png-bytes");
1905 let squat = site.join(format!("img.{}.png", &hash[..8]));
1906 fs::create_dir_all(squat.join("keep")).unwrap();
1907
1908 let err = FingerprintPlugin
1909 .after_compile(&plugin_ctx(dir.path(), &site))
1910 .unwrap_err();
1911 assert!(!format!("{err}").is_empty());
1912 }
1913
1914 #[test]
1915 fn after_compile_propagates_css_fingerprint_error() {
1916 let dir = tempdir().unwrap();
1919 let site = dir.path().join("site");
1920 fs::create_dir_all(&site).unwrap();
1921 let css = "body { color: blue; }";
1922 fs::write(site.join("style.css"), css).unwrap();
1923 let hash = sha256_hex(minify_css(css).as_bytes());
1924 let squat = site.join(format!("style.{}.css", &hash[..8]));
1925 fs::create_dir_all(squat.join("keep")).unwrap();
1926
1927 let err = FingerprintPlugin
1928 .after_compile(&plugin_ctx(dir.path(), &site))
1929 .unwrap_err();
1930 assert!(!format!("{err}").is_empty());
1931 }
1932
1933 #[test]
1934 #[cfg(unix)]
1935 fn after_compile_propagates_unreadable_css_error() {
1936 use std::os::unix::fs::PermissionsExt;
1937 let dir = tempdir().unwrap();
1938 let site = dir.path().join("site");
1939 fs::create_dir_all(&site).unwrap();
1940 let css_path = site.join("style.css");
1941 fs::write(&css_path, "body{}").unwrap();
1942 fs::set_permissions(&css_path, fs::Permissions::from_mode(0o000))
1943 .unwrap();
1944
1945 let res =
1946 FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1947
1948 let _ =
1949 fs::set_permissions(&css_path, fs::Permissions::from_mode(0o644));
1950 if let Err(e) = res {
1951 assert!(!format!("{e}").is_empty());
1952 }
1953 }
1954
1955 #[test]
1956 #[cfg(unix)]
1957 fn after_compile_fails_when_html_is_unreadable() {
1958 use std::os::unix::fs::PermissionsExt;
1959 let dir = tempdir().unwrap();
1960 let site = dir.path().join("site");
1961 fs::create_dir_all(&site).unwrap();
1962 fs::write(site.join("img.png"), b"png-bytes").unwrap();
1963 let html = site.join("index.html");
1964 fs::write(&html, "<img src=\"/img.png\">").unwrap();
1965 fs::set_permissions(&html, fs::Permissions::from_mode(0o000)).unwrap();
1966
1967 let res =
1968 FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1969
1970 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1971 if let Err(e) = res {
1972 assert!(!format!("{e}").is_empty());
1973 }
1974 }
1975
1976 #[test]
1977 #[cfg(unix)]
1978 fn after_compile_fails_when_html_is_readonly() {
1979 use std::os::unix::fs::PermissionsExt;
1980 let dir = tempdir().unwrap();
1981 let site = dir.path().join("site");
1982 fs::create_dir_all(&site).unwrap();
1983 fs::write(site.join("img.png"), b"png-bytes").unwrap();
1984 let html = site.join("index.html");
1985 fs::write(&html, "<img src=\"/img.png\">").unwrap();
1986 fs::set_permissions(&html, fs::Permissions::from_mode(0o444)).unwrap();
1987
1988 let res =
1989 FingerprintPlugin.after_compile(&plugin_ctx(dir.path(), &site));
1990
1991 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1992 if let Err(e) = res {
1993 assert!(!format!("{e}").is_empty());
1994 }
1995 }
1996
1997 #[test]
1998 #[cfg(unix)]
1999 fn rewrite_html_references_fails_on_unreadable_subdir() {
2000 use std::os::unix::fs::PermissionsExt;
2001 let dir = tempdir().unwrap();
2002 let site = dir.path().join("site");
2003 let locked = site.join("locked");
2004 fs::create_dir_all(&locked).unwrap();
2005 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
2006 .unwrap();
2007
2008 let res = rewrite_html_references(&site, &HashMap::new());
2009
2010 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
2011 if let Err(e) = res {
2012 assert!(!format!("{e}").is_empty());
2013 }
2014 }
2015
2016 #[test]
2017 #[cfg(unix)]
2018 fn rewrite_css_urls_inplace_write_error_on_readonly_css() {
2019 use std::os::unix::fs::PermissionsExt;
2020 let dir = tempdir().unwrap();
2021 let site = dir.path();
2022 let css_path = site.join("style.css");
2023 fs::write(&css_path, "a{background:url(/img.png);}").unwrap();
2024 let mut manifest = HashMap::new();
2025 let _ = manifest.insert(
2026 "img.png".to_string(),
2027 AssetInfo {
2028 fingerprinted: "img.deadbeef.png".to_string(),
2029 sri: "sha384-x".to_string(),
2030 },
2031 );
2032 fs::set_permissions(&css_path, fs::Permissions::from_mode(0o444))
2033 .unwrap();
2034
2035 let res = rewrite_css_urls_inplace(&css_path, site, &manifest);
2036
2037 let _ =
2038 fs::set_permissions(&css_path, fs::Permissions::from_mode(0o644));
2039 if let Err(e) = res {
2040 assert!(!format!("{e}").is_empty());
2041 }
2042 }
2043
2044 #[test]
2045 fn fingerprint_assets_propagates_missing_file_error() {
2046 let dir = tempdir().unwrap();
2047 let missing = vec![dir.path().join("nope.css")];
2048 let res =
2049 fingerprint_assets(&missing, dir.path(), SriAlgorithm::default());
2050 assert!(res.is_err());
2051 }
2052}
2053
2054#[cfg(all(test, feature = "test-fault-injection"))]
2065mod fault_tests {
2066 use super::*;
2067 use tempfile::tempdir;
2068
2069 struct FailGuard(&'static str);
2071
2072 impl Drop for FailGuard {
2073 fn drop(&mut self) {
2074 let _ = fail::cfg(self.0, "off");
2075 }
2076 }
2077
2078 #[test]
2079 #[serial_test::serial(assets_failpoint)]
2080 fn remove_original_failpoint_propagates() {
2081 let _guard = FailGuard("assets::remove-original");
2082 fail::cfg("assets::remove-original", "return")
2083 .expect("activate failpoint");
2084
2085 let dir = tempdir().unwrap();
2086 let css_path = dir.path().join("style.css");
2087 fs::write(&css_path, "body { color: red; }").unwrap();
2088
2089 let err =
2090 fingerprint_file(&css_path, dir.path(), SriAlgorithm::default())
2091 .expect_err("injected removal failure must propagate");
2092 assert!(
2093 format!("{err:?}").contains("injected: assets::remove-original")
2094 );
2095 assert!(css_path.exists());
2099 }
2100}