Skip to main content

git_proc/
ref_format.rs

1//! Shared validation for git reference names.
2//!
3//! Branches and tags are both git refs and share the rules enforced by
4//! `git check-ref-format`. This module is the single source of truth for those
5//! rules; type-specific newtypes (`Branch`, `Tag`) delegate to [`validate`].
6
7const fn is_forbidden_char(byte: u8) -> bool {
8    matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
9}
10
11/// Validate a git reference name against `git check-ref-format` rules.
12///
13/// Single-level names (no `/`) are accepted, equivalent to git's
14/// `--allow-onelevel`. The leading `-` rejection guards against shell
15/// argument confusion.
16pub const fn validate(input: &str) -> Result<(), RefFormatError> {
17    if input.is_empty() {
18        return Err(RefFormatError::Empty);
19    }
20
21    let bytes = input.as_bytes();
22
23    if bytes.len() == 1 && bytes[0] == b'@' {
24        return Err(RefFormatError::SingleAt);
25    }
26
27    if bytes[0] == b'-' {
28        return Err(RefFormatError::StartsWithDash);
29    }
30    if bytes[0] == b'.' {
31        return Err(RefFormatError::StartsWithDot);
32    }
33    if bytes[0] == b'/' {
34        return Err(RefFormatError::StartsWithSlash);
35    }
36
37    if bytes[bytes.len() - 1] == b'/' {
38        return Err(RefFormatError::EndsWithSlash);
39    }
40    if bytes[bytes.len() - 1] == b'.' {
41        return Err(RefFormatError::EndsWithDot);
42    }
43
44    // ".lock" suffix; byte-by-byte because array == is not const.
45    if bytes.len() >= 5
46        && bytes[bytes.len() - 5] == b'.'
47        && bytes[bytes.len() - 4] == b'l'
48        && bytes[bytes.len() - 3] == b'o'
49        && bytes[bytes.len() - 2] == b'c'
50        && bytes[bytes.len() - 1] == b'k'
51    {
52        return Err(RefFormatError::EndsWithLock);
53    }
54
55    let mut index = 0;
56    // Index loop because iterators are not const-compatible.
57    while index < bytes.len() {
58        let byte = bytes[index];
59
60        if byte < 0x20 || byte == 0x7f {
61            return Err(RefFormatError::ContainsControlCharacter);
62        }
63
64        if byte == b' ' {
65            return Err(RefFormatError::ContainsSpace);
66        }
67
68        if is_forbidden_char(byte) {
69            return Err(RefFormatError::ContainsForbiddenCharacter);
70        }
71
72        if byte == b'.' && index + 1 < bytes.len() && bytes[index + 1] == b'.' {
73            return Err(RefFormatError::ContainsDoubleDot);
74        }
75
76        if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'/' {
77            return Err(RefFormatError::ContainsDoubleSlash);
78        }
79
80        if byte == b'@' && index + 1 < bytes.len() && bytes[index + 1] == b'{' {
81            return Err(RefFormatError::ContainsAtBrace);
82        }
83
84        if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'.' {
85            return Err(RefFormatError::ComponentStartsWithDot);
86        }
87
88        if byte == b'.'
89            && index + 5 < bytes.len()
90            && bytes[index + 1] == b'l'
91            && bytes[index + 2] == b'o'
92            && bytes[index + 3] == b'c'
93            && bytes[index + 4] == b'k'
94            && bytes[index + 5] == b'/'
95        {
96            return Err(RefFormatError::ComponentEndsWithLock);
97        }
98
99        index += 1;
100    }
101
102    Ok(())
103}
104
105/// Errors that can occur when validating a git reference name.
106#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
107pub enum RefFormatError {
108    #[error("git ref name cannot be empty")]
109    Empty,
110    #[error("git ref name cannot be single '@'")]
111    SingleAt,
112    #[error("git ref name cannot start with '-'")]
113    StartsWithDash,
114    #[error("git ref name cannot start with '.'")]
115    StartsWithDot,
116    #[error("git ref name cannot start with '/'")]
117    StartsWithSlash,
118    #[error("git ref name cannot end with '/'")]
119    EndsWithSlash,
120    #[error("git ref name cannot end with '.'")]
121    EndsWithDot,
122    #[error("git ref name cannot end with '.lock'")]
123    EndsWithLock,
124    #[error("git ref name cannot contain '..'")]
125    ContainsDoubleDot,
126    #[error("git ref name cannot contain '//'")]
127    ContainsDoubleSlash,
128    #[error("git ref name cannot contain '@{{'")]
129    ContainsAtBrace,
130    #[error("git ref component cannot start with '.'")]
131    ComponentStartsWithDot,
132    #[error("git ref component cannot end with '.lock'")]
133    ComponentEndsWithLock,
134    #[error("git ref name cannot contain control characters")]
135    ContainsControlCharacter,
136    #[error("git ref name cannot contain spaces")]
137    ContainsSpace,
138    #[error("git ref name cannot contain forbidden characters (~^:?*[\\)")]
139    ContainsForbiddenCharacter,
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_valid() {
148        assert!(validate("main").is_ok());
149        assert!(validate("v1.0.0").is_ok());
150        assert!(validate("feature/login").is_ok());
151        assert!(validate("a/b/c").is_ok());
152    }
153
154    #[test]
155    fn test_empty() {
156        assert_eq!(validate(""), Err(RefFormatError::Empty));
157    }
158
159    #[test]
160    fn test_single_at() {
161        assert_eq!(validate("@"), Err(RefFormatError::SingleAt));
162    }
163
164    #[test]
165    fn test_starts_with_dash() {
166        assert_eq!(validate("-x"), Err(RefFormatError::StartsWithDash));
167    }
168
169    #[test]
170    fn test_starts_with_dot() {
171        assert_eq!(validate(".x"), Err(RefFormatError::StartsWithDot));
172    }
173
174    #[test]
175    fn test_starts_with_slash() {
176        assert_eq!(validate("/x"), Err(RefFormatError::StartsWithSlash));
177    }
178
179    #[test]
180    fn test_ends_with_slash() {
181        assert_eq!(validate("x/"), Err(RefFormatError::EndsWithSlash));
182    }
183
184    #[test]
185    fn test_ends_with_dot() {
186        assert_eq!(validate("x."), Err(RefFormatError::EndsWithDot));
187    }
188
189    #[test]
190    fn test_ends_with_lock() {
191        assert_eq!(validate("x.lock"), Err(RefFormatError::EndsWithLock));
192    }
193
194    #[test]
195    fn test_contains_double_dot() {
196        assert_eq!(validate("a..b"), Err(RefFormatError::ContainsDoubleDot));
197    }
198
199    #[test]
200    fn test_contains_double_slash() {
201        assert_eq!(validate("a//b"), Err(RefFormatError::ContainsDoubleSlash));
202    }
203
204    #[test]
205    fn test_contains_at_brace() {
206        assert_eq!(validate("a@{b"), Err(RefFormatError::ContainsAtBrace));
207    }
208
209    #[test]
210    fn test_component_starts_with_dot() {
211        assert_eq!(
212            validate("a/.b"),
213            Err(RefFormatError::ComponentStartsWithDot)
214        );
215        assert_eq!(
216            validate("a/b/.c/d"),
217            Err(RefFormatError::ComponentStartsWithDot)
218        );
219    }
220
221    #[test]
222    fn test_component_ends_with_lock() {
223        assert_eq!(
224            validate("a/b.lock/c"),
225            Err(RefFormatError::ComponentEndsWithLock)
226        );
227    }
228
229    #[test]
230    fn test_contains_space() {
231        assert_eq!(validate("a b"), Err(RefFormatError::ContainsSpace));
232    }
233
234    #[test]
235    fn test_contains_control_character() {
236        assert_eq!(
237            validate("a\x00b"),
238            Err(RefFormatError::ContainsControlCharacter)
239        );
240        assert_eq!(
241            validate("a\tb"),
242            Err(RefFormatError::ContainsControlCharacter)
243        );
244        assert_eq!(
245            validate("a\x7fb"),
246            Err(RefFormatError::ContainsControlCharacter)
247        );
248    }
249
250    #[test]
251    fn test_contains_forbidden_characters() {
252        for bad in ["a~b", "a^b", "a:b", "a?b", "a*b", "a[b", "a\\b"] {
253            assert_eq!(
254                validate(bad),
255                Err(RefFormatError::ContainsForbiddenCharacter)
256            );
257        }
258    }
259}