1use std::{fmt, str::FromStr};
7
8use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
9
10use crate::{
11 directory::{self, EntryKind},
12 error::{Error as FilesystemError, Operation},
13 path::{Component, Path, PathBuf},
14};
15
16const CHECKSUM_BYTES: usize = 32;
17const HEX_PAIR_BYTES: usize = 2;
18const ADJACENT_PAIR: usize = 2;
19const SECOND_ITEM: usize = 1;
20const CHECKSUM_HEX_LENGTH: usize = CHECKSUM_BYTES * HEX_PAIR_BYTES;
21const NIBBLE_BITS: u32 = 4;
22const HEX_ALPHA_OFFSET: u8 = 10;
23const TREE_DOMAIN: &[u8] = b"wowlab-fs:tree-snapshot:v1\0";
24
25#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
31pub struct Checksum([u8; CHECKSUM_BYTES]);
32
33impl Checksum {
34 #[must_use]
36 pub const fn as_bytes(&self) -> &[u8; CHECKSUM_BYTES] {
37 &self.0
38 }
39}
40
41impl fmt::Debug for Checksum {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.debug_tuple("Checksum").field(&self.to_string()).finish()
44 }
45}
46
47impl fmt::Display for Checksum {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 for byte in self.0 {
50 write!(f, "{byte:02x}")?;
51 }
52
53 Ok(())
54 }
55}
56
57impl FromStr for Checksum {
58 type Err = ParseChecksumError;
59
60 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
61 if value.len() != CHECKSUM_HEX_LENGTH {
62 return Err(ParseChecksumError);
63 }
64
65 let mut bytes = [0_u8; CHECKSUM_BYTES];
66
67 for (byte, pair) in bytes
68 .iter_mut()
69 .zip(value.as_bytes().chunks_exact(HEX_PAIR_BYTES))
70 {
71 let high = pair.first().copied().and_then(decode_hex);
72 let low = pair.get(SECOND_ITEM).copied().and_then(decode_hex);
73 let (Some(high), Some(low)) = (high, low) else {
74 return Err(ParseChecksumError);
75 };
76
77 *byte = (high << NIBBLE_BITS) | low;
78 }
79
80 Ok(Self(bytes))
81 }
82}
83
84impl Serialize for Checksum {
85 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
86 where
87 S: Serializer,
88 {
89 serializer.serialize_str(&self.to_string())
90 }
91}
92
93impl<'de> Deserialize<'de> for Checksum {
94 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
95 where
96 D: Deserializer<'de>,
97 {
98 let value = String::deserialize(deserializer)?;
99
100 value.parse().map_err(D::Error::custom)
102 }
103}
104
105#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
107#[error("a checksum must contain exactly 64 hexadecimal characters")]
108pub struct ParseChecksumError;
109
110#[must_use]
112pub fn bytes(contents: impl AsRef<[u8]>) -> Checksum {
113 Checksum(*blake3::hash(contents.as_ref()).as_bytes())
114}
115
116pub fn file(path: &Path) -> Result<Checksum> {
125 checked_file(path).map(|file| file.checksum)
126}
127
128#[cfg(not(target_family = "wasm"))]
136pub fn current_executable() -> Result<Checksum> {
137 let path = match std::env::current_exe() {
138 Ok(path) => PathBuf::from(path),
139 Err(source) => return Err(ErrorKind::CurrentExecutable(source).into()),
140 };
141
142 file(&path)
143}
144
145#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
147pub struct TreeEntry {
148 relative_path: PathBuf,
149 len: u64,
150 checksum: Checksum,
151 #[serde(skip)]
152 components: Vec<Box<str>>,
153}
154
155impl TreeEntry {
156 #[must_use]
158 pub fn relative_path(&self) -> &Path {
159 &self.relative_path
160 }
161
162 #[must_use]
164 pub const fn len(&self) -> u64 {
165 self.len
166 }
167
168 #[must_use]
170 pub const fn is_empty(&self) -> bool {
171 self.len == 0
172 }
173
174 #[must_use]
176 pub const fn checksum(&self) -> Checksum {
177 self.checksum
178 }
179}
180
181#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
188pub struct TreeSnapshot {
189 checksum: Checksum,
190 entries: Vec<TreeEntry>,
191 total_bytes: u64,
192}
193
194impl TreeSnapshot {
195 pub fn capture<I, P>(root: &Path, paths: I) -> Result<Self>
208 where
209 I: IntoIterator<Item = P>,
210 P: AsRef<Path>,
211 {
212 let mut candidates = paths
213 .into_iter()
214 .map(|path| Candidate::new(root, path.as_ref()))
215 .collect::<Result<Vec<_>>>()?;
216
217 candidates.sort_by(|left, right| left.sort_key.cmp(&right.sort_key));
218
219 let duplicate = candidates.windows(ADJACENT_PAIR).position(|pair| {
220 pair.first()
221 .zip(pair.get(SECOND_ITEM))
222 .is_some_and(|(left, right)| left.sort_key == right.sort_key)
223 });
224
225 if let Some(index) = duplicate {
226 let duplicate = candidates.remove(index + SECOND_ITEM);
227
228 return Err(ErrorKind::DuplicatePath {
229 path: duplicate.relative_path,
230 }
231 .into());
232 }
233
234 let mut entries = Vec::with_capacity(candidates.len());
235 let mut total_bytes = 0_u64;
236
237 for candidate in candidates {
238 let captured = checked_file(&candidate.path)?;
239
240 total_bytes = total_bytes
241 .checked_add(captured.len)
242 .ok_or(ErrorKind::InputTooLarge)?;
243 entries.push(TreeEntry {
244 relative_path: candidate.relative_path,
245 len: captured.len,
246 checksum: captured.checksum,
247 components: candidate.sort_key,
248 });
249 }
250
251 let checksum = tree_checksum(&entries)?;
252
253 Ok(Self {
254 checksum,
255 entries,
256 total_bytes,
257 })
258 }
259
260 #[must_use]
262 pub const fn checksum(&self) -> Checksum {
263 self.checksum
264 }
265
266 #[must_use]
268 pub fn entries(&self) -> &[TreeEntry] {
269 &self.entries
270 }
271
272 #[must_use]
274 pub fn file_count(&self) -> usize {
275 self.entries.len()
276 }
277
278 #[must_use]
280 pub const fn total_bytes(&self) -> u64 {
281 self.total_bytes
282 }
283}
284
285#[derive(Debug, thiserror::Error)]
287#[error(transparent)]
288pub struct Error(#[from] ErrorKind);
289
290#[derive(Debug, thiserror::Error)]
291enum ErrorKind {
292 #[error(transparent)]
293 Filesystem(#[from] FilesystemError),
294 #[cfg(not(target_family = "wasm"))]
295 #[error("failed to resolve the current executable: {0}")]
296 CurrentExecutable(#[source] std::io::Error),
297 #[error("checksum path {path} is outside root {root}: {source}")]
298 OutsideRoot {
299 root: PathBuf,
300 path: PathBuf,
301 #[source]
302 source: crate::path::StripPrefixError,
303 },
304 #[error("checksum path {path} is the snapshot root")]
305 RootPath { path: PathBuf },
306 #[error("checksum path {path} has a non-UTF-8 or non-normal relative component")]
307 InvalidRelativePath { path: PathBuf },
308 #[error("checksum path {path} appears more than once")]
309 DuplicatePath { path: PathBuf },
310 #[error("checksum file {path} does not exist")]
311 MissingFile { path: PathBuf },
312 #[error("checksum path {path} is not a regular file ({kind:?})")]
313 NotRegularFile { path: PathBuf, kind: EntryKind },
314 #[error("checksum input exceeds the supported length")]
315 InputTooLarge,
316}
317
318impl From<FilesystemError> for Error {
319 fn from(source: FilesystemError) -> Self {
320 ErrorKind::Filesystem(source).into()
321 }
322}
323
324pub type Result<T> = std::result::Result<T, Error>;
326
327struct Candidate {
328 path: PathBuf,
329 relative_path: PathBuf,
330 sort_key: Vec<Box<str>>,
331}
332
333impl Candidate {
334 fn new(root: &Path, path: &Path) -> Result<Self> {
335 let relative = path.strip_prefix(root).map_err(|source| {
336 Error::from(ErrorKind::OutsideRoot {
337 root: root.to_path_buf(),
338 path: path.to_path_buf(),
339 source,
340 })
341 })?;
342
343 if relative.is_empty() {
344 return Err(ErrorKind::RootPath {
345 path: path.to_path_buf(),
346 }
347 .into());
348 }
349
350 let mut relative_path = PathBuf::new();
351 let mut sort_key = Vec::new();
352
353 for component in relative.components() {
354 let Component::Normal(value) = component else {
355 return Err(ErrorKind::InvalidRelativePath {
356 path: path.to_path_buf(),
357 }
358 .into());
359 };
360 let value = value
361 .to_str()
362 .ok_or_else(|| ErrorKind::InvalidRelativePath {
363 path: path.to_path_buf(),
364 })?;
365
366 relative_path.push(value);
367 sort_key.push(value.into());
368 }
369
370 Ok(Self {
371 path: path.to_path_buf(),
372 relative_path,
373 sort_key,
374 })
375 }
376}
377
378struct CapturedFile {
379 checksum: Checksum,
380 len: u64,
381}
382
383fn checked_file(path: &Path) -> Result<CapturedFile> {
384 let Some(info) = directory::inspect_link(path)? else {
385 return Err(ErrorKind::MissingFile {
386 path: path.to_path_buf(),
387 }
388 .into());
389 };
390
391 if info.kind() != EntryKind::File {
392 return Err(ErrorKind::NotRegularFile {
393 path: path.to_path_buf(),
394 kind: info.kind(),
395 }
396 .into());
397 }
398
399 let file = crate::file::open(path)?;
400 let mut reader = CountingReader::new(file);
401 let mut hasher = blake3::Hasher::new();
402
403 hasher
404 .update_reader(&mut reader)
405 .map_err(|source| FilesystemError::new(Operation::ReadFile, path, source))?;
406
407 Ok(CapturedFile {
408 checksum: Checksum(*hasher.finalize().as_bytes()),
409 len: reader.len(),
410 })
411}
412
413struct CountingReader {
414 file: crate::file::File,
415 len: u64,
416}
417
418impl CountingReader {
419 const fn new(file: crate::file::File) -> Self {
420 Self { file, len: 0 }
421 }
422
423 const fn len(&self) -> u64 {
424 self.len
425 }
426}
427
428impl std::io::Read for CountingReader {
429 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
430 let read = self.file.read(buf)?;
431 let read_len = match u64::try_from(read) {
432 Ok(read_len) => read_len,
433 Err(source) => return Err(std::io::Error::other(source)),
434 };
435
436 self.len = self
437 .len
438 .checked_add(read_len)
439 .ok_or_else(|| std::io::Error::other("checksum input exceeds the supported length"))?;
440
441 Ok(read)
442 }
443}
444
445fn tree_checksum(entries: &[TreeEntry]) -> Result<Checksum> {
446 let mut hasher = blake3::Hasher::new();
447
448 hasher.update(TREE_DOMAIN);
449 update_length(&mut hasher, entries.len())?;
450
451 for entry in entries {
452 update_length(&mut hasher, entry.components.len())?;
453
454 for value in &entry.components {
455 update_length(&mut hasher, value.len())?;
456 hasher.update(value.as_bytes());
457 }
458
459 hasher.update(&entry.len.to_le_bytes());
460 hasher.update(entry.checksum.as_bytes());
461 }
462
463 Ok(Checksum(*hasher.finalize().as_bytes()))
464}
465
466fn update_length(hasher: &mut blake3::Hasher, len: usize) -> Result<()> {
467 let len = match u64::try_from(len) {
468 Ok(len) => len,
469 Err(_source) => return Err(ErrorKind::InputTooLarge.into()),
470 };
471
472 hasher.update(&len.to_le_bytes());
473
474 Ok(())
475}
476
477const fn decode_hex(value: u8) -> Option<u8> {
478 match value {
479 b'0'..=b'9' => Some(value - b'0'),
480 b'a'..=b'f' => Some(value - b'a' + HEX_ALPHA_OFFSET),
481 b'A'..=b'F' => Some(value - b'A' + HEX_ALPHA_OFFSET),
482 _ => None,
483 }
484}