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::Deserialize;
12use serde::Serialize;
13use sha2::Digest;
14use sha2::Sha256;
15use thiserror::Error;
16
17use crate::RelativePath;
18use crate::RelativePathError;
19use crate::tree::TreeError;
20
21/// An error during content hashing.
22#[derive(Debug, Error)]
23pub enum HashError {
24    /// A path supplied to [`Hasher::try_add`] failed relative-path
25    /// validation.
26    #[error(transparent)]
27    InvalidPath(#[from] RelativePathError),
28
29    /// An absolute path was supplied that does not live under the module
30    /// root.
31    #[error("absolute path `{0}` is not under the module root")]
32    AbsoluteNotUnderRoot(String),
33
34    /// A symbolic link target resolves outside the module root.
35    #[error("symbolic link `{0}` resolves outside the module root")]
36    SymlinkEscapesRoot(String),
37
38    /// A new path collides under Unicode Normalization Form C (NFC) with a
39    /// path that was already recorded. The spec requires the module's set
40    /// of relative paths to be unique under NFC.
41    #[error("path `{path}` collides with an already-recorded path under NFC form `{nfc}`")]
42    AmbiguousPath {
43        /// The newly-submitted path that collided.
44        path: String,
45        /// The shared NFC form.
46        nfc: String,
47    },
48
49    /// I/O failure while reading a file.
50    #[error("failed to read `{path}`")]
51    Io {
52        /// The path of the file that failed to read.
53        path: PathBuf,
54        /// The underlying I/O error.
55        #[source]
56        source: io::Error,
57    },
58
59    /// A module file-tree validation error (reserved-filename placement,
60    /// NFC duplicate paths).
61    #[error(transparent)]
62    Tree(#[from] TreeError),
63}
64
65/// An error parsing a [`ContentHash`].
66#[derive(Debug, Error)]
67pub enum ContentHashError {
68    /// The string does not start with the required `sha256:` prefix.
69    #[error("content hash must start with `sha256:`")]
70    MissingPrefix,
71
72    /// The hex portion of the hash is not 64 characters.
73    #[error("content hash must be exactly 64 hex characters; got {0}")]
74    WrongLength(usize),
75
76    /// The hex portion contains non-hex characters.
77    #[error("content hash contains non-hex characters")]
78    InvalidHex,
79}
80
81/// The prefix used in the wire form of a [`ContentHash`].
82const SHA256_PREFIX: &str = "sha256:";
83
84/// Domain-separation magic prepended to the SHA-256 input by
85/// [`Hasher::finalize`].
86const CONTENT_HASH_MAGIC: &[u8] = b"wdl-module-content\0v1\0";
87
88/// A 32-byte SHA-256 module content hash.
89#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
90#[serde(into = "String", try_from = "String")]
91pub struct ContentHash([u8; 32]);
92
93impl ContentHash {
94    /// Returns the raw 32-byte digest.
95    pub const fn as_bytes(&self) -> &[u8; 32] {
96        &self.0
97    }
98
99    /// Returns the hash as a 64-character lowercase hex string (without the
100    /// `sha256:` prefix).
101    pub fn to_hex(&self) -> String {
102        hex::encode(self.0)
103    }
104}
105
106impl From<[u8; 32]> for ContentHash {
107    fn from(bytes: [u8; 32]) -> Self {
108        Self(bytes)
109    }
110}
111
112impl From<ContentHash> for String {
113    fn from(hash: ContentHash) -> Self {
114        hash.to_string()
115    }
116}
117
118impl TryFrom<String> for ContentHash {
119    type Error = ContentHashError;
120
121    fn try_from(s: String) -> Result<Self, Self::Error> {
122        s.parse()
123    }
124}
125
126impl fmt::Display for ContentHash {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        write!(f, "{SHA256_PREFIX}{}", hex::encode(self.0))
129    }
130}
131
132impl FromStr for ContentHash {
133    type Err = ContentHashError;
134
135    fn from_str(s: &str) -> Result<Self, Self::Err> {
136        let hex = s
137            .strip_prefix(SHA256_PREFIX)
138            .ok_or(ContentHashError::MissingPrefix)?;
139        if hex.len() != 64 {
140            return Err(ContentHashError::WrongLength(hex.len()));
141        }
142        let bytes: [u8; 32] = hex::decode(hex)
143            .map_err(|_| ContentHashError::InvalidHex)?
144            .try_into()
145            .map_err(|_| ContentHashError::WrongLength(hex.len()))?;
146        Ok(Self(bytes))
147    }
148}
149
150/// An incremental content hasher for a module directory.
151///
152/// `try_add` records relative paths into a [`BTreeSet`], so they are kept in
153/// lexicographic order as they are inserted. `finalize` walks them in that
154/// order, opens each file under the configured root, and feeds path bytes
155/// plus raw file contents into a single SHA-256 state.
156#[derive(Debug)]
157pub struct Hasher {
158    /// The directory under which all recorded relative paths resolve.
159    root: PathBuf,
160    /// The set of relative paths recorded so far, kept sorted.
161    paths: BTreeSet<RelativePath>,
162}
163
164impl Hasher {
165    /// Creates a new [`Hasher`] rooted at `root`.
166    pub fn new(root: impl Into<PathBuf>) -> Self {
167        Self {
168            root: root.into(),
169            paths: BTreeSet::new(),
170        }
171    }
172
173    /// Returns an iterator over the recorded paths in lexicographic order.
174    pub fn paths(&self) -> impl Iterator<Item = &RelativePath> {
175        self.paths.iter()
176    }
177
178    /// Records a path relative to the hasher's root.
179    ///
180    /// Accepts an absolute path under `root` (which is converted to a relative
181    /// form) or any [`RelativePath`]-convertible input.
182    pub fn try_add(&mut self, path: impl Into<String>) -> Result<&mut Self, HashError> {
183        let raw = path.into();
184
185        let candidate = Path::new(&raw);
186        let relative = if candidate.is_absolute() {
187            candidate
188                .strip_prefix(&self.root)
189                .map_err(|_| HashError::AbsoluteNotUnderRoot(raw.clone()))?
190        } else {
191            candidate
192        };
193
194        let rel = RelativePath::try_from(relative)?;
195
196        let nfc = rel.as_str().to_string();
197        if !self.paths.insert(rel) {
198            return Err(HashError::AmbiguousPath { path: raw, nfc });
199        }
200        Ok(self)
201    }
202
203    /// Computes the [`ContentHash`] of the recorded paths.
204    ///
205    /// Each file's full path is canonicalized (resolving symbolic links)
206    /// before reading; if the resolved target falls outside the module
207    /// root, the module is rejected per the spec's symlink-containment
208    /// rule. Without this check, a symbolic link inside the module could
209    /// pull bytes from elsewhere on the filesystem into the digest.
210    pub fn finalize(self) -> Result<ContentHash, HashError> {
211        crate::tree::validate_tree(self.paths())?;
212
213        let canonical_root = std::fs::canonicalize(&self.root).map_err(|source| HashError::Io {
214            path: self.root.clone(),
215            source,
216        })?;
217
218        let mut sha = Sha256::new();
219        sha.update(CONTENT_HASH_MAGIC);
220        // NOTE: paths are sorted by the [`BTreeSet`].
221        for relative in &self.paths {
222            let bytes = relative.as_str().as_bytes();
223            sha.update((bytes.len() as u64).to_le_bytes());
224            sha.update(bytes);
225
226            let abs = self.root.join(relative);
227            let canonical_abs = std::fs::canonicalize(&abs).map_err(|source| HashError::Io {
228                path: abs.clone(),
229                source,
230            })?;
231
232            if !canonical_abs.starts_with(&canonical_root) {
233                return Err(HashError::SymlinkEscapesRoot(relative.as_str().to_string()));
234            }
235
236            let mut file = File::open(&canonical_abs).map_err(|source| HashError::Io {
237                path: canonical_abs.clone(),
238                source,
239            })?;
240            let len = file
241                .metadata()
242                .map_err(|source| HashError::Io {
243                    path: canonical_abs.clone(),
244                    source,
245                })?
246                .len();
247            sha.update(len.to_le_bytes());
248            io::copy(&mut file, &mut sha).map_err(|source| HashError::Io {
249                path: canonical_abs,
250                source,
251            })?;
252        }
253
254        sha.update((self.paths.len() as u64).to_le_bytes());
255        Ok(ContentHash::from(<[u8; 32]>::from(sha.finalize())))
256    }
257}
258
259/// Computes the content hash of a directory by walking it (excluding the
260/// spec-mandated exclusions `module.sig` and `module-lock.json`).
261pub fn hash_directory(root: impl AsRef<Path>) -> Result<ContentHash, HashError> {
262    let root = root.as_ref();
263    let mut hasher = Hasher::new(root.to_path_buf());
264    let mut stack = vec![root.to_path_buf()];
265    while let Some(dir) = stack.pop() {
266        let entries = std::fs::read_dir(&dir).map_err(|source| HashError::Io {
267            path: dir.clone(),
268            source,
269        })?;
270        for entry in entries {
271            let entry = entry.map_err(|source| HashError::Io {
272                path: dir.clone(),
273                source,
274            })?;
275            let path = entry.path();
276            let file_type = entry.file_type().map_err(|source| HashError::Io {
277                path: path.clone(),
278                source,
279            })?;
280            if file_type.is_dir() {
281                stack.push(path);
282                continue;
283            }
284
285            // SAFETY: `path` was produced by `read_dir(dir)` for a `dir`
286            // whose ancestor stack started at `root`, so it always lives
287            // under `root`.
288            let rel_path = path.strip_prefix(root).unwrap();
289            let rel = rel_path
290                .to_str()
291                .ok_or(RelativePathError::NonUtf8)?
292                .replace('\\', "/");
293            if rel == crate::SIGNATURE_FILENAME || rel == crate::LOCKFILE_FILENAME {
294                continue;
295            }
296            hasher.try_add(rel)?;
297        }
298    }
299
300    crate::tree::validate_tree(hasher.paths())?;
301
302    hasher.finalize()
303}
304
305#[cfg(test)]
306mod tests {
307    use std::fs;
308
309    use tempfile::tempdir;
310
311    use super::*;
312
313    #[test]
314    fn round_trips_via_display() {
315        let bytes = [0xAB; 32];
316        let hash = ContentHash::from(bytes);
317        let s = hash.to_string();
318        assert!(s.starts_with("sha256:"));
319        let parsed: ContentHash = s.parse().unwrap();
320        assert_eq!(parsed, hash);
321    }
322
323    #[test]
324    fn rejects_missing_prefix() {
325        assert!(matches!(
326            "ab".repeat(32).parse::<ContentHash>(),
327            Err(ContentHashError::MissingPrefix)
328        ));
329    }
330
331    #[test]
332    fn rejects_bad_hex() {
333        let s = format!("sha256:{}", "g".repeat(64));
334        assert!(matches!(
335            s.parse::<ContentHash>(),
336            Err(ContentHashError::InvalidHex)
337        ));
338    }
339
340    #[test]
341    fn rejects_unrecoverable_paths() {
342        let dir = tempdir().unwrap();
343        let mut h = Hasher::new(dir.path().to_path_buf());
344        for bad in [
345            "",                          // empty
346            ".",                         // resolves to empty
347            "..",                        // escapes root
348            "../escape",                 // escapes root
349            "/somewhere/not/under/root", // absolute, not under root
350            "has\0null",                 // null byte
351            "C:/win",                    // Windows drive letter
352            "c:\\win",                   // lowercase drive letter
353        ] {
354            assert!(h.try_add(bad).is_err(), "accepted `{bad}`");
355        }
356    }
357
358    #[test]
359    fn normalizes_relative_paths() {
360        let dir = tempdir().unwrap();
361        fs::write(dir.path().join("foo.txt"), b"x").unwrap();
362
363        let mut h_clean = Hasher::new(dir.path().to_path_buf());
364        h_clean.try_add("foo.txt").unwrap();
365
366        let mut h_dotty = Hasher::new(dir.path().to_path_buf());
367        h_dotty.try_add("./bar/../foo.txt").unwrap();
368
369        assert_eq!(h_clean.finalize().unwrap(), h_dotty.finalize().unwrap());
370    }
371
372    #[test]
373    fn accepts_absolute_under_root() {
374        let dir = tempdir().unwrap();
375        fs::write(dir.path().join("foo.txt"), b"x").unwrap();
376
377        let mut h_rel = Hasher::new(dir.path().to_path_buf());
378        h_rel.try_add("foo.txt").unwrap();
379
380        let mut h_abs = Hasher::new(dir.path().to_path_buf());
381        h_abs
382            .try_add(dir.path().join("foo.txt").to_string_lossy().to_string())
383            .unwrap();
384
385        assert_eq!(h_rel.finalize().unwrap(), h_abs.finalize().unwrap());
386    }
387
388    #[test]
389    fn hashes_two_files_deterministically() {
390        let dir = tempdir().unwrap();
391        fs::write(dir.path().join("a.txt"), b"alpha").unwrap();
392        fs::write(dir.path().join("b.txt"), b"beta").unwrap();
393
394        let mut h1 = Hasher::new(dir.path().to_path_buf());
395        h1.try_add("a.txt").unwrap().try_add("b.txt").unwrap();
396        let d1 = h1.finalize().unwrap();
397
398        // Same files, opposite add order.
399        let mut h2 = Hasher::new(dir.path().to_path_buf());
400        h2.try_add("b.txt").unwrap().try_add("a.txt").unwrap();
401        let d2 = h2.finalize().unwrap();
402
403        assert_eq!(d1, d2, "digests should match regardless of `try_add` order");
404    }
405
406    #[test]
407    fn detects_path_content_boundary_collision() {
408        // Without per-field length prefixes, `{a: "Xbc"}` and `{aXbc: ""}`
409        // would both feed the byte stream `aXbc` into the hasher and collide.
410        // The path-length and content-length prefixes shift the boundary,
411        // making the encoding injective.
412        let dir1 = tempdir().unwrap();
413        fs::write(dir1.path().join("a"), b"Xbc").unwrap();
414
415        let dir2 = tempdir().unwrap();
416        fs::write(dir2.path().join("aXbc"), b"").unwrap();
417
418        let d1 = hash_directory(dir1.path()).unwrap();
419        let d2 = hash_directory(dir2.path()).unwrap();
420        assert_ne!(d1, d2);
421    }
422
423    #[test]
424    fn excludes_module_sig_and_lockfile() {
425        let dir = tempdir().unwrap();
426        fs::write(dir.path().join("a.txt"), b"keep").unwrap();
427        let d_clean = hash_directory(dir.path()).unwrap();
428
429        fs::write(dir.path().join(crate::SIGNATURE_FILENAME), b"sig").unwrap();
430        fs::write(dir.path().join(crate::LOCKFILE_FILENAME), b"lock").unwrap();
431        let d_with_excludes = hash_directory(dir.path()).unwrap();
432
433        assert_eq!(d_clean, d_with_excludes);
434    }
435
436    #[test]
437    fn hash_directory_rejects_nested_reserved_filename() {
438        let dir = tempdir().unwrap();
439        fs::create_dir(dir.path().join("nested")).unwrap();
440        fs::write(
441            dir.path().join("nested").join(crate::MANIFEST_FILENAME),
442            b"x",
443        )
444        .unwrap();
445        let err = hash_directory(dir.path()).unwrap_err();
446        assert!(matches!(
447            err,
448            HashError::Tree(crate::TreeError::ReservedFilename {
449                name: crate::MANIFEST_FILENAME,
450                ..
451            })
452        ));
453    }
454
455    #[test]
456    fn finalize_errors_on_missing_file() {
457        let dir = tempdir().unwrap();
458        let mut h = Hasher::new(dir.path().to_path_buf());
459        h.try_add("missing.txt").unwrap();
460        assert!(matches!(h.finalize(), Err(HashError::Io { .. })));
461    }
462
463    #[test]
464    fn finalize_validates_reserved_filenames() {
465        let dir = tempdir().unwrap();
466        fs::create_dir(dir.path().join("nested")).unwrap();
467        fs::write(
468            dir.path().join("nested").join(crate::SIGNATURE_FILENAME),
469            b"x",
470        )
471        .unwrap();
472
473        let mut h = Hasher::new(dir.path().to_path_buf());
474        h.try_add("nested/module.sig").unwrap();
475        let err = h.finalize().unwrap_err();
476        assert!(matches!(
477            err,
478            HashError::Tree(crate::TreeError::ReservedFilename {
479                name: crate::SIGNATURE_FILENAME,
480                ..
481            })
482        ));
483    }
484
485    #[test]
486    fn rejects_paths_colliding_under_nfc() {
487        let dir = tempdir().unwrap();
488        let mut h = Hasher::new(dir.path().to_path_buf());
489
490        // Both forms of `é` normalize to the same NFC sequence.
491        let precomposed = "caf\u{00E9}.wdl";
492        let decomposed = "cafe\u{0301}.wdl";
493
494        h.try_add(precomposed).unwrap();
495        let err = h.try_add(decomposed).unwrap_err();
496        assert!(matches!(err, HashError::AmbiguousPath { .. }));
497    }
498
499    #[test]
500    fn nfc_normalizes_recorded_paths() {
501        let dir = tempdir().unwrap();
502        fs::write(dir.path().join("caf\u{00E9}.wdl"), b"x").unwrap();
503
504        let mut h_nfc = Hasher::new(dir.path().to_path_buf());
505        h_nfc.try_add("caf\u{00E9}.wdl").unwrap();
506
507        let mut h_nfd = Hasher::new(dir.path().to_path_buf());
508        h_nfd.try_add("cafe\u{0301}.wdl").unwrap();
509
510        assert_eq!(h_nfc.finalize().unwrap(), h_nfd.finalize().unwrap());
511    }
512}