use std::collections::BTreeMap;
use std::fs::File;
use std::path::Path;
use serde::Serialize;
use crate::error::Result;
pub const ALGORITHM: &str = "blake3";
pub fn hash_file(path: &Path) -> Result<String> {
let file = File::open(path)?;
let mut hasher = blake3::Hasher::new();
hasher.update_reader(file)?;
Ok(hasher.finalize().to_hex().as_str().to_owned())
}
#[derive(Serialize)]
pub struct FileHash {
pub path: String,
pub hash: String,
}
pub fn hash_each(base: &Path, rel_paths: &[String]) -> Result<Vec<FileHash>> {
rel_paths
.iter()
.map(|rel| {
Ok(FileHash {
path: rel.clone(),
hash: hash_file(&base.join(rel))?,
})
})
.collect()
}
#[must_use]
pub fn hash_bytes(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().as_str().to_owned()
}
#[must_use]
pub fn digest_hashes(files: &[FileHash]) -> String {
digest_all(files, &BTreeMap::new())
}
#[must_use]
pub fn digest_all(files: &[FileHash], probes: &BTreeMap<String, String>) -> String {
let mut hasher = blake3::Hasher::new();
for file in files {
hasher.update(file.path.as_bytes());
hasher.update(b"\0");
hasher.update(file.hash.as_bytes());
hasher.update(b"\n");
}
for (name, hash) in probes {
hasher.update(b"probe\0");
hasher.update(name.as_bytes());
hasher.update(b"\0");
hasher.update(hash.as_bytes());
hasher.update(b"\n");
}
hasher.finalize().to_hex().as_str().to_owned()
}
pub fn digest_files(base: &Path, rel_paths: &[String]) -> Result<String> {
Ok(digest_hashes(&hash_each(base, rel_paths)?))
}
pub fn digest_with(
base: &Path,
rel_paths: &[String],
probes: &BTreeMap<String, String>,
) -> Result<String> {
Ok(digest_all(&hash_each(base, rel_paths)?, probes))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{FileHash, digest_all, digest_files, digest_hashes, hash_bytes, hash_file};
fn pair(path: &str, hash: &str) -> FileHash {
FileHash {
path: path.to_owned(),
hash: hash.to_owned(),
}
}
#[test]
fn probes_fold_after_files_without_disturbing_a_probe_free_digest() {
let files = [pair("a.txt", "h1"), pair("b.txt", "h2")];
let none = BTreeMap::new();
assert_eq!(
digest_all(&files, &none),
digest_hashes(&files),
"no probes must fold byte-identically to the old file-only digest, so records written before probes existed stay valid"
);
let one: BTreeMap<String, String> =
[("tool".to_owned(), "p1".to_owned())].into_iter().collect();
let with_probe = digest_all(&files, &one);
assert_ne!(
with_probe,
digest_hashes(&files),
"a probe feeds the digest"
);
let moved: BTreeMap<String, String> =
[("tool".to_owned(), "p2".to_owned())].into_iter().collect();
assert_ne!(
with_probe,
digest_all(&files, &moved),
"and its output shifts it"
);
let named: BTreeMap<String, String> = [("a.txt".to_owned(), "h1".to_owned())]
.into_iter()
.collect();
assert_ne!(
digest_all(&[], &named),
digest_hashes(&[pair("a.txt", "h1")]),
"the probe marker keeps a probe name from imitating a file path"
);
}
#[test]
fn byte_hashing_matches_file_hashing_of_the_same_bytes() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("probe-output");
std::fs::write(&path, b"hello").expect("write");
assert_eq!(
hash_bytes(b"hello"),
hash_file(&path).expect("hash"),
"a probe's stdout hashes exactly as the same bytes on disk would"
);
}
#[test]
fn hashes_are_stable_and_content_dependent() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("a.txt");
std::fs::write(&path, b"hello").expect("write");
let first = hash_file(&path).expect("hash");
assert_eq!(first.len(), 64, "blake3 hex is 64 chars");
assert_eq!(first, hash_file(&path).expect("hash"), "deterministic");
std::fs::write(&path, b"world").expect("rewrite");
assert_ne!(first, hash_file(&path).expect("hash"), "content matters");
}
#[test]
fn digest_reflects_content_and_membership() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("a.txt"), b"one").expect("write");
std::fs::write(dir.path().join("b.txt"), b"two").expect("write");
let both = ["a.txt".to_owned(), "b.txt".to_owned()];
let base = digest_files(dir.path(), &both).expect("digest");
std::fs::write(dir.path().join("a.txt"), b"edited").expect("rewrite");
assert_ne!(
base,
digest_files(dir.path(), &both).expect("digest"),
"edit shifts digest"
);
let one = ["b.txt".to_owned()];
assert_ne!(
base,
digest_files(dir.path(), &one).expect("digest"),
"membership shifts digest"
);
}
}