Skip to main content

wdl_modules/
hash.rs

1//! Content hashing per the WDL module spec.
2
3use 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/// An error during content hashing.
24#[derive(Debug, Error)]
25pub enum HashError {
26    /// A path supplied to [`Hasher::try_add`] failed relative-path
27    /// validation.
28    #[error(transparent)]
29    InvalidPath(#[from] RelativePathError),
30
31    /// An absolute path was supplied that does not live under the module
32    /// root.
33    #[error("absolute path `{0}` is not under the module root")]
34    AbsoluteNotUnderRoot(String),
35
36    /// A new path collides under Unicode Normalization Form C (NFC) with a
37    /// path that was already recorded. The spec requires the module's set
38    /// of relative paths to be unique under NFC.
39    #[error("path `{path}` collides with an already-recorded path under NFC form `{nfc}`")]
40    AmbiguousPath {
41        /// The newly-submitted path that collided.
42        path: String,
43        /// The shared NFC form.
44        nfc: String,
45    },
46
47    /// I/O failure while reading a file.
48    #[error("failed to read `{path}`")]
49    Io {
50        /// The path of the file that failed to read.
51        path: PathBuf,
52        /// The underlying I/O error.
53        #[source]
54        source: io::Error,
55    },
56
57    /// A tree walk error (symlink containment, metadata target, etc.).
58    #[error(transparent)]
59    Walk(#[from] ModuleWalkError),
60
61    /// A module file-tree validation error (reserved-filename placement,
62    /// NFC duplicate paths).
63    #[error(transparent)]
64    Tree(#[from] TreeError),
65}
66
67/// An error parsing a [`ContentHash`].
68#[derive(Debug, Error)]
69pub enum ContentHashError {
70    /// The string does not start with the required `sha256:` prefix.
71    #[error("content hash must start with `sha256:`")]
72    MissingPrefix,
73
74    /// The hex portion of the hash is not 64 characters.
75    #[error("content hash must be exactly 64 hex characters; got {0}")]
76    WrongLength(usize),
77
78    /// The hex portion contains non-hex characters.
79    #[error("content hash contains non-hex characters")]
80    InvalidHex,
81}
82
83/// The prefix used in the wire form of a [`ContentHash`].
84const SHA256_PREFIX: &str = "sha256:";
85
86/// Domain-separation magic prepended to the SHA-256 input by
87/// [`Hasher::finalize`].
88const CONTENT_HASH_MAGIC: &[u8] = b"wdl-module-content\0v1\0";
89
90/// A 32-byte SHA-256 module content hash.
91#[derive(
92    Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, SerializeDisplay, DeserializeFromStr,
93)]
94pub struct ContentHash([u8; 32]);
95
96impl ContentHash {
97    /// Returns the raw 32-byte digest.
98    pub const fn as_bytes(&self) -> &[u8; 32] {
99        &self.0
100    }
101
102    /// Returns the hash as a 64-character lowercase hex string (without the
103    /// `sha256:` prefix).
104    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/// An incremental content hasher for a module directory.
146///
147/// `try_add` records relative paths into a [`BTreeSet`], so they are kept in
148/// lexicographic order as they are inserted. `finalize` walks them in that
149/// order, opens each file under the configured root, and feeds path bytes
150/// plus raw file contents into a single SHA-256 state.
151#[derive(Debug)]
152pub struct Hasher {
153    /// The directory under which all recorded relative paths resolve.
154    root: PathBuf,
155    /// The set of relative paths recorded so far, kept sorted.
156    paths: BTreeSet<RelativePath>,
157}
158
159impl Hasher {
160    /// Creates a new [`Hasher`] rooted at `root`.
161    pub fn new(root: impl Into<PathBuf>) -> Self {
162        Self {
163            root: root.into(),
164            paths: BTreeSet::new(),
165        }
166    }
167
168    /// Returns an iterator over the recorded paths in lexicographic order.
169    pub fn paths(&self) -> impl Iterator<Item = &RelativePath> {
170        self.paths.iter()
171    }
172
173    /// Records a path relative to the hasher's root.
174    ///
175    /// Accepts an absolute path under `root` (which is converted to a relative
176    /// form) or any [`RelativePath`]-convertible input.
177    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    /// Computes the [`ContentHash`] of the recorded paths.
199    ///
200    /// Each file's full path is canonicalized before reading; if the
201    /// resolved target falls outside the module root, the module is
202    /// rejected. The tree walk already forbids symbolic links anywhere
203    /// in a module, so this is a defensive backstop.
204    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        // NOTE: paths are sorted by the [`BTreeSet`].
215        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
262/// Computes the content hash of a directory by walking it (excluding the
263/// spec-mandated exclusions `module.sig` and `module-lock.json`).
264/// Directory and file names that are not module content and should
265/// be excluded from hashing, limit checks, and content walks.
266pub(crate) const NON_MODULE_CONTENT: &[&str] = &[".git", ".sprocket"];
267
268/// Returns `true` when a module-relative path is omitted from content hashing.
269pub(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
280/// Walks `root` and computes the deterministic content hash of the
281/// module directory, skipping non-module content and spec-defined
282/// exclusions.
283pub 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        // SAFETY: the walker only yields paths under `root`.
289        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            "",                          // empty
351            ".",                         // resolves to empty
352            "..",                        // escapes root
353            "../escape",                 // escapes root
354            "/somewhere/not/under/root", // absolute, not under root
355            "has\0null",                 // null byte
356            "C:/win",                    // Windows drive letter
357            "c:\\win",                   // lowercase drive letter
358        ] {
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        // Same files, opposite add order.
404        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        // Without per-field length prefixes, `{a: "Xbc"}` and `{aXbc: ""}`
414        // would both feed the byte stream `aXbc` into the hasher and collide.
415        // The path-length and content-length prefixes shift the boundary,
416        // making the encoding injective.
417        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        // Both forms of `é` normalize to the same NFC sequence.
515        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        // Symbolic links are not permitted anywhere in a module, even
592        // when they point at an in-root file.
593        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        // Simulate Unix-style paths (as `hash_directory` would produce on Unix).
636        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        // Simulate Windows-style paths after the `\` → `/` normalization that
642        // `hash_directory` applies before calling `try_add`.
643        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}