Skip to main content

ephemeris_files/
path.rs

1//! Repository-relative path types and filesystem path safety helpers.
2
3mod error;
4mod repository_path;
5mod repository_root;
6
7use std::path::Path;
8use std::str::Split;
9use std::{fs, io};
10
11use facet::Facet;
12
13pub use self::error::PathError;
14pub use self::repository_path::RepositoryPath;
15pub use self::repository_root::RepositoryRoot;
16use crate::error::IoResultExt as _;
17use crate::{FileMode, FilesError};
18
19/// A repository-relative path selector. Matching is case-sensitive and uses
20/// `/` separated UTF-8 path segments.
21#[derive(Debug, Clone, Facet, PartialEq, Eq)]
22#[facet(tag = "kind", deny_unknown_fields)]
23#[repr(u8)]
24pub enum PathSelector {
25    /// Matches only the exact sequence of literal segments.
26    #[facet(rename = "exact")]
27    Exact {
28        /// The literal segments the path must equal.
29        segments: Vec<PathSegment>,
30    },
31    /// Matches the segments as a tree root: the path or any descendant.
32    #[facet(rename = "tree")]
33    Tree {
34        /// The segments the path or an ancestor must equal.
35        segments: Vec<PathSegment>,
36    },
37    /// Matches the segments positionally, allowing wildcards.
38    #[facet(rename = "segments")]
39    Segments {
40        /// The segment patterns the path must match one-for-one.
41        segments: Vec<PathSegment>,
42    },
43}
44
45/// One segment in a structured path selector.
46#[derive(Debug, Clone, Facet, PartialEq, Eq)]
47#[facet(tag = "kind", deny_unknown_fields)]
48#[repr(u8)]
49pub enum PathSegment {
50    /// Matches a segment equal to `value`.
51    #[facet(rename = "literal")]
52    Literal {
53        /// The exact segment text to match.
54        value: String,
55    },
56    /// Matches exactly one arbitrary segment.
57    #[facet(rename = "one")]
58    One,
59    /// Matches zero or more arbitrary segments.
60    #[facet(rename = "descendants")]
61    Descendants,
62    /// Matches a segment with the given prefix and suffix.
63    #[facet(rename = "prefixSuffix")]
64    PrefixSuffix {
65        /// The segment's required leading text.
66        prefix: String,
67        /// The segment's required trailing text.
68        suffix: String,
69    },
70}
71
72impl PathSelector {
73    /// Match a repository-relative path without following filesystem links.
74    #[must_use]
75    pub fn matches(&self, path: &RepositoryPath) -> bool {
76        let Some(path) = path.as_path().to_str() else {
77            return false;
78        };
79        let (expected, tree) = match self {
80            Self::Exact { segments } | Self::Segments { segments } => (segments.as_slice(), false),
81            Self::Tree { segments } => (segments.as_slice(), true),
82        };
83        matches_segments(expected, path.split('/'), tree)
84    }
85
86    pub(crate) fn valid(&self) -> bool {
87        let (segments, exact) = match self {
88            Self::Exact { segments } => (segments, true),
89            Self::Tree { segments } | Self::Segments { segments } => (segments, false),
90        };
91        !segments.is_empty()
92            && segments.iter().all(|segment| match segment {
93                PathSegment::Literal { value } => valid_segment(value),
94                PathSegment::One | PathSegment::Descendants => !exact,
95                PathSegment::PrefixSuffix { prefix, suffix } => {
96                    !exact
97                        && !prefix.contains(['/', '\\'])
98                        && !suffix.contains(['/', '\\'])
99                        && (!prefix.is_empty() || !suffix.is_empty())
100                }
101            })
102    }
103}
104
105pub(crate) fn valid_segment(value: &str) -> bool {
106    !value.is_empty() && value != "." && value != ".." && !value.contains(['/', '\\'])
107}
108
109fn matches_segments(selectors: &[PathSegment], mut actual: Split<'_, char>, tree: bool) -> bool {
110    let Some((first, rest)) = selectors.split_first() else {
111        return tree || actual.next().is_none();
112    };
113    if matches!(first, PathSegment::Descendants) {
114        if matches_segments(rest, actual.clone(), tree) {
115            return true;
116        }
117        let Some(_segment) = actual.next() else {
118            return false;
119        };
120        return matches_segments(selectors, actual, tree);
121    }
122    let Some(value) = actual.next() else {
123        return false;
124    };
125    segment_matches(first, value) && matches_segments(rest, actual, tree)
126}
127
128fn segment_matches(selector: &PathSegment, value: &str) -> bool {
129    match selector {
130        PathSegment::Literal { value: literal } => literal == value,
131        PathSegment::One | PathSegment::Descendants => true,
132        PathSegment::PrefixSuffix { prefix, suffix } => {
133            value.starts_with(prefix)
134                && value.ends_with(suffix)
135                && value.len() >= prefix.len().saturating_add(suffix.len())
136        }
137    }
138}
139
140pub(crate) fn ensure_no_symlink_parent(root: &Path, path: &Path) -> Result<(), PathError> {
141    let relative = path
142        .strip_prefix(root)
143        .map_err(|_outside| PathError::Outside(path.to_path_buf()))?;
144    let mut current = root.to_path_buf();
145    for component in relative
146        .components()
147        .take(relative.components().count().saturating_sub(1))
148    {
149        current.push(component.as_os_str());
150        match current.symlink_metadata() {
151            Ok(metadata) if metadata.file_type().is_symlink() => {
152                return Err(PathError::Symlink(current));
153            }
154            Ok(_) => {}
155            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
156            Err(source) => {
157                return Err(PathError::Io {
158                    path: current,
159                    source,
160                });
161            }
162        }
163    }
164    Ok(())
165}
166
167pub(crate) fn set_mode(path: &Path, mode: FileMode) -> Result<(), FilesError> {
168    use std::os::unix::fs::PermissionsExt as _;
169    fs::set_permissions(path, fs::Permissions::from_mode(mode.bits() & 0o777)).at_path(path)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::{PathSegment as S, PathSelector as P, RepositoryPath};
175
176    fn lit(value: &str) -> S {
177        S::Literal {
178            value: value.into(),
179        }
180    }
181
182    fn repo(path: &str) -> RepositoryPath {
183        RepositoryPath::try_from(path).expect("valid repository path")
184    }
185
186    #[test]
187    fn selector_forms_match_case_sensitively() {
188        assert!(
189            P::Exact {
190                segments: vec![lit("Readme")]
191            }
192            .matches(&repo("Readme"))
193        );
194        assert!(
195            !P::Exact {
196                segments: vec![lit("Readme")]
197            }
198            .matches(&repo("README"))
199        );
200        assert!(
201            P::Tree {
202                segments: vec![lit("src")]
203            }
204            .matches(&repo("src/lib.rs"))
205        );
206        assert!(
207            P::Segments {
208                segments: vec![lit("packages"), S::One, lit("lib.rs")]
209            }
210            .matches(&repo("packages/files/lib.rs"))
211        );
212        assert!(
213            !P::Segments {
214                segments: vec![lit("packages"), S::One, lit("lib.rs")]
215            }
216            .matches(&repo("packages/a/b/lib.rs"))
217        );
218    }
219
220    #[test]
221    fn descendants_match_zero_or_more_segments_at_their_position() {
222        let selector = P::Segments {
223            segments: vec![lit("foo"), S::Descendants, lit("bar")],
224        };
225        assert!(selector.matches(&repo("foo/bar")));
226        assert!(selector.matches(&repo("foo/a/b/bar")));
227        assert!(!selector.matches(&repo("foo/a/baz")));
228    }
229
230    #[test]
231    fn prefix_suffix_requires_non_overlapping_parts() {
232        let selector = P::Segments {
233            segments: vec![S::PrefixSuffix {
234                prefix: "ab".into(),
235                suffix: "bc".into(),
236            }],
237        };
238        assert!(!selector.matches(&repo("abc")));
239        assert!(selector.matches(&repo("abbc")));
240    }
241
242    #[test]
243    fn tree_matching_covers_descendants_but_only_below_the_first_segments() {
244        let selector = P::Tree {
245            segments: vec![lit("vendor")],
246        };
247        assert!(selector.matches(&repo("vendor")));
248        assert!(selector.matches(&repo("vendor/linked")));
249        assert!(!selector.matches(&repo("other/vendor/linked")));
250        assert!(!selector.matches(&repo("vendors/linked")));
251    }
252
253    #[test]
254    fn literal_segments_allow_glob_characters_and_match_them_literally() {
255        let selector = P::Exact {
256            segments: vec![lit("literal*.txt")],
257        };
258        assert!(selector.valid());
259        assert!(selector.matches(&repo("literal*.txt")));
260        assert!(!selector.matches(&repo("literal-file.txt")));
261    }
262
263    #[test]
264    fn exact_selectors_require_literal_segments() {
265        for segment in [
266            S::One,
267            S::Descendants,
268            S::PrefixSuffix {
269                prefix: "pre".into(),
270                suffix: ".txt".into(),
271            },
272        ] {
273            assert!(
274                !P::Exact {
275                    segments: vec![segment]
276                }
277                .valid()
278            );
279        }
280        assert!(
281            P::Segments {
282                segments: vec![S::One]
283            }
284            .valid()
285        );
286    }
287}