Skip to main content

wdl_modules/
tree.rs

1//! Validation rules for the module file tree.
2//!
3//! These checks complement [`Hasher`](crate::hash::Hasher) by validating
4//! structural rules that span the whole tree rather than any single path.
5//! Per-path validity is already a guarantee of [`RelativePath`]; this
6//! module only enforces the cross-path rules: reserved filename placement
7//! and uniqueness under Unicode Normalization Form C.
8
9use std::collections::HashSet;
10
11use thiserror::Error;
12
13use crate::LOCKFILE_FILENAME;
14use crate::MANIFEST_FILENAME;
15use crate::SIGNATURE_FILENAME;
16use crate::relative_path::RelativePath;
17
18/// An error reported by [`validate_tree`].
19#[derive(Debug, Error)]
20pub enum TreeError {
21    /// A reserved filename was found at a non-root location. The names
22    /// `module.json`, `module-lock.json`, and `module.sig` may appear only
23    /// at the module root.
24    #[error("reserved filename `{name}` is only permitted at the module root; found at `{path}`")]
25    ReservedFilename {
26        /// The reserved filename that was misplaced.
27        name: &'static str,
28        /// The path under which the reserved filename appeared.
29        path: String,
30    },
31
32    /// Two distinct paths normalize to the same Unicode Normalization Form
33    /// C (NFC) form. The spec requires the module's set of relative paths
34    /// to be unique under NFC.
35    #[error("paths collapse to the same NFC form `{nfc}`")]
36    AmbiguousPath {
37        /// The shared NFC form.
38        nfc: String,
39    },
40}
41
42/// Validates the cross-path rules of a module file tree.
43///
44/// Two checks are performed.
45///
46/// - The reserved filenames `module.json`, `module-lock.json`, and `module.sig`
47///   may appear only at the module root (i.e. as a single path component, not
48///   nested in any subdirectory).
49/// - No two distinct paths may collapse to the same Unicode Normalization Form
50///   C (NFC).
51pub fn validate_tree<'a, I>(paths: I) -> Result<(), TreeError>
52where
53    I: IntoIterator<Item = &'a RelativePath>,
54{
55    let mut seen: HashSet<&RelativePath> = HashSet::new();
56    for path in paths {
57        if let Some((_, basename)) = path.as_str().rsplit_once('/')
58            && let Some(reserved) = [MANIFEST_FILENAME, LOCKFILE_FILENAME, SIGNATURE_FILENAME]
59                .into_iter()
60                .find(|r| *r == basename)
61        {
62            return Err(TreeError::ReservedFilename {
63                name: reserved,
64                path: path.as_str().to_string(),
65            });
66        }
67
68        if !seen.insert(path) {
69            return Err(TreeError::AmbiguousPath {
70                nfc: path.as_str().to_string(),
71            });
72        }
73    }
74    Ok(())
75}
76
77#[cfg(test)]
78mod tests {
79    use std::str::FromStr;
80
81    use super::*;
82
83    fn rel(s: &str) -> RelativePath {
84        RelativePath::from_str(s).unwrap()
85    }
86
87    #[test]
88    fn accepts_root_reserved_filenames() {
89        validate_tree(&[
90            rel(MANIFEST_FILENAME),
91            rel(LOCKFILE_FILENAME),
92            rel(SIGNATURE_FILENAME),
93            rel("index.wdl"),
94        ])
95        .unwrap();
96    }
97
98    #[test]
99    fn rejects_nested_manifest() {
100        let err = validate_tree(&[rel("src/module.json")]).unwrap_err();
101        assert!(matches!(
102            err,
103            TreeError::ReservedFilename {
104                name: MANIFEST_FILENAME,
105                ..
106            }
107        ));
108    }
109
110    #[test]
111    fn rejects_nested_lockfile() {
112        let err = validate_tree(&[rel("nested/dir/module-lock.json")]).unwrap_err();
113        assert!(matches!(
114            err,
115            TreeError::ReservedFilename {
116                name: LOCKFILE_FILENAME,
117                ..
118            }
119        ));
120    }
121
122    #[test]
123    fn rejects_nested_signature() {
124        let err = validate_tree(&[rel("sub/module.sig")]).unwrap_err();
125        assert!(matches!(
126            err,
127            TreeError::ReservedFilename {
128                name: SIGNATURE_FILENAME,
129                ..
130            }
131        ));
132    }
133
134    #[test]
135    fn rejects_paths_colliding_under_nfc() {
136        let err = validate_tree(&[rel("caf\u{00E9}.wdl"), rel("cafe\u{0301}.wdl")]).unwrap_err();
137        assert!(matches!(err, TreeError::AmbiguousPath { .. }));
138    }
139
140    #[test]
141    fn accepts_distinct_unicode_paths() {
142        validate_tree(&[rel("alpha.wdl"), rel("beta.wdl"), rel("caf\u{00E9}.wdl")]).unwrap();
143    }
144}