use std::fmt;
use std::path::{Component, Path};
use globset::Glob;
#[derive(Debug)]
pub enum GlobValidationError {
AbsolutePath {
field: &'static str,
pattern: String,
},
TraversalSegment {
field: &'static str,
pattern: String,
},
InvalidSyntax {
field: &'static str,
pattern: String,
source: globset::Error,
},
}
impl fmt::Display for GlobValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AbsolutePath { field, pattern } => {
write!(
f,
"{field}: '{pattern}' is an absolute path; \
use a pattern relative to the project root (e.g. 'src/**')"
)
}
Self::TraversalSegment { field, pattern } => {
write!(
f,
"{field}: '{pattern}' contains a '..' segment; \
rewrite the pattern to stay inside the project root, \
or run fallow with --root pointing at the directory you want to scan"
)
}
Self::InvalidSyntax {
field,
pattern,
source,
} => {
let source_msg = source.to_string();
let tail = source_msg
.find("': ")
.map_or(source_msg.as_str(), |idx| &source_msg[idx + 3..]);
write!(
f,
"{field}: invalid glob '{pattern}': {tail}; \
fix the syntax (see https://docs.rs/globset for the supported grammar)"
)
}
}
}
}
impl std::error::Error for GlobValidationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InvalidSyntax { source, .. } => Some(source),
_ => None,
}
}
}
fn is_absolute_pattern(pattern: &str) -> bool {
if pattern.starts_with('/') || pattern.starts_with('\\') {
return true;
}
let bytes = pattern.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return true;
}
false
}
fn has_traversal_segment(pattern: &str) -> bool {
pattern.split(['/', '\\']).any(|seg| seg == "..")
|| Path::new(pattern)
.components()
.any(|c| matches!(c, Component::ParentDir))
}
pub fn compile_user_glob(pattern: &str, field: &'static str) -> Result<Glob, GlobValidationError> {
if is_absolute_pattern(pattern) {
return Err(GlobValidationError::AbsolutePath {
field,
pattern: pattern.to_owned(),
});
}
if has_traversal_segment(pattern) {
return Err(GlobValidationError::TraversalSegment {
field,
pattern: pattern.to_owned(),
});
}
Glob::new(pattern).map_err(|source| GlobValidationError::InvalidSyntax {
field,
pattern: pattern.to_owned(),
source,
})
}
pub fn validate_user_globs(
patterns: &[String],
field: &'static str,
errors: &mut Vec<GlobValidationError>,
) {
for pattern in patterns {
if let Err(e) = compile_user_glob(pattern, field) {
errors.push(e);
}
}
}
pub fn validate_user_path(path: &str, field: &'static str) -> Result<(), GlobValidationError> {
if is_absolute_pattern(path) {
return Err(GlobValidationError::AbsolutePath {
field,
pattern: path.to_owned(),
});
}
if has_traversal_segment(path) {
return Err(GlobValidationError::TraversalSegment {
field,
pattern: path.to_owned(),
});
}
Ok(())
}
pub fn validate_user_paths(
paths: &[String],
field: &'static str,
errors: &mut Vec<GlobValidationError>,
) {
for path in paths {
if let Err(e) = validate_user_path(path, field) {
errors.push(e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_glob_accepted() {
assert!(compile_user_glob("src/**/*.ts", "entry").is_ok());
assert!(compile_user_glob("**/*.test.ts", "entry").is_ok());
assert!(compile_user_glob("./src/main.ts", "entry").is_ok());
assert!(compile_user_glob("packages/*/src/index.ts", "entry").is_ok());
assert!(compile_user_glob("**/{a,b}.ts", "entry").is_ok());
}
#[test]
fn bracket_character_class_accepted() {
assert!(compile_user_glob("[A-Z]*.tsx", "entry").is_ok());
assert!(compile_user_glob("src/**/[A-Z]*.{ts,tsx}", "ignoreExports[].file").is_ok());
assert!(compile_user_glob("**/[0-9][0-9]*.md", "entry").is_ok());
}
#[test]
fn validate_user_path_rejects_traversal_and_absolute() {
assert!(validate_user_path("../escape", "boundaries.zones[].root").is_err());
assert!(validate_user_path("/abs/dir", "boundaries.zones[].root").is_err());
assert!(validate_user_path("packages/ui", "boundaries.zones[].root").is_ok());
assert!(validate_user_path("[brackets-literal]/dir", "boundaries.zones[].root").is_ok());
}
#[test]
fn absolute_unix_path_rejected() {
let err = compile_user_glob("/etc/passwd", "entry").unwrap_err();
assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
let msg = err.to_string();
assert!(msg.contains("/etc/passwd"), "msg: {msg}");
assert!(msg.contains("entry"), "msg: {msg}");
assert!(msg.contains("absolute"), "msg: {msg}");
assert!(msg.contains("relative to the project root"), "msg: {msg}");
}
#[test]
fn absolute_unix_glob_rejected() {
let err = compile_user_glob("/root/.ssh/**", "ignorePatterns").unwrap_err();
assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
}
#[test]
fn absolute_windows_backslash_path_rejected() {
let err = compile_user_glob("\\Windows\\System32", "entry").unwrap_err();
assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
}
#[test]
fn unc_path_rejected() {
let err = compile_user_glob("\\\\share\\secrets", "entry").unwrap_err();
assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
}
#[test]
fn unc_forward_slash_rejected() {
let err = compile_user_glob("//share/secrets", "entry").unwrap_err();
assert!(matches!(err, GlobValidationError::AbsolutePath { .. }));
}
#[test]
fn windows_drive_letter_rejected() {
for pat in ["C:\\Users", "c:/Users", "D:foo", "Z:\\"] {
let err = compile_user_glob(pat, "entry").unwrap_err();
assert!(
matches!(err, GlobValidationError::AbsolutePath { .. }),
"expected AbsolutePath for {pat}, got {err:?}"
);
}
}
#[test]
fn traversal_segment_rejected() {
let err = compile_user_glob("../foo", "entry").unwrap_err();
assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
assert!(err.to_string().contains("../foo"));
}
#[test]
fn traversal_in_middle_rejected() {
let err = compile_user_glob("src/../../../etc", "ignorePatterns").unwrap_err();
assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
}
#[test]
fn traversal_with_backslash_rejected() {
let err = compile_user_glob("..\\foo", "entry").unwrap_err();
assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
}
#[test]
fn traversal_in_glob_pattern_rejected() {
let err = compile_user_glob("**/../secrets", "entry").unwrap_err();
assert!(matches!(err, GlobValidationError::TraversalSegment { .. }));
}
#[test]
fn double_dot_filename_accepted() {
assert!(compile_user_glob("foo..bar", "entry").is_ok());
assert!(compile_user_glob("src/file.with..dots.ts", "entry").is_ok());
}
#[test]
fn current_dir_dot_accepted() {
assert!(compile_user_glob("./src/**", "entry").is_ok());
}
#[test]
fn invalid_glob_syntax_rejected() {
let err = compile_user_glob("[invalid", "entry").unwrap_err();
assert!(matches!(err, GlobValidationError::InvalidSyntax { .. }));
let msg = err.to_string();
assert!(msg.contains("entry"), "msg: {msg}");
assert_eq!(msg.matches("[invalid").count(), 1, "msg: {msg}");
assert!(msg.contains("unclosed character class"), "msg: {msg}");
}
#[test]
fn empty_pattern_accepted_as_globset_handles_it() {
assert!(compile_user_glob("", "entry").is_ok());
}
#[test]
fn validate_user_globs_collects_all_errors() {
let patterns = vec![
"src/**".to_owned(),
"../foo".to_owned(),
"/abs".to_owned(),
"[bad".to_owned(),
"**/*.ts".to_owned(),
];
let mut errors = Vec::new();
validate_user_globs(&patterns, "ignorePatterns", &mut errors);
assert_eq!(errors.len(), 3);
assert!(matches!(
errors[0],
GlobValidationError::TraversalSegment { .. }
));
assert!(matches!(
errors[1],
GlobValidationError::AbsolutePath { .. }
));
assert!(matches!(
errors[2],
GlobValidationError::InvalidSyntax { .. }
));
}
#[test]
fn field_name_in_error_message() {
let err = compile_user_glob("../oops", "duplicates.ignore").unwrap_err();
assert!(err.to_string().starts_with("duplicates.ignore:"));
}
}