use citadel_buffer::pool::BufferPool;
use citadel_core::types::*;
use citadel_core::*;
use citadel_crypto::key_manager::{create_key_file, open_key_file};
use citadel_crypto::page_cipher;
use citadel_io::file_manager::*;
use citadel_io::mmap_io::MmapPageIO;
use citadel_io::traits::PageIO;
use citadel_page::page::Page;
use std::fs::File;
#[test]
fn full_encryption_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.citadel");
let key_path = dir.path().join("test.citadel-keys");
let passphrase = b"integration-test-passphrase";
let file_id: u64 = 0xDEAD_CAFE_1234;
let (key_file, keys) = create_key_file(
passphrase,
file_id,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1, )
.unwrap();
let key_buf = key_file.serialize();
std::fs::write(&key_path, key_buf).unwrap();
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = page_cipher::compute_dek_id(&keys.mac_key, &keys.dek);
let header = FileHeader::new(file_id, dek_id);
write_file_header(&io, &header).unwrap();
io.fsync().unwrap();
let mut pool = BufferPool::new(64);
let epoch = 1u32;
for i in 0..10u32 {
let mut page = Page::new(PageId(i), PageType::Leaf, TxnId(1));
let cell = format!("key-{i}:value-{i}");
page.write_cell(cell.as_bytes()).unwrap();
page.update_checksum();
let offset = page_offset(PageId(i));
ensure_file_size(&io, offset).unwrap();
pool.insert_new(PageId(i), page).unwrap();
}
assert_eq!(pool.dirty_count(), 10);
pool.flush_dirty(&io, &keys.dek, &keys.mac_key, epoch)
.unwrap();
io.fsync().unwrap();
assert_eq!(pool.dirty_count(), 0);
let raw_bytes = std::fs::read(&db_path).unwrap();
for i in 0..10u32 {
let needle = format!("key-{i}:value-{i}");
let found = raw_bytes
.windows(needle.len())
.any(|w| w == needle.as_bytes());
assert!(!found, "Plaintext found on disk for page {i}!");
}
let mut pool2 = BufferPool::new(64);
for i in 0..10u32 {
let page = pool2
.fetch(&io, PageId(i), &keys.dek, &keys.mac_key, epoch)
.unwrap();
assert_eq!(page.page_id(), PageId(i));
assert_eq!(page.page_type(), Some(PageType::Leaf));
assert_eq!(page.txn_id(), TxnId(1));
assert_eq!(page.num_cells(), 1);
assert!(page.verify_checksum());
let offset = page.cell_offset(0);
let expected = format!("key-{i}:value-{i}");
let data = page.cell_data(offset, expected.len());
assert_eq!(data, expected.as_bytes());
}
{
let tamper_offset = page_offset(PageId(5));
let mut encrypted = [0u8; PAGE_SIZE];
io.read_page(tamper_offset, &mut encrypted).unwrap();
encrypted[100] ^= 0x01; io.write_page(tamper_offset, &encrypted).unwrap();
}
let mut pool3 = BufferPool::new(64);
let result = pool3.fetch(&io, PageId(5), &keys.dek, &keys.mac_key, epoch);
assert!(matches!(result, Err(Error::PageTampered(PageId(5)))));
let page0 = pool3
.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, epoch)
.unwrap();
assert_eq!(page0.page_id(), PageId(0));
let key_bytes: [u8; KEY_FILE_SIZE] = key_buf;
let (_kf2, keys2) = open_key_file(&key_bytes, passphrase, file_id).unwrap();
assert_eq!(keys2.dek, keys.dek);
assert_eq!(keys2.mac_key, keys.mac_key);
let result = open_key_file(&key_bytes, b"wrong-password", file_id);
assert!(result.is_err());
let result = open_key_file(&key_bytes, passphrase, 0xBAAD);
assert!(matches!(result, Err(Error::KeyFileMismatch)));
}
#[test]
fn file_header_and_recovery() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("recovery.citadel");
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = [0xAAu8; MAC_SIZE];
let header = FileHeader::new(0x42, dek_id);
write_file_header(&io, &header).unwrap();
io.fsync().unwrap();
let (slot_idx, slot) = recover(&io).unwrap();
assert_eq!(slot_idx, 0);
assert_eq!(slot.txn_id, TxnId(0));
let new_slot = CommitSlot {
txn_id: TxnId(1),
tree_root: PageId(0),
tree_depth: 1,
tree_entries: 100,
catalog_root: PageId(0),
total_pages: 10,
high_water_mark: 9,
pending_free_root: PageId::INVALID,
encryption_epoch: 1,
dek_id,
checksum: 0,
merkle_root: [0u8; citadel_core::MERKLE_HASH_SIZE],
named_table_entries: Vec::new(),
};
write_commit_slot(&io, 1, &new_slot).unwrap(); io.fsync().unwrap();
write_god_byte(&io, GOD_BIT_ACTIVE_SLOT).unwrap(); io.fsync().unwrap();
let (slot_idx, slot) = recover(&io).unwrap();
assert_eq!(slot_idx, 1);
assert_eq!(slot.txn_id, TxnId(1));
assert_eq!(slot.tree_entries, 100);
}
#[test]
fn recovery_rejects_tree_root_at_high_water_mark() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("hwm.citadel");
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = [0xBBu8; MAC_SIZE];
let header = FileHeader::new(0x99, dek_id);
write_file_header(&io, &header).unwrap();
io.fsync().unwrap();
let slot = CommitSlot {
txn_id: TxnId(1),
tree_root: PageId(5),
tree_depth: 1,
tree_entries: 10,
catalog_root: PageId(0),
total_pages: 5,
high_water_mark: 5,
pending_free_root: PageId::INVALID,
encryption_epoch: 1,
dek_id,
checksum: 0,
merkle_root: [0u8; citadel_core::MERKLE_HASH_SIZE],
named_table_entries: Vec::new(),
};
write_commit_slot(&io, 0, &slot).unwrap();
io.fsync().unwrap();
let result = recover(&io);
assert!(
matches!(result, Err(Error::PageOutOfBounds(PageId(5)))),
"tree_root == high_water_mark should be rejected, got: {result:?}"
);
}
#[test]
fn recovery_rejects_pending_free_at_high_water_mark() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("hwm2.citadel");
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = [0xCCu8; MAC_SIZE];
let header = FileHeader::new(0xAA, dek_id);
write_file_header(&io, &header).unwrap();
io.fsync().unwrap();
let slot = CommitSlot {
txn_id: TxnId(1),
tree_root: PageId(1),
tree_depth: 1,
tree_entries: 10,
catalog_root: PageId(0),
total_pages: 8,
high_water_mark: 8,
pending_free_root: PageId(8),
encryption_epoch: 1,
dek_id,
checksum: 0,
merkle_root: [0u8; citadel_core::MERKLE_HASH_SIZE],
named_table_entries: Vec::new(),
};
write_commit_slot(&io, 0, &slot).unwrap();
io.fsync().unwrap();
let result = recover(&io);
assert!(
matches!(result, Err(Error::PageOutOfBounds(PageId(8)))),
"pending_free_root == high_water_mark should be rejected, got: {result:?}"
);
}
#[test]
fn sieve_eviction_dirty_never_evicted() {
use citadel_buffer::sieve::SieveCache;
let mut cache = SieveCache::<String>::new(3);
cache.insert(1, "one".into()).unwrap();
cache.insert(2, "two".into()).unwrap();
cache.insert(3, "three".into()).unwrap();
cache.set_dirty(2);
for i in 10..15 {
cache.insert(i, format!("val-{i}")).unwrap();
assert!(cache.contains(2), "dirty entry 2 should never be evicted");
assert_eq!(cache.get(2), Some(&"two".to_string()));
}
assert_eq!(cache.dirty_count(), 1);
assert!(cache.is_dirty(2));
}
#[test]
fn wrong_epoch_detected_on_fetch() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("epoch.citadel");
let (key_file, keys) = create_key_file(
b"password",
0x1111,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let _ = key_file;
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = page_cipher::compute_dek_id(&keys.mac_key, &keys.dek);
let header = FileHeader::new(0x1111, dek_id);
write_file_header(&io, &header).unwrap();
let mut page = Page::new(PageId(0), PageType::Leaf, TxnId(1));
page.update_checksum();
let mut encrypted = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut encrypted,
);
let offset = page_offset(PageId(0));
ensure_file_size(&io, offset).unwrap();
io.write_page(offset, &encrypted).unwrap();
io.fsync().unwrap();
let mut pool = BufferPool::new(64);
let result = pool.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, 2);
assert!(
matches!(result, Err(Error::PageTampered(PageId(0)))),
"wrong epoch should be detected as tampered"
);
let page_back = pool
.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, 1)
.unwrap();
assert_eq!(page_back.page_id(), PageId(0));
}
#[test]
fn buffer_pool_eviction_under_pressure() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("pressure.citadel");
let (_, keys) = create_key_file(
b"pass",
0x2222,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = page_cipher::compute_dek_id(&keys.mac_key, &keys.dek);
let header = FileHeader::new(0x2222, dek_id);
write_file_header(&io, &header).unwrap();
let epoch = 1u32;
for i in 0..50u32 {
let mut page = Page::new(PageId(i), PageType::Leaf, TxnId(1));
let cell = format!("data-{i:04}");
page.write_cell(cell.as_bytes()).unwrap();
page.update_checksum();
let offset = page_offset(PageId(i));
ensure_file_size(&io, offset).unwrap();
let mut encrypted = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(i),
epoch,
page.as_bytes(),
&mut encrypted,
);
io.write_page(offset, &encrypted).unwrap();
}
io.fsync().unwrap();
let mut pool = BufferPool::new(10);
for i in 0..50u32 {
let page = pool
.fetch(&io, PageId(i), &keys.dek, &keys.mac_key, epoch)
.unwrap();
assert_eq!(page.page_id(), PageId(i));
}
let page0 = pool
.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, epoch)
.unwrap();
assert_eq!(page0.page_id(), PageId(0));
assert_eq!(page0.num_cells(), 1);
}
#[test]
fn tamper_iv_region_detected() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("tamper_iv.citadel");
let (_, keys) = create_key_file(
b"pass",
0x3333,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = page_cipher::compute_dek_id(&keys.mac_key, &keys.dek);
let header = FileHeader::new(0x3333, dek_id);
write_file_header(&io, &header).unwrap();
let mut page = Page::new(PageId(0), PageType::Leaf, TxnId(1));
page.update_checksum();
let mut encrypted = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut encrypted,
);
let offset = page_offset(PageId(0));
ensure_file_size(&io, offset).unwrap();
io.write_page(offset, &encrypted).unwrap();
io.fsync().unwrap();
let mut tampered = [0u8; PAGE_SIZE];
io.read_page(offset, &mut tampered).unwrap();
tampered[0] ^= 0xFF; io.write_page(offset, &tampered).unwrap();
let mut pool = BufferPool::new(64);
let result = pool.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, 1);
assert!(
matches!(result, Err(Error::PageTampered(PageId(0)))),
"tampered IV should be detected by HMAC"
);
}
#[test]
fn tamper_mac_region_detected() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("tamper_mac.citadel");
let (_, keys) = create_key_file(
b"pass",
0x4444,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = page_cipher::compute_dek_id(&keys.mac_key, &keys.dek);
let header = FileHeader::new(0x4444, dek_id);
write_file_header(&io, &header).unwrap();
let mut page = Page::new(PageId(0), PageType::Leaf, TxnId(1));
page.update_checksum();
let mut encrypted = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut encrypted,
);
let offset = page_offset(PageId(0));
ensure_file_size(&io, offset).unwrap();
io.write_page(offset, &encrypted).unwrap();
io.fsync().unwrap();
let mut tampered = [0u8; PAGE_SIZE];
io.read_page(offset, &mut tampered).unwrap();
tampered[PAGE_SIZE - 1] ^= 0x01; io.write_page(offset, &tampered).unwrap();
let mut pool = BufferPool::new(64);
let result = pool.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, 1);
assert!(
matches!(result, Err(Error::PageTampered(PageId(0)))),
"tampered MAC should be detected"
);
}
#[test]
fn different_keys_produce_different_ciphertext() {
let (_, keys1) = create_key_file(
b"password1",
0x5555,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let (_, keys2) = create_key_file(
b"password2",
0x5555,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let mut page = Page::new(PageId(0), PageType::Leaf, TxnId(1));
page.update_checksum();
let mut enc1 = [0u8; PAGE_SIZE];
let mut enc2 = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys1.dek,
&keys1.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut enc1,
);
page_cipher::encrypt_page(
&keys2.dek,
&keys2.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut enc2,
);
assert_ne!(
&enc1[16..PAGE_SIZE - 32],
&enc2[16..PAGE_SIZE - 32],
"different DEKs must produce different ciphertext"
);
assert_ne!(
&enc1[PAGE_SIZE - 32..],
&enc2[PAGE_SIZE - 32..],
"different MAC keys must produce different MACs"
);
}
#[test]
fn same_page_encrypted_differently_each_write() {
let (_, keys) = create_key_file(
b"pass",
0x6666,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let mut page = Page::new(PageId(0), PageType::Leaf, TxnId(1));
page.update_checksum();
let mut enc1 = [0u8; PAGE_SIZE];
let mut enc2 = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut enc1,
);
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut enc2,
);
assert_ne!(
&enc1[0..16],
&enc2[0..16],
"IVs should be different (random)"
);
assert_ne!(
&enc1[16..PAGE_SIZE - 32],
&enc2[16..PAGE_SIZE - 32],
"ciphertext should differ due to different random IV"
);
}
#[test]
fn cache_hit_returns_identical_data() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("cache_hit.citadel");
let (_, keys) = create_key_file(
b"pass",
0x7777,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = page_cipher::compute_dek_id(&keys.mac_key, &keys.dek);
let header = FileHeader::new(0x7777, dek_id);
write_file_header(&io, &header).unwrap();
let mut page = Page::new(PageId(0), PageType::Leaf, TxnId(1));
let cell = b"test-cell-data";
page.write_cell(cell).unwrap();
page.update_checksum();
let offset = page_offset(PageId(0));
ensure_file_size(&io, offset).unwrap();
let mut encrypted = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut encrypted,
);
io.write_page(offset, &encrypted).unwrap();
io.fsync().unwrap();
let mut pool = BufferPool::new(64);
let p1 = pool
.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, 1)
.unwrap()
.clone();
let p2 = pool
.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, 1)
.unwrap();
assert_eq!(p1.page_id(), p2.page_id());
assert_eq!(p1.num_cells(), p2.num_cells());
assert_eq!(p1.as_bytes(), p2.as_bytes());
}
#[test]
fn multiple_page_types_all_encrypted() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("types.citadel");
let (_, keys) = create_key_file(
b"pass",
0x8888,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = page_cipher::compute_dek_id(&keys.mac_key, &keys.dek);
let header = FileHeader::new(0x8888, dek_id);
write_file_header(&io, &header).unwrap();
let epoch = 1u32;
let page_types = [(PageId(0), PageType::Leaf), (PageId(1), PageType::Branch)];
for &(page_id, page_type) in &page_types {
let mut page = Page::new(page_id, page_type, TxnId(1));
page.update_checksum();
let offset = page_offset(page_id);
ensure_file_size(&io, offset).unwrap();
let mut encrypted = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
page_id,
epoch,
page.as_bytes(),
&mut encrypted,
);
io.write_page(offset, &encrypted).unwrap();
}
io.fsync().unwrap();
let mut pool = BufferPool::new(64);
for &(page_id, page_type) in &page_types {
let page = pool
.fetch(&io, page_id, &keys.dek, &keys.mac_key, epoch)
.unwrap();
assert_eq!(
page.page_type(),
Some(page_type),
"page type should survive encrypt/decrypt for {:?}",
page_id
);
}
}
#[test]
fn page_swap_attack_detected() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("swap.citadel");
let (_, keys) = create_key_file(
b"pass",
0xAAAA,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = page_cipher::compute_dek_id(&keys.mac_key, &keys.dek);
let header = FileHeader::new(0xAAAA, dek_id);
write_file_header(&io, &header).unwrap();
let epoch = 1u32;
for i in 0..2u32 {
let mut page = Page::new(PageId(i), PageType::Leaf, TxnId(1));
let cell = format!("page-{i}-data");
page.write_cell(cell.as_bytes()).unwrap();
page.update_checksum();
let offset = page_offset(PageId(i));
ensure_file_size(&io, offset).unwrap();
let mut encrypted = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(i),
epoch,
page.as_bytes(),
&mut encrypted,
);
io.write_page(offset, &encrypted).unwrap();
}
io.fsync().unwrap();
let offset0 = page_offset(PageId(0));
let offset1 = page_offset(PageId(1));
let mut raw0 = [0u8; PAGE_SIZE];
let mut raw1 = [0u8; PAGE_SIZE];
io.read_page(offset0, &mut raw0).unwrap();
io.read_page(offset1, &mut raw1).unwrap();
io.write_page(offset0, &raw1).unwrap();
io.write_page(offset1, &raw0).unwrap();
io.fsync().unwrap();
let mut pool = BufferPool::new(64);
let result0 = pool.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, epoch);
assert!(
matches!(result0, Err(Error::PageTampered(PageId(0)))),
"page swap attack should be detected for page 0: {:?}",
result0
);
let result1 = pool.fetch(&io, PageId(1), &keys.dek, &keys.mac_key, epoch);
assert!(
matches!(result1, Err(Error::PageTampered(PageId(1)))),
"page swap attack should be detected for page 1: {:?}",
result1
);
}
#[test]
fn iv_uniqueness_across_many_writes() {
let (_, keys) = create_key_file(
b"pass",
0xBBBB,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let mut page = Page::new(PageId(0), PageType::Leaf, TxnId(1));
page.update_checksum();
let mut seen_ivs = std::collections::HashSet::new();
let num_encryptions = 1000;
for _ in 0..num_encryptions {
let mut encrypted = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut encrypted,
);
let iv: [u8; 16] = encrypted[0..16].try_into().unwrap();
let is_new = seen_ivs.insert(iv);
assert!(
is_new,
"IV must be unique across all encryptions - duplicate detected!"
);
}
assert_eq!(
seen_ivs.len(),
num_encryptions,
"all {num_encryptions} IVs should be unique"
);
}
#[test]
fn ctr_bit_flip_caught_before_decrypt() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("bitflip.citadel");
let (_, keys) = create_key_file(
b"pass",
0xCCCC,
CipherId::Aes256Ctr,
KdfAlgorithm::Argon2id,
64,
1,
1,
)
.unwrap();
let file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&db_path)
.unwrap();
let io = MmapPageIO::try_new(file).unwrap();
let dek_id = page_cipher::compute_dek_id(&keys.mac_key, &keys.dek);
let header = FileHeader::new(0xCCCC, dek_id);
write_file_header(&io, &header).unwrap();
let mut page = Page::new(PageId(0), PageType::Leaf, TxnId(1));
page.write_cell(b"sensitive-data").unwrap();
page.update_checksum();
let offset = page_offset(PageId(0));
ensure_file_size(&io, offset).unwrap();
let mut encrypted = [0u8; PAGE_SIZE];
page_cipher::encrypt_page(
&keys.dek,
&keys.mac_key,
PageId(0),
1,
page.as_bytes(),
&mut encrypted,
);
io.write_page(offset, &encrypted).unwrap();
io.fsync().unwrap();
let ciphertext_offsets = [16, 100, 500, 4000, PAGE_SIZE - 33];
for &flip_offset in &ciphertext_offsets {
let mut tampered = [0u8; PAGE_SIZE];
io.read_page(offset, &mut tampered).unwrap();
tampered[flip_offset] ^= 0x01; io.write_page(offset, &tampered).unwrap();
let mut pool = BufferPool::new(64);
let result = pool.fetch(&io, PageId(0), &keys.dek, &keys.mac_key, 1);
assert!(
matches!(result, Err(Error::PageTampered(PageId(0)))),
"bit flip at offset {flip_offset} should be detected by HMAC"
);
io.write_page(offset, &encrypted).unwrap();
}
}