1use std::collections::BTreeSet;
4use std::fmt;
5use std::fs::File;
6use std::io;
7use std::path::Path;
8use std::path::PathBuf;
9use std::str::FromStr;
10
11use serde::Deserialize;
12use serde::Serialize;
13use sha2::Digest;
14use sha2::Sha256;
15use thiserror::Error;
16
17use crate::RelativePath;
18use crate::RelativePathError;
19use crate::tree::TreeError;
20
21#[derive(Debug, Error)]
23pub enum HashError {
24 #[error(transparent)]
27 InvalidPath(#[from] RelativePathError),
28
29 #[error("absolute path `{0}` is not under the module root")]
32 AbsoluteNotUnderRoot(String),
33
34 #[error("symbolic link `{0}` resolves outside the module root")]
36 SymlinkEscapesRoot(String),
37
38 #[error("path `{path}` collides with an already-recorded path under NFC form `{nfc}`")]
42 AmbiguousPath {
43 path: String,
45 nfc: String,
47 },
48
49 #[error("failed to read `{path}`")]
51 Io {
52 path: PathBuf,
54 #[source]
56 source: io::Error,
57 },
58
59 #[error(transparent)]
62 Tree(#[from] TreeError),
63}
64
65#[derive(Debug, Error)]
67pub enum ContentHashError {
68 #[error("content hash must start with `sha256:`")]
70 MissingPrefix,
71
72 #[error("content hash must be exactly 64 hex characters; got {0}")]
74 WrongLength(usize),
75
76 #[error("content hash contains non-hex characters")]
78 InvalidHex,
79}
80
81const SHA256_PREFIX: &str = "sha256:";
83
84const CONTENT_HASH_MAGIC: &[u8] = b"wdl-module-content\0v1\0";
87
88#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
90#[serde(into = "String", try_from = "String")]
91pub struct ContentHash([u8; 32]);
92
93impl ContentHash {
94 pub const fn as_bytes(&self) -> &[u8; 32] {
96 &self.0
97 }
98
99 pub fn to_hex(&self) -> String {
102 hex::encode(self.0)
103 }
104}
105
106impl From<[u8; 32]> for ContentHash {
107 fn from(bytes: [u8; 32]) -> Self {
108 Self(bytes)
109 }
110}
111
112impl From<ContentHash> for String {
113 fn from(hash: ContentHash) -> Self {
114 hash.to_string()
115 }
116}
117
118impl TryFrom<String> for ContentHash {
119 type Error = ContentHashError;
120
121 fn try_from(s: String) -> Result<Self, Self::Error> {
122 s.parse()
123 }
124}
125
126impl fmt::Display for ContentHash {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 write!(f, "{SHA256_PREFIX}{}", hex::encode(self.0))
129 }
130}
131
132impl FromStr for ContentHash {
133 type Err = ContentHashError;
134
135 fn from_str(s: &str) -> Result<Self, Self::Err> {
136 let hex = s
137 .strip_prefix(SHA256_PREFIX)
138 .ok_or(ContentHashError::MissingPrefix)?;
139 if hex.len() != 64 {
140 return Err(ContentHashError::WrongLength(hex.len()));
141 }
142 let bytes: [u8; 32] = hex::decode(hex)
143 .map_err(|_| ContentHashError::InvalidHex)?
144 .try_into()
145 .map_err(|_| ContentHashError::WrongLength(hex.len()))?;
146 Ok(Self(bytes))
147 }
148}
149
150#[derive(Debug)]
157pub struct Hasher {
158 root: PathBuf,
160 paths: BTreeSet<RelativePath>,
162}
163
164impl Hasher {
165 pub fn new(root: impl Into<PathBuf>) -> Self {
167 Self {
168 root: root.into(),
169 paths: BTreeSet::new(),
170 }
171 }
172
173 pub fn paths(&self) -> impl Iterator<Item = &RelativePath> {
175 self.paths.iter()
176 }
177
178 pub fn try_add(&mut self, path: impl Into<String>) -> Result<&mut Self, HashError> {
183 let raw = path.into();
184
185 let candidate = Path::new(&raw);
186 let relative = if candidate.is_absolute() {
187 candidate
188 .strip_prefix(&self.root)
189 .map_err(|_| HashError::AbsoluteNotUnderRoot(raw.clone()))?
190 } else {
191 candidate
192 };
193
194 let rel = RelativePath::try_from(relative)?;
195
196 let nfc = rel.as_str().to_string();
197 if !self.paths.insert(rel) {
198 return Err(HashError::AmbiguousPath { path: raw, nfc });
199 }
200 Ok(self)
201 }
202
203 pub fn finalize(self) -> Result<ContentHash, HashError> {
211 crate::tree::validate_tree(self.paths())?;
212
213 let canonical_root = std::fs::canonicalize(&self.root).map_err(|source| HashError::Io {
214 path: self.root.clone(),
215 source,
216 })?;
217
218 let mut sha = Sha256::new();
219 sha.update(CONTENT_HASH_MAGIC);
220 for relative in &self.paths {
222 let bytes = relative.as_str().as_bytes();
223 sha.update((bytes.len() as u64).to_le_bytes());
224 sha.update(bytes);
225
226 let abs = self.root.join(relative);
227 let canonical_abs = std::fs::canonicalize(&abs).map_err(|source| HashError::Io {
228 path: abs.clone(),
229 source,
230 })?;
231
232 if !canonical_abs.starts_with(&canonical_root) {
233 return Err(HashError::SymlinkEscapesRoot(relative.as_str().to_string()));
234 }
235
236 let mut file = File::open(&canonical_abs).map_err(|source| HashError::Io {
237 path: canonical_abs.clone(),
238 source,
239 })?;
240 let len = file
241 .metadata()
242 .map_err(|source| HashError::Io {
243 path: canonical_abs.clone(),
244 source,
245 })?
246 .len();
247 sha.update(len.to_le_bytes());
248 io::copy(&mut file, &mut sha).map_err(|source| HashError::Io {
249 path: canonical_abs,
250 source,
251 })?;
252 }
253
254 sha.update((self.paths.len() as u64).to_le_bytes());
255 Ok(ContentHash::from(<[u8; 32]>::from(sha.finalize())))
256 }
257}
258
259pub fn hash_directory(root: impl AsRef<Path>) -> Result<ContentHash, HashError> {
262 let root = root.as_ref();
263 let mut hasher = Hasher::new(root.to_path_buf());
264 let mut stack = vec![root.to_path_buf()];
265 while let Some(dir) = stack.pop() {
266 let entries = std::fs::read_dir(&dir).map_err(|source| HashError::Io {
267 path: dir.clone(),
268 source,
269 })?;
270 for entry in entries {
271 let entry = entry.map_err(|source| HashError::Io {
272 path: dir.clone(),
273 source,
274 })?;
275 let path = entry.path();
276 let file_type = entry.file_type().map_err(|source| HashError::Io {
277 path: path.clone(),
278 source,
279 })?;
280 if file_type.is_dir() {
281 stack.push(path);
282 continue;
283 }
284
285 let rel_path = path.strip_prefix(root).unwrap();
289 let rel = rel_path
290 .to_str()
291 .ok_or(RelativePathError::NonUtf8)?
292 .replace('\\', "/");
293 if rel == crate::SIGNATURE_FILENAME || rel == crate::LOCKFILE_FILENAME {
294 continue;
295 }
296 hasher.try_add(rel)?;
297 }
298 }
299
300 crate::tree::validate_tree(hasher.paths())?;
301
302 hasher.finalize()
303}
304
305#[cfg(test)]
306mod tests {
307 use std::fs;
308
309 use tempfile::tempdir;
310
311 use super::*;
312
313 #[test]
314 fn round_trips_via_display() {
315 let bytes = [0xAB; 32];
316 let hash = ContentHash::from(bytes);
317 let s = hash.to_string();
318 assert!(s.starts_with("sha256:"));
319 let parsed: ContentHash = s.parse().unwrap();
320 assert_eq!(parsed, hash);
321 }
322
323 #[test]
324 fn rejects_missing_prefix() {
325 assert!(matches!(
326 "ab".repeat(32).parse::<ContentHash>(),
327 Err(ContentHashError::MissingPrefix)
328 ));
329 }
330
331 #[test]
332 fn rejects_bad_hex() {
333 let s = format!("sha256:{}", "g".repeat(64));
334 assert!(matches!(
335 s.parse::<ContentHash>(),
336 Err(ContentHashError::InvalidHex)
337 ));
338 }
339
340 #[test]
341 fn rejects_unrecoverable_paths() {
342 let dir = tempdir().unwrap();
343 let mut h = Hasher::new(dir.path().to_path_buf());
344 for bad in [
345 "", ".", "..", "../escape", "/somewhere/not/under/root", "has\0null", "C:/win", "c:\\win", ] {
354 assert!(h.try_add(bad).is_err(), "accepted `{bad}`");
355 }
356 }
357
358 #[test]
359 fn normalizes_relative_paths() {
360 let dir = tempdir().unwrap();
361 fs::write(dir.path().join("foo.txt"), b"x").unwrap();
362
363 let mut h_clean = Hasher::new(dir.path().to_path_buf());
364 h_clean.try_add("foo.txt").unwrap();
365
366 let mut h_dotty = Hasher::new(dir.path().to_path_buf());
367 h_dotty.try_add("./bar/../foo.txt").unwrap();
368
369 assert_eq!(h_clean.finalize().unwrap(), h_dotty.finalize().unwrap());
370 }
371
372 #[test]
373 fn accepts_absolute_under_root() {
374 let dir = tempdir().unwrap();
375 fs::write(dir.path().join("foo.txt"), b"x").unwrap();
376
377 let mut h_rel = Hasher::new(dir.path().to_path_buf());
378 h_rel.try_add("foo.txt").unwrap();
379
380 let mut h_abs = Hasher::new(dir.path().to_path_buf());
381 h_abs
382 .try_add(dir.path().join("foo.txt").to_string_lossy().to_string())
383 .unwrap();
384
385 assert_eq!(h_rel.finalize().unwrap(), h_abs.finalize().unwrap());
386 }
387
388 #[test]
389 fn hashes_two_files_deterministically() {
390 let dir = tempdir().unwrap();
391 fs::write(dir.path().join("a.txt"), b"alpha").unwrap();
392 fs::write(dir.path().join("b.txt"), b"beta").unwrap();
393
394 let mut h1 = Hasher::new(dir.path().to_path_buf());
395 h1.try_add("a.txt").unwrap().try_add("b.txt").unwrap();
396 let d1 = h1.finalize().unwrap();
397
398 let mut h2 = Hasher::new(dir.path().to_path_buf());
400 h2.try_add("b.txt").unwrap().try_add("a.txt").unwrap();
401 let d2 = h2.finalize().unwrap();
402
403 assert_eq!(d1, d2, "digests should match regardless of `try_add` order");
404 }
405
406 #[test]
407 fn detects_path_content_boundary_collision() {
408 let dir1 = tempdir().unwrap();
413 fs::write(dir1.path().join("a"), b"Xbc").unwrap();
414
415 let dir2 = tempdir().unwrap();
416 fs::write(dir2.path().join("aXbc"), b"").unwrap();
417
418 let d1 = hash_directory(dir1.path()).unwrap();
419 let d2 = hash_directory(dir2.path()).unwrap();
420 assert_ne!(d1, d2);
421 }
422
423 #[test]
424 fn excludes_module_sig_and_lockfile() {
425 let dir = tempdir().unwrap();
426 fs::write(dir.path().join("a.txt"), b"keep").unwrap();
427 let d_clean = hash_directory(dir.path()).unwrap();
428
429 fs::write(dir.path().join(crate::SIGNATURE_FILENAME), b"sig").unwrap();
430 fs::write(dir.path().join(crate::LOCKFILE_FILENAME), b"lock").unwrap();
431 let d_with_excludes = hash_directory(dir.path()).unwrap();
432
433 assert_eq!(d_clean, d_with_excludes);
434 }
435
436 #[test]
437 fn hash_directory_rejects_nested_reserved_filename() {
438 let dir = tempdir().unwrap();
439 fs::create_dir(dir.path().join("nested")).unwrap();
440 fs::write(
441 dir.path().join("nested").join(crate::MANIFEST_FILENAME),
442 b"x",
443 )
444 .unwrap();
445 let err = hash_directory(dir.path()).unwrap_err();
446 assert!(matches!(
447 err,
448 HashError::Tree(crate::TreeError::ReservedFilename {
449 name: crate::MANIFEST_FILENAME,
450 ..
451 })
452 ));
453 }
454
455 #[test]
456 fn finalize_errors_on_missing_file() {
457 let dir = tempdir().unwrap();
458 let mut h = Hasher::new(dir.path().to_path_buf());
459 h.try_add("missing.txt").unwrap();
460 assert!(matches!(h.finalize(), Err(HashError::Io { .. })));
461 }
462
463 #[test]
464 fn finalize_validates_reserved_filenames() {
465 let dir = tempdir().unwrap();
466 fs::create_dir(dir.path().join("nested")).unwrap();
467 fs::write(
468 dir.path().join("nested").join(crate::SIGNATURE_FILENAME),
469 b"x",
470 )
471 .unwrap();
472
473 let mut h = Hasher::new(dir.path().to_path_buf());
474 h.try_add("nested/module.sig").unwrap();
475 let err = h.finalize().unwrap_err();
476 assert!(matches!(
477 err,
478 HashError::Tree(crate::TreeError::ReservedFilename {
479 name: crate::SIGNATURE_FILENAME,
480 ..
481 })
482 ));
483 }
484
485 #[test]
486 fn rejects_paths_colliding_under_nfc() {
487 let dir = tempdir().unwrap();
488 let mut h = Hasher::new(dir.path().to_path_buf());
489
490 let precomposed = "caf\u{00E9}.wdl";
492 let decomposed = "cafe\u{0301}.wdl";
493
494 h.try_add(precomposed).unwrap();
495 let err = h.try_add(decomposed).unwrap_err();
496 assert!(matches!(err, HashError::AmbiguousPath { .. }));
497 }
498
499 #[test]
500 fn nfc_normalizes_recorded_paths() {
501 let dir = tempdir().unwrap();
502 fs::write(dir.path().join("caf\u{00E9}.wdl"), b"x").unwrap();
503
504 let mut h_nfc = Hasher::new(dir.path().to_path_buf());
505 h_nfc.try_add("caf\u{00E9}.wdl").unwrap();
506
507 let mut h_nfd = Hasher::new(dir.path().to_path_buf());
508 h_nfd.try_add("cafe\u{0301}.wdl").unwrap();
509
510 assert_eq!(h_nfc.finalize().unwrap(), h_nfd.finalize().unwrap());
511 }
512}