use std::fmt;
pub fn validate_name(name: &[u8]) -> Result<(), InvalidRemoteName> {
validate(name).map_err(|source| InvalidRemoteName {
name: name.to_vec(),
source,
})
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[error("remote names must be valid within refspecs for fetching: {}", Quoted(.name))]
#[non_exhaustive]
pub struct InvalidRemoteName {
pub name: Vec<u8>,
#[source]
pub source: RemoteNameError,
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum RemoteNameError {
#[error("Reference name cannot end with a slash")]
EndsWithSlash,
#[error("Reference name cannot contain repeated slashes")]
RepeatedSlash,
#[error("Reference name cannot start with a dot")]
StartsWithDot,
#[error("Reference name cannot end with '.lock'")]
LockFileSuffix,
#[error("Reference name cannot contain repeated dots")]
RepeatedDot,
#[error("Reference name cannot contain '@{{'")]
ReflogPortion,
#[error("Reference name contains invalid byte: {}", Quoted(&[*byte]))]
InvalidByte {
byte: u8,
},
#[error("Both sides of a two-sided specification need a pattern, like 'a/*:b/*'")]
PatternUnbalanced,
#[error("refspec patterns may only contain a single '*' character, found {}", Quoted(.pattern))]
PatternUnsupported {
pattern: Vec<u8>,
},
}
fn validate(name: &[u8]) -> Result<(), RemoteNameError> {
if let Some(colon) = name.iter().rposition(|&byte| byte == b':') {
let prefix = &name[..colon];
if prefix.iter().filter(|&&byte| byte == b'*').count() > 1 {
return Err(RemoteNameError::PatternUnsupported {
pattern: [b"refs/heads/test:refs/remotes/", prefix].concat(),
});
}
if prefix.is_empty() || prefix.ends_with(b"/") {
return Err(RemoteNameError::EndsWithSlash);
}
return Err(RemoteNameError::InvalidByte { byte: b':' });
}
let stars = name.iter().filter(|&&byte| byte == b'*').count();
if stars > 1 {
return Err(RemoteNameError::PatternUnsupported {
pattern: [b"refs/remotes/", name, b"/test"].concat(),
});
}
for component in name.split(|&byte| byte == b'/') {
if component.is_empty() {
return Err(RemoteNameError::RepeatedSlash);
}
if component.starts_with(b".") {
return Err(RemoteNameError::StartsWithDot);
}
for (index, &byte) in component.iter().enumerate() {
if byte <= b' ' || byte == 127 || b"~^:?[\\".contains(&byte) {
return Err(RemoteNameError::InvalidByte { byte });
}
if component[index..].starts_with(b"..") {
return Err(RemoteNameError::RepeatedDot);
}
if component[index..].starts_with(b"@{") {
return Err(RemoteNameError::ReflogPortion);
}
}
if component.ends_with(b".lock") {
return Err(RemoteNameError::LockFileSuffix);
}
}
if stars == 1 {
return Err(RemoteNameError::PatternUnbalanced);
}
Ok(())
}
struct Quoted<'a>(&'a [u8]);
impl fmt::Display for Quoted<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("\"")?;
for chunk in self.0.utf8_chunks() {
for character in chunk.valid().chars() {
match character {
'\0' => f.write_str("\\0")?,
'\t' => f.write_str("\\t")?,
'\r' => f.write_str("\\r")?,
'\n' => f.write_str("\\n")?,
'\x01'..='\x1f' | '\x7f' => write!(f, "\\x{:02x}", u32::from(character))?,
character => write!(f, "{}", character.escape_debug())?,
}
}
for byte in chunk.invalid() {
write!(f, "\\x{byte:02x}")?;
}
}
f.write_str("\"")
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::ordinary(b"origin")]
#[case::application_reserved(b"git")]
#[case::slash(b"team/fork")]
#[case::trailing_dot(b"fork.")]
#[case::dot_before_slash(b"a./b")]
#[case::at(b"@")]
#[case::leading_dash(b"-fork")]
#[case::double_quote(b"a\"b")]
#[case::apostrophe(b"a'b")]
#[case::closing_bracket(b"a]b")]
#[case::non_utf8(b"a\xffb")]
#[case::unicode("é".as_bytes())]
#[case::unicode_control("a\u{85}b".as_bytes())]
fn accepts_names(#[case] name: &[u8]) {
assert_eq!(validate_name(name), Ok(()));
}
#[rstest]
#[case::colon(b":", RemoteNameError::EndsWithSlash)]
#[case::colon_after_slash(b"a/:x", RemoteNameError::EndsWithSlash)]
#[case::colon_before_stars(b"a:x**", RemoteNameError::InvalidByte { byte: b':' })]
#[case::colon_before_space(b"a :x", RemoteNameError::InvalidByte { byte: b':' })]
#[case::last_colon(b"a/:x:", RemoteNameError::InvalidByte { byte: b':' })]
#[case::empty(b"", RemoteNameError::RepeatedSlash)]
#[case::leading_slash(b"/a", RemoteNameError::RepeatedSlash)]
#[case::trailing_slash(b"a/", RemoteNameError::RepeatedSlash)]
#[case::repeated_slash(b"a//b", RemoteNameError::RepeatedSlash)]
#[case::leading_dot(b".a", RemoteNameError::StartsWithDot)]
#[case::nested_leading_dot(b"a/.b", RemoteNameError::StartsWithDot)]
#[case::nested_lock_suffix(b"a.lock/b", RemoteNameError::LockFileSuffix)]
#[case::lock_suffix(b"a.lock", RemoteNameError::LockFileSuffix)]
#[case::repeated_dot(b"a..b", RemoteNameError::RepeatedDot)]
#[case::reflog(b"a@{b", RemoteNameError::ReflogPortion)]
#[case::wildcard(b"a*b", RemoteNameError::PatternUnbalanced)]
#[case::space_before_wildcard_error(b"a*b c", RemoteNameError::InvalidByte { byte: b' ' })]
#[case::lock_before_wildcard_error(b"a*b.lock", RemoteNameError::LockFileSuffix)]
#[case::lock_before_space(b"a.lock/ ", RemoteNameError::LockFileSuffix)]
#[case::dot_before_space(b". a", RemoteNameError::StartsWithDot)]
#[case::first_invalid_byte(b"a \n", RemoteNameError::InvalidByte { byte: b' ' })]
#[case::dots_before_space(b"a.. b", RemoteNameError::RepeatedDot)]
#[case::reflog_before_dots(b"a@{..", RemoteNameError::ReflogPortion)]
fn rejects_names_in_diagnostic_order(#[case] name: &[u8], #[case] source: RemoteNameError) {
assert_eq!(
validate_name(name),
Err(InvalidRemoteName {
name: name.to_vec(),
source
})
);
}
#[test]
fn colon_changes_the_first_checked_pattern() {
assert_eq!(
validate_name(b"a**:x").unwrap_err().source,
RemoteNameError::PatternUnsupported {
pattern: b"refs/heads/test:refs/remotes/a**".to_vec(),
}
);
}
#[test]
fn multiple_wildcards_precede_reference_errors() {
assert_eq!(
validate_name(b". ** ").unwrap_err().source,
RemoteNameError::PatternUnsupported {
pattern: b"refs/remotes/. ** /test".to_vec()
}
);
}
#[rstest]
#[case::nul(0)]
#[case::control_start(1)]
#[case::control_end(31)]
#[case::space(b' ')]
#[case::delete(127)]
#[case::tilde(b'~')]
#[case::caret(b'^')]
#[case::colon(b':')]
#[case::question(b'?')]
#[case::bracket(b'[')]
#[case::backslash(b'\\')]
fn rejects_forbidden_bytes(#[case] byte: u8) {
assert_eq!(
validate_name(&[b'a', byte, b'b']).unwrap_err().source,
RemoteNameError::InvalidByte { byte }
);
}
#[test]
fn wildcard_diagnostic_preserves_non_utf8() {
let error = validate_name(b"a**\xff").unwrap_err();
assert_eq!(
error.source.to_string(),
"refspec patterns may only contain a single '*' character, found \"refs/remotes/a**\\xff/test\""
);
}
#[test]
fn diagnostic_chain_preserves_bytes() {
use std::error::Error as _;
let error = validate_name(b"a\xff\0").unwrap_err();
assert_eq!(
error.to_string(),
"remote names must be valid within refspecs for fetching: \"a\\xff\\0\""
);
assert_eq!(
error.source().unwrap().to_string(),
"Reference name contains invalid byte: \"\\0\""
);
}
}