Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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)
}