use std::{
fs::File,
io::{Read, Write},
path::Path,
sync::Arc,
};
use bytemuck::{Pod, Zeroable};
use super::arena::{FileNode, StringPool};
pub const FILE_VERSION: u16 = 2;
pub const ZSTD_COMPRESSION_LEVEL: i32 = 3;
#[derive(Debug, Copy, Clone, Pod, Zeroable)]
#[repr(C, align(8))]
pub struct FileHeader {
pub magic: [u8; 4],
pub version: u16,
_padding: u16,
pub uncompressed_size: u64,
pub node_count: u64,
pub string_pool_offset: u64,
pub string_pool_length: u64,
pub reserved: [u64; 4], }
#[derive(Debug)]
pub struct PersistentArena {
decompressed_data: Vec<u8>,
node_count: usize,
}
impl PersistentArena {
#[must_use]
pub const fn new(decompressed_data: Vec<u8>, node_count: usize) -> Self {
Self {
decompressed_data,
node_count,
}
}
#[must_use]
#[inline]
pub fn nodes(&self) -> &[FileNode] {
let start = 0;
let end = self.node_count * std::mem::size_of::<FileNode>();
let bytes = &self.decompressed_data[start..end];
bytemuck::cast_slice(bytes)
}
#[inline]
pub fn nodes_mut(&mut self) -> &mut [FileNode] {
let start = 0;
let end = self.node_count * std::mem::size_of::<FileNode>();
let bytes = &mut self.decompressed_data[start..end];
bytemuck::cast_slice_mut(bytes)
}
}
pub fn save_snapshot(
nodes: &[FileNode],
string_pool: &StringPool,
path: &Path,
) -> Result<(), crate::EdirstatError> {
let mut file = File::create(path)?;
let (arena_string, offsets) = string_pool.interner.clone().export_arena().map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Interner handle overflow during export",
)
})?;
let nodes_size = std::mem::size_of_val(nodes);
let offsets_size = offsets.len() * std::mem::size_of::<u32>();
let bytes_count = arena_string.len();
let string_pool_length = 8 + offsets_size + 8 + bytes_count;
let uncompressed_size = nodes_size + string_pool_length;
let header = FileHeader {
magic: *b"EDST",
version: FILE_VERSION,
_padding: 0,
uncompressed_size: uncompressed_size as u64,
node_count: nodes.len() as u64,
string_pool_offset: nodes_size as u64,
string_pool_length: string_pool_length as u64,
reserved: [0; 4],
};
let mut raw_payload = Vec::with_capacity(uncompressed_size);
raw_payload.write_all(bytemuck::cast_slice(nodes))?;
raw_payload.write_all(&(offsets.len() as u64).to_le_bytes())?;
raw_payload.write_all(bytemuck::cast_slice(&offsets))?;
raw_payload.write_all(&(bytes_count as u64).to_le_bytes())?;
raw_payload.write_all(arena_string.as_bytes())?;
let compressed_payload = zstd::encode_all(&raw_payload[..], ZSTD_COMPRESSION_LEVEL)?;
file.write_all(bytemuck::bytes_of(&header))?;
file.write_all(&compressed_payload)?;
file.sync_all()?;
Ok(())
}
pub fn load_snapshot(path: &Path) -> Result<(PersistentArena, StringPool), crate::EdirstatError> {
let mut file = File::open(path)?;
let metadata = file.metadata()?;
if metadata.len() < 72 {
return Err(crate::EdirstatError::HeaderTooSmall);
}
let mut header_bytes = [0u8; 72];
file.read_exact(&mut header_bytes)?;
let header: &FileHeader = bytemuck::from_bytes(&header_bytes);
if header.magic != *b"EDST" {
return Err(crate::EdirstatError::InvalidMagic);
}
if header.version != FILE_VERSION {
return Err(crate::EdirstatError::UnsupportedVersion(header.version));
}
let mut compressed_payload = Vec::with_capacity((metadata.len() - 72) as usize);
file.read_to_end(&mut compressed_payload)?;
let decompressed_data =
zstd::bulk::decompress(&compressed_payload, header.uncompressed_size as usize)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let node_count = header.node_count as usize;
let expected_nodes_size = node_count * std::mem::size_of::<FileNode>();
if decompressed_data.len() < expected_nodes_size {
return Err(crate::EdirstatError::TruncatedNodes);
}
let sp_start = header.string_pool_offset as usize;
let sp_end = sp_start + header.string_pool_length as usize;
if decompressed_data.len() < sp_end {
return Err(crate::EdirstatError::TruncatedStringPool);
}
let sp_slice = &decompressed_data[sp_start..sp_end];
let mut offset_count_bytes = [0u8; 8];
offset_count_bytes.copy_from_slice(&sp_slice[0..8]);
let offsets_count = u64::from_le_bytes(offset_count_bytes) as usize;
let offsets_start = 8;
let offsets_end = offsets_start + offsets_count * std::mem::size_of::<u32>();
if sp_slice.len() < offsets_end + 8 {
return Err(crate::EdirstatError::TruncatedStringPool);
}
let offsets_bytes = &sp_slice[offsets_start..offsets_end];
let offsets: &[u32] = bytemuck::cast_slice(offsets_bytes);
let mut bytes_count_bytes = [0u8; 8];
bytes_count_bytes.copy_from_slice(&sp_slice[offsets_end..offsets_end + 8]);
let bytes_count = u64::from_le_bytes(bytes_count_bytes) as usize;
let raw_bytes_start = offsets_end + 8;
let raw_bytes_end = raw_bytes_start + bytes_count;
if sp_slice.len() < raw_bytes_end {
return Err(crate::EdirstatError::TruncatedStringPool);
}
let raw_bytes = &sp_slice[raw_bytes_start..raw_bytes_end];
let arena_data: Arc<str> = Arc::from(std::str::from_utf8(raw_bytes).unwrap_or(""));
let mut interner = xgx_intern::Interner::new(ahash::RandomState::new());
for i in 0..offsets.len() - 1 {
let offset = offsets[i];
let len = offsets[i + 1] - offset;
let shared_str = xgx_intern::ArenaString::Shared {
arena: arena_data.clone(),
offset,
len,
};
let _ = interner.intern_owned(shared_str);
}
let string_pool = StringPool { interner };
let arena = PersistentArena::new(decompressed_data, node_count);
Ok((arena, string_pool))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_binary_serialization_and_compressed_load() -> Result<(), crate::EdirstatError> {
let mut pool = StringPool::new();
let name_root = pool.get_or_insert(b"/");
let name_dir = pool.get_or_insert(b"target");
let name_file = pool.get_or_insert(b"lib.rs");
let mut nodes = vec![
FileNode::new(name_root, None, true, false, 0, 0, 0),
FileNode::new(name_dir, Some(0), true, false, 0, 0, 0),
FileNode::new(name_file, Some(1), false, false, 0, 0, 0),
];
nodes[0].first_child = 1;
nodes[1].first_child = 2;
nodes[1].size = 12345;
nodes[1].file_count = 1;
nodes[2].size = 12345;
let temp_dir = std::env::current_dir()?.join("target");
let test_path = temp_dir.join("test_snapshot.edst");
let _ = std::fs::create_dir_all(&temp_dir);
save_snapshot(&nodes, &pool, &test_path)?;
let (mut loaded_arena, loaded_pool) = load_snapshot(&test_path)?;
let loaded_nodes = loaded_arena.nodes();
assert_eq!(loaded_nodes.len(), 3);
assert_eq!(loaded_nodes[0].name_id, name_root);
assert_eq!(loaded_nodes[1].name_id, name_dir);
assert_eq!(loaded_nodes[2].name_id, name_file);
assert_eq!(loaded_nodes[0].first_child, 1);
assert_eq!(loaded_nodes[1].first_child, 2);
assert_eq!(loaded_nodes[1].size, 12345);
assert_eq!(loaded_nodes[2].size, 12345);
assert_eq!(loaded_pool.get(name_root), Some("/"));
assert_eq!(loaded_pool.get(name_dir), Some("target"));
assert_eq!(loaded_pool.get(name_file), Some("lib.rs"));
let loaded_nodes_mut = loaded_arena.nodes_mut();
loaded_nodes_mut[1].next_sibling = 999;
assert_eq!(loaded_nodes_mut[1].next_sibling, 999);
let _ = std::fs::remove_file(&test_path);
Ok(())
}
#[test]
fn test_load_snapshot_header_too_small() -> Result<(), crate::EdirstatError> {
let temp_dir = std::env::current_dir()?.join("target");
let test_path = temp_dir.join("test_small.edst");
let _ = std::fs::create_dir_all(&temp_dir);
std::fs::write(&test_path, b"too_small")?;
let res = load_snapshot(&test_path);
assert!(matches!(res, Err(crate::EdirstatError::HeaderTooSmall)));
let _ = std::fs::remove_file(&test_path);
Ok(())
}
#[test]
fn test_load_snapshot_invalid_magic() -> Result<(), crate::EdirstatError> {
let temp_dir = std::env::current_dir()?.join("target");
let test_path = temp_dir.join("test_invalid_magic.edst");
let _ = std::fs::create_dir_all(&temp_dir);
let header = FileHeader {
magic: *b"BAD!",
version: FILE_VERSION,
_padding: 0,
uncompressed_size: 0,
node_count: 0,
string_pool_offset: 72,
string_pool_length: 0,
reserved: [0; 4],
};
std::fs::write(&test_path, bytemuck::bytes_of(&header))?;
let res = load_snapshot(&test_path);
assert!(matches!(res, Err(crate::EdirstatError::InvalidMagic)));
let _ = std::fs::remove_file(&test_path);
Ok(())
}
#[test]
fn test_load_snapshot_unsupported_version() -> Result<(), crate::EdirstatError> {
let temp_dir = std::env::current_dir()?.join("target");
let test_path = temp_dir.join("test_unsupported_version.edst");
let _ = std::fs::create_dir_all(&temp_dir);
let header = FileHeader {
magic: *b"EDST",
version: 99,
_padding: 0,
uncompressed_size: 0,
node_count: 0,
string_pool_offset: 72,
string_pool_length: 0,
reserved: [0; 4],
};
std::fs::write(&test_path, bytemuck::bytes_of(&header))?;
let res = load_snapshot(&test_path);
assert!(matches!(
res,
Err(crate::EdirstatError::UnsupportedVersion(99))
));
let _ = std::fs::remove_file(&test_path);
Ok(())
}
}