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