uv_extract/dirhash/
archive.rs1use 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
12pub(crate) enum UnzipOutput {
14 Unhashed(Vec<UnhashedFile>),
15 Hashed {
16 files: Vec<HashedFile>,
17 tree: DirhashTree,
18 },
19}
20
21#[derive(Debug, Clone, Eq, PartialEq)]
30pub struct DirectoryDigest(String);
31
32impl DirectoryDigest {
33 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#[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 pub fn path(&self) -> &Path {
65 &self.path
66 }
67
68 pub fn size(&self) -> u64 {
70 self.size
71 }
72}
73
74#[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 pub fn path(&self) -> &Path {
106 self.path.as_path()
107 }
108
109 pub fn is_executable(&self) -> bool {
111 self.executable
112 }
113
114 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 pub fn size(&self) -> u64 {
127 self.size
128 }
129}
130
131pub(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
152fn 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}