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//! `boundaries.zones[].patterns`, `overrides[].files`, `ignoreExports[].file`,
6//! `ignoreCatalogReferences[].consumer`). All of these are matched against
7//! project-root-relative file paths. The matcher cannot reach outside the
8//! project root by construction, but a malicious config can still slip in
9//! absolute paths or `..` traversal segments that silently no-op today and
10//! mask user intent.
11//!
12//! This module rejects such patterns at config-load time so users get a clear
13//! error instead of a silent no-match. Invalid glob syntax also fails loud
14//! here, replacing the historical `if let Ok(glob) = Glob::new(pattern)` drop
15//! patterns scattered across the codebase.
16//!
17//! See issue #463 for the threat model.
18
19use std::fmt;
20use std::path::{Component, Path};
21
22use globset::Glob;
23
24/// Validation failure for a single user-supplied glob pattern.
25#[derive(Debug)]
26pub enum GlobValidationError {
27    /// Pattern is an absolute path (`/foo`, `\foo`, `C:\foo`, `\\share`).
28    AbsolutePath {
29        field: &'static str,
30        pattern: String,
31    },
32    /// Pattern contains a `..` path segment.
33    TraversalSegment {
34        field: &'static str,
35        pattern: String,
36    },
37    /// Pattern is not valid glob syntax.
38    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
91/// Detect absolute paths cross-platform without relying on `Path::is_absolute`
92/// (which is platform-specific: on Unix, `C:\foo` would be treated as relative).
93///
94/// Rejected shapes:
95/// - Unix root: `/foo`
96/// - Windows backslash root: `\foo`
97/// - UNC: `\\share\path` or `//share/path`
98/// - Drive letter: `C:\foo`, `c:/foo`, `D:foo`
99fn 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
110/// Return `true` if any segment of `pattern` is `..`.
111///
112/// We split on BOTH `/` and `\` so a backslash-separated traversal pattern
113/// (`..\foo`) authored on a Windows machine is rejected even when fallow runs
114/// on Unix. `Path::components` on Unix treats `\` as a regular character, so
115/// it cannot be relied on as a cross-platform separator detector.
116///
117/// Glob meta characters (`*`, `**`, `[abc]`, `{a,b}`) pass through unchanged
118/// because the split only inspects separators.
119fn 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
126/// Validate that `pattern` is a relative, non-traversal, syntactically valid
127/// glob; return the compiled glob on success.
128///
129/// `field` is the dotted-path name of the config field the pattern came from
130/// (e.g. `"entry"`, `"ignorePatterns"`, `"duplicates.ignore"`); it appears
131/// verbatim in the error message so users can locate the bad value.
132///
133/// # Errors
134///
135/// Returns:
136/// - `AbsolutePath` if the pattern is rooted at `/`, `\`, `\\`, `//`, or a
137///   Windows drive letter
138/// - `TraversalSegment` if any path segment of the pattern is `..`
139/// - `InvalidSyntax` if `globset::Glob::new` rejects the pattern
140pub 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
160/// Validate a glob pattern that matches a raw import specifier, not a
161/// filesystem path.
162///
163/// Specifiers such as `../generated/foo` are valid import strings, so this
164/// intentionally skips the absolute-path and traversal-segment checks used for
165/// project-root-relative file globs.
166///
167/// # Errors
168///
169/// Returns `InvalidSyntax` if `globset::Glob::new` rejects the pattern.
170pub 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
181/// Validate a slice of import-specifier patterns, accumulating syntax errors.
182pub 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
194/// Validate a slice of patterns, accumulating ALL errors so the user sees
195/// every offending pattern in one run rather than fixing them one at a time.
196pub 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
208/// Validate a user-supplied DIRECTORY PATH (not a glob). Same absolute-path
209/// and traversal checks as `compile_user_glob`, but skips the glob-syntax
210/// check because the value is a literal path, not a pattern.
211///
212/// Used for fields like `boundaries.zones[].root` and
213/// `boundaries.zones[].autoDiscover` that name a directory subtree rather
214/// than a match pattern.
215///
216/// # Errors
217///
218/// Returns `AbsolutePath` or `TraversalSegment` for the same shapes
219/// `compile_user_glob` rejects. Never returns `InvalidSyntax`.
220pub 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
236/// Same as `validate_user_path` but accumulates errors over a slice.
237pub 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}