1mod 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#[derive(Debug, Clone, Facet, PartialEq, Eq)]
22#[facet(tag = "kind", deny_unknown_fields)]
23#[repr(u8)]
24pub enum PathSelector {
25 #[facet(rename = "exact")]
27 Exact {
28 segments: Vec<PathSegment>,
30 },
31 #[facet(rename = "tree")]
33 Tree {
34 segments: Vec<PathSegment>,
36 },
37 #[facet(rename = "segments")]
39 Segments {
40 segments: Vec<PathSegment>,
42 },
43}
44
45#[derive(Debug, Clone, Facet, PartialEq, Eq)]
47#[facet(tag = "kind", deny_unknown_fields)]
48#[repr(u8)]
49pub enum PathSegment {
50 #[facet(rename = "literal")]
52 Literal {
53 value: String,
55 },
56 #[facet(rename = "one")]
58 One,
59 #[facet(rename = "descendants")]
61 Descendants,
62 #[facet(rename = "prefixSuffix")]
64 PrefixSuffix {
65 prefix: String,
67 suffix: String,
69 },
70}
71
72impl PathSelector {
73 #[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}