#![warn(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::indexing_slicing,
reason = "parses untrusted repository or network input (#103)"
)]
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use super::{RefName, packed};
use crate::{ObjectFormat, ObjectId, Repository};
#[derive(Clone, Copy, Debug)]
pub struct References<'a> {
pub(super) reftable_limits: super::reftable::StackLimits,
pub(crate) git_dir: &'a Path,
pub(crate) common_dir: &'a Path,
pub(crate) object_format: ObjectFormat,
pub(crate) reference_backend: Backend,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct ReferenceObservation {
pub name: RefName,
pub target: Target,
pub peeled_hint: Option<ObjectId>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Backend {
Files,
Reftable,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Target {
Direct(ObjectId),
Symbolic(RefName),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Expected {
Any,
Absent,
Value(Target),
Exists,
AbsentOr(Target),
}
struct DisplayTarget<'a>(Option<&'a Target>);
impl std::fmt::Display for DisplayTarget<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
None => f.write_str("nothing"),
Some(Target::Direct(id)) => write!(f, "{id}"),
Some(Target::Symbolic(name)) => {
write!(f, "ref: {}", String::from_utf8_lossy(name.as_bytes()))
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct Resolution {
pub name: RefName,
pub id: Option<ObjectId>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ReferenceError {
#[error(transparent)]
Reftable(#[from] super::reftable::Error),
#[error("reference operation cancelled")]
Cancelled,
#[error(transparent)]
ObjectFormat(#[from] crate::ObjectFormatError),
#[error("reference I/O at {path}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("packed reference removed, but loose deletion failed at {path}")]
PackedDeleted {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("malformed reference at {path}: {reason}")]
Malformed {
path: PathBuf,
reason: &'static str,
},
#[error("unsupported reference feature: {0}")]
Unsupported(&'static str),
#[error("reference resource limit: {0}")]
Limit(&'static str),
#[error("reference lock already exists: {0}")]
Locked(PathBuf),
#[error("reference value did not match the expected value (found {})", DisplayTarget(actual.as_ref()))]
Mismatch {
actual: Option<Target>,
},
#[error("symbolic reference cycle at {0:?}")]
Cycle(RefName),
#[error("symbolic reference depth exceeds {0}")]
Depth(usize),
#[error("reference namespace conflict at {0}")]
Conflict(PathBuf),
#[error("symbolic HEAD must point into refs/")]
InvalidHeadTarget,
#[error("zero reference IDs are not supported")]
ZeroId,
}
impl<'a> References<'a> {
pub(crate) fn new(repository: &'a Repository) -> Result<Self, ReferenceError> {
Self::from_layout(
repository.git_dir(),
repository.common_dir(),
repository.object_format(),
repository.reference_backend(),
)
}
pub(crate) fn from_layout(
git_dir: &'a Path,
common_dir: &'a Path,
object_format: ObjectFormat,
reference_backend: Backend,
) -> Result<Self, ReferenceError> {
if !cfg!(any(unix, windows)) {
return Err(ReferenceError::Unsupported(
"reference storage on this platform",
));
}
Ok(Self {
git_dir,
common_dir,
object_format,
reference_backend,
reftable_limits: super::reftable::StackLimits::default(),
})
}
pub fn read(&self, name: &RefName) -> Result<Option<Target>, ReferenceError> {
Ok(self
.read_observation(name)?
.map(|observation| observation.target))
}
pub fn read_observation(
&self,
name: &RefName,
) -> Result<Option<ReferenceObservation>, ReferenceError> {
if self.reference_backend == Backend::Reftable {
return super::reftable::backend::read_observation(self, name);
}
let path = self.path(name)?;
if let Some(bytes) = read_optional(&path)? {
let target = parse_loose(self.object_format, &bytes, &path)?;
return Ok(Some(ReferenceObservation {
name: name.clone(),
target,
peeled_hint: None,
}));
}
if name.per_worktree() {
return Ok(None);
}
Ok(self.packed()?.get(name).map(|record| ReferenceObservation {
name: name.clone(),
target: Target::Direct(record.target),
peeled_hint: record.peeled_hint,
}))
}
pub fn resolve(&self, name: &RefName, max_depth: usize) -> Result<Resolution, ReferenceError> {
let mut current = name.clone();
let mut seen = Vec::new();
loop {
if seen.contains(¤t) {
return Err(ReferenceError::Cycle(current));
}
seen.push(current.clone());
match self.read(¤t)? {
Some(Target::Symbolic(next)) => {
if seen.len() > max_depth {
return Err(ReferenceError::Depth(max_depth));
}
current = next;
}
target => {
return Ok(Resolution {
name: current,
id: direct_id(target),
});
}
}
}
}
pub fn update_without_reflog(
&self,
name: &RefName,
target: Target,
expected: Expected,
) -> Result<(), ReferenceError> {
let _lock_operation = crate::locks::operation();
if self.reference_backend == Backend::Reftable {
return super::reftable::backend::single(
self,
name,
Some(target),
expected,
super::Dereference::Stored,
)
.map(|_| ());
}
validate_expected(self.object_format, &expected)?;
if name.as_bytes() == b"HEAD"
&& matches!(&target, Target::Symbolic(next) if next.as_bytes() == b"HEAD")
{
return Err(ReferenceError::InvalidHeadTarget);
}
validate_target(self.object_format, &target)?;
let _packed_lock = Lock::acquire(self.common_dir.join("packed-refs"))?;
let packed = self.packed()?;
self.check_packed_namespace(name, &packed)?;
let mut lock = Lock::acquire(self.path(name)?)?;
check_expected(self.read_locked(name, &packed)?, expected)?;
lock.publish(&target)
}
pub fn update_resolved_without_reflog(
&self,
name: &RefName,
id: ObjectId,
expected: Expected,
) -> Result<RefName, ReferenceError> {
let _lock_operation = crate::locks::operation();
if self.reference_backend == Backend::Reftable {
return super::reftable::backend::single(
self,
name,
Some(Target::Direct(id)),
expected,
super::Dereference::Follow,
)
.map(|outcome| outcome.name);
}
validate_expected(self.object_format, &expected)?;
let target = Target::Direct(id);
validate_target(self.object_format, &target)?;
let _packed_lock = Lock::acquire(self.common_dir.join("packed-refs"))?;
let packed = self.packed()?;
let (current, actual, mut locks) = self.lock_resolution(name, &packed)?;
check_expected(actual, expected)?;
#[expect(
clippy::unwrap_used,
reason = "lock_resolution locks at least the named reference"
)]
locks.last_mut().unwrap().publish(&target)?;
Ok(current)
}
pub fn delete_without_reflog(
&self,
name: &RefName,
expected: Expected,
) -> Result<(), ReferenceError> {
let _lock_operation = crate::locks::operation();
if self.reference_backend == Backend::Reftable {
return super::reftable::backend::single(
self,
name,
None,
expected,
super::Dereference::Stored,
)
.map(|_| ());
}
validate_expected(self.object_format, &expected)?;
let packed_lock = Lock::acquire(self.common_dir.join("packed-refs"))?;
let bytes = read_optional(&packed_lock.destination)?.unwrap_or_default();
let packed = packed::parse(self.object_format, &bytes, &packed_lock.destination)?;
self.check_packed_namespace(name, &packed)?;
let lock = Lock::acquire(self.path(name)?)?;
check_expected(self.read_locked(name, &packed)?, expected)?;
delete_locked(
self.object_format,
&packed_lock,
&lock,
name,
&bytes,
&packed,
)
}
pub fn delete_resolved_without_reflog(
&self,
name: &RefName,
expected: Expected,
) -> Result<RefName, ReferenceError> {
let _lock_operation = crate::locks::operation();
if self.reference_backend == Backend::Reftable {
return super::reftable::backend::single(
self,
name,
None,
expected,
super::Dereference::Follow,
)
.map(|outcome| outcome.name);
}
validate_expected(self.object_format, &expected)?;
let packed_lock = Lock::acquire(self.common_dir.join("packed-refs"))?;
let bytes = read_optional(&packed_lock.destination)?.unwrap_or_default();
let packed = packed::parse(self.object_format, &bytes, &packed_lock.destination)?;
let (current, actual, locks) = self.lock_resolution(name, &packed)?;
check_expected(actual, expected)?;
#[expect(
clippy::unwrap_used,
reason = "lock_resolution locks at least the named reference"
)]
delete_locked(
self.object_format,
&packed_lock,
locks.last().unwrap(),
¤t,
&bytes,
&packed,
)?;
Ok(current)
}
fn lock_resolution(
&self,
name: &RefName,
packed: &packed::Packed,
) -> Result<(RefName, Option<Target>, Vec<Lock>), ReferenceError> {
let mut current = name.clone();
let mut seen = Vec::new();
let mut locks = Vec::new();
loop {
if seen.contains(¤t) {
return Err(ReferenceError::Cycle(current));
}
seen.push(current.clone());
self.check_packed_namespace(¤t, packed)?;
locks.push(Lock::acquire(self.path(¤t)?)?);
match self.read_locked(¤t, packed)? {
Some(Target::Symbolic(next)) => {
if seen.len() > 32 {
return Err(ReferenceError::Depth(32));
}
current = next;
}
actual => return Ok((current, actual, locks)),
}
}
}
pub(super) fn read_locked(
&self,
name: &RefName,
packed: &packed::Packed,
) -> Result<Option<Target>, ReferenceError> {
let path = self.path(name)?;
if let Some(bytes) = read_optional(&path)? {
return parse_loose(self.object_format, &bytes, &path).map(Some);
}
Ok(packed
.get(name)
.filter(|_| !name.per_worktree())
.map(|record| Target::Direct(record.target)))
}
pub(super) fn packed(&self) -> Result<packed::Packed, ReferenceError> {
let path = self.common_dir.join("packed-refs");
let bytes = read_optional(&path)?.unwrap_or_default();
packed::parse(self.object_format, &bytes, &path)
}
pub(super) fn path(&self, name: &RefName) -> Result<PathBuf, ReferenceError> {
let base = if name.per_worktree() {
self.git_dir
} else {
self.common_dir
};
#[cfg(unix)]
let relative = {
use std::os::unix::ffi::OsStrExt;
std::ffi::OsStr::from_bytes(name.as_bytes())
};
#[cfg(not(unix))]
let relative = std::ffi::OsStr::new(
std::str::from_utf8(name.as_bytes())
.map_err(|_| ReferenceError::Unsupported("non-UTF-8 filesystem name"))?,
);
#[cfg(windows)]
for part in name.as_bytes().split(|b| *b == b'/') {
let stem = part
.split(|b| *b == b'.')
.next()
.unwrap_or_default()
.to_ascii_uppercase();
if part.ends_with(b".")
|| part.iter().any(|b| b"<>\"|".contains(b))
|| matches!(
stem.as_slice(),
b"CON"
| b"PRN"
| b"AUX"
| b"NUL"
| [b'C', b'O', b'M', b'1'..=b'9']
| [b'L', b'P', b'T', b'1'..=b'9']
)
{
return Err(ReferenceError::Unsupported("Windows reference filename"));
}
}
Ok(base.join(relative))
}
pub(super) fn check_packed_namespace(
&self,
name: &RefName,
packed: &packed::Packed,
) -> Result<(), ReferenceError> {
if !name.per_worktree()
&& packed
.keys()
.any(|other| conflicts(name.as_bytes(), other.as_bytes()))
{
return Err(ReferenceError::Conflict(self.path(name)?));
}
Ok(())
}
}
fn delete_locked(
format: crate::ObjectFormat,
packed_lock: &Lock,
loose_lock: &Lock,
name: &RefName,
bytes: &[u8],
packed: &packed::Packed,
) -> Result<(), ReferenceError> {
let packed_changed = packed.contains_key(name);
if packed_changed {
let replacement = packed::without_ref(format, bytes, name);
packed_lock.publish_retaining_lock(&replacement)?;
}
remove_loose(&loose_lock.destination, packed_changed)
}
pub(super) fn remove_loose(path: &Path, packed_changed: bool) -> Result<(), ReferenceError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(source) if packed_changed => Err(ReferenceError::PackedDeleted {
path: path.into(),
source,
}),
Err(source) => Err(io_error(path, source)),
}
}
pub(super) fn conflicts(a: &[u8], b: &[u8]) -> bool {
a.strip_prefix(b).is_some_and(|rest| rest.starts_with(b"/"))
|| b.strip_prefix(a).is_some_and(|rest| rest.starts_with(b"/"))
}
fn direct_id(target: Option<Target>) -> Option<ObjectId> {
match target {
Some(Target::Direct(id)) => Some(id),
_ => None,
}
}
pub(super) fn validate_target(
format: crate::ObjectFormat,
target: &Target,
) -> Result<(), ReferenceError> {
if let Target::Direct(id) = target {
id.require_format(format)?;
}
if matches!(target, Target::Direct(id) if id.is_null()) {
return Err(ReferenceError::ZeroId);
}
Ok(())
}
pub(super) fn validate_expected(
format: crate::ObjectFormat,
expected: &Expected,
) -> Result<(), ReferenceError> {
if let Expected::Value(Target::Direct(id)) | Expected::AbsentOr(Target::Direct(id)) = expected {
id.require_format(format)?;
}
Ok(())
}
pub(super) fn check_expected(
actual: Option<Target>,
expected: Expected,
) -> Result<(), ReferenceError> {
let matches = match expected {
Expected::Any => true,
Expected::Absent => actual.is_none(),
Expected::Value(value) => actual.as_ref() == Some(&value),
Expected::Exists => actual.is_some(),
Expected::AbsentOr(value) => actual.is_none() || actual.as_ref() == Some(&value),
};
if !matches {
return Err(ReferenceError::Mismatch { actual });
}
Ok(())
}
pub(super) fn parse_loose(
format: crate::ObjectFormat,
bytes: &[u8],
path: &Path,
) -> Result<Target, ReferenceError> {
let bytes = bytes.trim_ascii_end();
if let Some(name) = bytes.strip_prefix(b"ref: ") {
let name = RefName::new(name).map_err(|_| malformed(path, "invalid symbolic target"))?;
return Ok(Target::Symbolic(name));
}
packed::parse_id(format, bytes, path).map(Target::Direct)
}
pub(super) fn read_optional(path: &Path) -> Result<Option<Vec<u8>>, ReferenceError> {
check_path(path)?;
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(io_error(path, source)),
}
}
#[cfg(test)]
thread_local! {
pub(crate) static PATH_CHECKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub(super) fn check_path(path: &Path) -> Result<(), ReferenceError> {
#[cfg(test)]
PATH_CHECKS.with(|checks| checks.set(checks.get() + 1));
for ancestor in path.ancestors() {
match fs::symlink_metadata(ancestor) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(ReferenceError::Unsupported(
"filesystem symlink in reference path",
));
}
Ok(metadata) if ancestor != path && !metadata.is_dir() => {
return Err(ReferenceError::Conflict(ancestor.into()));
}
Ok(metadata) if ancestor == path && metadata.is_dir() => {
return Err(ReferenceError::Conflict(path.into()));
}
Ok(metadata) if ancestor == path && !metadata.is_file() => {
return Err(ReferenceError::Unsupported("non-regular reference file"));
}
Ok(_) => (),
Err(error)
if error.kind() == io::ErrorKind::NotFound
|| error.kind() == io::ErrorKind::NotADirectory => {}
Err(source) => return Err(io_error(ancestor, source)),
}
}
Ok(())
}
pub(super) struct Lock {
pub(super) destination: PathBuf,
path: PathBuf,
file: Option<crate::locks::SharedFile>,
identity: fs::Metadata,
cleanup: crate::locks::Guard,
}
impl Lock {
pub(super) fn acquire(destination: PathBuf) -> Result<Self, ReferenceError> {
let _lock_operation = crate::locks::operation();
let destination =
std::path::absolute(&destination).map_err(|source| io_error(&destination, source))?;
check_path(&destination)?;
#[expect(
clippy::unwrap_used,
reason = "callers join validated names or fixed file names onto repository roots"
)]
let parent = destination.parent().unwrap();
fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
let mut path = destination.as_os_str().to_os_string();
path.push(".lock");
let path = PathBuf::from(path);
let file = crate::locks::unreplaceable_lock_options()
.open(&path)
.map_err(|source| {
if is_held(&source) {
ReferenceError::Locked(path.clone())
} else {
io_error(&path, source)
}
})?;
let identity = crate::locks::capture_identity(&path, &file)
.map_err(|source| io_error(&path, source))?;
let file = crate::locks::SharedFile::from(file);
let cleanup = crate::locks::Guard::new(&path, &file, identity.clone());
Ok(Self {
destination,
cleanup,
path: path.clone(),
identity,
file: Some(file),
})
}
pub(super) fn check_owned(&self) -> Result<(), ReferenceError> {
self.cleanup
.check()
.map_err(|source| io_error(&self.path, source))?;
let current =
fs::symlink_metadata(&self.path).map_err(|source| io_error(&self.path, source))?;
if !current.is_file() || current.file_type().is_symlink() {
return Err(ReferenceError::Locked(self.path.clone()));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if current.dev() != self.identity.dev() || current.ino() != self.identity.ino() {
return Err(ReferenceError::Locked(self.path.clone()));
}
}
Ok(())
}
pub(super) fn publish_retaining_lock(&self, bytes: &[u8]) -> Result<(), ReferenceError> {
self.publish_replacement(bytes, false)
}
pub(super) fn publish_synced(&self, bytes: &[u8]) -> Result<(), ReferenceError> {
self.publish_replacement(bytes, true)
}
fn publish_replacement(&self, bytes: &[u8], sync: bool) -> Result<(), ReferenceError> {
let _lock_operation = crate::locks::operation();
self.check_owned()?;
#[expect(clippy::unwrap_used, reason = "lock destinations lie below a root")]
let parent = self.destination.parent().unwrap();
let mut temporary = tempfile::Builder::new()
.prefix(".girt-packed-")
.tempfile_in(parent)
.map_err(|source| io_error(parent, source))?;
let permissions = self.identity.permissions();
temporary
.as_file()
.set_permissions(permissions)
.map_err(|source| io_error(temporary.path(), source))?;
temporary
.write_all(bytes)
.map_err(|source| io_error(temporary.path(), source))?;
if sync {
temporary
.as_file()
.sync_all()
.map_err(|source| io_error(temporary.path(), source))?;
}
self.check_owned()?;
temporary
.persist(&self.destination)
.map_err(|error| io_error(&self.destination, error.error))?;
Ok(())
}
fn publish(&mut self, target: &Target) -> Result<(), ReferenceError> {
let mut bytes = match target {
Target::Direct(id) => id.to_string().into_bytes(),
Target::Symbolic(name) => {
let mut bytes = b"ref: ".to_vec();
bytes.extend_from_slice(name.as_bytes());
bytes
}
};
bytes.push(b'\n');
#[expect(clippy::unwrap_used, reason = "an unpublished lock holds its file")]
self.file
.as_mut()
.unwrap()
.write_all(&bytes)
.map_err(|source| io_error(&self.path, source))?;
self.publish_retaining_lock(&bytes)
}
}
impl Drop for Lock {
fn drop(&mut self) {
let _lock_operation = crate::locks::operation();
if self.check_owned().is_ok() {
drop(self.file.take());
let _ = fs::remove_file(&self.path);
}
}
}
#[cfg(unix)]
pub(super) fn sync_directory(directory: &Path) -> Result<(), ReferenceError> {
fs::File::open(directory)
.and_then(|directory| directory.sync_all())
.map_err(|source| io_error(directory, source))
}
pub(super) fn io_error(path: &Path, source: io::Error) -> ReferenceError {
ReferenceError::Io {
path: path.into(),
source,
}
}
pub(super) fn malformed(path: &Path, reason: &'static str) -> ReferenceError {
ReferenceError::Malformed {
path: path.into(),
reason,
}
}
fn is_held(error: &io::Error) -> bool {
error.kind() == io::ErrorKind::AlreadyExists
|| (cfg!(windows) && error.kind() == io::ErrorKind::PermissionDenied)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn fixture() -> (tempfile::TempDir, Repository) {
let temp = tempfile::tempdir().unwrap();
fs::create_dir(temp.path().join("objects")).unwrap();
fs::create_dir(temp.path().join("refs")).unwrap();
fs::write(temp.path().join("HEAD"), b"ref: refs/heads/main\n").unwrap();
let repository = Repository::open(temp.path()).unwrap();
(temp, repository)
}
fn name(value: &[u8]) -> RefName {
RefName::new(value).unwrap()
}
fn id() -> ObjectId {
ObjectId::Sha1([1; 20])
}
#[rstest]
#[case::no_newline(b"1111111111111111111111111111111111111111")]
#[case::trailing_space(b"1111111111111111111111111111111111111111 \r\n\t")]
fn parses_loose_ids(#[case] bytes: &[u8]) {
assert_eq!(
parse_loose(crate::ObjectFormat::Sha1, bytes, Path::new("ref")).unwrap(),
Target::Direct(ObjectId::Sha1([0x11; 20]))
);
}
#[rstest]
#[case::empty(b"")]
#[case::garbage(b"garbage\n")]
#[case::zero(b"0000000000000000000000000000000000000000\n")]
#[case::sha256(b"1111111111111111111111111111111111111111111111111111111111111111\n")]
#[case::bad_symbol(b"ref: refs/../main\n")]
#[case::extra_line(b"1111111111111111111111111111111111111111\nextra\n")]
fn rejects_loose_data(#[case] bytes: &[u8]) {
assert!(matches!(
parse_loose(crate::ObjectFormat::Sha1, bytes, Path::new("ref")),
Err(ReferenceError::Malformed { .. })
));
}
#[test]
fn unborn_resolution_identifies_missing_terminal() {
let (_temp, repo) = fixture();
let refs = repo.references().unwrap();
assert_eq!(
refs.resolve(&name(b"HEAD"), 1).unwrap(),
Resolution {
name: name(b"refs/heads/main"),
id: None
}
);
assert!(matches!(
refs.resolve(&name(b"HEAD"), 0),
Err(ReferenceError::Depth(0))
));
assert_eq!(refs.resolve(&name(b"refs/absent"), 0).unwrap().id, None);
}
#[test]
fn cycle_failure_cleans_all_owned_locks() {
let (_temp, repo) = fixture();
let refs = repo.references().unwrap();
refs.update_without_reflog(
&name(b"refs/heads/main"),
Target::Symbolic(name(b"HEAD")),
Expected::Absent,
)
.unwrap();
assert!(matches!(
refs.resolve(&name(b"HEAD"), 32),
Err(ReferenceError::Cycle(_))
));
assert!(matches!(
refs.update_resolved_without_reflog(&name(b"HEAD"), id(), Expected::Any),
Err(ReferenceError::Cycle(_))
));
assert!(!repo.git_dir().join("HEAD.lock").exists());
assert!(!repo.git_dir().join("refs/heads/main.lock").exists());
assert!(!repo.common_dir().join("packed-refs.lock").exists());
}
#[test]
fn mismatch_preserves_bytes_and_cleans_locks() {
let (_temp, repo) = fixture();
let refs = repo.references().unwrap();
let before = fs::read(repo.git_dir().join("HEAD")).unwrap();
assert!(matches!(
refs.update_without_reflog(&name(b"HEAD"), Target::Direct(id()), Expected::Absent),
Err(ReferenceError::Mismatch { .. })
));
assert_eq!(fs::read(repo.git_dir().join("HEAD")).unwrap(), before);
assert!(!repo.git_dir().join("HEAD.lock").exists());
assert!(!repo.common_dir().join("packed-refs.lock").exists());
}
#[rstest]
#[case::reference("HEAD.lock")]
#[case::packed("packed-refs.lock")]
fn never_steals_existing_lock(#[case] lock_name: &str) {
let (_temp, repo) = fixture();
let lock_path = repo.git_dir().join(lock_name);
fs::write(&lock_path, b"another writer").unwrap();
assert!(matches!(
repo.references().unwrap().update_without_reflog(
&name(b"HEAD"),
Target::Direct(id()),
Expected::Any
),
Err(ReferenceError::Locked(_))
));
assert_eq!(fs::read(lock_path).unwrap(), b"another writer");
assert_eq!(
fs::read(repo.git_dir().join("HEAD")).unwrap(),
b"ref: refs/heads/main\n"
);
}
#[test]
fn write_failure_preserves_destination_and_releases_lock() {
let (_temp, repo) = fixture();
let path = repo.git_dir().join("HEAD");
let mut lock = Lock::acquire(path.clone()).unwrap();
lock.file = Some(fs::File::open(&lock.path).unwrap().into());
assert!(lock.publish(&Target::Direct(id())).is_err());
drop(lock);
assert_eq!(fs::read(path).unwrap(), b"ref: refs/heads/main\n");
assert!(!repo.git_dir().join("HEAD.lock").exists());
}
#[test]
fn failed_rename_preserves_unrelated_data() {
let (_temp, repo) = fixture();
let path = repo.git_dir().join("refs/new");
let mut lock = Lock::acquire(path.clone()).unwrap();
fs::create_dir(&path).unwrap();
fs::write(path.join("unrelated"), b"keep").unwrap();
assert!(lock.publish(&Target::Direct(id())).is_err());
drop(lock);
assert_eq!(fs::read(path.join("unrelated")).unwrap(), b"keep");
assert!(!repo.git_dir().join("refs/new.lock").exists());
}
#[test]
fn zero_id_has_no_side_effects() {
let (_temp, repo) = fixture();
assert!(matches!(
repo.references().unwrap().update_without_reflog(
&name(b"refs/new/deep"),
Target::Direct(ObjectId::Sha1([0; 20])),
Expected::Any
),
Err(ReferenceError::ZeroId)
));
assert!(!repo.git_dir().join("refs/new").exists());
assert!(!repo.common_dir().join("packed-refs.lock").exists());
}
#[cfg(unix)]
#[rstest]
#[case::directory("refs/link", "refs/link/a")]
#[case::file("refs/link", "refs/link")]
fn rejects_symlinks_without_touching_target(#[case] link: &str, #[case] reference: &str) {
let (temp, repo) = fixture();
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("a"), b"keep").unwrap();
std::os::unix::fs::symlink(outside.path(), temp.path().join(link)).unwrap();
let refs = repo.references().unwrap();
assert!(matches!(
refs.read(&name(reference.as_bytes())),
Err(ReferenceError::Unsupported(_))
));
assert!(matches!(
refs.update_without_reflog(
&name(reference.as_bytes()),
Target::Direct(id()),
Expected::Any
),
Err(ReferenceError::Unsupported(_))
));
assert_eq!(fs::read(outside.path().join("a")).unwrap(), b"keep");
}
}
#[cfg(test)]
mod chain_tests {
use super::*;
fn chain(root: &Path, hops: usize) {
fs::create_dir_all(root.join("objects")).unwrap();
fs::create_dir_all(root.join("refs/heads")).unwrap();
fs::write(root.join("HEAD"), b"ref: refs/heads/0\n").unwrap();
for index in 0..hops {
fs::write(
root.join(format!("refs/heads/{index}")),
format!("ref: refs/heads/{}\n", index + 1),
)
.unwrap();
}
}
#[test]
fn depth_failure_releases_chain_locks() {
let root = tempfile::tempdir().unwrap();
chain(root.path(), 33);
let repo = Repository::open(root.path()).unwrap();
let refs = repo.references().unwrap();
let head = RefName::HEAD;
assert!(matches!(
refs.update_resolved_without_reflog(&head, ObjectId::Sha1([1; 20]), Expected::Absent),
Err(ReferenceError::Depth(32))
));
assert_eq!(refs.resolve(&head, 34).unwrap().id, None);
assert_eq!(
fs::read_dir(root.path().join("refs/heads"))
.unwrap()
.count(),
33
);
assert!(!root.path().join("HEAD.lock").exists());
assert!(!root.path().join("packed-refs.lock").exists());
}
#[test]
fn deletion_depth_failure_preserves_chain() {
let root = tempfile::tempdir().unwrap();
chain(root.path(), 33);
let repo = Repository::open(root.path()).unwrap();
assert!(matches!(
repo.references()
.unwrap()
.delete_resolved_without_reflog(&RefName::HEAD, Expected::Any),
Err(ReferenceError::Depth(32))
));
assert_eq!(
fs::read_dir(root.path().join("refs/heads"))
.unwrap()
.count(),
33
);
assert!(!root.path().join("HEAD.lock").exists());
assert!(!root.path().join("packed-refs.lock").exists());
}
#[test]
fn locked_terminal_preserves_symbolic_chain() {
let root = tempfile::tempdir().unwrap();
chain(root.path(), 1);
fs::write(root.path().join("refs/heads/1.lock"), b"owner").unwrap();
let repo = Repository::open(root.path()).unwrap();
let head = RefName::HEAD;
assert!(matches!(
repo.references().unwrap().update_resolved_without_reflog(
&head,
ObjectId::Sha1([1; 20]),
Expected::Absent
),
Err(ReferenceError::Locked(_))
));
assert_eq!(
fs::read(root.path().join("refs/heads/0")).unwrap(),
b"ref: refs/heads/1\n"
);
assert_eq!(
fs::read(root.path().join("refs/heads/1.lock")).unwrap(),
b"owner"
);
assert!(!root.path().join("HEAD.lock").exists());
assert!(!root.path().join("refs/heads/0.lock").exists());
assert!(!root.path().join("packed-refs.lock").exists());
}
}
#[cfg(test)]
#[path = "delete_tests.rs"]
mod delete_tests;
#[cfg(test)]
mod ownership_tests {
use super::*;
#[cfg(unix)]
#[test]
fn replaced_lock_is_neither_published_nor_removed() {
let root = tempfile::tempdir().unwrap();
let path = root.path().canonicalize().unwrap().join("ref");
fs::write(&path, b"old").unwrap();
let lock = Lock::acquire(path.clone()).unwrap();
fs::remove_file(&lock.path).unwrap();
fs::write(&lock.path, b"replacement owner").unwrap();
assert!(matches!(
lock.publish_retaining_lock(b"new"),
Err(ReferenceError::Locked(_))
));
let lock_path = lock.path.clone();
drop(lock);
assert_eq!(fs::read(path).unwrap(), b"old");
assert_eq!(fs::read(lock_path).unwrap(), b"replacement owner");
}
#[cfg(windows)]
#[test]
fn windows_lock_disallows_replacement_until_release() {
let root = tempfile::tempdir().unwrap();
let path = root.path().canonicalize().unwrap().join("ref");
let lock = Lock::acquire(path.clone()).unwrap();
assert!(fs::remove_file(&lock.path).is_err());
let replacement = root.path().join("replacement");
fs::write(&replacement, b"foreign").unwrap();
assert!(fs::rename(&replacement, &lock.path).is_err());
lock.publish_retaining_lock(b"new").unwrap();
let lock_path = lock.path.clone();
drop(lock);
assert!(!lock_path.exists());
assert_eq!(fs::read(path).unwrap(), b"new");
assert_eq!(fs::read(replacement).unwrap(), b"foreign");
}
}
#[cfg(test)]
mod termination_tests {
use super::*;
#[test]
fn child_exits_with_owned_lock() {
let Some(root) = std::env::var_os("GIRT_R11_EXIT_LOCK_ROOT") else {
return;
};
let _lock = Lock::acquire(PathBuf::from(root).join("ref")).unwrap();
std::process::exit(0);
}
#[test]
fn termination_leaves_a_stale_lock_for_explicit_recovery() {
let root = tempfile::tempdir().unwrap();
let directory = root.path().canonicalize().unwrap();
let status = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"refs::store::termination_tests::child_exits_with_owned_lock",
])
.env("GIRT_R11_EXIT_LOCK_ROOT", &directory)
.status()
.unwrap();
assert!(status.success());
assert!(directory.join("ref.lock").exists());
assert!(matches!(
Lock::acquire(directory.join("ref")),
Err(ReferenceError::Locked(_))
));
fs::remove_file(directory.join("ref.lock")).unwrap();
let lock = Lock::acquire(directory.join("ref")).unwrap();
lock.publish_retaining_lock(b"recovered").unwrap();
drop(lock);
assert_eq!(fs::read(directory.join("ref")).unwrap(), b"recovered");
assert!(!directory.join("ref.lock").exists());
}
}
#[cfg(test)]
mod interrupt_cleanup_tests {
use super::*;
#[test]
fn cleanup_covers_reference_lock_topologies_without_changing_destinations() {
let root = tempfile::tempdir().unwrap();
let directory = root.path().canonicalize().unwrap();
for name in [
"refs/heads/main",
"logs/refs/heads/main",
"packed-refs",
"reftable/tables.list",
] {
let destination = directory.join(name);
fs::create_dir_all(destination.parent().unwrap()).unwrap();
fs::write(&destination, b"published bytes").unwrap();
let lock = Lock::acquire(destination.clone()).unwrap();
let report = crate::locks::cleanup_under(&directory).unwrap();
assert_eq!(report.removed.len(), 1, "{:?}", report.failures);
assert!(lock.publish_retaining_lock(b"unsafe replacement").is_err());
assert_eq!(fs::read(destination).unwrap(), b"published bytes");
}
}
}