use crate::WireError;
use std::hash::Hasher;
use twox_hash::XxHash3_64;
pub const BATCH_HEADER_SIZE: usize = 256;
pub const BATCH_MESSAGE_HEADER_SIZE: usize = 48;
pub const BATCH_CHECKSUM_OFFSET: usize = 40;
pub const BATCH_MESSAGE_COUNT_OFFSET: usize = 48;
pub const BATCH_RESERVED_OFFSET: usize = 52;
pub const MAX_TIMESTAMP_DELTA_MICROS: u64 = u32::MAX as u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BatchHeader {
pub partition_id: u64,
pub base_offset: u64,
pub base_timestamp: u64,
pub origin_timestamp: u64,
pub batch_length: u64,
pub batch_checksum: u64,
pub message_count: u32,
}
impl BatchHeader {
#[must_use]
pub const fn new(
partition_id: u64,
origin_timestamp: u64,
batch_length: u64,
message_count: u32,
) -> Self {
Self {
partition_id,
base_offset: 0,
base_timestamp: 0,
origin_timestamp,
batch_length,
batch_checksum: 0,
message_count,
}
}
pub fn decode(bytes: &[u8]) -> Result<Self, WireError> {
if bytes.len() < BATCH_HEADER_SIZE {
return Err(WireError::UnexpectedEof {
offset: 0,
need: BATCH_HEADER_SIZE,
have: bytes.len(),
});
}
let batch_length = read_u64(bytes, 32);
if batch_length < BATCH_HEADER_SIZE as u64 {
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"batch length must cover the batch header",
)));
}
if bytes[BATCH_RESERVED_OFFSET..BATCH_HEADER_SIZE]
.iter()
.any(|&reserved_byte| reserved_byte != 0)
{
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"batch header reserved bytes must be zero",
)));
}
Ok(Self {
partition_id: read_u64(bytes, 0),
base_offset: read_u64(bytes, 8),
base_timestamp: read_u64(bytes, 16),
origin_timestamp: read_u64(bytes, 24),
batch_length,
batch_checksum: read_u64(bytes, BATCH_CHECKSUM_OFFSET),
message_count: read_u32(bytes, BATCH_MESSAGE_COUNT_OFFSET),
})
}
pub fn encode_into(&self, bytes: &mut [u8]) {
assert!(bytes.len() >= BATCH_HEADER_SIZE);
bytes[..BATCH_HEADER_SIZE].fill(0);
bytes[0..8].copy_from_slice(&self.partition_id.to_le_bytes());
bytes[8..16].copy_from_slice(&self.base_offset.to_le_bytes());
bytes[16..24].copy_from_slice(&self.base_timestamp.to_le_bytes());
bytes[24..32].copy_from_slice(&self.origin_timestamp.to_le_bytes());
bytes[32..40].copy_from_slice(&self.batch_length.to_le_bytes());
bytes[BATCH_CHECKSUM_OFFSET..BATCH_CHECKSUM_OFFSET + 8]
.copy_from_slice(&self.batch_checksum.to_le_bytes());
bytes[BATCH_MESSAGE_COUNT_OFFSET..BATCH_MESSAGE_COUNT_OFFSET + 4]
.copy_from_slice(&self.message_count.to_le_bytes());
}
#[must_use]
pub fn total_size(&self) -> usize {
usize::try_from(self.batch_length).expect("batch length exceeds usize::MAX")
}
pub fn blob_len(&self) -> Result<usize, WireError> {
self.batch_length
.checked_sub(BATCH_HEADER_SIZE as u64)
.and_then(|len| usize::try_from(len).ok())
.ok_or(WireError::Validation(std::borrow::Cow::Borrowed(
"batch length must cover the batch header",
)))
}
#[must_use]
pub fn checksum_for_blob(&self, blob: &[u8]) -> u64 {
calculate_batch_checksum(self, blob)
}
}
#[derive(Debug, Clone, Copy)]
pub struct BatchRef<'a> {
pub header: BatchHeader,
blob: &'a [u8],
}
impl<'a> BatchRef<'a> {
#[must_use]
pub const fn new(header: BatchHeader, blob: &'a [u8]) -> Self {
Self { header, blob }
}
#[must_use]
pub const fn iter(&self) -> BatchIterator<'a> {
BatchIterator {
blob: self.blob,
position: 0,
}
}
#[must_use]
pub const fn iter_with_offsets(&self) -> BatchIteratorWithOffsets<'a> {
BatchIteratorWithOffsets {
blob: self.blob,
position: 0,
}
}
#[must_use]
pub const fn blob(&self) -> &'a [u8] {
self.blob
}
#[must_use]
pub const fn message_count(&self) -> u32 {
self.header.message_count
}
}
impl<'a> IntoIterator for &BatchRef<'a> {
type Item = BatchMessageView<'a>;
type IntoIter = BatchIterator<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
#[derive(Debug, Clone, Copy)]
pub struct BatchMessageHeader {
pub checksum: u64,
pub id: u128,
pub offset_delta: u32,
pub timestamp_delta: u32,
pub user_headers_length: u32,
pub payload_length: u32,
}
impl BatchMessageHeader {
pub fn decode(bytes: &[u8]) -> Result<Self, WireError> {
if bytes.len() < BATCH_MESSAGE_HEADER_SIZE {
return Err(WireError::UnexpectedEof {
offset: 0,
need: BATCH_MESSAGE_HEADER_SIZE,
have: bytes.len(),
});
}
if read_u64(bytes, 40) != 0 {
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"message frame reserved bytes must be zero",
)));
}
Ok(Self {
checksum: read_u64(bytes, 0),
id: read_u128(bytes, 8),
offset_delta: read_u32(bytes, 24),
timestamp_delta: read_u32(bytes, 28),
user_headers_length: read_u32(bytes, 32),
payload_length: read_u32(bytes, 36),
})
}
#[must_use]
pub const fn total_size(&self) -> usize {
BATCH_MESSAGE_HEADER_SIZE + self.user_headers_length as usize + self.payload_length as usize
}
}
#[derive(Debug, Clone, Copy)]
pub struct BatchMessageView<'a> {
pub header: BatchMessageHeader,
pub user_headers: &'a [u8],
pub payload: &'a [u8],
}
pub struct BatchIterator<'a> {
blob: &'a [u8],
position: usize,
}
impl<'a> Iterator for BatchIterator<'a> {
type Item = BatchMessageView<'a>;
fn next(&mut self) -> Option<Self::Item> {
if self.position >= self.blob.len() {
return None;
}
let header = BatchMessageHeader::decode(&self.blob[self.position..]).ok()?;
let start = self.position + BATCH_MESSAGE_HEADER_SIZE;
let payload_end = start + header.payload_length as usize;
let headers_end = payload_end + header.user_headers_length as usize;
let payload = self.blob.get(start..payload_end)?;
let user_headers = self.blob.get(payload_end..headers_end)?;
self.position += header.total_size();
Some(BatchMessageView {
header,
user_headers,
payload,
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct BatchMessageViewWithOffsets<'a> {
pub message: BatchMessageView<'a>,
pub start: usize,
pub end: usize,
}
pub struct BatchIteratorWithOffsets<'a> {
blob: &'a [u8],
position: usize,
}
impl<'a> Iterator for BatchIteratorWithOffsets<'a> {
type Item = BatchMessageViewWithOffsets<'a>;
fn next(&mut self) -> Option<Self::Item> {
if self.position >= self.blob.len() {
return None;
}
let start = self.position;
let header = BatchMessageHeader::decode(&self.blob[self.position..]).ok()?;
let message_start = self.position + BATCH_MESSAGE_HEADER_SIZE;
let payload_end = message_start + header.payload_length as usize;
let headers_end = payload_end + header.user_headers_length as usize;
let payload = self.blob.get(message_start..payload_end)?;
let user_headers = self.blob.get(payload_end..headers_end)?;
self.position += header.total_size();
Some(BatchMessageViewWithOffsets {
message: BatchMessageView {
header,
user_headers,
payload,
},
start,
end: self.position,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchIntegrity {
Verify,
LayoutOnly,
}
pub fn decode_batch_slice(body: &[u8]) -> Result<BatchRef<'_>, WireError> {
decode_batch_slice_with(body, BatchIntegrity::Verify)
}
pub fn decode_batch_slice_with(
body: &[u8],
integrity: BatchIntegrity,
) -> Result<BatchRef<'_>, WireError> {
let header = BatchHeader::decode(body)?;
let blob_len = header.blob_len()?;
if body.len() < header.total_size() {
return Err(WireError::UnexpectedEof {
offset: 0,
need: header.total_size(),
have: body.len(),
});
}
let blob = &body[BATCH_HEADER_SIZE..BATCH_HEADER_SIZE + blob_len];
let batch = BatchRef { header, blob };
match integrity {
BatchIntegrity::Verify => {
let expected_checksum = verify_and_recompute_batch_checksum(&batch)?;
if header.batch_checksum != expected_checksum {
return Err(WireError::InvalidBatchChecksum {
stored: header.batch_checksum,
computed: expected_checksum,
base_offset: header.base_offset,
});
}
}
BatchIntegrity::LayoutOnly => validate_batch_layout(&batch)?,
}
Ok(batch)
}
#[must_use]
pub fn calculate_batch_checksum(header: &BatchHeader, blob: &[u8]) -> u64 {
let mut hasher = XxHash3_64::new();
write_batch_header_fields(&mut hasher, header);
let batch = BatchRef {
header: *header,
blob,
};
for framed in batch.iter_with_offsets() {
hasher.write(&blob[framed.start..framed.start + 8]);
}
hasher.finish()
}
fn write_batch_header_fields(hasher: &mut XxHash3_64, header: &BatchHeader) {
hasher.write(&header.partition_id.to_le_bytes());
hasher.write(&header.base_offset.to_le_bytes());
hasher.write(&header.base_timestamp.to_le_bytes());
hasher.write(&header.origin_timestamp.to_le_bytes());
hasher.write(&header.batch_length.to_le_bytes());
hasher.write(&header.message_count.to_le_bytes());
}
pub fn verify_and_recompute_batch_checksum(batch: &BatchRef<'_>) -> Result<u64, WireError> {
let blob = batch.blob();
let mut hasher = XxHash3_64::new();
write_batch_header_fields(&mut hasher, &batch.header);
let mut verified = 0u32;
let mut covered = 0usize;
for framed in batch.iter_with_offsets() {
let stored = framed.message.header.checksum;
let expected = XxHash3_64::oneshot(&blob[framed.start + 8..framed.end]);
if expected != stored {
return Err(WireError::InvalidMessageChecksum {
stored,
computed: expected,
offset: batch
.header
.base_offset
.saturating_add(u64::from(framed.message.header.offset_delta)),
});
}
hasher.write(&blob[framed.start..framed.start + 8]);
verified += 1;
covered = framed.end;
}
if verified != batch.message_count() || covered != blob.len() {
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"batch frames do not tile message_count exactly",
)));
}
Ok(hasher.finish())
}
fn validate_batch_layout(batch: &BatchRef<'_>) -> Result<(), WireError> {
let blob = batch.blob();
let mut counted = 0u32;
let mut covered = 0usize;
for framed in batch.iter_with_offsets() {
counted += 1;
covered = framed.end;
}
if counted != batch.message_count() || covered != blob.len() {
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"batch frames do not tile message_count exactly",
)));
}
Ok(())
}
fn read_u32(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("4-byte slice"))
}
fn read_u64(bytes: &[u8], offset: usize) -> u64 {
u64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("8-byte slice"))
}
fn read_u128(bytes: &[u8], offset: usize) -> u128 {
u128::from_le_bytes(
bytes[offset..offset + 16]
.try_into()
.expect("16-byte slice"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::cast_possible_truncation)]
fn frame(id: u128, offset_delta: u32, timestamp_delta: u32, payload: &[u8]) -> Vec<u8> {
let mut bytes = vec![0u8; BATCH_MESSAGE_HEADER_SIZE];
bytes[8..24].copy_from_slice(&id.to_le_bytes());
bytes[24..28].copy_from_slice(&offset_delta.to_le_bytes());
bytes[28..32].copy_from_slice(×tamp_delta.to_le_bytes());
bytes[36..40].copy_from_slice(&(payload.len() as u32).to_le_bytes());
bytes.extend_from_slice(payload);
let checksum = XxHash3_64::oneshot(&bytes[8..]);
bytes[0..8].copy_from_slice(&checksum.to_le_bytes());
bytes
}
#[allow(clippy::cast_possible_truncation)]
fn batch_bytes(frames: &[Vec<u8>]) -> Vec<u8> {
let blob: Vec<u8> = frames.concat();
let mut header = BatchHeader::new(
7,
1_000,
(BATCH_HEADER_SIZE + blob.len()) as u64,
frames.len() as u32,
);
header.batch_checksum = calculate_batch_checksum(&header, &blob);
let mut bytes = vec![0u8; BATCH_HEADER_SIZE];
header.encode_into(&mut bytes);
bytes.extend_from_slice(&blob);
bytes
}
#[test]
fn header_roundtrip() {
let mut header = BatchHeader::new(1, 2, 300, 4);
header.base_offset = 10;
header.base_timestamp = 20;
header.batch_checksum = 30;
let mut bytes = vec![0u8; BATCH_HEADER_SIZE];
header.encode_into(&mut bytes);
let decoded = BatchHeader::decode(&bytes).unwrap();
assert_eq!(decoded, header);
}
#[test]
fn decode_verifies_checksums() {
let bytes = batch_bytes(&[frame(1, 0, 0, b"first"), frame(2, 1, 5, b"second")]);
let batch = decode_batch_slice(&bytes).unwrap();
assert_eq!(batch.message_count(), 2);
let views: Vec<_> = batch.iter().collect();
assert_eq!(views[0].payload, b"first");
assert_eq!(views[1].payload, b"second");
assert_eq!(views[1].header.offset_delta, 1);
}
#[test]
fn decode_rejects_flipped_body_byte() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
let last = bytes.len() - 1;
bytes[last] ^= 0xFF;
assert!(matches!(
decode_batch_slice(&bytes),
Err(WireError::InvalidMessageChecksum { .. })
));
}
#[test]
fn decode_rejects_flipped_batch_checksum() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_CHECKSUM_OFFSET] ^= 0xFF;
assert!(matches!(
decode_batch_slice(&bytes),
Err(WireError::InvalidBatchChecksum { .. })
));
}
#[test]
fn layout_only_accepts_zero_checksums() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_CHECKSUM_OFFSET..BATCH_CHECKSUM_OFFSET + 8].fill(0);
let batch = decode_batch_slice_with(&bytes, BatchIntegrity::LayoutOnly).unwrap();
assert_eq!(batch.message_count(), 1);
}
#[test]
fn decode_rejects_miscounted_batch() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_MESSAGE_COUNT_OFFSET..BATCH_MESSAGE_COUNT_OFFSET + 4]
.copy_from_slice(&2u32.to_le_bytes());
assert!(decode_batch_slice_with(&bytes, BatchIntegrity::LayoutOnly).is_err());
}
#[test]
fn decode_rejects_truncated_batch() {
let bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
assert!(decode_batch_slice(&bytes[..bytes.len() - 1]).is_err());
}
#[test]
fn decode_rejects_nonzero_reserved() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_HEADER_SIZE + 40] = 1;
assert!(decode_batch_slice_with(&bytes, BatchIntegrity::LayoutOnly).is_err());
}
#[test]
fn decode_rejects_nonzero_header_reserved() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_RESERVED_OFFSET] = 1;
assert!(matches!(
BatchHeader::decode(&bytes),
Err(WireError::Validation(_))
));
bytes[BATCH_RESERVED_OFFSET] = 0;
bytes[BATCH_HEADER_SIZE - 1] = 1;
assert!(BatchHeader::decode(&bytes).is_err());
}
}