#![warn(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::indexing_slicing,
reason = "parses untrusted repository or network input (#103)"
)]
use std::collections::BTreeMap;
use std::fs;
use std::io::Read;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use super::store::{check_path, io_error, malformed, parse_loose};
use super::{RefName, ReferenceError, ReferenceObservation, References, Target};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Reference {
pub name: RefName,
pub target: Target,
}
impl References<'_> {
pub(crate) fn head_controlled(
&self,
max_bytes: usize,
cancel: &AtomicBool,
) -> Result<Option<Target>, ReferenceError> {
#[expect(clippy::expect_used, reason = "constant reference name is valid")]
let name = RefName::new(b"HEAD").expect("HEAD is a valid name");
if self.reference_backend == super::Backend::Reftable {
if cancel.load(Ordering::Relaxed) {
return Err(ReferenceError::Cancelled);
}
let target = self.read(&name)?;
if cancel.load(Ordering::Relaxed) {
return Err(ReferenceError::Cancelled);
}
return Ok(target);
}
let mut budget = ScanBudget {
entries: 1,
bytes: max_bytes,
cancel,
};
let path = self.path(&name)?;
read_limited(&path, &mut budget)?
.map(|bytes| parse_loose(self.object_format, &bytes, &path))
.transpose()
}
pub fn list(&self) -> Result<Vec<Reference>, ReferenceError> {
self.enumerate(None)
}
pub fn list_controlled(
&self,
max_entries: usize,
max_bytes: usize,
cancel: &AtomicBool,
) -> Result<Vec<Reference>, ReferenceError> {
let mut budget = ScanBudget {
entries: max_entries,
bytes: max_bytes,
cancel,
};
self.enumerate_with_budget(None, Some(&mut budget))
}
pub fn list_observations_controlled(
&self,
max_entries: usize,
max_bytes: usize,
cancel: &AtomicBool,
) -> Result<Vec<ReferenceObservation>, ReferenceError> {
let mut budget = ScanBudget {
entries: max_entries,
bytes: max_bytes,
cancel,
};
self.enumerate_observations(None, Some(&mut budget))
}
pub fn list_namespace(&self, namespace: &RefName) -> Result<Vec<Reference>, ReferenceError> {
if namespace.as_bytes() == b"HEAD" {
return Err(ReferenceError::Unsupported("enumerating pseudorefs"));
}
self.enumerate(Some(namespace))
}
pub fn list_namespace_observations(
&self,
namespace: &RefName,
) -> Result<Vec<ReferenceObservation>, ReferenceError> {
if namespace.as_bytes() == b"HEAD" {
return Err(ReferenceError::Unsupported("enumerating pseudorefs"));
}
self.enumerate_observations(Some(namespace), None)
}
fn enumerate(&self, namespace: Option<&RefName>) -> Result<Vec<Reference>, ReferenceError> {
self.enumerate_with_budget(namespace, None)
}
fn enumerate_with_budget(
&self,
namespace: Option<&RefName>,
budget: Option<&mut ScanBudget<'_>>,
) -> Result<Vec<Reference>, ReferenceError> {
Ok(self
.enumerate_observations(namespace, budget)?
.into_iter()
.map(|observation| Reference {
name: observation.name,
target: observation.target,
})
.collect())
}
fn enumerate_observations(
&self,
namespace: Option<&RefName>,
mut budget: Option<&mut ScanBudget<'_>>,
) -> Result<Vec<ReferenceObservation>, ReferenceError> {
if let Some(budget) = budget.as_deref_mut() {
budget.check()?;
}
if self.reference_backend == super::Backend::Reftable {
let result = super::reftable::backend::list_observations(self, namespace)?;
if let Some(budget) = budget.as_deref_mut() {
budget.check()?;
if result.len() > budget.entries {
return Err(ReferenceError::Limit("reference entries"));
}
}
return Ok(result);
}
let packed = if let Some(budget) = budget.as_deref_mut() {
let path = self.common_dir.join("packed-refs");
let bytes = read_limited(&path, budget)?.unwrap_or_default();
super::packed::parse(self.object_format, &bytes, &path)?
} else {
self.packed()?
};
if let Some(budget) = budget.as_deref_mut() {
budget.charge(packed.len())?;
}
let mut entries: BTreeMap<_, _> = packed
.into_iter()
.filter(|(name, _)| selected(name.as_bytes(), namespace))
.map(|(name, record)| {
(
name.clone(),
ReferenceObservation {
name,
target: Target::Direct(record.target),
peeled_hint: record.peeled_hint,
},
)
})
.collect();
let separate = self.git_dir != self.common_dir;
collect_loose(
self.object_format,
self.common_dir,
namespace,
if separate {
LooseScope::Shared
} else {
LooseScope::All
},
&mut entries,
budget.as_deref_mut(),
)?;
if separate {
collect_loose(
self.object_format,
self.git_dir,
namespace,
LooseScope::Private,
&mut entries,
budget,
)?;
}
for name in entries.keys() {
for (index, byte) in name.as_bytes().iter().enumerate().skip(5) {
if *byte == b'/'
&& name
.as_bytes()
.get(..index)
.and_then(|parent| RefName::new(parent).ok())
.is_some_and(|parent| entries.contains_key(&parent))
{
return Err(ReferenceError::Conflict(self.path(name)?));
}
}
}
Ok(entries.into_values().collect())
}
}
fn selected(name: &[u8], namespace: Option<&RefName>) -> bool {
namespace.is_none_or(|namespace| within(name, namespace.as_bytes()))
}
fn within(name: &[u8], namespace: &[u8]) -> bool {
name == namespace
|| name
.strip_prefix(namespace)
.is_some_and(|rest| rest.starts_with(b"/"))
}
fn private(name: &[u8]) -> bool {
let namespaces = [
b"refs/bisect".as_slice(),
b"refs/rewritten",
b"refs/worktree",
];
namespaces.iter().any(|prefix| within(name, prefix))
}
#[derive(Clone, Copy)]
enum LooseScope {
All,
Shared,
Private,
}
#[cfg(test)]
thread_local! {
pub(crate) static LOOSE_SCANS: std::cell::RefCell<Vec<std::path::PathBuf>> =
const { std::cell::RefCell::new(Vec::new()) };
}
fn collect_loose(
format: crate::ObjectFormat,
root: &Path,
namespace: Option<&RefName>,
scope: LooseScope,
entries: &mut BTreeMap<RefName, ReferenceObservation>,
mut budget: Option<&mut ScanBudget<'_>>,
) -> Result<(), ReferenceError> {
#[cfg(test)]
LOOSE_SCANS.with(|scans| scans.borrow_mut().push(root.to_owned()));
let mut pending = vec![(root.join("refs"), b"refs".to_vec())];
while let Some((path, bytes)) = pending.pop() {
if let Some(budget) = budget.as_deref_mut() {
budget.check()?;
}
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(source) => return Err(io_error(&path, source)),
};
let is_private = if metadata.is_dir() || metadata.file_type().is_symlink() {
private(&bytes)
} else {
RefName::new(&bytes).is_ok_and(|name| name.per_worktree())
};
let excluded = match scope {
LooseScope::All => false,
LooseScope::Shared => is_private,
LooseScope::Private => !is_private,
};
if bytes != b"refs" && excluded {
continue;
}
if metadata.file_type().is_symlink() {
return Err(ReferenceError::Unsupported(
"filesystem symlink in reference path",
));
}
if metadata.is_dir() {
let children = match fs::read_dir(&path) {
Ok(children) => children,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(source) => return Err(io_error(&path, source)),
};
for child in children {
if let Some(budget) = budget.as_deref_mut() {
budget.check()?;
budget.charge(1)?;
}
let child = child.map_err(|source| io_error(&path, source))?;
let component = child.file_name();
#[cfg(unix)]
let component = {
use std::os::unix::ffi::OsStrExt;
component.as_bytes()
};
#[cfg(not(unix))]
let component = component
.to_str()
.ok_or(ReferenceError::Unsupported("non-UTF-8 filesystem name"))?
.as_bytes();
if component.starts_with(b".") || component.ends_with(b".lock") {
continue;
}
let mut name = bytes.clone();
name.push(b'/');
name.extend_from_slice(component);
if namespace.is_some_and(|ns| {
!within(&name, ns.as_bytes()) && !within(ns.as_bytes(), &name)
}) {
continue;
}
pending.push((child.path(), name));
}
} else if selected(&bytes, namespace) {
let name = RefName::new(bytes)
.map_err(|_| malformed(&path, "invalid loose reference name"))?;
if !metadata.is_file() {
return Err(ReferenceError::Unsupported("non-regular reference file"));
}
let value = if let Some(budget) = budget.as_deref_mut() {
budget.check()?;
read_limited_walked(&path, budget)?
} else {
read_walked(&path)?
};
if let Some(bytes) = value {
let target = parse_loose(format, &bytes, &path)?;
entries.insert(
name.clone(),
ReferenceObservation {
name,
target,
peeled_hint: None,
},
);
}
}
}
Ok(())
}
fn read_walked(path: &Path) -> Result<Option<Vec<u8>>, ReferenceError> {
let Some(file) = open_walked(path)? else {
return Ok(None);
};
let mut bytes = Vec::new();
(&file)
.read_to_end(&mut bytes)
.map_err(|source| io_error(path, source))?;
Ok(Some(bytes))
}
fn open_walked(path: &Path) -> Result<Option<fs::File>, ReferenceError> {
#[cfg(any(target_os = "macos", target_os = "linux"))]
let opened = {
use std::os::unix::fs::OpenOptionsExt;
let flags = rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK;
fs::OpenOptions::new()
.read(true)
.custom_flags(flags.bits() as i32)
.open(path)
};
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let opened = {
check_path(path)?;
fs::File::open(path)
};
let file = match opened {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
#[cfg(any(target_os = "macos", target_os = "linux"))]
Err(error) if error.raw_os_error() == Some(rustix::io::Errno::LOOP.raw_os_error()) => {
return Err(ReferenceError::Unsupported(
"filesystem symlink in reference path",
));
}
Err(source) => return Err(io_error(path, source)),
};
let metadata = file.metadata().map_err(|source| io_error(path, source))?;
if !metadata.is_file() {
return Err(ReferenceError::Unsupported("non-regular reference file"));
}
Ok(Some(file))
}
struct ScanBudget<'a> {
entries: usize,
bytes: usize,
cancel: &'a AtomicBool,
}
impl ScanBudget<'_> {
fn check(&self) -> Result<(), ReferenceError> {
if self.cancel.load(Ordering::Relaxed) {
Err(ReferenceError::Cancelled)
} else {
Ok(())
}
}
fn charge(&mut self, entries: usize) -> Result<(), ReferenceError> {
self.entries = self
.entries
.checked_sub(entries)
.ok_or(ReferenceError::Limit("reference entries"))?;
Ok(())
}
}
fn read_limited(
path: &Path,
budget: &mut ScanBudget<'_>,
) -> Result<Option<Vec<u8>>, ReferenceError> {
budget.check()?;
check_path(path)?;
let file = match fs::File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(io_error(path, source)),
};
read_bounded(file, path, budget)
}
fn read_limited_walked(
path: &Path,
budget: &mut ScanBudget<'_>,
) -> Result<Option<Vec<u8>>, ReferenceError> {
let Some(file) = open_walked(path)? else {
return Ok(None);
};
read_bounded(file, path, budget)
}
fn read_bounded(
file: impl Read,
path: &Path,
budget: &mut ScanBudget<'_>,
) -> Result<Option<Vec<u8>>, ReferenceError> {
let mut bytes = Vec::new();
file.take(budget.bytes.saturating_add(1) as u64)
.read_to_end(&mut bytes)
.map_err(|source| io_error(path, source))?;
if bytes.len() > budget.bytes {
return Err(ReferenceError::Limit("reference bytes"));
}
budget.bytes -= bytes.len();
budget.check()?;
Ok(Some(bytes))
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
use crate::{InitKind, ObjectId, Repository};
fn fixture() -> (tempfile::TempDir, Repository) {
let root = tempfile::tempdir().unwrap();
let repo = Repository::init(
crate::ObjectFormat::Sha1,
root.path().join("repo"),
InitKind::Bare,
)
.unwrap();
fs::create_dir_all(repo.git_dir().join("refs/heads")).unwrap();
fs::write(
repo.git_dir().join("refs/heads/a"),
b"ref: refs/heads/missing\n",
)
.unwrap();
fs::write(repo.git_dir().join("packed-refs"), b"1111111111111111111111111111111111111111 refs/heads/a\n2222222222222222222222222222222222222222 refs/tags/z\n").unwrap();
(root, repo)
}
#[test]
fn lists_shadowing_symbols_and_packed_tags_in_byte_order() {
let (_root, repo) = fixture();
let entries = repo.references().unwrap().list().unwrap();
assert_eq!(
entries,
vec![
Reference {
name: RefName::new(b"refs/heads/a").unwrap(),
target: Target::Symbolic(RefName::new(b"refs/heads/missing").unwrap())
},
Reference {
name: RefName::new(b"refs/tags/z").unwrap(),
target: Target::Direct(ObjectId::Sha1([0x22; 20]))
},
]
);
}
#[test]
fn controlled_inventory_rejects_limits_and_cancellation() {
let (_root, repo) = fixture();
let refs = repo.references().unwrap();
let active = AtomicBool::new(false);
assert_eq!(
refs.list_controlled(10, 1024, &active).unwrap(),
refs.list().unwrap()
);
assert!(matches!(
refs.list_controlled(0, 1024, &active),
Err(ReferenceError::Limit("reference entries"))
));
assert!(matches!(
refs.list_controlled(10, 0, &active),
Err(ReferenceError::Limit("reference bytes"))
));
let cancelled = AtomicBool::new(true);
assert!(matches!(
refs.list_controlled(10, 1024, &cancelled),
Err(ReferenceError::Cancelled)
));
}
#[rstest]
#[case::branch_namespace(b"refs/heads", 1)]
#[case::exact(b"refs/heads/a", 1)]
#[case::not_a_string_prefix(b"refs/head", 0)]
#[case::missing(b"refs/other", 0)]
fn filters_at_namespace_boundaries(#[case] namespace: &[u8], #[case] count: usize) {
let (_root, repo) = fixture();
assert_eq!(
repo.references()
.unwrap()
.list_namespace(&RefName::new(namespace).unwrap())
.unwrap()
.len(),
count
);
}
#[test]
fn skips_locks_dot_entries_and_empty_directories() {
let (_root, repo) = fixture();
fs::write(repo.git_dir().join("refs/heads/a.lock"), b"writer").unwrap();
fs::write(repo.git_dir().join("refs/heads/.hidden"), b"unrelated").unwrap();
fs::create_dir(repo.git_dir().join("refs/heads/empty")).unwrap();
assert_eq!(repo.references().unwrap().list().unwrap().len(), 2);
}
#[test]
fn broken_loose_does_not_fall_back_but_unselected_loose_is_not_read() {
let (_root, repo) = fixture();
fs::write(repo.git_dir().join("refs/heads/a"), b"broken").unwrap();
assert!(matches!(
repo.references().unwrap().list(),
Err(ReferenceError::Malformed { .. })
));
assert_eq!(
repo.references()
.unwrap()
.list_namespace(&RefName::new(b"refs/tags").unwrap())
.unwrap()
.len(),
1
);
assert_eq!(
fs::read(repo.git_dir().join("refs/heads/a")).unwrap(),
b"broken"
);
}
#[test]
fn filtered_list_still_validates_all_packed_data() {
let (_root, repo) = fixture();
fs::write(repo.git_dir().join("packed-refs"), b"broken").unwrap();
assert!(matches!(
repo.references()
.unwrap()
.list_namespace(&RefName::new(b"refs/heads").unwrap()),
Err(ReferenceError::Malformed { .. })
));
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn listing_does_not_recheck_ancestors_per_reference() {
let (_root, repo) = fixture();
let id = "1111111111111111111111111111111111111111";
for n in 0..50 {
let dir = repo.git_dir().join(format!("refs/tags/group{}", n % 5));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("t{n}")), format!("{id}\n")).unwrap();
}
let refs = repo.references().unwrap();
crate::refs::store::PATH_CHECKS.with(|checks| checks.set(0));
let listed = refs.list().unwrap();
assert!(listed.len() >= 50);
assert_eq!(
crate::refs::store::PATH_CHECKS.with(std::cell::Cell::get),
1
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn walked_open_refuses_replaced_leaves() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
fs::write(&target, b"1111111111111111111111111111111111111111\n").unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(matches!(
read_walked(&link),
Err(ReferenceError::Unsupported(_))
));
let fifo = dir.path().join("fifo");
let made = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.unwrap();
assert!(made.success());
assert!(matches!(
read_walked(&fifo),
Err(ReferenceError::Unsupported(_))
));
let mut budget = ScanBudget {
entries: 1,
bytes: 1000,
cancel: &std::sync::atomic::AtomicBool::new(false),
};
assert!(matches!(
read_limited_walked(&link, &mut budget),
Err(ReferenceError::Unsupported(_))
));
assert_eq!(read_walked(&dir.path().join("gone")).unwrap(), None);
}
#[test]
fn bounded_read_collects_short_reads() {
struct Trickle<'a>(&'a [u8]);
impl Read for Trickle<'_> {
fn read(&mut self, output: &mut [u8]) -> std::io::Result<usize> {
let Some((&first, rest)) = self.0.split_first() else {
return Ok(0);
};
output[0] = first;
self.0 = rest;
Ok(1)
}
}
let content = b"ref: refs/heads/main\n";
let mut budget = ScanBudget {
entries: 1,
bytes: 1000,
cancel: &std::sync::atomic::AtomicBool::new(false),
};
let read = read_bounded(Trickle(content), Path::new("ref"), &mut budget).unwrap();
assert_eq!(read.unwrap(), content);
}
#[cfg(unix)]
#[test]
fn rejects_symlink_reference_file_in_listing() {
let (_root, repo) = fixture();
let target = repo.git_dir().join("refs/heads/real");
fs::write(&target, b"1111111111111111111111111111111111111111\n").unwrap();
std::os::unix::fs::symlink(&target, repo.git_dir().join("refs/heads/alias")).unwrap();
assert!(matches!(
repo.references().unwrap().list(),
Err(ReferenceError::Unsupported(_))
));
}
#[cfg(unix)]
#[test]
fn rejects_symlink_directory_without_traversing_it() {
let (_root, repo) = fixture();
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), repo.git_dir().join("refs/link")).unwrap();
assert!(matches!(
repo.references().unwrap().list(),
Err(ReferenceError::Unsupported(_))
));
}
#[cfg(unix)]
#[test]
fn private_namespace_symlink_is_rejected_in_its_own_worktree() {
let (_root, repo) = fixture();
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), repo.git_dir().join("refs/worktree")).unwrap();
let mut entries = BTreeMap::new();
assert!(matches!(
collect_loose(
repo.object_format(),
repo.git_dir(),
None,
LooseScope::Private,
&mut entries,
None,
),
Err(ReferenceError::Unsupported(_))
));
}
#[test]
fn rejects_invalid_loose_name() {
let (_root, repo) = fixture();
fs::write(
repo.git_dir().join("refs/heads/bad name"),
b"1111111111111111111111111111111111111111\n",
)
.unwrap();
assert!(matches!(
repo.references().unwrap().list(),
Err(ReferenceError::Malformed { .. })
));
}
#[test]
fn rejects_nonadjacent_namespace_conflicts() {
let (_root, repo) = fixture();
fs::write(repo.git_dir().join("packed-refs"), b"1111111111111111111111111111111111111111 refs/a\n1111111111111111111111111111111111111111 refs/a.b\n1111111111111111111111111111111111111111 refs/a/c\n").unwrap();
assert!(matches!(
repo.references().unwrap().list(),
Err(ReferenceError::Conflict(_))
));
}
#[test]
fn preserves_packed_non_utf8_names() {
let (_root, repo) = fixture();
fs::write(
repo.git_dir().join("packed-refs"),
b"1111111111111111111111111111111111111111 refs/tags/\xff\n",
)
.unwrap();
let tags = repo
.references()
.unwrap()
.list_namespace(&RefName::new(b"refs/tags").unwrap())
.unwrap();
assert_eq!(tags[0].name.as_bytes(), b"refs/tags/\xff");
}
#[test]
fn head_requires_single_name_read() {
let (_root, repo) = fixture();
assert!(matches!(
repo.references()
.unwrap()
.list_namespace(&RefName::new(b"HEAD").unwrap()),
Err(ReferenceError::Unsupported(_))
));
}
}