1use std::borrow::Cow;
26
27use crate::git_path::normalize_path_copy;
28
29use thiserror::Error;
30
31#[derive(Debug, Error, PartialEq, Eq)]
33pub enum RefNameError {
34 #[error("ref name is empty")]
36 Empty,
37 #[error("ref name is a lone '@'")]
39 LoneAt,
40 #[error("ref name component starts with '.'")]
42 ComponentStartsDot,
43 #[error("ref name contains '..'")]
45 DoubleDot,
46 #[error("ref name contains an illegal character")]
48 IllegalChar,
49 #[error("ref name contains '@{{'")]
51 AtBrace,
52 #[error("ref name contains invalid use of '*'")]
55 InvalidWildcard,
56 #[error("ref name component ends with '.lock'")]
58 DotLock,
59 #[error("ref name ends with '/'")]
61 TrailingSlash,
62 #[error("ref name starts with '/'")]
64 LeadingSlash,
65 #[error("ref name ends with '.'")]
67 TrailingDot,
68 #[error("ref name has only one component (needs --allow-onelevel)")]
70 OneLevel,
71 #[error("branch name is reserved")]
73 ReservedBranchName,
74 #[error("branch name must not start with '-'")]
76 BranchStartsWithDash,
77 #[error("ref name contains consecutive slashes")]
80 ConsecutiveSlashes,
81}
82
83#[derive(Debug, Clone, Default)]
85pub struct RefNameOptions {
86 pub allow_onelevel: bool,
88 pub refspec_pattern: bool,
90 pub normalize: bool,
94}
95
96pub fn check_refname_format(refname: &str, opts: &RefNameOptions) -> Result<String, RefNameError> {
105 check_refname_format_borrowed(refname, opts).map(Cow::into_owned)
106}
107
108pub(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 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 if name == "@" {
135 return Err(RefNameError::LoneAt);
136 }
137
138 if !opts.normalize && name.starts_with('/') {
144 return Err(RefNameError::LeadingSlash);
145 }
146
147 if name.ends_with('/') {
150 return Err(RefNameError::TrailingSlash);
151 }
152
153 if name.ends_with('.') {
155 return Err(RefNameError::TrailingDot);
156 }
157
158 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 let comp_len = i - component_start;
173 if comp_len == 0 {
174 return Err(RefNameError::ConsecutiveSlashes);
177 }
178 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 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 let last_comp = &bytes[component_start..];
214 if last_comp.is_empty() {
215 return Err(RefNameError::TrailingSlash);
217 }
218 validate_component(last_comp, &mut wildcard_used, opts)?;
219 component_count += 1;
220
221 if !opts.allow_onelevel && component_count < 2 {
223 return Err(RefNameError::OneLevel);
224 }
225
226 Ok(normalized)
227}
228
229fn 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 if comp[0] == b'.' {
246 return Err(RefNameError::ComponentStartsDot);
247 }
248
249 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#[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#[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
299pub 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 continue;
311 }
312 result.push(ch as char);
313 prev = ch;
314 }
315
316 result
317}
318
319pub 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
339pub 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#[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#[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 valid("foo./bar", &opts_default());
555 }
556
557 #[test]
558 fn utf8_allowed() {
559 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}