Skip to main content

fallow_config/config/
glob_validation.rs

1//! Validation of user-supplied glob patterns from the config file.
2//!
3//! Fallow accepts filesystem glob patterns in several config fields (`entry`,
4//! `ignorePatterns`, `ignoreFindings`, `dynamicallyLoaded`, `duplicates.ignore`, `health.ignore`,
5//! `health.thresholdOverrides[].files`, `boundaries.zones[].patterns`,
6//! `overrides[].files`, `ignoreExports[].file`, `ignoreCatalogReferences[].consumer`).
7//! All of these are matched against
8//! project-root-relative file paths. The matcher cannot reach outside the
9//! project root by construction, but a malicious config can still slip in
10//! absolute paths or `..` traversal segments that silently no-op today and
11//! mask user intent.
12//!
13//! This module rejects such patterns at config-load time so users get a clear
14//! error instead of a silent no-match. Invalid glob syntax also fails loud
15//! here, replacing the historical `if let Ok(glob) = Glob::new(pattern)` drop
16//! patterns scattered across the codebase.
17//!
18//! See issue #463 for the threat model.
19
20use std::fmt;
21use std::path::{Component, Path};
22
23use globset::Glob;
24
25use super::finding_ignore::FindingIgnoreMatcher;
26
27/// Validation failure for user-supplied glob configuration.
28#[derive(Debug)]
29pub enum GlobValidationError {
30    /// Pattern is an absolute path (`/foo`, `\foo`, `C:\foo`, `\\share`).
31    AbsolutePath {
32        /// Config field the pattern came from (e.g. `entry`), named in the error.
33        field: &'static str,
34        /// The offending pattern as written in the config.
35        pattern: String,
36    },
37    /// Pattern contains a `..` path segment.
38    TraversalSegment {
39        /// Config field the pattern came from, named in the error.
40        field: &'static str,
41        /// The offending pattern as written in the config.
42        pattern: String,
43    },
44    /// Pattern is not valid glob syntax.
45    InvalidSyntax {
46        /// Config field the pattern came from, named in the error.
47        field: &'static str,
48        /// The offending pattern as written in the config.
49        pattern: String,
50        /// Underlying glob parse error.
51        source: globset::Error,
52    },
53    /// A finding-ignore exception contains `!` without a pattern body.
54    EmptyNegation {
55        /// Config field the pattern came from, named in the error.
56        field: &'static str,
57        /// The offending pattern as written in the config.
58        pattern: String,
59    },
60    /// Individually valid patterns cannot be compiled into one matcher.
61    PatternSetCompilation {
62        /// Config field whose pattern set failed to build, named in the error.
63        field: &'static str,
64        /// Underlying glob-set build error.
65        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
127/// Detect absolute paths cross-platform without relying on `Path::is_absolute`
128/// (which is platform-specific: on Unix, `C:\foo` would be treated as relative).
129///
130/// Rejected shapes:
131/// - Unix root: `/foo`
132/// - Windows backslash root: `\foo`
133/// - UNC: `\\share\path` or `//share/path`
134/// - Drive letter: `C:\foo`, `c:/foo`, `D:foo`
135fn 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
146/// Return `true` if any segment of `pattern` is `..`.
147///
148/// We split on BOTH `/` and `\` so a backslash-separated traversal pattern
149/// (`..\foo`) authored on a Windows machine is rejected even when fallow runs
150/// on Unix. `Path::components` on Unix treats `\` as a regular character, so
151/// it cannot be relied on as a cross-platform separator detector.
152///
153/// Glob meta characters (`*`, `**`, `[abc]`, `{a,b}`) pass through unchanged
154/// because the split only inspects separators.
155fn 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
162/// Validate that `pattern` is a relative, non-traversal, syntactically valid
163/// glob; return the compiled glob on success.
164///
165/// `field` is the dotted-path name of the config field the pattern came from
166/// (e.g. `"entry"`, `"ignorePatterns"`, `"duplicates.ignore"`); it appears
167/// verbatim in the error message so users can locate the bad value.
168///
169/// # Errors
170///
171/// Returns:
172/// - `AbsolutePath` if the pattern is rooted at `/`, `\`, `\\`, `//`, or a
173///   Windows drive letter
174/// - `TraversalSegment` if any path segment of the pattern is `..`
175/// - `InvalidSyntax` if `globset::Glob::new` rejects the pattern
176pub 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
196/// Validate a glob pattern that matches a raw import specifier, not a
197/// filesystem path.
198///
199/// Specifiers such as `../generated/foo` are valid import strings, so this
200/// intentionally skips the absolute-path and traversal-segment checks used for
201/// project-root-relative file globs.
202///
203/// # Errors
204///
205/// Returns `InvalidSyntax` if `globset::Glob::new` rejects the pattern.
206pub 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
217/// Validate a slice of import-specifier patterns, accumulating syntax errors.
218pub 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
230/// Validate a slice of patterns, accumulating ALL errors so the user sees
231/// every offending pattern in one run rather than fixing them one at a time.
232pub 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
244/// Validate finding-ignore patterns, treating a leading `!` as a report
245/// exception, validating each project-relative body, and proving both matcher
246/// sets can be compiled together.
247///
248/// An empty body is only an error after an explicit `!`. A bare empty pattern is
249/// a glob that matches nothing, which `ignorePatterns` already accepts.
250pub 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
277/// Validate a user-supplied DIRECTORY PATH (not a glob). Same absolute-path
278/// and traversal checks as `compile_user_glob`, but skips the glob-syntax
279/// check because the value is a literal path, not a pattern.
280///
281/// Used for fields like `boundaries.zones[].root` and
282/// `boundaries.zones[].autoDiscover` that name a directory subtree rather
283/// than a match pattern.
284///
285/// # Errors
286///
287/// Returns `AbsolutePath` or `TraversalSegment` for the same shapes
288/// `compile_user_glob` rejects. Never returns `InvalidSyntax`.
289pub 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
305/// Same as `validate_user_path` but accumulates errors over a slice.
306pub 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    /// `ignorePatterns` accepts an empty pattern, so `ignoreFindings` rejecting it
514    /// was both an asymmetry and a misleading message about a `!` it lacks.
515    #[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}