1use std::fmt;
20use std::path::{Component, Path};
21
22use globset::Glob;
23
24#[derive(Debug)]
26pub enum GlobValidationError {
27 AbsolutePath {
29 field: &'static str,
30 pattern: String,
31 },
32 TraversalSegment {
34 field: &'static str,
35 pattern: String,
36 },
37 InvalidSyntax {
39 field: &'static str,
40 pattern: String,
41 source: globset::Error,
42 },
43}
44
45impl fmt::Display for GlobValidationError {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 match self {
48 Self::AbsolutePath { field, pattern } => {
49 write!(
50 f,
51 "{field}: '{pattern}' is an absolute path; \
52 use a pattern relative to the project root (e.g. 'src/**')"
53 )
54 }
55 Self::TraversalSegment { field, pattern } => {
56 write!(
57 f,
58 "{field}: '{pattern}' contains a '..' segment; \
59 rewrite the pattern to stay inside the project root, \
60 or run fallow with --root pointing at the directory you want to scan"
61 )
62 }
63 Self::InvalidSyntax {
64 field,
65 pattern,
66 source,
67 } => {
68 let source_msg = source.to_string();
69 let tail = source_msg
70 .find("': ")
71 .map_or(source_msg.as_str(), |idx| &source_msg[idx + 3..]);
72 write!(
73 f,
74 "{field}: invalid glob '{pattern}': {tail}; \
75 fix the syntax (see https://docs.rs/globset for the supported grammar)"
76 )
77 }
78 }
79 }
80}
81
82impl std::error::Error for GlobValidationError {
83 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
84 match self {
85 Self::InvalidSyntax { source, .. } => Some(source),
86 _ => None,
87 }
88 }
89}
90
91fn is_absolute_pattern(pattern: &str) -> bool {
100 if pattern.starts_with('/') || pattern.starts_with('\\') {
101 return true;
102 }
103 let bytes = pattern.as_bytes();
104 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
105 return true;
106 }
107 false
108}
109
110fn has_traversal_segment(pattern: &str) -> bool {
120 pattern.split(['/', '\\']).any(|seg| seg == "..")
121 || Path::new(pattern)
122 .components()
123 .any(|c| matches!(c, Component::ParentDir))
124}
125
126pub fn compile_user_glob(pattern: &str, field: &'static str) -> Result<Glob, GlobValidationError> {
141 if is_absolute_pattern(pattern) {
142 return Err(GlobValidationError::AbsolutePath {
143 field,
144 pattern: pattern.to_owned(),
145 });
146 }
147 if has_traversal_segment(pattern) {
148 return Err(GlobValidationError::TraversalSegment {
149 field,
150 pattern: pattern.to_owned(),
151 });
152 }
153 Glob::new(pattern).map_err(|source| GlobValidationError::InvalidSyntax {
154 field,
155 pattern: pattern.to_owned(),
156 source,
157 })
158}
159
160pub fn compile_user_specifier_glob(
171 pattern: &str,
172 field: &'static str,
173) -> Result<Glob, GlobValidationError> {
174 Glob::new(pattern).map_err(|source| GlobValidationError::InvalidSyntax {
175 field,
176 pattern: pattern.to_owned(),
177 source,
178 })
179}
180
181pub fn validate_user_specifier_globs(
183 patterns: &[String],
184 field: &'static str,
185 errors: &mut Vec<GlobValidationError>,
186) {
187 for pattern in patterns {
188 if let Err(e) = compile_user_specifier_glob(pattern, field) {
189 errors.push(e);
190 }
191 }
192}
193
194pub fn validate_user_globs(
197 patterns: &[String],
198 field: &'static str,
199 errors: &mut Vec<GlobValidationError>,
200) {
201 for pattern in patterns {
202 if let Err(e) = compile_user_glob(pattern, field) {
203 errors.push(e);
204 }
205 }
206}
207
208pub fn validate_user_path(path: &str, field: &'static str) -> Result<(), GlobValidationError> {
221 if is_absolute_pattern(path) {
222 return Err(GlobValidationError::AbsolutePath {
223 field,
224 pattern: path.to_owned(),
225 });
226 }
227 if has_traversal_segment(path) {
228 return Err(GlobValidationError::TraversalSegment {
229 field,
230 pattern: path.to_owned(),
231 });
232 }
233 Ok(())
234}
235
236pub fn validate_user_paths(
238 paths: &[String],
239 field: &'static str,
240 errors: &mut Vec<GlobValidationError>,
241) {
242 for path in paths {
243 if let Err(e) = validate_user_path(path, field) {
244 errors.push(e);
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn relative_glob_accepted() {
255 assert!(compile_user_glob("src/**/*.ts", "entry").is_ok());
256 assert!(compile_user_glob("**/*.test.ts", "entry").is_ok());
257 assert!(compile_user_glob("./src/main.ts", "entry").is_ok());
258 assert!(compile_user_glob("packages/*/src/index.ts", "entry").is_ok());
259 assert!(compile_user_glob("**/{a,b}.ts", "entry").is_ok());
260 }
261
262 #[test]
263 fn bracket_character_class_accepted() {
264 assert!(compile_user_glob("[A-Z]*.tsx", "entry").is_ok());
265 assert!(compile_user_glob("src/**/[A-Z]*.{ts,tsx}", "ignoreExports[].file").is_ok());
266 assert!(compile_user_glob("**/[0-9][0-9]*.md", "entry").is_ok());
267 }
268
269 #[test]
270 fn validate_user_path_rejects_traversal_and_absolute() {
271 assert!(validate_user_path("../escape", "boundaries.zones[].root").is_err());
272 assert!(validate_user_path("/abs/dir", "boundaries.zones[].root").is_err());
273 assert!(validate_user_path("packages/ui", "boundaries.zones[].root").is_ok());
274 assert!(validate_user_path("[brackets-literal]/dir", "boundaries.zones[].root").is_ok());
275 }
276
277 #[test]
278 fn absolute_unix_path_rejected() {
279 let err = compile_user_glob("/etc/passwd", "entry").unwrap_err();
280 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
281 let msg = err.to_string();
282 assert!(msg.contains("/etc/passwd"), "msg: {msg}");
283 assert!(msg.contains("entry"), "msg: {msg}");
284 assert!(msg.contains("absolute"), "msg: {msg}");
285 assert!(msg.contains("relative to the project root"), "msg: {msg}");
286 }
287
288 #[test]
289 fn absolute_unix_glob_rejected() {
290 let err = compile_user_glob("/root/.ssh/**", "ignorePatterns").unwrap_err();
291 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
292 }
293
294 #[test]
295 fn absolute_windows_backslash_path_rejected() {
296 let err = compile_user_glob("\\Windows\\System32", "entry").unwrap_err();
297 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
298 }
299
300 #[test]
301 fn unc_path_rejected() {
302 let err = compile_user_glob("\\\\share\\secrets", "entry").unwrap_err();
303 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
304 }
305
306 #[test]
307 fn unc_forward_slash_rejected() {
308 let err = compile_user_glob("//share/secrets", "entry").unwrap_err();
309 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
310 }
311
312 #[test]
313 fn windows_drive_letter_rejected() {
314 for pat in ["C:\\Users", "c:/Users", "D:foo", "Z:\\"] {
315 let err = compile_user_glob(pat, "entry").unwrap_err();
316 assert!(
317 matches!(err, GlobValidationError::AbsolutePath { .. }),
318 "expected AbsolutePath for {pat}, got {err:?}"
319 );
320 }
321 }
322
323 #[test]
324 fn traversal_segment_rejected() {
325 let err = compile_user_glob("../foo", "entry").unwrap_err();
326 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
327 assert!(err.to_string().contains("../foo"));
328 }
329
330 #[test]
331 fn traversal_in_middle_rejected() {
332 let err = compile_user_glob("src/../../../etc", "ignorePatterns").unwrap_err();
333 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
334 }
335
336 #[test]
337 fn traversal_with_backslash_rejected() {
338 let err = compile_user_glob("..\\foo", "entry").unwrap_err();
339 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
340 }
341
342 #[test]
343 fn traversal_in_glob_pattern_rejected() {
344 let err = compile_user_glob("**/../secrets", "entry").unwrap_err();
345 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
346 }
347
348 #[test]
349 fn double_dot_filename_accepted() {
350 assert!(compile_user_glob("foo..bar", "entry").is_ok());
351 assert!(compile_user_glob("src/file.with..dots.ts", "entry").is_ok());
352 }
353
354 #[test]
355 fn current_dir_dot_accepted() {
356 assert!(compile_user_glob("./src/**", "entry").is_ok());
357 }
358
359 #[test]
360 fn invalid_glob_syntax_rejected() {
361 let err = compile_user_glob("[invalid", "entry").unwrap_err();
362 assert!(matches!(err, GlobValidationError::InvalidSyntax { .. }));
363 let msg = err.to_string();
364 assert!(msg.contains("entry"), "msg: {msg}");
365 assert_eq!(msg.matches("[invalid").count(), 1, "msg: {msg}");
366 assert!(msg.contains("unclosed character class"), "msg: {msg}");
367 }
368
369 #[test]
370 fn empty_pattern_accepted_as_globset_handles_it() {
371 assert!(compile_user_glob("", "entry").is_ok());
372 }
373
374 #[test]
375 fn validate_user_globs_collects_all_errors() {
376 let patterns = vec![
377 "src/**".to_owned(),
378 "../foo".to_owned(),
379 "/abs".to_owned(),
380 "[bad".to_owned(),
381 "**/*.ts".to_owned(),
382 ];
383 let mut errors = Vec::new();
384 validate_user_globs(&patterns, "ignorePatterns", &mut errors);
385 assert_eq!(errors.len(), 3);
386 assert!(matches!(
387 errors[0],
388 GlobValidationError::TraversalSegment { .. }
389 ));
390 assert!(matches!(
391 errors[1],
392 GlobValidationError::AbsolutePath { .. }
393 ));
394 assert!(matches!(
395 errors[2],
396 GlobValidationError::InvalidSyntax { .. }
397 ));
398 }
399
400 #[test]
401 fn field_name_in_error_message() {
402 let err = compile_user_glob("../oops", "duplicates.ignore").unwrap_err();
403 assert!(err.to_string().starts_with("duplicates.ignore:"));
404 }
405}