crates/gunnar-server/tests/repo_create_transactional.rs
//! **`repo create` is transactional, and it never adopts a store it did not
//! make; a delete that could not reclaim tombstones the name** (2026-08-30).
//!
//! MEASURED by the site test on the reference appliance (v1.1.3-166): `repo
//! delete` + `repo create` of the same name resurrected the deleted
//! repository — `ls-remote` right after the create listed all 59 refs of
//! skade; recreated `--hash sha256` the name served sha1 refs and git said
//! `fatal: protocol error`. Delete dropped the catalog row and left the
//! store; create opened whatever stood at the name.
//!
//! `ServerStores::create_repo` is the one verb every create routes through:
//! disk first, then the row, then the store, with the row rolled back if the
//! store fails.
use gunnar_core::{HashKind, NewRepo, RepoRef};
use gunnar_server::stores::{dir_bytes, ServerStores};
fn stores_with_namespace() -> (tempfile::TempDir, ServerStores) {
let dir = tempfile::tempdir().expect("tempdir");
let stores = ServerStores::open(dir.path()).expect("stores");
stores
.catalog()
.create_namespace("alice")
.expect("namespace");
(dir, stores)
}
/// The resurrection, closed: a store standing at the name — the shape a
/// catalog-only delete left — is refused BY NAME, and the catalog is not
/// written.
#[test]
fn create_refuses_a_store_it_did_not_make_and_writes_nothing() {
let (_dir, stores) = stores_with_namespace();
let path = RepoRef::new("alice", "skade").expect("legal");
let repo = stores
.create_repo(NewRepo::new(path.clone()).hash_kind(HashKind::Sha1), false)
.expect("first create");
let git_dir = stores.repo_dir(&path).expect("dir");
assert!(git_dir.is_dir());
assert_eq!(
std::fs::read_to_string(git_dir.join(ServerStores::HASH_MARKER))
.expect("marker")
.trim(),
"sha1",
"the hash marker is written at create"
);
// The old delete: the row goes, the store stays.
stores.catalog().delete(&path).expect("catalog delete");
stores.close_repo(&path);
assert!(git_dir.is_dir(), "the fixture needs the store to survive");
let err = stores
.create_repo(
NewRepo::new(path.clone()).hash_kind(HashKind::Sha256),
false,
)
.expect_err("a create must not adopt a stranger's store");
let sentence = format!("{err:#}");
assert!(sentence.contains("a store already exists at"), "{sentence}");
assert!(sentence.contains("reclaims it"), "{sentence}");
assert!(sentence.contains("--adopt"), "{sentence}");
assert!(
stores.catalog().get(&path).expect("read").is_none(),
"the refused create wrote a catalog row"
);
let _ = repo;
}
/// `--adopt` takes the store over — for a matching hash only. A sha256 create
/// over a sha1 store is what served sha1 refs as sha256 on the site.
#[test]
fn adopt_takes_a_store_of_the_same_hash_and_refuses_the_other() {
let (_dir, stores) = stores_with_namespace();
let path = RepoRef::new("alice", "restored").expect("legal");
stores
.create_repo(NewRepo::new(path.clone()).hash_kind(HashKind::Sha1), false)
.expect("create");
stores.catalog().delete(&path).expect("catalog delete");
stores.close_repo(&path);
let err = stores
.create_repo(NewRepo::new(path.clone()).hash_kind(HashKind::Sha256), true)
.expect_err("adopting a sha1 store as sha256 must be refused");
assert!(format!("{err:#}").contains("cannot adopt"), "{err:#}");
assert!(stores.catalog().get(&path).expect("read").is_none());
let adopted = stores
.create_repo(NewRepo::new(path.clone()).hash_kind(HashKind::Sha1), true)
.expect("adopting a sha1 store as sha1");
assert_eq!(adopted.hash_kind, HashKind::Sha1);
assert!(stores.catalog().get(&path).expect("read").is_some());
}
/// A delete whose reclaim fails leaves a TOMBSTONE; create refuses the name
/// until a delete succeeds, and that delete removes the tombstone too.
#[test]
fn a_failed_reclaim_tombstones_the_name_until_a_delete_succeeds() {
let (_dir, stores) = stores_with_namespace();
let path = RepoRef::new("alice", "stuck").expect("legal");
stores
.create_repo(NewRepo::new(path.clone()), false)
.expect("create");
let git_dir = stores.repo_dir(&path).expect("dir");
let tombstone = ServerStores::tombstone_path(&git_dir);
stores.catalog().delete(&path).expect("catalog delete");
// Make the reclaim fail: a directory nobody may remove entries from.
let inner = git_dir.join("held");
std::fs::create_dir_all(&inner).expect("held");
std::fs::write(inner.join("f"), b"x").expect("file");
make_unwritable(&inner);
let err = stores
.purge_repo(&path)
.expect_err("the reclaim must fail on the read-only dir");
assert!(format!("{err:#}").contains("tombstoned"), "{err:#}");
assert!(
tombstone.is_file(),
"no tombstone at {}",
tombstone.display()
);
assert!(stores.is_tombstoned(&path).expect("read"));
let err = stores
.create_repo(NewRepo::new(path.clone()), false)
.expect_err("a tombstoned name must be refused");
let sentence = format!("{err:#}");
assert!(sentence.contains("could not reclaim"), "{sentence}");
assert!(sentence.contains("retries the reclaim"), "{sentence}");
assert!(stores.catalog().get(&path).expect("read").is_none());
// The operator fixes the cause and deletes again: reclaimed, tombstone gone.
let mut rw = std::fs::metadata(&inner).expect("meta").permissions();
#[allow(clippy::permissions_set_readonly_false)]
rw.set_readonly(false);
std::fs::set_permissions(&inner, rw).expect("chmod back");
let (_, after) = stores.purge_repo(&path).expect("the retry reclaims");
assert_eq!(after, 0);
assert!(!git_dir.exists());
assert!(!tombstone.exists(), "the tombstone must go with the store");
assert_eq!(dir_bytes(&git_dir), 0);
// And the name is free again.
stores
.create_repo(NewRepo::new(path.clone()), false)
.expect("create after a clean delete");
}
/// The row is rolled back when the store cannot be made: nothing for
/// `repo get` to find, no directory.
#[test]
fn a_create_whose_store_fails_rolls_the_catalog_row_back() {
let (_dir, stores) = stores_with_namespace();
let path = RepoRef::new("alice", "nostore").expect("legal");
// The namespace directory exists and refuses new entries, so the engine
// cannot lay the store down.
let ns_dir = stores
.repo_dir(&path)
.expect("dir")
.parent()
.unwrap()
.to_path_buf();
std::fs::create_dir_all(&ns_dir).expect("ns dir");
make_unwritable(&ns_dir);
let err = stores
.create_repo(NewRepo::new(path.clone()), false)
.expect_err("the store cannot be made");
assert!(format!("{err:#}").contains("nothing was kept"), "{err:#}");
assert!(
stores.catalog().get(&path).expect("read").is_none(),
"the row survived a create whose store failed"
);
assert!(!stores.repo_dir(&path).expect("dir").exists());
let mut rw = std::fs::metadata(&ns_dir).expect("meta").permissions();
#[allow(clippy::permissions_set_readonly_false)]
rw.set_readonly(false);
std::fs::set_permissions(&ns_dir, rw).expect("chmod back");
}
/// **Make `dir` refuse new entries, and prove it did.**
///
/// Both tests below need one filesystem failure they can predict, and a
/// read-only directory is the portable way to get it. It is not portable to
/// every *caller*: **root ignores the mode**, so under `sudo`, in a container
/// whose default user is root, or on a filesystem mounted without permission
/// checks, the write simply succeeds and the operation under test never
/// fails. The test would then panic at its `expect_err` saying "the store
/// cannot be made", which reads like the product broke.
///
/// So the premise is asserted where it is created, in the words that name the
/// cause. Same stance as `xtask/tests/cve.rs`: a condition that cannot be
/// established is a loud failure, never a silent skip.
fn make_unwritable(dir: &std::path::Path) {
let mut ro = std::fs::metadata(dir).expect("meta").permissions();
ro.set_readonly(true);
std::fs::set_permissions(dir, ro).expect("chmod");
let probe = dir.join(".can-this-process-still-write-here");
let wrote = std::fs::write(&probe, b"x").is_ok();
let _ = std::fs::remove_file(&probe);
assert!(
!wrote,
"{} is mode 0555 and this process could still create a file in it, so the failure \
these tests inject cannot happen and they would prove nothing. Almost always: \
running as root (uid {}), which ignores the mode. Run the suite as an ordinary \
user — in the toolchain container, `podman run --userns=keep-id`.",
dir.display(),
unsafe { libc_getuid() }
);
}
/// `getuid(2)` without a libc dependency: this crate has none, and the number
/// is only ever printed in the message above.
unsafe fn libc_getuid() -> u32 {
std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|status| {
status
.lines()
.find_map(|l| l.strip_prefix("Uid:"))
.and_then(|l| l.split_whitespace().next().map(str::to_owned))
})
.and_then(|uid| uid.parse().ok())
.unwrap_or(u32::MAX)
}