Menu
Public repository / master
AkurAI-Framework / crates/assets/src/fingerprint.rs
Latest change 9df51040bf1687e4dc8a91557f02df52e8d5cf3a - feat(assets): content-hash fingerprinting + conservative minifiers by Ólafur Búi Ólafsson
//! Content-hash fingerprinting for cache-busting static assets.
//!
//! The hash is a 64-bit [FNV-1a](https://en.wikipedia.org/wiki/Fowler–Noll–Vo_hash_function)
//! digest rendered as zero-padded lowercase hex. FNV-1a is not cryptographic —
//! it does not need to be. The only requirement is that the same bytes always
//! map to the same short string and that a single changed byte reliably changes
//! the digest, which is exactly what cache-busting needs.
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
/// Content hash of `bytes` as a stable, lowercase, 16-char hex string.
///
/// Deterministic: identical input always yields the same digest, and flipping a
/// single byte changes it. Suitable as the cache-busting token in a filename.
///
/// ```
/// use akurai_assets::fingerprint;
/// assert_eq!(fingerprint(b"hello"), fingerprint(b"hello"));
/// assert_ne!(fingerprint(b"hello"), fingerprint(b"hellp"));
/// assert_eq!(fingerprint(b"hello").len(), 16);
/// ```
pub fn fingerprint(bytes: &[u8]) -> String {
let mut hash = FNV_OFFSET_BASIS;
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("{hash:016x}")
}
/// Fold the content hash of `bytes` into `name`, before the file extension.
///
/// `app.css` ⇒ `app.<hash>.css`. The hash is inserted before the **last** `.`
/// so multi-dot names keep their full stem (`vendor.min.js` ⇒
/// `vendor.min.<hash>.js`). A name with no extension just gets the hash
/// appended (`LICENSE` ⇒ `LICENSE.<hash>`). A leading-dot dotfile with no other
/// `.` (`.htaccess`) is treated as extensionless and gets the hash appended.
///
/// ```
/// use akurai_assets::hashed_name;
/// let n = hashed_name("app.css", b"x");
/// assert!(n.starts_with("app.") && n.ends_with(".css"));
/// assert_eq!(hashed_name("LICENSE", b"x").matches('.').count(), 1);
/// ```
pub fn hashed_name(name: &str, bytes: &[u8]) -> String {
let hash = fingerprint(bytes);
// Find the extension dot: the last '.' that is not the very first char
// (so leading-dot dotfiles like ".htaccess" are extensionless).
match name.rfind('.').filter(|&i| i > 0) {
Some(dot) => {
let (stem, ext) = name.split_at(dot); // ext includes the leading '.'
format!("{stem}.{hash}{ext}")
}
None => format!("{name}.{hash}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fingerprint_is_stable() {
assert_eq!(
fingerprint(b"the same bytes"),
fingerprint(b"the same bytes")
);
}
#[test]
fn fingerprint_is_sensitive() {
// One byte different → different digest.
assert_ne!(fingerprint(b"abcdef"), fingerprint(b"abcdeg"));
// Empty vs non-empty.
assert_ne!(fingerprint(b""), fingerprint(b"a"));
}
#[test]
fn fingerprint_is_lowercase_hex_16() {
let h = fingerprint(b"some asset contents");
assert_eq!(h.len(), 16);
assert!(h
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
}
#[test]
fn hashed_name_inserts_before_extension() {
let h = fingerprint(b"body{}");
assert_eq!(hashed_name("app.css", b"body{}"), format!("app.{h}.css"));
}
#[test]
fn hashed_name_uses_last_dot() {
let h = fingerprint(b"x");
assert_eq!(
hashed_name("vendor.min.js", b"x"),
format!("vendor.min.{h}.js")
);
}
#[test]
fn hashed_name_without_extension_appends() {
let h = fingerprint(b"x");
assert_eq!(hashed_name("LICENSE", b"x"), format!("LICENSE.{h}"));
}
#[test]
fn hashed_name_dotfile_is_extensionless() {
let h = fingerprint(b"x");
assert_eq!(hashed_name(".htaccess", b"x"), format!(".htaccess.{h}"));
}
#[test]
fn hashed_name_changes_with_content() {
assert_ne!(hashed_name("app.css", b"v1"), hashed_name("app.css", b"v2"));
}
}