#![forbid(unsafe_code)]
use crate::error::{Error, Result};
use crate::pager::checksum::crc32c;
use crate::pager::page::{Page, PAGE_SIZE};
pub const MAGIC: [u8; 4] = *b"OBJF";
pub const FORMAT_MAJOR: u16 = 1;
pub(crate) const SUPPORTED_FORMAT_MAJORS: &[u16] = &[0, 1];
#[must_use]
pub(crate) fn is_supported_format_major(format_major: u16) -> bool {
SUPPORTED_FORMAT_MAJORS.contains(&format_major)
}
pub const FORMAT_MINOR: u16 = 2;
pub const FEATURE_FLAG_COMPRESSION: u32 = 1 << 0;
pub const FEATURE_FLAG_ENCRYPTION: u32 = 1 << 1;
pub const FEATURE_FLAGS_KNOWN: u32 = FEATURE_FLAG_COMPRESSION | FEATURE_FLAG_ENCRYPTION;
const PAGE_SIZE_U16: u16 = 4096;
const _: () = assert!(PAGE_SIZE_U16 as usize == PAGE_SIZE);
const OFF_MAGIC: usize = 0;
const OFF_FORMAT_MAJOR: usize = 4;
const OFF_FORMAT_MINOR: usize = 6;
const OFF_PAGE_SIZE: usize = 8;
const OFF_FEATURE_FLAGS: usize = 10;
const OFF_PAGE_COUNT: usize = 16;
const OFF_ROOT_CATALOG: usize = 24;
const OFF_FREELIST_HEAD: usize = 32;
const OFF_WAL_SALT: usize = 40;
const OFF_FILE_UUID: usize = 56;
const OFF_KDF_SALT: usize = 72;
const OFF_HEADER_CRC: usize = PAGE_SIZE - 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileHeader {
pub format_major: u16,
pub format_minor: u16,
pub page_size: u16,
pub feature_flags: u32,
pub page_count: u64,
pub root_catalog: u64,
pub freelist_head: u64,
pub wal_salt: [u8; 16],
pub file_uuid: [u8; 16],
pub kdf_salt: [u8; 32],
}
impl FileHeader {
#[must_use]
pub const fn new_empty() -> Self {
Self {
format_major: FORMAT_MAJOR,
format_minor: FORMAT_MINOR,
page_size: PAGE_SIZE_U16,
feature_flags: 0,
page_count: 1,
root_catalog: 0,
freelist_head: 0,
wal_salt: [0; 16],
file_uuid: [0; 16],
kdf_salt: [0; 32],
}
}
#[must_use]
pub const fn new_empty_with_compression() -> Self {
Self {
format_major: FORMAT_MAJOR,
format_minor: FORMAT_MINOR,
page_size: PAGE_SIZE_U16,
feature_flags: FEATURE_FLAG_COMPRESSION,
page_count: 1,
root_catalog: 0,
freelist_head: 0,
wal_salt: [0; 16],
file_uuid: [0; 16],
kdf_salt: [0; 32],
}
}
#[must_use]
pub const fn new_empty_with_encryption(kdf_salt: [u8; 32]) -> Self {
Self {
format_major: FORMAT_MAJOR,
format_minor: FORMAT_MINOR,
page_size: PAGE_SIZE_U16,
feature_flags: FEATURE_FLAG_ENCRYPTION,
page_count: 1,
root_catalog: 0,
freelist_head: 0,
wal_salt: [0; 16],
file_uuid: [0; 16],
kdf_salt,
}
}
#[must_use]
pub const fn new_empty_with_encryption_and_compression(kdf_salt: [u8; 32]) -> Self {
Self {
format_major: FORMAT_MAJOR,
format_minor: FORMAT_MINOR,
page_size: PAGE_SIZE_U16,
feature_flags: FEATURE_FLAG_COMPRESSION | FEATURE_FLAG_ENCRYPTION,
page_count: 1,
root_catalog: 0,
freelist_head: 0,
wal_salt: [0; 16],
file_uuid: [0; 16],
kdf_salt,
}
}
}
pub fn encode_header(header: &FileHeader, page: &mut Page) {
debug_assert_eq!(
header.page_size as usize, PAGE_SIZE,
"every supported format major fixes PAGE_SIZE at 4096",
);
debug_assert!(
SUPPORTED_FORMAT_MAJORS.contains(&header.format_major),
"encoder only writes a format major this build supports",
);
let buf = page.as_bytes_mut();
buf.fill(0);
buf[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&MAGIC);
buf[OFF_FORMAT_MAJOR..OFF_FORMAT_MAJOR + 2].copy_from_slice(&header.format_major.to_le_bytes());
buf[OFF_FORMAT_MINOR..OFF_FORMAT_MINOR + 2].copy_from_slice(&header.format_minor.to_le_bytes());
buf[OFF_PAGE_SIZE..OFF_PAGE_SIZE + 2].copy_from_slice(&header.page_size.to_le_bytes());
buf[OFF_FEATURE_FLAGS..OFF_FEATURE_FLAGS + 4]
.copy_from_slice(&header.feature_flags.to_le_bytes());
buf[OFF_PAGE_COUNT..OFF_PAGE_COUNT + 8].copy_from_slice(&header.page_count.to_le_bytes());
buf[OFF_ROOT_CATALOG..OFF_ROOT_CATALOG + 8].copy_from_slice(&header.root_catalog.to_le_bytes());
buf[OFF_FREELIST_HEAD..OFF_FREELIST_HEAD + 8]
.copy_from_slice(&header.freelist_head.to_le_bytes());
buf[OFF_WAL_SALT..OFF_WAL_SALT + 16].copy_from_slice(&header.wal_salt);
buf[OFF_FILE_UUID..OFF_FILE_UUID + 16].copy_from_slice(&header.file_uuid);
buf[OFF_KDF_SALT..OFF_KDF_SALT + 32].copy_from_slice(&header.kdf_salt);
let crc = crc32c(&buf[..OFF_HEADER_CRC]);
buf[OFF_HEADER_CRC..OFF_HEADER_CRC + 4].copy_from_slice(&crc.to_le_bytes());
}
pub fn decode_header(page: &Page) -> Result<FileHeader> {
let buf = page.as_bytes();
if buf[OFF_MAGIC..OFF_MAGIC + 4] != MAGIC {
return Err(Error::InvalidFormat {
reason: "magic bytes are not OBJF",
});
}
let format_major = u16::from_le_bytes(read_array(buf, OFF_FORMAT_MAJOR));
if !SUPPORTED_FORMAT_MAJORS.contains(&format_major) {
return Err(Error::InvalidFormat {
reason: "format-major version not supported",
});
}
let format_minor = u16::from_le_bytes(read_array::<2>(buf, OFF_FORMAT_MINOR));
if !is_supported_minor(format_major, format_minor) {
return Err(Error::InvalidFormat {
reason: "format-minor not valid for the file's format-major",
});
}
let page_size = u16::from_le_bytes(read_array(buf, OFF_PAGE_SIZE));
if usize::from(page_size) != PAGE_SIZE {
return Err(Error::InvalidFormat {
reason: "page size does not match this build",
});
}
let stored_crc = u32::from_le_bytes(read_array::<4>(buf, OFF_HEADER_CRC));
let computed_crc = crc32c(&buf[..OFF_HEADER_CRC]);
if stored_crc != computed_crc {
return Err(Error::Corruption { page_id: 0 });
}
let feature_flags = u32::from_le_bytes(read_array::<4>(buf, OFF_FEATURE_FLAGS));
if feature_flags & !FEATURE_FLAGS_KNOWN != 0 {
return Err(Error::InvalidFormat {
reason: "unknown feature_flags bit set on page-0 header",
});
}
let reserved_after_flags =
u16::from_le_bytes([buf[OFF_FEATURE_FLAGS + 4], buf[OFF_FEATURE_FLAGS + 5]]);
if reserved_after_flags != 0 {
return Err(Error::InvalidFormat {
reason: "reserved bytes after feature_flags must be zero",
});
}
Ok(FileHeader {
format_major,
format_minor,
page_size,
feature_flags,
page_count: u64::from_le_bytes(read_array(buf, OFF_PAGE_COUNT)),
root_catalog: u64::from_le_bytes(read_array(buf, OFF_ROOT_CATALOG)),
freelist_head: u64::from_le_bytes(read_array(buf, OFF_FREELIST_HEAD)),
wal_salt: read_array(buf, OFF_WAL_SALT),
file_uuid: read_array(buf, OFF_FILE_UUID),
kdf_salt: read_array(buf, OFF_KDF_SALT),
})
}
fn read_array<const N: usize>(buf: &[u8; PAGE_SIZE], off: usize) -> [u8; N] {
debug_assert!(off + N <= PAGE_SIZE, "header field out of bounds");
let mut out = [0u8; N];
out.copy_from_slice(&buf[off..off + N]);
out
}
pub(crate) fn is_supported_minor(format_major: u16, format_minor: u16) -> bool {
match format_major {
0 => (0..=2).contains(&format_minor),
1 => format_minor == FORMAT_MINOR,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::{decode_header, encode_header, FileHeader, FORMAT_MAJOR, FORMAT_MINOR};
use crate::pager::page::Page;
#[test]
fn round_trip_default_header() {
let h = FileHeader::new_empty();
let mut p = Page::zeroed();
encode_header(&h, &mut p);
let decoded = decode_header(&p).expect("encode/decode round-trip");
assert_eq!(decoded, h);
}
#[test]
fn round_trip_non_default_header() {
let h = FileHeader {
format_major: FORMAT_MAJOR,
format_minor: FORMAT_MINOR,
page_size: 4096,
feature_flags: 0,
page_count: 17,
root_catalog: 2,
freelist_head: 3,
wal_salt: [0xAA; 16],
file_uuid: [0xCC; 16],
kdf_salt: [0; 32],
};
let mut p = Page::zeroed();
encode_header(&h, &mut p);
assert_eq!(decode_header(&p).expect("round-trip"), h);
}
#[test]
fn round_trip_compression_header() {
let h = FileHeader {
format_major: FORMAT_MAJOR,
format_minor: FORMAT_MINOR,
page_size: 4096,
feature_flags: super::FEATURE_FLAG_COMPRESSION,
page_count: 5,
root_catalog: 0,
freelist_head: 0,
wal_salt: [0; 16],
file_uuid: [0; 16],
kdf_salt: [0; 32],
};
let mut p = Page::zeroed();
encode_header(&h, &mut p);
assert_eq!(decode_header(&p).expect("round-trip"), h);
}
#[test]
fn round_trip_encryption_header() {
let mut salt = [0u8; 32];
for (i, b) in salt.iter_mut().enumerate() {
*b = u8::try_from(i & 0xFF).unwrap_or(0);
}
let h = FileHeader {
format_major: FORMAT_MAJOR,
format_minor: FORMAT_MINOR,
page_size: 4096,
feature_flags: super::FEATURE_FLAG_ENCRYPTION,
page_count: 5,
root_catalog: 0,
freelist_head: 0,
wal_salt: [0; 16],
file_uuid: [0; 16],
kdf_salt: salt,
};
let mut p = Page::zeroed();
encode_header(&h, &mut p);
assert_eq!(decode_header(&p).expect("round-trip"), h);
}
#[test]
fn round_trip_encryption_and_compression_header() {
let h = FileHeader {
format_major: FORMAT_MAJOR,
format_minor: FORMAT_MINOR,
page_size: 4096,
feature_flags: super::FEATURE_FLAG_COMPRESSION | super::FEATURE_FLAG_ENCRYPTION,
page_count: 5,
root_catalog: 0,
freelist_head: 0,
wal_salt: [0; 16],
file_uuid: [0; 16],
kdf_salt: [0x77; 32],
};
let mut p = Page::zeroed();
encode_header(&h, &mut p);
assert_eq!(decode_header(&p).expect("round-trip"), h);
}
#[test]
fn rejects_unknown_feature_flag() {
let mut h = FileHeader::new_empty();
h.feature_flags = 0b100;
let mut p = Page::zeroed();
encode_header(&h, &mut p);
let err = decode_header(&p).expect_err("unknown flag must fail");
assert!(matches!(err, crate::error::Error::InvalidFormat { .. }));
}
#[test]
fn decodes_legacy_format_major_zero_minor_zero() {
let h = FileHeader {
format_major: 0,
format_minor: 0,
page_size: 4096,
feature_flags: 0,
page_count: 7,
root_catalog: 0,
freelist_head: 0,
wal_salt: [0x11; 16],
file_uuid: [0x22; 16],
kdf_salt: [0; 32],
};
let mut p = Page::zeroed();
encode_header(&h, &mut p);
let decoded = decode_header(&p).expect("legacy 0.x file must decode");
assert_eq!(decoded, h);
assert_eq!(decoded.format_major, 0);
assert_eq!(decoded.format_minor, 0);
}
#[test]
fn decodes_legacy_format_major_zero_minor_one() {
let h = FileHeader {
format_major: 0,
format_minor: 1,
page_size: 4096,
feature_flags: super::FEATURE_FLAG_COMPRESSION,
page_count: 3,
root_catalog: 0,
freelist_head: 0,
wal_salt: [0; 16],
file_uuid: [0; 16],
kdf_salt: [0; 32],
};
let mut p = Page::zeroed();
encode_header(&h, &mut p);
let decoded = decode_header(&p).expect("0.x compression-capable must decode");
assert_eq!(decoded, h);
}
#[test]
fn rejects_unsupported_format_major_two() {
let h = FileHeader::new_empty();
let mut p = Page::zeroed();
encode_header(&h, &mut p);
p.as_bytes_mut()[super::OFF_FORMAT_MAJOR..super::OFF_FORMAT_MAJOR + 2]
.copy_from_slice(&2u16.to_le_bytes());
let crc = super::crc32c(&p.as_bytes()[..super::OFF_HEADER_CRC]);
p.as_bytes_mut()[super::OFF_HEADER_CRC..super::OFF_HEADER_CRC + 4]
.copy_from_slice(&crc.to_le_bytes());
let err = decode_header(&p).expect_err("format_major = 2 must be rejected");
assert!(
matches!(err, crate::error::Error::InvalidFormat { reason }
if reason.contains("format-major")),
"expected InvalidFormat reason mentioning format-major; got {err:?}",
);
}
#[test]
fn rejects_format_major_one_with_legacy_minor() {
for bad_minor in [0u16, 1u16] {
let h = FileHeader::new_empty();
let mut p = Page::zeroed();
encode_header(&h, &mut p);
p.as_bytes_mut()[super::OFF_FORMAT_MINOR..super::OFF_FORMAT_MINOR + 2]
.copy_from_slice(&bad_minor.to_le_bytes());
let crc = super::crc32c(&p.as_bytes()[..super::OFF_HEADER_CRC]);
p.as_bytes_mut()[super::OFF_HEADER_CRC..super::OFF_HEADER_CRC + 4]
.copy_from_slice(&crc.to_le_bytes());
let err = decode_header(&p).expect_err("format_major = 1 + legacy minor must fail");
assert!(
matches!(err, crate::error::Error::InvalidFormat { reason }
if reason.contains("format-minor")),
"expected InvalidFormat reason mentioning format-minor; got {err:?}",
);
}
}
#[test]
fn rejects_nonzero_reserved_after_feature_flags() {
let h = FileHeader::new_empty();
let mut p = Page::zeroed();
encode_header(&h, &mut p);
p.as_bytes_mut()[14] = 0xFF;
let crc = super::crc32c(&p.as_bytes()[..super::OFF_HEADER_CRC]);
p.as_bytes_mut()[super::OFF_HEADER_CRC..super::OFF_HEADER_CRC + 4]
.copy_from_slice(&crc.to_le_bytes());
let err = decode_header(&p).expect_err("nonzero reserved must fail");
assert!(matches!(err, crate::error::Error::InvalidFormat { .. }));
}
#[test]
fn rejects_bad_magic() {
let h = FileHeader::new_empty();
let mut p = Page::zeroed();
encode_header(&h, &mut p);
p.as_bytes_mut()[0] = b'X';
let err = decode_header(&p).expect_err("bad magic must fail");
assert!(matches!(err, crate::error::Error::InvalidFormat { .. }));
}
}