Skip to main content

grit_lib/
check_ref_format.rs

1//! Ref-name validation — `git check-ref-format` rules.
2//!
3//! This module implements the same validation logic as
4//! `git check_refname_format()` in `git/refs.c`, including the
5//! `--allow-onelevel`, `--refspec-pattern`, and `--normalize` options.
6//!
7//! # Rules
8//!
9//! A ref name is valid when:
10//!
11//! 1. No path component begins with `.`
12//! 2. No `..` anywhere
13//! 3. No ASCII control characters (< 0x20 or DEL 0x7f)
14//! 4. No space, `~`, `^`, `:`, `?`, `[`, `\`
15//! 5. No trailing `/`
16//! 6. No path component ends with `.lock`
17//! 7. No `@{`
18//! 8. Cannot be exactly `@`
19//! 9. No consecutive slashes `//` (unless `--normalize` collapses them)
20//! 10. No leading `/` (unless `--normalize` strips it)
21//! 11. No trailing `.`
22//! 12. Must have at least two slash-separated components (unless
23//!     `--allow-onelevel`)
24
25use thiserror::Error;
26
27/// Errors returned by [`check_refname_format`].
28#[derive(Debug, Error, PartialEq, Eq)]
29pub enum RefNameError {
30    /// The ref name is empty.
31    #[error("ref name is empty")]
32    Empty,
33    /// The ref name is exactly `@`.
34    #[error("ref name is a lone '@'")]
35    LoneAt,
36    /// A component starts with `.`.
37    #[error("ref name component starts with '.'")]
38    ComponentStartsDot,
39    /// The ref name contains `..`.
40    #[error("ref name contains '..'")]
41    DoubleDot,
42    /// An illegal character was found (control chars, space, `~`, `^`, `:`, `?`, `[`, `\\`).
43    #[error("ref name contains an illegal character")]
44    IllegalChar,
45    /// The ref name contains `@{{`.
46    #[error("ref name contains '@{{'")]
47    AtBrace,
48    /// The ref name contains `*` but `--refspec-pattern` was not set, or
49    /// contains more than one `*` with `--refspec-pattern`.
50    #[error("ref name contains invalid use of '*'")]
51    InvalidWildcard,
52    /// A path component ends with `.lock`.
53    #[error("ref name component ends with '.lock'")]
54    DotLock,
55    /// The ref name ends with `/`.
56    #[error("ref name ends with '/'")]
57    TrailingSlash,
58    /// The ref name starts with `/` (after normalization).
59    #[error("ref name starts with '/'")]
60    LeadingSlash,
61    /// The ref name ends with `.`.
62    #[error("ref name ends with '.'")]
63    TrailingDot,
64    /// The ref name has only one component and `--allow-onelevel` was not set.
65    #[error("ref name has only one component (needs --allow-onelevel)")]
66    OneLevel,
67    /// The ref name has zero-length components (consecutive slashes) that
68    /// cannot be normalized away.
69    #[error("ref name contains consecutive slashes")]
70    ConsecutiveSlashes,
71}
72
73/// Options controlling validation.
74#[derive(Debug, Clone, Default)]
75pub struct RefNameOptions {
76    /// Allow a single-level refname (no `/` separator required).
77    pub allow_onelevel: bool,
78    /// Allow exactly one `*` wildcard anywhere in the name.
79    pub refspec_pattern: bool,
80    /// Before validating, collapse consecutive slashes and strip a leading
81    /// slash.  When the resulting name is valid, [`check_refname_format`]
82    /// returns it.
83    pub normalize: bool,
84}
85
86/// Validate `refname` according to Git ref-name rules.
87///
88/// Returns `Ok(normalized)` where `normalized` is:
89/// - the ref name itself when `opts.normalize` is `false`, or
90/// - the ref name with leading `/` stripped and consecutive slashes
91///   collapsed when `opts.normalize` is `true`.
92///
93/// Returns `Err` when the ref name is invalid.
94pub fn check_refname_format(refname: &str, opts: &RefNameOptions) -> Result<String, RefNameError> {
95    if refname.is_empty() {
96        return Err(RefNameError::Empty);
97    }
98
99    // Apply normalization (collapse leading/consecutive slashes) when requested.
100    let normalized = if opts.normalize {
101        collapse_slashes(refname)
102    } else {
103        refname.to_owned()
104    };
105
106    let name: &str = &normalized;
107
108    if name.is_empty() {
109        return Err(RefNameError::Empty);
110    }
111
112    // Lone '@' is always invalid.
113    if name == "@" {
114        return Err(RefNameError::LoneAt);
115    }
116
117    // Leading '/' is invalid (even after normalization collapse_slashes strips
118    // a leading slash, so if it's still here it means the whole name was just
119    // slashes → empty after stripping, caught above).
120    // Actually collapse_slashes keeps one leading slash if there is content
121    // after it — in non-normalize mode we reject it directly.
122    if !opts.normalize && name.starts_with('/') {
123        return Err(RefNameError::LeadingSlash);
124    }
125
126    // Trailing '/' is always invalid (even after normalize it would be gone
127    // because there's no component after it).
128    if name.ends_with('/') {
129        return Err(RefNameError::TrailingSlash);
130    }
131
132    // Trailing '.' is always invalid.
133    if name.ends_with('.') {
134        return Err(RefNameError::TrailingDot);
135    }
136
137    // Walk through the name byte-by-byte, tracking component starts.
138    let bytes = name.as_bytes();
139    let mut component_start = 0usize;
140    let mut component_count = 0usize;
141    let mut last = b'\0';
142    let mut wildcard_used = false;
143
144    let mut i = 0usize;
145    while i < bytes.len() {
146        let ch = bytes[i];
147
148        match ch {
149            b'/' => {
150                // End of a component.
151                let comp_len = i - component_start;
152                if comp_len == 0 {
153                    // Consecutive or leading slash (shouldn't happen after
154                    // normalization, but catch it in non-normalize mode).
155                    return Err(RefNameError::ConsecutiveSlashes);
156                }
157                // Validate the finished component.
158                validate_component(&bytes[component_start..i], &mut wildcard_used, opts)?;
159                component_count += 1;
160                component_start = i + 1;
161                last = ch;
162                i += 1;
163                continue;
164            }
165            b'.' if last == b'.' => {
166                return Err(RefNameError::DoubleDot);
167            }
168            b'{' if last == b'@' => {
169                return Err(RefNameError::AtBrace);
170            }
171            b'*' => {
172                if !opts.refspec_pattern {
173                    return Err(RefNameError::InvalidWildcard);
174                }
175                if wildcard_used {
176                    return Err(RefNameError::InvalidWildcard);
177                }
178                wildcard_used = true;
179            }
180            // Control characters (< 0x20 or DEL 0x7f) and forbidden chars.
181            0x00..=0x1f | 0x7f | b' ' | b'~' | b'^' | b':' | b'?' | b'[' | b'\\' => {
182                return Err(RefNameError::IllegalChar);
183            }
184            _ => {}
185        }
186
187        last = ch;
188        i += 1;
189    }
190
191    // Validate the last component (from component_start to end).
192    let last_comp = &bytes[component_start..];
193    if last_comp.is_empty() {
194        // Name ended with '/' — already checked above, but be safe.
195        return Err(RefNameError::TrailingSlash);
196    }
197    validate_component(last_comp, &mut wildcard_used, opts)?;
198    component_count += 1;
199
200    // At least two components required unless --allow-onelevel.
201    if !opts.allow_onelevel && component_count < 2 {
202        return Err(RefNameError::OneLevel);
203    }
204
205    Ok(normalized)
206}
207
208/// Validate a single path component (the bytes between `/` separators, or the
209/// entire name when there are no slashes).
210///
211/// Rules checked here:
212/// - Must not start with `.`
213/// - Must not end with `.lock`
214fn validate_component(
215    comp: &[u8],
216    _wildcard_used: &mut bool,
217    _opts: &RefNameOptions,
218) -> Result<(), RefNameError> {
219    if comp.is_empty() {
220        return Err(RefNameError::ConsecutiveSlashes);
221    }
222
223    // Component must not start with '.'.
224    if comp[0] == b'.' {
225        return Err(RefNameError::ComponentStartsDot);
226    }
227
228    // Component must not end with ".lock".
229    const LOCK_SUFFIX: &[u8] = b".lock";
230    if comp.len() >= LOCK_SUFFIX.len() && comp.ends_with(LOCK_SUFFIX) {
231        return Err(RefNameError::DotLock);
232    }
233
234    Ok(())
235}
236
237/// Strip a leading `/` and collapse consecutive interior slashes to one.
238///
239/// This mirrors git's `collapse_slashes()` in `builtin/check-ref-format.c`.
240pub fn collapse_slashes(refname: &str) -> String {
241    let mut result = String::with_capacity(refname.len());
242    let mut prev = b'/';
243
244    for ch in refname.bytes() {
245        if prev == b'/' && ch == b'/' {
246            // Skip consecutive slashes (including a leading one when prev
247            // was initialized to '/').
248            continue;
249        }
250        result.push(ch as char);
251        prev = ch;
252    }
253
254    result
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn opts_default() -> RefNameOptions {
262        RefNameOptions::default()
263    }
264
265    fn opts_onelevel() -> RefNameOptions {
266        RefNameOptions {
267            allow_onelevel: true,
268            ..Default::default()
269        }
270    }
271
272    fn opts_refspec() -> RefNameOptions {
273        RefNameOptions {
274            refspec_pattern: true,
275            ..Default::default()
276        }
277    }
278
279    fn opts_normalize() -> RefNameOptions {
280        RefNameOptions {
281            normalize: true,
282            ..Default::default()
283        }
284    }
285
286    fn valid(refname: &str, opts: &RefNameOptions) {
287        assert!(
288            check_refname_format(refname, opts).is_ok(),
289            "expected '{refname}' to be valid with opts={opts:?}"
290        );
291    }
292
293    fn invalid(refname: &str, opts: &RefNameOptions) {
294        assert!(
295            check_refname_format(refname, opts).is_err(),
296            "expected '{refname}' to be invalid with opts={opts:?}"
297        );
298    }
299
300    #[test]
301    fn empty_is_invalid() {
302        invalid("", &opts_default());
303        invalid("", &opts_onelevel());
304    }
305
306    #[test]
307    fn basic_valid() {
308        valid("foo/bar/baz", &opts_default());
309        valid("refs/heads/main", &opts_default());
310    }
311
312    #[test]
313    fn one_level_requires_flag() {
314        invalid("foo", &opts_default());
315        valid("foo", &opts_onelevel());
316    }
317
318    #[test]
319    fn double_dot_invalid() {
320        invalid("heads/foo..bar", &opts_default());
321    }
322
323    #[test]
324    fn trailing_dot_invalid() {
325        invalid("refs/heads/foo.", &opts_default());
326        invalid("heads/foo.", &opts_default());
327    }
328
329    #[test]
330    fn component_starts_with_dot() {
331        invalid("./foo", &opts_default());
332        invalid(".refs/foo", &opts_default());
333        invalid("foo/./bar", &opts_default());
334    }
335
336    #[test]
337    fn dot_lock_invalid() {
338        invalid("heads/foo.lock", &opts_default());
339        invalid("foo.lock/bar", &opts_default());
340    }
341
342    #[test]
343    fn at_brace_invalid() {
344        invalid("heads/v@{ation", &opts_default());
345    }
346
347    #[test]
348    fn lone_at_invalid() {
349        invalid("@", &opts_default());
350        invalid("@", &opts_onelevel());
351    }
352
353    #[test]
354    fn wildcard_requires_flag() {
355        invalid("foo/*", &opts_default());
356        valid(
357            "foo/*",
358            &RefNameOptions {
359                refspec_pattern: true,
360                allow_onelevel: false,
361                normalize: false,
362            },
363        );
364    }
365
366    #[test]
367    fn double_wildcard_invalid() {
368        invalid("foo/*/*", &opts_refspec());
369    }
370
371    #[test]
372    fn control_chars_invalid() {
373        invalid("heads/foo\x01", &opts_default());
374        invalid("heads/foo\x7f", &opts_default());
375    }
376
377    #[test]
378    fn forbidden_chars_invalid() {
379        invalid("heads/foo?bar", &opts_default());
380        invalid("heads/foo bar", &opts_default());
381        invalid("heads/foo~bar", &opts_default());
382        invalid("heads/foo^bar", &opts_default());
383        invalid("heads/foo:bar", &opts_default());
384        invalid("heads/foo[bar", &opts_default());
385        invalid("heads/foo\\bar", &opts_default());
386    }
387
388    #[test]
389    fn normalize_collapses_slashes() {
390        let result = check_refname_format("refs///heads/foo", &opts_normalize());
391        assert!(result.is_ok());
392        assert_eq!(result.unwrap(), "refs/heads/foo");
393    }
394
395    #[test]
396    fn normalize_strips_leading_slash() {
397        let result = check_refname_format("/heads/foo", &opts_normalize());
398        assert!(result.is_ok());
399        assert_eq!(result.unwrap(), "heads/foo");
400    }
401
402    #[test]
403    fn leading_slash_without_normalize() {
404        invalid("/heads/foo", &opts_default());
405    }
406
407    #[test]
408    fn foo_dot_slash_bar_valid() {
409        // "foo./bar" is valid — the dot is not at the start of a component
410        // and doesn't form ".lock".
411        valid("foo./bar", &opts_default());
412    }
413
414    #[test]
415    fn utf8_allowed() {
416        // Non-ASCII bytes that are valid UTF-8 are allowed.
417        valid("heads/fu\u{00DF}", &opts_default());
418    }
419}