#[cfg(unix)]
use std::collections::BTreeSet;
use std::fs;
#[cfg(unix)]
use std::io;
use std::sync::atomic::AtomicBool;
use rstest::rstest;
use super::decode_tests::{fixture, git};
use super::stack::create_unique_file;
#[cfg(unix)]
use super::stack::{ExpireStackError, expire_logs, expire_logs_with_sync};
use super::{Snapshot, StackLimits, compact, table_name};
use crate::ObjectFormat;
use crate::refs::ReferenceError;
#[cfg(unix)]
#[test]
fn expiry_directory_sync_failure_keeps_old_tables_after_visible_list() {
let root = fixture("sha1");
let directory = root.path().join(".git/reftable");
let before = Snapshot::read(
&directory,
ObjectFormat::Sha1,
StackLimits::default(),
&AtomicBool::new(false),
)
.unwrap();
let old_list = fs::read(directory.join("tables.list")).unwrap();
let log = before
.table
.logs
.iter()
.find(|record| record.value.is_some())
.unwrap();
let keys = BTreeSet::from([(log.name.clone(), log.update_index)]);
let refs = git(root.path(), &["show-ref", "--head"]);
let mut syncs = 0;
let error = expire_logs_with_sync(
&directory,
ObjectFormat::Sha1,
StackLimits::default(),
&before,
&keys,
&AtomicBool::new(false),
|path| {
syncs += 1;
if syncs == 2 {
return Err(ReferenceError::Io {
path: path.to_path_buf(),
source: io::Error::from_raw_os_error(28),
});
}
fs::File::open(path)
.and_then(|file| file.sync_all())
.map_err(|source| ReferenceError::Io {
path: path.to_path_buf(),
source,
})
},
);
assert!(matches!(
error,
Err(ExpireStackError::Failed {
publication_uncertain: true,
durable: false,
..
})
));
assert_ne!(fs::read(directory.join("tables.list")).unwrap(), old_list);
for name in &before.names {
assert!(directory.join(name).exists());
}
assert_eq!(git(root.path(), &["show-ref", "--head"]), refs);
}
#[cfg(unix)]
#[test]
fn expiry_rejects_changed_stack_before_publication() {
let root = fixture("sha1");
let directory = root.path().join(".git/reftable");
let before = Snapshot::read(
&directory,
ObjectFormat::Sha1,
StackLimits::default(),
&AtomicBool::new(false),
)
.unwrap();
let log = before
.table
.logs
.iter()
.find(|record| record.value.is_some())
.unwrap();
let keys = BTreeSet::from([(log.name.clone(), log.update_index)]);
git(root.path(), &["commit", "--allow-empty", "-m", "next"]);
let list = fs::read(directory.join("tables.list")).unwrap();
assert!(matches!(
expire_logs(
&directory,
ObjectFormat::Sha1,
StackLimits::default(),
&before,
&keys,
&AtomicBool::new(false),
),
Err(ExpireStackError::Changed)
));
assert_eq!(fs::read(directory.join("tables.list")).unwrap(), list);
}
#[rstest]
#[case::sha1("sha1", ObjectFormat::Sha1)]
#[case::sha256("sha256", ObjectFormat::Sha256)]
fn compaction_preserves_git_refs_logs_and_owned_snapshot(
#[case] spelling: &str,
#[case] format: ObjectFormat,
) {
let root = fixture(spelling);
git(root.path(), &["config", "reftable.autoCompact", "false"]);
git(root.path(), &["commit", "--allow-empty", "-m", "second"]);
git(root.path(), &["update-ref", "refs/tags/deleted", "HEAD"]);
git(root.path(), &["update-ref", "-d", "refs/tags/deleted"]);
let before = git(root.path(), &["show-ref", "--head"]);
let log = git(
root.path(),
&["reflog", "show", "--format=%H %gn %gs", "HEAD"],
);
let directory = root.path().join(".git/reftable");
let old = Snapshot::read(
&directory,
format,
StackLimits::default(),
&AtomicBool::new(false),
)
.unwrap();
let report = compact(
&directory,
format,
StackLimits::default(),
&AtomicBool::new(false),
)
.unwrap();
assert!(report.input_tables > 1);
assert!(report.retained.is_empty());
assert_eq!(git(root.path(), &["show-ref", "--head"]), before);
assert_eq!(
git(
root.path(),
&["reflog", "show", "--format=%H %gn %gs", "HEAD"]
),
log
);
git(root.path(), &["refs", "verify"]);
let new = Snapshot::read(
&directory,
format,
StackLimits::default(),
&AtomicBool::new(false),
)
.unwrap();
let retained: Vec<_> = old
.table
.references
.into_iter()
.filter(|record| record.target.is_some())
.collect();
assert_eq!(new.table.references, retained);
}
#[test]
fn stack_cancellation_precedes_lock_creation() {
let directory = tempfile::tempdir().unwrap();
assert!(matches!(
compact(
directory.path(),
ObjectFormat::Sha1,
StackLimits::default(),
&AtomicBool::new(true)
),
Err(ReferenceError::Cancelled)
));
assert!(!directory.path().join("tables.list.lock").exists());
}
#[rstest]
#[case::parent(b"../outside.ref\n")]
#[case::absolute(b"/outside.ref\n")]
#[case::duplicate(b"one.ref\none.ref\n")]
#[case::truncated(b"one.ref")]
#[case::empty(b"\n")]
#[case::backslash(b"dir\\one.ref\n")]
fn rejects_malformed_list_before_opening_tables(#[case] bytes: &[u8]) {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("tables.list"), bytes).unwrap();
assert!(matches!(
Snapshot::read(
directory.path(),
ObjectFormat::Sha1,
StackLimits::default(),
&AtomicBool::new(false)
),
Err(ReferenceError::Malformed { .. })
));
}
#[test]
fn compaction_never_steals_stack_lock() {
let root = fixture("sha1");
let directory = root.path().join(".git/reftable");
let before = fs::read(directory.join("tables.list")).unwrap();
fs::write(directory.join("tables.list.lock"), b"other writer").unwrap();
assert!(matches!(
compact(
&directory,
ObjectFormat::Sha1,
StackLimits::default(),
&AtomicBool::new(false)
),
Err(ReferenceError::Locked(_))
));
assert_eq!(fs::read(directory.join("tables.list")).unwrap(), before);
assert_eq!(
fs::read(directory.join("tables.list.lock")).unwrap(),
b"other writer"
);
}
#[test]
fn interrupted_list_publication_preserves_old_stack_and_leaves_unlisted_table() {
use crate::refs::store::Lock;
let root = fixture("sha1");
let directory = root.path().join(".git/reftable").canonicalize().unwrap();
let before = fs::read(directory.join("tables.list")).unwrap();
let snapshot = Snapshot::read(
&directory,
ObjectFormat::Sha1,
StackLimits::default(),
&AtomicBool::new(false),
)
.unwrap();
let lock = Lock::acquire(directory.join("tables.list")).unwrap();
let bytes = snapshot.table.encode(super::Limits::default()).unwrap();
let count = fs::read_dir(&directory).unwrap().count();
assert!(
super::stack::publish_with(
&lock,
&snapshot.names,
&snapshot.table,
&bytes,
StackLimits::default(),
|| Err(std::io::Error::other("injected before list replacement"))
)
.is_err()
);
assert_eq!(fs::read(directory.join("tables.list")).unwrap(), before);
assert_eq!(fs::read_dir(&directory).unwrap().count(), count + 1);
drop(lock);
let after = Snapshot::read(
&directory,
ObjectFormat::Sha1,
StackLimits::default(),
&AtomicBool::new(false),
)
.unwrap();
assert_eq!(after.table, snapshot.table);
}
#[test]
fn compaction_respects_existing_compactor_table_lock() {
let root = fixture("sha1");
let directory = root.path().join(".git/reftable");
let before = fs::read_to_string(directory.join("tables.list")).unwrap();
let lock = directory.join(format!("{}.lock", before.lines().next().unwrap()));
fs::write(&lock, b"other compactor").unwrap();
assert!(matches!(
compact(
&directory,
ObjectFormat::Sha1,
StackLimits::default(),
&AtomicBool::new(false)
),
Err(ReferenceError::Locked(_))
));
assert_eq!(
fs::read_to_string(directory.join("tables.list")).unwrap(),
before
);
assert_eq!(fs::read(lock).unwrap(), b"other compactor");
assert!(!directory.join("tables.list.lock").exists());
}
#[rstest]
#[case::sha1("sha1", ObjectFormat::Sha1)]
#[case::sha256("sha256", ObjectFormat::Sha256)]
fn compaction_rejects_damaged_table_and_preserves_stack(
#[case] spelling: &str,
#[case] format: ObjectFormat,
) {
let root = fixture(spelling);
git(root.path(), &["config", "reftable.autoCompact", "false"]);
git(root.path(), &["commit", "--allow-empty", "-m", "second"]);
git(root.path(), &["update-ref", "refs/tags/one", "HEAD"]);
git(root.path(), &["update-ref", "refs/tags/two", "HEAD"]);
let directory = root.path().join(".git/reftable");
let list = fs::read(directory.join("tables.list")).unwrap();
let names: Vec<_> = list
.split(|byte| *byte == b'\n')
.filter(|line| line.ends_with(b".ref"))
.map(|line| String::from_utf8(line.to_vec()).unwrap())
.collect();
assert!(names.len() > 1);
let damaged = directory.join(&names[0]);
let mut bytes = fs::read(&damaged).unwrap();
let header = if format == ObjectFormat::Sha1 { 24 } else { 28 };
assert_eq!(bytes[header], b'r');
let end = usize::from(bytes[header + 1]) << 16
| usize::from(bytes[header + 2]) << 8
| usize::from(bytes[header + 3]);
bytes[end - 2..end].copy_from_slice(&[0, 0]);
fs::write(&damaged, &bytes).unwrap();
let tables: Vec<_> = names
.iter()
.map(|name| fs::read(directory.join(name)).unwrap())
.collect();
assert!(
compact(
&directory,
format,
StackLimits::default(),
&AtomicBool::new(false)
)
.is_err()
);
assert_eq!(fs::read(directory.join("tables.list")).unwrap(), list);
for (name, bytes) in names.iter().zip(&tables) {
assert_eq!(&fs::read(directory.join(name)).unwrap(), bytes);
}
let entries = fs::read_dir(&directory).unwrap().count();
assert_eq!(entries, names.len() + 1, "no new or lock files remain");
}
#[test]
fn table_names_have_gits_form() {
let name = table_name(1, 0x1_0000_0002);
let suffix = name
.strip_prefix("0x000000000001-0x000100000002-")
.and_then(|rest| rest.strip_suffix(".ref"))
.unwrap();
assert_eq!(suffix.len(), 8);
assert!(
suffix
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
);
assert_ne!(table_name(1, 1), table_name(1, 1));
}
#[test]
fn unique_file_skips_existing_names_and_removes_unkept_files() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("taken.ref"), b"kept").unwrap();
let mut names = ["taken.ref", "fresh.ref"].into_iter();
let file =
create_unique_file(directory.path(), 2, || names.next().unwrap().to_owned()).unwrap();
assert_eq!(file.path(), directory.path().join("fresh.ref"));
assert_eq!(
fs::read(directory.path().join("taken.ref")).unwrap(),
b"kept"
);
drop(file);
assert!(!directory.path().join("fresh.ref").exists());
}
#[test]
fn unique_file_stops_after_its_attempts() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("taken.ref"), b"kept").unwrap();
let mut calls = 0;
let error = create_unique_file(directory.path(), 3, || {
calls += 1;
"taken.ref".to_owned()
})
.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(calls, 3);
}