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