use crate::crypto::kdf::{derive_hk, derive_mk};
use crate::pager::header::{ActiveSlot, bootstrap_header, commit_header, open_header};
use crate::pager::structural_header::{MainDbHeaderFields, encode_main_db_header};
use crate::vfs::memory::MemVfs;
use crate::vfs::{OpenMode, Vfs, VfsFile};
use crate::{CommitId, PagedbError};
fn sample(seq: u64, page_size_log2: u8, anchor: u64) -> MainDbHeaderFields {
MainDbHeaderFields {
format_version: crate::pager::format::structural_header::MAIN_FORMAT_VERSION,
cipher_id: 1,
page_size_log2,
flags: 0,
file_id: [0xAB; 16],
kek_salt: [0xCD; 16],
mk_epoch: 0,
seq,
active_root_page_id: 4,
active_root_txn_id: 1,
counter_anchor: anchor,
commit_id: CommitId::new(seq),
free_list_root: [0; 16],
catalog_root: [0; 16],
apply_journal_root_page_id: 0,
apply_journal_root_version: 0,
commit_history_root_page_id: 0,
commit_history_root_version: 0,
restore_mode: 0,
next_page_id: 4,
commit_retain_policy_tag: 0,
commit_retain_policy_value: 1024,
realm_id: crate::RealmId::new([0x42; 16]),
}
}
fn hk() -> crate::crypto::DerivedKey {
let mk = derive_mk(&[7u8; 32], &[0u8; 16], 0).unwrap();
derive_hk(&mk).unwrap()
}
#[tokio::test(flavor = "current_thread")]
async fn alternation_picks_latest_seq() {
let vfs = MemVfs::new();
let hk = hk();
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 0), 4096)
.await
.unwrap();
let slot = commit_header(
&vfs,
"/main.db",
&hk,
&sample(2, 12, 1024),
ActiveSlot::A,
4096,
)
.await
.unwrap();
assert_eq!(slot, ActiveSlot::B);
let slot = commit_header(
&vfs,
"/main.db",
&hk,
&sample(3, 12, 2048),
ActiveSlot::B,
4096,
)
.await
.unwrap();
assert_eq!(slot, ActiveSlot::A);
let slot = commit_header(
&vfs,
"/main.db",
&hk,
&sample(4, 12, 3072),
ActiveSlot::A,
4096,
)
.await
.unwrap();
assert_eq!(slot, ActiveSlot::B);
let (got, active) = open_header(&vfs, "/main.db", &hk, 4096).await.unwrap();
assert_eq!(got.seq, 4);
assert_eq!(got.counter_anchor, 3072);
assert_eq!(active, ActiveSlot::B);
}
#[tokio::test(flavor = "current_thread")]
async fn torn_write_to_inactive_slot_recovers_active() {
let vfs = MemVfs::new();
let hk = hk();
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 0), 4096)
.await
.unwrap();
commit_header(
&vfs,
"/main.db",
&hk,
&sample(2, 12, 100),
ActiveSlot::A,
4096,
)
.await
.unwrap();
let mut f = vfs.open("/main.db", OpenMode::ReadWrite).await.unwrap();
let mut buf = vec![0u8; 4096];
f.read_at(4096, &mut buf).await.unwrap();
for b in &mut buf[4080..] {
*b = 0;
}
f.write_at(4096, &buf).await.unwrap();
f.sync().await.unwrap();
drop(f);
let (got, active) = open_header(&vfs, "/main.db", &hk, 4096).await.unwrap();
assert_eq!(got.seq, 1);
assert_eq!(active, ActiveSlot::A);
}
#[tokio::test(flavor = "current_thread")]
async fn both_slots_corrupt_returns_header_unverifiable() {
let vfs = MemVfs::new();
let hk = hk();
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 0), 4096)
.await
.unwrap();
let mut f = vfs.open("/main.db", OpenMode::ReadWrite).await.unwrap();
let zeros16 = [0u8; 16];
f.write_at(4080, &zeros16).await.unwrap();
f.write_at(8176, &zeros16).await.unwrap();
f.sync().await.unwrap();
drop(f);
let err = open_header(&vfs, "/main.db", &hk, 4096)
.await
.err()
.unwrap();
assert!(matches!(err, PagedbError::Corruption(_)));
}
#[tokio::test(flavor = "current_thread")]
async fn anchor_recovery_picks_max_via_seq() {
let vfs = MemVfs::new();
let hk = hk();
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 4500), 4096)
.await
.unwrap();
commit_header(
&vfs,
"/main.db",
&hk,
&sample(2, 12, 5000),
ActiveSlot::A,
4096,
)
.await
.unwrap();
let (got, _) = open_header(&vfs, "/main.db", &hk, 4096).await.unwrap();
assert_eq!(got.counter_anchor, 5000);
}
#[tokio::test(flavor = "current_thread")]
async fn page_size_4k_8k_16k_round_trip() {
let hk = hk();
for log2 in [12u8, 13, 14] {
let page_size = 1usize << log2;
let vfs = MemVfs::new();
let path = format!("/main_{log2}.db");
bootstrap_header(&vfs, &path, &hk, &sample(1, log2, 0), page_size)
.await
.unwrap();
commit_header(
&vfs,
&path,
&hk,
&sample(2, log2, 1024),
ActiveSlot::A,
page_size,
)
.await
.unwrap();
let (got, slot) = open_header(&vfs, &path, &hk, page_size).await.unwrap();
assert_eq!(got.seq, 2);
assert_eq!(slot, ActiveSlot::B);
}
}
#[tokio::test(flavor = "current_thread")]
async fn bootstrap_refuses_existing_file() {
let vfs = MemVfs::new();
let hk = hk();
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 0), 4096)
.await
.unwrap();
let err = bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 0), 4096)
.await
.err()
.unwrap();
assert!(matches!(err, PagedbError::Io(_)));
}
#[tokio::test(flavor = "current_thread")]
async fn commit_returns_inverted_slot() {
let vfs = MemVfs::new();
let hk = hk();
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 0), 4096)
.await
.unwrap();
let s = commit_header(
&vfs,
"/main.db",
&hk,
&sample(2, 12, 0),
ActiveSlot::A,
4096,
)
.await
.unwrap();
assert_eq!(s, ActiveSlot::B);
let s = commit_header(
&vfs,
"/main.db",
&hk,
&sample(3, 12, 0),
ActiveSlot::B,
4096,
)
.await
.unwrap();
assert_eq!(s, ActiveSlot::A);
}
#[tokio::test(flavor = "current_thread")]
async fn open_picks_higher_seq_when_both_verify() {
let vfs = MemVfs::new();
let hk = hk();
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 0), 4096)
.await
.unwrap();
let bytes = encode_main_db_header(&sample(5, 12, 9000), &hk, 4096).unwrap();
let mut f = vfs.open("/main.db", OpenMode::ReadWrite).await.unwrap();
f.write_at(4096, &bytes).await.unwrap();
f.sync().await.unwrap();
drop(f);
let (got, slot) = open_header(&vfs, "/main.db", &hk, 4096).await.unwrap();
assert_eq!(got.seq, 5);
assert_eq!(slot, ActiveSlot::B);
assert_eq!(got.counter_anchor, 9000);
}
#[tokio::test(flavor = "current_thread")]
async fn open_survives_a_file_truncated_inside_the_inactive_slot() {
let vfs = MemVfs::new();
let hk = hk();
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 4242), 4096)
.await
.unwrap();
let mut f = vfs.open("/main.db", OpenMode::ReadWrite).await.unwrap();
f.truncate(4096 + 2048).await.unwrap();
f.sync().await.unwrap();
drop(f);
let (got, slot) = open_header(&vfs, "/main.db", &hk, 4096)
.await
.expect("the intact slot must still open the database");
assert_eq!(slot, ActiveSlot::A);
assert_eq!(got.seq, 1);
assert_eq!(got.counter_anchor, 4242);
}
#[tokio::test(flavor = "current_thread")]
async fn open_reports_corruption_when_truncation_takes_both_slots() {
let vfs = MemVfs::new();
let hk = hk();
bootstrap_header(&vfs, "/main.db", &hk, &sample(1, 12, 0), 4096)
.await
.unwrap();
let mut f = vfs.open("/main.db", OpenMode::ReadWrite).await.unwrap();
f.truncate(0).await.unwrap();
f.sync().await.unwrap();
drop(f);
let err = open_header(&vfs, "/main.db", &hk, 4096)
.await
.expect_err("a file with no readable slot must not open");
assert!(
matches!(err, PagedbError::Corruption(_)),
"expected Corruption, got {err:?}"
);
}