use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::{Pin, pin};
use rayon::prelude::*;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
async fn read_exact_or_eof(
mut reader: Pin<&mut impl AsyncRead>,
mut buf: &mut [u8],
) -> io::Result<usize> {
let mut bytes_read = 0;
loop {
match reader.read(buf).await {
Ok(0) => return Ok(bytes_read),
Ok(n) => {
bytes_read += n;
if n == buf.len() {
return Ok(bytes_read);
}
buf = &mut buf[n..];
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
}
pub async fn blake3_copy<R, W>(reader: R, writer: W) -> io::Result<(u64, blake3::Hash)>
where
R: AsyncRead,
W: AsyncWrite,
{
let mut reader = pin!(reader);
let mut writer = pin!(writer);
let mut hasher = blake3::Hasher::new();
let mut buffer = [0; 1 << 16]; let mut total = 0u64;
loop {
let bytes_read = read_exact_or_eof(reader.as_mut(), &mut buffer).await?;
if bytes_read == 0 {
break; }
total += bytes_read as u64;
let bytes = &buffer[..bytes_read];
hasher.update(bytes);
writer.write_all(bytes).await?;
if bytes_read < buffer.len() {
break; }
}
writer.flush().await?;
Ok((total, hasher.finalize()))
}
#[derive(Debug, thiserror::Error)]
pub enum DirhashError {
#[error("Invalid path for directory hashing: {path:?}")]
InvalidPath { path: PathBuf },
#[error("Archive path is missing from the directory hash tree: {path:?}")]
MissingPath { path: PathBuf },
#[error("Archive contains duplicate entries for path: {path:?}")]
DuplicatePath { path: PathBuf },
#[error("Archive path is used as both a file and a directory: {path:?}")]
FileDirectoryConflict { path: PathBuf },
#[error("Encountered a symlink cycle while hashing a directory: {paths:?}")]
SymlinkCycle { paths: Vec<PathBuf> },
#[error(transparent)]
Io(#[from] io::Error),
}
struct SeenSymlinkNode<'a> {
canonical_path: PathBuf,
previous: Option<&'a Self>,
}
struct SeenSymlinks<'a> {
node: Option<SeenSymlinkNode<'a>>,
}
impl<'a> SeenSymlinks<'a> {
fn new() -> Self {
Self { node: None }
}
fn iter(&self) -> impl Iterator<Item = &Path> {
let mut node = self.node.as_ref();
std::iter::from_fn(move || {
if let Some(next_node) = node {
let next_path = &next_node.canonical_path;
node = next_node.previous;
Some(next_path.as_path())
} else {
None
}
})
}
fn push(&'a self, symlink_path: &Path) -> Result<Self, DirhashError> {
let canonical_path = canonical_path_to_symlink(symlink_path)?;
for seen in self.iter() {
if canonical_path == seen {
let mut paths: Vec<PathBuf> = self.iter().map(Path::to_owned).collect();
paths.reverse();
paths.push(canonical_path);
return Err(DirhashError::SymlinkCycle { paths });
}
}
Ok(Self {
node: Some(SeenSymlinkNode {
canonical_path,
previous: self.node.as_ref(),
}),
})
}
}
fn canonical_path_to_symlink(symlink_path: &Path) -> Result<PathBuf, DirhashError> {
let Some(filename) = symlink_path.file_name() else {
return Err(DirhashError::InvalidPath {
path: symlink_path.to_path_buf(),
});
};
let parent = symlink_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
Ok(fs_err::canonicalize(parent)?.join(filename))
}
pub fn dirhash_path(path: &Path) -> Result<blake3::Hash, DirhashError> {
uv_configuration::initialize_rayon_once();
let seen_symlinks = SeenSymlinks::new();
dirhash_path_inner(path, &seen_symlinks)
}
fn dirhash_path_inner(
path: &Path,
seen_symlinks: &SeenSymlinks,
) -> Result<blake3::Hash, DirhashError> {
let metadata = fs_err::symlink_metadata(path)?;
if metadata.is_symlink() {
let seen_symlinks = seen_symlinks.push(path)?;
dirhash_path_inner_resolved(path, &fs_err::metadata(path)?, &seen_symlinks)
} else {
dirhash_path_inner_resolved(path, &metadata, seen_symlinks)
}
}
fn dirhash_path_inner_resolved(
path: &Path,
metadata: &std::fs::Metadata,
seen_symlinks: &SeenSymlinks,
) -> Result<blake3::Hash, DirhashError> {
if metadata.is_dir() {
let mut dir_contents = Vec::new();
for entry in fs_err::read_dir(path)? {
let entry = entry?;
let path = entry.path();
let Ok(name) = entry.file_name().into_string() else {
return Err(DirhashError::InvalidPath { path });
};
dir_contents.push((name, path));
}
dir_contents.sort_unstable();
let hashes = dir_contents
.par_iter()
.map(|(_, path)| dirhash_path_inner(path, seen_symlinks))
.collect::<Result<Vec<blake3::Hash>, _>>()?;
let dirhash_entries = dir_contents
.iter()
.zip(hashes)
.map(|((name, _), hash)| (name.as_str(), hash));
Ok(hash_dir_entries(dirhash_entries))
} else {
Ok(blake3::Hasher::new().update_mmap_rayon(path)?.finalize())
}
}
#[derive(Debug, Clone)]
enum DirhashEntry {
File(blake3::Hash),
Directory(DirhashTree),
}
#[derive(Debug, Clone, Default)]
pub struct DirhashTree {
children: BTreeMap<String, DirhashEntry>,
}
impl DirhashTree {
pub fn new() -> Self {
Self::default()
}
fn insertion_entry(
&mut self,
normalized_path: &str,
original_path: &str,
create_dirs: bool,
) -> Result<Entry<'_, String, DirhashEntry>, DirhashError> {
if let Some((component, rest)) = normalized_path.split_once('/') {
if self.children.contains_key(component) {
match self.children.get_mut(component).unwrap() {
DirhashEntry::Directory(child) => {
child.insertion_entry(rest, original_path, create_dirs)
}
DirhashEntry::File(_) => Err(DirhashError::FileDirectoryConflict {
path: PathBuf::from(original_path),
}),
}
} else {
if create_dirs {
let child = self
.children
.entry(String::from(component))
.or_insert(DirhashEntry::Directory(Self::default()));
let DirhashEntry::Directory(child) = child else {
unreachable!()
};
child.insertion_entry(rest, original_path, create_dirs)
} else {
Err(DirhashError::MissingPath {
path: PathBuf::from(original_path),
})
}
}
} else {
Ok(self.children.entry(String::from(normalized_path)))
}
}
pub fn add_file(&mut self, path: &str, hash: blake3::Hash) -> Result<(), DirhashError> {
let normalized_path = normalize_dirhash_path(path)?;
let entry = self.insertion_entry(&normalized_path, path, true)?;
match entry {
Entry::Vacant(vacant) => {
vacant.insert(DirhashEntry::File(hash));
Ok(())
}
Entry::Occupied(_) => Err(DirhashError::DuplicatePath {
path: PathBuf::from(path),
}),
}
}
pub fn update_file(&mut self, path: &str, hash: blake3::Hash) -> Result<(), DirhashError> {
let normalized_path = normalize_dirhash_path(path)?;
let entry = self.insertion_entry(&normalized_path, path, false)?;
match entry {
Entry::Vacant(_) => Err(DirhashError::MissingPath {
path: PathBuf::from(path),
}),
Entry::Occupied(mut occupied) => match occupied.get_mut() {
DirhashEntry::File(prev_hash) => {
*prev_hash = hash;
Ok(())
}
DirhashEntry::Directory(_) => Err(DirhashError::FileDirectoryConflict {
path: PathBuf::from(path),
}),
},
}
}
pub fn add_empty_dir(&mut self, path: &str) -> Result<(), DirhashError> {
let normalized_path = normalize_dirhash_path(path)?;
let entry = self.insertion_entry(&normalized_path, path, true)?;
match entry {
Entry::Vacant(vacant) => {
vacant.insert(DirhashEntry::Directory(Self::default()));
Ok(())
}
Entry::Occupied(occupied) => match occupied.get() {
DirhashEntry::Directory(_) => Ok(()),
DirhashEntry::File(_) => Err(DirhashError::FileDirectoryConflict {
path: PathBuf::from(path),
}),
},
}
}
pub fn hash(&self) -> blake3::Hash {
hash_dir_entries(self.children.iter().map(|(name, entry)| {
let hash = match entry {
DirhashEntry::File(hash) => *hash,
DirhashEntry::Directory(child) => child.hash(),
};
(name.as_str(), hash)
}))
}
}
fn component_needs_normalization(component: &str) -> bool {
matches!(component, "" | "." | "..")
}
fn normalize_dirhash_path(mut path: &str) -> Result<Cow<'_, str>, DirhashError> {
if path.starts_with('/') {
return Err(DirhashError::InvalidPath {
path: PathBuf::from(path),
});
}
path = path.trim_start_matches("./");
path = path.trim_end_matches('/');
if !path.split('/').any(component_needs_normalization) {
return Ok(Cow::Borrowed(path));
}
let mut components = Vec::new();
for component in path.split('/') {
match component {
"" | "." => {}
".." => {
if components.pop().is_none() {
return Err(DirhashError::InvalidPath {
path: PathBuf::from(path),
});
}
}
component => components.push(component),
}
}
if components.is_empty() {
return Err(DirhashError::InvalidPath {
path: PathBuf::from(path),
});
}
Ok(Cow::Owned(components.join("/")))
}
fn hash_dir_entries<'a, Iter>(entries: Iter) -> blake3::Hash
where
Iter: IntoIterator<Item = (&'a str, blake3::Hash)>,
{
let mut hasher = blake3::Hasher::new_derive_key("directory");
for (name, hash) in entries {
hasher.update(name.as_bytes());
hasher.update(&[0xff]);
hasher.update(hash.as_bytes());
}
hasher.finalize()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cmp;
use std::task::{Context, Poll};
#[test]
fn test_normalize() {
let success_cases = [
("foo", Cow::Borrowed("foo")),
("foo", Cow::Borrowed("foo")),
("foo/", Cow::Borrowed("foo")),
("./foo", Cow::Borrowed("foo")),
("././foo/bar///", Cow::Borrowed("foo/bar")),
("foo//bar", Cow::Owned("foo/bar".to_string())),
("foo/./bar", Cow::Owned("foo/bar".to_string())),
("foo/.///./bar", Cow::Owned("foo/bar".to_string())),
("foo/bar/..", Cow::Owned("foo".to_string())),
("foo/bar/../../baz", Cow::Owned("baz".to_string())),
];
for (path, expected) in success_cases {
let normalized = super::normalize_dirhash_path(path).unwrap();
assert_eq!(normalized, expected);
}
let error_cases = [
"",
"/",
"/foo",
"///foo",
"..",
"foo/..",
"foo/bar/../../../baz",
];
for path in error_cases {
super::normalize_dirhash_path(path).unwrap_err();
}
}
#[test]
fn test_add_update_and_add_empty_dir() {
let a_hash = blake3::hash(b"hello");
let c_hash = blake3::hash(b"goodbye");
let d_hash = blake3::derive_key("directory", b"");
let mut b_input = Vec::new();
b_input.extend_from_slice(b"c.txt\xff");
b_input.extend_from_slice(c_hash.as_bytes());
b_input.extend_from_slice(b"d\xff");
b_input.extend_from_slice(&d_hash);
let b_hash = blake3::derive_key("directory", &b_input);
let mut root_input = Vec::new();
root_input.extend_from_slice(b"a.txt\xff");
root_input.extend_from_slice(a_hash.as_bytes());
root_input.extend_from_slice(b"b\xff");
root_input.extend_from_slice(&b_hash);
let root_hash = blake3::derive_key("directory", &root_input);
assert_eq!(
blake3::Hash::from_bytes(root_hash).to_hex().as_str(),
"e508467d129e0d19cefa96527f5f6cb3760530be4d931c527f2818a0dff5d517"
);
let mut tree = super::DirhashTree::default();
tree.add_file("a.txt", a_hash).unwrap();
tree.add_file("b/c.txt", c_hash).unwrap();
tree.add_empty_dir("b/d").unwrap();
assert_eq!(tree.hash(), root_hash);
tree.update_file("b/c.txt", [0; 32].into()).unwrap();
assert_ne!(tree.hash(), root_hash);
tree.update_file("b/c.txt", c_hash).unwrap();
assert_eq!(tree.hash(), root_hash);
tree.add_empty_dir("b").unwrap(); assert_eq!(tree.hash(), root_hash);
tree.add_empty_dir("e").unwrap(); assert_ne!(tree.hash(), root_hash);
}
#[test]
fn test_dirhash_path() -> Result<(), super::DirhashError> {
let temp_dir = tempfile::tempdir()?;
let root = temp_dir.path();
fs_err::write(root.join("a.txt"), b"hello")?;
fs_err::create_dir(root.join("b"))?;
fs_err::write(root.join("b/c.txt"), b"goodbye")?;
fs_err::create_dir(root.join("b/d"))?;
let mut expected = super::DirhashTree::default();
expected.add_file("a.txt", blake3::hash(b"hello"))?;
expected.add_file("b/c.txt", blake3::hash(b"goodbye"))?;
expected.add_empty_dir("b/d")?;
assert_eq!(super::dirhash_path(root)?, expected.hash());
assert_eq!(
super::dirhash_path(&root.join("a.txt"))?,
blake3::hash(b"hello")
);
Ok(())
}
#[cfg(unix)]
#[test]
fn test_dirhash_path_symlinks() -> Result<(), super::DirhashError> {
use fs_err::os::unix::fs::symlink;
let temp_dir = tempfile::tempdir()?;
let root = temp_dir.path();
fs_err::create_dir(root.join("dir1"))?;
fs_err::create_dir(root.join("dir2"))?;
fs_err::write(root.join("dir1/file.txt"), b"hello")?;
symlink("../dir2", root.join("dir1/dir_link"))?;
symlink("../dir1/file.txt", root.join("dir2/file_link"))?;
let mut in_memory = super::DirhashTree::default();
in_memory.add_file("dir1/file.txt", blake3::hash(b"hello"))?;
in_memory.add_file("dir1/dir_link/file_link", blake3::hash(b"hello"))?;
in_memory.add_file("dir2/file_link", blake3::hash(b"hello"))?;
let from_disk = super::dirhash_path(root)?;
assert_eq!(in_memory.hash(), from_disk);
fs_err::create_dir(root.join("dir2/inner"))?;
symlink("../../dir1", root.join("dir2/inner/dir_link"))?;
let error = super::dirhash_path(root).unwrap_err();
std::assert_matches!(error, super::DirhashError::SymlinkCycle { .. });
Ok(())
}
fn paint_input(buf: &mut [u8]) {
let mut value = 0u8;
for byte in buf {
*byte = value;
value = if value == 250 { 0 } else { value + 1 };
}
}
#[tokio::test]
async fn test_blake3_copy() -> io::Result<()> {
let input = b"hello";
let mut output = Vec::new();
let (bytes_read, hash) = Box::pin(super::blake3_copy(&input[..], &mut output)).await?;
assert_eq!(bytes_read, input.len() as u64);
assert_eq!(input, &output[..]);
assert_eq!(hash, blake3::hash(input));
let mut big_input = vec![0; 64_000 * 3];
paint_input(&mut big_input);
let mut big_output = Vec::new();
let (big_bytes_read, big_hash) =
Box::pin(super::blake3_copy(&big_input[..], &mut big_output)).await?;
assert_eq!(big_bytes_read, big_input.len() as u64);
assert_eq!(big_input, big_output);
assert_eq!(big_hash, blake3::hash(&big_input));
Ok(())
}
struct ShortReader<'a>(&'a [u8]);
impl AsyncRead for ShortReader<'_> {
fn poll_read(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<io::Result<()>> {
const SHORT_READ_LEN: usize = 251; let want = cmp::min(self.0.len(), buf.remaining());
let take = cmp::min(want, SHORT_READ_LEN);
buf.put_slice(&self.0[..take]);
self.0 = &self.0[take..];
Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn test_blake3_copy_short_reader() -> io::Result<()> {
let mut input = vec![0; 64_000 * 3];
paint_input(&mut input);
let mut output = Vec::new();
let (bytes_read, hash) =
Box::pin(super::blake3_copy(ShortReader(&input), &mut output)).await?;
assert_eq!(bytes_read, input.len() as u64);
assert_eq!(input, &output[..]);
assert_eq!(hash, blake3::hash(&input));
Ok(())
}
fn walk_test_vector_input(
input_dir: &serde_json::Map<String, serde_json::Value>,
dirhash_tree: &mut DirhashTree,
tempdir: &tempfile::TempDir,
relative_path: Option<&str>,
) -> anyhow::Result<()> {
for (name, file_or_dir) in input_dir {
let entry_path = match relative_path {
Some(parent) => &format!("{parent}/{name}"),
None => name,
};
match file_or_dir {
serde_json::Value::String(file_text) => {
fs_err::write(tempdir.path().join(entry_path), file_text)?;
dirhash_tree.add_file(entry_path, blake3::hash(file_text.as_bytes()))?;
}
serde_json::Value::Object(input_subdir) => {
fs_err::create_dir(tempdir.path().join(entry_path))?;
dirhash_tree.add_empty_dir(entry_path)?;
walk_test_vector_input(input_subdir, dirhash_tree, tempdir, Some(entry_path))?;
}
_ => panic!("unexpected JSON type"),
}
}
Ok(())
}
#[derive(Debug, serde::Deserialize)]
struct JsonTestVector {
input: serde_json::Map<String, serde_json::Value>,
dirhash: String,
}
#[tokio::test]
async fn test_vectors_json() -> anyhow::Result<()> {
let test_vectors: Vec<JsonTestVector> =
serde_json::from_str(include_str!("../test_vectors/test_vectors.json"))?;
for JsonTestVector { input, dirhash } in &test_vectors {
let mut tree = DirhashTree::new();
let tempdir = tempfile::tempdir()?;
walk_test_vector_input(
input, &mut tree, &tempdir, None,
)?;
assert_eq!(dirhash.as_str(), tree.hash().to_hex().as_str());
assert_eq!(
dirhash.as_str(),
dirhash_path(tempdir.path())?.to_hex().as_str(),
);
}
Ok(())
}
}