use crate::error::{Error, Result};
pub const MAX_SYMREF_DEPTH: usize = 5;
pub const HEAD: &str = "HEAD";
pub const DEFAULT_BRANCH: &str = "refs/heads/main";
pub const MAX_REF_COMPONENT_BYTES: usize = 255 - ".lock".len();
fn reject(name: &[u8], reason: &'static str) -> Error {
Error::InvalidRefName {
name: String::from_utf8_lossy(name).into_owned(),
reason,
}
}
pub fn validate(name: impl AsRef<[u8]>) -> Result<()> {
let name = name.as_ref();
if name.is_empty() {
return Err(reject(name, "empty"));
}
if name != HEAD.as_bytes() && !name.starts_with(b"refs/") {
return Err(reject(name, "must be `HEAD` or start with `refs/`"));
}
if name.ends_with(b"/") {
return Err(reject(name, "trailing slash"));
}
if name.ends_with(b".") {
return Err(reject(name, "trailing dot"));
}
if name.windows(2).any(|w| w == b"..") {
return Err(reject(name, "contains `..`"));
}
if name.windows(2).any(|w| w == b"@{") {
return Err(reject(name, "contains `@{`"));
}
if name.windows(2).any(|w| w == b"//") {
return Err(reject(name, "empty path component"));
}
for &b in name {
match b {
0..=0x20 | 0x7f => return Err(reject(name, "control character or space")),
b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\' => {
return Err(reject(name, "forbidden character"))
}
_ => {}
}
}
for component in name.split(|&b| b == b'/') {
if component.is_empty() {
return Err(reject(name, "empty path component"));
}
if component.starts_with(b".") {
return Err(reject(name, "path component starts with a dot"));
}
if component.ends_with(b".lock") {
return Err(reject(name, "path component ends with `.lock`"));
}
if component.len() > MAX_REF_COMPONENT_BYTES {
return Err(reject(name, "path component longer than 250 bytes"));
}
}
Ok(())
}
pub fn is_valid(name: impl AsRef<[u8]>) -> bool {
validate(name).is_ok()
}
pub fn is_directory_prefix(candidate: &str, name: &str) -> bool {
name.len() > candidate.len()
&& name.starts_with(candidate)
&& name.as_bytes()[candidate.len()] == b'/'
}
pub fn check_no_name_conflict(
writing: &[&str],
deleting: &[&str],
mut exists: impl FnMut(&str) -> Result<bool>,
mut under: impl FnMut(&str) -> Result<Vec<String>>,
) -> Result<()> {
let gone = |name: &str| deleting.contains(&name);
for name in writing {
for other in writing {
if other != name && is_directory_prefix(other, name) {
return Err(Error::RefNameConflict {
name: (*name).to_owned(),
conflicts_with: (*other).to_owned(),
});
}
}
let bytes = name.as_bytes();
for (i, _) in bytes.iter().enumerate().filter(|(_, b)| **b == b'/') {
let prefix = &name[..i];
if !is_valid(prefix) || gone(prefix) {
continue;
}
if exists(prefix)? {
return Err(Error::RefNameConflict {
name: (*name).to_owned(),
conflicts_with: prefix.to_owned(),
});
}
}
for existing in under(&format!("{name}/"))? {
if gone(existing.as_str()) {
continue;
}
return Err(Error::RefNameConflict {
name: (*name).to_owned(),
conflicts_with: existing,
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_names_that_must_never_be_accepted() {
for bad in [
"refs/heads/../../etc/passwd",
"refs/heads/..",
"/refs/heads/main",
"refs/heads/",
"refs/heads//main",
"refs/heads/x y",
"refs/heads/x~1",
"refs/heads/x^",
"refs/heads/x:y",
"refs/heads/x?",
"refs/heads/x*",
"refs/heads/x[",
"refs/heads/x\\y",
"refs/heads/.hidden",
"refs/heads/x.lock",
"refs/heads/x@{1}",
"main",
"",
] {
assert!(
validate(bad).is_err(),
"the reference name {bad:?} was accepted; a path escape starts exactly here"
);
}
}
#[test]
fn the_names_that_must_be_accepted() {
for good in [
"HEAD",
"refs/heads/main",
"refs/heads/team/feature/very/deep-name_1.2",
"refs/tags/v1.0.0",
&format!("refs/heads/{}", "x".repeat(MAX_REF_COMPONENT_BYTES)),
&format!("refs/heads/{}", vec!["a"; 60].join("/")),
] {
validate(good).unwrap_or_else(|err| panic!("{good:?} was refused: {err}"));
}
}
#[test]
fn the_component_ceiling_is_gits_own() {
assert!(validate(format!(
"refs/heads/{}",
"x".repeat(MAX_REF_COMPONENT_BYTES)
))
.is_ok());
for over in [MAX_REF_COMPONENT_BYTES + 1, 70_000] {
assert!(
validate(format!("refs/heads/{}", "x".repeat(over))).is_err(),
"a {over}-byte path component was accepted; `fs` cannot write it \
(ENAMETOOLONG on the `.lock`) and neither can stock git"
);
}
}
#[test]
fn a_directory_prefix_is_a_conflict_and_a_shared_prefix_is_not() {
let held = ["refs/heads/x"];
let exists = |name: &str| Ok(held.contains(&name));
let under = |_: &str| Ok(Vec::new());
assert!(matches!(
check_no_name_conflict(&["refs/heads/x/y"], &[], exists, under),
Err(Error::RefNameConflict { .. })
));
check_no_name_conflict(&["refs/heads/xy"], &[], exists, under)
.expect("a shared prefix that is not a directory prefix is not a conflict");
}
#[test]
fn both_halves_of_a_batch_are_refused_rather_than_ordered() {
assert!(matches!(
check_no_name_conflict(
&["refs/heads/x", "refs/heads/x/y"],
&[],
|_| Ok(false),
|_| Ok(Vec::new()),
),
Err(Error::RefNameConflict { .. })
));
}
#[test]
fn a_deleted_name_does_not_conflict() {
check_no_name_conflict(
&["refs/heads/x/y"],
&["refs/heads/x"],
|name| Ok(name == "refs/heads/x"),
|_| Ok(Vec::new()),
)
.expect("git applies the delete first; measured");
}
}