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();
73 let tail = source_msg
74 .find("': ")
75 .map_or(source_msg.as_str(), |idx| &source_msg[idx + 3..]);
76 write!(
77 f,
78 "{field}: invalid glob '{pattern}': {tail}; \
79 fix the syntax (see https://docs.rs/globset for the supported grammar)"
80 )
81 }
82 }
83 }
84}
85
86impl std::error::Error for GlobValidationError {
87 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
88 match self {
89 Self::InvalidSyntax { source, .. } => Some(source),
90 _ => None,
91 }
92 }
93}
94
95fn is_absolute_pattern(pattern: &str) -> bool {
104 if pattern.starts_with('/') || pattern.starts_with('\\') {
105 return true;
106 }
107 let bytes = pattern.as_bytes();
108 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
109 return true;
110 }
111 false
112}
113
114fn has_traversal_segment(pattern: &str) -> bool {
124 pattern.split(['/', '\\']).any(|seg| seg == "..")
125 || Path::new(pattern)
126 .components()
127 .any(|c| matches!(c, Component::ParentDir))
128}
129
130pub fn compile_user_glob(pattern: &str, field: &'static str) -> Result<Glob, GlobValidationError> {
145 if is_absolute_pattern(pattern) {
146 return Err(GlobValidationError::AbsolutePath {
147 field,
148 pattern: pattern.to_owned(),
149 });
150 }
151 if has_traversal_segment(pattern) {
152 return Err(GlobValidationError::TraversalSegment {
153 field,
154 pattern: pattern.to_owned(),
155 });
156 }
157 Glob::new(pattern).map_err(|source| GlobValidationError::InvalidSyntax {
158 field,
159 pattern: pattern.to_owned(),
160 source,
161 })
162}
163
164pub fn compile_user_specifier_glob(
175 pattern: &str,
176 field: &'static str,
177) -> Result<Glob, GlobValidationError> {
178 Glob::new(pattern).map_err(|source| GlobValidationError::InvalidSyntax {
179 field,
180 pattern: pattern.to_owned(),
181 source,
182 })
183}
184
185pub fn validate_user_specifier_globs(
187 patterns: &[String],
188 field: &'static str,
189 errors: &mut Vec<GlobValidationError>,
190) {
191 for pattern in patterns {
192 if let Err(e) = compile_user_specifier_glob(pattern, field) {
193 errors.push(e);
194 }
195 }
196}
197
198pub fn validate_user_globs(
201 patterns: &[String],
202 field: &'static str,
203 errors: &mut Vec<GlobValidationError>,
204) {
205 for pattern in patterns {
206 if let Err(e) = compile_user_glob(pattern, field) {
207 errors.push(e);
208 }
209 }
210}
211
212pub fn validate_user_path(path: &str, field: &'static str) -> Result<(), GlobValidationError> {
225 if is_absolute_pattern(path) {
226 return Err(GlobValidationError::AbsolutePath {
227 field,
228 pattern: path.to_owned(),
229 });
230 }
231 if has_traversal_segment(path) {
232 return Err(GlobValidationError::TraversalSegment {
233 field,
234 pattern: path.to_owned(),
235 });
236 }
237 Ok(())
238}
239
240pub fn validate_user_paths(
242 paths: &[String],
243 field: &'static str,
244 errors: &mut Vec<GlobValidationError>,
245) {
246 for path in paths {
247 if let Err(e) = validate_user_path(path, field) {
248 errors.push(e);
249 }
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn relative_glob_accepted() {
259 assert!(compile_user_glob("src/**/*.ts", "entry").is_ok());
260 assert!(compile_user_glob("**/*.test.ts", "entry").is_ok());
261 assert!(compile_user_glob("./src/main.ts", "entry").is_ok());
262 assert!(compile_user_glob("packages/*/src/index.ts", "entry").is_ok());
263 assert!(compile_user_glob("**/{a,b}.ts", "entry").is_ok());
264 }
265
266 #[test]
267 fn bracket_character_class_accepted() {
268 assert!(compile_user_glob("[A-Z]*.tsx", "entry").is_ok());
273 assert!(compile_user_glob("src/**/[A-Z]*.{ts,tsx}", "ignoreExports[].file").is_ok());
274 assert!(compile_user_glob("**/[0-9][0-9]*.md", "entry").is_ok());
275 }
276
277 #[test]
278 fn validate_user_path_rejects_traversal_and_absolute() {
279 assert!(validate_user_path("../escape", "boundaries.zones[].root").is_err());
280 assert!(validate_user_path("/abs/dir", "boundaries.zones[].root").is_err());
281 assert!(validate_user_path("packages/ui", "boundaries.zones[].root").is_ok());
282 assert!(validate_user_path("[brackets-literal]/dir", "boundaries.zones[].root").is_ok());
284 }
285
286 #[test]
287 fn absolute_unix_path_rejected() {
288 let err = compile_user_glob("/etc/passwd", "entry").unwrap_err();
289 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
290 let msg = err.to_string();
291 assert!(msg.contains("/etc/passwd"), "msg: {msg}");
292 assert!(msg.contains("entry"), "msg: {msg}");
293 assert!(msg.contains("absolute"), "msg: {msg}");
294 assert!(msg.contains("relative to the project root"), "msg: {msg}");
295 }
296
297 #[test]
298 fn absolute_unix_glob_rejected() {
299 let err = compile_user_glob("/root/.ssh/**", "ignorePatterns").unwrap_err();
300 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
301 }
302
303 #[test]
304 fn absolute_windows_backslash_path_rejected() {
305 let err = compile_user_glob("\\Windows\\System32", "entry").unwrap_err();
306 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
307 }
308
309 #[test]
310 fn unc_path_rejected() {
311 let err = compile_user_glob("\\\\share\\secrets", "entry").unwrap_err();
312 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
313 }
314
315 #[test]
316 fn unc_forward_slash_rejected() {
317 let err = compile_user_glob("//share/secrets", "entry").unwrap_err();
318 assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
319 }
320
321 #[test]
322 fn windows_drive_letter_rejected() {
323 for pat in ["C:\\Users", "c:/Users", "D:foo", "Z:\\"] {
324 let err = compile_user_glob(pat, "entry").unwrap_err();
325 assert!(
326 matches!(err, GlobValidationError::AbsolutePath { .. }),
327 "expected AbsolutePath for {pat}, got {err:?}"
328 );
329 }
330 }
331
332 #[test]
333 fn traversal_segment_rejected() {
334 let err = compile_user_glob("../foo", "entry").unwrap_err();
335 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
336 assert!(err.to_string().contains("../foo"));
337 }
338
339 #[test]
340 fn traversal_in_middle_rejected() {
341 let err = compile_user_glob("src/../../../etc", "ignorePatterns").unwrap_err();
342 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
343 }
344
345 #[test]
346 fn traversal_with_backslash_rejected() {
347 let err = compile_user_glob("..\\foo", "entry").unwrap_err();
348 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
349 }
350
351 #[test]
352 fn traversal_in_glob_pattern_rejected() {
353 let err = compile_user_glob("**/../secrets", "entry").unwrap_err();
354 assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
355 }
356
357 #[test]
358 fn double_dot_filename_accepted() {
359 assert!(compile_user_glob("foo..bar", "entry").is_ok());
362 assert!(compile_user_glob("src/file.with..dots.ts", "entry").is_ok());
363 }
364
365 #[test]
366 fn current_dir_dot_accepted() {
367 assert!(compile_user_glob("./src/**", "entry").is_ok());
369 }
370
371 #[test]
372 fn invalid_glob_syntax_rejected() {
373 let err = compile_user_glob("[invalid", "entry").unwrap_err();
374 assert!(matches!(err, GlobValidationError::InvalidSyntax { .. }));
375 let msg = err.to_string();
376 assert!(msg.contains("entry"), "msg: {msg}");
377 assert_eq!(msg.matches("[invalid").count(), 1, "msg: {msg}");
379 assert!(msg.contains("unclosed character class"), "msg: {msg}");
380 }
381
382 #[test]
383 fn empty_pattern_accepted_as_globset_handles_it() {
384 assert!(compile_user_glob("", "entry").is_ok());
389 }
390
391 #[test]
392 fn validate_user_globs_collects_all_errors() {
393 let patterns = vec![
394 "src/**".to_owned(),
395 "../foo".to_owned(),
396 "/abs".to_owned(),
397 "[bad".to_owned(),
398 "**/*.ts".to_owned(),
399 ];
400 let mut errors = Vec::new();
401 validate_user_globs(&patterns, "ignorePatterns", &mut errors);
402 assert_eq!(errors.len(), 3);
403 assert!(matches!(
404 errors[0],
405 GlobValidationError::TraversalSegment { .. }
406 ));
407 assert!(matches!(
408 errors[1],
409 GlobValidationError::AbsolutePath { .. }
410 ));
411 assert!(matches!(
412 errors[2],
413 GlobValidationError::InvalidSyntax { .. }
414 ));
415 }
416
417 #[test]
418 fn field_name_in_error_message() {
419 let err = compile_user_glob("../oops", "duplicates.ignore").unwrap_err();
420 assert!(err.to_string().starts_with("duplicates.ignore:"));
421 }
422}