Skip to main content

ephemeris_files/path/
repository_path.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4use std::{fmt, fs};
5
6use facet::Facet;
7
8use super::{PathError, RepositoryRoot, ensure_no_symlink_parent, valid_segment};
9use crate::FilesError;
10
11/// A normalized, relative, parent-free repository path.
12///
13/// [`TryFrom`] is the only way to build one, so every value already holds
14/// the invariant: no absolute paths, no empty segments, and no `..`
15/// traversal. `"."` is accepted as a reference to the repository root.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Facet)]
17#[facet(proxy = String)]
18pub struct RepositoryPath(PathBuf);
19
20impl RepositoryPath {
21    /// Borrow the normalized relative path.
22    #[must_use]
23    pub fn as_path(&self) -> &Path {
24        &self.0
25    }
26
27    /// Resolve this path against a repository root, rejecting symlinks in
28    /// any parent or leaf component.
29    ///
30    /// Missing final files are allowed so callers can safely plan their
31    /// creation.
32    ///
33    /// # Errors
34    /// Returns an error when a parent or leaf component is a symlink, an
35    /// ancestor is inaccessible, or the resolved path escapes the repository.
36    pub fn resolve(&self, root: &RepositoryRoot) -> Result<PathBuf, PathError> {
37        let root = root.as_path();
38        if self.0.as_os_str().is_empty() {
39            return Ok(root.to_path_buf());
40        }
41        let full = root.join(&self.0);
42        ensure_no_symlink_parent(root, &full)?;
43        if let Ok(metadata) = fs::symlink_metadata(&full)
44            && metadata.file_type().is_symlink()
45        {
46            return Err(PathError::Symlink(full));
47        }
48        let mut ancestor = full
49            .parent()
50            .ok_or_else(|| PathError::Outside(full.clone()))?;
51        while !ancestor.exists() {
52            ancestor = ancestor
53                .parent()
54                .ok_or_else(|| PathError::Outside(full.clone()))?;
55        }
56        let canonical_ancestor = ancestor.canonicalize().map_err(|source| PathError::Io {
57            path: ancestor.to_path_buf(),
58            source,
59        })?;
60        if !canonical_ancestor.starts_with(root) {
61            return Err(PathError::Outside(full));
62        }
63        Ok(full)
64    }
65
66    /// Reject a set of paths that differ only by case.
67    ///
68    /// Two declared paths differing only in case collide on the
69    /// case-insensitive filesystems macOS and Windows default to, even
70    /// though [`RepositoryPath`] equality is byte-exact.
71    ///
72    /// # Errors
73    /// Returns an error naming the first two paths found that are distinct
74    /// but match when lowercased.
75    pub(crate) fn reject_case_collisions<'a>(
76        paths: impl IntoIterator<Item = &'a Self>,
77    ) -> Result<(), FilesError> {
78        let mut seen: BTreeMap<String, &Self> = BTreeMap::new();
79        for path in paths {
80            let key = path.0.to_string_lossy().to_lowercase();
81            if let Some(&first) = seen.get(&key) {
82                if first != path {
83                    return Err(FilesError::CaseInsensitivePath {
84                        first: first.clone(),
85                        second: path.clone(),
86                    });
87                }
88            } else {
89                seen.insert(key, path);
90            }
91        }
92        Ok(())
93    }
94}
95
96impl TryFrom<&str> for RepositoryPath {
97    type Error = PathError;
98
99    fn try_from(value: &str) -> Result<Self, PathError> {
100        if value == "." {
101            return Ok(Self(PathBuf::new()));
102        }
103        if value.split('/').any(|segment| !valid_segment(segment)) {
104            return Err(PathError::Invalid {
105                path: value.to_owned(),
106            });
107        }
108        Ok(Self(PathBuf::from(value)))
109    }
110}
111
112impl FromStr for RepositoryPath {
113    type Err = PathError;
114
115    fn from_str(value: &str) -> Result<Self, PathError> {
116        Self::try_from(value)
117    }
118}
119
120impl TryFrom<String> for RepositoryPath {
121    type Error = PathError;
122
123    fn try_from(value: String) -> Result<Self, PathError> {
124        Self::try_from(value.as_str())
125    }
126}
127
128impl TryFrom<&Path> for RepositoryPath {
129    type Error = PathError;
130
131    fn try_from(value: &Path) -> Result<Self, PathError> {
132        let text = value.to_str().ok_or_else(|| PathError::Invalid {
133            path: value.to_string_lossy().into_owned(),
134        })?;
135        Self::try_from(text)
136    }
137}
138
139impl From<&RepositoryPath> for String {
140    fn from(path: &RepositoryPath) -> Self {
141        path.to_string()
142    }
143}
144
145impl fmt::Display for RepositoryPath {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        if self.0.as_os_str().is_empty() {
148            f.write_str(".")
149        } else {
150            write!(f, "{}", self.0.display())
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use std::fs;
158    use std::path::Path;
159
160    use super::{PathError, RepositoryPath, RepositoryRoot};
161    use crate::FilesError;
162
163    #[test]
164    fn relative_paths_reject_empty_noncanonical_and_backslash_segments() {
165        for path in ["", "./file", "a/./b", "a//b", "a/", "a\\b"] {
166            assert!(RepositoryPath::try_from(path).is_err(), "accepted {path:?}");
167        }
168        assert_eq!(
169            RepositoryPath::try_from("a/b.txt").unwrap().as_path(),
170            Path::new("a/b.txt")
171        );
172    }
173
174    #[test]
175    fn case_insensitive_collisions_are_rejected_and_exact_duplicates_are_not() {
176        let foo = RepositoryPath::try_from("foo.txt").unwrap();
177        let bar = RepositoryPath::try_from("bar.txt").unwrap();
178        assert!(RepositoryPath::reject_case_collisions([&foo, &bar]).is_ok());
179        assert!(RepositoryPath::reject_case_collisions([&foo, &foo]).is_ok());
180
181        let shouting = RepositoryPath::try_from("FOO.txt").unwrap();
182        assert!(matches!(
183            RepositoryPath::reject_case_collisions([&foo, &shouting]),
184            Err(FilesError::CaseInsensitivePath { first, second })
185                if first.as_path() == Path::new("foo.txt") && second.as_path() == Path::new("FOO.txt")
186        ));
187    }
188
189    #[test]
190    fn dot_denotes_the_repository_root() {
191        let root = RepositoryPath::try_from(".").unwrap();
192        assert_eq!(root.as_path(), Path::new(""));
193        assert_eq!(root.to_string(), ".");
194    }
195
196    #[test]
197    fn invalid_paths_report_the_offending_text() {
198        assert!(matches!(
199            RepositoryPath::try_from("a/./b"),
200            Err(PathError::Invalid { path }) if path == "a/./b"
201        ));
202    }
203
204    #[test]
205    fn accepts_missing_leaf_and_parent_directories_inside_repository() {
206        let root = tempfile::tempdir().unwrap();
207        let canonical_root = root.path().canonicalize().unwrap();
208        assert_eq!(
209            RepositoryPath::try_from("new/nested/CHANGELOG.md")
210                .unwrap()
211                .resolve(&RepositoryRoot::new(root.path()).unwrap())
212                .unwrap(),
213            canonical_root.join("new/nested/CHANGELOG.md")
214        );
215        assert_eq!(
216            RepositoryPath::try_from(".")
217                .unwrap()
218                .resolve(&RepositoryRoot::new(root.path()).unwrap())
219                .unwrap(),
220            canonical_root
221        );
222    }
223
224    #[test]
225    fn rejects_parent_traversal_and_symlink_parents_or_leaves() {
226        let root = tempfile::tempdir().unwrap();
227        assert!(RepositoryPath::try_from("../outside").is_err());
228        {
229            use std::os::unix::fs::symlink;
230            let outside = tempfile::tempdir().unwrap();
231            symlink(outside.path(), root.path().join("parent-link")).unwrap();
232            assert!(
233                RepositoryPath::try_from("parent-link/file")
234                    .unwrap()
235                    .resolve(&RepositoryRoot::new(root.path()).unwrap())
236                    .is_err()
237            );
238            fs::write(outside.path().join("file"), "version").unwrap();
239            symlink(outside.path().join("file"), root.path().join("leaf-link")).unwrap();
240            assert!(
241                RepositoryPath::try_from("leaf-link")
242                    .unwrap()
243                    .resolve(&RepositoryRoot::new(root.path()).unwrap())
244                    .is_err()
245            );
246        }
247    }
248}