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