use core::{fmt, marker::PhantomData, mem::align_of, ops::Range};
use std::{
fs::{self, File, OpenOptions},
io::{self, Write},
path::Path,
};
use memmap2::{Mmap, MmapOptions};
use rkyv::{
api::high::{HighSerializer, HighValidator},
bytecheck::CheckBytes,
rancor::Error as RkyvError,
ser::allocator::ArenaHandle,
util::AlignedVec,
Portable,
};
use crate::ErrorCategory;
pub use rkyv::{Archive, Deserialize, Serialize};
const MAGIC: [u8; 8] = *b"RBARCV01";
const FORMAT_VERSION: u16 = 1;
const FORMAT_FLAGS: u16 = 0x0003;
const HEADER_LEN: usize = 64;
const PAYLOAD_OFFSET: usize = HEADER_LEN;
const MAX_ARCHIVE_ALIGNMENT: usize = 64;
pub const DEFAULT_ARCHIVE_SIZE_LIMIT: u64 = 1024 * 1024 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ArchiveLimits {
max_file_size: u64,
}
impl ArchiveLimits {
pub const fn new() -> Self {
Self {
max_file_size: DEFAULT_ARCHIVE_SIZE_LIMIT,
}
}
pub const fn with_max_file_size(mut self, max_file_size: u64) -> Self {
self.max_file_size = max_file_size;
self
}
pub const fn max_file_size(self) -> u64 {
self.max_file_size
}
}
impl Default for ArchiveLimits {
fn default() -> Self {
Self::new()
}
}
pub trait ArchiveSchema: Archive {
const SCHEMA_ID: u64;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ArchiveHeader {
schema_id: u64,
payload_len: u64,
file_len: u64,
}
impl ArchiveHeader {
pub const fn format_version(self) -> u16 {
FORMAT_VERSION
}
pub const fn schema_id(self) -> u64 {
self.schema_id
}
pub const fn payload_len(self) -> u64 {
self.payload_len
}
pub const fn file_len(self) -> u64 {
self.file_len
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ArchiveError {
Io(io::Error),
SizeLimit {
limit: u64,
actual: u64,
},
InvalidHeader(&'static str),
InvalidSchemaId,
SchemaMismatch {
expected: u64,
actual: u64,
},
UnsupportedAlignment {
required: usize,
supported: usize,
},
Serialization(String),
Validation(String),
}
impl ArchiveError {
pub const fn category(&self) -> ErrorCategory {
match self {
Self::Io(_) | Self::SizeLimit { .. } | Self::UnsupportedAlignment { .. } => {
ErrorCategory::Configuration
}
Self::InvalidHeader(_) | Self::SchemaMismatch { .. } | Self::Validation(_) => {
ErrorCategory::Protocol
}
Self::InvalidSchemaId => ErrorCategory::Configuration,
Self::Serialization(_) => ErrorCategory::UserInput,
}
}
}
impl fmt::Display for ArchiveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "archive I/O error: {error}"),
Self::SizeLimit { limit, actual } => write!(
f,
"archive size {actual} exceeds the configured limit of {limit} bytes"
),
Self::InvalidHeader(reason) => write!(f, "invalid archive header: {reason}"),
Self::InvalidSchemaId => {
f.write_str("archive root uses the reserved zero schema identifier")
}
Self::SchemaMismatch { expected, actual } => write!(
f,
"archive schema mismatch: expected {expected:#018x}, found {actual:#018x}"
),
Self::UnsupportedAlignment {
required,
supported,
} => write!(
f,
"archived root requires {required}-byte alignment; at most {supported} is supported"
),
Self::Serialization(message) => write!(f, "archive serialization failed: {message}"),
Self::Validation(message) => write!(f, "archive validation failed: {message}"),
}
}
}
impl std::error::Error for ArchiveError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
_ => None,
}
}
}
impl From<io::Error> for ArchiveError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
pub struct OwnedArchive<T: ArchiveSchema> {
bytes: AlignedVec<MAX_ARCHIVE_ALIGNMENT>,
payload: Range<usize>,
header: ArchiveHeader,
marker: PhantomData<T>,
}
impl<T: ArchiveSchema> OwnedArchive<T> {
pub const fn header(&self) -> ArchiveHeader {
self.header
}
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
pub fn payload(&self) -> &[u8] {
&self.bytes[self.payload.clone()]
}
pub fn write_new(&self, path: impl AsRef<Path>) -> Result<(), ArchiveError> {
let path = path.as_ref();
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
let result = (|| {
file.write_all(self.as_bytes())?;
file.sync_all()
})();
if let Err(error) = result {
drop(file);
let _ = fs::remove_file(path);
return Err(ArchiveError::Io(error));
}
Ok(())
}
}
impl<T> OwnedArchive<T>
where
T: ArchiveSchema,
T::Archived: Portable,
{
pub fn root(&self) -> &T::Archived {
unsafe { rkyv::access_unchecked::<T::Archived>(self.payload()) }
}
}
pub struct MappedArchive<T: ArchiveSchema> {
map: Mmap,
payload: Range<usize>,
header: ArchiveHeader,
marker: PhantomData<T>,
}
impl<T> MappedArchive<T>
where
T: ArchiveSchema,
T::Archived: Portable + for<'a> CheckBytes<HighValidator<'a, RkyvError>>,
{
pub unsafe fn open(
path: impl AsRef<Path>,
limits: ArchiveLimits,
) -> Result<Self, ArchiveError> {
let file = File::open(path)?;
let metadata_len = file.metadata()?.len();
check_size_limit(metadata_len, limits)?;
if metadata_len < HEADER_LEN as u64 {
return Err(ArchiveError::InvalidHeader(
"file is shorter than the envelope",
));
}
let map = unsafe { MmapOptions::new().map(&file) }?;
let (header, payload) = validate_archive::<T>(&map, limits)?;
Ok(Self {
map,
payload,
header,
marker: PhantomData,
})
}
pub const fn header(&self) -> ArchiveHeader {
self.header
}
pub fn as_bytes(&self) -> &[u8] {
&self.map
}
pub fn payload(&self) -> &[u8] {
&self.map[self.payload.clone()]
}
pub fn root(&self) -> &T::Archived {
unsafe { rkyv::access_unchecked::<T::Archived>(self.payload()) }
}
}
pub fn build<T>(value: &T, limits: ArchiveLimits) -> Result<OwnedArchive<T>, ArchiveError>
where
T: ArchiveSchema
+ for<'a> rkyv::Serialize<HighSerializer<AlignedVec, ArenaHandle<'a>, RkyvError>>,
T::Archived: Portable + for<'a> CheckBytes<HighValidator<'a, RkyvError>>,
{
validate_schema_id::<T>()?;
validate_alignment::<T>()?;
let payload = rkyv::to_bytes::<RkyvError>(value)
.map_err(|error| ArchiveError::Serialization(error.to_string()))?;
let payload_len = u64::try_from(payload.len()).map_err(|_| ArchiveError::SizeLimit {
limit: limits.max_file_size(),
actual: u64::MAX,
})?;
let file_len = (HEADER_LEN as u64)
.checked_add(payload_len)
.ok_or(ArchiveError::SizeLimit {
limit: limits.max_file_size(),
actual: u64::MAX,
})?;
check_size_limit(file_len, limits)?;
let header = ArchiveHeader {
schema_id: T::SCHEMA_ID,
payload_len,
file_len,
};
let capacity = usize::try_from(file_len).map_err(|_| ArchiveError::SizeLimit {
limit: limits.max_file_size(),
actual: file_len,
})?;
let mut bytes = AlignedVec::<MAX_ARCHIVE_ALIGNMENT>::with_capacity(capacity);
bytes.extend_from_slice(&encode_header(header));
bytes.extend_from_slice(&payload);
let (_, payload_range) = validate_archive::<T>(&bytes, limits)?;
Ok(OwnedArchive {
bytes,
payload: payload_range,
header,
marker: PhantomData,
})
}
pub fn access<T>(bytes: &[u8], limits: ArchiveLimits) -> Result<&T::Archived, ArchiveError>
where
T: ArchiveSchema,
T::Archived: Portable + for<'a> CheckBytes<HighValidator<'a, RkyvError>>,
{
let (_, payload) = validate_archive::<T>(bytes, limits)?;
rkyv::access::<T::Archived, RkyvError>(&bytes[payload])
.map_err(|error| ArchiveError::Validation(error.to_string()))
}
fn validate_archive<T>(
bytes: &[u8],
limits: ArchiveLimits,
) -> Result<(ArchiveHeader, Range<usize>), ArchiveError>
where
T: ArchiveSchema,
T::Archived: Portable + for<'a> CheckBytes<HighValidator<'a, RkyvError>>,
{
validate_schema_id::<T>()?;
validate_alignment::<T>()?;
let header = parse_header(bytes, limits)?;
if header.schema_id != T::SCHEMA_ID {
return Err(ArchiveError::SchemaMismatch {
expected: T::SCHEMA_ID,
actual: header.schema_id,
});
}
let payload_len = usize::try_from(header.payload_len)
.map_err(|_| ArchiveError::InvalidHeader("payload length does not fit usize"))?;
let payload_end = PAYLOAD_OFFSET
.checked_add(payload_len)
.ok_or(ArchiveError::InvalidHeader("payload range overflows usize"))?;
let payload = PAYLOAD_OFFSET..payload_end;
let payload_bytes = &bytes[payload.clone()];
let required_alignment = align_of::<T::Archived>();
if !(payload_bytes.as_ptr() as usize).is_multiple_of(required_alignment) {
return Err(ArchiveError::Validation(
"payload base does not satisfy archived root alignment".into(),
));
}
rkyv::access::<T::Archived, RkyvError>(payload_bytes)
.map_err(|error| ArchiveError::Validation(error.to_string()))?;
Ok((header, payload))
}
fn parse_header(bytes: &[u8], limits: ArchiveLimits) -> Result<ArchiveHeader, ArchiveError> {
let actual = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
check_size_limit(actual, limits)?;
if bytes.len() < HEADER_LEN {
return Err(ArchiveError::InvalidHeader(
"file is shorter than the envelope",
));
}
if bytes[..8] != MAGIC {
return Err(ArchiveError::InvalidHeader("magic does not match"));
}
if read_u16(bytes, 8) != FORMAT_VERSION {
return Err(ArchiveError::InvalidHeader("unsupported format version"));
}
if read_u16(bytes, 10) != FORMAT_FLAGS {
return Err(ArchiveError::InvalidHeader("format flags do not match"));
}
if read_u32(bytes, 12) != HEADER_LEN as u32 {
return Err(ArchiveError::InvalidHeader("header length does not match"));
}
let schema_id = read_u64(bytes, 16);
if schema_id == 0 {
return Err(ArchiveError::InvalidHeader("schema identifier is zero"));
}
let payload_len = read_u64(bytes, 24);
if read_u64(bytes, 32) != PAYLOAD_OFFSET as u64 {
return Err(ArchiveError::InvalidHeader("payload offset does not match"));
}
let file_len = read_u64(bytes, 40);
if file_len != actual {
return Err(ArchiveError::InvalidHeader(
"declared file length does not match",
));
}
let expected_file_len =
(HEADER_LEN as u64)
.checked_add(payload_len)
.ok_or(ArchiveError::InvalidHeader(
"declared payload length overflows",
))?;
if expected_file_len != file_len {
return Err(ArchiveError::InvalidHeader(
"declared payload length does not match",
));
}
if bytes[48..HEADER_LEN].iter().any(|&byte| byte != 0) {
return Err(ArchiveError::InvalidHeader(
"reserved header bytes are non-zero",
));
}
Ok(ArchiveHeader {
schema_id,
payload_len,
file_len,
})
}
fn encode_header(header: ArchiveHeader) -> [u8; HEADER_LEN] {
let mut bytes = [0_u8; HEADER_LEN];
bytes[..8].copy_from_slice(&MAGIC);
bytes[8..10].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
bytes[10..12].copy_from_slice(&FORMAT_FLAGS.to_le_bytes());
bytes[12..16].copy_from_slice(&(HEADER_LEN as u32).to_le_bytes());
bytes[16..24].copy_from_slice(&header.schema_id.to_le_bytes());
bytes[24..32].copy_from_slice(&header.payload_len.to_le_bytes());
bytes[32..40].copy_from_slice(&(PAYLOAD_OFFSET as u64).to_le_bytes());
bytes[40..48].copy_from_slice(&header.file_len.to_le_bytes());
bytes
}
fn check_size_limit(actual: u64, limits: ArchiveLimits) -> Result<(), ArchiveError> {
if actual > limits.max_file_size() {
Err(ArchiveError::SizeLimit {
limit: limits.max_file_size(),
actual,
})
} else {
Ok(())
}
}
fn validate_schema_id<T: ArchiveSchema>() -> Result<(), ArchiveError> {
if T::SCHEMA_ID == 0 {
Err(ArchiveError::InvalidSchemaId)
} else {
Ok(())
}
}
fn validate_alignment<T: ArchiveSchema>() -> Result<(), ArchiveError>
where
T::Archived: Portable,
{
let required = align_of::<T::Archived>();
if required > MAX_ARCHIVE_ALIGNMENT {
Err(ArchiveError::UnsupportedAlignment {
required,
supported: MAX_ARCHIVE_ALIGNMENT,
})
} else {
Ok(())
}
}
fn read_u16(bytes: &[u8], offset: usize) -> u16 {
let mut value = [0_u8; 2];
value.copy_from_slice(&bytes[offset..offset + 2]);
u16::from_le_bytes(value)
}
fn read_u32(bytes: &[u8], offset: usize) -> u32 {
let mut value = [0_u8; 4];
value.copy_from_slice(&bytes[offset..offset + 4]);
u32::from_le_bytes(value)
}
fn read_u64(bytes: &[u8], offset: usize) -> u64 {
let mut value = [0_u8; 8];
value.copy_from_slice(&bytes[offset..offset + 8]);
u64::from_le_bytes(value)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT_FILE_ID: AtomicU64 = AtomicU64::new(0);
struct TemporaryArchive(PathBuf);
impl TemporaryArchive {
fn new() -> Self {
let id = NEXT_FILE_ID.fetch_add(1, Ordering::Relaxed);
Self(std::env::temp_dir().join(format!(
"rustbinary-archive-test-{}-{id}.rba",
std::process::id()
)))
}
}
impl Drop for TemporaryArchive {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
#[derive(Archive, Serialize)]
struct Root {
sequence: u64,
name: String,
samples: Vec<i32>,
}
impl ArchiveSchema for Root {
const SCHEMA_ID: u64 = 0x5255_5354_4249_4e31;
}
#[derive(Archive, Serialize)]
struct OtherRoot {
sequence: u64,
name: String,
samples: Vec<i32>,
}
impl ArchiveSchema for OtherRoot {
const SCHEMA_ID: u64 = 0x5255_5354_4249_4e32;
}
#[derive(Archive)]
struct InvalidSchemaRoot;
impl ArchiveSchema for InvalidSchemaRoot {
const SCHEMA_ID: u64 = 0;
}
fn value() -> Root {
Root {
sequence: 42,
name: "mapped".into(),
samples: vec![-7, 0, 11, 65_536],
}
}
fn expect_error<T>(result: Result<T, ArchiveError>) -> ArchiveError {
match result {
Ok(_) => panic!("expected archive operation to fail"),
Err(error) => error,
}
}
#[test]
fn owned_archive_validates_and_borrows_relative_fields() {
let archive = build(&value(), ArchiveLimits::new()).unwrap();
const GOLDEN: &[u8] = &[
0x52, 0x42, 0x41, 0x52, 0x43, 0x56, 0x30, 0x31, 0x01, 0x00, 0x03, 0x00, 0x40, 0x00,
0x00, 0x00, 0x31, 0x4e, 0x49, 0x42, 0x54, 0x53, 0x55, 0x52, 0x28, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x68, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf9, 0xff, 0xff, 0xff, 0x00, 0x00,
0x00, 0x00, 0x0b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x2a, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x6d, 0x61, 0x70, 0x70, 0x65, 0x64, 0xff, 0xff, 0xe0, 0xff,
0xff, 0xff, 0x04, 0x00, 0x00, 0x00,
];
assert_eq!(archive.as_bytes(), GOLDEN);
let root = archive.root();
assert_eq!(root.sequence, 42);
assert_eq!(root.name.as_str(), "mapped");
assert_eq!(root.samples.as_slice(), [-7, 0, 11, 65_536]);
let start = archive.as_bytes().as_ptr() as usize;
let end = start + archive.as_bytes().len();
let name = root.name.as_bytes().as_ptr() as usize;
let samples = root.samples.as_ptr() as usize;
assert!((start..end).contains(&name));
assert!((start..end).contains(&samples));
assert_eq!(
access::<Root>(archive.as_bytes(), ArchiveLimits::new())
.unwrap()
.sequence,
42
);
}
#[test]
fn envelope_rejects_corruption_schema_drift_and_resource_abuse() {
let archive = build(&value(), ArchiveLimits::new()).unwrap();
let schema_error = expect_error(access::<OtherRoot>(
archive.as_bytes(),
ArchiveLimits::new(),
));
assert!(matches!(schema_error, ArchiveError::SchemaMismatch { .. }));
assert_eq!(schema_error.category(), ErrorCategory::Protocol);
let limit_error = expect_error(access::<Root>(
archive.as_bytes(),
ArchiveLimits::new().with_max_file_size(16),
));
assert!(matches!(limit_error, ArchiveError::SizeLimit { .. }));
assert_eq!(limit_error.category(), ErrorCategory::Configuration);
let schema_id_error = expect_error(access::<InvalidSchemaRoot>(
archive.as_bytes(),
ArchiveLimits::new(),
));
assert!(matches!(schema_id_error, ArchiveError::InvalidSchemaId));
assert_eq!(schema_id_error.category(), ErrorCategory::Configuration);
assert!(matches!(
access::<Root>(&archive.as_bytes()[..32], ArchiveLimits::new()),
Err(ArchiveError::InvalidHeader(_))
));
let mut corrupted = archive.as_bytes().to_vec();
corrupted[0] ^= 1;
assert!(matches!(
access::<Root>(&corrupted, ArchiveLimits::new()),
Err(ArchiveError::InvalidHeader("magic does not match"))
));
let mut reserved = archive.as_bytes().to_vec();
reserved[63] = 1;
assert!(matches!(
access::<Root>(&reserved, ArchiveLimits::new()),
Err(ArchiveError::InvalidHeader(
"reserved header bytes are non-zero"
))
));
let mut invalid_graph = AlignedVec::<MAX_ARCHIVE_ALIGNMENT>::new();
invalid_graph.extend_from_slice(archive.as_bytes());
invalid_graph[PAYLOAD_OFFSET..].fill(0xff);
assert!(matches!(
access::<Root>(&invalid_graph, ArchiveLimits::new()),
Err(ArchiveError::Validation(_))
));
}
#[test]
fn file_archive_maps_and_accesses_fields_in_place() {
let file = TemporaryArchive::new();
let archive = build(&value(), ArchiveLimits::new()).unwrap();
archive.write_new(&file.0).unwrap();
let mapped = unsafe { MappedArchive::<Root>::open(&file.0, ArchiveLimits::new()) }.unwrap();
let root = mapped.root();
assert_eq!(root.name.as_str(), "mapped");
assert_eq!(root.samples.as_slice(), [-7, 0, 11, 65_536]);
let start = mapped.as_bytes().as_ptr() as usize;
let end = start + mapped.as_bytes().len();
assert!((start..end).contains(&(root.name.as_bytes().as_ptr() as usize)));
assert!((start..end).contains(&(root.samples.as_ptr() as usize)));
}
}