use std::fmt;
use fsqlite_types::PageSize;
pub const JOURNAL_MAGIC: [u8; 8] = [0xd9, 0xd5, 0x05, 0xf9, 0x20, 0xa1, 0x63, 0xd7];
pub const JOURNAL_HEADER_SIZE: usize = 28;
pub const CHECKSUM_STRIDE: usize = 200;
pub const PENDING_BYTE_OFFSET: u64 = 0x4000_0000;
#[must_use]
pub const fn lock_byte_page(page_size: PageSize) -> u32 {
#[expect(clippy::cast_possible_truncation)]
let offset_u32 = PENDING_BYTE_OFFSET as u32;
(offset_u32 / page_size.get()) + 1
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JournalHeader {
pub page_count: i32,
pub nonce: u32,
pub initial_db_size: u32,
pub sector_size: u32,
pub page_size: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JournalError {
HeaderTooShort { needed: usize, actual: usize },
BadMagic { actual: [u8; 8] },
RecordTooShort { needed: usize, actual: usize },
ChecksumMismatch {
page_number: u32,
expected: u32,
actual: u32,
},
InvalidPageSize { raw: u32 },
InvalidPageCount { raw: i32 },
}
impl fmt::Display for JournalError {
#[allow(clippy::too_many_lines)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::HeaderTooShort { needed, actual } => {
write!(
f,
"journal header too short: need {needed} bytes, got {actual}"
)
}
Self::BadMagic { actual } => {
write!(f, "journal magic mismatch: got {actual:02x?}")
}
Self::RecordTooShort { needed, actual } => {
write!(
f,
"journal record too short: need {needed} bytes, got {actual}"
)
}
Self::ChecksumMismatch {
page_number,
expected,
actual,
} => {
write!(
f,
"journal checksum mismatch for page {page_number}: expected {expected:#010x}, got {actual:#010x}"
)
}
Self::InvalidPageSize { raw } => {
write!(f, "invalid page size in journal header: {raw}")
}
Self::InvalidPageCount { raw } => {
write!(f, "invalid page count in journal header: {raw}")
}
}
}
}
impl JournalHeader {
#[must_use]
pub fn encode(&self) -> [u8; JOURNAL_HEADER_SIZE] {
let mut buf = [0u8; JOURNAL_HEADER_SIZE];
buf[0..8].copy_from_slice(&JOURNAL_MAGIC);
buf[8..12].copy_from_slice(&self.page_count.to_be_bytes());
buf[12..16].copy_from_slice(&self.nonce.to_be_bytes());
buf[16..20].copy_from_slice(&self.initial_db_size.to_be_bytes());
buf[20..24].copy_from_slice(&self.sector_size.to_be_bytes());
buf[24..28].copy_from_slice(&self.page_size.to_be_bytes());
buf
}
#[must_use]
pub fn encode_padded(&self) -> Vec<u8> {
let pad_size = (self.sector_size as usize).max(JOURNAL_HEADER_SIZE);
let mut buf = vec![0u8; pad_size];
let header = self.encode();
buf[..JOURNAL_HEADER_SIZE].copy_from_slice(&header);
buf
}
pub fn decode(buf: &[u8]) -> Result<Self, JournalError> {
if buf.len() < JOURNAL_HEADER_SIZE {
return Err(JournalError::HeaderTooShort {
needed: JOURNAL_HEADER_SIZE,
actual: buf.len(),
});
}
let mut magic = [0u8; 8];
magic.copy_from_slice(&buf[0..8]);
if magic != JOURNAL_MAGIC {
return Err(JournalError::BadMagic { actual: magic });
}
let page_count = i32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]);
let nonce = u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]);
let initial_db_size = u32::from_be_bytes([buf[16], buf[17], buf[18], buf[19]]);
let sector_size = u32::from_be_bytes([buf[20], buf[21], buf[22], buf[23]]);
let page_size = u32::from_be_bytes([buf[24], buf[25], buf[26], buf[27]]);
if page_count < -1 {
return Err(JournalError::InvalidPageCount { raw: page_count });
}
if PageSize::new(page_size).is_none() {
return Err(JournalError::InvalidPageSize { raw: page_size });
}
Ok(Self {
page_count,
nonce,
initial_db_size,
sector_size,
page_size,
})
}
#[must_use]
pub fn compute_page_count_from_file_size(&self, file_size: u64) -> u32 {
let header_padded = u64::from(self.sector_size).max(JOURNAL_HEADER_SIZE as u64);
let record_size = 4 + u64::from(self.page_size) + 4;
if file_size <= header_padded {
return 0;
}
let count = (file_size - header_padded) / record_size;
#[expect(clippy::cast_possible_truncation)]
let result = count as u32;
result
}
}
#[must_use]
pub fn journal_checksum(data: &[u8], nonce: u32) -> u32 {
let page_size = data.len();
let mut sum = nonce;
if page_size >= CHECKSUM_STRIDE {
let mut i = page_size - CHECKSUM_STRIDE;
while i > 0 {
sum = sum.wrapping_add(u32::from(data[i]));
if i < CHECKSUM_STRIDE {
break;
}
i -= CHECKSUM_STRIDE;
}
}
sum
}
#[must_use]
pub const fn checksum_sample_count(page_size: usize) -> usize {
if page_size < CHECKSUM_STRIDE {
return 0;
}
let mut count = 0;
let mut i = page_size - CHECKSUM_STRIDE;
while i > 0 {
count += 1;
if i < CHECKSUM_STRIDE {
break;
}
i -= CHECKSUM_STRIDE;
}
count
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JournalPageRecord {
pub page_number: u32,
pub content: Vec<u8>,
pub checksum: u32,
}
impl JournalPageRecord {
#[must_use]
pub fn new(page_number: u32, content: Vec<u8>, nonce: u32) -> Self {
let checksum = journal_checksum(&content, nonce);
Self {
page_number,
content,
checksum,
}
}
#[must_use]
pub fn encoded_size(&self) -> usize {
4 + self.content.len() + 4
}
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(self.encoded_size());
buf.extend_from_slice(&self.page_number.to_be_bytes());
buf.extend_from_slice(&self.content);
buf.extend_from_slice(&self.checksum.to_be_bytes());
buf
}
pub fn decode(buf: &[u8], page_size: u32) -> Result<Self, JournalError> {
let needed = 4 + page_size as usize + 4;
if buf.len() < needed {
return Err(JournalError::RecordTooShort {
needed,
actual: buf.len(),
});
}
let page_number = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
let content = buf[4..4 + page_size as usize].to_vec();
let cksum_offset = 4 + page_size as usize;
let checksum = u32::from_be_bytes([
buf[cksum_offset],
buf[cksum_offset + 1],
buf[cksum_offset + 2],
buf[cksum_offset + 3],
]);
Ok(Self {
page_number,
content,
checksum,
})
}
pub fn verify_checksum(&self, nonce: u32) -> Result<(), JournalError> {
let expected = journal_checksum(&self.content, nonce);
if expected == self.checksum {
Ok(())
} else {
Err(JournalError::ChecksumMismatch {
page_number: self.page_number,
expected,
actual: self.checksum,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_page_size_valid_powers_of_two() {
let valid = [512, 1024, 2048, 4096, 8192, 16384, 32768, 65536];
for &size in &valid {
assert!(
PageSize::new(size).is_some(),
"page size {size} should be valid"
);
}
}
#[test]
fn test_page_size_invalid_rejected() {
let invalid = [0, 1, 256, 511, 513, 3000, 4095, 4097, 131_072];
for &size in &invalid {
assert!(
PageSize::new(size).is_none(),
"page size {size} should be rejected"
);
}
}
#[test]
fn test_page_size_65536_encoding() {
let ps = PageSize::new(65536).expect("65536 is valid");
assert_eq!(ps.get(), 65536);
assert!(PageSize::new(1).is_none());
}
#[test]
fn test_page_size_default_4096() {
assert_eq!(PageSize::default().get(), 4096);
}
#[test]
fn test_lock_byte_page_4096() {
let ps = PageSize::new(4096).unwrap();
assert_eq!(lock_byte_page(ps), 262_145);
}
#[test]
fn test_lock_byte_page_512() {
let ps = PageSize::new(512).unwrap();
assert_eq!(lock_byte_page(ps), 2_097_153);
}
#[test]
fn test_lock_byte_page_65536() {
let ps = PageSize::new(65536).unwrap();
assert_eq!(lock_byte_page(ps), 16_385);
}
#[test]
fn test_lock_byte_page_1024() {
let ps = PageSize::new(1024).unwrap();
assert_eq!(lock_byte_page(ps), 1_048_577);
}
#[test]
fn test_journal_header_magic() {
assert_eq!(
JOURNAL_MAGIC,
[0xd9, 0xd5, 0x05, 0xf9, 0x20, 0xa1, 0x63, 0xd7]
);
}
#[test]
fn test_journal_header_encode_decode() {
let header = JournalHeader {
page_count: 10,
nonce: 0xDEAD_BEEF,
initial_db_size: 50,
sector_size: 512,
page_size: 4096,
};
let encoded = header.encode();
assert_eq!(encoded.len(), JOURNAL_HEADER_SIZE);
assert_eq!(&encoded[0..8], &JOURNAL_MAGIC);
let decoded = JournalHeader::decode(&encoded).expect("decode should succeed");
assert_eq!(decoded, header);
}
#[test]
fn test_journal_header_decode_too_short() {
let buf = [0u8; 20];
let err = JournalHeader::decode(&buf).unwrap_err();
assert!(matches!(
err,
JournalError::HeaderTooShort {
needed: 28,
actual: 20
}
));
}
#[test]
fn test_journal_header_decode_bad_magic() {
let mut buf = [0u8; 28];
buf[0..8].copy_from_slice(&[0, 1, 2, 3, 4, 5, 6, 7]);
buf[24..28].copy_from_slice(&4096u32.to_be_bytes());
let err = JournalHeader::decode(&buf).unwrap_err();
assert!(matches!(err, JournalError::BadMagic { .. }));
}
#[test]
fn test_journal_header_decode_invalid_page_size() {
let header = JournalHeader {
page_count: 1,
nonce: 0,
initial_db_size: 1,
sector_size: 512,
page_size: 4096,
};
let mut encoded = header.encode();
encoded[24..28].copy_from_slice(&3000u32.to_be_bytes());
let err = JournalHeader::decode(&encoded).unwrap_err();
assert!(matches!(err, JournalError::InvalidPageSize { raw: 3000 }));
}
#[test]
fn test_journal_header_rejects_invalid_negative_page_count() {
let header = JournalHeader {
page_count: -2,
nonce: 0,
initial_db_size: 1,
sector_size: 512,
page_size: 4096,
};
let err = JournalHeader::decode(&header.encode()).unwrap_err();
assert!(matches!(err, JournalError::InvalidPageCount { raw: -2 }));
}
#[test]
fn test_journal_header_sector_padding() {
let header = JournalHeader {
page_count: 5,
nonce: 42,
initial_db_size: 100,
sector_size: 512,
page_size: 4096,
};
let padded = header.encode_padded();
assert_eq!(padded.len(), 512, "padded to sector_size");
assert_eq!(&padded[0..8], &JOURNAL_MAGIC);
assert!(padded[28..].iter().all(|&b| b == 0));
}
#[test]
fn test_journal_header_sector_padding_small_sector() {
let header = JournalHeader {
page_count: 1,
nonce: 0,
initial_db_size: 1,
sector_size: 16, page_size: 512,
};
let padded = header.encode_padded();
assert_eq!(padded.len(), JOURNAL_HEADER_SIZE);
}
#[test]
fn test_journal_page_count_minus_one() {
let header = JournalHeader {
page_count: -1,
nonce: 0,
initial_db_size: 10,
sector_size: 512,
page_size: 4096,
};
let computed = header.compute_page_count_from_file_size(12824);
assert_eq!(computed, 3);
}
#[test]
fn test_journal_page_count_from_empty() {
let header = JournalHeader {
page_count: -1,
nonce: 0,
initial_db_size: 0,
sector_size: 512,
page_size: 4096,
};
assert_eq!(header.compute_page_count_from_file_size(100), 0);
assert_eq!(header.compute_page_count_from_file_size(512), 0);
}
#[test]
fn test_journal_checksum_algorithm() {
let mut data = vec![0u8; 4096];
data[3896] = 1;
data[3696] = 2;
data[3496] = 3;
data[0] = 0xFF;
let nonce = 100;
let cksum = journal_checksum(&data, nonce);
assert_eq!(cksum, nonce + 1 + 2 + 3);
}
#[test]
fn test_journal_checksum_data0_never_sampled() {
let mut data = vec![0u8; 4096];
let nonce = 0;
let cksum_a = journal_checksum(&data, nonce);
data[0] = 0xFF;
let cksum_b = journal_checksum(&data, nonce);
assert_eq!(cksum_a, cksum_b, "data[0] must NOT be sampled");
}
#[test]
fn test_journal_checksum_sample_count_4096() {
assert_eq!(checksum_sample_count(4096), 20);
}
#[test]
fn test_journal_checksum_sample_count_512() {
assert_eq!(checksum_sample_count(512), 2);
}
#[test]
fn test_journal_checksum_sample_count_1024() {
assert_eq!(checksum_sample_count(1024), 5);
}
#[test]
fn test_journal_checksum_small_page() {
assert_eq!(checksum_sample_count(100), 0);
let data = vec![0u8; 100];
assert_eq!(journal_checksum(&data, 42), 42);
}
#[test]
fn test_journal_page_record_encode_decode() {
let content = vec![0xAB; 4096];
let nonce = 0x1234_5678;
let record = JournalPageRecord::new(3, content.clone(), nonce);
assert_eq!(record.page_number, 3);
assert_eq!(record.content, content);
let encoded = record.encode();
assert_eq!(encoded.len(), 4 + 4096 + 4);
assert_eq!(&encoded[0..4], &3u32.to_be_bytes());
let decoded = JournalPageRecord::decode(&encoded, 4096).expect("decode ok");
assert_eq!(decoded.page_number, record.page_number);
assert_eq!(decoded.content, record.content);
assert_eq!(decoded.checksum, record.checksum);
}
#[test]
fn test_journal_page_record_checksum_verify() {
let content = vec![42u8; 4096];
let nonce = 99;
let record = JournalPageRecord::new(1, content, nonce);
assert!(record.verify_checksum(nonce).is_ok());
}
#[test]
fn test_journal_page_record_checksum_mismatch() {
let content = vec![42u8; 4096];
let nonce = 99;
let record = JournalPageRecord::new(1, content, nonce);
let err = record.verify_checksum(nonce + 1).unwrap_err();
assert!(matches!(
err,
JournalError::ChecksumMismatch { page_number: 1, .. }
));
}
#[test]
fn test_journal_page_record_corruption_detected() {
let content = vec![0u8; 4096];
let nonce = 100;
let mut record = JournalPageRecord::new(5, content, nonce);
record.content[3896] = 0xFF;
let err = record.verify_checksum(nonce).unwrap_err();
assert!(matches!(
err,
JournalError::ChecksumMismatch { page_number: 5, .. }
));
}
#[test]
fn test_journal_page_record_decode_too_short() {
let buf = [0u8; 10];
let err = JournalPageRecord::decode(&buf, 4096).unwrap_err();
assert!(matches!(
err,
JournalError::RecordTooShort {
needed: 4104,
actual: 10
}
));
}
#[test]
fn test_journal_error_display() {
let err = JournalError::HeaderTooShort {
needed: 28,
actual: 10,
};
assert!(err.to_string().contains("28"));
assert!(err.to_string().contains("10"));
let err = JournalError::BadMagic { actual: [0; 8] };
assert!(err.to_string().contains("magic"));
let err = JournalError::ChecksumMismatch {
page_number: 7,
expected: 100,
actual: 200,
};
let s = err.to_string();
assert!(s.contains('7'));
assert!(s.contains("checksum"));
let err = JournalError::InvalidPageSize { raw: 3000 };
assert!(err.to_string().contains("3000"));
}
#[test]
fn test_journal_full_roundtrip() {
let header = JournalHeader {
page_count: 2,
nonce: 0xCAFE_BABE,
initial_db_size: 100,
sector_size: 512,
page_size: 4096,
};
let header_bytes = header.encode_padded();
assert_eq!(header_bytes.len(), 512);
let page1_content: Vec<u8> = (0u8..=255).cycle().take(4096).collect();
let page2_content = vec![0xFF; 4096];
let rec1 = JournalPageRecord::new(1, page1_content.clone(), header.nonce);
let rec2 = JournalPageRecord::new(42, page2_content.clone(), header.nonce);
let rec1_bytes = rec1.encode();
let rec2_bytes = rec2.encode();
let mut journal_file = header_bytes;
journal_file.extend_from_slice(&rec1_bytes);
journal_file.extend_from_slice(&rec2_bytes);
let decoded_header = JournalHeader::decode(&journal_file).expect("header ok");
assert_eq!(decoded_header, header);
let offset1 = 512;
let decoded_rec1 =
JournalPageRecord::decode(&journal_file[offset1..], 4096).expect("rec1 ok");
assert_eq!(decoded_rec1.page_number, 1);
assert_eq!(decoded_rec1.content, page1_content);
decoded_rec1
.verify_checksum(header.nonce)
.expect("rec1 checksum ok");
let offset2 = 512 + 4104;
let decoded_rec2 =
JournalPageRecord::decode(&journal_file[offset2..], 4096).expect("rec2 ok");
assert_eq!(decoded_rec2.page_number, 42);
assert_eq!(decoded_rec2.content, page2_content);
decoded_rec2
.verify_checksum(header.nonce)
.expect("rec2 checksum ok");
}
#[test]
fn test_journal_error_display_invalid_page_count() {
let err = JournalError::InvalidPageCount { raw: -5 };
let s = err.to_string();
assert!(s.contains("-5"));
assert!(s.contains("page count"));
}
#[test]
fn test_journal_page_record_encoded_size() {
let record = JournalPageRecord::new(1, vec![0u8; 4096], 0);
assert_eq!(record.encoded_size(), 4 + 4096 + 4);
let small = JournalPageRecord::new(2, vec![0u8; 512], 0);
assert_eq!(small.encoded_size(), 4 + 512 + 4);
assert_eq!(record.encode().len(), record.encoded_size());
}
#[test]
fn test_compute_page_count_truncated_record() {
let header = JournalHeader {
page_count: -1,
nonce: 0,
initial_db_size: 10,
sector_size: 512,
page_size: 4096,
};
let record_size = 4 + 4096 + 4;
let full_3 = 512 + 3 * record_size as u64;
assert_eq!(header.compute_page_count_from_file_size(full_3), 3);
let partial = full_3 + record_size as u64 / 2;
assert_eq!(
header.compute_page_count_from_file_size(partial),
3,
"partial trailing record must not count"
);
}
#[test]
fn test_lock_byte_page_matches_pending_byte_formula() {
for &raw in &[512u32, 1024, 4096, 65536] {
let ps = PageSize::new(raw).unwrap();
let expected = (PENDING_BYTE_OFFSET / u64::from(raw)) as u32 + 1;
assert_eq!(lock_byte_page(ps), expected);
}
}
#[test]
fn test_journal_header_minus_one_page_count_roundtrip() {
let header = JournalHeader {
page_count: -1,
nonce: 0xABCD,
initial_db_size: 5,
sector_size: 512,
page_size: 4096,
};
let decoded = JournalHeader::decode(&header.encode()).expect("decode");
assert_eq!(decoded.page_count, -1);
assert_eq!(decoded, header);
}
}