1use std::borrow::Cow;
65use std::collections::BTreeMap;
66use std::collections::btree_map::Entry;
67use std::io;
68use std::path::{Path, PathBuf};
69use std::pin::{Pin, pin};
70
71use rayon::prelude::*;
72use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
73
74mod archive;
75mod seek;
76
77pub use archive::DirectoryDigest;
78pub(crate) use archive::{ExtractedFile, directory_tree_from_extracted};
79pub(crate) use seek::{unzip, unzip_and_hash};
80
81async fn read_exact_or_eof(
84 mut reader: Pin<&mut impl AsyncRead>,
85 mut buf: &mut [u8],
86) -> io::Result<usize> {
87 let mut bytes_read = 0;
88 loop {
89 match reader.read(buf).await {
90 Ok(0) => return Ok(bytes_read),
91 Ok(n) => {
92 bytes_read += n;
93 if n == buf.len() {
94 return Ok(bytes_read);
95 }
96 buf = &mut buf[n..];
97 }
98 Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
99 Err(e) => return Err(e),
100 }
101 }
102}
103
104pub async fn blake3_copy<R, W>(reader: R, writer: W) -> io::Result<(u64, blake3::Hash)>
108where
109 R: AsyncRead,
110 W: AsyncWrite,
111{
112 let mut reader = pin!(reader);
113 let mut writer = pin!(writer);
114 let mut hasher = blake3::Hasher::new();
115 let mut buffer = vec![0; 1 << 16]; let mut total = 0u64;
117 loop {
123 let bytes_read = read_exact_or_eof(reader.as_mut(), &mut buffer).await?;
124 if bytes_read == 0 {
125 break; }
127 total += bytes_read as u64;
128 let bytes = &buffer[..bytes_read];
129 hasher.update(bytes);
130 writer.write_all(bytes).await?;
131 if bytes_read < buffer.len() {
132 break; }
134 }
135 writer.flush().await?;
136 Ok((total, hasher.finalize()))
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum DirhashError {
141 #[error("Invalid path for directory hashing: {path:?}")]
142 InvalidPath { path: PathBuf },
143 #[error("Archive path is missing from the directory hash tree: {path:?}")]
144 MissingPath { path: PathBuf },
145 #[error("Archive contains duplicate entries for path: {path:?}")]
146 DuplicatePath { path: PathBuf },
147 #[error("Archive path is used as both a file and a directory: {path:?}")]
148 FileDirectoryConflict { path: PathBuf },
149 #[error("Encountered a symlink cycle while hashing a directory: {paths:?}")]
150 SymlinkCycle { paths: Vec<PathBuf> },
151 #[error(transparent)]
152 Io(#[from] io::Error),
153}
154
155struct SeenSymlinkNode<'a> {
157 canonical_path: PathBuf,
158 previous: Option<&'a Self>,
159}
160
161struct SeenSymlinks<'a> {
162 node: Option<SeenSymlinkNode<'a>>,
163}
164
165impl<'a> SeenSymlinks<'a> {
166 fn new() -> Self {
167 Self { node: None }
168 }
169
170 fn iter(&self) -> impl Iterator<Item = &Path> {
171 let mut node = self.node.as_ref();
172 std::iter::from_fn(move || {
173 if let Some(next_node) = node {
174 let next_path = &next_node.canonical_path;
175 node = next_node.previous;
176 Some(next_path.as_path())
177 } else {
178 None
179 }
180 })
181 }
182
183 fn push(&'a self, symlink_path: &Path) -> Result<Self, DirhashError> {
184 let canonical_path = canonical_path_to_symlink(symlink_path)?;
185 for seen in self.iter() {
187 if canonical_path == seen {
188 let mut paths: Vec<PathBuf> = self.iter().map(Path::to_owned).collect();
189 paths.reverse();
190 paths.push(canonical_path);
191 return Err(DirhashError::SymlinkCycle { paths });
192 }
193 }
194 Ok(Self {
195 node: Some(SeenSymlinkNode {
196 canonical_path,
197 previous: self.node.as_ref(),
198 }),
199 })
200 }
201}
202
203fn canonical_path_to_symlink(symlink_path: &Path) -> Result<PathBuf, DirhashError> {
206 let Some(filename) = symlink_path.file_name() else {
207 return Err(DirhashError::InvalidPath {
208 path: symlink_path.to_path_buf(),
209 });
210 };
211 let parent = symlink_path
212 .parent()
213 .filter(|parent| !parent.as_os_str().is_empty())
214 .unwrap_or(Path::new("."));
215 Ok(fs_err::canonicalize(parent)?.join(filename))
216}
217
218pub fn dirhash_path(path: &Path) -> Result<blake3::Hash, DirhashError> {
227 uv_configuration::initialize_rayon_once();
228 let seen_symlinks = SeenSymlinks::new();
229 dirhash_path_inner(path, &seen_symlinks)
230}
231
232fn dirhash_path_inner(
234 path: &Path,
235 seen_symlinks: &SeenSymlinks,
236) -> Result<blake3::Hash, DirhashError> {
237 let metadata = fs_err::symlink_metadata(path)?;
238 if metadata.is_symlink() {
239 let seen_symlinks = seen_symlinks.push(path)?;
240 dirhash_path_inner_resolved(path, &fs_err::metadata(path)?, &seen_symlinks)
241 } else {
242 dirhash_path_inner_resolved(path, &metadata, seen_symlinks)
243 }
244}
245
246fn dirhash_path_inner_resolved(
248 path: &Path,
249 metadata: &std::fs::Metadata,
250 seen_symlinks: &SeenSymlinks,
251) -> Result<blake3::Hash, DirhashError> {
252 if metadata.is_dir() {
253 let mut dir_contents = Vec::new();
255 for entry in fs_err::read_dir(path)? {
256 let entry = entry?;
257 let path = entry.path();
258 let Ok(name) = entry.file_name().into_string() else {
261 return Err(DirhashError::InvalidPath { path });
262 };
263 dir_contents.push((name, path));
264 }
265 dir_contents.sort_unstable();
267 let hashes = dir_contents
269 .par_iter()
270 .map(|(_, path)| dirhash_path_inner(path, seen_symlinks))
272 .collect::<Result<Vec<blake3::Hash>, _>>()?;
273 let dirhash_entries = dir_contents
274 .iter()
275 .zip(hashes)
276 .map(|((name, _), hash)| (name.as_str(), hash));
277 Ok(hash_dir_entries(dirhash_entries))
278 } else {
279 Ok(blake3::Hasher::new().update_mmap_rayon(path)?.finalize())
282 }
283}
284
285#[derive(Debug, Clone)]
286enum DirhashEntry {
287 File(blake3::Hash),
288 Directory(DirhashTree),
289}
290
291#[derive(Debug, Clone, Default)]
296pub struct DirhashTree {
297 children: BTreeMap<String, DirhashEntry>,
298}
299
300impl DirhashTree {
301 pub fn new() -> Self {
303 Self::default()
304 }
305
306 fn insertion_entry(
307 &mut self,
308 normalized_path: &str,
309 original_path: &str,
310 create_dirs: bool,
311 ) -> Result<Entry<'_, String, DirhashEntry>, DirhashError> {
312 if let Some((component, rest)) = normalized_path.split_once('/') {
313 if self.children.contains_key(component) {
315 match self.children.get_mut(component).unwrap() {
322 DirhashEntry::Directory(child) => {
323 child.insertion_entry(rest, original_path, create_dirs)
324 }
325 DirhashEntry::File(_) => Err(DirhashError::FileDirectoryConflict {
326 path: PathBuf::from(original_path),
327 }),
328 }
329 } else {
330 if create_dirs {
332 let child = self
333 .children
334 .entry(String::from(component))
335 .or_insert(DirhashEntry::Directory(Self::default()));
336 let DirhashEntry::Directory(child) = child else {
337 unreachable!()
338 };
339 child.insertion_entry(rest, original_path, create_dirs)
340 } else {
341 Err(DirhashError::MissingPath {
342 path: PathBuf::from(original_path),
343 })
344 }
345 }
346 } else {
347 Ok(self.children.entry(String::from(normalized_path)))
349 }
350 }
351
352 pub fn add_file(&mut self, path: &str, hash: blake3::Hash) -> Result<(), DirhashError> {
365 let normalized_path = normalize_dirhash_path(path)?;
366 let entry = self.insertion_entry(&normalized_path, path, true)?;
367 match entry {
368 Entry::Vacant(vacant) => {
369 vacant.insert(DirhashEntry::File(hash));
370 Ok(())
371 }
372 Entry::Occupied(_) => Err(DirhashError::DuplicatePath {
373 path: PathBuf::from(path),
374 }),
375 }
376 }
377
378 pub fn update_file(&mut self, path: &str, hash: blake3::Hash) -> Result<(), DirhashError> {
390 let normalized_path = normalize_dirhash_path(path)?;
391 let entry = self.insertion_entry(&normalized_path, path, false)?;
392 match entry {
393 Entry::Vacant(_) => Err(DirhashError::MissingPath {
394 path: PathBuf::from(path),
395 }),
396 Entry::Occupied(mut occupied) => match occupied.get_mut() {
397 DirhashEntry::File(prev_hash) => {
398 *prev_hash = hash;
399 Ok(())
400 }
401 DirhashEntry::Directory(_) => Err(DirhashError::FileDirectoryConflict {
402 path: PathBuf::from(path),
403 }),
404 },
405 }
406 }
407
408 pub fn add_empty_dir(&mut self, path: &str) -> Result<(), DirhashError> {
416 let normalized_path = normalize_dirhash_path(path)?;
417 let entry = self.insertion_entry(&normalized_path, path, true)?;
418 match entry {
419 Entry::Vacant(vacant) => {
420 vacant.insert(DirhashEntry::Directory(Self::default()));
421 Ok(())
422 }
423 Entry::Occupied(occupied) => match occupied.get() {
424 DirhashEntry::Directory(_) => Ok(()),
425 DirhashEntry::File(_) => Err(DirhashError::FileDirectoryConflict {
426 path: PathBuf::from(path),
427 }),
428 },
429 }
430 }
431
432 pub fn hash(&self) -> blake3::Hash {
438 hash_dir_entries(self.children.iter().map(|(name, entry)| {
439 let hash = match entry {
440 DirhashEntry::File(hash) => *hash,
441 DirhashEntry::Directory(child) => child.hash(),
442 };
443 (name.as_str(), hash)
444 }))
445 }
446}
447
448fn component_needs_normalization(component: &str) -> bool {
449 matches!(component, "" | "." | "..")
450}
451
452fn normalize_dirhash_path(mut path: &str) -> Result<Cow<'_, str>, DirhashError> {
453 if path.starts_with('/') {
454 return Err(DirhashError::InvalidPath {
455 path: PathBuf::from(path),
456 });
457 }
458 path = path.trim_start_matches("./");
459 path = path.trim_end_matches('/');
460 if !path.split('/').any(component_needs_normalization) {
461 return Ok(Cow::Borrowed(path));
462 }
463 let mut components = Vec::new();
464 for component in path.split('/') {
465 match component {
466 "" | "." => {}
467 ".." => {
468 if components.pop().is_none() {
469 return Err(DirhashError::InvalidPath {
470 path: PathBuf::from(path),
471 });
472 }
473 }
474 component => components.push(component),
475 }
476 }
477 if components.is_empty() {
478 return Err(DirhashError::InvalidPath {
479 path: PathBuf::from(path),
480 });
481 }
482 Ok(Cow::Owned(components.join("/")))
483}
484
485fn hash_dir_entries<'a, Iter>(entries: Iter) -> blake3::Hash
486where
487 Iter: IntoIterator<Item = (&'a str, blake3::Hash)>,
488{
489 let mut hasher = blake3::Hasher::new_derive_key("directory");
493 for (name, hash) in entries {
494 hasher.update(name.as_bytes());
495 hasher.update(&[0xff]);
496 hasher.update(hash.as_bytes());
497 }
498 hasher.finalize()
499}
500
501#[cfg(test)]
502mod tests {
503 #[cfg(unix)]
504 use std::assert_matches;
505
506 use super::*;
507 use std::cmp;
508 use std::process::Command;
509 use std::task::{Context, Poll};
510
511 #[test]
512 fn test_normalize() {
513 let success_cases = [
514 ("foo", Cow::Borrowed("foo")),
515 ("foo", Cow::Borrowed("foo")),
516 ("foo/", Cow::Borrowed("foo")),
517 ("./foo", Cow::Borrowed("foo")),
518 ("././foo/bar///", Cow::Borrowed("foo/bar")),
519 ("foo//bar", Cow::Owned("foo/bar".to_string())),
520 ("foo/./bar", Cow::Owned("foo/bar".to_string())),
521 ("foo/.///./bar", Cow::Owned("foo/bar".to_string())),
522 ("foo/bar/..", Cow::Owned("foo".to_string())),
523 ("foo/bar/../../baz", Cow::Owned("baz".to_string())),
524 ];
525 for (path, expected) in success_cases {
526 let normalized = super::normalize_dirhash_path(path).unwrap();
527 assert_eq!(normalized, expected);
528 }
529 let error_cases = [
530 "",
531 "/",
532 "/foo",
533 "///foo",
534 "..",
535 "foo/..",
536 "foo/bar/../../../baz",
537 ];
538 for path in error_cases {
539 super::normalize_dirhash_path(path).unwrap_err();
540 }
541 }
542
543 #[test]
544 fn test_add_update_and_add_empty_dir() {
545 let a_hash = blake3::hash(b"hello");
554 let c_hash = blake3::hash(b"goodbye");
555 let d_hash = blake3::derive_key("directory", b"");
556 let mut b_input = Vec::new();
557 b_input.extend_from_slice(b"c.txt\xff");
558 b_input.extend_from_slice(c_hash.as_bytes());
559 b_input.extend_from_slice(b"d\xff");
560 b_input.extend_from_slice(&d_hash);
561 let b_hash = blake3::derive_key("directory", &b_input);
562 let mut root_input = Vec::new();
563 root_input.extend_from_slice(b"a.txt\xff");
564 root_input.extend_from_slice(a_hash.as_bytes());
565 root_input.extend_from_slice(b"b\xff");
566 root_input.extend_from_slice(&b_hash);
567 let root_hash = blake3::derive_key("directory", &root_input);
568 assert_eq!(
570 blake3::Hash::from_bytes(root_hash).to_hex().as_str(),
571 "e508467d129e0d19cefa96527f5f6cb3760530be4d931c527f2818a0dff5d517"
572 );
573
574 let mut tree = super::DirhashTree::default();
576 tree.add_file("a.txt", a_hash).unwrap();
577 tree.add_file("b/c.txt", c_hash).unwrap();
578 tree.add_empty_dir("b/d").unwrap();
579 assert_eq!(tree.hash(), root_hash);
580
581 tree.update_file("b/c.txt", [0; 32].into()).unwrap();
583 assert_ne!(tree.hash(), root_hash);
584 tree.update_file("b/c.txt", c_hash).unwrap();
586 assert_eq!(tree.hash(), root_hash);
587
588 tree.add_empty_dir("b").unwrap(); assert_eq!(tree.hash(), root_hash);
591 tree.add_empty_dir("e").unwrap(); assert_ne!(tree.hash(), root_hash);
594 }
595
596 #[test]
597 fn test_dirhash_path() -> Result<(), super::DirhashError> {
598 let temp_dir = tempfile::tempdir()?;
608 let root = temp_dir.path();
609 fs_err::write(root.join("a.txt"), b"hello")?;
610 fs_err::create_dir(root.join("b"))?;
611 fs_err::write(root.join("b/c.txt"), b"goodbye")?;
612 fs_err::create_dir(root.join("b/d"))?;
613
614 let mut expected = super::DirhashTree::default();
615 expected.add_file("a.txt", blake3::hash(b"hello"))?;
616 expected.add_file("b/c.txt", blake3::hash(b"goodbye"))?;
617 expected.add_empty_dir("b/d")?;
618
619 assert_eq!(super::dirhash_path(root)?, expected.hash());
620 assert_eq!(
623 super::dirhash_path(&root.join("a.txt"))?,
624 blake3::hash(b"hello")
625 );
626 Ok(())
627 }
628
629 #[cfg(unix)]
630 #[test]
631 fn test_dirhash_path_symlinks() -> Result<(), super::DirhashError> {
632 use fs_err::os::unix::fs::symlink;
633
634 let temp_dir = tempfile::tempdir()?;
645 let root = temp_dir.path();
646 fs_err::create_dir(root.join("dir1"))?;
647 fs_err::create_dir(root.join("dir2"))?;
648 fs_err::write(root.join("dir1/file.txt"), b"hello")?;
649 symlink("../dir2", root.join("dir1/dir_link"))?;
650 symlink("../dir1/file.txt", root.join("dir2/file_link"))?;
651
652 let mut in_memory = super::DirhashTree::default();
653 in_memory.add_file("dir1/file.txt", blake3::hash(b"hello"))?;
654 in_memory.add_file("dir1/dir_link/file_link", blake3::hash(b"hello"))?;
655 in_memory.add_file("dir2/file_link", blake3::hash(b"hello"))?;
656 let from_disk = super::dirhash_path(root)?;
657 assert_eq!(in_memory.hash(), from_disk);
658
659 fs_err::create_dir(root.join("dir2/inner"))?;
661 symlink("../../dir1", root.join("dir2/inner/dir_link"))?;
662 let error = super::dirhash_path(root).unwrap_err();
663 assert_matches!(error, super::DirhashError::SymlinkCycle { .. });
664 Ok(())
665 }
666
667 fn paint_input(buf: &mut [u8]) {
670 let mut value = 0u8;
671 for byte in buf {
672 *byte = value;
673 value = if value == 250 { 0 } else { value + 1 };
674 }
675 }
676
677 #[tokio::test]
678 async fn test_blake3_copy() -> io::Result<()> {
679 let input = b"hello";
680 let mut output = Vec::new();
681 let (bytes_read, hash) = super::blake3_copy(&input[..], &mut output).await?;
682 assert_eq!(bytes_read, input.len() as u64);
683 assert_eq!(input, &output[..]);
684 assert_eq!(hash, blake3::hash(input));
685
686 let mut big_input = vec![0; 64_000 * 3];
687 paint_input(&mut big_input);
688 let mut big_output = Vec::new();
689 let (big_bytes_read, big_hash) =
690 super::blake3_copy(&big_input[..], &mut big_output).await?;
691 assert_eq!(big_bytes_read, big_input.len() as u64);
692 assert_eq!(big_input, big_output);
693 assert_eq!(big_hash, blake3::hash(&big_input));
694 Ok(())
695 }
696
697 struct ShortReader<'a>(&'a [u8]);
699
700 impl AsyncRead for ShortReader<'_> {
701 fn poll_read(
702 mut self: Pin<&mut Self>,
703 _cx: &mut Context<'_>,
704 buf: &mut tokio::io::ReadBuf<'_>,
705 ) -> Poll<io::Result<()>> {
706 const SHORT_READ_LEN: usize = 251; let want = cmp::min(self.0.len(), buf.remaining());
708 let take = cmp::min(want, SHORT_READ_LEN);
709 buf.put_slice(&self.0[..take]);
710 self.0 = &self.0[take..];
711 Poll::Ready(Ok(()))
712 }
713 }
714
715 #[tokio::test]
717 async fn test_blake3_copy_short_reader() -> io::Result<()> {
718 let mut input = vec![0; 64_000 * 3];
719 paint_input(&mut input);
720 let mut output = Vec::new();
721 let (bytes_read, hash) = super::blake3_copy(ShortReader(&input), &mut output).await?;
722 assert_eq!(bytes_read, input.len() as u64);
723 assert_eq!(input, &output[..]);
724 assert_eq!(hash, blake3::hash(&input));
725 Ok(())
726 }
727
728 fn walk_test_vector_input(
731 input_dir: &serde_json::Map<String, serde_json::Value>,
732 dirhash_tree: &mut DirhashTree,
733 tempdir: &tempfile::TempDir,
734 relative_path: Option<&str>,
736 ) -> anyhow::Result<()> {
737 for (name, file_or_dir) in input_dir {
738 let entry_path = match relative_path {
741 Some(parent) => &format!("{parent}/{name}"),
742 None => name,
743 };
744 match file_or_dir {
745 serde_json::Value::String(file_text) => {
747 fs_err::write(tempdir.path().join(entry_path), file_text)?;
749 dirhash_tree.add_file(entry_path, blake3::hash(file_text.as_bytes()))?;
751 }
752 serde_json::Value::Object(input_subdir) => {
754 fs_err::create_dir(tempdir.path().join(entry_path))?;
756 dirhash_tree.add_empty_dir(entry_path)?;
760 walk_test_vector_input(input_subdir, dirhash_tree, tempdir, Some(entry_path))?;
762 }
763 _ => panic!("unexpected JSON type"),
764 }
765 }
766 Ok(())
767 }
768
769 #[derive(Debug, serde::Deserialize)]
770 struct JsonTestVector {
771 input: serde_json::Map<String, serde_json::Value>,
773 dirhash: String,
775 }
776
777 #[tokio::test]
782 async fn test_vectors_json() -> anyhow::Result<()> {
783 let test_vectors: Vec<JsonTestVector> =
784 serde_json::from_str(include_str!("../test_vectors/test_vectors.json"))?;
785 for JsonTestVector { input, dirhash } in &test_vectors {
786 let mut tree = DirhashTree::new();
787 let tempdir = tempfile::tempdir()?;
788 walk_test_vector_input(
790 input, &mut tree, &tempdir, None, )?;
792
793 assert_eq!(dirhash.as_str(), tree.hash().to_hex().as_str());
795
796 assert_eq!(
798 dirhash.as_str(),
799 dirhash_path(tempdir.path())?.to_hex().as_str(),
800 );
801
802 let python_script = Path::new(env!("CARGO_MANIFEST_DIR"))
804 .join("test_vectors")
805 .join("dirhash.py");
806 let output = Command::new("uv")
807 .args(["run", "--locked", "--script"])
808 .arg(python_script)
809 .arg(tempdir.path())
810 .output()?;
811 assert!(output.status.success());
812 let python_dirhash = std::str::from_utf8(&output.stdout)?
816 .split_whitespace()
817 .next()
818 .unwrap();
819 assert_eq!(dirhash.as_str(), python_dirhash);
820 }
821 Ok(())
822 }
823}