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> {
206 crate::tree::validate_tree(self.paths())?;
207
208 let canonical_root = std::fs::canonicalize(&self.root).map_err(|source| HashError::Io {
209 path: self.root.clone(),
210 source,
211 })?;
212
213 let mut sha = Sha256::new();
214 sha.update(CONTENT_HASH_MAGIC);
215 for relative in &self.paths {
217 let bytes = relative.as_str().as_bytes();
218 sha.update((bytes.len() as u64).to_le_bytes());
219 sha.update(bytes);
220
221 let abs = self.root.join(relative);
222 let canonical_abs = std::fs::canonicalize(&abs).map_err(|source| HashError::Io {
223 path: abs.clone(),
224 source,
225 })?;
226
227 if !canonical_abs.starts_with(&canonical_root) {
228 return Err(
229 ModuleWalkError::SymlinkEscapesRoot(relative.as_str().to_string()).into(),
230 );
231 }
232
233 let mut file = File::open(&canonical_abs).map_err(|source| HashError::Io {
234 path: canonical_abs.clone(),
235 source,
236 })?;
237 let len = file
238 .metadata()
239 .map_err(|source| HashError::Io {
240 path: canonical_abs.clone(),
241 source,
242 })?
243 .len();
244 sha.update(len.to_le_bytes());
245 let mut buffer = [0; 8192];
246 loop {
247 let bytes = file.read(&mut buffer).map_err(|source| HashError::Io {
248 path: canonical_abs.clone(),
249 source,
250 })?;
251
252 if bytes == 0 {
253 break;
254 }
255
256 sha.update(&buffer[..bytes]);
257 }
258 }
259
260 sha.update((self.paths.len() as u64).to_le_bytes());
261 Ok(ContentHash::from(<[u8; 32]>::from(sha.finalize())))
262 }
263}
264
265pub(crate) const NON_MODULE_CONTENT: &[&str] = &[".git"];
270
271pub fn hash_directory(root: impl AsRef<Path>) -> Result<ContentHash, HashError> {
275 let root = root.as_ref();
276 let mut hasher = Hasher::new(root.to_path_buf());
277
278 crate::module_walk::walk_module_tree(root, &mut |path: &Path, _size| {
279 let rel_path = path.strip_prefix(root).unwrap();
281 let rel = rel_path
282 .to_str()
283 .ok_or(RelativePathError::NonUtf8)?
284 .replace('\\', "/");
285 if rel == crate::SIGNATURE_FILENAME || rel == crate::LOCKFILE_FILENAME {
287 return Ok(());
288 }
289 hasher.try_add(rel)?;
290 Ok(())
291 })
292 .map_err(|e| match e {
293 crate::module_walk::WalkError::Walk(w) => HashError::from(w),
294 crate::module_walk::WalkError::Visitor(h) => h,
295 })?;
296
297 crate::tree::validate_tree(hasher.paths())?;
298
299 hasher.finalize()
300}
301
302#[cfg(test)]
303mod tests {
304 use std::fs;
305
306 use tempfile::tempdir;
307
308 use super::*;
309
310 #[test]
311 fn round_trips_via_display() {
312 let bytes = [0xAB; 32];
313 let hash = ContentHash::from(bytes);
314 let s = hash.to_string();
315 assert!(s.starts_with("sha256:"));
316 let parsed: ContentHash = s.parse().unwrap();
317 assert_eq!(parsed, hash);
318 }
319
320 #[test]
321 fn rejects_missing_prefix() {
322 assert!(matches!(
323 "ab".repeat(32).parse::<ContentHash>(),
324 Err(ContentHashError::MissingPrefix)
325 ));
326 }
327
328 #[test]
329 fn rejects_bad_hex() {
330 let s = format!("sha256:{}", "g".repeat(64));
331 assert!(matches!(
332 s.parse::<ContentHash>(),
333 Err(ContentHashError::InvalidHex)
334 ));
335 }
336
337 #[test]
338 fn rejects_unrecoverable_paths() {
339 let dir = tempdir().unwrap();
340 let mut h = Hasher::new(dir.path().to_path_buf());
341 for bad in [
342 "", ".", "..", "../escape", "/somewhere/not/under/root", "has\0null", "C:/win", "c:\\win", ] {
351 assert!(h.try_add(bad).is_err(), "accepted `{bad}`");
352 }
353 }
354
355 #[test]
356 fn normalizes_relative_paths() {
357 let dir = tempdir().unwrap();
358 fs::write(dir.path().join("foo.txt"), b"x").unwrap();
359
360 let mut h_clean = Hasher::new(dir.path().to_path_buf());
361 h_clean.try_add("foo.txt").unwrap();
362
363 let mut h_dotty = Hasher::new(dir.path().to_path_buf());
364 h_dotty.try_add("./bar/../foo.txt").unwrap();
365
366 assert_eq!(h_clean.finalize().unwrap(), h_dotty.finalize().unwrap());
367 }
368
369 #[test]
370 fn accepts_absolute_under_root() {
371 let dir = tempdir().unwrap();
372 fs::write(dir.path().join("foo.txt"), b"x").unwrap();
373
374 let mut h_rel = Hasher::new(dir.path().to_path_buf());
375 h_rel.try_add("foo.txt").unwrap();
376
377 let mut h_abs = Hasher::new(dir.path().to_path_buf());
378 h_abs
379 .try_add(dir.path().join("foo.txt").to_string_lossy().to_string())
380 .unwrap();
381
382 assert_eq!(h_rel.finalize().unwrap(), h_abs.finalize().unwrap());
383 }
384
385 #[test]
386 fn hashes_two_files_deterministically() {
387 let dir = tempdir().unwrap();
388 fs::write(dir.path().join("a.txt"), b"alpha").unwrap();
389 fs::write(dir.path().join("b.txt"), b"beta").unwrap();
390
391 let mut h1 = Hasher::new(dir.path().to_path_buf());
392 h1.try_add("a.txt").unwrap().try_add("b.txt").unwrap();
393 let d1 = h1.finalize().unwrap();
394
395 let mut h2 = Hasher::new(dir.path().to_path_buf());
397 h2.try_add("b.txt").unwrap().try_add("a.txt").unwrap();
398 let d2 = h2.finalize().unwrap();
399
400 assert_eq!(d1, d2, "digests should match regardless of `try_add` order");
401 }
402
403 #[test]
404 fn detects_path_content_boundary_collision() {
405 let dir1 = tempdir().unwrap();
410 fs::write(dir1.path().join("a"), b"Xbc").unwrap();
411
412 let dir2 = tempdir().unwrap();
413 fs::write(dir2.path().join("aXbc"), b"").unwrap();
414
415 let d1 = hash_directory(dir1.path()).unwrap();
416 let d2 = hash_directory(dir2.path()).unwrap();
417 assert_ne!(d1, d2);
418 }
419
420 #[test]
421 fn excludes_module_sig_and_lockfile() {
422 let dir = tempdir().unwrap();
423 fs::write(dir.path().join("a.txt"), b"keep").unwrap();
424 let d_clean = hash_directory(dir.path()).unwrap();
425
426 fs::write(dir.path().join(crate::SIGNATURE_FILENAME), b"sig").unwrap();
427 fs::write(dir.path().join(crate::LOCKFILE_FILENAME), b"lock").unwrap();
428 let d_with_excludes = hash_directory(dir.path()).unwrap();
429
430 assert_eq!(d_clean, d_with_excludes);
431 }
432
433 #[test]
434 fn hash_directory_rejects_nested_reserved_filename() {
435 let dir = tempdir().unwrap();
436 fs::create_dir(dir.path().join("nested")).unwrap();
437 fs::write(
438 dir.path().join("nested").join(crate::MANIFEST_FILENAME),
439 b"x",
440 )
441 .unwrap();
442 let err = hash_directory(dir.path()).unwrap_err();
443 assert!(matches!(
444 err,
445 HashError::Tree(crate::tree::TreeError::ReservedFilename {
446 name: crate::MANIFEST_FILENAME,
447 ..
448 })
449 ));
450 }
451
452 #[test]
453 fn finalize_errors_on_missing_file() {
454 let dir = tempdir().unwrap();
455 let mut h = Hasher::new(dir.path().to_path_buf());
456 h.try_add("missing.txt").unwrap();
457 assert!(matches!(h.finalize(), Err(HashError::Io { .. })));
458 }
459
460 #[test]
461 fn finalize_validates_reserved_filenames() {
462 let dir = tempdir().unwrap();
463 fs::create_dir(dir.path().join("nested")).unwrap();
464 fs::write(
465 dir.path().join("nested").join(crate::SIGNATURE_FILENAME),
466 b"x",
467 )
468 .unwrap();
469
470 let mut h = Hasher::new(dir.path().to_path_buf());
471 h.try_add("nested/module.sig").unwrap();
472 let err = h.finalize().unwrap_err();
473 assert!(matches!(
474 err,
475 HashError::Tree(crate::tree::TreeError::ReservedFilename {
476 name: crate::SIGNATURE_FILENAME,
477 ..
478 })
479 ));
480 }
481
482 #[test]
483 fn rejects_paths_colliding_under_nfc() {
484 let dir = tempdir().unwrap();
485 let mut h = Hasher::new(dir.path().to_path_buf());
486
487 let precomposed = "caf\u{00E9}.wdl";
489 let decomposed = "cafe\u{0301}.wdl";
490
491 h.try_add(precomposed).unwrap();
492 let err = h.try_add(decomposed).unwrap_err();
493 assert!(matches!(err, HashError::AmbiguousPath { .. }));
494 }
495
496 #[test]
497 fn nfc_normalizes_recorded_paths() {
498 let dir = tempdir().unwrap();
499 fs::write(dir.path().join("caf\u{00E9}.wdl"), b"x").unwrap();
500
501 let mut h_nfc = Hasher::new(dir.path().to_path_buf());
502 h_nfc.try_add("caf\u{00E9}.wdl").unwrap();
503
504 let mut h_nfd = Hasher::new(dir.path().to_path_buf());
505 h_nfd.try_add("cafe\u{0301}.wdl").unwrap();
506
507 assert_eq!(h_nfc.finalize().unwrap(), h_nfd.finalize().unwrap());
508 }
509
510 #[test]
511 fn hash_stable_despite_dot_git_and_sparse_json() {
512 let dir = tempdir().unwrap();
513 fs::write(
514 dir.path().join(crate::MANIFEST_FILENAME),
515 br#"{"name":"x","version":"1.0.0","license":"MIT"}"#,
516 )
517 .unwrap();
518 fs::write(dir.path().join("index.wdl"), b"workflow w {}").unwrap();
519
520 let hash1 = hash_directory(dir.path()).unwrap();
521
522 fs::create_dir(dir.path().join(".git")).unwrap();
523 fs::write(
524 dir.path().join(".git").join("HEAD"),
525 b"ref: refs/heads/main",
526 )
527 .unwrap();
528
529 let hash2 = hash_directory(dir.path()).unwrap();
530
531 assert_eq!(hash1, hash2, "`.git` must not affect the content hash");
532 }
533
534 fn symlink_file(target: &std::path::Path, link: &std::path::Path) {
535 #[cfg(unix)]
536 std::os::unix::fs::symlink(target, link).unwrap();
537 #[cfg(windows)]
538 std::os::windows::fs::symlink_file(target, link).unwrap();
539 }
540
541 #[test]
542 fn symlink_to_dot_git_is_rejected() {
543 let dir = tempdir().unwrap();
544 fs::write(
545 dir.path().join(crate::MANIFEST_FILENAME),
546 br#"{"name":"x","version":"1.0.0","license":"MIT"}"#,
547 )
548 .unwrap();
549 fs::create_dir(dir.path().join(".git")).unwrap();
550 fs::write(dir.path().join(".git").join("config"), b"[core]").unwrap();
551 symlink_file(
552 &dir.path().join(".git").join("config"),
553 &dir.path().join("sneaky.wdl"),
554 );
555 let err = hash_directory(dir.path()).unwrap_err();
556 assert!(
557 matches!(
558 err,
559 HashError::Walk(ModuleWalkError::SymlinkTargetsMetadata(_))
560 ),
561 "got: {err}"
562 );
563 }
564
565 #[test]
566 fn symlink_within_module_root_is_allowed() {
567 let dir = tempdir().unwrap();
568 fs::write(
569 dir.path().join(crate::MANIFEST_FILENAME),
570 br#"{"name":"x","version":"1.0.0","license":"MIT"}"#,
571 )
572 .unwrap();
573 fs::write(dir.path().join("real.wdl"), b"workflow w {}").unwrap();
574 symlink_file(&dir.path().join("real.wdl"), &dir.path().join("alias.wdl"));
575 hash_directory(dir.path()).unwrap();
576 }
577
578 #[test]
579 fn symlink_to_nested_dot_git_is_rejected() {
580 let dir = tempdir().unwrap();
581 fs::create_dir_all(dir.path().join("nested").join(".git")).unwrap();
582 fs::write(
583 dir.path().join("nested").join(".git").join("config"),
584 b"private metadata",
585 )
586 .unwrap();
587 symlink_file(
588 &dir.path().join("nested").join(".git").join("config"),
589 &dir.path().join("index.wdl"),
590 );
591 let err = hash_directory(dir.path()).unwrap_err();
592 assert!(
593 matches!(
594 err,
595 HashError::Walk(ModuleWalkError::SymlinkTargetsMetadata(_))
596 ),
597 "expected metadata symlink rejection, got: {err}"
598 );
599 }
600
601 #[test]
602 fn windows_and_unix_paths_hash_identically() {
603 let dir = tempdir().unwrap();
604 fs::create_dir(dir.path().join("sub")).unwrap();
605 fs::write(dir.path().join("root.wdl"), b"workflow w {}").unwrap();
606 fs::write(dir.path().join("sub").join("nested.wdl"), b"task t {}").unwrap();
607
608 let mut h_unix = Hasher::new(dir.path().to_path_buf());
610 for p in ["root.wdl", "sub/nested.wdl"] {
611 h_unix.try_add(p).unwrap();
612 }
613
614 let mut h_win = Hasher::new(dir.path().to_path_buf());
617 for p in ["root.wdl", "sub\\nested.wdl"] {
618 h_win.try_add(p.replace('\\', "/")).unwrap();
619 }
620
621 assert_eq!(
622 h_unix.finalize().unwrap(),
623 h_win.finalize().unwrap(),
624 "digests must be platform-independent after path-separator normalization"
625 );
626 }
627
628 #[test]
629 fn directory_symlink_cycle_is_rejected() {
630 let dir = tempdir().unwrap();
631 fs::write(dir.path().join("real.wdl"), b"version 1.2\n").unwrap();
632 fs::create_dir(dir.path().join("sub")).unwrap();
633 #[cfg(unix)]
634 std::os::unix::fs::symlink("..", dir.path().join("sub").join("loop")).unwrap();
635 #[cfg(windows)]
636 std::os::windows::fs::symlink_dir("..", dir.path().join("sub").join("loop")).unwrap();
637 let err = hash_directory(dir.path()).unwrap_err();
638 assert!(
639 matches!(
640 err,
641 HashError::Walk(
642 ModuleWalkError::DirectorySymlink(_)
643 | ModuleWalkError::SymlinkEscapesRoot(_)
644 | ModuleWalkError::SymlinkTargetsMetadata(_)
645 )
646 ),
647 "directory symlink cycles must be rejected, got: {err}"
648 );
649 }
650}