Skip to main content

wdl_modules/project/
validation.rs

1use std::fmt;
2use std::path::Path;
3use std::path::PathBuf;
4
5use thiserror::Error;
6
7use super::ModuleProject;
8use crate::hash::ContentHash;
9use crate::hash::HashError;
10
11/// A manifest-referenced file required for a valid module project.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum ProjectFileKind {
14    /// The module's WDL entrypoint.
15    Entrypoint,
16    /// The module's readme.
17    Readme,
18}
19
20impl fmt::Display for ProjectFileKind {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::Entrypoint => f.write_str("entrypoint"),
24            Self::Readme => f.write_str("readme"),
25        }
26    }
27}
28
29/// An error validating a loaded module project.
30#[derive(Debug, Error)]
31pub enum ProjectValidationError {
32    /// A required manifest-referenced file does not exist.
33    #[error("module {kind} `{path}` does not exist")]
34    MissingFile {
35        /// The role of the missing file.
36        kind: ProjectFileKind,
37        /// The resolved path that was missing.
38        path: PathBuf,
39    },
40
41    /// A required manifest reference does not resolve to a regular file.
42    #[error("module {kind} `{path}` is not a regular file")]
43    NotRegularFile {
44        /// The role of the invalid file.
45        kind: ProjectFileKind,
46        /// The resolved path that was not a regular file.
47        path: PathBuf,
48    },
49
50    /// A required manifest reference is omitted from module content hashing.
51    #[error("module {kind} `{path}` is excluded from module content hashing")]
52    ExcludedFile {
53        /// The role of the excluded file.
54        kind: ProjectFileKind,
55        /// The resolved path that would be omitted from the digest.
56        path: PathBuf,
57    },
58
59    /// Inspecting a required manifest-referenced file failed.
60    #[error("failed to inspect module {kind} `{path}`")]
61    Io {
62        /// The role of the file.
63        kind: ProjectFileKind,
64        /// The resolved path that could not be inspected.
65        path: PathBuf,
66        /// The underlying I/O error.
67        #[source]
68        source: std::io::Error,
69    },
70
71    /// Module content violated a tree or hashing invariant.
72    #[error(transparent)]
73    Content(#[from] HashError),
74}
75
76impl ModuleProject {
77    /// Validates this project's manifest-referenced files and content tree.
78    ///
79    /// The returned digest can be reused for signature verification.
80    pub fn validate(&self) -> Result<ContentHash, ProjectValidationError> {
81        validate_regular_file(
82            &self.root,
83            self.manifest().entrypoint_filename(),
84            ProjectFileKind::Entrypoint,
85        )?;
86        if let Some(readme) = self.manifest().readme_filename() {
87            validate_regular_file(&self.root, readme, ProjectFileKind::Readme)?;
88        }
89
90        crate::hash::hash_directory(&self.root).map_err(Into::into)
91    }
92}
93
94/// Validates that a manifest-referenced path exists as a regular file and is
95/// included in module content hashing.
96fn validate_regular_file(
97    root: &Path,
98    relative_path: &Path,
99    kind: ProjectFileKind,
100) -> Result<(), ProjectValidationError> {
101    let path = root.join(relative_path);
102    if crate::hash::path_is_excluded_from_hash(relative_path) {
103        return Err(ProjectValidationError::ExcludedFile { kind, path });
104    }
105
106    let metadata = match std::fs::symlink_metadata(&path) {
107        Ok(metadata) => metadata,
108        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
109            return Err(ProjectValidationError::MissingFile { kind, path });
110        }
111        Err(source) => {
112            return Err(ProjectValidationError::Io { kind, path, source });
113        }
114    };
115    if !metadata.is_file() {
116        return Err(ProjectValidationError::NotRegularFile { kind, path });
117    }
118    Ok(())
119}
120
121#[cfg(test)]
122mod tests {
123    use std::error::Error;
124
125    use super::*;
126
127    fn project(manifest: &str) -> Result<(tempfile::TempDir, ModuleProject), Box<dyn Error>> {
128        let directory = tempfile::tempdir()?;
129        let manifest_path = directory.path().join(crate::MANIFEST_FILENAME);
130        std::fs::write(&manifest_path, manifest)?;
131        let project = ModuleProject::load(manifest_path)?;
132        Ok((directory, project))
133    }
134
135    #[test]
136    fn validates_default_entrypoint_and_readme() -> Result<(), Box<dyn Error>> {
137        let (directory, project) = project(r#"{"name":"example","license":"MIT"}"#)?;
138        std::fs::write(directory.path().join("index.wdl"), "version 1.3\n")?;
139        std::fs::write(directory.path().join("README.md"), "# Example\n")?;
140
141        let checksum = project.validate()?;
142        assert_eq!(checksum, crate::hash::hash_directory(directory.path())?);
143        Ok(())
144    }
145
146    #[test]
147    fn validates_custom_references() -> Result<(), Box<dyn Error>> {
148        let (directory, project) = project(
149            r#"{
150                "name":"example",
151                "license":"MIT",
152                "entrypoint":"src/main.wdl",
153                "readme":"docs/guide.md"
154            }"#,
155        )?;
156        std::fs::create_dir_all(directory.path().join("src"))?;
157        std::fs::create_dir_all(directory.path().join("docs"))?;
158        std::fs::write(directory.path().join("src/main.wdl"), "version 1.3\n")?;
159        std::fs::write(directory.path().join("docs/guide.md"), "# Guide\n")?;
160
161        project.validate()?;
162        Ok(())
163    }
164
165    #[test]
166    fn accepts_disabled_readme() -> Result<(), Box<dyn Error>> {
167        let (directory, project) = project(r#"{"name":"example","license":"MIT","readme":false}"#)?;
168        std::fs::write(directory.path().join("index.wdl"), "version 1.3\n")?;
169
170        project.validate()?;
171        Ok(())
172    }
173
174    #[test]
175    fn reports_missing_entrypoint() -> Result<(), Box<dyn Error>> {
176        let (directory, project) = project(r#"{"name":"example","license":"MIT"}"#)?;
177        std::fs::write(directory.path().join("README.md"), "# Example\n")?;
178
179        let error = project.validate().unwrap_err();
180        assert!(matches!(
181            error,
182            ProjectValidationError::MissingFile {
183                kind: ProjectFileKind::Entrypoint,
184                ..
185            }
186        ));
187        assert!(error.to_string().contains("index.wdl"));
188        Ok(())
189    }
190
191    #[test]
192    fn reports_missing_readme() -> Result<(), Box<dyn Error>> {
193        let (directory, project) = project(r#"{"name":"example","license":"MIT"}"#)?;
194        std::fs::write(directory.path().join("index.wdl"), "version 1.3\n")?;
195
196        let error = project.validate().unwrap_err();
197        assert!(matches!(
198            error,
199            ProjectValidationError::MissingFile {
200                kind: ProjectFileKind::Readme,
201                ..
202            }
203        ));
204        assert!(error.to_string().contains("README.md"));
205        Ok(())
206    }
207
208    #[test]
209    fn reports_missing_custom_references() -> Result<(), Box<dyn Error>> {
210        let (directory, project) = project(
211            r#"{
212                "name":"example",
213                "license":"MIT",
214                "entrypoint":"src/main.wdl",
215                "readme":"docs/guide.md"
216            }"#,
217        )?;
218        std::fs::create_dir_all(directory.path().join("docs"))?;
219        std::fs::write(directory.path().join("docs/guide.md"), "# Guide\n")?;
220
221        let error = project.validate().unwrap_err();
222        assert!(matches!(
223            error,
224            ProjectValidationError::MissingFile {
225                kind: ProjectFileKind::Entrypoint,
226                ..
227            }
228        ));
229        assert!(error.to_string().contains("src/main.wdl"));
230
231        std::fs::create_dir_all(directory.path().join("src"))?;
232        std::fs::write(directory.path().join("src/main.wdl"), "version 1.3\n")?;
233        std::fs::remove_file(directory.path().join("docs/guide.md"))?;
234
235        let error = project.validate().unwrap_err();
236        assert!(matches!(
237            error,
238            ProjectValidationError::MissingFile {
239                kind: ProjectFileKind::Readme,
240                ..
241            }
242        ));
243        assert!(error.to_string().contains("docs/guide.md"));
244        Ok(())
245    }
246
247    #[test]
248    fn rejects_directory_in_place_of_referenced_file() -> Result<(), Box<dyn Error>> {
249        let (directory, project) = project(r#"{"name":"example","license":"MIT"}"#)?;
250        std::fs::create_dir(directory.path().join("index.wdl"))?;
251        std::fs::write(directory.path().join("README.md"), "# Example\n")?;
252
253        let error = project.validate().unwrap_err();
254        assert!(matches!(
255            error,
256            ProjectValidationError::NotRegularFile {
257                kind: ProjectFileKind::Entrypoint,
258                ..
259            }
260        ));
261        Ok(())
262    }
263
264    #[test]
265    fn rejects_entrypoint_excluded_from_hash_at_module_root() -> Result<(), Box<dyn Error>> {
266        let (directory, project) = project(
267            r#"{
268                "name":"example",
269                "license":"MIT",
270                "entrypoint":"module-lock.json"
271            }"#,
272        )?;
273        std::fs::write(directory.path().join(crate::LOCKFILE_FILENAME), "{}\n")?;
274        std::fs::write(directory.path().join("README.md"), "# Example\n")?;
275
276        let error = project.validate().unwrap_err();
277        assert!(matches!(
278            &error,
279            ProjectValidationError::ExcludedFile {
280                kind: ProjectFileKind::Entrypoint,
281                path,
282            } if path == &directory.path().join(crate::LOCKFILE_FILENAME)
283        ));
284        assert!(matches!(
285            error,
286            ProjectValidationError::ExcludedFile {
287                kind: ProjectFileKind::Entrypoint,
288                ..
289            }
290        ));
291        assert!(error.to_string().contains(crate::LOCKFILE_FILENAME));
292        Ok(())
293    }
294
295    #[test]
296    fn rejects_readme_excluded_from_hash_in_skipped_directory() -> Result<(), Box<dyn Error>> {
297        let (directory, project) = project(
298            r#"{
299                "name":"example",
300                "license":"MIT",
301                "readme":".sprocket/README.md"
302            }"#,
303        )?;
304        std::fs::write(directory.path().join("index.wdl"), "version 1.3\n")?;
305        std::fs::create_dir(directory.path().join(".sprocket"))?;
306        std::fs::write(directory.path().join(".sprocket/README.md"), "# Hidden\n")?;
307
308        let error = project.validate().unwrap_err();
309        assert!(matches!(
310            &error,
311            ProjectValidationError::ExcludedFile {
312                kind: ProjectFileKind::Readme,
313                path,
314            } if path == &directory.path().join(".sprocket/README.md")
315        ));
316        assert!(matches!(
317            error,
318            ProjectValidationError::ExcludedFile {
319                kind: ProjectFileKind::Readme,
320                ..
321            }
322        ));
323        assert!(error.to_string().contains(".sprocket/README.md"));
324        Ok(())
325    }
326
327    #[test]
328    fn preserves_tree_validation_errors() -> Result<(), Box<dyn Error>> {
329        let (directory, project) = project(r#"{"name":"example","license":"MIT"}"#)?;
330        std::fs::write(directory.path().join("index.wdl"), "version 1.3\n")?;
331        std::fs::write(directory.path().join("README.md"), "# Example\n")?;
332        std::fs::create_dir(directory.path().join("nested"))?;
333        std::fs::write(directory.path().join("nested/module.json"), b"not metadata")?;
334
335        let error = project.validate().unwrap_err();
336        assert!(matches!(error, ProjectValidationError::Content(_)));
337        assert!(
338            error
339                .to_string()
340                .contains("only permitted at the module root")
341        );
342        Ok(())
343    }
344
345    #[cfg(unix)]
346    #[test]
347    fn rejects_symlink_anywhere_in_module() -> Result<(), Box<dyn Error>> {
348        let (directory, project) = project(r#"{"name":"example","license":"MIT"}"#)?;
349        std::fs::write(directory.path().join("index.wdl"), "version 1.3\n")?;
350        std::fs::write(directory.path().join("README.md"), "# Example\n")?;
351        std::fs::write(directory.path().join("real.wdl"), "version 1.3\n")?;
352        std::os::unix::fs::symlink(
353            directory.path().join("real.wdl"),
354            directory.path().join("alias.wdl"),
355        )?;
356
357        let error = project.validate().unwrap_err();
358        assert!(matches!(error, ProjectValidationError::Content(_)));
359        Ok(())
360    }
361}