grit_lib/
check_ref_format.rs1use thiserror::Error;
26
27#[derive(Debug, Error, PartialEq, Eq)]
29pub enum RefNameError {
30 #[error("ref name is empty")]
32 Empty,
33 #[error("ref name is a lone '@'")]
35 LoneAt,
36 #[error("ref name component starts with '.'")]
38 ComponentStartsDot,
39 #[error("ref name contains '..'")]
41 DoubleDot,
42 #[error("ref name contains an illegal character")]
44 IllegalChar,
45 #[error("ref name contains '@{{'")]
47 AtBrace,
48 #[error("ref name contains invalid use of '*'")]
51 InvalidWildcard,
52 #[error("ref name component ends with '.lock'")]
54 DotLock,
55 #[error("ref name ends with '/'")]
57 TrailingSlash,
58 #[error("ref name starts with '/'")]
60 LeadingSlash,
61 #[error("ref name ends with '.'")]
63 TrailingDot,
64 #[error("ref name has only one component (needs --allow-onelevel)")]
66 OneLevel,
67 #[error("ref name contains consecutive slashes")]
70 ConsecutiveSlashes,
71}
72
73#[derive(Debug, Clone, Default)]
75pub struct RefNameOptions {
76 pub allow_onelevel: bool,
78 pub refspec_pattern: bool,
80 pub normalize: bool,
84}
85
86pub fn check_refname_format(refname: &str, opts: &RefNameOptions) -> Result<String, RefNameError> {
95 if refname.is_empty() {
96 return Err(RefNameError::Empty);
97 }
98
99 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 if name == "@" {
114 return Err(RefNameError::LoneAt);
115 }
116
117 if !opts.normalize && name.starts_with('/') {
123 return Err(RefNameError::LeadingSlash);
124 }
125
126 if name.ends_with('/') {
129 return Err(RefNameError::TrailingSlash);
130 }
131
132 if name.ends_with('.') {
134 return Err(RefNameError::TrailingDot);
135 }
136
137 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 let comp_len = i - component_start;
152 if comp_len == 0 {
153 return Err(RefNameError::ConsecutiveSlashes);
156 }
157 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 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 let last_comp = &bytes[component_start..];
193 if last_comp.is_empty() {
194 return Err(RefNameError::TrailingSlash);
196 }
197 validate_component(last_comp, &mut wildcard_used, opts)?;
198 component_count += 1;
199
200 if !opts.allow_onelevel && component_count < 2 {
202 return Err(RefNameError::OneLevel);
203 }
204
205 Ok(normalized)
206}
207
208fn 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 if comp[0] == b'.' {
225 return Err(RefNameError::ComponentStartsDot);
226 }
227
228 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
237pub 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 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 valid("foo./bar", &opts_default());
412 }
413
414 #[test]
415 fn utf8_allowed() {
416 valid("heads/fu\u{00DF}", &opts_default());
418 }
419}