Skip to main content

opy_rs/
project.rs

1use std::fmt;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6/// A filesystem-backed OPY project target and the root used to resolve it.
7#[derive(Debug)]
8pub struct FilesystemProject {
9    source: String,
10    main_path: PathBuf,
11    root: PathBuf,
12}
13
14impl FilesystemProject {
15    /// Load an OPY project from a file entry or a project directory.
16    pub fn load(path: &Path) -> Result<Self, FilesystemProjectError> {
17        let canonical_target = path.canonicalize().map_err(|error| {
18            if is_absent(error.kind()) {
19                FilesystemProjectError::entry_not_found(path.to_path_buf(), error)
20            } else {
21                // Permission, loop, or name-resolution failures are not an
22                // absent entry (#484).
23                FilesystemProjectError::entry_unreadable(path.to_path_buf(), error)
24            }
25        })?;
26        let selected_path = if canonical_target.is_dir() {
27            default_entry(&canonical_target)?
28        } else {
29            canonical_target.clone()
30        };
31        let source = fs::read_to_string(&selected_path).map_err(|error| {
32            FilesystemProjectError::entry_unreadable(selected_path.clone(), error)
33        })?;
34        let root = selected_path
35            .parent()
36            .map(Path::to_path_buf)
37            .unwrap_or_else(|| PathBuf::from("."));
38        Ok(Self {
39            source,
40            main_path: if canonical_target.is_dir() {
41                selected_path
42            } else {
43                path.to_path_buf()
44            },
45            root,
46        })
47    }
48
49    /// The source text read from the selected entry.
50    pub fn source(&self) -> &str {
51        &self.source
52    }
53
54    /// The selected entry path used as the source identity in diagnostics.
55    pub fn main_path(&self) -> &Path {
56        &self.main_path
57    }
58
59    /// The canonical parent directory used for project-relative resolution.
60    pub fn root(&self) -> &Path {
61        &self.root
62    }
63}
64
65/// Whether an I/O failure means the probed path cannot exist: the final
66/// component is absent (`NotFound`), or an intermediate component is a
67/// regular file (`NotADirectory` on Unix, a not-found error on Windows).
68/// Both name an entry that does not exist rather than one that failed to
69/// load (#497).
70fn is_absent(kind: io::ErrorKind) -> bool {
71    matches!(kind, io::ErrorKind::NotFound | io::ErrorKind::NotADirectory)
72}
73
74fn default_entry(directory: &Path) -> Result<PathBuf, FilesystemProjectError> {
75    let candidates = [directory.join("main.opy"), directory.join("src/main.opy")];
76    for candidate in candidates {
77        match fs::metadata(&candidate) {
78            Ok(metadata) if metadata.is_file() => return Ok(candidate),
79            // Present but not a regular file, or absent — keep looking; only
80            // a real I/O failure aborts classification (#484). A regular file
81            // named `src` makes `src/main.opy` absent, so the probe reports
82            // no default entry instead of an unreadable one (#497).
83            Ok(_) => {}
84            Err(error) if is_absent(error.kind()) => {}
85            Err(error) => {
86                return Err(FilesystemProjectError::entry_unreadable(candidate, error));
87            }
88        }
89    }
90    Err(FilesystemProjectError::default_entry_not_found(
91        directory.to_path_buf(),
92    ))
93}
94
95/// Failure while loading a filesystem-backed OPY project entry.
96#[derive(Debug)]
97pub enum FilesystemProjectError {
98    EntryNotFound { path: PathBuf, source: io::Error },
99    EntryUnreadable { path: PathBuf, source: io::Error },
100    DefaultEntryNotFound { path: PathBuf },
101}
102
103impl FilesystemProjectError {
104    fn entry_not_found(path: PathBuf, source: io::Error) -> Self {
105        Self::EntryNotFound { path, source }
106    }
107
108    fn entry_unreadable(path: PathBuf, source: io::Error) -> Self {
109        Self::EntryUnreadable { path, source }
110    }
111
112    fn default_entry_not_found(path: PathBuf) -> Self {
113        Self::DefaultEntryNotFound { path }
114    }
115
116    /// Whether the entry could not be resolved to a canonical filesystem path.
117    /// An `EntryNotFound` carrying a non-absent I/O cause — possible
118    /// through the public fields — reports the real category (#484). A path
119    /// through a regular file counts as absent (`NotADirectory`, #497).
120    pub fn is_entry_not_found(&self) -> bool {
121        match self {
122            Self::EntryNotFound { source, .. } => is_absent(source.kind()),
123            Self::DefaultEntryNotFound { .. } => true,
124            Self::EntryUnreadable { .. } => false,
125        }
126    }
127}
128
129impl fmt::Display for FilesystemProjectError {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            Self::EntryNotFound { source, .. } => write!(formatter, "cannot find entry: {source}"),
133            Self::EntryUnreadable { source, .. } => {
134                write!(formatter, "cannot read entry: {source}")
135            }
136            Self::DefaultEntryNotFound { path } => {
137                write!(
138                    formatter,
139                    "cannot find a default OPY entry in '{}'; expected main.opy or src/main.opy",
140                    path.display()
141                )
142            }
143        }
144    }
145}
146
147impl std::error::Error for FilesystemProjectError {
148    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
149        match self {
150            Self::EntryNotFound { source, .. } | Self::EntryUnreadable { source, .. } => {
151                Some(source)
152            }
153            Self::DefaultEntryNotFound { .. } => None,
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::{FilesystemProject, FilesystemProjectError};
161    use std::io;
162    use std::path::{Path, PathBuf};
163
164    #[test]
165    fn directory_targets_use_the_owner_default_entry() {
166        let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
167            .join("tests/fixtures/project-preprocessing");
168        let project = FilesystemProject::load(&directory).expect("directory project loads");
169        assert!(project.main_path().ends_with("main.opy"));
170        assert_eq!(project.root(), directory.canonicalize().unwrap());
171        assert!(project.source().contains("rule \"main\""));
172    }
173
174    fn scratch(tag: &str) -> PathBuf {
175        let dir = std::env::temp_dir().join(format!("opy-project-io-{tag}-{}", std::process::id()));
176        let _ = std::fs::remove_dir_all(&dir);
177        std::fs::create_dir_all(&dir).expect("scratch dir");
178        dir
179    }
180
181    #[test]
182    fn absent_file_entry_is_entry_not_found() {
183        let missing = scratch("missing").join("no-such.opy");
184        let error = FilesystemProject::load(&missing).expect_err("absent entry fails");
185        assert!(error.is_entry_not_found());
186        assert!(matches!(
187            error,
188            FilesystemProjectError::EntryNotFound { .. }
189        ));
190    }
191
192    /// A symlink cycle fails canonicalization with `Filesystem loop`, not
193    /// `NotFound`: the failure must keep its real category instead of
194    /// masquerading as an absent entry (#484).
195    #[cfg(unix)]
196    #[test]
197    fn unresolvable_entry_is_not_classified_as_missing() {
198        let dir = scratch("loop");
199        let entry = dir.join("main.opy");
200        std::os::unix::fs::symlink(Path::new("loop.opy"), dir.join("loop.opy")).unwrap();
201        std::os::unix::fs::symlink(Path::new("main.opy"), &entry).unwrap();
202        let error = FilesystemProject::load(&entry).expect_err("symlink cycle fails");
203        assert!(!error.is_entry_not_found());
204        assert!(matches!(
205            error,
206            FilesystemProjectError::EntryUnreadable { .. }
207        ));
208        let source = std::error::Error::source(&error).expect("io cause preserved");
209        let io_error = source.downcast_ref::<io::Error>().expect("typed io::Error");
210        assert_ne!(io_error.kind(), io::ErrorKind::NotFound);
211        std::fs::remove_dir_all(&dir).unwrap();
212    }
213
214    /// `main.opy` present but unresolvable: the default-entry search must
215    /// report the I/O failure rather than `DefaultEntryNotFound` (#484).
216    #[cfg(unix)]
217    #[test]
218    fn unresolvable_default_entry_is_not_classified_as_missing() {
219        let dir = scratch("default-loop");
220        std::os::unix::fs::symlink(Path::new("main.opy"), dir.join("main.opy")).unwrap();
221        let error = FilesystemProject::load(&dir).expect_err("default entry cycle fails");
222        assert!(!error.is_entry_not_found());
223        assert!(matches!(
224            error,
225            FilesystemProjectError::EntryUnreadable { .. }
226        ));
227        std::fs::remove_dir_all(&dir).unwrap();
228    }
229
230    /// A file that resolves but cannot be read stays `EntryUnreadable`.
231    /// Skipped under root, which bypasses permission checks.
232    #[cfg(unix)]
233    #[test]
234    fn unreadable_entry_reports_unreadable() {
235        use std::os::unix::fs::PermissionsExt;
236        if unsafe { libc::geteuid() } == 0 {
237            return;
238        }
239        let dir = scratch("denied");
240        let entry = dir.join("main.opy");
241        std::fs::write(&entry, "rule \"x\": @Event global\n").unwrap();
242        std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o000)).unwrap();
243        let result = FilesystemProject::load(&entry);
244        std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o644)).unwrap();
245        std::fs::remove_dir_all(&dir).unwrap();
246        let error = result.expect_err("unreadable entry fails");
247        assert!(!error.is_entry_not_found());
248        assert!(matches!(
249            error,
250            FilesystemProjectError::EntryUnreadable { .. }
251        ));
252    }
253
254    /// A directory whose only `src` is a regular file has no default entry,
255    /// the same as an empty directory (#497). Verified on Unix: `src` being
256    /// a file makes the `src/main.opy` probe fail `ENOTDIR`. Windows reports
257    /// a not-found error for the same probe, which the `is_absent`
258    /// classification covers identically; CI on Windows currently runs only a
259    /// subset of tests.
260    #[test]
261    fn file_named_src_is_default_entry_not_found() {
262        let dir = scratch("file-src");
263        std::fs::write(dir.join("src"), "not a directory\n").unwrap();
264        let error = FilesystemProject::load(&dir).expect_err("no default entry");
265        assert!(error.is_entry_not_found());
266        assert!(matches!(
267            error,
268            FilesystemProjectError::DefaultEntryNotFound { .. }
269        ));
270        std::fs::remove_dir_all(&dir).unwrap();
271    }
272
273    /// An empty directory reports `DefaultEntryNotFound` — the baseline the
274    /// file-named-`src` case must match (#497).
275    #[test]
276    fn empty_directory_is_default_entry_not_found() {
277        let dir = scratch("empty");
278        let error = FilesystemProject::load(&dir).expect_err("no default entry");
279        assert!(error.is_entry_not_found());
280        assert!(matches!(
281            error,
282            FilesystemProjectError::DefaultEntryNotFound { .. }
283        ));
284        std::fs::remove_dir_all(&dir).unwrap();
285    }
286
287    /// A path inside a regular file names an entry that does not exist, so it
288    /// carries the not-found classification on every platform (#497). Verified
289    /// on Unix, where canonicalization fails `ENOTDIR`; Windows reports a
290    /// not-found error that lands in the same classification.
291    #[test]
292    fn entry_inside_a_file_is_entry_not_found() {
293        let dir = scratch("notdir");
294        let file = dir.join("main.opy");
295        std::fs::write(&file, "rule \"x\": @Event global\n").unwrap();
296        let entry = file.join("child.opy");
297        let error = FilesystemProject::load(&entry).expect_err("ENOTDIR fails");
298        assert!(error.is_entry_not_found());
299        assert!(matches!(
300            error,
301            FilesystemProjectError::EntryNotFound { .. }
302        ));
303        std::fs::remove_dir_all(&dir).unwrap();
304    }
305}