use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
pub fn sha256_file(path: &Path) -> io::Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 65536];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
pub fn sha256_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
pub fn hash_tree(root: &Path, exclude_top_level: &[&str]) -> io::Result<Vec<HashedArtifact>> {
let mut entries: Vec<HashedArtifact> = Vec::new();
for entry in WalkDir::new(root).sort_by_file_name() {
let entry = entry.map_err(io::Error::other)?;
if !entry.file_type().is_file() {
continue;
}
let abs = entry.path();
let is_top_level = abs.parent() == Some(root);
if is_top_level {
if let Some(name) = entry.file_name().to_str() {
if exclude_top_level.contains(&name) {
continue;
}
}
}
let rel = abs
.strip_prefix(root)
.map_err(|e| io::Error::other(format!("strip_prefix: {e}")))?
.to_path_buf();
let rel_str = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
let sha = sha256_file(abs)?;
let bytes = fs::metadata(abs)?.len();
entries.push(HashedArtifact {
path: rel_str,
sha256: sha,
bytes,
absolute: abs.to_path_buf(),
});
}
Ok(entries)
}
#[derive(Debug, Clone)]
pub struct HashedArtifact {
pub path: String,
pub sha256: String,
pub bytes: u64,
pub absolute: PathBuf,
}
pub fn atomic_write(dest: &Path, bytes: &[u8]) -> io::Result<()> {
let parent = dest
.parent()
.ok_or_else(|| io::Error::other("atomic_write: dest has no parent"))?;
let tmp = parent.join(format!(
".{}.tmp",
dest.file_name()
.and_then(|s| s.to_str())
.unwrap_or("anonymous")
));
{
let mut f = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
fs::rename(&tmp, dest)?;
if let Ok(dir) = File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_known_vector() {
assert_eq!(
sha256_bytes(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn hash_tree_is_deterministic_and_sorted() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("b.txt"), b"two").unwrap();
fs::write(dir.path().join("a.txt"), b"one").unwrap();
fs::write(dir.path().join("sub/c.txt"), b"three").unwrap();
fs::write(dir.path().join("manifest.json"), b"meta").unwrap();
let result = hash_tree(dir.path(), &["manifest.json"]).unwrap();
let paths: Vec<_> = result.iter().map(|h| h.path.clone()).collect();
assert_eq!(paths, vec!["a.txt", "b.txt", "sub/c.txt"]);
assert_eq!(result[0].sha256, sha256_bytes(b"one"));
assert_eq!(result[1].sha256, sha256_bytes(b"two"));
assert_eq!(result[2].sha256, sha256_bytes(b"three"));
assert_eq!(result[0].bytes, 3);
assert_eq!(result[2].bytes, 5);
}
#[test]
fn hash_tree_excludes_only_at_top_level() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("by-system/foo/raw")).unwrap();
fs::write(dir.path().join("manifest.json"), b"top-meta").unwrap();
fs::write(
dir.path().join("by-system/foo/raw/manifest.json"),
b"nested",
)
.unwrap();
let result = hash_tree(dir.path(), &["manifest.json"]).unwrap();
let paths: Vec<_> = result.iter().map(|h| h.path.clone()).collect();
assert_eq!(paths, vec!["by-system/foo/raw/manifest.json"]);
assert_eq!(result[0].sha256, sha256_bytes(b"nested"));
}
#[test]
fn atomic_write_replaces_existing_file() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out.json");
atomic_write(&dest, b"first").unwrap();
assert_eq!(fs::read(&dest).unwrap(), b"first");
atomic_write(&dest, b"second").unwrap();
assert_eq!(fs::read(&dest).unwrap(), b"second");
let leftovers: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.ends_with(".tmp"))
.unwrap_or(false)
})
.collect();
assert!(leftovers.is_empty(), "leftover tmp files: {leftovers:?}");
}
}