Skip to main content

grit_lib/
refspec.rs

1//! Refspec parsing and validation — a port of `git/refspec.c`.
2//!
3//! A *refspec* describes how references map between a local and a remote
4//! repository.  It has the general form `[+|^]<src>[:<dst>]`.  This module
5//! parses a single refspec string and validates it according to the same rules
6//! as Git's `parse_refspec()`, distinguishing fetch refspecs from push
7//! refspecs (the two have slightly different validity rules).
8//!
9//! The primary entry points are [`parse_fetch_refspec`] and
10//! [`parse_push_refspec`], which return [`RefspecItem`] on success or
11//! [`RefspecError::Invalid`] when the refspec is malformed.  Callers that only
12//! care about validity (for example loading `remote.<name>.fetch` /
13//! `remote.<name>.push` config) can use [`valid_fetch_refspec`] and
14//! [`valid_push_refspec`].
15
16use crate::check_ref_format::{check_refname_format, RefNameOptions};
17
18/// A parsed refspec item.
19///
20/// Fields mirror `struct refspec_item` in `git/refspec.h`, capturing the
21/// modifier flags and the source/destination sides of the mapping.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct RefspecItem {
24    /// The `+` modifier was present (force / non-fast-forward update).
25    pub force: bool,
26    /// The `^` modifier was present (negative refspec — exclusion).
27    pub negative: bool,
28    /// This refspec is the bare `:` (or `+:`) push refspec for matching refs.
29    pub matching: bool,
30    /// The refspec uses a `*` glob pattern.
31    pub pattern: bool,
32    /// The source side is an exact (full-length hex) object id.
33    pub exact_sha1: bool,
34    /// The source side (`<src>`), or `None` when absent.
35    pub src: Option<String>,
36    /// The destination side (`<dst>`), or `None` when no `:` was present.
37    pub dst: Option<String>,
38}
39
40/// Error returned when a refspec cannot be parsed.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum RefspecError {
43    /// The refspec string is invalid.
44    Invalid(String),
45}
46
47impl std::fmt::Display for RefspecError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            RefspecError::Invalid(s) => write!(f, "invalid refspec '{s}'"),
51        }
52    }
53}
54
55impl std::error::Error for RefspecError {}
56
57/// Length of a full SHA-1 hex object id.
58const SHA1_HEXSZ: usize = 40;
59
60/// Returns `true` when `s` is a string of exactly `SHA1_HEXSZ` hex digits.
61fn is_exact_sha1_hex(s: &str) -> bool {
62    s.len() == SHA1_HEXSZ && s.bytes().all(|b| b.is_ascii_hexdigit())
63}
64
65/// Validate `name` as a refname using the same flags as Git's
66/// `check_refname_format` with `REFNAME_ALLOW_ONELEVEL` and, when `is_glob` is
67/// set, `REFNAME_REFSPEC_PATTERN`.
68///
69/// Returns `true` when the name is well-formed.
70fn refname_ok(name: &str, is_glob: bool) -> bool {
71    let opts = RefNameOptions {
72        allow_onelevel: true,
73        refspec_pattern: is_glob,
74        normalize: false,
75    };
76    check_refname_format(name, &opts).is_ok()
77}
78
79/// Parse a single refspec.  `fetch` selects fetch (`true`) vs push (`false`)
80/// validity rules.
81///
82/// This is a direct port of `parse_refspec()` in `git/refspec.c`.
83fn parse_refspec(refspec: &str, fetch: bool) -> Result<RefspecItem, RefspecError> {
84    let bytes = refspec.as_bytes();
85    let invalid = || RefspecError::Invalid(refspec.to_owned());
86
87    let mut item = RefspecItem::default();
88    let mut is_glob = false;
89
90    // Leading modifier: '+' (force) or '^' (negative).
91    let mut lhs_start = 0usize;
92    if let Some(&first) = bytes.first() {
93        if first == b'+' {
94            item.force = true;
95            lhs_start = 1;
96        } else if first == b'^' {
97            item.negative = true;
98            lhs_start = 1;
99        }
100    }
101
102    let lhs = &refspec[lhs_start..];
103
104    // rhs points to the last ':' within lhs (strrchr).
105    let colon_pos = lhs.rfind(':');
106
107    // Negative refspecs only have one side.
108    if item.negative && colon_pos.is_some() {
109        return Err(invalid());
110    }
111
112    // Special case ":" (or "+:") as a push refspec for matching refs.
113    // In C: rhs == lhs && rhs[1] == '\0' — i.e. the ':' is the first char of
114    // lhs and is the only char.
115    if !fetch && colon_pos == Some(0) && lhs.len() == 1 {
116        item.matching = true;
117        return Ok(item);
118    }
119
120    // Compute src (lhs) and dst (rhs) substrings.
121    let (lhs_str, rhs_opt): (&str, Option<&str>) = match colon_pos {
122        Some(pos) => (&lhs[..pos], Some(&lhs[pos + 1..])),
123        None => (lhs, None),
124    };
125
126    if let Some(rhs) = rhs_opt {
127        let rlen = rhs.len();
128        is_glob = rlen >= 1 && rhs.contains('*');
129        item.dst = Some(rhs.to_owned());
130    } else {
131        item.dst = None;
132    }
133
134    let llen = lhs_str.len();
135    if llen >= 1 && lhs_str.contains('*') {
136        // LHS has a '*'.
137        if (rhs_opt.is_some() && !is_glob) || (rhs_opt.is_none() && !item.negative && fetch) {
138            return Err(invalid());
139        }
140        is_glob = true;
141    } else if rhs_opt.is_some() && is_glob {
142        // RHS is a glob but LHS is not.
143        return Err(invalid());
144    }
145
146    item.pattern = is_glob;
147    if llen == 1 && lhs_str == "@" {
148        item.src = Some("HEAD".to_owned());
149    } else {
150        item.src = Some(lhs_str.to_owned());
151    }
152    let src = item.src.as_deref().unwrap_or("");
153
154    if item.negative {
155        // Negative refspecs only have a LHS.
156        if src.is_empty() {
157            return Err(invalid()); // must not be empty
158        } else if is_exact_sha1_hex(src) {
159            return Err(invalid()); // cannot be exact sha1
160        } else if refname_ok(src, is_glob) {
161            // valid looking ref is ok
162        } else {
163            return Err(invalid());
164        }
165        return Ok(item);
166    }
167
168    if fetch {
169        // LHS
170        if src.is_empty() {
171            // empty is ok; it means "HEAD"
172        } else if is_exact_sha1_hex(src) {
173            item.exact_sha1 = true; // ok
174        } else if refname_ok(src, is_glob) {
175            // valid looking ref is ok
176        } else {
177            return Err(invalid());
178        }
179        // RHS
180        match item.dst.as_deref() {
181            None => {}     // missing is ok; same as empty
182            Some("") => {} // empty is ok; means "do not store"
183            Some(dst) => {
184                if !refname_ok(dst, is_glob) {
185                    return Err(invalid());
186                }
187            }
188        }
189    } else {
190        // push
191        // LHS
192        if src.is_empty() {
193            // empty is ok
194        } else if is_glob {
195            if !refname_ok(src, is_glob) {
196                return Err(invalid());
197            }
198        } else {
199            // anything goes, for now
200        }
201        // RHS
202        match item.dst.as_deref() {
203            None => {
204                // missing is allowed, but LHS then must be a valid looking ref.
205                if !refname_ok(src, is_glob) {
206                    return Err(invalid());
207                }
208            }
209            Some("") => {
210                // empty is not allowed.
211                return Err(invalid());
212            }
213            Some(dst) => {
214                if !refname_ok(dst, is_glob) {
215                    return Err(invalid());
216                }
217            }
218        }
219    }
220
221    Ok(item)
222}
223
224/// Parse a fetch refspec, returning the parsed item or an error.
225pub fn parse_fetch_refspec(refspec: &str) -> Result<RefspecItem, RefspecError> {
226    parse_refspec(refspec, true)
227}
228
229/// Parse a push refspec, returning the parsed item or an error.
230pub fn parse_push_refspec(refspec: &str) -> Result<RefspecItem, RefspecError> {
231    parse_refspec(refspec, false)
232}
233
234/// Returns `true` when `refspec` is a valid fetch refspec.
235pub fn valid_fetch_refspec(refspec: &str) -> bool {
236    parse_refspec(refspec, true).is_ok()
237}
238
239/// Returns `true` when `refspec` is a valid push refspec.
240pub fn valid_push_refspec(refspec: &str) -> bool {
241    parse_refspec(refspec, false).is_ok()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    // Mirrors the cases in git's t5511-refspec.sh.
249
250    fn fetch_valid(s: &str) {
251        assert!(valid_fetch_refspec(s), "expected fetch '{s}' to be valid");
252    }
253    fn fetch_invalid(s: &str) {
254        assert!(
255            !valid_fetch_refspec(s),
256            "expected fetch '{s}' to be invalid"
257        );
258    }
259    fn push_valid(s: &str) {
260        assert!(valid_push_refspec(s), "expected push '{s}' to be valid");
261    }
262    fn push_invalid(s: &str) {
263        assert!(!valid_push_refspec(s), "expected push '{s}' to be invalid");
264    }
265
266    #[test]
267    fn empty_and_colon() {
268        push_invalid("");
269        push_valid(":");
270        push_invalid("::");
271        push_valid("+:");
272        fetch_valid("");
273        fetch_valid(":");
274        fetch_invalid("::");
275    }
276
277    #[test]
278    fn glob_balance() {
279        push_valid("refs/heads/*:refs/remotes/frotz/*");
280        push_invalid("refs/heads/*:refs/remotes/frotz");
281        push_invalid("refs/heads:refs/remotes/frotz/*");
282        push_valid("refs/heads/main:refs/remotes/frotz/xyzzy");
283
284        fetch_valid("refs/heads/*:refs/remotes/frotz/*");
285        fetch_invalid("refs/heads/*:refs/remotes/frotz");
286        fetch_invalid("refs/heads:refs/remotes/frotz/*");
287        fetch_valid("refs/heads/main:refs/remotes/frotz/xyzzy");
288        fetch_invalid("refs/heads/main::refs/remotes/frotz/xyzzy");
289        fetch_invalid("refs/heads/maste :refs/remotes/frotz/xyzzy");
290    }
291
292    #[test]
293    fn rev_expressions() {
294        push_valid("main~1:refs/remotes/frotz/backup");
295        fetch_invalid("main~1:refs/remotes/frotz/backup");
296        push_valid("HEAD~4:refs/remotes/frotz/new");
297        fetch_invalid("HEAD~4:refs/remotes/frotz/new");
298    }
299
300    #[test]
301    fn bare_head_and_at() {
302        push_valid("HEAD");
303        fetch_valid("HEAD");
304        push_valid("@");
305        fetch_valid("@");
306        push_invalid("refs/heads/ nitfol");
307        fetch_invalid("refs/heads/ nitfol");
308    }
309
310    #[test]
311    fn head_colon() {
312        push_invalid("HEAD:");
313        fetch_valid("HEAD:");
314        push_invalid("refs/heads/ nitfol:");
315        fetch_invalid("refs/heads/ nitfol:");
316    }
317
318    #[test]
319    fn delete_specs() {
320        push_valid(":refs/remotes/frotz/deleteme");
321        fetch_valid(":refs/remotes/frotz/HEAD-to-me");
322        push_invalid(":refs/remotes/frotz/delete me");
323        fetch_invalid(":refs/remotes/frotz/HEAD to me");
324    }
325
326    #[test]
327    fn star_placements() {
328        fetch_valid("refs/heads/*/for-linus:refs/remotes/mine/*-blah");
329        push_valid("refs/heads/*/for-linus:refs/remotes/mine/*-blah");
330        fetch_valid("refs/heads*/for-linus:refs/remotes/mine/*");
331        push_valid("refs/heads*/for-linus:refs/remotes/mine/*");
332        fetch_invalid("refs/heads/*/*/for-linus:refs/remotes/mine/*");
333        push_invalid("refs/heads/*/*/for-linus:refs/remotes/mine/*");
334        fetch_invalid("refs/heads/*g*/for-linus:refs/remotes/mine/*");
335        push_invalid("refs/heads/*g*/for-linus:refs/remotes/mine/*");
336        fetch_valid("refs/heads/*/for-linus:refs/remotes/mine/*");
337        push_valid("refs/heads/*/for-linus:refs/remotes/mine/*");
338    }
339
340    #[test]
341    fn utf8_and_tab() {
342        fetch_valid("refs/heads/\u{00C4}");
343        fetch_invalid("refs/heads/\ttab");
344    }
345}