Skip to main content

codeowners_parser/
validation.rs

1//! Syntactic validation for CODEOWNERS owners and glob patterns.
2//!
3//! These checks are _syntactic only_ — they confirm the shape of an owner
4//! (`@user`, `@org/team`, or `email@host`) or the well-formedness of a glob
5//! pattern. They do not verify that the user/team exists on GitHub or that
6//! the pattern actually matches any files in the repository.
7
8use glob::Pattern;
9use regex::Regex;
10use std::sync::LazyLock;
11
12static TEAM_RE: LazyLock<Regex> =
13    LazyLock::new(|| Regex::new(r"^@[a-zA-Z0-9-]+/[a-zA-Z0-9-]+$").unwrap());
14static USER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^@[a-zA-Z0-9-]+$").unwrap());
15static EMAIL_RE: LazyLock<Regex> =
16    LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap());
17
18/// Validate an owner format - returns error message if invalid
19pub fn validate_owner(owner: &str) -> Option<String> {
20    if TEAM_RE.is_match(owner) || USER_RE.is_match(owner) || EMAIL_RE.is_match(owner) {
21        None
22    } else {
23        Some(format!(
24            "Invalid owner format '{}'. Expected @user, @org/team, or email@domain.com",
25            owner
26        ))
27    }
28}
29
30/// Validate a glob pattern - returns error message if invalid
31pub fn validate_pattern(pattern: &str) -> Option<String> {
32    // Strip leading slash for glob validation
33    let pattern_for_glob = pattern.trim_start_matches('/');
34
35    // Empty pattern is invalid
36    if pattern_for_glob.is_empty() {
37        return Some("Empty pattern".to_string());
38    }
39
40    // These are valid general-purpose glob syntax, so the glob crate accepts
41    // them — but GitHub documents both as unsupported in CODEOWNERS, where
42    // they are treated as inert literals that silently match nothing.
43    if pattern.starts_with('!') {
44        return Some(
45            "CODEOWNERS does not support '!' negation; the rule will never match".to_string(),
46        );
47    }
48    if pattern.contains('[') {
49        return Some(
50            "CODEOWNERS does not support '[...]' character ranges; the rule will never match"
51                .to_string(),
52        );
53    }
54
55    // Try to compile as glob pattern
56    if let Err(e) = Pattern::new(pattern_for_glob) {
57        return Some(format!("Invalid glob pattern: {}", e));
58    }
59
60    None
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    // Owner validation tests
68    #[test]
69    fn test_valid_user() {
70        assert!(validate_owner("@username").is_none());
71        assert!(validate_owner("@user-name").is_none());
72        assert!(validate_owner("@user123").is_none());
73    }
74
75    #[test]
76    fn test_valid_team() {
77        assert!(validate_owner("@org/team").is_none());
78        assert!(validate_owner("@my-org/my-team").is_none());
79    }
80
81    #[test]
82    fn test_invalid_owner_with_underscore() {
83        // GitHub usernames/orgs don't allow underscores
84        assert!(validate_owner("@user_name").is_some());
85        assert!(validate_owner("@org_name/team_name").is_some());
86    }
87
88    #[test]
89    fn test_valid_email() {
90        assert!(validate_owner("user@example.com").is_none());
91        assert!(validate_owner("user.name@domain.co.uk").is_none());
92        assert!(validate_owner("user+tag@example.org").is_none());
93    }
94
95    #[test]
96    fn test_invalid_owner_with_period() {
97        // GitHub usernames/orgs don't allow periods
98        assert!(validate_owner("@user.name").is_some());
99        assert!(validate_owner("@org.name/team").is_some());
100    }
101
102    #[test]
103    fn test_invalid_owner_no_at() {
104        assert!(validate_owner("username").is_some());
105    }
106
107    #[test]
108    fn test_invalid_owner_empty() {
109        assert!(validate_owner("").is_some());
110        assert!(validate_owner("@").is_some());
111    }
112
113    #[test]
114    fn test_invalid_owner_spaces() {
115        assert!(validate_owner("@user name").is_some());
116        assert!(validate_owner("user @name").is_some());
117    }
118
119    #[test]
120    fn test_invalid_team_format() {
121        assert!(validate_owner("@org/").is_some());
122        assert!(validate_owner("@/team").is_some());
123        assert!(validate_owner("@org//team").is_some());
124    }
125
126    // Pattern validation tests
127    #[test]
128    fn test_valid_patterns() {
129        assert!(validate_pattern("*").is_none());
130        assert!(validate_pattern("**").is_none());
131        assert!(validate_pattern("*.rs").is_none());
132        assert!(validate_pattern("/src/").is_none());
133        assert!(validate_pattern("/src/**").is_none());
134        assert!(validate_pattern("docs/*.md").is_none());
135    }
136
137    #[test]
138    fn test_invalid_empty_pattern() {
139        assert!(validate_pattern("").is_some());
140        assert!(validate_pattern("/").is_some());
141    }
142
143    #[test]
144    fn test_invalid_glob_syntax() {
145        // Unclosed bracket
146        assert!(validate_pattern("[invalid").is_some());
147    }
148
149    #[test]
150    fn test_rejects_syntax_codeowners_does_not_support() {
151        // Valid general-purpose glob, but GitHub treats both as inert literals
152        // that silently match nothing — so they must not pass validation.
153        assert!(validate_pattern("[abc].txt").is_some());
154        assert!(validate_pattern("src/[a-z]*.rs").is_some());
155        assert!(validate_pattern("!docs/").is_some());
156        assert!(validate_pattern("!/src/main.rs").is_some());
157
158        // A `!` that is not leading is just an ordinary filename character.
159        assert!(validate_pattern("docs/hello!.md").is_none());
160    }
161}