1use std::fmt;
21use std::path::{Component, Path};
22
23use globset::Glob;
24
25#[derive(Debug)]
27pub enum GlobValidationError {
28 AbsolutePath {
30 field: &'static str,
31 pattern: String,
32 },
33 TraversalSegment {
35 field: &'static str,
36 pattern: String,
37 },
38 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
92fn 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
111fn 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
127pub 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
161pub 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
182pub 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
195pub 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
209pub 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
237pub 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}