use crate::error::{Result, TdbError};
use crate::storage::file_manager::FileManager;
use crate::storage::page::{Page, PageId, PageType};
use serde::{Deserialize, Serialize};
pub const SUPERBLOCK_MAGIC: u64 = 0x4F58_4952_5354_4442;
pub const SUPERBLOCK_FORMAT_VERSION: u32 = 3;
pub const SUPERBLOCK_PAGE_ID: PageId = 0;
const NONE_PAGE: u64 = 0;
const LEN_OFFSET: usize = 0;
const PAYLOAD_OFFSET: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Superblock {
pub magic: u64,
pub format_version: u32,
pub spo_root: u64,
pub pos_root: u64,
pub osp_root: u64,
pub node_to_id_root: u64,
pub id_to_term_root: u64,
pub next_node_id: u64,
pub triple_count: u64,
pub free_list_head: u64,
pub gspo_root: u64,
pub gpos_root: u64,
pub gosp_root: u64,
pub quad_count: u64,
pub checkpoint_lsn: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
struct SuperblockHeader {
magic: u64,
format_version: u32,
}
impl SuperblockHeader {
fn from_bytes(bytes: &[u8]) -> Result<Self> {
oxicode::serde::decode_from_slice(bytes, oxicode::config::standard())
.map(|(header, _)| header)
.map_err(|e| TdbError::Deserialization(e.to_string()))
}
}
impl Superblock {
pub fn new_empty(first_node_id: u64) -> Self {
Superblock {
magic: SUPERBLOCK_MAGIC,
format_version: SUPERBLOCK_FORMAT_VERSION,
spo_root: NONE_PAGE,
pos_root: NONE_PAGE,
osp_root: NONE_PAGE,
node_to_id_root: NONE_PAGE,
id_to_term_root: NONE_PAGE,
next_node_id: first_node_id,
triple_count: 0,
free_list_head: NONE_PAGE,
gspo_root: NONE_PAGE,
gpos_root: NONE_PAGE,
gosp_root: NONE_PAGE,
quad_count: 0,
checkpoint_lsn: 0,
}
}
#[inline]
pub fn slot_to_option(slot: u64) -> Option<PageId> {
if slot == NONE_PAGE {
None
} else {
Some(slot)
}
}
#[inline]
pub fn option_to_slot(page: Option<PageId>) -> u64 {
page.unwrap_or(NONE_PAGE)
}
pub fn spo_root(&self) -> Option<PageId> {
Self::slot_to_option(self.spo_root)
}
pub fn pos_root(&self) -> Option<PageId> {
Self::slot_to_option(self.pos_root)
}
pub fn osp_root(&self) -> Option<PageId> {
Self::slot_to_option(self.osp_root)
}
pub fn node_to_id_root(&self) -> Option<PageId> {
Self::slot_to_option(self.node_to_id_root)
}
pub fn id_to_term_root(&self) -> Option<PageId> {
Self::slot_to_option(self.id_to_term_root)
}
pub fn free_list_head(&self) -> Option<PageId> {
Self::slot_to_option(self.free_list_head)
}
pub fn gspo_root(&self) -> Option<PageId> {
Self::slot_to_option(self.gspo_root)
}
pub fn gpos_root(&self) -> Option<PageId> {
Self::slot_to_option(self.gpos_root)
}
pub fn gosp_root(&self) -> Option<PageId> {
Self::slot_to_option(self.gosp_root)
}
fn to_bytes(&self) -> Result<Vec<u8>> {
oxicode::serde::encode_to_vec(self, oxicode::config::standard())
.map_err(|e| TdbError::Serialization(e.to_string()))
}
fn from_bytes(bytes: &[u8]) -> Result<Self> {
oxicode::serde::decode_from_slice(bytes, oxicode::config::standard())
.map(|(sb, _)| sb)
.map_err(|e| TdbError::Deserialization(e.to_string()))
}
pub fn write(&self, file_manager: &FileManager) -> Result<()> {
let bytes = self.to_bytes()?;
let len = bytes.len();
let len_u32 = u32::try_from(len).map_err(|_| {
TdbError::Serialization(format!("Superblock too large to persist: {len} bytes"))
})?;
let mut page = Page::new(SUPERBLOCK_PAGE_ID, PageType::Metadata);
page.write_at(LEN_OFFSET, &len_u32.to_le_bytes())?;
page.write_at(PAYLOAD_OFFSET, &bytes)?;
page.update_header();
file_manager.write_page(&mut page)?;
file_manager.flush()?;
Ok(())
}
pub fn read(file_manager: &FileManager) -> Result<Option<Self>> {
if file_manager.num_pages() == 0 {
return Ok(None);
}
let page = file_manager.read_page(SUPERBLOCK_PAGE_ID)?;
if !page.verify_checksum() {
return Err(TdbError::Other(
"Corrupt superblock at page 0: checksum mismatch. The catalog page was likely \
torn by a crash during an in-place write; the store cannot be opened safely. \
Restore from backup or recover from the WAL."
.to_string(),
));
}
let len_bytes = page.read_at(LEN_OFFSET, 4)?;
let mut len_arr = [0u8; 4];
len_arr.copy_from_slice(len_bytes);
let len = u32::from_le_bytes(len_arr) as usize;
if len == 0 || len > crate::storage::page::PAGE_USABLE_SIZE - PAYLOAD_OFFSET {
return Err(TdbError::Other(format!(
"Corrupt superblock at page 0: implausible payload length {len}. \
The data file was not created by a compatible oxirs-tdb version."
)));
}
let payload = page.read_at(PAYLOAD_OFFSET, len)?;
let header = SuperblockHeader::from_bytes(payload)?;
if header.magic != SUPERBLOCK_MAGIC {
return Err(TdbError::Other(format!(
"Not an oxirs-tdb store: superblock magic {:#018x} != {:#018x} (page 0 is not a \
valid superblock; the store may pre-date on-disk persistence or be corrupt)",
header.magic, SUPERBLOCK_MAGIC
)));
}
if header.format_version != SUPERBLOCK_FORMAT_VERSION {
return Err(TdbError::Other(format!(
"Unsupported oxirs-tdb on-disk format version {} (this build writes format \
version {}). The store was created by a different oxirs-tdb release; migrate \
or recreate it.",
header.format_version, SUPERBLOCK_FORMAT_VERSION
)));
}
let sb = Self::from_bytes(payload)?;
Ok(Some(sb))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dictionary::NodeId;
use std::env;
fn temp_file_manager() -> (std::path::PathBuf, FileManager) {
let dir = env::temp_dir().join(format!("oxirs_tdb_superblock_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("data.tdb");
let fm = FileManager::open(&path, false).expect("open file manager");
(dir, fm)
}
#[test]
fn test_superblock_read_fresh_file_is_none() {
let (dir, fm) = temp_file_manager();
assert!(Superblock::read(&fm).expect("read").is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_superblock_round_trip() {
let (dir, fm) = temp_file_manager();
let page0 = fm.allocate_page().expect("allocate page 0");
assert_eq!(page0, SUPERBLOCK_PAGE_ID);
let mut sb = Superblock::new_empty(NodeId::FIRST.as_u64());
sb.spo_root = 7;
sb.pos_root = 8;
sb.osp_root = 9;
sb.node_to_id_root = 10;
sb.id_to_term_root = 11;
sb.next_node_id = 42;
sb.triple_count = 123;
sb.free_list_head = 5;
sb.gspo_root = 12;
sb.gpos_root = 13;
sb.gosp_root = 14;
sb.quad_count = 456;
sb.checkpoint_lsn = 789;
sb.write(&fm).expect("write superblock");
let read_back = Superblock::read(&fm)
.expect("read")
.expect("some superblock");
assert_eq!(sb, read_back);
assert_eq!(read_back.spo_root(), Some(7));
assert_eq!(read_back.free_list_head(), Some(5));
assert_eq!(read_back.gspo_root(), Some(12));
assert_eq!(read_back.gpos_root(), Some(13));
assert_eq!(read_back.gosp_root(), Some(14));
assert_eq!(read_back.quad_count, 456);
assert_eq!(read_back.checkpoint_lsn, 789);
assert_eq!(read_back.format_version, SUPERBLOCK_FORMAT_VERSION);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_superblock_rejects_old_format_version() {
let (dir, fm) = temp_file_manager();
fm.allocate_page().expect("allocate page 0");
let mut sb = Superblock::new_empty(NodeId::FIRST.as_u64());
sb.format_version = SUPERBLOCK_FORMAT_VERSION - 1;
sb.write(&fm).expect("write old-format superblock");
let err = Superblock::read(&fm).expect_err("old format must be rejected");
let msg = err.to_string();
assert!(
msg.contains("format version"),
"expected a version-mismatch error, got: {msg}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_superblock_slot_option_conversions() {
assert_eq!(Superblock::slot_to_option(0), None);
assert_eq!(Superblock::slot_to_option(3), Some(3));
assert_eq!(Superblock::option_to_slot(None), 0);
assert_eq!(Superblock::option_to_slot(Some(3)), 3);
}
#[test]
fn regression_superblock_rejects_torn_page_with_intact_magic() {
let dir = env::temp_dir().join(format!("oxirs_tdb_sb_torn_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("data.tdb");
{
let fm = FileManager::open(&path, false).expect("open fm");
fm.allocate_page().expect("allocate page 0");
let mut sb = Superblock::new_empty(NodeId::FIRST.as_u64());
sb.spo_root = 7;
sb.triple_count = 99;
sb.write(&fm).expect("write superblock");
fm.flush().expect("flush");
}
{
let mut bytes = std::fs::read(&path).expect("read data file");
let corrupt_at = 64;
assert!(bytes.len() > corrupt_at);
bytes[corrupt_at] ^= 0xFF;
std::fs::write(&path, &bytes).expect("write corrupted data file");
}
let fm = FileManager::open(&path, false).expect("reopen fm");
let err = Superblock::read(&fm).expect_err("torn superblock must be rejected");
let msg = err.to_string();
assert!(
msg.contains("checksum"),
"expected a checksum-mismatch error, got: {msg}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_superblock_rejects_bad_magic() {
let (dir, fm) = temp_file_manager();
fm.allocate_page().expect("allocate page 0");
let mut page = Page::new(SUPERBLOCK_PAGE_ID, PageType::Metadata);
page.write_at(0, &7u32.to_le_bytes()).expect("write len");
page.write_at(4, &[1u8, 2, 3, 4, 5, 6, 7])
.expect("write junk");
page.update_header();
fm.write_page(&mut page).expect("write junk page");
assert!(Superblock::read(&fm).is_err());
std::fs::remove_dir_all(&dir).ok();
}
}