Skip to main content

uv_extract/dirhash/
archive.rs

1//! Content-addressed identities for extracted wheel archives.
2
3use std::path::{Path, PathBuf};
4
5use super::{DirhashError, DirhashTree};
6use crate::archive_path::SanitizedArchivePath;
7
8const DIRECTORY_DIGEST_LENGTH: usize = 24;
9const BASE36_ALPHABET: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
10const BASE36_RADIX: u16 = 36;
11
12/// Files extracted with or without content hashes.
13pub(crate) enum UnzipOutput {
14    Unhashed(Vec<UnhashedFile>),
15    Hashed {
16        files: Vec<HashedFile>,
17        tree: DirhashTree,
18    },
19}
20
21/// A path-safe encoding of the directory hash of an extracted wheel.
22///
23/// The underlying [`DirhashTree`] includes normalized relative paths, file contents, and empty
24/// directories. It intentionally uses the shared dirhash scheme directly, so executable permissions
25/// are not currently part of the archive identity.
26///
27/// The digest is formatted as 24 lowercase base-36 characters, providing approximately 124 bits
28/// of output entropy. Its alphabet is safe for case-insensitive filesystems.
29#[derive(Debug, Clone, Eq, PartialEq)]
30pub struct DirectoryDigest(String);
31
32impl DirectoryDigest {
33    /// Return the complete path-safe digest string.
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39impl From<blake3::Hash> for DirectoryDigest {
40    fn from(hash: blake3::Hash) -> Self {
41        Self(encode_digest(&hash))
42    }
43}
44
45impl From<DirectoryDigest> for String {
46    fn from(digest: DirectoryDigest) -> Self {
47        digest.0
48    }
49}
50
51/// The path and size of an extracted file, without a content hash.
52#[derive(Debug, Clone, Eq, PartialEq)]
53pub struct UnhashedFile {
54    path: PathBuf,
55    size: u64,
56}
57
58impl UnhashedFile {
59    pub(crate) fn new(path: PathBuf, size: u64) -> Self {
60        Self { path, size }
61    }
62
63    /// Return the path of the extracted file within the archive.
64    pub fn path(&self) -> &Path {
65        &self.path
66    }
67
68    /// Return the size of the extracted file in bytes.
69    pub fn size(&self) -> u64 {
70        self.size
71    }
72}
73
74/// A file extracted from an archive, along with its content-addressing metadata.
75#[derive(Debug, Clone, Eq, PartialEq)]
76pub struct HashedFile {
77    path: SanitizedArchivePath,
78    size: u64,
79    digest: blake3::Hash,
80    executable: bool,
81}
82
83impl HashedFile {
84    pub(crate) fn new(
85        path: SanitizedArchivePath,
86        size: u64,
87        digest: blake3::Hash,
88        executable: bool,
89    ) -> Self {
90        let executable = executable
91            || (cfg!(windows)
92                && path
93                    .as_path()
94                    .extension()
95                    .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")));
96        Self {
97            path,
98            size,
99            digest,
100            executable,
101        }
102    }
103
104    /// Return the path of the extracted file within the archive.
105    pub fn path(&self) -> &Path {
106        self.path.as_path()
107    }
108
109    /// Return whether the archive marks this file executable, or it is a Windows executable.
110    pub fn is_executable(&self) -> bool {
111        self.executable
112    }
113
114    /// Return a hex-encoded digest of the file's contents and executable status.
115    ///
116    /// This identifies shared file objects without changing the content digest used in directory
117    /// hashes.
118    pub fn object_digest_hex(&self) -> String {
119        let mut hasher = blake3::Hasher::new_derive_key("uv archive file v0");
120        hasher.update(self.digest.as_bytes());
121        hasher.update(&[u8::from(self.is_executable())]);
122        hasher.finalize().to_hex().to_string()
123    }
124
125    /// Return the size of the extracted file in bytes.
126    pub fn size(&self) -> u64 {
127        self.size
128    }
129}
130
131/// Build the shared directory hash tree from extracted file and directory entries.
132pub(crate) fn directory_tree_from_extracted<'a>(
133    files: &[HashedFile],
134    directories: impl IntoIterator<Item = &'a SanitizedArchivePath>,
135) -> Result<DirhashTree, DirhashError> {
136    let mut tree = DirhashTree::default();
137
138    for directory in directories {
139        let path = digest_path(directory);
140        if !path.is_empty() {
141            tree.add_empty_dir(&path)?;
142        }
143    }
144
145    for file in files {
146        tree.add_file(&digest_path(&file.path), file.digest)?;
147    }
148
149    Ok(tree)
150}
151
152/// Format a sanitized archive path with platform-independent separators.
153fn digest_path(path: &SanitizedArchivePath) -> String {
154    let mut normalized = String::new();
155    for component in path.as_path() {
156        if !normalized.is_empty() {
157            normalized.push('/');
158        }
159        normalized.push_str(&component.to_string_lossy());
160    }
161    normalized
162}
163
164fn encode_digest(digest: &blake3::Hash) -> String {
165    let mut value = *digest.as_bytes();
166    let mut encoded = [b'0'; DIRECTORY_DIGEST_LENGTH];
167
168    for digit in encoded.iter_mut().rev() {
169        let mut remainder = 0u16;
170        for byte in &mut value {
171            let dividend = (remainder << 8) | u16::from(*byte);
172            let quotient = dividend / BASE36_RADIX;
173            debug_assert!(u8::try_from(quotient).is_ok());
174            *byte = quotient.to_le_bytes()[0];
175            remainder = dividend % BASE36_RADIX;
176        }
177        *digit = BASE36_ALPHABET[usize::from(remainder)];
178    }
179
180    encoded.into_iter().map(char::from).collect()
181}
182
183#[cfg(test)]
184mod tests {
185    use crate::Error;
186    use crate::archive_path::SanitizedArchivePath;
187
188    use super::{
189        DIRECTORY_DIGEST_LENGTH, DirectoryDigest, HashedFile, digest_path,
190        directory_tree_from_extracted,
191    };
192
193    #[test]
194    fn directory_digest_uses_shared_dirhash_scheme() -> Result<(), Error> {
195        let a = SanitizedArchivePath::from_archive_member("a.txt")?.expect("valid path");
196        let c = SanitizedArchivePath::from_archive_member("b/c.txt")?.expect("valid path");
197        let directory = SanitizedArchivePath::from_archive_member("b/d")?.expect("valid path");
198
199        let tree = directory_tree_from_extracted(
200            &[
201                HashedFile::new(a, 5, blake3::hash(b"hello"), false),
202                HashedFile::new(c, 7, blake3::hash(b"goodbye"), false),
203            ],
204            [&directory],
205        )
206        .expect("valid directory tree");
207        let digest = DirectoryDigest::from(tree.hash());
208
209        assert_eq!(digest.as_str(), "xhg9bffqlabg1f3sq4i83jfb");
210        assert_eq!(digest.as_str().len(), DIRECTORY_DIGEST_LENGTH);
211        assert!(
212            digest
213                .as_str()
214                .bytes()
215                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
216        );
217        Ok(())
218    }
219
220    #[test]
221    fn extracted_file_executable_status() -> Result<(), Error> {
222        for (name, executable, expected) in [
223            ("tool", true, true),
224            ("data", false, false),
225            ("tool.EXE", false, cfg!(windows)),
226        ] {
227            let path = SanitizedArchivePath::from_archive_member(name)?.expect("valid path");
228            let file = HashedFile::new(path, 1, blake3::hash(b"x"), executable);
229            assert_eq!(file.is_executable(), expected);
230        }
231        Ok(())
232    }
233
234    #[test]
235    fn digest_path_uses_normalized_archive_path() -> Result<(), Error> {
236        let path = SanitizedArchivePath::from_archive_member("example/../package/./data.txt")?;
237        assert_eq!(
238            path.as_ref().map(digest_path).as_deref(),
239            Some("package/data.txt")
240        );
241        Ok(())
242    }
243}