1use std::collections::BTreeSet;
4use std::fmt;
5use std::fs::File;
6use std::io;
7use std::io::Read;
8use std::path::Path;
9use std::path::PathBuf;
10use std::str::FromStr;
11
12use serde_with::DeserializeFromStr;
13use serde_with::SerializeDisplay;
14use sha2::Digest;
15use sha2::Sha256;
16use thiserror::Error;
17
18use crate::module_walk::ModuleWalkError;
19use crate::relative_path::RelativePath;
20use crate::relative_path::RelativePathError;
21use crate::tree::TreeError;
22
23#[derive(Debug, Error)]
25pub enum HashError {
26 #[error(transparent)]
29 InvalidPath(#[from] RelativePathError),
30
31 #[error("absolute path `{0}` is not under the module root")]
34 AbsoluteNotUnderRoot(String),
35
36 #[error("path `{path}` collides with an already-recorded path under NFC form `{nfc}`")]
40 AmbiguousPath {
41 path: String,
43 nfc: String,
45 },
46
47 #[error("failed to read `{path}`")]
49 Io {
50 path: PathBuf,
52 #[source]
54 source: io::Error,
55 },
56
57 #[error(transparent)]
59 Walk(#[from] ModuleWalkError),
60
61 #[error(transparent)]
64 Tree(#[from] TreeError),
65}
66
67#[derive(Debug, Error)]
69pub enum ContentHashError {
70 #[error("content hash must start with `sha256:`")]
72 MissingPrefix,
73
74 #[error("content hash must be exactly 64 hex characters; got {0}")]
76 WrongLength(usize),
77
78 #[error("content hash contains non-hex characters")]
80 InvalidHex,
81}
82
83const SHA256_PREFIX: &str = "sha256:";
85
86const CONTENT_HASH_MAGIC: &[u8] = b"wdl-module-content\0v1\0";
89
90#[derive(
92 Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, SerializeDisplay, DeserializeFromStr,
93)]
94pub struct ContentHash([u8; 32]);
95
96impl ContentHash {
97 pub const fn as_bytes(&self) -> &[u8; 32] {
99 &self.0
100 }
101
102 pub fn to_hex(&self) -> String {
105 hex::encode(self.0)
106 }
107}
108
109impl From<[u8; 32]> for ContentHash {
110 fn from(bytes: [u8; 32]) -> Self {
111 Self(bytes)
112 }
113}
114
115impl From<ContentHash> for String {
116 fn from(hash: ContentHash) -> Self {
117 hash.to_string()
118 }
119}
120
121impl fmt::Display for ContentHash {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 write!(f, "{SHA256_PREFIX}{}", hex::encode(self.0))
124 }
125}
126
127impl FromStr for ContentHash {
128 type Err = ContentHashError;
129
130 fn from_str(s: &str) -> Result<Self, Self::Err> {
131 let hex = s
132 .strip_prefix(SHA256_PREFIX)
133 .ok_or(ContentHashError::MissingPrefix)?;
134 if hex.len() != 64 {
135 return Err(ContentHashError::WrongLength(hex.len()));
136 }
137 let bytes: [u8; 32] = hex::decode(hex)
138 .map_err(|_| ContentHashError::InvalidHex)?
139 .try_into()
140 .map_err(|_| ContentHashError::WrongLength(hex.len()))?;
141 Ok(Self(bytes))
142 }
143}
144
145#[derive(Debug)]
152pub struct Hasher {
153 root: PathBuf,
155 paths: BTreeSet<RelativePath>,
157}
158
159impl Hasher {
160 pub fn new(root: impl Into<PathBuf>) -> Self {
162 Self {
163 root: root.into(),
164 paths: BTreeSet::new(),
165 }
166 }
167
168 pub fn paths(&self) -> impl Iterator<Item = &RelativePath> {
170 self.paths.iter()
171 }
172
173 pub fn try_add(&mut self, path: impl Into<String>) -> Result<&mut Self, HashError> {
178 let raw = path.into();
179
180 let candidate = Path::new(&raw);
181 let relative = if candidate.is_absolute() {
182 candidate
183 .strip_prefix(&self.root)
184 .map_err(|_| HashError::AbsoluteNotUnderRoot(raw.clone()))?
185 } else {
186 candidate
187 };
188
189 let rel = RelativePath::try_from(relative)?;
190
191 let nfc = rel.as_str().to_string();
192 if !self.paths.insert(rel) {
193 return Err(HashError::AmbiguousPath { path: raw, nfc });
194 }
195 Ok(self)
196 }
197
198 pub fn finalize(self) -> Result<ContentHash, HashError> {
205 crate::tree::validate_tree(self.paths())?;
206
207 let canonical_root = std::fs::canonicalize(&self.root).map_err(|source| HashError::Io {
208 path: self.root.clone(),
209 source,
210 })?;
211
212 let mut sha = Sha256::new();
213 sha.update(CONTENT_HASH_MAGIC);
214 for relative in &self.paths {
216 let bytes = relative.as_str().as_bytes();
217 sha.update((bytes.len() as u64).to_le_bytes());
218 sha.update(bytes);
219
220 let abs = self.root.join(relative);
221 let canonical_abs = std::fs::canonicalize(&abs).map_err(|source| HashError::Io {
222 path: abs.clone(),
223 source,
224 })?;
225
226 if !canonical_abs.starts_with(&canonical_root) {
227 return Err(ModuleWalkError::Symlink(relative.as_str().to_string()).into());
228 }
229
230 let mut file = File::open(&canonical_abs).map_err(|source| HashError::Io {
231 path: canonical_abs.clone(),
232 source,
233 })?;
234 let len = file
235 .metadata()
236 .map_err(|source| HashError::Io {
237 path: canonical_abs.clone(),
238 source,
239 })?
240 .len();
241 sha.update(len.to_le_bytes());
242 let mut buffer = [0; 8192];
243 loop {
244 let bytes = file.read(&mut buffer).map_err(|source| HashError::Io {
245 path: canonical_abs.clone(),
246 source,
247 })?;
248
249 if bytes == 0 {
250 break;
251 }
252
253 sha.update(&buffer[..bytes]);
254 }
255 }
256
257 sha.update((self.paths.len() as u64).to_le_bytes());
258 Ok(ContentHash::from(<[u8; 32]>::from(sha.finalize())))
259 }
260}
261
262pub(crate) const NON_MODULE_CONTENT: &[&str] = &[".git", ".sprocket"];
267
268pub(crate) fn path_is_excluded_from_hash(path: &Path) -> bool {
270 path == Path::new(crate::SIGNATURE_FILENAME)
271 || path == Path::new(crate::LOCKFILE_FILENAME)
272 || path.components().any(|component| match component {
273 std::path::Component::Normal(name) => NON_MODULE_CONTENT
274 .iter()
275 .any(|excluded| name == std::ffi::OsStr::new(excluded)),
276 _ => false,
277 })
278}
279
280pub fn hash_directory(root: impl AsRef<Path>) -> Result<ContentHash, HashError> {
284 let root = root.as_ref();
285 let mut hasher = Hasher::new(root.to_path_buf());
286
287 crate::module_walk::walk_module_tree(root, &mut |path: &Path, _size| {
288 let rel_path = path.strip_prefix(root).unwrap();
290 let rel = rel_path
291 .to_str()
292 .ok_or(RelativePathError::NonUtf8)?
293 .replace('\\', "/");
294 if path_is_excluded_from_hash(Path::new(&rel)) {
295 return Ok(());
296 }
297 hasher.try_add(rel)?;
298 Ok(())
299 })
300 .map_err(|e| match e {
301 crate::module_walk::WalkError::Walk(w) => HashError::from(w),
302 crate::module_walk::WalkError::Visitor(h) => h,
303 })?;
304
305 crate::tree::validate_tree(hasher.paths())?;
306
307 hasher.finalize()
308}
309
310#[cfg(test)]
311mod tests {
312 use std::fs;
313
314 use tempfile::tempdir;
315
316 use super::*;
317
318 #[test]
319 fn round_trips_via_display() {
320 let bytes = [0xAB; 32];
321 let hash = ContentHash::from(bytes);
322 let s = hash.to_string();
323 assert!(s.starts_with("sha256:"));
324 let parsed: ContentHash = s.parse().unwrap();
325 assert_eq!(parsed, hash);
326 }
327
328 #[test]
329 fn rejects_missing_prefix() {
330 assert!(matches!(
331 "ab".repeat(32).parse::<ContentHash>(),
332 Err(ContentHashError::MissingPrefix)
333 ));
334 }
335
336 #[test]
337 fn rejects_bad_hex() {
338 let s = format!("sha256:{}", "g".repeat(64));
339 assert!(matches!(
340 s.parse::<ContentHash>(),
341 Err(ContentHashError::InvalidHex)
342 ));
343 }
344
345 #[test]
346 fn rejects_unrecoverable_paths() {
347 let dir = tempdir().unwrap();
348 let mut h = Hasher::new(dir.path().to_path_buf());
349 for bad in [
350 "", ".", "..", "../escape", "/somewhere/not/under/root", "has\0null", "C:/win", "c:\\win", ] {
359 assert!(h.try_add(bad).is_err(), "accepted `{bad}`");
360 }
361 }
362
363 #[test]
364 fn normalizes_relative_paths() {
365 let dir = tempdir().unwrap();
366 fs::write(dir.path().join("foo.txt"), b"x").unwrap();
367
368 let mut h_clean = Hasher::new(dir.path().to_path_buf());
369 h_clean.try_add("foo.txt").unwrap();
370
371 let mut h_dotty = Hasher::new(dir.path().to_path_buf());
372 h_dotty.try_add("./bar/../foo.txt").unwrap();
373
374 assert_eq!(h_clean.finalize().unwrap(), h_dotty.finalize().unwrap());
375 }
376
377 #[test]
378 fn accepts_absolute_under_root() {
379 let dir = tempdir().unwrap();
380 fs::write(dir.path().join("foo.txt"), b"x").unwrap();
381
382 let mut h_rel = Hasher::new(dir.path().to_path_buf());
383 h_rel.try_add("foo.txt").unwrap();
384
385 let mut h_abs = Hasher::new(dir.path().to_path_buf());
386 h_abs
387 .try_add(dir.path().join("foo.txt").to_string_lossy().to_string())
388 .unwrap();
389
390 assert_eq!(h_rel.finalize().unwrap(), h_abs.finalize().unwrap());
391 }
392
393 #[test]
394 fn hashes_two_files_deterministically() {
395 let dir = tempdir().unwrap();
396 fs::write(dir.path().join("a.txt"), b"alpha").unwrap();
397 fs::write(dir.path().join("b.txt"), b"beta").unwrap();
398
399 let mut h1 = Hasher::new(dir.path().to_path_buf());
400 h1.try_add("a.txt").unwrap().try_add("b.txt").unwrap();
401 let d1 = h1.finalize().unwrap();
402
403 let mut h2 = Hasher::new(dir.path().to_path_buf());
405 h2.try_add("b.txt").unwrap().try_add("a.txt").unwrap();
406 let d2 = h2.finalize().unwrap();
407
408 assert_eq!(d1, d2, "digests should match regardless of `try_add` order");
409 }
410
411 #[test]
412 fn detects_path_content_boundary_collision() {
413 let dir1 = tempdir().unwrap();
418 fs::write(dir1.path().join("a"), b"Xbc").unwrap();
419
420 let dir2 = tempdir().unwrap();
421 fs::write(dir2.path().join("aXbc"), b"").unwrap();
422
423 let d1 = hash_directory(dir1.path()).unwrap();
424 let d2 = hash_directory(dir2.path()).unwrap();
425 assert_ne!(d1, d2);
426 }
427
428 #[test]
429 fn excludes_module_sig_and_lockfile() {
430 let dir = tempdir().unwrap();
431 fs::write(dir.path().join("a.txt"), b"keep").unwrap();
432 let d_clean = hash_directory(dir.path()).unwrap();
433
434 fs::write(dir.path().join(crate::SIGNATURE_FILENAME), b"sig").unwrap();
435 fs::write(dir.path().join(crate::LOCKFILE_FILENAME), b"lock").unwrap();
436 let d_with_excludes = hash_directory(dir.path()).unwrap();
437
438 assert_eq!(d_clean, d_with_excludes);
439 }
440
441 #[test]
442 fn excludes_sprocket_state() {
443 let dir = tempdir().unwrap();
444 fs::write(dir.path().join("a.txt"), b"keep").unwrap();
445 let d_clean = hash_directory(dir.path()).unwrap();
446
447 let state_directory = dir
448 .path()
449 .join(".sprocket")
450 .join("module-mutation")
451 .join("nested");
452 fs::create_dir_all(&state_directory).unwrap();
453 fs::write(state_directory.join(crate::MANIFEST_FILENAME), b"x").unwrap();
454
455 let d_with_state = hash_directory(dir.path()).unwrap();
456
457 assert_eq!(d_clean, d_with_state);
458 }
459
460 #[test]
461 fn hash_directory_rejects_nested_reserved_filename() {
462 let dir = tempdir().unwrap();
463 fs::create_dir(dir.path().join("nested")).unwrap();
464 fs::write(
465 dir.path().join("nested").join(crate::MANIFEST_FILENAME),
466 b"x",
467 )
468 .unwrap();
469 let err = hash_directory(dir.path()).unwrap_err();
470 assert!(matches!(
471 err,
472 HashError::Tree(crate::tree::TreeError::ReservedFilename {
473 name: crate::MANIFEST_FILENAME,
474 ..
475 })
476 ));
477 }
478
479 #[test]
480 fn finalize_errors_on_missing_file() {
481 let dir = tempdir().unwrap();
482 let mut h = Hasher::new(dir.path().to_path_buf());
483 h.try_add("missing.txt").unwrap();
484 assert!(matches!(h.finalize(), Err(HashError::Io { .. })));
485 }
486
487 #[test]
488 fn finalize_validates_reserved_filenames() {
489 let dir = tempdir().unwrap();
490 fs::create_dir(dir.path().join("nested")).unwrap();
491 fs::write(
492 dir.path().join("nested").join(crate::SIGNATURE_FILENAME),
493 b"x",
494 )
495 .unwrap();
496
497 let mut h = Hasher::new(dir.path().to_path_buf());
498 h.try_add("nested/module.sig").unwrap();
499 let err = h.finalize().unwrap_err();
500 assert!(matches!(
501 err,
502 HashError::Tree(crate::tree::TreeError::ReservedFilename {
503 name: crate::SIGNATURE_FILENAME,
504 ..
505 })
506 ));
507 }
508
509 #[test]
510 fn rejects_paths_colliding_under_nfc() {
511 let dir = tempdir().unwrap();
512 let mut h = Hasher::new(dir.path().to_path_buf());
513
514 let precomposed = "caf\u{00E9}.wdl";
516 let decomposed = "cafe\u{0301}.wdl";
517
518 h.try_add(precomposed).unwrap();
519 let err = h.try_add(decomposed).unwrap_err();
520 assert!(matches!(err, HashError::AmbiguousPath { .. }));
521 }
522
523 #[test]
524 fn nfc_normalizes_recorded_paths() {
525 let dir = tempdir().unwrap();
526 fs::write(dir.path().join("caf\u{00E9}.wdl"), b"x").unwrap();
527
528 let mut h_nfc = Hasher::new(dir.path().to_path_buf());
529 h_nfc.try_add("caf\u{00E9}.wdl").unwrap();
530
531 let mut h_nfd = Hasher::new(dir.path().to_path_buf());
532 h_nfd.try_add("cafe\u{0301}.wdl").unwrap();
533
534 assert_eq!(h_nfc.finalize().unwrap(), h_nfd.finalize().unwrap());
535 }
536
537 #[test]
538 fn hash_stable_despite_dot_git_and_sparse_json() {
539 let dir = tempdir().unwrap();
540 fs::write(
541 dir.path().join(crate::MANIFEST_FILENAME),
542 br#"{"name":"x","license":"MIT"}"#,
543 )
544 .unwrap();
545 fs::write(dir.path().join("index.wdl"), b"workflow w {}").unwrap();
546
547 let hash1 = hash_directory(dir.path()).unwrap();
548
549 fs::create_dir(dir.path().join(".git")).unwrap();
550 fs::write(
551 dir.path().join(".git").join("HEAD"),
552 b"ref: refs/heads/main",
553 )
554 .unwrap();
555
556 let hash2 = hash_directory(dir.path()).unwrap();
557
558 assert_eq!(hash1, hash2, "`.git` must not affect the content hash");
559 }
560
561 fn symlink_file(target: &std::path::Path, link: &std::path::Path) {
562 #[cfg(unix)]
563 std::os::unix::fs::symlink(target, link).unwrap();
564 #[cfg(windows)]
565 std::os::windows::fs::symlink_file(target, link).unwrap();
566 }
567
568 #[test]
569 fn symlink_to_dot_git_is_rejected() {
570 let dir = tempdir().unwrap();
571 fs::write(
572 dir.path().join(crate::MANIFEST_FILENAME),
573 br#"{"name":"x","license":"MIT"}"#,
574 )
575 .unwrap();
576 fs::create_dir(dir.path().join(".git")).unwrap();
577 fs::write(dir.path().join(".git").join("config"), b"[core]").unwrap();
578 symlink_file(
579 &dir.path().join(".git").join("config"),
580 &dir.path().join("sneaky.wdl"),
581 );
582 let err = hash_directory(dir.path()).unwrap_err();
583 assert!(
584 matches!(err, HashError::Walk(ModuleWalkError::Symlink(_))),
585 "got: {err}"
586 );
587 }
588
589 #[test]
590 fn symlink_within_module_root_is_rejected() {
591 let dir = tempdir().unwrap();
594 fs::write(
595 dir.path().join(crate::MANIFEST_FILENAME),
596 br#"{"name":"x","license":"MIT"}"#,
597 )
598 .unwrap();
599 fs::write(dir.path().join("real.wdl"), b"workflow w {}").unwrap();
600 symlink_file(&dir.path().join("real.wdl"), &dir.path().join("alias.wdl"));
601 let err = hash_directory(dir.path()).unwrap_err();
602 assert!(
603 matches!(err, HashError::Walk(ModuleWalkError::Symlink(_))),
604 "got: {err}"
605 );
606 }
607
608 #[test]
609 fn symlink_to_nested_dot_git_is_rejected() {
610 let dir = tempdir().unwrap();
611 fs::create_dir_all(dir.path().join("nested").join(".git")).unwrap();
612 fs::write(
613 dir.path().join("nested").join(".git").join("config"),
614 b"private metadata",
615 )
616 .unwrap();
617 symlink_file(
618 &dir.path().join("nested").join(".git").join("config"),
619 &dir.path().join("index.wdl"),
620 );
621 let err = hash_directory(dir.path()).unwrap_err();
622 assert!(
623 matches!(err, HashError::Walk(ModuleWalkError::Symlink(_))),
624 "expected symlink rejection, got: {err}"
625 );
626 }
627
628 #[test]
629 fn windows_and_unix_paths_hash_identically() {
630 let dir = tempdir().unwrap();
631 fs::create_dir(dir.path().join("sub")).unwrap();
632 fs::write(dir.path().join("root.wdl"), b"workflow w {}").unwrap();
633 fs::write(dir.path().join("sub").join("nested.wdl"), b"task t {}").unwrap();
634
635 let mut h_unix = Hasher::new(dir.path().to_path_buf());
637 for p in ["root.wdl", "sub/nested.wdl"] {
638 h_unix.try_add(p).unwrap();
639 }
640
641 let mut h_win = Hasher::new(dir.path().to_path_buf());
644 for p in ["root.wdl", "sub\\nested.wdl"] {
645 h_win.try_add(p.replace('\\', "/")).unwrap();
646 }
647
648 assert_eq!(
649 h_unix.finalize().unwrap(),
650 h_win.finalize().unwrap(),
651 "digests must be platform-independent after path-separator normalization"
652 );
653 }
654
655 #[test]
656 fn directory_symlink_cycle_is_rejected() {
657 let dir = tempdir().unwrap();
658 fs::write(dir.path().join("real.wdl"), b"version 1.3\n").unwrap();
659 fs::create_dir(dir.path().join("sub")).unwrap();
660 #[cfg(unix)]
661 std::os::unix::fs::symlink("..", dir.path().join("sub").join("loop")).unwrap();
662 #[cfg(windows)]
663 std::os::windows::fs::symlink_dir("..", dir.path().join("sub").join("loop")).unwrap();
664 let err = hash_directory(dir.path()).unwrap_err();
665 assert!(
666 matches!(err, HashError::Walk(ModuleWalkError::Symlink(_))),
667 "directory symlinks must be rejected, got: {err}"
668 );
669 }
670}