1use crate::plugin::{Plugin, PluginContext};
10use anyhow::{Context, Result};
11use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
12use sha2::{Digest, Sha256};
13use std::{
14 collections::HashMap,
15 fs,
16 path::{Path, PathBuf},
17};
18
19#[derive(Debug, Clone, Copy)]
27pub struct FingerprintPlugin;
28
29impl Plugin for FingerprintPlugin {
30 fn name(&self) -> &'static str {
31 "fingerprint"
32 }
33
34 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
35 if !ctx.site_dir.exists() {
36 return Ok(());
37 }
38
39 let all_assets = collect_assets(&ctx.site_dir)?;
40 if all_assets.is_empty() {
41 return Ok(());
42 }
43
44 let (css_files, non_css): (Vec<_>, Vec<_>) = all_assets
63 .into_iter()
64 .partition(|p| p.extension().is_some_and(|e| e == "css"));
65
66 let mut manifest = fingerprint_assets(&non_css, &ctx.site_dir)?;
67
68 for css_path in &css_files {
69 rewrite_css_urls_inplace(css_path, &ctx.site_dir, &manifest)?;
70 }
71
72 let css_manifest = fingerprint_assets(&css_files, &ctx.site_dir)?;
73 manifest.extend(css_manifest);
74
75 rewrite_html_references(&ctx.site_dir, &manifest)?;
76
77 log::info!(
78 "[fingerprint] Processed {} asset(s) across {} CSS + {} other",
79 manifest.len(),
80 css_files.len(),
81 manifest.len() - css_files.len()
82 );
83 Ok(())
84 }
85}
86
87fn fingerprint_assets(
89 assets: &[PathBuf],
90 site_dir: &Path,
91) -> Result<HashMap<String, AssetInfo>> {
92 let mut manifest = HashMap::new();
93
94 for asset_path in assets {
95 let info = fingerprint_file(asset_path, site_dir)?;
96 let _ = manifest.insert(info.0, info.1);
97 }
98
99 Ok(manifest)
100}
101
102fn fingerprint_file(
104 asset_path: &Path,
105 site_dir: &Path,
106) -> Result<(String, AssetInfo)> {
107 let content = fs::read(asset_path)?;
108 let hash = sha256_hex(&content);
109 let short_hash = &hash[..8];
110
111 let stem = asset_path.file_stem().unwrap_or_default().to_string_lossy();
112 let ext = asset_path.extension().unwrap_or_default().to_string_lossy();
113 let new_name = format!("{stem}.{short_hash}.{ext}");
114 let new_path = asset_path.with_file_name(&new_name);
115
116 let sri = format!("sha256-{}", sri_base64(&content));
117
118 fs::rename(asset_path, &new_path).with_context(|| {
119 format!("Failed to rename {}", asset_path.display())
120 })?;
121
122 let rel_old = asset_path
123 .strip_prefix(site_dir)
124 .unwrap_or(asset_path)
125 .to_string_lossy()
126 .replace('\\', "/");
127 let rel_new = new_path
128 .strip_prefix(site_dir)
129 .unwrap_or(&new_path)
130 .to_string_lossy()
131 .replace('\\', "/");
132
133 Ok((
134 rel_old,
135 AssetInfo {
136 fingerprinted: rel_new,
137 sri,
138 },
139 ))
140}
141
142fn rewrite_html_references(
144 site_dir: &Path,
145 manifest: &HashMap<String, AssetInfo>,
146) -> Result<()> {
147 let html_files = collect_html_files(site_dir)?;
148 for html_path in &html_files {
149 let html = fs::read_to_string(html_path)?;
150 let rewritten = rewrite_asset_refs(&html, manifest);
151 if rewritten != html {
152 fs::write(html_path, rewritten)?;
153 }
154 }
155 Ok(())
156}
157
158#[derive(Debug, Clone)]
159struct AssetInfo {
160 fingerprinted: String,
161 sri: String,
162}
163
164fn rewrite_css_urls(
188 css: &str,
189 css_path: &Path,
190 site_dir: &Path,
191 manifest: &HashMap<String, AssetInfo>,
192) -> String {
193 let css_dir = css_path.parent().unwrap_or(css_path);
194 let mut out = String::with_capacity(css.len());
195 let mut remaining = css;
196
197 while let Some(idx) = remaining.find("url(") {
198 out.push_str(&remaining[..idx]);
199 let after_open = &remaining[idx + 4..]; let Some(close_idx) = after_open.find(')') else {
201 out.push_str("url(");
203 out.push_str(after_open);
204 return out;
205 };
206 let raw = &after_open[..close_idx];
207 let rest = &after_open[close_idx + 1..];
208
209 let trimmed = raw.trim();
211 let (quote, inner) = if let Some(s) = trimmed.strip_prefix('"') {
212 ('"', s.strip_suffix('"').unwrap_or(s))
213 } else if let Some(s) = trimmed.strip_prefix('\'') {
214 ('\'', s.strip_suffix('\'').unwrap_or(s))
215 } else {
216 ('\0', trimmed)
217 };
218
219 let (url, suffix) = if let Some(i) = inner.find(['?', '#']) {
221 (&inner[..i], &inner[i..])
222 } else {
223 (inner, "")
224 };
225
226 let resolved = resolve_css_url(url, css_dir, site_dir);
227 let hit = resolved.and_then(|key| manifest.get(&key).map(|i| (key, i)));
228
229 out.push_str("url(");
230 if let Some((_, info)) = hit {
231 let new_url = format!("/{}{}", info.fingerprinted, suffix);
233 if quote != '\0' {
234 out.push(quote);
235 }
236 out.push_str(&new_url);
237 if quote != '\0' {
238 out.push(quote);
239 }
240 } else {
241 out.push_str(raw);
243 }
244 out.push(')');
245
246 remaining = rest;
247 }
248
249 out.push_str(remaining);
250 out
251}
252
253fn resolve_css_url(
258 url: &str,
259 css_dir: &Path,
260 site_dir: &Path,
261) -> Option<String> {
262 let trimmed = url.trim();
263 if trimmed.is_empty()
264 || trimmed.starts_with("data:")
265 || trimmed.starts_with("http://")
266 || trimmed.starts_with("https://")
267 || trimmed.starts_with("//")
268 {
269 return None;
270 }
271
272 let candidate = if let Some(stripped) = trimmed.strip_prefix('/') {
274 site_dir.join(stripped)
275 } else {
276 css_dir.join(trimmed)
277 };
278
279 let mut components: Vec<&std::ffi::OsStr> = Vec::new();
283 for c in candidate.components() {
284 match c {
285 std::path::Component::CurDir => {}
286 std::path::Component::ParentDir => {
287 let _ = components.pop();
288 }
289 std::path::Component::Normal(s) => components.push(s),
290 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
291 components.clear();
292 }
293 }
294 }
295 let mut resolved = PathBuf::new();
296 for c in components {
297 resolved.push(c);
298 }
299
300 let site_components: Vec<&std::ffi::OsStr> = site_dir
302 .components()
303 .filter_map(|c| match c {
304 std::path::Component::Normal(s) => Some(s),
305 _ => None,
306 })
307 .collect();
308 let resolved_components: Vec<&std::ffi::OsStr> = resolved
309 .components()
310 .filter_map(|c| match c {
311 std::path::Component::Normal(s) => Some(s),
312 _ => None,
313 })
314 .collect();
315
316 if resolved_components.len() < site_components.len()
317 || resolved_components[..site_components.len()] != site_components[..]
318 {
319 return None;
320 }
321
322 let rel: PathBuf = resolved_components[site_components.len()..]
323 .iter()
324 .collect();
325 Some(rel.to_string_lossy().replace('\\', "/"))
326}
327
328fn rewrite_css_urls_inplace(
331 css_path: &Path,
332 site_dir: &Path,
333 manifest: &HashMap<String, AssetInfo>,
334) -> Result<()> {
335 let css = fs::read_to_string(css_path).with_context(|| {
336 format!("Failed to read CSS {}", css_path.display())
337 })?;
338 let rewritten = rewrite_css_urls(&css, css_path, site_dir, manifest);
339 if rewritten != css {
340 fs::write(css_path, rewritten).with_context(|| {
341 format!("Failed to write rewritten CSS {}", css_path.display())
342 })?;
343 }
344 Ok(())
345}
346
347fn rewrite_asset_refs(
349 html: &str,
350 manifest: &HashMap<String, AssetInfo>,
351) -> String {
352 let mut result = html.to_string();
353 for (old_path, info) in manifest {
354 let old_ref = format!("\"{old_path}\"");
356 let old_ref_slash = format!("\"/{old_path}\"");
357 let new_ref = format!(
358 "\"{}\" integrity=\"{}\" crossorigin=\"anonymous\"",
359 info.fingerprinted, info.sri
360 );
361 let new_ref_slash = format!(
362 "\"/{}\" integrity=\"{}\" crossorigin=\"anonymous\"",
363 info.fingerprinted, info.sri
364 );
365
366 result = result.replace(&old_ref, &new_ref);
367 result = result.replace(&old_ref_slash, &new_ref_slash);
368 }
369 result
370}
371
372fn sha256_hex(data: &[u8]) -> String {
380 let mut hasher = Sha256::new();
381 hasher.update(data);
382 let bytes = hasher.finalize();
383 let mut s = String::with_capacity(64);
384 for b in bytes {
385 use std::fmt::Write as _;
386 let _ = write!(s, "{b:02x}");
387 }
388 s
389}
390
391fn sri_base64(data: &[u8]) -> String {
399 let mut hasher = Sha256::new();
400 hasher.update(data);
401 let bytes = hasher.finalize();
402 BASE64.encode(bytes)
403}
404
405const FINGERPRINTED_EXTENSIONS: &[&str] = &[
412 "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
413 "woff", "woff2", "ttf", "otf",
414];
415
416fn collect_assets(dir: &Path) -> Result<Vec<PathBuf>> {
418 crate::walk::walk_files_multi(dir, FINGERPRINTED_EXTENSIONS)
419}
420
421fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>> {
422 crate::walk::walk_files(dir, "html")
423}
424
425#[cfg(test)]
426#[allow(clippy::unwrap_used, clippy::expect_used)]
427mod tests {
428 use super::*;
429 use tempfile::tempdir;
430
431 #[test]
432 fn test_sha256_hex_deterministic() {
433 let h1 = sha256_hex(b"hello");
434 let h2 = sha256_hex(b"hello");
435 assert_eq!(h1, h2);
436 assert_eq!(h1.len(), 64);
438 }
439
440 #[test]
441 fn test_sha256_hex_known_vectors() {
442 assert_eq!(
445 sha256_hex(b""),
446 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
447 );
448 assert_eq!(
450 sha256_hex(b"abc"),
451 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
452 );
453 }
454
455 #[test]
456 fn test_sri_base64_known_vector() {
457 assert_eq!(
459 sri_base64(b""),
460 "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
461 );
462 }
463
464 #[test]
465 fn test_sha256_hex_varies() {
466 let h1 = sha256_hex(b"hello");
467 let h2 = sha256_hex(b"world");
468 assert_ne!(h1, h2);
469 }
470
471 #[test]
472 fn test_fingerprint_plugin() {
473 let dir = tempdir().unwrap();
474 let site = dir.path().join("site");
475 fs::create_dir_all(&site).unwrap();
476
477 fs::write(site.join("style.css"), "body { color: red; }").unwrap();
479
480 let html = r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#;
482 fs::write(site.join("index.html"), html).unwrap();
483
484 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
485 FingerprintPlugin.after_compile(&ctx).unwrap();
486
487 assert!(!site.join("style.css").exists());
489
490 let entries: Vec<_> = fs::read_dir(&site)
492 .unwrap()
493 .filter_map(std::result::Result::ok)
494 .filter(|e| {
495 e.path()
496 .file_name()
497 .unwrap()
498 .to_string_lossy()
499 .starts_with("style.")
500 && e.path().extension().is_some_and(|e| e == "css")
501 })
502 .collect();
503 assert_eq!(entries.len(), 1);
504
505 let output = fs::read_to_string(site.join("index.html")).unwrap();
507 assert!(output.contains("integrity="));
508 assert!(output.contains("crossorigin=\"anonymous\""));
509 assert!(!output.contains("href=\"style.css\""));
510 }
511
512 #[test]
513 fn name_returns_static_fingerprint_identifier() {
514 assert_eq!(FingerprintPlugin.name(), "fingerprint");
515 }
516
517 #[test]
518 fn after_compile_missing_site_dir_returns_ok() {
519 let dir = tempdir().unwrap();
521 let missing = dir.path().join("missing");
522 let ctx =
523 PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
524 FingerprintPlugin.after_compile(&ctx).unwrap();
525 assert!(!missing.exists());
526 }
527
528 #[test]
529 fn after_compile_no_assets_short_circuits() {
530 let dir = tempdir().unwrap();
533 let site = dir.path().join("site");
534 fs::create_dir_all(&site).unwrap();
535 fs::write(site.join("index.html"), "<p></p>").unwrap();
536
537 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
538 FingerprintPlugin.after_compile(&ctx).unwrap();
539 assert_eq!(
541 fs::read_to_string(site.join("index.html")).unwrap(),
542 "<p></p>"
543 );
544 }
545
546 #[test]
547 fn after_compile_fingerprint_absolute_path_href() {
548 let dir = tempdir().unwrap();
551 let site = dir.path().join("site");
552 fs::create_dir_all(&site).unwrap();
553 fs::write(site.join("app.js"), "console.log(1);").unwrap();
554 fs::write(
555 site.join("index.html"),
556 r#"<html><head><script src="/app.js"></script></head></html>"#,
557 )
558 .unwrap();
559
560 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
561 FingerprintPlugin.after_compile(&ctx).unwrap();
562 let html = fs::read_to_string(site.join("index.html")).unwrap();
563 assert!(html.contains("integrity="));
564 }
565
566 #[test]
567 fn collect_assets_picks_up_fingerprintable_extensions() {
568 let dir = tempdir().unwrap();
572 fs::write(dir.path().join("a.css"), "").unwrap();
573 fs::write(dir.path().join("b.js"), "").unwrap();
574 fs::write(dir.path().join("c.html"), "").unwrap();
575 fs::write(dir.path().join("d.png"), "").unwrap();
576 fs::write(dir.path().join("e.woff2"), "").unwrap();
577 fs::write(dir.path().join("f.txt"), "").unwrap();
578 let files = collect_assets(dir.path()).unwrap();
579 assert_eq!(files.len(), 4);
581 }
582
583 #[test]
584 fn collect_assets_recurses_into_subdirectories() {
585 let dir = tempdir().unwrap();
586 let nested = dir.path().join("vendor");
587 fs::create_dir(&nested).unwrap();
588 fs::write(dir.path().join("top.css"), "").unwrap();
589 fs::write(nested.join("lib.js"), "").unwrap();
590 let files = collect_assets(dir.path()).unwrap();
591 assert_eq!(files.len(), 2);
592 }
593
594 #[test]
595 fn collect_html_files_filters_non_html() {
596 let dir = tempdir().unwrap();
597 fs::write(dir.path().join("a.html"), "").unwrap();
598 fs::write(dir.path().join("b.css"), "").unwrap();
599 let files = collect_html_files(dir.path()).unwrap();
600 assert_eq!(files.len(), 1);
601 }
602
603 #[test]
604 fn sha256_hex_produces_64_hex_chars() {
605 assert_eq!(sha256_hex(b"abc").len(), 64);
606 assert_eq!(sha256_hex(b"").len(), 64);
607 }
608
609 #[test]
610 fn sri_base64_is_nonempty_for_input() {
611 assert!(!sri_base64(b"hello").is_empty());
612 }
613
614 #[test]
615 fn sri_base64_emits_44_char_payload() {
616 assert_eq!(sri_base64(b"hello").len(), 44);
618 assert_eq!(sri_base64(b"").len(), 44);
619 }
620
621 #[test]
622 fn test_rewrite_asset_refs() {
623 let mut manifest = HashMap::new();
624 let _ = manifest.insert(
625 "style.css".to_string(),
626 AssetInfo {
627 fingerprinted: "style.abc12345.css".to_string(),
628 sri: "sha256-xyz".to_string(),
629 },
630 );
631
632 let html = r#"<link rel="stylesheet" href="style.css">"#;
633 let result = rewrite_asset_refs(html, &manifest);
634 assert!(result.contains("style.abc12345.css"));
635 assert!(result.contains("integrity=\"sha256-xyz\""));
636 }
637
638 fn css_manifest() -> HashMap<String, AssetInfo> {
641 let mut m = HashMap::new();
642 let _ = m.insert(
643 "images/logo.png".to_string(),
644 AssetInfo {
645 fingerprinted: "images/logo.deadbeef.png".to_string(),
646 sri: String::new(),
647 },
648 );
649 let _ = m.insert(
650 "fonts/sans.woff2".to_string(),
651 AssetInfo {
652 fingerprinted: "fonts/sans.cafef00d.woff2".to_string(),
653 sri: String::new(),
654 },
655 );
656 m
657 }
658
659 #[test]
660 fn rewrite_css_urls_handles_absolute_path() {
661 let dir = tempdir().unwrap();
662 let css_path = dir.path().join("assets/style.css");
663 let css = "body { background: url(/images/logo.png); }";
664 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
665 assert!(out.contains("url(/images/logo.deadbeef.png)"));
666 assert!(!out.contains("logo.png)"));
667 }
668
669 #[test]
670 fn rewrite_css_urls_handles_relative_path() {
671 let dir = tempdir().unwrap();
672 let css_path = dir.path().join("assets/style.css");
673 let css = "body { background: url(../images/logo.png); }";
674 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
675 assert!(out.contains("url(/images/logo.deadbeef.png)"));
676 }
677
678 #[test]
679 fn rewrite_css_urls_handles_double_quotes() {
680 let dir = tempdir().unwrap();
681 let css_path = dir.path().join("style.css");
682 let css = r#"body { background: url("/images/logo.png"); }"#;
683 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
684 assert!(out.contains(r#"url("/images/logo.deadbeef.png")"#));
685 }
686
687 #[test]
688 fn rewrite_css_urls_handles_single_quotes() {
689 let dir = tempdir().unwrap();
690 let css_path = dir.path().join("style.css");
691 let css = "body { background: url('/images/logo.png'); }";
692 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
693 assert!(out.contains("url('/images/logo.deadbeef.png')"));
694 }
695
696 #[test]
697 fn rewrite_css_urls_preserves_query_and_fragment() {
698 let dir = tempdir().unwrap();
699 let css_path = dir.path().join("style.css");
700 let css = "@font-face { src: url(/fonts/sans.woff2?v=1#hint); }";
701 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
702 assert!(out.contains("/fonts/sans.cafef00d.woff2?v=1#hint"));
703 }
704
705 #[test]
706 fn rewrite_css_urls_skips_external_and_data_urls() {
707 let dir = tempdir().unwrap();
708 let css_path = dir.path().join("style.css");
709 let css = r#"
710 a { background: url(https://cdn.example.com/x.png); }
711 b { background: url(//cdn.example.com/y.png); }
712 c { background: url(data:image/svg+xml,<svg/>); }
713 "#;
714 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
715 assert!(out.contains("https://cdn.example.com/x.png"));
717 assert!(out.contains("//cdn.example.com/y.png"));
718 assert!(out.contains("data:image/svg+xml"));
719 }
720
721 #[test]
722 fn rewrite_css_urls_no_change_when_url_not_in_manifest() {
723 let dir = tempdir().unwrap();
724 let css_path = dir.path().join("style.css");
725 let css = "body { background: url(/images/missing.png); }";
726 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
727 assert_eq!(out, css);
728 }
729
730 #[test]
731 fn rewrite_css_urls_unterminated_url_does_not_panic() {
732 let dir = tempdir().unwrap();
733 let css_path = dir.path().join("style.css");
734 let css = "body { background: url(/images/logo.png";
735 let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
736 assert!(!out.is_empty());
737 }
738
739 #[test]
740 fn after_compile_rewrites_css_url_to_fingerprinted_image() {
741 let dir = tempdir().unwrap();
745 let site = dir.path().join("site");
746 fs::create_dir_all(site.join("images")).unwrap();
747 let png_bytes: &[u8] = &[
749 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00,
750 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
751 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89,
752 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63,
753 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4,
754 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60,
755 0x82,
756 ];
757 fs::write(site.join("images/logo.png"), png_bytes).unwrap();
758 fs::write(
759 site.join("style.css"),
760 "body { background: url(/images/logo.png); }",
761 )
762 .unwrap();
763 fs::write(
764 site.join("index.html"),
765 r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#,
766 )
767 .unwrap();
768
769 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
770 FingerprintPlugin.after_compile(&ctx).unwrap();
771
772 let mut css_text = None;
775 for entry in fs::read_dir(&site).unwrap().flatten() {
776 let p = entry.path();
777 if p.extension().is_some_and(|e| e == "css") {
778 css_text = Some(fs::read_to_string(&p).unwrap());
779 }
780 }
781 let css_text = css_text.expect("renamed CSS file present");
782 assert!(
783 css_text.contains("/images/logo."),
784 "rewritten CSS should reference renamed PNG: {css_text}"
785 );
786 assert!(css_text.contains(".png"), "still ends in .png: {css_text}");
787 assert!(
790 !css_text.contains("/images/logo.png)"),
791 "must no longer point at the un-fingerprinted PNG: {css_text}"
792 );
793 }
794}