#![cfg(test)]
use crate::page::{self, PageType, PAGE_SIZE};
use crate::superblock::Superblock;
use crate::{Chisel, ChiselError, Key, Options};
use std::fs;
use std::io::{Read as _, Seek, SeekFrom, Write};
use tempfile::{NamedTempFile, TempDir};
use zeroize::Zeroizing;
fn find_page_of_type(path: &std::path::Path, want: PageType) -> u64 {
let mut f = fs::File::open(path).unwrap();
let len = f.metadata().unwrap().len();
let n_pages = len / PAGE_SIZE as u64;
for p in 2..n_pages {
f.seek(SeekFrom::Start(p * PAGE_SIZE as u64)).unwrap();
let mut first = [0u8; 1];
f.read_exact(&mut first).unwrap();
if first[0] == want as u8 {
return p;
}
}
panic!("no page of type {want:?} found in {path:?}");
}
fn rewrite_page_with_valid_checksum(
path: &std::path::Path,
page_id: u64,
mutate: impl FnOnce(&mut [u8; PAGE_SIZE]),
) {
let mut f = fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.unwrap();
let mut buf = [0u8; PAGE_SIZE];
f.seek(SeekFrom::Start(page_id * PAGE_SIZE as u64)).unwrap();
f.read_exact(&mut buf).unwrap();
mutate(&mut buf);
page::stamp_checksum(&mut buf);
f.seek(SeekFrom::Start(page_id * PAGE_SIZE as u64)).unwrap();
f.write_all(&buf).unwrap();
f.sync_all().unwrap();
}
fn active_superblock(path: &std::path::Path) -> Superblock {
let mut f = fs::File::open(path).unwrap();
let mut bufs = [[0u8; PAGE_SIZE]; crate::superblock::DEFAULT_SUPERBLOCK_COUNT as usize];
for (i, b) in bufs.iter_mut().enumerate() {
f.seek(SeekFrom::Start(i as u64 * PAGE_SIZE as u64))
.unwrap();
f.read_exact(b).unwrap();
}
Superblock::select(&bufs).expect("a valid winning superblock")
}
#[test]
fn test_recovery_cyclic_handle_table_spine_is_rejected_not_hung() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
for i in 0..600u64 {
db.allocate(&i.to_le_bytes()).unwrap();
}
db.commit().unwrap();
}
let root = active_superblock(&path).root_handle_table_page;
rewrite_page_with_valid_checksum(&path, root, |buf| {
buf[page::DATA_PAGE_HEADER_SIZE..page::DATA_PAGE_HEADER_SIZE + 8]
.copy_from_slice(&root.to_le_bytes());
});
match Chisel::open(&path, Default::default()) {
Err(e) => assert!(e.is_fatal(), "expected a fatal error, got {e:?}"),
Ok(_) => panic!("Chisel::open accepted a cyclic handle-table spine"),
}
}
#[test]
fn test_recovery_corrupt_membership_inner_depth_is_corrupt_page() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
db.allocate_tagged(b"row", crate::Tag::new(7).unwrap())
.unwrap();
db.commit().unwrap();
}
let outer = active_superblock(&path).root_membership_index_page;
rewrite_page_with_valid_checksum(&path, outer, |buf| {
let off = page::DATA_PAGE_HEADER_SIZE + 7 * 8;
let packed = u64::from_le_bytes(buf[off..off + 8].try_into().unwrap());
let corrupt = (packed & ((1u64 << 58) - 1)) | (30u64 << 58); buf[off..off + 8].copy_from_slice(&corrupt.to_le_bytes());
});
let db = Chisel::open(&path, Default::default()).unwrap();
match db.handles_with_tag(crate::Tag::new(7).unwrap()) {
Err(ChiselError::CorruptPage { .. }) => {}
other => panic!("expected CorruptPage, got {other:?}"),
}
}
#[test]
fn test_recovery_after_clean_close() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handle;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
handle = db.allocate(b"durable data").unwrap();
db.commit().unwrap();
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.read(handle).unwrap(), b"durable data");
}
#[test]
fn test_recovery_uncommitted_data_lost() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let committed_handle;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
committed_handle = db.allocate(b"committed").unwrap();
db.commit().unwrap();
db.begin().unwrap();
db.allocate(b"uncommitted").unwrap();
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.read(committed_handle).unwrap(), b"committed");
assert!(db
.read(crate::Handle::from(committed_handle.get() + 1))
.is_err());
}
#[test]
fn test_recovery_corrupt_superblock_b() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handle;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
handle = db.allocate(b"safe").unwrap();
db.commit().unwrap();
}
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(PAGE_SIZE as u64)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.sync_all().unwrap();
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.read(handle).unwrap(), b"safe");
}
#[test]
fn test_recovery_tagged_index_recovers_from_prior_superblock() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let h1;
let h2;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
h1 = db
.allocate_tagged(b"row1", crate::Tag::new(9).unwrap())
.unwrap();
db.commit().unwrap(); db.begin().unwrap();
h2 = db
.allocate_tagged(b"row2", crate::Tag::new(9).unwrap())
.unwrap();
db.commit().unwrap(); }
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(PAGE_SIZE as u64)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.sync_all().unwrap();
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(
db.handles_with_tag(crate::Tag::new(9).unwrap()).unwrap(),
vec![h1]
);
assert_eq!(db.tag(h1).unwrap().unwrap(), 9);
assert!(
matches!(db.read(h2), Err(ChiselError::InvalidHandle(_))),
"h2 must be InvalidHandle after recovery to pre-h2 state"
);
}
#[test]
fn test_recovery_crash_between_data_fsync_and_superblock_fsync() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let ha;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
ha = db.allocate(b"state-A-value").unwrap();
db.commit().unwrap();
}
let sb_pages = active_superblock(&path).superblock_count as usize;
let sb_region = {
let mut f = fs::File::open(&path).unwrap();
let mut buf = vec![0u8; sb_pages * PAGE_SIZE];
f.read_exact(&mut buf).unwrap();
buf
};
let hb;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
hb = db.allocate(b"state-B-value-must-be-lost").unwrap();
db.commit().unwrap();
}
let len_after_b = fs::metadata(&path).unwrap().len();
assert!(
len_after_b > (sb_pages as u64 + 1) * PAGE_SIZE as u64,
"B should have written data pages past the superblock region"
);
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&sb_region).unwrap();
f.sync_all().unwrap();
}
{
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.read(ha).unwrap(), b"state-A-value");
assert!(
db.read(hb).is_err(),
"state-B transaction must be lost atomically, not half-applied"
);
assert_eq!(
db.handles().unwrap().len(),
1,
"recovered table must hold only state A's handle"
);
}
let hc;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
hc = db.allocate(b"state-C-after-recovery").unwrap();
db.commit().unwrap();
assert_eq!(
hc, hb,
"next_handle must have rewound to state A on recovery"
);
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.read(ha).unwrap(), b"state-A-value");
assert_eq!(db.read(hc).unwrap(), b"state-C-after-recovery");
}
#[test]
fn test_recovery_torn_first_commit() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
{
let _db = Chisel::open(&path, Default::default()).unwrap();
}
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.sync_all().unwrap();
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.handles().unwrap().len(), 0);
}
#[test]
fn test_recovery_both_superblocks_corrupt() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
db.allocate(b"doomed").unwrap();
db.commit().unwrap();
}
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.seek(SeekFrom::Start(PAGE_SIZE as u64)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.sync_all().unwrap();
}
let err = match Chisel::open(&path, Default::default()) {
Ok(_) => panic!("both-superblocks-corrupt must fail to open"),
Err(e) => e,
};
assert!(
err.is_fatal(),
"both-superblocks-corrupt must be a fatal error, got {err:?}"
);
}
#[test]
fn test_recovery_corrupted_overflow_page_surfaces_fatal_error() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handle;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
handle = db.allocate(&vec![0xAB; 20_000]).unwrap();
db.commit().unwrap();
}
let overflow_page = find_page_of_type(&path, PageType::Overflow);
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(overflow_page * PAGE_SIZE as u64))
.unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.sync_all().unwrap();
}
let db = Chisel::open(&path, Default::default())
.expect("open should still succeed — only an overflow page is damaged");
let err = db
.read(handle)
.expect_err("read of a broken overflow chain must fail");
match err {
ChiselError::ChecksumMismatch { page_id } => {
assert_eq!(page_id, overflow_page, "wrong page id in error");
}
other => panic!("expected ChecksumMismatch on the overflow page, got {other:?}"),
}
}
#[test]
fn test_read_of_structurally_broken_data_page_returns_corrupt_page() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handle;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
handle = db.allocate(b"alive but doomed").unwrap();
db.commit().unwrap();
}
let data_page = find_page_of_type(&path, PageType::Data);
rewrite_page_with_valid_checksum(&path, data_page, |buf| {
buf[0] = 0xFF; });
let db = Chisel::open(&path, Default::default())
.expect("open scans the handle table only, not data pages");
let err = db.read(handle).expect_err("read must fail");
match err {
ChiselError::CorruptPage { page_id } => {
assert_eq!(page_id, data_page, "CorruptPage error had wrong page id");
}
ChiselError::InvalidHandle(_) => panic!(
"structurally-broken data page was misclassified as InvalidHandle; \
the transaction.rs Live-slot upgrade is missing"
),
other => panic!("expected CorruptPage, got {other:?}"),
}
}
#[test]
fn test_recovery_truncated_file_is_rejected() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
db.allocate(b"x").unwrap();
db.commit().unwrap();
}
{
let f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.set_len(PAGE_SIZE as u64).unwrap();
}
let err = match Chisel::open(&path, Default::default()) {
Ok(_) => panic!("truncated file was accepted by Chisel::open"),
Err(e) => e,
};
assert!(
matches!(
err,
ChiselError::InvalidPageId { .. }
| ChiselError::CorruptSuperblock { .. }
| ChiselError::FileSizeMismatch { .. }
),
"expected a typed integrity error, got {err:?}"
);
assert!(
err.is_fatal(),
"truncation rejection must be a fatal error, got {err:?}"
);
}
#[test]
fn test_recovery_superblock_pointing_past_eof_is_rejected() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
db.allocate(b"seed").unwrap();
db.commit().unwrap();
}
let sb = Superblock {
magic: page::MAGIC,
format_version: page::FORMAT_VERSION,
txn_counter: u64::MAX / 2, root_handle_table_page: page::PAGE_ID_NONE,
root_freemap_page: page::PAGE_ID_NONE,
total_pages: 1_000_000,
next_handle: 0,
page_size: PAGE_SIZE as u32,
named_roots: [crate::superblock::NamedRoot::EMPTY; crate::superblock::NAMED_ROOT_COUNT],
superblock_count: crate::superblock::DEFAULT_SUPERBLOCK_COUNT,
root_membership_index_page: page::PAGE_ID_NONE,
freemap_depth: 0,
encryption: None,
};
let buf = sb.serialize();
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&buf).unwrap();
f.seek(SeekFrom::Start(PAGE_SIZE as u64)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.sync_all().unwrap();
}
match Chisel::open(&path, Default::default()) {
Err(ChiselError::FileSizeMismatch { expected, actual }) => {
assert_eq!(expected, 1_000_000 * PAGE_SIZE as u64);
assert!(actual < expected);
}
Err(other) => panic!("expected FileSizeMismatch, got {other:?}"),
Ok(_) => panic!("expected FileSizeMismatch, open succeeded"),
}
}
#[test]
fn test_recovery_superblock_total_pages_max_is_rejected_not_panic() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
db.allocate(b"seed").unwrap();
db.commit().unwrap();
}
let sb = Superblock {
magic: page::MAGIC,
format_version: page::FORMAT_VERSION,
txn_counter: u64::MAX / 2, root_handle_table_page: page::PAGE_ID_NONE,
root_freemap_page: page::PAGE_ID_NONE,
total_pages: u64::MAX, next_handle: 0,
page_size: PAGE_SIZE as u32,
named_roots: [crate::superblock::NamedRoot::EMPTY; crate::superblock::NAMED_ROOT_COUNT],
superblock_count: crate::superblock::DEFAULT_SUPERBLOCK_COUNT,
root_membership_index_page: page::PAGE_ID_NONE,
freemap_depth: 0,
encryption: None,
};
let buf = sb.serialize();
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&buf).unwrap();
f.seek(SeekFrom::Start(PAGE_SIZE as u64)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.sync_all().unwrap();
}
match Chisel::open(&path, Default::default()) {
Err(ChiselError::FileSizeMismatch { expected, actual }) => {
assert_eq!(
expected,
u64::MAX,
"expected byte count must saturate, not wrap"
);
assert!(actual < expected);
}
Err(other) => panic!("expected FileSizeMismatch, got {other:?}"),
Ok(_) => panic!("expected FileSizeMismatch, open succeeded"),
}
}
#[test]
fn test_recovery_torn_second_superblock_falls_back_to_previous() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let h1;
let h2;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
h1 = db.allocate(b"from commit 1").unwrap();
db.commit().unwrap();
db.begin().unwrap();
h2 = db.allocate(b"from commit 2").unwrap();
db.commit().unwrap();
}
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(PAGE_SIZE as u64)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.sync_all().unwrap();
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.read(h1).unwrap(), b"from commit 1");
assert!(
db.read(h2).is_err(),
"handle from the torn commit should not be visible after recovery"
);
}
#[test]
fn test_reject_unsupported_format_version() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
{
let _db = Chisel::open(&path, Default::default()).unwrap();
}
let future_major = page::pack_format_version(page::FORMAT_MAJOR_VERSION.wrapping_add(1), 0);
let mut sb = Superblock {
magic: page::MAGIC,
format_version: future_major,
txn_counter: 5,
root_handle_table_page: page::PAGE_ID_NONE,
root_freemap_page: page::PAGE_ID_NONE,
total_pages: 2,
next_handle: 0,
page_size: PAGE_SIZE as u32,
named_roots: [crate::superblock::NamedRoot::EMPTY; crate::superblock::NAMED_ROOT_COUNT],
superblock_count: crate::superblock::DEFAULT_SUPERBLOCK_COUNT,
root_membership_index_page: page::PAGE_ID_NONE,
freemap_depth: 0,
encryption: None,
};
let buf_a = sb.serialize();
sb.txn_counter = 4;
let buf_b = sb.serialize();
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&buf_a).unwrap();
f.seek(SeekFrom::Start(PAGE_SIZE as u64)).unwrap();
f.write_all(&buf_b).unwrap();
f.sync_all().unwrap();
}
match Chisel::open(&path, Default::default()) {
Err(ChiselError::UnsupportedFormatVersion { found, expected }) => {
assert_eq!(found, future_major);
assert_eq!(expected, page::FORMAT_VERSION);
}
Err(other) => panic!("expected UnsupportedFormatVersion, got error {:?}", other),
Ok(_) => panic!("expected UnsupportedFormatVersion, but open succeeded"),
}
}
#[test]
fn test_read_takes_shared_reference() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handle = {
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
let h = db.allocate(b"via shared ref").unwrap();
db.commit().unwrap();
h
};
let db = Chisel::open(&path, Default::default()).unwrap();
fn read_only_probe(db: &Chisel, h: crate::Handle) -> Vec<u8> {
assert!(!db.is_poisoned());
let _ = db.handles().unwrap();
let _ = db.stats().unwrap();
db.read(h).unwrap()
}
assert_eq!(read_only_probe(&db, handle), b"via shared ref");
assert_eq!(read_only_probe(&db, handle), b"via shared ref");
}
#[test]
fn test_poison_recovery_by_reopen() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handle = {
let mut db = Chisel::open(&path, Default::default()).unwrap();
assert!(!db.is_poisoned());
db.begin().unwrap();
let h = db.allocate(b"survives recovery").unwrap();
db.commit().unwrap();
h
};
let db = Chisel::open(&path, Default::default()).unwrap();
assert!(!db.is_poisoned());
assert_eq!(db.read(handle).unwrap(), b"survives recovery");
}
#[test]
fn test_named_roots_survive_commit_and_reopen() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let meta_handle;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
meta_handle = db.allocate(b"meta btree root state").unwrap();
db.set_root_name("meta", meta_handle).unwrap();
db.commit().unwrap();
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.get_root_name("meta").unwrap(), Some(meta_handle));
assert_eq!(db.read(meta_handle).unwrap(), b"meta btree root state");
assert_eq!(db.get_root_name("does_not_exist").unwrap(), None);
}
#[test]
fn test_named_roots_revert_on_rollback() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
let h1 = db.allocate(b"initial").unwrap();
db.set_root_name("meta", h1).unwrap();
db.commit().unwrap();
db.begin().unwrap();
let h2 = db.allocate(b"replacement").unwrap();
db.set_root_name("meta", h2).unwrap();
assert_eq!(db.get_root_name("meta").unwrap(), Some(h2));
db.rollback().unwrap();
assert_eq!(db.get_root_name("meta").unwrap(), Some(h1));
}
#[test]
fn test_named_roots_revert_on_rollback_to_savepoint() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
let h1 = db.allocate(b"a").unwrap();
db.set_root_name("meta", h1).unwrap();
db.savepoint("sp").unwrap();
let h2 = db.allocate(b"b").unwrap();
db.set_root_name("meta", h2).unwrap();
db.set_root_name("index", h2).unwrap();
assert_eq!(db.get_root_name("meta").unwrap(), Some(h2));
assert_eq!(db.get_root_name("index").unwrap(), Some(h2));
db.rollback_to("sp").unwrap();
assert_eq!(db.get_root_name("meta").unwrap(), Some(h1));
assert_eq!(db.get_root_name("index").unwrap(), None);
db.commit().unwrap();
}
#[test]
fn test_named_roots_validation_errors() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
assert!(matches!(
db.set_root_name("", crate::Handle::from(0)),
Err(ChiselError::InvalidRootName)
));
let too_long = "a".repeat(25);
assert!(matches!(
db.set_root_name(&too_long, crate::Handle::from(0)),
Err(ChiselError::InvalidRootName)
));
assert!(matches!(
db.set_root_name("bad\0name", crate::Handle::from(0)),
Err(ChiselError::InvalidRootName)
));
let exactly_24 = "a".repeat(24);
assert!(db
.set_root_name(&exactly_24, crate::Handle::from(42))
.is_ok());
assert_eq!(
db.get_root_name(&exactly_24).unwrap(),
Some(crate::Handle::from(42))
);
db.commit().unwrap();
}
#[test]
fn test_named_roots_table_full() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
for i in 0..8 {
let name = format!("r{i}");
db.set_root_name(&name, crate::Handle::from(i as u64))
.unwrap();
}
db.set_root_name("r3", crate::Handle::from(999)).unwrap();
assert_eq!(
db.get_root_name("r3").unwrap(),
Some(crate::Handle::from(999))
);
assert!(matches!(
db.set_root_name("overflow", crate::Handle::from(1234)),
Err(ChiselError::RootNameTableFull)
));
db.clear_root_name("r0").unwrap();
assert!(db
.set_root_name("new_one", crate::Handle::from(1234))
.is_ok());
assert_eq!(
db.get_root_name("new_one").unwrap(),
Some(crate::Handle::from(1234))
);
db.commit().unwrap();
}
#[test]
fn test_named_roots_clear_is_idempotent() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
db.set_root_name("meta", crate::Handle::from(7)).unwrap();
db.clear_root_name("meta").unwrap();
assert_eq!(db.get_root_name("meta").unwrap(), None);
db.clear_root_name("meta").unwrap();
db.clear_root_name("never_set").unwrap();
db.commit().unwrap();
}
#[test]
fn test_delete_then_allocate_reuses_pages() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
let mut handles = Vec::new();
for i in 0..20 {
handles.push(db.allocate(format!("value-{i}").as_bytes()).unwrap());
}
db.commit().unwrap();
db.begin().unwrap();
for &h in &handles {
db.delete(h).unwrap();
}
db.commit().unwrap();
let pages_after_delete = db.stats().unwrap().total_pages;
db.begin().unwrap();
for i in 0..20 {
db.allocate(format!("replacement-{i}").as_bytes()).unwrap();
}
db.commit().unwrap();
let pages_after_reuse = db.stats().unwrap().total_pages;
let growth = pages_after_reuse - pages_after_delete;
assert!(
growth < 30,
"delete+reallocate grew file by {growth} pages; without R2 this would be ~40+, with R2 data pages are reused (only ht+freemap churn remains)"
);
}
#[test]
fn test_update_does_not_leak_old_inline_page() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
let handle = db.allocate(b"initial").unwrap();
db.commit().unwrap();
let pages_after_initial = db.stats().unwrap().total_pages;
for i in 0..50 {
db.begin().unwrap();
db.update(handle, format!("round-{i}").as_bytes()).unwrap();
db.commit().unwrap();
}
let pages_after_updates = db.stats().unwrap().total_pages;
let growth = pages_after_updates - pages_after_initial;
assert!(
growth < 90,
"50 updates grew the file by {growth} pages; without I9 this would be ~100+, with I9 old data pages are reused"
);
assert_eq!(db.read(handle).unwrap(), b"round-49");
}
#[test]
fn test_rollback_after_delete_does_not_free_pages() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
let handle = db.allocate(b"keep me").unwrap();
db.commit().unwrap();
db.begin().unwrap();
db.delete(handle).unwrap();
db.rollback().unwrap();
assert_eq!(db.read(handle).unwrap(), b"keep me");
db.begin().unwrap();
db.allocate(b"new value").unwrap();
db.commit().unwrap();
assert_eq!(db.read(handle).unwrap(), b"keep me");
}
#[test]
fn test_delete_many_atomicity() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
let handles: Vec<crate::Handle> = (0..10)
.map(|i| db.allocate(format!("h{i}").as_bytes()).unwrap())
.collect();
db.commit().unwrap();
db.begin().unwrap();
db.delete_many(&handles).unwrap();
db.rollback().unwrap();
for (i, &h) in handles.iter().enumerate() {
assert_eq!(db.read(h).unwrap(), format!("h{i}").as_bytes());
}
db.begin().unwrap();
db.delete_many(&handles).unwrap();
db.commit().unwrap();
for &h in &handles {
assert!(matches!(db.read(h), Err(ChiselError::InvalidHandle(_))));
}
let pages_before = db.stats().unwrap().total_pages;
db.begin().unwrap();
for i in 0..10 {
db.allocate(format!("r{i}").as_bytes()).unwrap();
}
db.commit().unwrap();
let pages_after = db.stats().unwrap().total_pages;
let growth = pages_after - pages_before;
assert!(
growth < 15,
"bulk delete_many + re-allocate grew file by {growth} pages; without reuse this would be ~20+"
);
}
#[test]
fn test_r1_small_values_pack_into_data_pages() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
let pages_before = db.stats().unwrap().total_pages;
db.begin().unwrap();
for i in 0..100 {
db.allocate(format!("v{i}").as_bytes()).unwrap();
}
db.commit().unwrap();
let pages_after = db.stats().unwrap().total_pages;
let growth = pages_after - pages_before;
assert!(
growth < 150,
"100 small values grew the file by {growth} pages; without R1 this would be ~200+, with R1 most values share data pages"
);
}
#[test]
fn test_r1_packed_values_are_readable_after_reopen() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handles: Vec<crate::Handle>;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
handles = (0..50)
.map(|i| db.allocate(format!("packed-{i}").as_bytes()).unwrap())
.collect();
db.commit().unwrap();
}
let db = Chisel::open(&path, Default::default()).unwrap();
for (i, &h) in handles.iter().enumerate() {
assert_eq!(db.read(h).unwrap(), format!("packed-{i}").as_bytes());
}
}
#[test]
fn test_r1_deleting_last_slot_frees_the_page() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
let handles: Vec<crate::Handle> = (0..5)
.map(|i| db.allocate(format!("s{i}").as_bytes()).unwrap())
.collect();
db.commit().unwrap();
let pages_after_alloc = db.stats().unwrap().total_pages;
db.begin().unwrap();
for &h in &handles {
db.delete(h).unwrap();
}
db.commit().unwrap();
db.begin().unwrap();
for i in 0..5 {
db.allocate(format!("r{i}").as_bytes()).unwrap();
}
db.commit().unwrap();
let pages_after_reuse = db.stats().unwrap().total_pages;
let growth = pages_after_reuse - pages_after_alloc;
assert!(
growth < 15,
"re-allocate after bulk delete grew file by {growth} pages; packed page should be reusable"
);
}
#[test]
fn test_r1_rollback_reverts_slot_counts() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
let h1 = db.allocate(b"survive-1").unwrap();
let h2 = db.allocate(b"survive-2").unwrap();
db.commit().unwrap();
db.begin().unwrap();
let _ = db.allocate(b"rolled-back-1").unwrap();
let _ = db.allocate(b"rolled-back-2").unwrap();
db.rollback().unwrap();
assert_eq!(db.read(h1).unwrap(), b"survive-1");
assert_eq!(db.read(h2).unwrap(), b"survive-2");
db.begin().unwrap();
let h3 = db.allocate(b"after-rollback").unwrap();
db.commit().unwrap();
assert_eq!(db.read(h3).unwrap(), b"after-rollback");
}
#[test]
fn test_freemap_persists_across_reopen() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handle_to_delete;
let pages_after_delete;
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
for i in 0..5 {
db.allocate(format!("keep-{i}").as_bytes()).unwrap();
}
handle_to_delete = db.allocate(b"delete me").unwrap();
db.commit().unwrap();
db.begin().unwrap();
db.delete(handle_to_delete).unwrap();
db.commit().unwrap();
pages_after_delete = db.stats().unwrap().total_pages;
}
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
db.allocate(b"reuse after reopen").unwrap();
db.commit().unwrap();
let pages_after_reopen_alloc = db.stats().unwrap().total_pages;
let growth = pages_after_reopen_alloc - pages_after_delete;
assert!(
growth < 3,
"after reopen, an allocation should reuse the freemap-tracked page (growth {growth})"
);
}
#[test]
fn test_r4_create_with_custom_superblock_count() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
{
let mut db = Chisel::open(&path, Options::default().superblock_count(4)).unwrap();
db.begin().unwrap();
let h = db.allocate(b"r4 payload").unwrap();
db.commit().unwrap();
assert_eq!(db.read(h).unwrap(), b"r4 payload");
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.handles().unwrap().len(), 1);
}
#[test]
fn test_r4_invalid_superblock_count_rejected() {
let file = NamedTempFile::new().unwrap();
let result = Chisel::open(file.path(), Options::default().superblock_count(1));
assert!(matches!(
result,
Err(ChiselError::InvalidSuperblockCount { value: 1 })
));
let file2 = NamedTempFile::new().unwrap();
let result = Chisel::open(file2.path(), Options::default().superblock_count(17));
assert!(matches!(
result,
Err(ChiselError::InvalidSuperblockCount { value: 17 })
));
}
#[test]
fn test_r4_n3_survives_two_consecutive_torn_writes() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handle;
{
let mut db = Chisel::open(&path, Options::default().superblock_count(3)).unwrap();
db.begin().unwrap();
handle = db.allocate(b"survives two tears").unwrap();
db.commit().unwrap(); db.begin().unwrap();
db.allocate(b"txn 2").unwrap();
db.commit().unwrap(); db.begin().unwrap();
db.allocate(b"txn 3").unwrap();
db.commit().unwrap(); }
{
let mut f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.seek(SeekFrom::Start(PAGE_SIZE as u64)).unwrap();
f.write_all(&[0u8; PAGE_SIZE]).unwrap();
f.sync_all().unwrap();
}
let db = Chisel::open(&path, Default::default()).unwrap();
assert_eq!(db.read(handle).unwrap(), b"survives two tears");
assert_eq!(db.handles().unwrap().len(), 3);
}
#[test]
fn test_r4_commits_rotate_through_all_slots() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let mut db = Chisel::open(&path, Options::default().superblock_count(3)).unwrap();
db.begin().unwrap();
db.allocate(b"first").unwrap();
db.commit().unwrap();
let baseline = db.stats().unwrap().total_pages;
db.begin().unwrap();
let h = db.allocate(b"packed").unwrap();
db.commit().unwrap();
for i in 0..4 {
db.begin().unwrap();
db.update(h, format!("update {i}").as_bytes()).unwrap();
db.commit().unwrap();
}
let after = db.stats().unwrap().total_pages;
assert!(
after - baseline < 20,
"5 commits under N=3 shouldn't grow the file significantly (baseline={baseline}, after={after})"
);
}
#[test]
fn test_r4_default_is_two_slots() {
let file = NamedTempFile::new().unwrap();
let db = Chisel::open(file.path(), Default::default()).unwrap();
assert_eq!(db.stats().unwrap().total_pages, 2);
}
#[test]
fn test_file_not_found_without_create() {
let path = std::path::PathBuf::from("/tmp/chisel_nonexistent_test.db");
let _ = fs::remove_file(&path);
let result = Chisel::open(&path, Options::default().create_if_missing(false));
assert!(
matches!(result, Err(ChiselError::FileNotFound)),
"open of nonexistent file with create_if_missing=false must be FileNotFound"
);
}
#[test]
fn corrupt_magic_surfaces_as_corrupt_superblock_not_invalid_magic() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.chisel");
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
db.allocate(b"x").unwrap();
db.commit().unwrap();
db.close().unwrap();
}
for slot in 0..crate::superblock::DEFAULT_SUPERBLOCK_COUNT as u64 {
rewrite_page_with_valid_checksum(&path, slot, |buf| {
buf[0] ^= 0xFF; });
}
match Chisel::open(&path, Default::default()) {
Err(ChiselError::CorruptSuperblock { defects }) => {
let expected = crate::superblock::DEFAULT_SUPERBLOCK_COUNT as usize;
assert_eq!(
defects.len(),
expected,
"expected exactly {expected} defects (one per real superblock slot), got {defects:?}"
);
for slot in 0..crate::superblock::DEFAULT_SUPERBLOCK_COUNT {
assert!(
defects.iter().any(|d| d.slot == slot
&& d.defect == crate::superblock::SuperblockDefect::BadMagic),
"slot {slot} should be reported BadMagic, got {defects:?}"
);
}
}
Err(e) => panic!("bad magic must surface as CorruptSuperblock, got {e:?}"),
Ok(_) => panic!("Chisel::open accepted a fully-corrupted-magic file"),
};
}
#[test]
fn test_open_rejects_page_size_mismatch() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.chisel");
{
let mut db = Chisel::open(&path, Default::default()).unwrap();
db.begin().unwrap();
db.allocate(b"x").unwrap();
db.commit().unwrap();
db.close().unwrap();
}
const WRONG_PAGE_SIZE: u32 = 4096;
for slot in 0..crate::superblock::DEFAULT_SUPERBLOCK_COUNT as u64 {
rewrite_page_with_valid_checksum(&path, slot, |buf| {
buf[48..52].copy_from_slice(&WRONG_PAGE_SIZE.to_le_bytes());
});
}
match Chisel::open(&path, Default::default()) {
Err(ChiselError::UnsupportedPageSize { stored, compiled }) => {
assert_eq!(
stored, WRONG_PAGE_SIZE,
"stored page size should be the forged value"
);
assert_eq!(
compiled, PAGE_SIZE as u32,
"compiled page size must be PAGE_SIZE"
);
}
Err(e) => panic!("expected UnsupportedPageSize, got {e:?}"),
Ok(_) => panic!("Chisel::open accepted a file with a mismatched page_size"),
}
}
#[test]
fn corrupt_crypto_header_stride_errors_not_panic() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("enc.db");
let opts = || Options::default().encryption_key(Key::Raw(Zeroizing::new(vec![0x11u8; 32])));
{
let mut db = Chisel::open(&path, opts()).unwrap();
db.begin().unwrap();
db.allocate(b"payload").unwrap();
db.commit().unwrap();
}
rewrite_page_with_valid_checksum(&path, 0, |buf| {
buf[325..329].copy_from_slice(&0u32.to_le_bytes());
});
match Chisel::open(&path, opts()) {
Err(ChiselError::CorruptSuperblock { .. }) => {}
Err(other) => panic!("expected CorruptSuperblock for a forged stride, got {other:?}"),
Ok(_) => panic!("forged-stride open unexpectedly succeeded (should have failed)"),
}
}