Skip to main content

uv_extract/
dirhash.rs

1//! Dirhash is a scheme for hashing directory trees, and by extension the contents of archive
2//! files.
3//!
4//! The underlying hash function is BLAKE3, and the dirhash of a file is the regular `blake3::hash`
5//! of its content bytes. To compute the dirhash of a directory, we sort and concatenate its
6//! entries. Each entry has three components, which are also concatenated:
7//!
8//! - the UTF-8 filename or subdirectory name
9//! - a terminator byte, `0xff`, which cannot occur in UTF-8
10//! - the 32-byte dirhash (recursive) of the entry's contents
11//!
12//! To avoid collisions between files and directories, we compute the hash of those sorted,
13//! concatenated directory entries with `blake3::derive_key("directory", ...)`. The implementation
14//! checks that directory entries are sorted and that their names are unique and valid UTF-8. It
15//! also checks that names don't contain `/` and aren't equal to `.` or `..`. Empty directories are
16//! represented as the empty hash rather than omitted (as in Git).
17//!
18//! Symlinks aren't encoded, and we hash symlinks as the files or directories they points to. That
19//! means we can't compute the dirhash of a symlink cycle. When the implementation is reading the
20//! filesystem, it detects cycles and reports an error in that case.
21//!
22//! We don't hash any metadata about a file besides its name. In particular, that means that
23//! (unlike Git) we don't hash the Unix executable bit. Two archives that encode the same files
24//! with different executable bits could have the same dirhash, and its possible that could cause
25//! bugs in some cases. On this other hand, this gives us the property that the dirhash of an
26//! archive is the same as the dirhash of its unpacked files, even if the archive was prepared on
27//! Unix and unpacked on Windows. Note that Python wheel installers [already include
28//! heuristics][heuristics] for these cross-platform problems.
29//!
30//! [heuristics]: https://packaging.python.org/en/latest/specifications/binary-distribution-format/#recommended-installer-features
31//!
32//! There are two separate implementations in this module:
33//!
34//! - [`dirhash_path`] reads a directory tree from the filesystem and hashes it using Rayon.
35//! - [`DirhashTree`] is an in-memory representation of a directory tree, which accepts entries in
36//!   any order. This is intended for inspecting or unpacking archives, so that we can hash file
37//!   bytes while they're in memory instead of extracting the whole tree and reading it back. The
38//!   `blake3_copy` function helps with the common case of extracting a `Read` implementation (like
39//!   `ZipEntryReader`) to a `Write` implementation (like `std::fs::File`).
40//!
41//! # Example
42//!
43//! ```
44//! # use uv_extract::dirhash::{dirhash_path, DirhashTree};
45//! # fn main() -> anyhow::Result<()> {
46//! // Create a small tree of files and hash it by path.
47//! let tempdir = tempfile::tempdir()?;
48//! std::fs::create_dir(tempdir.path().join("a"))?;
49//! std::fs::write(tempdir.path().join("a/b.txt"), b"hello")?;
50//! std::fs::write(tempdir.path().join("c.txt"), b"goodbye")?;
51//! let fs_hash = dirhash_path(tempdir.path())?;
52//!
53//! // Recompute the same hash in memory.
54//! let mut tree = DirhashTree::new();
55//! tree.add_file("a/b.txt", blake3::hash(b"hello"))?;
56//! tree.add_file("c.txt", blake3::hash(b"goodbye"))?;
57//! let in_memory_hash = tree.hash();
58//!
59//! // Both approaches should give the same result.
60//! assert_eq!(fs_hash, in_memory_hash);
61//! # Ok(())
62//! # }
63//! ```
64use 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(crate) use archive::UnzipOutput;
78pub(crate) use archive::directory_tree_from_extracted;
79pub use archive::{DirectoryDigest, HashedFile, UnhashedFile};
80pub(crate) use seek::{unzip, unzip_and_hash};
81
82// Read repeatedly until the whole buffer is full, similar to `read_exact`. But if EOF is
83// encountered, return `Ok(n)` with a short length instead of reporting an error.
84async fn read_exact_or_eof(
85    mut reader: Pin<&mut impl AsyncRead>,
86    mut buf: &mut [u8],
87) -> io::Result<usize> {
88    let mut bytes_read = 0;
89    loop {
90        match reader.read(buf).await {
91            Ok(0) => return Ok(bytes_read),
92            Ok(n) => {
93                bytes_read += n;
94                if n == buf.len() {
95                    return Ok(bytes_read);
96                }
97                buf = &mut buf[n..];
98            }
99            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
100            Err(e) => return Err(e),
101        }
102    }
103}
104
105/// Copy all the bytes from an async reader to an async writer while computing their BLAKE3 hash.
106/// This uses the same buffer for reading, writing, and hashing, to avoid unnecessary re-reads or
107/// intermediate copies. Return the number of bytes copied and the resulting hash.
108pub async fn blake3_copy<R, W>(reader: R, writer: W) -> io::Result<(u64, blake3::Hash)>
109where
110    R: AsyncRead,
111    W: AsyncWrite,
112{
113    blake3_copy_with_buffer(reader, writer, &mut Vec::new()).await
114}
115
116/// Copy and hash bytes with a reusable 64 KiB buffer, allocating it on the first call.
117pub(crate) async fn blake3_copy_with_buffer<R, W>(
118    reader: R,
119    writer: W,
120    buffer: &mut Vec<u8>,
121) -> io::Result<(u64, blake3::Hash)>
122where
123    R: AsyncRead,
124    W: AsyncWrite,
125{
126    let mut reader = pin!(reader);
127    let mut writer = pin!(writer);
128    let mut hasher = blake3::Hasher::new();
129    buffer.resize(1 << 16, 0); // 64 KiB
130    let mut total = 0u64;
131    // BLAKE3 is fastest when hashing power-of-two sized buffers. That maximizes the time we spend
132    // in the wide SIMD part of the implementation (which wants between 4 and 16 KiB at a time
133    // depending on the platform) and minimizes the time we spend in the slower part that handles
134    // short inputs. Hash as many full 64 KiB buffers as we can, and then one possibly-short buffer
135    // when we reach EOF.
136    loop {
137        let bytes_read = read_exact_or_eof(reader.as_mut(), buffer).await?;
138        if bytes_read == 0 {
139            break; // EOF reached with no bytes. Skip unnecessary calls to `update` and `write_all`.
140        }
141        total += bytes_read as u64;
142        let bytes = &buffer[..bytes_read];
143        hasher.update(bytes);
144        writer.write_all(bytes).await?;
145        if bytes_read < buffer.len() {
146            break; // EOF
147        }
148    }
149    writer.flush().await?;
150    Ok((total, hasher.finalize()))
151}
152
153#[derive(Debug, thiserror::Error)]
154pub enum DirhashError {
155    #[error("Invalid path for directory hashing: {path:?}")]
156    InvalidPath { path: PathBuf },
157    #[error("Archive path is missing from the directory hash tree: {path:?}")]
158    MissingPath { path: PathBuf },
159    #[error("Archive contains duplicate entries for path: {path:?}")]
160    DuplicatePath { path: PathBuf },
161    #[error("Archive path is used as both a file and a directory: {path:?}")]
162    FileDirectoryConflict { path: PathBuf },
163    #[error("Encountered a symlink cycle while hashing a directory: {paths:?}")]
164    SymlinkCycle { paths: Vec<PathBuf> },
165    #[error(transparent)]
166    Io(#[from] io::Error),
167}
168
169// Seen symlinks form a linked list on the stack as we recurse.
170struct SeenSymlinkNode<'a> {
171    canonical_path: PathBuf,
172    previous: Option<&'a Self>,
173}
174
175struct SeenSymlinks<'a> {
176    node: Option<SeenSymlinkNode<'a>>,
177}
178
179impl<'a> SeenSymlinks<'a> {
180    fn new() -> Self {
181        Self { node: None }
182    }
183
184    fn iter(&self) -> impl Iterator<Item = &Path> {
185        let mut node = self.node.as_ref();
186        std::iter::from_fn(move || {
187            if let Some(next_node) = node {
188                let next_path = &next_node.canonical_path;
189                node = next_node.previous;
190                Some(next_path.as_path())
191            } else {
192                None
193            }
194        })
195    }
196
197    fn push(&'a self, symlink_path: &Path) -> Result<Self, DirhashError> {
198        let canonical_path = canonical_path_to_symlink(symlink_path)?;
199        // Walk the seen symlinks list and error out if we've seen this one before.
200        for seen in self.iter() {
201            if canonical_path == seen {
202                let mut paths: Vec<PathBuf> = self.iter().map(Path::to_owned).collect();
203                paths.reverse();
204                paths.push(canonical_path);
205                return Err(DirhashError::SymlinkCycle { paths });
206            }
207        }
208        Ok(Self {
209            node: Some(SeenSymlinkNode {
210                canonical_path,
211                previous: self.node.as_ref(),
212            }),
213        })
214    }
215}
216
217// The canonical path *to a link itself*, not the canonical path the link *points to*. For a
218// regular file or directory, this is the same as its canonical path.
219fn canonical_path_to_symlink(symlink_path: &Path) -> Result<PathBuf, DirhashError> {
220    let Some(filename) = symlink_path.file_name() else {
221        return Err(DirhashError::InvalidPath {
222            path: symlink_path.to_path_buf(),
223        });
224    };
225    let parent = symlink_path
226        .parent()
227        .filter(|parent| !parent.as_os_str().is_empty())
228        .unwrap_or(Path::new("."));
229    Ok(fs_err::canonicalize(parent)?.join(filename))
230}
231
232/// Compute the dirhash of a file or directory tree on disk.
233///
234/// `path` itself can be any existing path on the system, and it's not required to be valid
235/// Unicode. However, if `path` is a directory, its contents need to have Unicode names, otherwise
236/// `dirhash_path` returns an error.
237///
238/// `dirhash_path` will traverse symlinks, including links that lead outside of `path`. However, if
239/// it encounters a symlink cycle, it will return an error.
240pub fn dirhash_path(path: &Path) -> Result<blake3::Hash, DirhashError> {
241    uv_configuration::initialize_rayon_once();
242    let seen_symlinks = SeenSymlinks::new();
243    dirhash_path_inner(path, &seen_symlinks)
244}
245
246// Recurse to compute a dirhash, handling symlink cycles.
247fn dirhash_path_inner(
248    path: &Path,
249    seen_symlinks: &SeenSymlinks,
250) -> Result<blake3::Hash, DirhashError> {
251    let metadata = fs_err::symlink_metadata(path)?;
252    if metadata.is_symlink() {
253        let seen_symlinks = seen_symlinks.push(path)?;
254        dirhash_path_inner_resolved(path, &fs_err::metadata(path)?, &seen_symlinks)
255    } else {
256        dirhash_path_inner_resolved(path, &metadata, seen_symlinks)
257    }
258}
259
260// Recurse to compute a dirhash, after symlinks are resolved.
261fn dirhash_path_inner_resolved(
262    path: &Path,
263    metadata: &std::fs::Metadata,
264    seen_symlinks: &SeenSymlinks,
265) -> Result<blake3::Hash, DirhashError> {
266    if metadata.is_dir() {
267        // This is a directory. Recurse over its contents.
268        let mut dir_contents = Vec::new();
269        for entry in fs_err::read_dir(path)? {
270            let entry = entry?;
271            let path = entry.path();
272            // Prior components of the `path` can be non-Unicode, but names in the hashed directory
273            // tree are required to be Unicode, otherwise we report an error.
274            let Ok(name) = entry.file_name().into_string() else {
275                return Err(DirhashError::InvalidPath { path });
276            };
277            dir_contents.push((name, path));
278        }
279        // Sort the directory contents by name, in lexicographic/UTF-8 order.
280        dir_contents.sort_unstable();
281        // Iterate over the contents in parallel using Rayon, hashing each one recursively.
282        let hashes = dir_contents
283            .par_iter()
284            // Recurse back to `dirhash_path_inner` for symlink handling.
285            .map(|(_, path)| dirhash_path_inner(path, seen_symlinks))
286            .collect::<Result<Vec<blake3::Hash>, _>>()?;
287        let dirhash_entries = dir_contents
288            .iter()
289            .zip(hashes)
290            .map(|((name, _), hash)| (name.as_str(), hash));
291        Ok(hash_dir_entries(dirhash_entries))
292    } else {
293        // This is not a directory, so treat it like a file and hash it. `update_mmap_rayon` shares
294        // the same thread pool as `par_iter` above.
295        Ok(blake3::Hasher::new().update_mmap_rayon(path)?.finalize())
296    }
297}
298
299#[derive(Debug, Clone)]
300enum DirhashEntry {
301    File(blake3::Hash),
302    Directory(DirhashTree),
303}
304
305/// An in-memory directory structure for computing a dirhash from an archive as we unpack it, when
306/// the entries might come out in any order.
307///
308/// See the [module-level example](index.html#example) for usage.
309#[derive(Debug, Clone, Default)]
310pub struct DirhashTree {
311    children: BTreeMap<String, DirhashEntry>,
312}
313
314impl DirhashTree {
315    /// Create an new, empty `DirhashTree`.
316    pub fn new() -> Self {
317        Self::default()
318    }
319
320    fn insertion_entry(
321        &mut self,
322        normalized_path: &str,
323        original_path: &str,
324        create_dirs: bool,
325    ) -> Result<Entry<'_, String, DirhashEntry>, DirhashError> {
326        if let Some((component, rest)) = normalized_path.split_once('/') {
327            // There are further path components after this one, so this one is a directory.
328            if self.children.contains_key(component) {
329                // This entry already exists.
330                //
331                // We have to do a double lookup here because of borrowck limitations. The
332                // alternative is using the `.entry()` API and always allocating a temporary
333                // `String` key. Polonius can't come soon enough, but also `BTreeMap` needs a "raw
334                // entry" API.
335                match self.children.get_mut(component).unwrap() {
336                    DirhashEntry::Directory(child) => {
337                        child.insertion_entry(rest, original_path, create_dirs)
338                    }
339                    DirhashEntry::File(_) => Err(DirhashError::FileDirectoryConflict {
340                        path: PathBuf::from(original_path),
341                    }),
342                }
343            } else {
344                // We need to create this directory, or error if `create_dirs` is false.
345                if create_dirs {
346                    let child = self
347                        .children
348                        .entry(String::from(component))
349                        .or_insert(DirhashEntry::Directory(Self::default()));
350                    let DirhashEntry::Directory(child) = child else {
351                        unreachable!()
352                    };
353                    child.insertion_entry(rest, original_path, create_dirs)
354                } else {
355                    Err(DirhashError::MissingPath {
356                        path: PathBuf::from(original_path),
357                    })
358                }
359            }
360        } else {
361            // This is the final path component.
362            Ok(self.children.entry(String::from(normalized_path)))
363        }
364    }
365
366    /// Add a pre-hashed file to the tree. It's an error if the filepath already exists.
367    ///
368    /// `path` is a Unix-style, `/`-separated relative path. This is the format used in ZIP
369    /// archives. Trailing slashes are ignored.
370    ///
371    /// This function creates parent directories as needed.
372    ///
373    /// The `hash` of a file is the standard [`blake3::hash`] of its contents. If the file is
374    /// already on disk, the typical way to hash it is either [`blake3::Hasher::update_reader`]
375    /// (ordinary serial reads) or [`blake3::Hasher::update_mmap_rayon`] (memory mapping plus
376    /// multithreading). However, note that if you're also writing the file to disk, it's more
377    /// efficient to hash it as you write it than to read it back again. See [`blake3_copy`].
378    pub fn add_file(&mut self, path: &str, hash: blake3::Hash) -> Result<(), DirhashError> {
379        let normalized_path = normalize_dirhash_path(path)?;
380        let entry = self.insertion_entry(&normalized_path, path, true)?;
381        match entry {
382            Entry::Vacant(vacant) => {
383                vacant.insert(DirhashEntry::File(hash));
384                Ok(())
385            }
386            Entry::Occupied(_) => Err(DirhashError::DuplicatePath {
387                path: PathBuf::from(path),
388            }),
389        }
390    }
391
392    /// Update the hash of a file in the tree. It's an error if the filepath doesn't exist or if it
393    /// refers to a directory.
394    ///
395    /// `path` is a Unix-style, `/`-separated relative path. This is the format used in ZIP
396    /// archives. Trailing slashes are ignored.
397    ///
398    /// The separation between [`add_file`](Self::add_file) and `update_file` is intended to catch
399    /// cases where an archive has duplicate entries for the same filepath. Those cases should
400    /// ideally produce an error during unpacking, rather than arbitrarily picking a winner.
401    /// `update_file` is intended for callers who are deliberately changing the contents of a path
402    /// that already exists.
403    pub fn update_file(&mut self, path: &str, hash: blake3::Hash) -> Result<(), DirhashError> {
404        let normalized_path = normalize_dirhash_path(path)?;
405        let entry = self.insertion_entry(&normalized_path, path, false)?;
406        match entry {
407            Entry::Vacant(_) => Err(DirhashError::MissingPath {
408                path: PathBuf::from(path),
409            }),
410            Entry::Occupied(mut occupied) => match occupied.get_mut() {
411                DirhashEntry::File(prev_hash) => {
412                    *prev_hash = hash;
413                    Ok(())
414                }
415                DirhashEntry::Directory(_) => Err(DirhashError::FileDirectoryConflict {
416                    path: PathBuf::from(path),
417                }),
418            },
419        }
420    }
421
422    /// Add an empty directory to the tree. This succeeds if the directory already exists, but it's
423    /// an error if the path refers to a file.
424    ///
425    /// `path` is a Unix-style, `/`-separated relative path. This is the format used in ZIP
426    /// archives. Trailing slashes are ignored.
427    ///
428    /// This function creates parent directories as needed.
429    pub fn add_empty_dir(&mut self, path: &str) -> Result<(), DirhashError> {
430        let normalized_path = normalize_dirhash_path(path)?;
431        let entry = self.insertion_entry(&normalized_path, path, true)?;
432        match entry {
433            Entry::Vacant(vacant) => {
434                vacant.insert(DirhashEntry::Directory(Self::default()));
435                Ok(())
436            }
437            Entry::Occupied(occupied) => match occupied.get() {
438                DirhashEntry::Directory(_) => Ok(()),
439                DirhashEntry::File(_) => Err(DirhashError::FileDirectoryConflict {
440                    path: PathBuf::from(path),
441                }),
442            },
443        }
444    }
445
446    /// Compute the root dirhash of the assembled tree.
447    ///
448    /// This method is idempotent, and you can call it again after adding more files or updating
449    /// existing ones. However, `DirhashTree` doesn't currently cache the subtree hashes of
450    /// unchanged directories, so calling `hash` repeatedly can be wasteful.
451    pub fn hash(&self) -> blake3::Hash {
452        hash_dir_entries(self.children.iter().map(|(name, entry)| {
453            let hash = match entry {
454                DirhashEntry::File(hash) => *hash,
455                DirhashEntry::Directory(child) => child.hash(),
456            };
457            (name.as_str(), hash)
458        }))
459    }
460}
461
462fn component_needs_normalization(component: &str) -> bool {
463    matches!(component, "" | "." | "..")
464}
465
466fn normalize_dirhash_path(mut path: &str) -> Result<Cow<'_, str>, DirhashError> {
467    if path.starts_with('/') {
468        return Err(DirhashError::InvalidPath {
469            path: PathBuf::from(path),
470        });
471    }
472    path = path.trim_start_matches("./");
473    path = path.trim_end_matches('/');
474    if !path.split('/').any(component_needs_normalization) {
475        return Ok(Cow::Borrowed(path));
476    }
477    let mut components = Vec::new();
478    for component in path.split('/') {
479        match component {
480            "" | "." => {}
481            ".." => {
482                if components.pop().is_none() {
483                    return Err(DirhashError::InvalidPath {
484                        path: PathBuf::from(path),
485                    });
486                }
487            }
488            component => components.push(component),
489        }
490    }
491    if components.is_empty() {
492        return Err(DirhashError::InvalidPath {
493            path: PathBuf::from(path),
494        });
495    }
496    Ok(Cow::Owned(components.join("/")))
497}
498
499fn hash_dir_entries<'a, Iter>(entries: Iter) -> blake3::Hash
500where
501    Iter: IntoIterator<Item = (&'a str, blake3::Hash)>,
502{
503    // File hashes are the normal BLAKE3 hash of the file's contents. A directory hash shouldn't
504    // collide with a file hash, no matter what bytes the file happens contain. BLAKE3's derive-key
505    // mode with a context string guarantees that.
506    let mut hasher = blake3::Hasher::new_derive_key("directory");
507    for (name, hash) in entries {
508        hasher.update(name.as_bytes());
509        hasher.update(&[0xff]);
510        hasher.update(hash.as_bytes());
511    }
512    hasher.finalize()
513}
514
515#[cfg(test)]
516mod tests {
517    #[cfg(unix)]
518    use std::assert_matches;
519
520    use super::*;
521    use std::cmp;
522    use std::process::Command;
523    use std::task::{Context, Poll};
524
525    #[test]
526    fn test_normalize() {
527        let success_cases = [
528            ("foo", Cow::Borrowed("foo")),
529            ("foo", Cow::Borrowed("foo")),
530            ("foo/", Cow::Borrowed("foo")),
531            ("./foo", Cow::Borrowed("foo")),
532            ("././foo/bar///", Cow::Borrowed("foo/bar")),
533            ("foo//bar", Cow::Owned("foo/bar".to_string())),
534            ("foo/./bar", Cow::Owned("foo/bar".to_string())),
535            ("foo/.///./bar", Cow::Owned("foo/bar".to_string())),
536            ("foo/bar/..", Cow::Owned("foo".to_string())),
537            ("foo/bar/../../baz", Cow::Owned("baz".to_string())),
538        ];
539        for (path, expected) in success_cases {
540            let normalized = super::normalize_dirhash_path(path).unwrap();
541            assert_eq!(normalized, expected);
542        }
543        let error_cases = [
544            "",
545            "/",
546            "/foo",
547            "///foo",
548            "..",
549            "foo/..",
550            "foo/bar/../../../baz",
551        ];
552        for path in error_cases {
553            super::normalize_dirhash_path(path).unwrap_err();
554        }
555    }
556
557    #[test]
558    fn test_add_update_and_add_empty_dir() {
559        // Hash the following tree:
560        //
561        // a.txt      <-- "hello"
562        // b
563        // ├── c.txt  <-- "goodbye"
564        // └── d      <-- empty dir
565        //
566        // First, assemble the whole hash tree manually.
567        let a_hash = blake3::hash(b"hello");
568        let c_hash = blake3::hash(b"goodbye");
569        let d_hash = blake3::derive_key("directory", b"");
570        let mut b_input = Vec::new();
571        b_input.extend_from_slice(b"c.txt\xff");
572        b_input.extend_from_slice(c_hash.as_bytes());
573        b_input.extend_from_slice(b"d\xff");
574        b_input.extend_from_slice(&d_hash);
575        let b_hash = blake3::derive_key("directory", &b_input);
576        let mut root_input = Vec::new();
577        root_input.extend_from_slice(b"a.txt\xff");
578        root_input.extend_from_slice(a_hash.as_bytes());
579        root_input.extend_from_slice(b"b\xff");
580        root_input.extend_from_slice(&b_hash);
581        let root_hash = blake3::derive_key("directory", &root_input);
582        // Pin the specific value of the dirhash. TODO: a full set of test vectors
583        assert_eq!(
584            blake3::Hash::from_bytes(root_hash).to_hex().as_str(),
585            "e508467d129e0d19cefa96527f5f6cb3760530be4d931c527f2818a0dff5d517"
586        );
587
588        // Now, confirm that `DirhashTree` gives the same answer.
589        let mut tree = super::DirhashTree::default();
590        tree.add_file("a.txt", a_hash).unwrap();
591        tree.add_file("b/c.txt", c_hash).unwrap();
592        tree.add_empty_dir("b/d").unwrap();
593        assert_eq!(tree.hash(), root_hash);
594
595        // Changing the hash of a file changes the root hash.
596        tree.update_file("b/c.txt", [0; 32].into()).unwrap();
597        assert_ne!(tree.hash(), root_hash);
598        // But we can change it back and recover the original.
599        tree.update_file("b/c.txt", c_hash).unwrap();
600        assert_eq!(tree.hash(), root_hash);
601
602        // Reinserting an existing empty directory is a no-op.
603        tree.add_empty_dir("b").unwrap(); // no-op
604        assert_eq!(tree.hash(), root_hash);
605        // But inserting a new empty directory changes the hash.
606        tree.add_empty_dir("e").unwrap(); // no-op
607        assert_ne!(tree.hash(), root_hash);
608    }
609
610    #[test]
611    fn test_dirhash_path() -> Result<(), super::DirhashError> {
612        // Hash the following tree:
613        //
614        // a.txt      <-- "hello"
615        // b
616        // ├── c.txt  <-- "goodbye"
617        // └── d      <-- empty dir
618        //
619        // Compare both `DirhashTree` (in memory) and `dirhash_path` (on disk) to make sure we get
620        // the same hash from both.
621        let temp_dir = tempfile::tempdir()?;
622        let root = temp_dir.path();
623        fs_err::write(root.join("a.txt"), b"hello")?;
624        fs_err::create_dir(root.join("b"))?;
625        fs_err::write(root.join("b/c.txt"), b"goodbye")?;
626        fs_err::create_dir(root.join("b/d"))?;
627
628        let mut expected = super::DirhashTree::default();
629        expected.add_file("a.txt", blake3::hash(b"hello"))?;
630        expected.add_file("b/c.txt", blake3::hash(b"goodbye"))?;
631        expected.add_empty_dir("b/d")?;
632
633        assert_eq!(super::dirhash_path(root)?, expected.hash());
634        // Asking for the dirhash of a file is also valid, and it's equivalent to the regular
635        // BLAKE3 hash.
636        assert_eq!(
637            super::dirhash_path(&root.join("a.txt"))?,
638            blake3::hash(b"hello")
639        );
640        Ok(())
641    }
642
643    #[cfg(unix)]
644    #[test]
645    fn test_dirhash_path_symlinks() -> Result<(), super::DirhashError> {
646        use fs_err::os::unix::fs::symlink;
647
648        // Start with the following tree, which does not have a cycle:
649        //
650        // dir1
651        // ├── file.txt  <-- "hello"
652        // └── dir_link  <-- ../dir2
653        // dir2
654        // └── file_link <-- ../dir1/file.txt
655        //
656        // Make sure we get the same answer from both `DirhashTree` (in memory) and `dirhash_path`
657        // (on disk).
658        let temp_dir = tempfile::tempdir()?;
659        let root = temp_dir.path();
660        fs_err::create_dir(root.join("dir1"))?;
661        fs_err::create_dir(root.join("dir2"))?;
662        fs_err::write(root.join("dir1/file.txt"), b"hello")?;
663        symlink("../dir2", root.join("dir1/dir_link"))?;
664        symlink("../dir1/file.txt", root.join("dir2/file_link"))?;
665
666        let mut in_memory = super::DirhashTree::default();
667        in_memory.add_file("dir1/file.txt", blake3::hash(b"hello"))?;
668        in_memory.add_file("dir1/dir_link/file_link", blake3::hash(b"hello"))?;
669        in_memory.add_file("dir2/file_link", blake3::hash(b"hello"))?;
670        let from_disk = super::dirhash_path(root)?;
671        assert_eq!(in_memory.hash(), from_disk);
672
673        // Now add another symlink to make a proper cycle. This should error.
674        fs_err::create_dir(root.join("dir2/inner"))?;
675        symlink("../../dir1", root.join("dir2/inner/dir_link"))?;
676        let error = super::dirhash_path(root).unwrap_err();
677        assert_matches!(error, super::DirhashError::SymlinkCycle { .. });
678        Ok(())
679    }
680
681    /// Write a test input byte pattern that doesn't repeat at regular power-of-two boundaries.
682    /// This is more likely to catch mistakes than hashing a buffer of e.g. all zeros.
683    fn paint_input(buf: &mut [u8]) {
684        let mut value = 0u8;
685        for byte in buf {
686            *byte = value;
687            value = if value == 250 { 0 } else { value + 1 };
688        }
689    }
690
691    #[tokio::test]
692    async fn test_blake3_copy() -> io::Result<()> {
693        let input = b"hello";
694        let mut output = Vec::new();
695        let (bytes_read, hash) = super::blake3_copy(&input[..], &mut output).await?;
696        assert_eq!(bytes_read, input.len() as u64);
697        assert_eq!(input, &output[..]);
698        assert_eq!(hash, blake3::hash(input));
699
700        let mut big_input = vec![0; 64_000 * 3];
701        paint_input(&mut big_input);
702        let mut big_output = Vec::new();
703        let (big_bytes_read, big_hash) =
704            super::blake3_copy(&big_input[..], &mut big_output).await?;
705        assert_eq!(big_bytes_read, big_input.len() as u64);
706        assert_eq!(big_input, big_output);
707        assert_eq!(big_hash, blake3::hash(&big_input));
708        Ok(())
709    }
710
711    #[tokio::test]
712    async fn test_blake3_copy_reuses_buffer() -> io::Result<()> {
713        let mut input = vec![0; 64_000 * 3];
714        paint_input(&mut input);
715        let mut buffer = Vec::new();
716        for input in [input.as_slice(), b"hello", b""] {
717            let mut output = Vec::new();
718            let (bytes_read, hash) =
719                super::blake3_copy_with_buffer(input, &mut output, &mut buffer).await?;
720            assert_eq!(bytes_read, input.len() as u64);
721            assert_eq!(input, output);
722            assert_eq!(hash, blake3::hash(input));
723        }
724        Ok(())
725    }
726
727    /// A reader that always returns short reads, even if it holds lots of input.
728    struct ShortReader<'a>(&'a [u8]);
729
730    impl AsyncRead for ShortReader<'_> {
731        fn poll_read(
732            mut self: Pin<&mut Self>,
733            _cx: &mut Context<'_>,
734            buf: &mut tokio::io::ReadBuf<'_>,
735        ) -> Poll<io::Result<()>> {
736            const SHORT_READ_LEN: usize = 251; // any small prime will do
737            let want = cmp::min(self.0.len(), buf.remaining());
738            let take = cmp::min(want, SHORT_READ_LEN);
739            buf.put_slice(&self.0[..take]);
740            self.0 = &self.0[take..];
741            Poll::Ready(Ok(()))
742        }
743    }
744
745    /// Exercise the buffer filling logic with a reader that always returns short reads.
746    #[tokio::test]
747    async fn test_blake3_copy_short_reader() -> io::Result<()> {
748        let mut input = vec![0; 64_000 * 3];
749        paint_input(&mut input);
750        let mut output = Vec::new();
751        let (bytes_read, hash) = super::blake3_copy(ShortReader(&input), &mut output).await?;
752        assert_eq!(bytes_read, input.len() as u64);
753        assert_eq!(input, &output[..]);
754        assert_eq!(hash, blake3::hash(&input));
755        Ok(())
756    }
757
758    // Populate both a `DirhashTree` and a temp dir on the filesystem, by recursively walking the
759    // input tree of a JSON test vector. See `../test_vectors/test_vectors.json`.
760    fn walk_test_vector_input(
761        input_dir: &serde_json::Map<String, serde_json::Value>,
762        dirhash_tree: &mut DirhashTree,
763        tempdir: &tempfile::TempDir,
764        // The relative path starts as `None` and grows as we descend recursively into the input tree.
765        relative_path: Option<&str>,
766    ) -> anyhow::Result<()> {
767        for (name, file_or_dir) in input_dir {
768            // Use Unix-style forward slashes for the relative path, because that's what
769            // `DirhashTree` expects. `Path::join` can handle these, even on Windows.
770            let entry_path = match relative_path {
771                Some(parent) => &format!("{parent}/{name}"),
772                None => name,
773            };
774            match file_or_dir {
775                // a file
776                serde_json::Value::String(file_text) => {
777                    // Write this file under the temp dir.
778                    fs_err::write(tempdir.path().join(entry_path), file_text)?;
779                    // Add this file as an entry in the `DirhashTree`.
780                    dirhash_tree.add_file(entry_path, blake3::hash(file_text.as_bytes()))?;
781                }
782                // a subdirectory
783                serde_json::Value::Object(input_subdir) => {
784                    // Create this directory under the temp dir.
785                    fs_err::create_dir(tempdir.path().join(entry_path))?;
786                    // Non-empty subdirs get added to the `DirhashTree` automatically when we
787                    // populate their contents, but there's no harm in calling `add_empty_dir` for
788                    // every directory.
789                    dirhash_tree.add_empty_dir(entry_path)?;
790                    // Recurse!
791                    walk_test_vector_input(input_subdir, dirhash_tree, tempdir, Some(entry_path))?;
792                }
793                _ => panic!("unexpected JSON type"),
794            }
795        }
796        Ok(())
797    }
798
799    #[derive(Debug, serde::Deserialize)]
800    struct JsonTestVector {
801        // A tree of directories and the files they contain.
802        input: serde_json::Map<String, serde_json::Value>,
803        // The hexadecimal dirhash of the input tree.
804        dirhash: String,
805    }
806
807    // `../test_vectors/test_vectors.json` contains a series of input trees and hashes, which is
808    // generated by `cargo dev generate-dirhash-test-vectors`. This tests both `dirhash_path` and
809    // `DirhashTree` from this file, and also the `dirhash.py` Python implementation, so we're
810    // testing that three different implementations agree.
811    #[tokio::test]
812    async fn test_vectors_json() -> anyhow::Result<()> {
813        let test_vectors: Vec<JsonTestVector> =
814            serde_json::from_str(include_str!("../test_vectors/test_vectors.json"))?;
815        for JsonTestVector { input, dirhash } in &test_vectors {
816            let mut tree = DirhashTree::new();
817            let tempdir = tempfile::tempdir()?;
818            // `walk_test_vector_input` populates both the `DirhashTree` and the temp dir on disk.
819            walk_test_vector_input(
820                input, &mut tree, &tempdir, None, /* the relative path starts empty */
821            )?;
822
823            // Check the root dirhash from the `DirhashTree`.
824            assert_eq!(dirhash.as_str(), tree.hash().to_hex().as_str());
825
826            // Check `dirhash_path`.
827            assert_eq!(
828                dirhash.as_str(),
829                dirhash_path(tempdir.path())?.to_hex().as_str(),
830            );
831
832            // Check the Python implementation, which also reads the FS.
833            let python_script = Path::new(env!("CARGO_MANIFEST_DIR"))
834                .join("test_vectors")
835                .join("dirhash.py");
836            let output = Command::new("uv")
837                .args(["run", "--locked", "--script"])
838                .arg(python_script)
839                .arg(tempdir.path())
840                .output()?;
841            assert!(output.status.success());
842            // This script's output is formatted like `md5sum` or `b3sum`, with each line including
843            // a hash, a couple spaces, and a path. With only one path arg, there will be only one
844            // line.
845            let python_dirhash = std::str::from_utf8(&output.stdout)?
846                .split_whitespace()
847                .next()
848                .unwrap();
849            assert_eq!(dirhash.as_str(), python_dirhash);
850        }
851        Ok(())
852    }
853}