use crate::plugin::{Plugin, PluginContext};
use anyhow::{Context, Result};
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 assets = collect_assets(&ctx.site_dir)?;
if assets.is_empty() {
return Ok(());
}
let manifest = fingerprint_assets(&assets, &ctx.site_dir)?;
rewrite_html_references(&ctx.site_dir, &manifest)?;
log::info!("[fingerprint] Processed {} asset(s)", manifest.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-{}", base64_encode(&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_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 h: u64 = 0xcbf2_9ce4_8422_2325; for &byte in data {
h ^= u64::from(byte);
h = h.wrapping_mul(0x0000_0100_0000_01b3); }
let h2 = h.wrapping_add(data.len() as u64);
format!("{h:016x}{h2:016x}")
}
fn base64_encode(data: &[u8]) -> String {
sha256_hex(data)
}
fn collect_assets(dir: &Path) -> Result<Vec<PathBuf>> {
crate::walk::walk_files_multi(dir, &["css", "js"])
}
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(), 32); }
#[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_filters_non_css_js_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();
let files = collect_assets(dir.path()).unwrap();
assert_eq!(files.len(), 2);
}
#[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_32_hex_chars() {
assert_eq!(sha256_hex(b"abc").len(), 32);
assert_eq!(sha256_hex(b"").len(), 32);
}
#[test]
fn base64_encode_is_nonempty_for_input() {
assert!(!base64_encode(b"hello").is_empty());
}
#[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\""));
}
}