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 (resolving symbolic links)
201    /// before reading; if the resolved target falls outside the module
202    /// root, the module is rejected per the spec's symlink-containment
203    /// rule. Without this check, a symbolic link inside the module could
204    /// pull bytes from elsewhere on the filesystem into the digest.
205    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        // NOTE: paths are sorted by the [`BTreeSet`].
216        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
265/// Computes the content hash of a directory by walking it (excluding the
266/// spec-mandated exclusions `module.sig` and `module-lock.json`).
267/// Directory and file names that are not module content and should
268/// be excluded from hashing, limit checks, and content walks.
269pub(crate) const NON_MODULE_CONTENT: &[&str] = &[".git"];
270
271/// Walks `root` and computes the deterministic content hash of the
272/// module directory, skipping non-module content and spec-defined
273/// exclusions.
274pub 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        // SAFETY: the walker only yields paths under `root`.
280        let rel_path = path.strip_prefix(root).unwrap();
281        let rel = rel_path
282            .to_str()
283            .ok_or(RelativePathError::NonUtf8)?
284            .replace('\\', "/");
285        // Spec-defined hash exclusions (present in tree but not hashed).
286        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            "",                          // empty
343            ".",                         // resolves to empty
344            "..",                        // escapes root
345            "../escape",                 // escapes root
346            "/somewhere/not/under/root", // absolute, not under root
347            "has\0null",                 // null byte
348            "C:/win",                    // Windows drive letter
349            "c:\\win",                   // lowercase drive letter
350        ] {
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        // Same files, opposite add order.
396        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        // Without per-field length prefixes, `{a: "Xbc"}` and `{aXbc: ""}`
406        // would both feed the byte stream `aXbc` into the hasher and collide.
407        // The path-length and content-length prefixes shift the boundary,
408        // making the encoding injective.
409        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        // Both forms of `é` normalize to the same NFC sequence.
488        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        // Simulate Unix-style paths (as `hash_directory` would produce on Unix).
609        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        // Simulate Windows-style paths after the `\` → `/` normalization that
615        // `hash_directory` applies before calling `try_add`.
616        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}