ephemeris_files/path/
repository_path.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Facet)]
17#[facet(proxy = String)]
18pub struct RepositoryPath(PathBuf);
19
20impl RepositoryPath {
21 #[must_use]
23 pub fn as_path(&self) -> &Path {
24 &self.0
25 }
26
27 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 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}