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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum ProjectFileKind {
14 Entrypoint,
16 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#[derive(Debug, Error)]
31pub enum ProjectValidationError {
32 #[error("module {kind} `{path}` does not exist")]
34 MissingFile {
35 kind: ProjectFileKind,
37 path: PathBuf,
39 },
40
41 #[error("module {kind} `{path}` is not a regular file")]
43 NotRegularFile {
44 kind: ProjectFileKind,
46 path: PathBuf,
48 },
49
50 #[error("module {kind} `{path}` is excluded from module content hashing")]
52 ExcludedFile {
53 kind: ProjectFileKind,
55 path: PathBuf,
57 },
58
59 #[error("failed to inspect module {kind} `{path}`")]
61 Io {
62 kind: ProjectFileKind,
64 path: PathBuf,
66 #[source]
68 source: std::io::Error,
69 },
70
71 #[error(transparent)]
73 Content(#[from] HashError),
74}
75
76impl ModuleProject {
77 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
94fn 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}