1use std::fmt;
21use std::path::{Component, Path};
22
23use globset::Glob;
24
25use super::finding_ignore::FindingIgnoreMatcher;
26
27#[derive(Debug)]
29pub enum GlobValidationError {
30 AbsolutePath {
32 field: &'static str,
34 pattern: String,
36 },
37 TraversalSegment {
39 field: &'static str,
41 pattern: String,
43 },
44 InvalidSyntax {
46 field: &'static str,
48 pattern: String,
50 source: globset::Error,
52 },
53 EmptyNegation {
55 field: &'static str,
57 pattern: String,
59 },
60 PatternSetCompilation {
62 field: &'static str,
64 source: globset::Error,
66 },
67}
68
69impl fmt::Display for GlobValidationError {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::AbsolutePath { field, pattern } => {
73 write!(
74 f,
75 "{field}: '{pattern}' is an absolute path; \
76 use a pattern relative to the project root (e.g. 'src/**')"
77 )
78 }
79 Self::TraversalSegment { field, pattern } => {
80 write!(
81 f,
82 "{field}: '{pattern}' contains a '..' segment; \
83 rewrite the pattern to stay inside the project root, \
84 or run fallow with --root pointing at the directory you want to scan"
85 )
86 }
87 Self::InvalidSyntax {
88 field,
89 pattern,
90 source,
91 } => {
92 let source_msg = source.to_string();
93 let tail = source_msg
94 .find("': ")
95 .map_or(source_msg.as_str(), |idx| &source_msg[idx + 3..]);
96 write!(
97 f,
98 "{field}: invalid glob '{pattern}': {tail}; \
99 fix the syntax (see https://docs.rs/globset for the supported grammar)"
100 )
101 }
102 Self::EmptyNegation { field, pattern } => write!(
103 f,
104 "{field}: invalid glob '{pattern}': a negated pattern requires a pattern after '!'"
105 ),
106 Self::PatternSetCompilation { field, source } => write!(
107 f,
108 "{field}: glob patterns cannot be compiled together: {source}; simplify the pattern set"
109 ),
110 }
111 }
112}
113
114impl std::error::Error for GlobValidationError {
115 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
116 match self {
117 Self::InvalidSyntax { source, .. } | Self::PatternSetCompilation { source, .. } => {
118 Some(source)
119 }
120 Self::AbsolutePath { .. }
121 | Self::TraversalSegment { .. }
122 | Self::EmptyNegation { .. } => None,
123 }
124 }
125}
126
127fn is_absolute_pattern(pattern: &str) -> bool {
136 if pattern.starts_with('/') || pattern.starts_with('\\') {
137 return true;
138 }
139 let bytes = pattern.as_bytes();
140 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
141 return true;
142 }
143 false
144}
145
146fn has_traversal_segment(pattern: &str) -> bool {
156 pattern.split(['/', '\\']).any(|seg| seg == "..")
157 || Path::new(pattern)
158 .components()
159 .any(|c| matches!(c, Component::ParentDir))
160}
161
162pub fn compile_user_glob(pattern: &str, field: &'static str) -> Result<Glob, GlobValidationError> {
177 if is_absolute_pattern(pattern) {
178 return Err(GlobValidationError::AbsolutePath {
179 field,
180 pattern: pattern.to_owned(),
181 });
182 }
183 if has_traversal_segment(pattern) {
184 return Err(GlobValidationError::TraversalSegment {
185 field,
186 pattern: pattern.to_owned(),
187 });
188 }
189 Glob::new(pattern).map_err(|source| GlobValidationError::InvalidSyntax {
190 field,
191 pattern: pattern.to_owned(),
192 source,
193 })
194}
195
196pub fn compile_user_specifier_glob(
207 pattern: &str,
208 field: &'static str,
209) -> Result<Glob, GlobValidationError> {
210 Glob::new(pattern).map_err(|source| GlobValidationError::InvalidSyntax {
211 field,
212 pattern: pattern.to_owned(),
213 source,
214 })
215}
216
217pub fn validate_user_specifier_globs(
219 patterns: &[String],
220 field: &'static str,
221 errors: &mut Vec<GlobValidationError>,
222) {
223 for pattern in patterns {
224 if let Err(e) = compile_user_specifier_glob(pattern, field) {
225 errors.push(e);
226 }
227 }
228}
229
230pub fn validate_user_globs(
233 patterns: &[String],
234 field: &'static str,
235 errors: &mut Vec<GlobValidationError>,
236) {
237 for pattern in patterns {
238 if let Err(e) = compile_user_glob(pattern, field) {
239 errors.push(e);
240 }
241 }
242}
243
244pub fn validate_user_finding_ignore_globs(
251 patterns: &[String],
252 field: &'static str,
253 errors: &mut Vec<GlobValidationError>,
254) {
255 let initial_error_count = errors.len();
256 for pattern in patterns {
257 let negated_body = pattern.strip_prefix('!');
258 if negated_body.is_some_and(str::is_empty) {
259 errors.push(GlobValidationError::EmptyNegation {
260 field,
261 pattern: pattern.clone(),
262 });
263 continue;
264 }
265 if let Err(error) = compile_user_glob(negated_body.unwrap_or(pattern.as_str()), field) {
266 errors.push(error);
267 }
268 }
269
270 if errors.len() == initial_error_count
271 && let Err(source) = FindingIgnoreMatcher::validate_compilation(patterns)
272 {
273 errors.push(GlobValidationError::PatternSetCompilation { field, source });
274 }
275}
276
277pub fn validate_user_path(path: &str, field: &'static str) -> Result<(), GlobValidationError> {
290 if is_absolute_pattern(path) {
291 return Err(GlobValidationError::AbsolutePath {
292 field,
293 pattern: path.to_owned(),
294 });
295 }
296 if has_traversal_segment(path) {
297 return Err(GlobValidationError::TraversalSegment {
298 field,
299 pattern: path.to_owned(),
300 });
301 }
302 Ok(())
303}
304
305pub fn validate_user_paths(
307 paths: &[String],
308 field: &'static str,
309 errors: &mut Vec<GlobValidationError>,
310) {
311 for path in paths {
312 if let Err(e) = validate_user_path(path, field) {
313 errors.push(e);
314 }
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn relative_glob_accepted() {
324 assert!(compile_user_glob("src/**/*.ts", "entry").is_ok());
325 assert!(compile_user_glob("**/*.test.ts", "entry").is_ok());
326 assert!(compile_user_glob("./src/main.ts", "entry").is_ok());
327 assert!(compile_user_glob("packages/*/src/index.ts", "entry").is_ok());
328 assert!(compile_user_glob("**/{a,b}.ts", "entry").is_ok());
329 }
330
331 #[test]
332 fn finding_ignore_globs_validate_negated_pattern_bodies() {
333 let mut errors = Vec::new();
334 validate_user_finding_ignore_globs(
335 &["**/*.test.ts".to_string(), "!src/public/**".to_string()],
336 "ignoreFindings",
337 &mut errors,
338 );
339
340 assert!(errors.is_empty());
341 }
342
343 #[test]
344 fn finding_ignore_globs_reject_bare_negation() {
345 let mut errors = Vec::new();
346 validate_user_finding_ignore_globs(&["!".to_string()], "ignoreFindings", &mut errors);
347
348 assert!(matches!(
349 errors.as_slice(),
350 [GlobValidationError::EmptyNegation { .. }]
351 ));
352 }
353
354 #[test]
355 fn finding_ignore_globs_validate_negated_paths_and_syntax() {
356 let cases = ["!/absolute/**", "!../outside/**", "![unclosed"];
357
358 for pattern in cases {
359 let mut errors = Vec::new();
360 validate_user_finding_ignore_globs(
361 &[pattern.to_string()],
362 "ignoreFindings",
363 &mut errors,
364 );
365 assert_eq!(errors.len(), 1, "pattern: {pattern}");
366 }
367 }
368
369 #[test]
370 fn bracket_character_class_accepted() {
371 assert!(compile_user_glob("[A-Z]*.tsx", "entry").is_ok());
372 assert!(compile_user_glob("src/**/[A-Z]*.{ts,tsx}", "ignoreExports[].file").is_ok());
373 assert!(compile_user_glob("**/[0-9][0-9]*.md", "entry").is_ok());
374 }
375
376 #[test]
377 fn validate_user_path_rejects_traversal_and_absolute() {
378 assert!(validate_user_path("../escape", "boundaries.zones[].root").is_err());
379 assert!(validate_user_path("/abs/dir", "boundaries.zones[].root").is_err());
380 assert!(validate_user_path("packages/ui", "boundaries.zones[].root").is_ok());
381 assert!(validate_user_path("[brackets-literal]/dir", "boundaries.zones[].root").is_ok());
382 }
383
384 #[test]
385 fn absolute_unix_path_rejected() {
386 let err = compile_user_glob("/etc/passwd", "entry").unwrap_err();
387 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
388 let msg = err.to_string();
389 assert!(msg.contains("/etc/passwd"), "msg: {msg}");
390 assert!(msg.contains("entry"), "msg: {msg}");
391 assert!(msg.contains("absolute"), "msg: {msg}");
392 assert!(msg.contains("relative to the project root"), "msg: {msg}");
393 }
394
395 #[test]
396 fn absolute_unix_glob_rejected() {
397 let err = compile_user_glob("/root/.ssh/**", "ignorePatterns").unwrap_err();
398 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
399 }
400
401 #[test]
402 fn absolute_windows_backslash_path_rejected() {
403 let err = compile_user_glob("\\Windows\\System32", "entry").unwrap_err();
404 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
405 }
406
407 #[test]
408 fn unc_path_rejected() {
409 let err = compile_user_glob("\\\\share\\secrets", "entry").unwrap_err();
410 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
411 }
412
413 #[test]
414 fn unc_forward_slash_rejected() {
415 let err = compile_user_glob("//share/secrets", "entry").unwrap_err();
416 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
417 }
418
419 #[test]
420 fn windows_drive_letter_rejected() {
421 for pat in ["C:\\Users", "c:/Users", "D:foo", "Z:\\"] {
422 let err = compile_user_glob(pat, "entry").unwrap_err();
423 assert!(
424 matches!(err, GlobValidationError::AbsolutePath { .. }),
425 "expected AbsolutePath for {pat}, got {err:?}"
426 );
427 }
428 }
429
430 #[test]
431 fn traversal_segment_rejected() {
432 let err = compile_user_glob("../foo", "entry").unwrap_err();
433 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
434 assert!(err.to_string().contains("../foo"));
435 }
436
437 #[test]
438 fn traversal_in_middle_rejected() {
439 let err = compile_user_glob("src/../../../etc", "ignorePatterns").unwrap_err();
440 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
441 }
442
443 #[test]
444 fn traversal_with_backslash_rejected() {
445 let err = compile_user_glob("..\\foo", "entry").unwrap_err();
446 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
447 }
448
449 #[test]
450 fn traversal_in_glob_pattern_rejected() {
451 let err = compile_user_glob("**/../secrets", "entry").unwrap_err();
452 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
453 }
454
455 #[test]
456 fn double_dot_filename_accepted() {
457 assert!(compile_user_glob("foo..bar", "entry").is_ok());
458 assert!(compile_user_glob("src/file.with..dots.ts", "entry").is_ok());
459 }
460
461 #[test]
462 fn current_dir_dot_accepted() {
463 assert!(compile_user_glob("./src/**", "entry").is_ok());
464 }
465
466 #[test]
467 fn invalid_glob_syntax_rejected() {
468 let err = compile_user_glob("[invalid", "entry").unwrap_err();
469 assert!(matches!(err, GlobValidationError::InvalidSyntax { .. }));
470 let msg = err.to_string();
471 assert!(msg.contains("entry"), "msg: {msg}");
472 assert_eq!(msg.matches("[invalid").count(), 1, "msg: {msg}");
473 assert!(msg.contains("unclosed character class"), "msg: {msg}");
474 }
475
476 #[test]
477 fn empty_pattern_accepted_as_globset_handles_it() {
478 assert!(compile_user_glob("", "entry").is_ok());
479 }
480
481 #[test]
482 fn validate_user_globs_collects_all_errors() {
483 let patterns = vec![
484 "src/**".to_owned(),
485 "../foo".to_owned(),
486 "/abs".to_owned(),
487 "[bad".to_owned(),
488 "**/*.ts".to_owned(),
489 ];
490 let mut errors = Vec::new();
491 validate_user_globs(&patterns, "ignorePatterns", &mut errors);
492 assert_eq!(errors.len(), 3);
493 assert!(matches!(
494 errors[0],
495 GlobValidationError::TraversalSegment { .. }
496 ));
497 assert!(matches!(
498 errors[1],
499 GlobValidationError::AbsolutePath { .. }
500 ));
501 assert!(matches!(
502 errors[2],
503 GlobValidationError::InvalidSyntax { .. }
504 ));
505 }
506
507 #[test]
508 fn field_name_in_error_message() {
509 let err = compile_user_glob("../oops", "duplicates.ignore").unwrap_err();
510 assert!(err.to_string().starts_with("duplicates.ignore:"));
511 }
512
513 #[test]
516 fn finding_ignore_globs_accept_empty_pattern_like_ignore_patterns() {
517 let mut findings_errors = Vec::new();
518 validate_user_finding_ignore_globs(
519 &[String::new()],
520 "ignoreFindings",
521 &mut findings_errors,
522 );
523
524 let mut patterns_errors = Vec::new();
525 validate_user_globs(&[String::new()], "ignorePatterns", &mut patterns_errors);
526
527 assert!(findings_errors.is_empty(), "errors: {findings_errors:?}");
528 assert!(patterns_errors.is_empty(), "errors: {patterns_errors:?}");
529 }
530
531 #[test]
532 fn finding_ignore_globs_reject_empty_body_only_after_bang() {
533 let mut errors = Vec::new();
534 validate_user_finding_ignore_globs(
535 &[String::new(), "!".to_string(), "src/**".to_string()],
536 "ignoreFindings",
537 &mut errors,
538 );
539 assert_eq!(errors.len(), 1, "only the bare `!` is invalid: {errors:?}");
540 assert!(matches!(
541 errors[0],
542 GlobValidationError::EmptyNegation { .. }
543 ));
544 }
545}