use crate::manifest::Manifest;
pub trait Hasher {
fn hash_hex(&self, bytes: &[u8]) -> String;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Blake3Hasher;
impl Blake3Hasher {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Hasher for Blake3Hasher {
fn hash_hex(&self, bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
}
#[derive(Debug, Clone)]
pub struct Blake3KeyedHasher {
context: String,
}
impl Blake3KeyedHasher {
#[must_use]
pub fn new(context: impl Into<String>) -> Self {
Self {
context: context.into(),
}
}
}
impl Hasher for Blake3KeyedHasher {
fn hash_hex(&self, bytes: &[u8]) -> String {
let mut hasher = blake3::Hasher::new_derive_key(&self.context);
hasher.update(bytes);
hasher.finalize().to_hex().to_string()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Md5Hasher;
impl Md5Hasher {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Hasher for Md5Hasher {
fn hash_hex(&self, bytes: &[u8]) -> String {
use md5::{Digest, Md5};
let digest = Md5::digest(bytes);
hex_lower(&digest)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Sha256Hasher;
impl Sha256Hasher {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Hasher for Sha256Hasher {
fn hash_hex(&self, bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(bytes);
hex_lower(&digest)
}
}
fn hex_lower(bytes: &[u8]) -> String {
use core::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
pub fn directory_checksum<'a, I, H>(child_checksums: I, hasher: &H) -> String
where
I: IntoIterator<Item = &'a str>,
H: Hasher,
{
let unique: std::collections::BTreeSet<&str> = child_checksums.into_iter().collect();
let mut concatenated = String::new();
for checksum in unique {
concatenated.push_str(checksum);
}
hasher.hash_hex(concatenated.as_bytes())
}
#[must_use]
pub fn snapshot_id<H>(manifest: &Manifest, hasher: &H) -> String
where
H: Hasher,
{
let mut text = manifest.to_string();
text.push('\n');
hasher.hash_hex(text.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
const EMPTY_BLAKE3: &str = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
const TWO_EMPTY_FILES_ROOT_ID: &str =
"dba5865c0d91b17958e4d2cac98c338f85cbbda07b71a020ab16c391b5e7af4b";
#[test]
fn blake3_hasher_matches_b3sum_no_names_for_empty_input() {
let hasher = Blake3Hasher::new();
assert_eq!(hasher.hash_hex(b""), EMPTY_BLAKE3);
}
#[test]
fn empty_directory_checksum_is_hash_of_empty_string() {
let hasher = Blake3Hasher::new();
let no_children: [&str; 0] = [];
assert_eq!(directory_checksum(no_children, &hasher), EMPTY_BLAKE3);
}
#[test]
fn directory_checksum_matches_guide_fixture_empty_files_root() {
let hasher = Blake3Hasher::new();
let children = [EMPTY_BLAKE3, EMPTY_BLAKE3];
assert_eq!(
directory_checksum(children, &hasher),
TWO_EMPTY_FILES_ROOT_ID
);
}
#[test]
fn directory_checksum_sorts_dedups_and_concatenates_in_order() {
let hasher = Blake3Hasher::new();
let children = ["ccc", "aaa", "bbb", "bbb"];
let got = directory_checksum(children, &hasher);
let expected = blake3::hash(b"aaabbbccc").to_hex().to_string();
assert_eq!(got, expected);
}
#[test]
fn directory_checksum_dedup_collapses_identical_children() {
let hasher = Blake3Hasher::new();
let children = ["zz", "zz", "zz"];
let got = directory_checksum(children, &hasher);
let expected = blake3::hash(b"zz").to_hex().to_string();
assert_eq!(got, expected);
}
#[test]
fn directory_checksum_is_order_independent_of_input_ordering() {
let hasher = Blake3Hasher::new();
let forward = directory_checksum(["a1", "b2", "c3"], &hasher);
let reverse = directory_checksum(["c3", "b2", "a1"], &hasher);
assert_eq!(forward, reverse);
}
#[test]
fn directory_checksum_is_the_root_d_line_field() {
let hasher = Blake3Hasher::new();
let root_children = [EMPTY_BLAKE3, EMPTY_BLAKE3];
let root_d_line_checksum = directory_checksum(root_children, &hasher);
assert_eq!(root_d_line_checksum, TWO_EMPTY_FILES_ROOT_ID);
}
const EMPTY_FILES_MANIFEST: &str = "\
D 700 dba5865c0d91b17958e4d2cac98c338f85cbbda07b71a020ab16c391b5e7af4b 0 ./
F 600 af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262 0 ./bar.txt
F 600 af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262 0 ./foo.txt";
const MODIFIED_MANIFEST: &str = "\
D 700 4a0732cfb45ebe9d8d572fc4c77b759384bed029911e35f8859430b889427d4d 4 ./
F 600 af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262 0 ./bar.txt
F 600 49dc870df1de7fd60794cebce449f5ccdae575affaa67a24b62acb03e039db92 4 ./foo.txt";
const EMPTY_FILES_SNAPSHOT_ID: &str =
"c678a299380893769bd7795628b96147229b410a9d5a5b7cae563bcae3c27857";
const MODIFIED_SNAPSHOT_ID: &str =
"8af03a1bec09b1838d2c4f56c6940ed35ccdad1064243d2d775e8347ba82b9be";
#[test]
fn snapshot_id_reproduces_empty_files_golden_id() {
let hasher = Blake3Hasher::new();
let manifest = Manifest::parse(EMPTY_FILES_MANIFEST).expect("parses");
assert_eq!(snapshot_id(&manifest, &hasher), EMPTY_FILES_SNAPSHOT_ID);
}
#[test]
fn snapshot_id_reproduces_modified_golden_id() {
let hasher = Blake3Hasher::new();
let manifest = Manifest::parse(MODIFIED_MANIFEST).expect("parses");
assert_eq!(snapshot_id(&manifest, &hasher), MODIFIED_SNAPSHOT_ID);
}
#[test]
fn snapshot_id_requires_the_trailing_newline() {
let hasher = Blake3Hasher::new();
let manifest = Manifest::parse(EMPTY_FILES_MANIFEST).expect("parses");
let without_newline = hasher.hash_hex(manifest.to_string().as_bytes());
assert_ne!(without_newline, EMPTY_FILES_SNAPSHOT_ID);
assert_eq!(snapshot_id(&manifest, &hasher), EMPTY_FILES_SNAPSHOT_ID);
}
#[test]
fn snapshot_id_ignores_comment_lines() {
let hasher = Blake3Hasher::new();
let with_comments = format!("# generated by snapdir\n{EMPTY_FILES_MANIFEST}\n# eof");
let manifest = Manifest::parse(&with_comments).expect("parses");
assert_eq!(snapshot_id(&manifest, &hasher), EMPTY_FILES_SNAPSHOT_ID);
}
#[test]
fn golden_md5_known_vectors() {
let hasher = Md5Hasher::new();
assert_eq!(hasher.hash_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
assert_eq!(hasher.hash_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
assert_eq!(
hasher.hash_hex(b"foo\n"),
"d3b07384d113edec49eaa6238ad5ff00"
);
}
#[test]
fn golden_md5_works_with_directory_checksum_and_snapshot_id() {
let hasher = Md5Hasher::new();
let dir = directory_checksum(["ccc", "aaa", "bbb", "bbb"], &hasher);
assert_eq!(dir, hasher.hash_hex(b"aaabbbccc"));
let manifest = Manifest::parse(EMPTY_FILES_MANIFEST).expect("parses");
let text = format!("{manifest}\n");
assert_eq!(
snapshot_id(&manifest, &hasher),
hasher.hash_hex(text.as_bytes())
);
}
#[test]
fn golden_sha256_known_vectors() {
let hasher = Sha256Hasher::new();
assert_eq!(
hasher.hash_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
hasher.hash_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
hasher.hash_hex(b"foo\n"),
"b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c"
);
}
#[test]
fn golden_sha256_works_with_directory_checksum_and_snapshot_id() {
let hasher = Sha256Hasher::new();
let dir = directory_checksum(["ccc", "aaa", "bbb", "bbb"], &hasher);
assert_eq!(dir, hasher.hash_hex(b"aaabbbccc"));
let manifest = Manifest::parse(EMPTY_FILES_MANIFEST).expect("parses");
let text = format!("{manifest}\n");
assert_eq!(
snapshot_id(&manifest, &hasher),
hasher.hash_hex(text.as_bytes())
);
}
#[test]
fn keyed_context_matches_blake3_derive_key_and_differs_from_unkeyed() {
let context = "snapdir 2026 test context";
let input = b"the quick brown fox";
let keyed = Blake3KeyedHasher::new(context);
let expected = blake3::derive_key(context, input);
let expected_hex = blake3::Hash::from(expected).to_hex().to_string();
assert_eq!(keyed.hash_hex(input), expected_hex);
let unkeyed = Blake3Hasher::new();
assert_ne!(keyed.hash_hex(input), unkeyed.hash_hex(input));
let other = Blake3KeyedHasher::new("a different context");
assert_ne!(keyed.hash_hex(input), other.hash_hex(input));
}
#[test]
fn keyed_context_drives_directory_checksum_and_snapshot_id() {
let context = "interop matrix context";
let keyed = Blake3KeyedHasher::new(context);
let dir = directory_checksum(["b", "a"], &keyed);
assert_eq!(dir, keyed.hash_hex(b"ab"));
let manifest = Manifest::parse(EMPTY_FILES_MANIFEST).expect("parses");
let id = snapshot_id(&manifest, &keyed);
let unkeyed_id = snapshot_id(&manifest, &Blake3Hasher::new());
assert_ne!(id, unkeyed_id);
}
#[test]
fn snapshot_id_differs_from_root_directory_checksum() {
let hasher = Blake3Hasher::new();
let manifest = Manifest::parse(EMPTY_FILES_MANIFEST).expect("parses");
let id = snapshot_id(&manifest, &hasher);
let root_children: Vec<&str> = manifest
.entries()
.iter()
.filter(|e| e.path != "./")
.map(|e| e.checksum.as_str())
.collect();
let root_dir_checksum = directory_checksum(root_children, &hasher);
assert_eq!(root_dir_checksum, TWO_EMPTY_FILES_ROOT_ID);
assert_ne!(id, root_dir_checksum);
assert_eq!(id, EMPTY_FILES_SNAPSHOT_ID);
}
}