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

nordisk/gunnar

crates/gunnar-core/tests/txlog_erase.rs

//! **An erased account leaves no bytes in the transaction log** (owner ruling
//! g-nonpay, 2026-09-29: a customer who stopped paying is DELETED on day 30,
//! "namespaces/repos + LFS on main AND twin").
//!
//! Purging the stores empties the disk, and the log still holds every pack
//! and every LFS object the account ever pushed — on main until retention
//! trims it, and on the twin FOREVER, because the twin's log is the copy. So
//! the frames themselves are erased in place: the header says
//! [`Kind::Redacted`], the blob's bytes are gone from the file (a punched hole,
//! or zeros), and every sequence number stays where it was — the twin's gap
//! rule and every index stay true.

use gunnar_core::txlog::{Kind, Log, Meta, RefEdit};

const ALICE: &[u8] = b"ALICE-SECRET-PACK-BYTES-";
const ALICE_LFS: &[u8] = b"ALICE-SECRET-LFS-OBJECT-";
const BOB: &[u8] = b"BOB-KEEPS-HIS-PACK-BYTES";

fn big(pattern: &[u8]) -> Vec<u8> {
    pattern.iter().copied().cycle().take(256 * 1024).collect()
}

fn segment_bytes(dir: &std::path::Path) -> Vec<u8> {
    let mut all = Vec::new();
    for e in std::fs::read_dir(dir).unwrap() {
        let p = e.unwrap().path();
        if p.extension().and_then(|x| x.to_str()) == Some("log") {
            all.extend(std::fs::read(p).unwrap());
        }
    }
    all
}

fn holds(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

fn pack(repo: &str) -> Meta {
    Meta::Pack {
        repo: repo.into(),
        hash: "sha1".into(),
    }
}

#[test]
fn an_erased_accounts_frames_are_redacted_in_place_and_nothing_else_moves() {
    let dir = tempfile::tempdir().unwrap();
    let log = Log::open(dir.path()).unwrap();
    let created = log
        .append(
            Kind::RepoStore,
            &Meta::RepoStore {
                repo: "alice/app".into(),
                op: "created".into(),
                hash: "sha1".into(),
            },
            &[],
        )
        .unwrap();
    let a_pack = log
        .append(Kind::Pack, &pack("alice/app"), &big(ALICE))
        .unwrap();
    let b_pack = log.append(Kind::Pack, &pack("bob/lib"), &big(BOB)).unwrap();
    let a_refs = log
        .append(
            Kind::Refs,
            &Meta::Refs {
                repo: "alice/app".into(),
                atomic: false,
                edits: vec![RefEdit {
                    name: "refs/heads/main".into(),
                    old: None,
                    new: Some("ab".repeat(20)),
                    peeled: None,
                    symref: None,
                }],
            },
            &[],
        )
        .unwrap();
    let a_lfs = log
        .append(
            Kind::LfsPut,
            &Meta::Lfs {
                repo: "alice/app".into(),
                oid: "cd".repeat(32),
                size: 262_144,
            },
            &big(ALICE_LFS),
        )
        .unwrap();
    let nested = log
        .append(Kind::Pack, &pack("alice/team/x"), &big(ALICE))
        .unwrap();
    let erase = log
        .append(
            Kind::Erase,
            &Meta::Erase {
                account: "alice".into(),
                repos: vec!["alice/app".into(), "alice/team/x".into()],
                reason: "nonpay".into(),
            },
            &[],
        )
        .unwrap();
    // A repository of the same name created AFTER the erase is somebody's new data.
    let later = log
        .append(Kind::Pack, &pack("alice/app"), &big(BOB))
        .unwrap();
    let head = log.head_seq();
    assert!(
        holds(&segment_bytes(dir.path()), ALICE),
        "the fixture holds alice's bytes"
    );

    let repos = ["alice/app".to_string(), "alice/team/x".to_string()];
    let done = log.erase(erase, &repos).unwrap();
    assert_eq!(done.frames, 4, "two packs, the refs and the LFS object");
    assert!(done.bytes >= 3 * 256 * 1024, "{done:?}");

    for seq in [a_pack, a_refs, a_lfs, nested] {
        let e = log.read(seq).unwrap();
        assert_eq!(e.kind(), Some(Kind::Redacted), "seq {seq}");
        assert!(
            e.blob.is_empty(),
            "seq {seq}: a redacted frame reads with no blob"
        );
        assert_eq!(
            log.locate_blob(seq).unwrap().blob_len,
            0,
            "seq {seq}: and streams none"
        );
    }
    assert_eq!(
        log.read(created).unwrap().kind(),
        Some(Kind::RepoStore),
        "the store's create/purge frames stay: a replay needs them"
    );
    assert_eq!(log.read(b_pack).unwrap().blob, big(BOB), "bob is untouched");
    assert_eq!(
        log.read(later).unwrap().blob,
        big(BOB),
        "a frame AFTER the erase is never touched"
    );
    assert_eq!(log.read(erase).unwrap().kind(), Some(Kind::Erase));
    assert_eq!(log.head_seq(), head, "no sequence moves");

    let on_disk = segment_bytes(dir.path());
    assert!(
        !holds(&on_disk, ALICE),
        "alice's pack bytes are gone from the file"
    );
    assert!(
        !holds(&on_disk, ALICE_LFS),
        "alice's LFS bytes are gone from the file"
    );

    // Idempotent, and the log reopens whole (the tail check does not cut a redacted frame).
    assert_eq!(log.erase(erase, &repos).unwrap().frames, 0);
    drop(log);
    let log = Log::open(dir.path()).unwrap();
    assert_eq!(log.head_seq(), head, "a reopened log keeps every frame");
    assert_eq!(log.read(a_pack).unwrap().kind(), Some(Kind::Redacted));
    assert_eq!(log.range(1, head).unwrap().len() as u64, head);
    assert_eq!(
        log.append(Kind::Pack, &pack("bob/lib"), b"more").unwrap(),
        head + 1,
        "and appends after it"
    );
}

/// A redacted frame as the TAIL of a segment: the open's torn-tail check must
/// not cut it (it cannot match its old payload checksum any more).
#[test]
fn a_redacted_tail_frame_survives_a_reopen() {
    let dir = tempfile::tempdir().unwrap();
    let log = Log::open(dir.path()).unwrap();
    let a = log
        .append(Kind::Pack, &pack("alice/app"), &big(ALICE))
        .unwrap();
    log.erase(a + 1, &["alice/app".to_string()]).unwrap();
    drop(log);
    let log = Log::open(dir.path()).unwrap();
    assert_eq!(log.head_seq(), a, "the redacted tail is still frame {a}");
    assert_eq!(log.read(a).unwrap().kind(), Some(Kind::Redacted));
}