use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use std::io::{Read, Write};
pub const FRAME_HEADER_LEN: usize = 4 + 8;
pub const MAX_FRAME_PAYLOAD: u32 = 64 * 1024;
pub fn write_framed_record<W, F>(
writer: &mut W,
scratch: &mut Vec<u8>,
payload_fn: F,
) -> crate::Result<()>
where
W: Write,
F: FnOnce(&mut Vec<u8>) -> crate::Result<()>,
{
scratch.clear();
payload_fn(scratch)?;
if scratch.len() > MAX_FRAME_PAYLOAD as usize {
log::error!(
"write_framed_record refusing to emit oversized payload \
({} bytes; MAX_FRAME_PAYLOAD = {})",
scratch.len(),
MAX_FRAME_PAYLOAD,
);
return Err(crate::Error::Unrecoverable);
}
#[expect(
clippy::cast_possible_truncation,
reason = "the explicit MAX_FRAME_PAYLOAD guard above ensures scratch.len() fits in u32"
)]
let len = scratch.len() as u32;
let digest = xxhash_rust::xxh3::xxh3_64(scratch);
writer.write_u32::<LittleEndian>(len)?;
writer.write_u64::<LittleEndian>(digest)?;
writer.write_all(scratch)?;
Ok(())
}
#[derive(Debug)]
pub enum FramedRecordOutcome {
Ok,
ChecksumMismatch {
bytes_consumed: u64,
expected: u64,
got: u64,
},
BadHeader,
LenMismatch {
got: u32,
expected: u32,
},
TailTruncation,
}
pub fn read_framed_record<R: Read>(
reader: &mut R,
remaining_in_section: u64,
expected_payload_len: Option<u32>,
payload_scratch: &mut Vec<u8>,
) -> crate::Result<FramedRecordOutcome> {
let len = match reader.read_u32::<LittleEndian>() {
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok(FramedRecordOutcome::TailTruncation);
}
Err(e) => return Err(e.into()),
};
if len > MAX_FRAME_PAYLOAD {
return Ok(FramedRecordOutcome::BadHeader);
}
if let Some(expected) = expected_payload_len
&& len != expected
{
return Ok(FramedRecordOutcome::LenMismatch { got: len, expected });
}
if u64::from(len) + FRAME_HEADER_LEN as u64 > remaining_in_section {
return Ok(FramedRecordOutcome::TailTruncation);
}
let digest_expected = match reader.read_u64::<LittleEndian>() {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok(FramedRecordOutcome::TailTruncation);
}
Err(e) => return Err(e.into()),
};
payload_scratch.resize(len as usize, 0);
match reader.read_exact(payload_scratch) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok(FramedRecordOutcome::TailTruncation);
}
Err(e) => return Err(e.into()),
}
let digest_actual = xxhash_rust::xxh3::xxh3_64(payload_scratch);
if digest_actual == digest_expected {
Ok(FramedRecordOutcome::Ok)
} else {
let bytes_consumed = FRAME_HEADER_LEN as u64 + u64::from(len);
Ok(FramedRecordOutcome::ChecksumMismatch {
bytes_consumed,
expected: digest_expected,
got: digest_actual,
})
}
}
#[cfg(test)]
#[expect(
clippy::expect_used,
clippy::indexing_slicing,
reason = "test code: explicit panics and direct indexing keep test \
setup readable; values are inline-known"
)]
mod tests {
use super::*;
use std::io::Cursor;
use test_log::test;
fn roundtrip(payload: &[u8]) -> Vec<u8> {
let mut buf = Vec::new();
let mut scratch = Vec::new();
write_framed_record(&mut buf, &mut scratch, |out| {
out.extend_from_slice(payload);
Ok(())
})
.expect("write");
buf
}
#[test]
fn framed_record_ok_roundtrip() {
let payload = b"hello world";
let bytes = roundtrip(payload);
let mut cursor = Cursor::new(&bytes);
let mut scratch = Vec::new();
let outcome = read_framed_record(&mut cursor, u64::MAX, None, &mut scratch).expect("read");
match outcome {
FramedRecordOutcome::Ok => assert_eq!(scratch.as_slice(), payload),
other => panic!("expected Ok, got {other:?}"),
}
}
#[test]
fn framed_record_checksum_mismatch_detected() {
let payload = b"hello world";
let mut bytes = roundtrip(payload);
bytes[FRAME_HEADER_LEN] ^= 0x01;
let mut cursor = Cursor::new(&bytes);
let outcome =
read_framed_record(&mut cursor, u64::MAX, None, &mut Vec::new()).expect("read");
match outcome {
FramedRecordOutcome::ChecksumMismatch {
bytes_consumed,
expected,
got,
} => {
assert_eq!(bytes_consumed, (FRAME_HEADER_LEN + payload.len()) as u64);
assert_ne!(expected, got);
}
other => panic!("expected ChecksumMismatch, got {other:?}"),
}
}
#[test]
fn framed_record_oversized_len_rejected_as_bad_header() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(MAX_FRAME_PAYLOAD + 1).to_le_bytes());
bytes.extend_from_slice(&0u64.to_le_bytes());
let mut cursor = Cursor::new(&bytes);
let outcome =
read_framed_record(&mut cursor, u64::MAX, None, &mut Vec::new()).expect("read");
assert!(
matches!(outcome, FramedRecordOutcome::BadHeader),
"expected BadHeader for oversized len, got {outcome:?}",
);
}
#[test]
fn framed_record_len_exceeding_section_bound_classified_as_tail_truncation() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&100u32.to_le_bytes());
bytes.extend_from_slice(&0u64.to_le_bytes());
let mut cursor = Cursor::new(&bytes);
let outcome = read_framed_record(&mut cursor, 8 + 1, None, &mut Vec::new()).expect("read");
assert!(
matches!(outcome, FramedRecordOutcome::TailTruncation),
"expected TailTruncation for len > remaining, got {outcome:?}",
);
}
#[test]
fn framed_record_tail_truncation_at_header() {
let mut cursor = Cursor::new(Vec::<u8>::new());
let outcome =
read_framed_record(&mut cursor, u64::MAX, None, &mut Vec::new()).expect("read");
assert!(
matches!(outcome, FramedRecordOutcome::TailTruncation),
"expected TailTruncation, got {outcome:?}",
);
}
#[test]
fn framed_record_tail_truncation_mid_payload() {
let payload = b"hello world";
let mut bytes = roundtrip(payload);
bytes.truncate(FRAME_HEADER_LEN + payload.len() / 2);
let mut cursor = Cursor::new(&bytes);
let outcome =
read_framed_record(&mut cursor, u64::MAX, None, &mut Vec::new()).expect("read");
assert!(
matches!(outcome, FramedRecordOutcome::TailTruncation),
"expected TailTruncation, got {outcome:?}",
);
}
#[test]
fn framed_record_fixed_len_mismatch_surfaces_lenmismatch() {
let payload = b"ten-byte!!"; let bytes = roundtrip(payload);
let mut cursor = Cursor::new(&bytes);
let outcome =
read_framed_record(&mut cursor, u64::MAX, Some(33), &mut Vec::new()).expect("read");
match outcome {
FramedRecordOutcome::LenMismatch { got, expected } => {
assert_eq!(got, 10, "got should carry the on-disk len");
assert_eq!(expected, 33, "expected should carry the caller's pin");
}
other => panic!("expected LenMismatch {{ got: 10, expected: 33 }}, got {other:?}"),
}
}
}