use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RefNameError {
#[error("ref name is empty")]
Empty,
#[error("ref name is a lone '@'")]
LoneAt,
#[error("ref name component starts with '.'")]
ComponentStartsDot,
#[error("ref name contains '..'")]
DoubleDot,
#[error("ref name contains an illegal character")]
IllegalChar,
#[error("ref name contains '@{{'")]
AtBrace,
#[error("ref name contains invalid use of '*'")]
InvalidWildcard,
#[error("ref name component ends with '.lock'")]
DotLock,
#[error("ref name ends with '/'")]
TrailingSlash,
#[error("ref name starts with '/'")]
LeadingSlash,
#[error("ref name ends with '.'")]
TrailingDot,
#[error("ref name has only one component (needs --allow-onelevel)")]
OneLevel,
#[error("ref name contains consecutive slashes")]
ConsecutiveSlashes,
}
#[derive(Debug, Clone, Default)]
pub struct RefNameOptions {
pub allow_onelevel: bool,
pub refspec_pattern: bool,
pub normalize: bool,
}
pub fn check_refname_format(refname: &str, opts: &RefNameOptions) -> Result<String, RefNameError> {
if refname.is_empty() {
return Err(RefNameError::Empty);
}
let normalized = if opts.normalize {
collapse_slashes(refname)
} else {
refname.to_owned()
};
let name: &str = &normalized;
if name.is_empty() {
return Err(RefNameError::Empty);
}
if name == "@" {
return Err(RefNameError::LoneAt);
}
if !opts.normalize && name.starts_with('/') {
return Err(RefNameError::LeadingSlash);
}
if name.ends_with('/') {
return Err(RefNameError::TrailingSlash);
}
if name.ends_with('.') {
return Err(RefNameError::TrailingDot);
}
let bytes = name.as_bytes();
let mut component_start = 0usize;
let mut component_count = 0usize;
let mut last = b'\0';
let mut wildcard_used = false;
let mut i = 0usize;
while i < bytes.len() {
let ch = bytes[i];
match ch {
b'/' => {
let comp_len = i - component_start;
if comp_len == 0 {
return Err(RefNameError::ConsecutiveSlashes);
}
validate_component(&bytes[component_start..i], &mut wildcard_used, opts)?;
component_count += 1;
component_start = i + 1;
last = ch;
i += 1;
continue;
}
b'.' if last == b'.' => {
return Err(RefNameError::DoubleDot);
}
b'{' if last == b'@' => {
return Err(RefNameError::AtBrace);
}
b'*' => {
if !opts.refspec_pattern {
return Err(RefNameError::InvalidWildcard);
}
if wildcard_used {
return Err(RefNameError::InvalidWildcard);
}
wildcard_used = true;
}
0x00..=0x1f | 0x7f | b' ' | b'~' | b'^' | b':' | b'?' | b'[' | b'\\' => {
return Err(RefNameError::IllegalChar);
}
_ => {}
}
last = ch;
i += 1;
}
let last_comp = &bytes[component_start..];
if last_comp.is_empty() {
return Err(RefNameError::TrailingSlash);
}
validate_component(last_comp, &mut wildcard_used, opts)?;
component_count += 1;
if !opts.allow_onelevel && component_count < 2 {
return Err(RefNameError::OneLevel);
}
Ok(normalized)
}
fn validate_component(
comp: &[u8],
_wildcard_used: &mut bool,
_opts: &RefNameOptions,
) -> Result<(), RefNameError> {
if comp.is_empty() {
return Err(RefNameError::ConsecutiveSlashes);
}
if comp[0] == b'.' {
return Err(RefNameError::ComponentStartsDot);
}
const LOCK_SUFFIX: &[u8] = b".lock";
if comp.len() >= LOCK_SUFFIX.len() && comp.ends_with(LOCK_SUFFIX) {
return Err(RefNameError::DotLock);
}
Ok(())
}
pub fn collapse_slashes(refname: &str) -> String {
let mut result = String::with_capacity(refname.len());
let mut prev = b'/';
for ch in refname.bytes() {
if prev == b'/' && ch == b'/' {
continue;
}
result.push(ch as char);
prev = ch;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn opts_default() -> RefNameOptions {
RefNameOptions::default()
}
fn opts_onelevel() -> RefNameOptions {
RefNameOptions {
allow_onelevel: true,
..Default::default()
}
}
fn opts_refspec() -> RefNameOptions {
RefNameOptions {
refspec_pattern: true,
..Default::default()
}
}
fn opts_normalize() -> RefNameOptions {
RefNameOptions {
normalize: true,
..Default::default()
}
}
fn valid(refname: &str, opts: &RefNameOptions) {
assert!(
check_refname_format(refname, opts).is_ok(),
"expected '{refname}' to be valid with opts={opts:?}"
);
}
fn invalid(refname: &str, opts: &RefNameOptions) {
assert!(
check_refname_format(refname, opts).is_err(),
"expected '{refname}' to be invalid with opts={opts:?}"
);
}
#[test]
fn empty_is_invalid() {
invalid("", &opts_default());
invalid("", &opts_onelevel());
}
#[test]
fn basic_valid() {
valid("foo/bar/baz", &opts_default());
valid("refs/heads/main", &opts_default());
}
#[test]
fn one_level_requires_flag() {
invalid("foo", &opts_default());
valid("foo", &opts_onelevel());
}
#[test]
fn double_dot_invalid() {
invalid("heads/foo..bar", &opts_default());
}
#[test]
fn trailing_dot_invalid() {
invalid("refs/heads/foo.", &opts_default());
invalid("heads/foo.", &opts_default());
}
#[test]
fn component_starts_with_dot() {
invalid("./foo", &opts_default());
invalid(".refs/foo", &opts_default());
invalid("foo/./bar", &opts_default());
}
#[test]
fn dot_lock_invalid() {
invalid("heads/foo.lock", &opts_default());
invalid("foo.lock/bar", &opts_default());
}
#[test]
fn at_brace_invalid() {
invalid("heads/v@{ation", &opts_default());
}
#[test]
fn lone_at_invalid() {
invalid("@", &opts_default());
invalid("@", &opts_onelevel());
}
#[test]
fn wildcard_requires_flag() {
invalid("foo/*", &opts_default());
valid(
"foo/*",
&RefNameOptions {
refspec_pattern: true,
allow_onelevel: false,
normalize: false,
},
);
}
#[test]
fn double_wildcard_invalid() {
invalid("foo/*/*", &opts_refspec());
}
#[test]
fn control_chars_invalid() {
invalid("heads/foo\x01", &opts_default());
invalid("heads/foo\x7f", &opts_default());
}
#[test]
fn forbidden_chars_invalid() {
invalid("heads/foo?bar", &opts_default());
invalid("heads/foo bar", &opts_default());
invalid("heads/foo~bar", &opts_default());
invalid("heads/foo^bar", &opts_default());
invalid("heads/foo:bar", &opts_default());
invalid("heads/foo[bar", &opts_default());
invalid("heads/foo\\bar", &opts_default());
}
#[test]
fn normalize_collapses_slashes() {
let result = check_refname_format("refs///heads/foo", &opts_normalize());
assert!(result.is_ok());
assert_eq!(result.unwrap(), "refs/heads/foo");
}
#[test]
fn normalize_strips_leading_slash() {
let result = check_refname_format("/heads/foo", &opts_normalize());
assert!(result.is_ok());
assert_eq!(result.unwrap(), "heads/foo");
}
#[test]
fn leading_slash_without_normalize() {
invalid("/heads/foo", &opts_default());
}
#[test]
fn foo_dot_slash_bar_valid() {
valid("foo./bar", &opts_default());
}
#[test]
fn utf8_allowed() {
valid("heads/fu\u{00DF}", &opts_default());
}
}