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`, `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
25/// Validation failure for a single user-supplied glob pattern.
26#[derive(Debug)]
27pub enum GlobValidationError {
28    /// Pattern is an absolute path (`/foo`, `\foo`, `C:\foo`, `\\share`).
29    AbsolutePath {
30        field: &'static str,
31        pattern: String,
32    },
33    /// Pattern contains a `..` path segment.
34    TraversalSegment {
35        field: &'static str,
36        pattern: String,
37    },
38    /// Pattern is not valid glob syntax.
39    InvalidSyntax {
40        field: &'static str,
41        pattern: String,
42        source: globset::Error,
43    },
44}
45
46impl fmt::Display for GlobValidationError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::AbsolutePath { field, pattern } => {
50                write!(
51                    f,
52                    "{field}: '{pattern}' is an absolute path; \
53                     use a pattern relative to the project root (e.g. 'src/**')"
54                )
55            }
56            Self::TraversalSegment { field, pattern } => {
57                write!(
58                    f,
59                    "{field}: '{pattern}' contains a '..' segment; \
60                     rewrite the pattern to stay inside the project root, \
61                     or run fallow with --root pointing at the directory you want to scan"
62                )
63            }
64            Self::InvalidSyntax {
65                field,
66                pattern,
67                source,
68            } => {
69                let source_msg = source.to_string();
70                let tail = source_msg
71                    .find("': ")
72                    .map_or(source_msg.as_str(), |idx| &source_msg[idx + 3..]);
73                write!(
74                    f,
75                    "{field}: invalid glob '{pattern}': {tail}; \
76                     fix the syntax (see https://docs.rs/globset for the supported grammar)"
77                )
78            }
79        }
80    }
81}
82
83impl std::error::Error for GlobValidationError {
84    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85        match self {
86            Self::InvalidSyntax { source, .. } => Some(source),
87            _ => None,
88        }
89    }
90}
91
92/// Detect absolute paths cross-platform without relying on `Path::is_absolute`
93/// (which is platform-specific: on Unix, `C:\foo` would be treated as relative).
94///
95/// Rejected shapes:
96/// - Unix root: `/foo`
97/// - Windows backslash root: `\foo`
98/// - UNC: `\\share\path` or `//share/path`
99/// - Drive letter: `C:\foo`, `c:/foo`, `D:foo`
100fn is_absolute_pattern(pattern: &str) -> bool {
101    if pattern.starts_with('/') || pattern.starts_with('\\') {
102        return true;
103    }
104    let bytes = pattern.as_bytes();
105    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
106        return true;
107    }
108    false
109}
110
111/// Return `true` if any segment of `pattern` is `..`.
112///
113/// We split on BOTH `/` and `\` so a backslash-separated traversal pattern
114/// (`..\foo`) authored on a Windows machine is rejected even when fallow runs
115/// on Unix. `Path::components` on Unix treats `\` as a regular character, so
116/// it cannot be relied on as a cross-platform separator detector.
117///
118/// Glob meta characters (`*`, `**`, `[abc]`, `{a,b}`) pass through unchanged
119/// because the split only inspects separators.
120fn has_traversal_segment(pattern: &str) -> bool {
121    pattern.split(['/', '\\']).any(|seg| seg == "..")
122        || Path::new(pattern)
123            .components()
124            .any(|c| matches!(c, Component::ParentDir))
125}
126
127/// Validate that `pattern` is a relative, non-traversal, syntactically valid
128/// glob; return the compiled glob on success.
129///
130/// `field` is the dotted-path name of the config field the pattern came from
131/// (e.g. `"entry"`, `"ignorePatterns"`, `"duplicates.ignore"`); it appears
132/// verbatim in the error message so users can locate the bad value.
133///
134/// # Errors
135///
136/// Returns:
137/// - `AbsolutePath` if the pattern is rooted at `/`, `\`, `\\`, `//`, or a
138///   Windows drive letter
139/// - `TraversalSegment` if any path segment of the pattern is `..`
140/// - `InvalidSyntax` if `globset::Glob::new` rejects the pattern
141pub fn compile_user_glob(pattern: &str, field: &'static str) -> Result<Glob, GlobValidationError> {
142    if is_absolute_pattern(pattern) {
143        return Err(GlobValidationError::AbsolutePath {
144            field,
145            pattern: pattern.to_owned(),
146        });
147    }
148    if has_traversal_segment(pattern) {
149        return Err(GlobValidationError::TraversalSegment {
150            field,
151            pattern: pattern.to_owned(),
152        });
153    }
154    Glob::new(pattern).map_err(|source| GlobValidationError::InvalidSyntax {
155        field,
156        pattern: pattern.to_owned(),
157        source,
158    })
159}
160
161/// Validate a glob pattern that matches a raw import specifier, not a
162/// filesystem path.
163///
164/// Specifiers such as `../generated/foo` are valid import strings, so this
165/// intentionally skips the absolute-path and traversal-segment checks used for
166/// project-root-relative file globs.
167///
168/// # Errors
169///
170/// Returns `InvalidSyntax` if `globset::Glob::new` rejects the pattern.
171pub fn compile_user_specifier_glob(
172    pattern: &str,
173    field: &'static str,
174) -> Result<Glob, GlobValidationError> {
175    Glob::new(pattern).map_err(|source| GlobValidationError::InvalidSyntax {
176        field,
177        pattern: pattern.to_owned(),
178        source,
179    })
180}
181
182/// Validate a slice of import-specifier patterns, accumulating syntax errors.
183pub fn validate_user_specifier_globs(
184    patterns: &[String],
185    field: &'static str,
186    errors: &mut Vec<GlobValidationError>,
187) {
188    for pattern in patterns {
189        if let Err(e) = compile_user_specifier_glob(pattern, field) {
190            errors.push(e);
191        }
192    }
193}
194
195/// Validate a slice of patterns, accumulating ALL errors so the user sees
196/// every offending pattern in one run rather than fixing them one at a time.
197pub fn validate_user_globs(
198    patterns: &[String],
199    field: &'static str,
200    errors: &mut Vec<GlobValidationError>,
201) {
202    for pattern in patterns {
203        if let Err(e) = compile_user_glob(pattern, field) {
204            errors.push(e);
205        }
206    }
207}
208
209/// Validate a user-supplied DIRECTORY PATH (not a glob). Same absolute-path
210/// and traversal checks as `compile_user_glob`, but skips the glob-syntax
211/// check because the value is a literal path, not a pattern.
212///
213/// Used for fields like `boundaries.zones[].root` and
214/// `boundaries.zones[].autoDiscover` that name a directory subtree rather
215/// than a match pattern.
216///
217/// # Errors
218///
219/// Returns `AbsolutePath` or `TraversalSegment` for the same shapes
220/// `compile_user_glob` rejects. Never returns `InvalidSyntax`.
221pub fn validate_user_path(path: &str, field: &'static str) -> Result<(), GlobValidationError> {
222    if is_absolute_pattern(path) {
223        return Err(GlobValidationError::AbsolutePath {
224            field,
225            pattern: path.to_owned(),
226        });
227    }
228    if has_traversal_segment(path) {
229        return Err(GlobValidationError::TraversalSegment {
230            field,
231            pattern: path.to_owned(),
232        });
233    }
234    Ok(())
235}
236
237/// Same as `validate_user_path` but accumulates errors over a slice.
238pub fn validate_user_paths(
239    paths: &[String],
240    field: &'static str,
241    errors: &mut Vec<GlobValidationError>,
242) {
243    for path in paths {
244        if let Err(e) = validate_user_path(path, field) {
245            errors.push(e);
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn relative_glob_accepted() {
256        assert!(compile_user_glob("src/**/*.ts", "entry").is_ok());
257        assert!(compile_user_glob("**/*.test.ts", "entry").is_ok());
258        assert!(compile_user_glob("./src/main.ts", "entry").is_ok());
259        assert!(compile_user_glob("packages/*/src/index.ts", "entry").is_ok());
260        assert!(compile_user_glob("**/{a,b}.ts", "entry").is_ok());
261    }
262
263    #[test]
264    fn bracket_character_class_accepted() {
265        assert!(compile_user_glob("[A-Z]*.tsx", "entry").is_ok());
266        assert!(compile_user_glob("src/**/[A-Z]*.{ts,tsx}", "ignoreExports[].file").is_ok());
267        assert!(compile_user_glob("**/[0-9][0-9]*.md", "entry").is_ok());
268    }
269
270    #[test]
271    fn validate_user_path_rejects_traversal_and_absolute() {
272        assert!(validate_user_path("../escape", "boundaries.zones[].root").is_err());
273        assert!(validate_user_path("/abs/dir", "boundaries.zones[].root").is_err());
274        assert!(validate_user_path("packages/ui", "boundaries.zones[].root").is_ok());
275        assert!(validate_user_path("[brackets-literal]/dir", "boundaries.zones[].root").is_ok());
276    }
277
278    #[test]
279    fn absolute_unix_path_rejected() {
280        let err = compile_user_glob("/etc/passwd", "entry").unwrap_err();
281        assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
282        let msg = err.to_string();
283        assert!(msg.contains("/etc/passwd"), "msg: {msg}");
284        assert!(msg.contains("entry"), "msg: {msg}");
285        assert!(msg.contains("absolute"), "msg: {msg}");
286        assert!(msg.contains("relative to the project root"), "msg: {msg}");
287    }
288
289    #[test]
290    fn absolute_unix_glob_rejected() {
291        let err = compile_user_glob("/root/.ssh/**", "ignorePatterns").unwrap_err();
292        assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
293    }
294
295    #[test]
296    fn absolute_windows_backslash_path_rejected() {
297        let err = compile_user_glob("\\Windows\\System32", "entry").unwrap_err();
298        assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
299    }
300
301    #[test]
302    fn unc_path_rejected() {
303        let err = compile_user_glob("\\\\share\\secrets", "entry").unwrap_err();
304        assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
305    }
306
307    #[test]
308    fn unc_forward_slash_rejected() {
309        let err = compile_user_glob("//share/secrets", "entry").unwrap_err();
310        assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
311    }
312
313    #[test]
314    fn windows_drive_letter_rejected() {
315        for pat in ["C:\\Users", "c:/Users", "D:foo", "Z:\\"] {
316            let err = compile_user_glob(pat, "entry").unwrap_err();
317            assert!(
318                matches!(err, GlobValidationError::AbsolutePath { .. }),
319                "expected AbsolutePath for {pat}, got {err:?}"
320            );
321        }
322    }
323
324    #[test]
325    fn traversal_segment_rejected() {
326        let err = compile_user_glob("../foo", "entry").unwrap_err();
327        assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
328        assert!(err.to_string().contains("../foo"));
329    }
330
331    #[test]
332    fn traversal_in_middle_rejected() {
333        let err = compile_user_glob("src/../../../etc", "ignorePatterns").unwrap_err();
334        assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
335    }
336
337    #[test]
338    fn traversal_with_backslash_rejected() {
339        let err = compile_user_glob("..\\foo", "entry").unwrap_err();
340        assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
341    }
342
343    #[test]
344    fn traversal_in_glob_pattern_rejected() {
345        let err = compile_user_glob("**/../secrets", "entry").unwrap_err();
346        assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
347    }
348
349    #[test]
350    fn double_dot_filename_accepted() {
351        assert!(compile_user_glob("foo..bar", "entry").is_ok());
352        assert!(compile_user_glob("src/file.with..dots.ts", "entry").is_ok());
353    }
354
355    #[test]
356    fn current_dir_dot_accepted() {
357        assert!(compile_user_glob("./src/**", "entry").is_ok());
358    }
359
360    #[test]
361    fn invalid_glob_syntax_rejected() {
362        let err = compile_user_glob("[invalid", "entry").unwrap_err();
363        assert!(matches!(err, GlobValidationError::InvalidSyntax { .. }));
364        let msg = err.to_string();
365        assert!(msg.contains("entry"), "msg: {msg}");
366        assert_eq!(msg.matches("[invalid").count(), 1, "msg: {msg}");
367        assert!(msg.contains("unclosed character class"), "msg: {msg}");
368    }
369
370    #[test]
371    fn empty_pattern_accepted_as_globset_handles_it() {
372        assert!(compile_user_glob("", "entry").is_ok());
373    }
374
375    #[test]
376    fn validate_user_globs_collects_all_errors() {
377        let patterns = vec![
378            "src/**".to_owned(),
379            "../foo".to_owned(),
380            "/abs".to_owned(),
381            "[bad".to_owned(),
382            "**/*.ts".to_owned(),
383        ];
384        let mut errors = Vec::new();
385        validate_user_globs(&patterns, "ignorePatterns", &mut errors);
386        assert_eq!(errors.len(), 3);
387        assert!(matches!(
388            errors[0],
389            GlobValidationError::TraversalSegment { .. }
390        ));
391        assert!(matches!(
392            errors[1],
393            GlobValidationError::AbsolutePath { .. }
394        ));
395        assert!(matches!(
396            errors[2],
397            GlobValidationError::InvalidSyntax { .. }
398        ));
399    }
400
401    #[test]
402    fn field_name_in_error_message() {
403        let err = compile_user_glob("../oops", "duplicates.ignore").unwrap_err();
404        assert!(err.to_string().starts_with("duplicates.ignore:"));
405    }
406}