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 std::borrow::Cow;
26
27use crate::git_path::normalize_path_copy;
28
29use thiserror::Error;
30
31/// Errors returned by [`check_refname_format`].
32#[derive(Debug, Error, PartialEq, Eq)]
33pub enum RefNameError {
34    /// The ref name is empty.
35    #[error("ref name is empty")]
36    Empty,
37    /// The ref name is exactly `@`.
38    #[error("ref name is a lone '@'")]
39    LoneAt,
40    /// A component starts with `.`.
41    #[error("ref name component starts with '.'")]
42    ComponentStartsDot,
43    /// The ref name contains `..`.
44    #[error("ref name contains '..'")]
45    DoubleDot,
46    /// An illegal character was found (control chars, space, `~`, `^`, `:`, `?`, `[`, `\\`).
47    #[error("ref name contains an illegal character")]
48    IllegalChar,
49    /// The ref name contains `@{{`.
50    #[error("ref name contains '@{{'")]
51    AtBrace,
52    /// The ref name contains `*` but `--refspec-pattern` was not set, or
53    /// contains more than one `*` with `--refspec-pattern`.
54    #[error("ref name contains invalid use of '*'")]
55    InvalidWildcard,
56    /// A path component ends with `.lock`.
57    #[error("ref name component ends with '.lock'")]
58    DotLock,
59    /// The ref name ends with `/`.
60    #[error("ref name ends with '/'")]
61    TrailingSlash,
62    /// The ref name starts with `/` (after normalization).
63    #[error("ref name starts with '/'")]
64    LeadingSlash,
65    /// The ref name ends with `.`.
66    #[error("ref name ends with '.'")]
67    TrailingDot,
68    /// The ref name has only one component and `--allow-onelevel` was not set.
69    #[error("ref name has only one component (needs --allow-onelevel)")]
70    OneLevel,
71    /// The branch short name is reserved (e.g. `HEAD`).
72    #[error("branch name is reserved")]
73    ReservedBranchName,
74    /// Branch short names must not begin with `-` (matches Git's `check_branch_ref`).
75    #[error("branch name must not start with '-'")]
76    BranchStartsWithDash,
77    /// The ref name has zero-length components (consecutive slashes) that
78    /// cannot be normalized away.
79    #[error("ref name contains consecutive slashes")]
80    ConsecutiveSlashes,
81}
82
83/// Options controlling validation.
84#[derive(Debug, Clone, Default)]
85pub struct RefNameOptions {
86    /// Allow a single-level refname (no `/` separator required).
87    pub allow_onelevel: bool,
88    /// Allow exactly one `*` wildcard anywhere in the name.
89    pub refspec_pattern: bool,
90    /// Before validating, collapse consecutive slashes and strip a leading
91    /// slash.  When the resulting name is valid, [`check_refname_format`]
92    /// returns it.
93    pub normalize: bool,
94}
95
96/// Validate `refname` according to Git ref-name rules.
97///
98/// Returns `Ok(normalized)` where `normalized` is:
99/// - the ref name itself when `opts.normalize` is `false`, or
100/// - the ref name with leading `/` stripped and consecutive slashes
101///   collapsed when `opts.normalize` is `true`.
102///
103/// Returns `Err` when the ref name is invalid.
104pub fn check_refname_format(refname: &str, opts: &RefNameOptions) -> Result<String, RefNameError> {
105    check_refname_format_borrowed(refname, opts).map(Cow::into_owned)
106}
107
108/// Validate a ref name without copying it unless normalization is requested.
109///
110/// Returns a borrowed `refname` or the normalized name, with the same errors as
111/// [`check_refname_format`] for the supplied `opts`.
112pub(crate) fn check_refname_format_borrowed<'a>(
113    refname: &'a str,
114    opts: &RefNameOptions,
115) -> Result<Cow<'a, str>, RefNameError> {
116    if refname.is_empty() {
117        return Err(RefNameError::Empty);
118    }
119
120    // Apply normalization (collapse leading/consecutive slashes) when requested.
121    let normalized = if opts.normalize {
122        Cow::Owned(collapse_slashes(refname))
123    } else {
124        Cow::Borrowed(refname)
125    };
126
127    let name: &str = &normalized;
128
129    if name.is_empty() {
130        return Err(RefNameError::Empty);
131    }
132
133    // Lone '@' is always invalid.
134    if name == "@" {
135        return Err(RefNameError::LoneAt);
136    }
137
138    // Leading '/' is invalid (even after normalization collapse_slashes strips
139    // a leading slash, so if it's still here it means the whole name was just
140    // slashes → empty after stripping, caught above).
141    // Actually collapse_slashes keeps one leading slash if there is content
142    // after it — in non-normalize mode we reject it directly.
143    if !opts.normalize && name.starts_with('/') {
144        return Err(RefNameError::LeadingSlash);
145    }
146
147    // Trailing '/' is always invalid (even after normalize it would be gone
148    // because there's no component after it).
149    if name.ends_with('/') {
150        return Err(RefNameError::TrailingSlash);
151    }
152
153    // Trailing '.' is always invalid.
154    if name.ends_with('.') {
155        return Err(RefNameError::TrailingDot);
156    }
157
158    // Walk through the name byte-by-byte, tracking component starts.
159    let bytes = name.as_bytes();
160    let mut component_start = 0usize;
161    let mut component_count = 0usize;
162    let mut last = b'\0';
163    let mut wildcard_used = false;
164
165    let mut i = 0usize;
166    while i < bytes.len() {
167        let ch = bytes[i];
168
169        match ch {
170            b'/' => {
171                // End of a component.
172                let comp_len = i - component_start;
173                if comp_len == 0 {
174                    // Consecutive or leading slash (shouldn't happen after
175                    // normalization, but catch it in non-normalize mode).
176                    return Err(RefNameError::ConsecutiveSlashes);
177                }
178                // Validate the finished component.
179                validate_component(&bytes[component_start..i], &mut wildcard_used, opts)?;
180                component_count += 1;
181                component_start = i + 1;
182                last = ch;
183                i += 1;
184                continue;
185            }
186            b'.' if last == b'.' => {
187                return Err(RefNameError::DoubleDot);
188            }
189            b'{' if last == b'@' => {
190                return Err(RefNameError::AtBrace);
191            }
192            b'*' => {
193                if !opts.refspec_pattern {
194                    return Err(RefNameError::InvalidWildcard);
195                }
196                if wildcard_used {
197                    return Err(RefNameError::InvalidWildcard);
198                }
199                wildcard_used = true;
200            }
201            // Control characters (< 0x20 or DEL 0x7f) and forbidden chars.
202            0x00..=0x1f | 0x7f | b' ' | b'~' | b'^' | b':' | b'?' | b'[' | b'\\' => {
203                return Err(RefNameError::IllegalChar);
204            }
205            _ => {}
206        }
207
208        last = ch;
209        i += 1;
210    }
211
212    // Validate the last component (from component_start to end).
213    let last_comp = &bytes[component_start..];
214    if last_comp.is_empty() {
215        // Name ended with '/' — already checked above, but be safe.
216        return Err(RefNameError::TrailingSlash);
217    }
218    validate_component(last_comp, &mut wildcard_used, opts)?;
219    component_count += 1;
220
221    // At least two components required unless --allow-onelevel.
222    if !opts.allow_onelevel && component_count < 2 {
223        return Err(RefNameError::OneLevel);
224    }
225
226    Ok(normalized)
227}
228
229/// Validate a single path component (the bytes between `/` separators, or the
230/// entire name when there are no slashes).
231///
232/// Rules checked here:
233/// - Must not start with `.`
234/// - Must not end with `.lock`
235fn validate_component(
236    comp: &[u8],
237    _wildcard_used: &mut bool,
238    _opts: &RefNameOptions,
239) -> Result<(), RefNameError> {
240    if comp.is_empty() {
241        return Err(RefNameError::ConsecutiveSlashes);
242    }
243
244    // Component must not start with '.'.
245    if comp[0] == b'.' {
246        return Err(RefNameError::ComponentStartsDot);
247    }
248
249    // Component must not end with ".lock".
250    const LOCK_SUFFIX: &[u8] = b".lock";
251    if comp.len() >= LOCK_SUFFIX.len() && comp.ends_with(LOCK_SUFFIX) {
252        return Err(RefNameError::DotLock);
253    }
254
255    Ok(())
256}
257
258/// Whether `refname` is safe to map to a path under ref storage.
259///
260/// Matches Git's `refname_is_safe()` in `refs.c`: refs under `refs/` must not
261/// escape that prefix via `.` / `..` components; root-style names must be
262/// uppercase ASCII letters and underscores only (e.g. `HEAD`).
263#[must_use]
264pub fn refname_is_safe(refname: &str) -> bool {
265    if let Some(rest) = refname.strip_prefix("refs/") {
266        if rest.is_empty() || rest.starts_with('/') || rest.ends_with('/') {
267            return false;
268        }
269        return normalize_path_copy(rest)
270            .ok()
271            .is_some_and(|normalized| normalized == rest);
272    }
273
274    if refname.is_empty() {
275        return false;
276    }
277    refname.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')
278}
279
280/// Validate a ref name sent on the receive-pack wire before applying updates.
281///
282/// Mirrors the check in Git's `receive-pack` `update()`: only `refs/…` names,
283/// with the suffix validated via [`check_refname_format`].
284#[must_use]
285pub fn receive_pack_refname_ok(refname: &str, is_delete: bool) -> bool {
286    if !refname.starts_with("refs/") {
287        return false;
288    }
289    check_refname_format(
290        &refname[5..],
291        &RefNameOptions {
292            allow_onelevel: is_delete,
293            ..Default::default()
294        },
295    )
296    .is_ok()
297}
298
299/// Strip a leading `/` and collapse consecutive interior slashes to one.
300///
301/// This collapses runs of `/` the same way `git check-ref-format` does.
302pub fn collapse_slashes(refname: &str) -> String {
303    let mut result = String::with_capacity(refname.len());
304    let mut prev = b'/';
305
306    for ch in refname.bytes() {
307        if prev == b'/' && ch == b'/' {
308            // Skip consecutive slashes (including a leading one when prev
309            // was initialized to '/').
310            continue;
311        }
312        result.push(ch as char);
313        prev = ch;
314    }
315
316    result
317}
318
319/// Validate a branch short name (`refs/heads/<name>`) the way `git check-ref-format
320/// --branch` does (Git's `check_branch_ref` in `refs.c`).
321///
322/// # Errors
323///
324/// Returns [`RefNameError`] when the name is empty, reserved, or fails ref-name rules.
325pub fn validate_branch_short_name(name: &str) -> Result<(), RefNameError> {
326    if name.is_empty() {
327        return Err(RefNameError::Empty);
328    }
329    if name.starts_with('-') {
330        return Err(RefNameError::BranchStartsWithDash);
331    }
332    if name == "HEAD" {
333        return Err(RefNameError::ReservedBranchName);
334    }
335    let full = format!("refs/heads/{name}");
336    check_refname_format(&full, &RefNameOptions::default()).map(|_| ())
337}
338
339/// Validate a tag short name (`refs/tags/<name>`) the way `git check-ref-format
340/// refs/tags/<name>` does.
341///
342/// # Errors
343///
344/// Returns [`RefNameError`] when the full tag ref would be invalid.
345pub fn validate_tag_short_name(name: &str) -> Result<(), RefNameError> {
346    if name.is_empty() {
347        return Err(RefNameError::Empty);
348    }
349    let full = format!("refs/tags/{name}");
350    check_refname_format(&full, &RefNameOptions::default()).map(|_| ())
351}
352
353/// Whether a ref name from a fetch or upload-pack advertisement should be kept.
354///
355/// Matches Git's `check_ref()` in `connect.c` with `REF_NORMAL`: the name must
356/// begin with `refs/` and pass [`check_refname_format`] without
357/// `--allow-onelevel` (rejecting `..`, illegal characters, and other malformed
358/// paths). Peeled `^{}` carriers are handled separately by callers.
359#[must_use]
360pub fn is_valid_fetch_advertised_ref(name: &str) -> bool {
361    if !name.starts_with("refs/") {
362        return false;
363    }
364    check_refname_format(name, &RefNameOptions::default()).is_ok()
365}
366
367/// Whether a `symref=HEAD:<target>` (or v2 `symref-target:`) value is safe to record.
368///
369/// Matches Git's `parse_symref_capability` in `connect.c`, which validates both
370/// sides with [`RefNameOptions::allow_onelevel`].
371#[must_use]
372pub fn is_valid_advertised_symref_target(name: &str) -> bool {
373    check_refname_format(
374        name,
375        &RefNameOptions {
376            allow_onelevel: true,
377            ..Default::default()
378        },
379    )
380    .is_ok()
381}
382
383#[cfg(test)]
384mod tests {
385    #[test]
386    fn validation_borrows_names_when_normalization_is_disabled() {
387        let name = "refs/heads/topic";
388        assert!(matches!(
389            super::check_refname_format_borrowed(name, &super::RefNameOptions::default()),
390            Ok(std::borrow::Cow::Borrowed(value)) if value == name
391        ));
392        let opts = super::RefNameOptions {
393            normalize: true,
394            ..Default::default()
395        };
396        assert!(matches!(
397            super::check_refname_format_borrowed("/refs//heads/topic", &opts),
398            Ok(std::borrow::Cow::Owned(value)) if value == name
399        ));
400    }
401
402    use super::*;
403
404    fn opts_default() -> RefNameOptions {
405        RefNameOptions::default()
406    }
407
408    fn opts_onelevel() -> RefNameOptions {
409        RefNameOptions {
410            allow_onelevel: true,
411            ..Default::default()
412        }
413    }
414
415    fn opts_refspec() -> RefNameOptions {
416        RefNameOptions {
417            refspec_pattern: true,
418            ..Default::default()
419        }
420    }
421
422    fn opts_normalize() -> RefNameOptions {
423        RefNameOptions {
424            normalize: true,
425            ..Default::default()
426        }
427    }
428
429    fn valid(refname: &str, opts: &RefNameOptions) {
430        assert!(
431            check_refname_format(refname, opts).is_ok(),
432            "expected '{refname}' to be valid with opts={opts:?}"
433        );
434    }
435
436    fn invalid(refname: &str, opts: &RefNameOptions) {
437        assert!(
438            check_refname_format(refname, opts).is_err(),
439            "expected '{refname}' to be invalid with opts={opts:?}"
440        );
441    }
442
443    #[test]
444    fn empty_is_invalid() {
445        invalid("", &opts_default());
446        invalid("", &opts_onelevel());
447    }
448
449    #[test]
450    fn basic_valid() {
451        valid("foo/bar/baz", &opts_default());
452        valid("refs/heads/main", &opts_default());
453    }
454
455    #[test]
456    fn one_level_requires_flag() {
457        invalid("foo", &opts_default());
458        valid("foo", &opts_onelevel());
459    }
460
461    #[test]
462    fn double_dot_invalid() {
463        invalid("heads/foo..bar", &opts_default());
464    }
465
466    #[test]
467    fn trailing_dot_invalid() {
468        invalid("refs/heads/foo.", &opts_default());
469        invalid("heads/foo.", &opts_default());
470    }
471
472    #[test]
473    fn component_starts_with_dot() {
474        invalid("./foo", &opts_default());
475        invalid(".refs/foo", &opts_default());
476        invalid("foo/./bar", &opts_default());
477    }
478
479    #[test]
480    fn dot_lock_invalid() {
481        invalid("heads/foo.lock", &opts_default());
482        invalid("foo.lock/bar", &opts_default());
483    }
484
485    #[test]
486    fn at_brace_invalid() {
487        invalid("heads/v@{ation", &opts_default());
488    }
489
490    #[test]
491    fn lone_at_invalid() {
492        invalid("@", &opts_default());
493        invalid("@", &opts_onelevel());
494    }
495
496    #[test]
497    fn wildcard_requires_flag() {
498        invalid("foo/*", &opts_default());
499        valid(
500            "foo/*",
501            &RefNameOptions {
502                refspec_pattern: true,
503                allow_onelevel: false,
504                normalize: false,
505            },
506        );
507    }
508
509    #[test]
510    fn double_wildcard_invalid() {
511        invalid("foo/*/*", &opts_refspec());
512    }
513
514    #[test]
515    fn control_chars_invalid() {
516        invalid("heads/foo\x01", &opts_default());
517        invalid("heads/foo\x7f", &opts_default());
518    }
519
520    #[test]
521    fn forbidden_chars_invalid() {
522        invalid("heads/foo?bar", &opts_default());
523        invalid("heads/foo bar", &opts_default());
524        invalid("heads/foo~bar", &opts_default());
525        invalid("heads/foo^bar", &opts_default());
526        invalid("heads/foo:bar", &opts_default());
527        invalid("heads/foo[bar", &opts_default());
528        invalid("heads/foo\\bar", &opts_default());
529    }
530
531    #[test]
532    fn normalize_collapses_slashes() {
533        let result = check_refname_format("refs///heads/foo", &opts_normalize());
534        assert!(result.is_ok());
535        assert_eq!(result.unwrap(), "refs/heads/foo");
536    }
537
538    #[test]
539    fn normalize_strips_leading_slash() {
540        let result = check_refname_format("/heads/foo", &opts_normalize());
541        assert!(result.is_ok());
542        assert_eq!(result.unwrap(), "heads/foo");
543    }
544
545    #[test]
546    fn leading_slash_without_normalize() {
547        invalid("/heads/foo", &opts_default());
548    }
549
550    #[test]
551    fn foo_dot_slash_bar_valid() {
552        // "foo./bar" is valid — the dot is not at the start of a component
553        // and doesn't form ".lock".
554        valid("foo./bar", &opts_default());
555    }
556
557    #[test]
558    fn utf8_allowed() {
559        // Non-ASCII bytes that are valid UTF-8 are allowed.
560        valid("heads/fu\u{00DF}", &opts_default());
561    }
562
563    #[test]
564    fn refname_is_safe_rejects_traversal_under_refs() {
565        assert!(!refname_is_safe("refs/heads/../../config"));
566        assert!(!refname_is_safe("refs/foo/../../bar"));
567        assert!(!refname_is_safe("refs/foo/../bar"));
568        assert!(refname_is_safe("refs/heads/main"));
569        assert!(refname_is_safe("HEAD"));
570    }
571
572    #[test]
573    fn receive_pack_refname_ok_rejects_funny_refs() {
574        assert!(!receive_pack_refname_ok("refs/heads/../../config", true));
575        assert!(!receive_pack_refname_ok("refs/heads/../../config", false));
576        assert!(!receive_pack_refname_ok("heads/main", false));
577        assert!(receive_pack_refname_ok("refs/heads/main", false));
578        assert!(receive_pack_refname_ok("refs/tags/v1", true));
579    }
580
581    #[test]
582    fn fetch_advertised_ref_rejects_traversal_and_illegal() {
583        assert!(is_valid_fetch_advertised_ref("refs/heads/main"));
584        assert!(is_valid_fetch_advertised_ref("refs/tags/v1.0"));
585        assert!(!is_valid_fetch_advertised_ref("refs/heads/../../../config"));
586        assert!(!is_valid_fetch_advertised_ref("heads/main"));
587        assert!(!is_valid_fetch_advertised_ref("refs/heads/foo..bar"));
588    }
589
590    #[test]
591    fn symref_target_rejects_traversal() {
592        assert!(is_valid_advertised_symref_target("refs/heads/main"));
593        assert!(!is_valid_advertised_symref_target(
594            "refs/heads/../../../config"
595        ));
596    }
597
598    #[test]
599    fn validate_branch_short_name_issue_927() {
600        let invalid = ["bad name", "x..y", "a~b", "a.lock", "HEAD", "a:b", "a*b"];
601        for name in invalid {
602            assert!(
603                validate_branch_short_name(name).is_err(),
604                "expected branch name {name:?} to be rejected"
605            );
606        }
607        assert!(validate_branch_short_name("main").is_ok());
608        assert!(validate_branch_short_name("feature/foo").is_ok());
609        assert!(validate_branch_short_name("@").is_ok());
610        assert!(validate_branch_short_name("-").is_err());
611        assert!(validate_branch_short_name("-topic").is_err());
612    }
613
614    #[test]
615    fn validate_tag_short_name_issue_927() {
616        let invalid = ["bad name", "a..b", "x.lock"];
617        for name in invalid {
618            assert!(
619                validate_tag_short_name(name).is_err(),
620                "expected tag name {name:?} to be rejected"
621            );
622        }
623        assert!(validate_tag_short_name("v1.0").is_ok());
624        assert!(validate_tag_short_name("@").is_ok());
625        assert!(validate_tag_short_name("HEAD").is_ok());
626    }
627}