use std::collections::BTreeMap;
use std::fs::File;
use std::io;
use std::path::Path;
use serde::Serialize;
use crate::error::{Error, Result};
use crate::provenance::Provenance;
pub const ALGORITHM: &str = "blake3";
pub fn hash_file(path: &Path) -> Result<String> {
hash_named(path, path)
}
fn hash_named(path: &Path, shown: &Path) -> Result<String> {
let file = File::open(path).map_err(|source| unreadable(shown, source))?;
let mut hasher = blake3::Hasher::new();
hasher
.update_reader(file)
.map_err(|source| unreadable(shown, source))?;
Ok(hasher.finalize().to_hex().as_str().to_owned())
}
fn unreadable(path: &Path, source: io::Error) -> Error {
let path = path.to_path_buf();
if source.kind() == io::ErrorKind::NotFound {
Error::InputVanished { path }
} else {
Error::InputUnreadable { path, source }
}
}
#[derive(Debug, 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| {
let full = base.join(rel);
Ok(FileHash {
path: rel.clone(),
hash: hash_named(&full, &Provenance::shorten(&full, base))?,
})
})
.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::{
Error, File, FileHash, digest_all, digest_files, digest_hashes, hash_bytes, hash_each,
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 an_input_that_vanished_after_resolution_is_named() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
std::fs::write(base.join("kept.txt"), b"one").expect("write");
std::fs::write(base.join("gone.txt"), b"two").expect("write");
let files = crate::resolve::expand(&["*.txt".to_owned()], base, false).expect("resolve");
std::fs::remove_file(base.join("gone.txt")).expect("delete");
let err = hash_each(base, &files).expect_err("a deleted input must fail the hash");
let message = err.to_string();
assert!(
matches!(err, Error::InputVanished { .. }),
"a vanished input is the resolve-then-hash race, not a bare i/o error: {message}"
);
assert!(
message.contains("gone.txt"),
"the error must name the input it could not read, got: {message}"
);
}
#[cfg(unix)]
#[test]
fn an_unreadable_input_is_named_beside_its_errno() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let path = base.join("locked.txt");
std::fs::write(&path, b"one").expect("write");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).expect("chmod");
if File::open(&path).is_ok() {
return;
}
let err = hash_each(base, &["locked.txt".to_owned()])
.expect_err("an unreadable input must fail the hash");
let message = err.to_string();
assert!(
matches!(err, Error::InputUnreadable { .. }),
"a mode a reader cannot satisfy is not the vanished-input race: {message}"
);
assert!(
message.contains("locked.txt") && message.contains("ermission"),
"the error must name the input and keep the errno, got: {message}"
);
}
#[test]
fn an_input_outside_the_root_is_named_absolutely() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path().join("project");
std::fs::create_dir(&base).expect("mkdir");
let outside = dir.path().join("outside.txt");
let err = hash_each(&base, &[outside.display().to_string()])
.expect_err("a missing input must fail the hash");
assert!(
err.to_string().contains(&outside.display().to_string()),
"an input outside the root keeps its absolute path, got: {err}"
);
}
#[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"
);
}
}