Skip to main content

gix_validate/
tag.rs

1use bstr::{BStr, BString, ByteSlice};
2
3///
4pub mod name {
5    use bstr::BString;
6
7    /// The error returned by [`name()`][super::name()].
8    #[derive(Debug)]
9    #[expect(missing_docs)]
10    #[non_exhaustive]
11    pub enum Error {
12        InvalidByte { byte: BString },
13        StartsWithSlash,
14        RepeatedSlash,
15        RepeatedDot,
16        LockFileSuffix,
17        ReflogPortion,
18        Asterisk,
19        StartsWithDot,
20        EndsWithDot,
21        EndsWithSlash,
22        Empty,
23    }
24
25    impl std::fmt::Display for Error {
26        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27            match self {
28                Error::InvalidByte { byte } => write!(
29                    f,
30                    "A ref must not contain invalid bytes or ascii control characters: {byte:?}"
31                ),
32                Error::StartsWithSlash => write!(f, "A reference name must not start with a slash '/'"),
33                Error::RepeatedSlash => write!(
34                    f,
35                    "Multiple slashes in a row are not allowed as they may change the reference's meaning"
36                ),
37                Error::RepeatedDot => write!(f, "A ref must not contain '..' as it may be mistaken for a range"),
38                Error::LockFileSuffix => write!(f, "A ref must not end with '.lock'"),
39                Error::ReflogPortion => write!(f, "A ref must not contain '@{{' which is a part of a ref-log"),
40                Error::Asterisk => write!(f, "A ref must not contain '*' character"),
41                Error::StartsWithDot => write!(f, "A ref must not start with a '.'"),
42                Error::EndsWithDot => write!(f, "A ref must not end with a '.'"),
43                Error::EndsWithSlash => write!(f, "A ref must not end with a '/'"),
44                Error::Empty => write!(f, "A ref must not be empty"),
45            }
46        }
47    }
48
49    impl std::error::Error for Error {
50        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51            Some(const { &gix_error::ClassificationMarker::VALIDATION })
52        }
53    }
54}
55
56/// Assure the given `input` resemble a valid git tag name, which is returned unchanged on success.
57/// Tag names are provided as names, like `v1.0` or `alpha-1`, without paths.
58pub fn name(input: &BStr) -> Result<&BStr, name::Error> {
59    match name_inner(input, Mode::Validate)? {
60        None => Ok(input),
61        Some(_) => {
62            unreachable!("When validating, the input isn't changed")
63        }
64    }
65}
66
67#[derive(Eq, PartialEq)]
68pub(crate) enum Mode {
69    Sanitize,
70    Validate,
71}
72
73/// Validate or sanitize `input` according to `mode`.
74///
75/// Validation returns `Ok(None)` when `input` is valid, while sanitization returns the rebuilt name
76/// in `Ok(Some(_))` even if it is identical to `input`. An error means validation failed.
77pub(crate) fn name_inner(input: &BStr, mode: Mode) -> Result<Option<BString>, name::Error> {
78    let mut out: Option<BString> =
79        matches!(mode, Mode::Sanitize).then(|| BString::from(Vec::with_capacity(input.len())));
80    if input.is_empty() {
81        return if let Some(out) = out {
82            Ok(Some(out))
83        } else {
84            Err(name::Error::Empty)
85        };
86    }
87    if *input.last().expect("non-empty") == b'/' && out.is_none() {
88        return Err(name::Error::EndsWithSlash);
89    }
90    if input.first() == Some(&b'/') && out.is_none() {
91        return Err(name::Error::StartsWithSlash);
92    }
93
94    let mut previous = 0;
95    let mut component_start;
96    let mut component_end = 0;
97    let last = input.len() - 1;
98    for (byte_pos, byte) in input.iter().enumerate() {
99        match byte {
100            b'\\' | b'^' | b':' | b'[' | b'?' | b' ' | b'~' | b'\0'..=b'\x1F' | b'\x7F' => {
101                if let Some(out) = out.as_mut() {
102                    out.push(b'-');
103                } else {
104                    return Err(name::Error::InvalidByte {
105                        byte: (&[*byte][..]).into(),
106                    });
107                }
108            }
109            b'*' => {
110                if let Some(out) = out.as_mut() {
111                    out.push(b'-');
112                } else {
113                    return Err(name::Error::Asterisk);
114                }
115            }
116
117            b'.' if previous == b'.' => {
118                if out.is_none() {
119                    return Err(name::Error::RepeatedDot);
120                }
121            }
122            b'.' if previous == b'/' => {
123                if let Some(out) = out.as_mut() {
124                    out.push(b'-');
125                } else {
126                    return Err(name::Error::StartsWithDot);
127                }
128            }
129            b'{' if previous == b'@' => {
130                if let Some(out) = out.as_mut() {
131                    out.push(b'-');
132                } else {
133                    return Err(name::Error::ReflogPortion);
134                }
135            }
136            b'/' if previous == b'/' => {
137                if out.is_none() {
138                    return Err(name::Error::RepeatedSlash);
139                }
140            }
141            c => {
142                if *c == b'/' {
143                    component_start = component_end;
144                    component_end = byte_pos;
145
146                    if input[component_start..component_end].ends_with_str(".lock") {
147                        if let Some(out) = out.as_mut() {
148                            while out.ends_with(b".lock") {
149                                let len_without_suffix = out.len() - b".lock".len();
150                                out.truncate(len_without_suffix);
151                            }
152                        } else {
153                            return Err(name::Error::LockFileSuffix);
154                        }
155                    }
156                }
157
158                if let Some(out) = out.as_mut() {
159                    // Like Git, replace the leading dot before stripping lock suffixes, preserving the component.
160                    out.push(if byte_pos == 0 && *c == b'.' { b'-' } else { *c });
161                }
162
163                if byte_pos == last && input[component_end + 1..].ends_with_str(".lock") {
164                    if let Some(out) = out.as_mut() {
165                        while out.ends_with(b".lock") {
166                            let len_without_suffix = out.len() - b".lock".len();
167                            out.truncate(len_without_suffix);
168                        }
169                    } else {
170                        return Err(name::Error::LockFileSuffix);
171                    }
172                }
173            }
174        }
175        previous = *byte;
176    }
177
178    if let Some(out) = out.as_mut() {
179        while out.last() == Some(&b'/') {
180            out.pop();
181        }
182        while out.first() == Some(&b'/') {
183            out.remove(0);
184        }
185    }
186    if out.as_ref().map_or(input, |b| b.as_bstr()).first() == Some(&b'.') {
187        if let Some(out) = out.as_mut() {
188            out[0] = b'-';
189        } else {
190            return Err(name::Error::StartsWithDot);
191        }
192    }
193    if out.as_ref().map_or(input, |b| b.as_bstr()).last() == Some(&b'.') {
194        if let Some(out) = out.as_mut() {
195            let last = out.len() - 1;
196            out[last] = b'-';
197        } else {
198            return Err(name::Error::EndsWithDot);
199        }
200    }
201    Ok(out)
202}