use crate::plugin::{Plugin, PluginContext};
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Copy)]
pub struct FingerprintPlugin;
impl Plugin for FingerprintPlugin {
fn name(&self) -> &'static str {
"fingerprint"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
if !ctx.site_dir.exists() {
return Ok(());
}
let all_assets = collect_assets(&ctx.site_dir)?;
if all_assets.is_empty() {
return Ok(());
}
let (css_files, non_css): (Vec<_>, Vec<_>) = all_assets
.into_iter()
.partition(|p| p.extension().is_some_and(|e| e == "css"));
let mut manifest = fingerprint_assets(&non_css, &ctx.site_dir)?;
for css_path in &css_files {
rewrite_css_urls_inplace(css_path, &ctx.site_dir, &manifest)?;
}
let css_manifest = fingerprint_assets(&css_files, &ctx.site_dir)?;
manifest.extend(css_manifest);
rewrite_html_references(&ctx.site_dir, &manifest)?;
log::info!(
"[fingerprint] Processed {} asset(s) across {} CSS + {} other",
manifest.len(),
css_files.len(),
manifest.len() - css_files.len()
);
Ok(())
}
}
fn fingerprint_assets(
assets: &[PathBuf],
site_dir: &Path,
) -> Result<HashMap<String, AssetInfo>> {
let mut manifest = HashMap::new();
for asset_path in assets {
let info = fingerprint_file(asset_path, site_dir)?;
let _ = manifest.insert(info.0, info.1);
}
Ok(manifest)
}
fn fingerprint_file(
asset_path: &Path,
site_dir: &Path,
) -> Result<(String, AssetInfo)> {
let content = fs::read(asset_path)?;
let hash = sha256_hex(&content);
let short_hash = &hash[..8];
let stem = asset_path.file_stem().unwrap_or_default().to_string_lossy();
let ext = asset_path.extension().unwrap_or_default().to_string_lossy();
let new_name = format!("{stem}.{short_hash}.{ext}");
let new_path = asset_path.with_file_name(&new_name);
let sri = format!("sha256-{}", sri_base64(&content));
fs::rename(asset_path, &new_path).with_context(|| {
format!("Failed to rename {}", asset_path.display())
})?;
let rel_old = asset_path
.strip_prefix(site_dir)
.unwrap_or(asset_path)
.to_string_lossy()
.replace('\\', "/");
let rel_new = new_path
.strip_prefix(site_dir)
.unwrap_or(&new_path)
.to_string_lossy()
.replace('\\', "/");
Ok((
rel_old,
AssetInfo {
fingerprinted: rel_new,
sri,
},
))
}
fn rewrite_html_references(
site_dir: &Path,
manifest: &HashMap<String, AssetInfo>,
) -> Result<()> {
let html_files = collect_html_files(site_dir)?;
for html_path in &html_files {
let html = fs::read_to_string(html_path)?;
let rewritten = rewrite_asset_refs(&html, manifest);
if rewritten != html {
fs::write(html_path, rewritten)?;
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct AssetInfo {
fingerprinted: String,
sri: String,
}
fn rewrite_css_urls(
css: &str,
css_path: &Path,
site_dir: &Path,
manifest: &HashMap<String, AssetInfo>,
) -> String {
let css_dir = css_path.parent().unwrap_or(css_path);
let mut out = String::with_capacity(css.len());
let mut remaining = css;
while let Some(idx) = remaining.find("url(") {
out.push_str(&remaining[..idx]);
let after_open = &remaining[idx + 4..]; let Some(close_idx) = after_open.find(')') else {
out.push_str("url(");
out.push_str(after_open);
return out;
};
let raw = &after_open[..close_idx];
let rest = &after_open[close_idx + 1..];
let trimmed = raw.trim();
let (quote, inner) = if let Some(s) = trimmed.strip_prefix('"') {
('"', s.strip_suffix('"').unwrap_or(s))
} else if let Some(s) = trimmed.strip_prefix('\'') {
('\'', s.strip_suffix('\'').unwrap_or(s))
} else {
('\0', trimmed)
};
let (url, suffix) = if let Some(i) = inner.find(['?', '#']) {
(&inner[..i], &inner[i..])
} else {
(inner, "")
};
let resolved = resolve_css_url(url, css_dir, site_dir);
let hit = resolved.and_then(|key| manifest.get(&key).map(|i| (key, i)));
out.push_str("url(");
if let Some((_, info)) = hit {
let new_url = format!("/{}{}", info.fingerprinted, suffix);
if quote != '\0' {
out.push(quote);
}
out.push_str(&new_url);
if quote != '\0' {
out.push(quote);
}
} else {
out.push_str(raw);
}
out.push(')');
remaining = rest;
}
out.push_str(remaining);
out
}
fn resolve_css_url(
url: &str,
css_dir: &Path,
site_dir: &Path,
) -> Option<String> {
let trimmed = url.trim();
if trimmed.is_empty()
|| trimmed.starts_with("data:")
|| trimmed.starts_with("http://")
|| trimmed.starts_with("https://")
|| trimmed.starts_with("//")
{
return None;
}
let candidate = if let Some(stripped) = trimmed.strip_prefix('/') {
site_dir.join(stripped)
} else {
css_dir.join(trimmed)
};
let mut components: Vec<&std::ffi::OsStr> = Vec::new();
for c in candidate.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
let _ = components.pop();
}
std::path::Component::Normal(s) => components.push(s),
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
components.clear();
}
}
}
let mut resolved = PathBuf::new();
for c in components {
resolved.push(c);
}
let site_components: Vec<&std::ffi::OsStr> = site_dir
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s),
_ => None,
})
.collect();
let resolved_components: Vec<&std::ffi::OsStr> = resolved
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s),
_ => None,
})
.collect();
if resolved_components.len() < site_components.len()
|| resolved_components[..site_components.len()] != site_components[..]
{
return None;
}
let rel: PathBuf = resolved_components[site_components.len()..]
.iter()
.collect();
Some(rel.to_string_lossy().replace('\\', "/"))
}
fn rewrite_css_urls_inplace(
css_path: &Path,
site_dir: &Path,
manifest: &HashMap<String, AssetInfo>,
) -> Result<()> {
let css = fs::read_to_string(css_path).with_context(|| {
format!("Failed to read CSS {}", css_path.display())
})?;
let rewritten = rewrite_css_urls(&css, css_path, site_dir, manifest);
if rewritten != css {
fs::write(css_path, rewritten).with_context(|| {
format!("Failed to write rewritten CSS {}", css_path.display())
})?;
}
Ok(())
}
fn rewrite_asset_refs(
html: &str,
manifest: &HashMap<String, AssetInfo>,
) -> String {
let mut result = html.to_string();
for (old_path, info) in manifest {
let old_ref = format!("\"{old_path}\"");
let old_ref_slash = format!("\"/{old_path}\"");
let new_ref = format!(
"\"{}\" integrity=\"{}\" crossorigin=\"anonymous\"",
info.fingerprinted, info.sri
);
let new_ref_slash = format!(
"\"/{}\" integrity=\"{}\" crossorigin=\"anonymous\"",
info.fingerprinted, info.sri
);
result = result.replace(&old_ref, &new_ref);
result = result.replace(&old_ref_slash, &new_ref_slash);
}
result
}
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let bytes = hasher.finalize();
let mut s = String::with_capacity(64);
for b in bytes {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
fn sri_base64(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let bytes = hasher.finalize();
BASE64.encode(bytes)
}
const FINGERPRINTED_EXTENSIONS: &[&str] = &[
"css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
"woff", "woff2", "ttf", "otf",
];
fn collect_assets(dir: &Path) -> Result<Vec<PathBuf>> {
crate::walk::walk_files_multi(dir, FINGERPRINTED_EXTENSIONS)
}
fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>> {
crate::walk::walk_files(dir, "html")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_sha256_hex_deterministic() {
let h1 = sha256_hex(b"hello");
let h2 = sha256_hex(b"hello");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
}
#[test]
fn test_sha256_hex_known_vectors() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn test_sri_base64_known_vector() {
assert_eq!(
sri_base64(b""),
"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
);
}
#[test]
fn test_sha256_hex_varies() {
let h1 = sha256_hex(b"hello");
let h2 = sha256_hex(b"world");
assert_ne!(h1, h2);
}
#[test]
fn test_fingerprint_plugin() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("style.css"), "body { color: red; }").unwrap();
let html = r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#;
fs::write(site.join("index.html"), html).unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
FingerprintPlugin.after_compile(&ctx).unwrap();
assert!(!site.join("style.css").exists());
let entries: Vec<_> = fs::read_dir(&site)
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| {
e.path()
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("style.")
&& e.path().extension().is_some_and(|e| e == "css")
})
.collect();
assert_eq!(entries.len(), 1);
let output = fs::read_to_string(site.join("index.html")).unwrap();
assert!(output.contains("integrity="));
assert!(output.contains("crossorigin=\"anonymous\""));
assert!(!output.contains("href=\"style.css\""));
}
#[test]
fn name_returns_static_fingerprint_identifier() {
assert_eq!(FingerprintPlugin.name(), "fingerprint");
}
#[test]
fn after_compile_missing_site_dir_returns_ok() {
let dir = tempdir().unwrap();
let missing = dir.path().join("missing");
let ctx =
PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
FingerprintPlugin.after_compile(&ctx).unwrap();
assert!(!missing.exists());
}
#[test]
fn after_compile_no_assets_short_circuits() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("index.html"), "<p></p>").unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
FingerprintPlugin.after_compile(&ctx).unwrap();
assert_eq!(
fs::read_to_string(site.join("index.html")).unwrap(),
"<p></p>"
);
}
#[test]
fn after_compile_fingerprint_absolute_path_href() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("app.js"), "console.log(1);").unwrap();
fs::write(
site.join("index.html"),
r#"<html><head><script src="/app.js"></script></head></html>"#,
)
.unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
FingerprintPlugin.after_compile(&ctx).unwrap();
let html = fs::read_to_string(site.join("index.html")).unwrap();
assert!(html.contains("integrity="));
}
#[test]
fn collect_assets_picks_up_fingerprintable_extensions() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a.css"), "").unwrap();
fs::write(dir.path().join("b.js"), "").unwrap();
fs::write(dir.path().join("c.html"), "").unwrap();
fs::write(dir.path().join("d.png"), "").unwrap();
fs::write(dir.path().join("e.woff2"), "").unwrap();
fs::write(dir.path().join("f.txt"), "").unwrap();
let files = collect_assets(dir.path()).unwrap();
assert_eq!(files.len(), 4);
}
#[test]
fn collect_assets_recurses_into_subdirectories() {
let dir = tempdir().unwrap();
let nested = dir.path().join("vendor");
fs::create_dir(&nested).unwrap();
fs::write(dir.path().join("top.css"), "").unwrap();
fs::write(nested.join("lib.js"), "").unwrap();
let files = collect_assets(dir.path()).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn collect_html_files_filters_non_html() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a.html"), "").unwrap();
fs::write(dir.path().join("b.css"), "").unwrap();
let files = collect_html_files(dir.path()).unwrap();
assert_eq!(files.len(), 1);
}
#[test]
fn sha256_hex_produces_64_hex_chars() {
assert_eq!(sha256_hex(b"abc").len(), 64);
assert_eq!(sha256_hex(b"").len(), 64);
}
#[test]
fn sri_base64_is_nonempty_for_input() {
assert!(!sri_base64(b"hello").is_empty());
}
#[test]
fn sri_base64_emits_44_char_payload() {
assert_eq!(sri_base64(b"hello").len(), 44);
assert_eq!(sri_base64(b"").len(), 44);
}
#[test]
fn test_rewrite_asset_refs() {
let mut manifest = HashMap::new();
let _ = manifest.insert(
"style.css".to_string(),
AssetInfo {
fingerprinted: "style.abc12345.css".to_string(),
sri: "sha256-xyz".to_string(),
},
);
let html = r#"<link rel="stylesheet" href="style.css">"#;
let result = rewrite_asset_refs(html, &manifest);
assert!(result.contains("style.abc12345.css"));
assert!(result.contains("integrity=\"sha256-xyz\""));
}
fn css_manifest() -> HashMap<String, AssetInfo> {
let mut m = HashMap::new();
let _ = m.insert(
"images/logo.png".to_string(),
AssetInfo {
fingerprinted: "images/logo.deadbeef.png".to_string(),
sri: String::new(),
},
);
let _ = m.insert(
"fonts/sans.woff2".to_string(),
AssetInfo {
fingerprinted: "fonts/sans.cafef00d.woff2".to_string(),
sri: String::new(),
},
);
m
}
#[test]
fn rewrite_css_urls_handles_absolute_path() {
let dir = tempdir().unwrap();
let css_path = dir.path().join("assets/style.css");
let css = "body { background: url(/images/logo.png); }";
let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
assert!(out.contains("url(/images/logo.deadbeef.png)"));
assert!(!out.contains("logo.png)"));
}
#[test]
fn rewrite_css_urls_handles_relative_path() {
let dir = tempdir().unwrap();
let css_path = dir.path().join("assets/style.css");
let css = "body { background: url(../images/logo.png); }";
let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
assert!(out.contains("url(/images/logo.deadbeef.png)"));
}
#[test]
fn rewrite_css_urls_handles_double_quotes() {
let dir = tempdir().unwrap();
let css_path = dir.path().join("style.css");
let css = r#"body { background: url("/images/logo.png"); }"#;
let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
assert!(out.contains(r#"url("/images/logo.deadbeef.png")"#));
}
#[test]
fn rewrite_css_urls_handles_single_quotes() {
let dir = tempdir().unwrap();
let css_path = dir.path().join("style.css");
let css = "body { background: url('/images/logo.png'); }";
let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
assert!(out.contains("url('/images/logo.deadbeef.png')"));
}
#[test]
fn rewrite_css_urls_preserves_query_and_fragment() {
let dir = tempdir().unwrap();
let css_path = dir.path().join("style.css");
let css = "@font-face { src: url(/fonts/sans.woff2?v=1#hint); }";
let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
assert!(out.contains("/fonts/sans.cafef00d.woff2?v=1#hint"));
}
#[test]
fn rewrite_css_urls_skips_external_and_data_urls() {
let dir = tempdir().unwrap();
let css_path = dir.path().join("style.css");
let css = r#"
a { background: url(https://cdn.example.com/x.png); }
b { background: url(//cdn.example.com/y.png); }
c { background: url(data:image/svg+xml,<svg/>); }
"#;
let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
assert!(out.contains("https://cdn.example.com/x.png"));
assert!(out.contains("//cdn.example.com/y.png"));
assert!(out.contains("data:image/svg+xml"));
}
#[test]
fn rewrite_css_urls_no_change_when_url_not_in_manifest() {
let dir = tempdir().unwrap();
let css_path = dir.path().join("style.css");
let css = "body { background: url(/images/missing.png); }";
let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
assert_eq!(out, css);
}
#[test]
fn rewrite_css_urls_unterminated_url_does_not_panic() {
let dir = tempdir().unwrap();
let css_path = dir.path().join("style.css");
let css = "body { background: url(/images/logo.png";
let out = rewrite_css_urls(css, &css_path, dir.path(), &css_manifest());
assert!(!out.is_empty());
}
#[test]
fn after_compile_rewrites_css_url_to_fingerprinted_image() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(site.join("images")).unwrap();
let png_bytes: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00,
0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63,
0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4,
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60,
0x82,
];
fs::write(site.join("images/logo.png"), png_bytes).unwrap();
fs::write(
site.join("style.css"),
"body { background: url(/images/logo.png); }",
)
.unwrap();
fs::write(
site.join("index.html"),
r#"<html><head><link rel="stylesheet" href="style.css"></head><body></body></html>"#,
)
.unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
FingerprintPlugin.after_compile(&ctx).unwrap();
let mut css_text = None;
for entry in fs::read_dir(&site).unwrap().flatten() {
let p = entry.path();
if p.extension().is_some_and(|e| e == "css") {
css_text = Some(fs::read_to_string(&p).unwrap());
}
}
let css_text = css_text.expect("renamed CSS file present");
assert!(
css_text.contains("/images/logo."),
"rewritten CSS should reference renamed PNG: {css_text}"
);
assert!(css_text.contains(".png"), "still ends in .png: {css_text}");
assert!(
!css_text.contains("/images/logo.png)"),
"must no longer point at the un-fingerprinted PNG: {css_text}"
);
}
}