From b691d33c4dd27390087fe0ec44fe671bda5f47f7 Mon Sep 17 00:00:00 2001 From: Hubert Gruszecki Date: Mon, 3 Aug 2026 10:56:56 +0200 Subject: [PATCH] perf(server-ng): compute batch checksum in a single produce pass Produce hashed each batch three times: convert, stamp, and flush revalidation. The batch checksum is redefined to cover the six header meta fields plus each message's stored checksum field instead of the whole blob, binding bodies transitively through the per-message checksums every validating decode re-verifies. Stamping now hashes N*8 bytes instead of the full blob, and locally originated batches decode trusted; replicated blobs keep a per-message receive gate on followers, so transit integrity holds end to end. Message data written by earlier server-ng builds fails checksum validation after this change; wipe local_data when upgrading. Per-message checksums are computed in a single oneshot pass over the encoded record, pinned by a streaming-vs-oneshot reference test. A frame walk stops at the last decodable record, so bytes past batch_length no longer alter any checksum. Both ingest boundaries therefore require the frame to end exactly at batch_length: a SendMessages request or prepare whose size overshoots is now rejected rather than carrying the surplus to disk, where the flush would write it and the segment walk would step over it into junk. --- core/partitions/src/iggy_partition.rs | 58 +- core/partitions/src/iggy_partitions.rs | 8 +- core/partitions/src/journal.rs | 13 +- core/server-ng/src/responses.rs | 2 +- core/server_common/src/send_messages2.rs | 987 ++++++++++++++++++++--- core/simulator/src/client.rs | 4 +- 6 files changed, 944 insertions(+), 128 deletions(-) diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs index eb48863c52..6695aae42c 100644 --- a/core/partitions/src/iggy_partition.rs +++ b/core/partitions/src/iggy_partition.rs @@ -57,7 +57,8 @@ use server_common::{ MESSAGE_ALIGN, Message, SegmentStorage, iobuf::{Frozen, Owned}, send_messages2::{ - convert_request_message, decode_prepare_slice, stamp_prepare_for_persistence, + ChecksumMode, convert_request_message, decode_prepare_slice, decode_prepare_slice_trusted, + stamp_prepare_for_persistence, verify_received_send_messages, }, sharding::IggyNamespace, }; @@ -1139,7 +1140,13 @@ where ); let message = if message.header().operation == Operation::SendMessages { - match convert_request_message(namespace, message) { + // Skip the batch-checksum pass: on the partition ingest path + // nothing reads it before `stamp_prepare_for_persistence` + // recomputes it over the stamped header. An already-canonical + // batch (native v2, or the plane's pre-encrypt convert output) + // returns early above, so Skip only affects the legacy + // transcode, whose output goes straight to project/stamp. + match convert_request_message(namespace, message, ChecksumMode::Skip) { Ok(message) => message, Err(error) => { emit_partition_diag( @@ -1522,6 +1529,32 @@ where ); } } + // First blob-integrity check on the replicated path. The consensus + // layer never validates the body (PrepareHeader integrity fields are + // inert zeros) and the batch checksum is recomputed locally at stamp, + // so a follower must verify each message's stamp-invariant per-message + // checksum before journaling transit bytes. Follower-only: the primary + // (and single-node self-replicate) produced these bytes and already + // checked the client batch at ingest, so they must not pay this pass. + // Fail closed on mismatch - drop without journaling, forwarding, or + // acking; the primary retransmits on prepare-timeout. + if is_backup + && header.operation == Operation::SendMessages + && let Err(error) = verify_received_send_messages(message.as_slice()) + { + emit_partition_diag( + tracing::Level::WARN, + &PartitionDiagEvent::new( + self.diag_ctx(), + "rejecting replicated send_messages: per-message checksum mismatch", + ) + .with_operation(header.operation) + .with_op(header.op) + .with_error(error.to_string()), + ); + return; + } + // Durability-before-ack: clone for chain-replicate, forward only // AFTER apply_replicated_operation persists. Forward-first would // give downstream an op whose WAL entry we never wrote, that violates @@ -1931,11 +1964,15 @@ where } continue; } - // A resident committed SendMessages entry decoded once at append - // (the offset index) with its checksum stamped over these exact - // bytes, so it must decode again here. Guard the invariant for a - // future disk read-back path that could make decode fallible. - let Ok(batch) = decode_prepare_slice(entry.as_slice()) else { + // Resident committed SendMessages entry: this node stamped it + // in `append_messages` (recomputing the batch checksum over these + // exact bytes), so a validating re-decode would only re-hash ~1 + // MiB to confirm our own write. Trust the structural decode; the + // batch-checksum recompute belongs at network ingress (repair + // validation + the follower receive gate), not on locally-stamped + // bytes. Guard the invariant for a future disk read-back path that + // could make decode fallible. + let Ok(batch) = decode_prepare_slice_trusted(entry.as_slice()) else { tracing::error!( target: "iggy.partitions.diag", namespace_raw = self.namespace().inner(), @@ -2335,8 +2372,11 @@ where let Some(entry) = self.log.journal().inner.entry(prepare_header).await else { return Err(IggyError::InvalidCommand); }; - let batch = - decode_prepare_slice(entry.as_slice()).map_err(|_| IggyError::InvalidCommand)?; + // Trusted (no batch-hash): the entry was read back from this replica's + // own journal, where it was stamped/validated at append; only header + // stats are needed, so re-hashing the ~1 MiB blob is redundant. + let batch = decode_prepare_slice_trusted(entry.as_slice()) + .map_err(|_| IggyError::InvalidCommand)?; let message_count = batch.message_count(); if message_count == 0 { return Ok(None); diff --git a/core/partitions/src/iggy_partitions.rs b/core/partitions/src/iggy_partitions.rs index 202799a60c..57d4c1773e 100644 --- a/core/partitions/src/iggy_partitions.rs +++ b/core/partitions/src/iggy_partitions.rs @@ -26,7 +26,7 @@ use iggy_binary_protocol::{ Command2, ConsensusHeader, Operation, PrepareHeader, PrepareOkHeader, RequestHeader, }; use message_bus::MessageBus; -use server_common::send_messages2::{convert_request_message, encrypt_batch_request}; +use server_common::send_messages2::{ChecksumMode, convert_request_message, encrypt_batch_request}; use server_common::sharding::{IggyNamespace, LocalIdx, ShardId}; #[cfg(debug_assertions)] use std::cell::Cell; @@ -509,7 +509,11 @@ where let message = if message.header().operation == Operation::SendMessages && let Some(encryptor) = &self.config().encryptor { - let canonical = convert_request_message(namespace, message) + // Compute the batch checksum: this canonical output is validated by + // `encrypt_batch_request`'s decode before re-encryption, and the + // re-encrypted batch (checksum kept by `encrypt_batch_request`) then + // re-enters `convert` as the canonical-vs-legacy discriminator. + let canonical = convert_request_message(namespace, message, ChecksumMode::Compute) .and_then(|message| encrypt_batch_request(message, encryptor)); match canonical { Ok(message) => message, diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs index da648fcb65..0b2eaf8d09 100644 --- a/core/partitions/src/journal.rs +++ b/core/partitions/src/journal.rs @@ -19,7 +19,7 @@ use iggy_binary_protocol::{Operation, PrepareHeader}; use journal::{Journal, Storage}; use server_common::{ iobuf::{Frozen, Owned}, - send_messages2::{COMMAND_HEADER_SIZE, SendMessages2Ref, decode_prepare_slice}, + send_messages2::{COMMAND_HEADER_SIZE, SendMessages2Ref, decode_prepare_slice_trusted}, }; use std::io; use std::{ @@ -538,8 +538,12 @@ impl PartitionJournal { // One decode feeds both the offset/timestamp index (keyed on // `origin_timestamp`) and the surfaced accounting meta (`base_timestamp`, // size, count); the two timestamps are distinct fields, do not conflate. + // Trusted (no batch-hash): every entry reaching append was just stamped + // by `stamp_prepare_for_persistence` (its checksum recomputed over this + // exact blob) or re-appended from an already-validated resident entry, + // so re-hashing the ~1 MiB blob here only to read the header is waste. let (index_offset_timestamp, meta) = if header.operation == Operation::SendMessages { - match decode_prepare_slice(entry.as_slice()) { + match decode_prepare_slice_trusted(entry.as_slice()) { Ok(batch) if batch.message_count() != 0 => { let message_count = batch.message_count(); let meta = RetainedBatchMeta { @@ -885,7 +889,10 @@ fn try_push_resident_entry( if header.operation != Operation::SendMessages { return; } - let Ok(batch) = decode_prepare_slice(prepare.as_slice()) else { + // Resident entries were locally stamped in `append_messages` or validated + // at repair ingress, so a validating re-decode would only re-hash our own + // write. See the invariant note at the committed-prefix flush walk. + let Ok(batch) = decode_prepare_slice_trusted(prepare.as_slice()) else { return; }; let Some(selection) = select_batch_slice(&batch, query, *matched_messages) else { diff --git a/core/server-ng/src/responses.rs b/core/server-ng/src/responses.rs index bfa459c680..6cc585230a 100644 --- a/core/server-ng/src/responses.rs +++ b/core/server-ng/src/responses.rs @@ -1437,7 +1437,7 @@ where /// Size of the in-storage (`IggyMessage2`) per-message header inside a /// `SendMessages2` batch blob: `checksum`(8) + `id`(16) + `offset_delta`(4) /// + `timestamp_delta`(4) + `user_headers_length`(4) + `payload_length`(4) -/// + reserved(8). See `server_common::send_messages2::from_legacy_request`. +/// + reserved(8). See `server_common::send_messages2::SendMessages2Owned::from_messages`. const STORED_MESSAGE_HEADER_SIZE: usize = 48; /// Build the `PolledMessages` reply body from the owning shard's poll diff --git a/core/server_common/src/send_messages2.rs b/core/server_common/src/send_messages2.rs index a64cd06641..f0d9a7f88d 100644 --- a/core/server_common/src/send_messages2.rs +++ b/core/server_common/src/send_messages2.rs @@ -173,84 +173,12 @@ impl SendMessages2Owned { header[32..36].copy_from_slice(&user_headers_length.to_le_bytes()); header[36..40].copy_from_slice(&payload_length.to_le_bytes()); - let checksum = calculate_checksum_parts(&header[8..], &message.payload, user_headers); - header[0..8].copy_from_slice(&checksum.to_le_bytes()); - + let msg_start = blob.len(); blob.extend_from_slice(&header); blob.extend_from_slice(&message.payload); blob.extend_from_slice(user_headers); - } - - let blob = blob.freeze(); - let mut header = SendMessages2Header::new( - namespace.partition_id() as u64, - origin_timestamp, - u64::try_from(COMMAND_HEADER_SIZE + blob.len()) - .map_err(|_| IggyError::InvalidCommand)?, - message_count, - ); - header.batch_checksum = calculate_batch_checksum(&header, &blob); - - Ok(Self { header, blob }) - } - - pub fn from_legacy_request(namespace: IggyNamespace, body: &[u8]) -> Result { - let (message_count, messages) = legacy_messages_slice(body)?; - let mut parsed = Vec::with_capacity(message_count as usize); - let mut origin_timestamp = u64::MAX; - let mut cursor = 0usize; - - while cursor < messages.len() && parsed.len() < message_count as usize { - let legacy = LegacyMessageRef::decode(&messages[cursor..])?; - origin_timestamp = origin_timestamp.min(legacy.origin_timestamp); - cursor += legacy.total_size; - parsed.push(legacy); - } - - if parsed.len() != message_count as usize || cursor != messages.len() { - return Err(IggyError::InvalidCommand); - } - - if origin_timestamp == u64::MAX { - origin_timestamp = 0; - } - - let mut blob = BytesMut::with_capacity(messages.len()); - for (index, legacy) in parsed.iter().enumerate() { - let id = if legacy.id == 0 { - random_id::get_uuid() - } else { - legacy.id - }; - let offset_delta = u32::try_from(index).map_err(|_| IggyError::InvalidCommand)?; - let timestamp_delta = legacy - .origin_timestamp - .checked_sub(origin_timestamp) - .ok_or(IggyError::InvalidCommand)?; - if timestamp_delta > MAX_TIMESTAMP_DELTA_MICROS { - return Err(IggyError::InvalidMessageTimestampDelta(timestamp_delta)); - } - let timestamp_delta = - u32::try_from(timestamp_delta).map_err(|_| IggyError::InvalidCommand)?; - let user_headers_length = - u32::try_from(legacy.user_headers.len()).map_err(|_| IggyError::InvalidCommand)?; - let payload_length = - u32::try_from(legacy.payload.len()).map_err(|_| IggyError::InvalidCommand)?; - - let mut header = [0u8; MESSAGE_HEADER_SIZE]; - header[8..24].copy_from_slice(&id.to_le_bytes()); - header[24..28].copy_from_slice(&offset_delta.to_le_bytes()); - header[28..32].copy_from_slice(×tamp_delta.to_le_bytes()); - header[32..36].copy_from_slice(&user_headers_length.to_le_bytes()); - header[36..40].copy_from_slice(&payload_length.to_le_bytes()); - - let checksum = - calculate_checksum_parts(&header[8..], legacy.payload, legacy.user_headers); - header[0..8].copy_from_slice(&checksum.to_le_bytes()); - - blob.extend_from_slice(&header); - blob.extend_from_slice(legacy.payload); - blob.extend_from_slice(legacy.user_headers); + let checksum = XxHash3_64::oneshot(&blob[msg_start + 8..]); + blob[msg_start..msg_start + 8].copy_from_slice(&checksum.to_le_bytes()); } let blob = blob.freeze(); @@ -573,12 +501,12 @@ pub fn encrypt_batch_request( header[28..32].copy_from_slice(&view.header.timestamp_delta.to_le_bytes()); header[32..36].copy_from_slice(&user_headers_length.to_le_bytes()); header[36..40].copy_from_slice(&payload_length.to_le_bytes()); - let checksum = calculate_checksum_parts(&header[8..], &encrypted_payload, user_headers); - header[0..8].copy_from_slice(&checksum.to_le_bytes()); - + let msg_start = blob.len(); blob.extend_from_slice(&header); blob.extend_from_slice(&encrypted_payload); blob.extend_from_slice(user_headers); + let checksum = XxHash3_64::oneshot(&blob[msg_start + 8..]); + blob[msg_start..msg_start + 8].copy_from_slice(&checksum.to_le_bytes()); } let blob = blob.freeze(); @@ -590,22 +518,165 @@ pub fn encrypt_batch_request( SendMessages2Owned { header, blob }.encode_request(request_header) } +/// Whether the legacy transcode stamps a batch checksum onto its output. +/// +/// The recompute is an `XxHash3` batch-checksum pass, needed only when a reader +/// validates the transcoded batch before [`stamp_prepare_for_persistence`] +/// recomputes it: the encrypt ingest path re-decodes the canonicalized batch +/// (`encrypt_batch_request`'s validating decode, then the second `convert` its +/// output re-enters as the canonical-vs-legacy discriminator). The partition +/// ingest path has no such reader, so it skips the pass and the checksum stays +/// zero until stamp. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChecksumMode { + /// Compute the batch checksum for the transcoded batch. + Compute, + /// Leave the batch checksum zero; `stamp_prepare_for_persistence` fills it. + Skip, +} + pub fn convert_request_message( namespace: IggyNamespace, message: Message, + checksum: ChecksumMode, ) -> Result, IggyError> { let request_header = *message.header(); let total_size = request_header.size as usize; let body = &message.as_slice()[std::mem::size_of::()..total_size]; - if decode_batch_slice(body).is_ok() { - return Ok(message); + // A canonical body enters the pipeline verbatim, so it must end exactly at + // `batch_length`: `size` and `batch_length` are independent client-supplied + // fields and `decode_batch_slice` only lower-bounds the frame. A suffix past + // `batch_length` is covered by no checksum, still rides the buffer to disk, + // and desyncs the segment walk that advances by `batch_length`. + match decode_batch_slice(body).map(|batch| batch.header.total_size()) { + Ok(batch_length) if body.len() == batch_length => Ok(message), + Ok(_) => Err(IggyError::InvalidCommand), + Err(_) => transcode_legacy_request(namespace, body, request_header, checksum), } - SendMessages2Owned::from_legacy_request(namespace, body)?.encode_request(request_header) } -/// Decode one batch slice (`[256B command header][blob]`), validating the -/// batch checksum. The persisted segment-file record and the request slice -/// share this layout, so both decode through here. +/// Transcode a legacy `SendMessages` request body directly into the canonical +/// `[RequestHeader][256B SendMessages2Header][blob]` form, writing each message +/// record straight into the final aligned buffer. +/// +/// Fused replacement for the `from_legacy_request(..).encode_request(..)` +/// two-step: a size walk over the legacy input sizes the single output +/// allocation, then a write walk lays down each canonical record in place. This +/// drops the intermediate blob allocation and the full-blob copy the two-step +/// paid. Output bytes are identical to that path. +/// +/// `checksum` selects whether the output carries a batch checksum (see +/// [`ChecksumMode`]); [`ChecksumMode::Skip`] leaves it zero for the partition +/// ingest path, where stamp recomputes it. +fn transcode_legacy_request( + namespace: IggyNamespace, + body: &[u8], + mut request_header: RequestHeader, + checksum: ChecksumMode, +) -> Result, IggyError> { + let (message_count, messages) = legacy_messages_slice(body)?; + let mut parsed = Vec::with_capacity(message_count as usize); + let mut origin_timestamp = u64::MAX; + let mut cursor = 0usize; + let mut blob_len = 0usize; + + while cursor < messages.len() && parsed.len() < message_count as usize { + let legacy = LegacyMessageRef::decode(&messages[cursor..])?; + origin_timestamp = origin_timestamp.min(legacy.origin_timestamp); + cursor += legacy.total_size; + blob_len = blob_len + .checked_add(MESSAGE_HEADER_SIZE + legacy.payload.len() + legacy.user_headers.len()) + .ok_or(IggyError::InvalidCommand)?; + parsed.push(legacy); + } + + if parsed.len() != message_count as usize || cursor != messages.len() { + return Err(IggyError::InvalidCommand); + } + + if origin_timestamp == u64::MAX { + origin_timestamp = 0; + } + + let header_size = std::mem::size_of::(); + let batch_length = COMMAND_HEADER_SIZE + .checked_add(blob_len) + .ok_or(IggyError::InvalidCommand)?; + let total_size = header_size + .checked_add(batch_length) + .ok_or(IggyError::InvalidCommand)?; + request_header.size = u32::try_from(total_size).map_err(|_| IggyError::InvalidCommand)?; + + let mut buffer = Owned::::zeroed(total_size); + let bytes = buffer.as_mut_slice(); + bytes[0..header_size].copy_from_slice(bytemuck::bytes_of(&request_header)); + + let mut write = PREPARE_SPLIT_POINT; + for (index, legacy) in parsed.iter().enumerate() { + let id = if legacy.id == 0 { + random_id::get_uuid() + } else { + legacy.id + }; + let offset_delta = u32::try_from(index).map_err(|_| IggyError::InvalidCommand)?; + let timestamp_delta = legacy + .origin_timestamp + .checked_sub(origin_timestamp) + .ok_or(IggyError::InvalidCommand)?; + if timestamp_delta > MAX_TIMESTAMP_DELTA_MICROS { + return Err(IggyError::InvalidMessageTimestampDelta(timestamp_delta)); + } + let timestamp_delta = + u32::try_from(timestamp_delta).map_err(|_| IggyError::InvalidCommand)?; + let user_headers_length = + u32::try_from(legacy.user_headers.len()).map_err(|_| IggyError::InvalidCommand)?; + let payload_length = + u32::try_from(legacy.payload.len()).map_err(|_| IggyError::InvalidCommand)?; + + let mut header = [0u8; MESSAGE_HEADER_SIZE]; + header[8..24].copy_from_slice(&id.to_le_bytes()); + header[24..28].copy_from_slice(&offset_delta.to_le_bytes()); + header[28..32].copy_from_slice(×tamp_delta.to_le_bytes()); + header[32..36].copy_from_slice(&user_headers_length.to_le_bytes()); + header[36..40].copy_from_slice(&payload_length.to_le_bytes()); + let msg_start = write; + bytes[write..write + MESSAGE_HEADER_SIZE].copy_from_slice(&header); + write += MESSAGE_HEADER_SIZE; + bytes[write..write + legacy.payload.len()].copy_from_slice(legacy.payload); + write += legacy.payload.len(); + bytes[write..write + legacy.user_headers.len()].copy_from_slice(legacy.user_headers); + write += legacy.user_headers.len(); + // The cover is [msg_start + 8 .. write], including the 8 reserved zero + // header bytes. This relies on the stack header being zero-initialized, + // not on the output buffer being pre-zeroed. + let checksum = XxHash3_64::oneshot(&bytes[msg_start + 8..write]); + bytes[msg_start..msg_start + 8].copy_from_slice(&checksum.to_le_bytes()); + } + + let mut command = SendMessages2Header::new( + namespace.partition_id() as u64, + origin_timestamp, + batch_length as u64, + message_count, + ); + if checksum == ChecksumMode::Compute { + command.batch_checksum = calculate_batch_checksum( + &command, + &bytes[PREPARE_SPLIT_POINT..PREPARE_SPLIT_POINT + blob_len], + ); + } + command.encode_into(&mut bytes[header_size..header_size + COMMAND_HEADER_SIZE]); + + Message::try_from(buffer).map_err(|_| IggyError::InvalidCommand) +} + +/// Decode one batch slice (`[256B command header][blob]`), validating the batch +/// checksum and every per-message checksum. The persisted segment-file record +/// and the request slice share this layout, so both decode through here. +/// +/// `body` may extend past the batch: the poll disk walk hands in the rest of the +/// chunk and steps by `batch_length`. Callers whose buffer is meant to BE the +/// batch must reject the surplus themselves - see [`convert_request_message`]. pub fn decode_batch_slice(body: &[u8]) -> Result, IggyError> { if body.len() < COMMAND_HEADER_SIZE { return Err(IggyError::InvalidCommand); @@ -618,7 +689,8 @@ pub fn decode_batch_slice(body: &[u8]) -> Result, IggyError } let blob = &body[COMMAND_HEADER_SIZE..COMMAND_HEADER_SIZE + blob_len]; - let expected_checksum = calculate_batch_checksum(&header, blob); + let batch = SendMessages2Ref { header, blob }; + let expected_checksum = verify_and_recompute_batch_checksum(&batch)?; if header.batch_checksum != expected_checksum { return Err(IggyError::InvalidBatchChecksum( header.batch_checksum, @@ -627,10 +699,11 @@ pub fn decode_batch_slice(body: &[u8]) -> Result, IggyError )); } - Ok(SendMessages2Ref { header, blob }) + Ok(batch) } -/// Decode a `Prepare` message from a slice of bytes. +/// Decode a `Prepare` message from a slice of bytes, validating the batch +/// checksum and every per-message checksum. /// /// `bytes` must be 16-byte aligned (`PrepareHeader` has `u128` fields). Source /// from `Frozen` / `Owned` / `Message`. @@ -638,9 +711,43 @@ pub fn decode_batch_slice(body: &[u8]) -> Result, IggyError /// /// # Errors /// -/// `IggyError::InvalidCommand` on: short buffer, bad bit pattern, `size` -/// outside `[header_size, bytes.len()]`, short/checksum-mismatched body. +/// `IggyError::InvalidCommand` on a short buffer, bad bit pattern, `size` +/// outside `[header_size, bytes.len()]`, a `size` that does not describe the +/// batch exactly, or frames that do not tile the batch; +/// `InvalidBatchChecksum` / `InvalidMessageChecksum` on an integrity mismatch. pub fn decode_prepare_slice(bytes: &[u8]) -> Result, IggyError> { + decode_prepare_slice_inner(bytes, true) +} + +/// Like [`decode_prepare_slice`] but skips the per-message checksum +/// verification and batch-checksum recompute, extracting only the header meta. +/// Every cheap structural check (length, 16-byte alignment, `size` bounds, and +/// `size` describing the batch exactly) is still enforced. +/// +/// INVARIANT: `bytes` MUST be node-local self-stamped - +/// [`stamp_prepare_for_persistence`] recomputed the batch checksum over the +/// exact blob on THIS node - or already integrity-checked at their network +/// ingress. There is no consensus-layer blob validation: the `PrepareHeader` +/// integrity fields are inert zeros. A replicated `SendMessages` prepare is +/// gated per-message on receipt by [`verify_received_send_messages`], and a +/// repaired prepare is validated via [`decode_prepare_slice`]; both run BEFORE +/// the bytes reach any trusted decode. NEVER call this on unvalidated network +/// bytes - it would let a corrupted blob pass undetected. The full-body +/// per-message checksum pass dominates produce-path CPU, so trusted call sites +/// that only read header meta skip it. +/// +/// # Errors +/// +/// Same structural errors as [`decode_prepare_slice`], minus +/// `InvalidBatchChecksum` and `InvalidMessageChecksum`. +pub fn decode_prepare_slice_trusted(bytes: &[u8]) -> Result, IggyError> { + decode_prepare_slice_inner(bytes, false) +} + +fn decode_prepare_slice_inner( + bytes: &[u8], + validate_checksum: bool, +) -> Result, IggyError> { let header_size = std::mem::size_of::(); if bytes.len() < header_size { return Err(IggyError::InvalidCommand); @@ -670,23 +777,29 @@ pub fn decode_prepare_slice(bytes: &[u8]) -> Result, IggyEr } let header = SendMessages2Header::decode(&body[..COMMAND_HEADER_SIZE])?; - let blob = &body[COMMAND_HEADER_SIZE..]; let blob_len = header.blob_len()?; - if body.len() < header.total_size() { + // Exact, not a lower bound: a prepare frame IS one batch, so bytes past + // `batch_length` belong to nobody - no checksum covers them, yet the flush + // writes them, desyncing the segment walk. Readers walking a multi-batch + // chunk use `decode_batch_slice`, which bounds the blob by design. + if body.len() != header.total_size() { return Err(IggyError::InvalidCommand); } - let blob = &blob[..blob_len]; - let expected_checksum = calculate_batch_checksum(&header, blob); - if header.batch_checksum != expected_checksum { - return Err(IggyError::InvalidBatchChecksum( - header.batch_checksum, - expected_checksum, - header.base_offset, - )); + let blob = &body[COMMAND_HEADER_SIZE..COMMAND_HEADER_SIZE + blob_len]; + let batch = SendMessages2Ref { header, blob }; + if validate_checksum { + let expected_checksum = verify_and_recompute_batch_checksum(&batch)?; + if header.batch_checksum != expected_checksum { + return Err(IggyError::InvalidBatchChecksum( + header.batch_checksum, + expected_checksum, + header.base_offset, + )); + } } - Ok(SendMessages2Ref { header, blob }) + Ok(batch) } pub fn stamp_prepare_for_persistence( @@ -711,6 +824,35 @@ pub fn stamp_prepare_for_persistence( Ok((message, command, command.message_count)) } +/// Verify every per-message checksum in a received `SendMessages` prepare. +/// +/// The FIRST blob-integrity check on the replicated path: the `PrepareHeader` +/// integrity fields are inert zeros and the batch checksum is recomputed +/// locally at stamp, so transit corruption of a message body would otherwise +/// reach apply undetected. Backups call this before journaling a replicated +/// prepare; on a mismatch the caller fails closed (drop, no `PrepareOk`) and the +/// primary retransmits on prepare-timeout. +/// +/// The stored `batch_checksum` is not consulted (a received prepare is +/// pre-stamp, `base_offset` / `base_timestamp` zero): integrity rests on the +/// per-message checksums, recomputed over the stamp-invariant cover +/// (`header[8..48] || payload || user_headers`), which excludes the 256B command +/// header, so it holds whether or not this node has stamped yet. Shares the +/// frame walk with the validating decoders via +/// [`verify_and_recompute_batch_checksum`], discarding its recomputed batch +/// value. +/// +/// # Errors +/// +/// [`IggyError::InvalidCommand`] if the records do not tile `message_count` +/// exactly (a length-field corruption desyncs the walk); +/// [`IggyError::InvalidMessageChecksum`] on the first per-message mismatch. +pub fn verify_received_send_messages(bytes: &[u8]) -> Result<(), IggyError> { + let batch = decode_prepare_slice_trusted(bytes)?; + verify_and_recompute_batch_checksum(&batch)?; + Ok(()) +} + fn legacy_messages_slice(body: &[u8]) -> Result<(u32, &[u8]), IggyError> { if body.len() < 4 { return Err(IggyError::InvalidCommand); @@ -778,26 +920,84 @@ impl<'a> LegacyMessageRef<'a> { } } -// Hash in storage order: header tail, payload, user headers (the message -// sections follow the legacy wire layout). -fn calculate_checksum_parts(header_tail: &[u8], payload: &[u8], user_headers: &[u8]) -> u64 { +/// Batch checksum v2: streaming `XxHash3_64` over the six batch header meta +/// fields followed by each message's stored 8-byte checksum field in message +/// order - NOT the message bodies. +/// +/// Bodies are bound only transitively: each per-message checksum already covers +/// `header[8..48] || payload || user_headers`, so hashing the checksum fields +/// binds every body byte IFF a reader also re-verifies the per-message +/// checksums. Stamp (produce) hashes `N * 8` bytes instead of the whole blob; +/// validating decoders pay the one body pass as the per-message verify in +/// [`verify_and_recompute_batch_checksum`], which hashes the checksum-field +/// bytes in the same order so its recompute matches a compute here. +/// +/// Assumes a well-formed blob whose frames tile exactly; every compute site +/// builds the blob and satisfies this. +fn calculate_batch_checksum(header: &SendMessages2Header, blob: &[u8]) -> u64 { let mut hasher = XxHash3_64::new(); - hasher.write(header_tail); - hasher.write(payload); - hasher.write(user_headers); + write_batch_header_fields(&mut hasher, header); + let batch = SendMessages2Ref { + header: *header, + blob, + }; + for framed in batch.iter_with_offsets() { + hasher.write(&blob[framed.start..framed.start + 8]); + } hasher.finish() } -fn calculate_batch_checksum(header: &SendMessages2Header, blob: &[u8]) -> u64 { - let mut hasher = XxHash3_64::new(); +fn write_batch_header_fields(hasher: &mut XxHash3_64, header: &SendMessages2Header) { 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()); - hasher.write(blob); - hasher.finish() +} + +/// Verify every per-message checksum in `batch` and return the recomputed v2 +/// batch checksum (see [`calculate_batch_checksum`]) from a single frame walk. +/// +/// The per-message pass is the equal-integrity half of v2: the batch value +/// binds bodies only through the checksum fields, so a validating decode must +/// re-verify each message here or body corruption that leaves the checksum +/// field intact would pass. This is the one full-body pass a validating decode +/// pays; the caller then compares the returned value against the stored +/// `batch_checksum`. +/// +/// # Errors +/// +/// [`IggyError::InvalidMessageChecksum`] on the first per-message mismatch; +/// [`IggyError::InvalidCommand`] if the frames do not tile `message_count` +/// exactly. +fn verify_and_recompute_batch_checksum(batch: &SendMessages2Ref<'_>) -> Result { + 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() { + // Cover (`header[8..48] || payload || user_headers`) hashed raw from the + // blob, byte-exact with the encoder's, so a flipped body byte fails even + // when the stored checksum field is left intact. + let stored = framed.message.header.checksum; + let expected = XxHash3_64::oneshot(&blob[framed.start + 8..framed.end]); + if expected != stored { + return Err(IggyError::InvalidMessageChecksum( + stored, + expected, + batch.header.base_offset + 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(IggyError::InvalidCommand); + } + Ok(hasher.finish()) } fn read_u32(bytes: &[u8], offset: usize) -> Result { @@ -827,7 +1027,8 @@ fn read_u128(bytes: &[u8], offset: usize) -> Result { #[cfg(test)] mod tests { use super::*; - use iggy_binary_protocol::Command2; + use iggy_binary_protocol::{Command2, Operation}; + use iggy_common::Aes256GcmEncryptor; fn aligned_prepare_bytes(size: u32) -> Owned { let mut owned = Owned::::zeroed(std::mem::size_of::()); @@ -839,6 +1040,90 @@ mod tests { owned } + /// Assemble an already-stamped batch into a `Prepare`: + /// `[PrepareHeader][256B batch header][blob]`, copying `owned`'s header and + /// blob verbatim. Shared by every real-batch fixture. + fn prepare_from_owned(owned: &SendMessages2Owned) -> Owned { + let header_size = std::mem::size_of::(); + let total = header_size + owned.header.total_size(); + let mut buffer = Owned::::zeroed(total); + { + let prepare: &mut PrepareHeader = + bytemuck::checked::try_from_bytes_mut(&mut buffer.as_mut_slice()[..header_size]) + .expect("zeroed bytes form a valid PrepareHeader"); + prepare.command = Command2::Prepare; + prepare.size = u32::try_from(total).expect("prepare size fits u32"); + } + let bytes = buffer.as_mut_slice(); + owned + .header + .encode_into(&mut bytes[header_size..header_size + COMMAND_HEADER_SIZE]); + bytes[PREPARE_SPLIT_POINT..PREPARE_SPLIT_POINT + owned.blob.len()] + .copy_from_slice(&owned.blob); + buffer + } + + /// A checksum-consistent STAMPED `Prepare` carrying real per-message records, + /// stamped at a non-zero `base_offset` / `base_timestamp` with a v2 + /// `batch_checksum` over the final header fields + per-message checksum fields. + fn valid_prepare_bytes() -> Owned { + let namespace = IggyNamespace::new(1, 1, 7); + let mut owned = SendMessages2Owned::from_messages(namespace, &sample_messages()) + .expect("build send batch"); + owned.header.base_offset = 10; + owned.header.base_timestamp = 20; + owned.header.batch_checksum = owned.header.checksum_for_blob(&owned.blob); + prepare_from_owned(&owned) + } + + #[test] + fn decode_prepare_slice_trusted_matches_validating_for_valid_batch() { + // The trusted variant must surface byte-identical header meta to the + // validating decode for a checksum-consistent batch; only the + // per-message and batch-checksum passes are skipped. + let owned = valid_prepare_bytes(); + + let validated = decode_prepare_slice(owned.as_slice()).expect("valid batch decodes"); + let trusted = + decode_prepare_slice_trusted(owned.as_slice()).expect("valid batch decodes trusted"); + + assert_eq!(validated.header.base_offset, trusted.header.base_offset); + assert_eq!( + validated.header.base_timestamp, + trusted.header.base_timestamp + ); + assert_eq!( + validated.header.origin_timestamp, + trusted.header.origin_timestamp + ); + assert_eq!(validated.header.batch_length, trusted.header.batch_length); + assert_eq!(validated.message_count(), trusted.message_count()); + assert_eq!(validated.header.total_size(), trusted.header.total_size()); + assert_eq!(validated.blob(), trusted.blob()); + } + + #[test] + fn decode_prepare_slice_trusted_skips_batch_checksum() { + // A stored batch_checksum mutated after stamping fails the validating + // decode but passes the trusted one: exactly why the trusted variant is + // confined to locally-produced bytes (see its doc invariant). + let mut owned = valid_prepare_bytes(); + let corrupt_index = std::mem::size_of::() + BATCH_CHECKSUM_OFFSET; + owned.as_mut_slice()[corrupt_index] ^= 0xFF; + + assert!( + matches!( + decode_prepare_slice(owned.as_slice()), + Err(IggyError::InvalidBatchChecksum(..)) + ), + "validating decode must reject a mutated batch checksum", + ); + assert!( + decode_prepare_slice_trusted(owned.as_slice()).is_ok(), + "trusted decode skips the batch-checksum recomputation", + ); + } + #[test] fn decode_prepare_slice_size_below_header_size_does_not_panic() { // Regression: without the `total_size < header_size` guard, @@ -869,4 +1154,484 @@ mod tests { ); let _ = decode_prepare_slice(misaligned); } + + fn sample_messages() -> IggyMessages2 { + let mut messages = IggyMessages2::with_capacity(2); + messages.push(IggyMessage2 { + header: IggyMessage2Header { + id: 7, + origin_timestamp: 1_000, + ..Default::default() + }, + payload: Bytes::from_static(b"first-payload"), + user_headers: None, + }); + messages.push(IggyMessage2 { + header: IggyMessage2Header { + id: 8, + origin_timestamp: 1_050, + ..Default::default() + }, + payload: Bytes::from_static(b"second-payload"), + user_headers: Some(Bytes::from_static(b"user-header-bytes")), + }); + messages + } + + /// `[PrepareHeader][256B batch header][blob]` carrying real per-message + /// records + checksums from the production encoder, left pre-stamp + /// (`base_offset` / `base_timestamp` zero) as a follower receives it. + fn prepare_with_messages(messages: &IggyMessages2) -> Owned { + let namespace = IggyNamespace::new(1, 1, 7); + let owned = + SendMessages2Owned::from_messages(namespace, messages).expect("build send batch"); + prepare_from_owned(&owned) + } + + #[test] + fn verify_received_send_messages_accepts_clean_batch() { + let owned = prepare_with_messages(&sample_messages()); + verify_received_send_messages(owned.as_slice()) + .expect("a clean batch passes the receive gate"); + } + + #[test] + fn verify_received_send_messages_rejects_flipped_payload_byte() { + let mut owned = prepare_with_messages(&sample_messages()); + // First payload begins right after the first message's 48B header. + let payload_index = PREPARE_SPLIT_POINT + MESSAGE_HEADER_SIZE; + owned.as_mut_slice()[payload_index] ^= 0xFF; + assert!( + matches!( + verify_received_send_messages(owned.as_slice()), + Err(IggyError::InvalidMessageChecksum(..)) + ), + "a flipped payload byte must fail the per-message checksum", + ); + } + + #[test] + fn verify_received_send_messages_rejects_flipped_stored_checksum() { + let mut owned = prepare_with_messages(&sample_messages()); + // The first message's stored checksum is the first 8 bytes of the blob. + owned.as_mut_slice()[PREPARE_SPLIT_POINT] ^= 0xFF; + assert!( + matches!( + verify_received_send_messages(owned.as_slice()), + Err(IggyError::InvalidMessageChecksum(..)) + ), + "a flipped stored checksum must fail the per-message check", + ); + } + + #[test] + fn checksum_oneshot_matches_streaming_reference() { + // Formula pin: the per-message checksum is XxHash3-64 (default seed) + // over `header[8..48] || payload || user_headers` as one byte stream. + // The encoders hash the concatenation in a single oneshot pass; this + // streaming reference feeds the same parts separately. Both must agree + // for every shape, or checksums at rest stop verifying. + fn streaming_reference(header_tail: &[u8], payload: &[u8], user_headers: &[u8]) -> u64 { + let mut hasher = XxHash3_64::new(); + hasher.write(header_tail); + hasher.write(payload); + hasher.write(user_headers); + hasher.finish() + } + + let header_tail: Vec = (0u8..40).collect(); + let kilobyte: Vec = (0..1024u32).map(|index| (index % 251) as u8).collect(); + let cases: &[(&[u8], &[u8])] = &[ + (&[], &[]), + (b"payload-bytes", &[]), + (b"payload-bytes", b"user-header-bytes"), + (&kilobyte, &[]), + (&kilobyte, &kilobyte[..7]), + (&kilobyte[..1023], &kilobyte[..7]), + ]; + for (payload, user_headers) in cases { + let mut concatenated = + Vec::with_capacity(header_tail.len() + payload.len() + user_headers.len()); + concatenated.extend_from_slice(&header_tail); + concatenated.extend_from_slice(payload); + concatenated.extend_from_slice(user_headers); + assert_eq!( + XxHash3_64::oneshot(&concatenated), + streaming_reference(&header_tail, payload, user_headers), + "oneshot must match the streaming reference for payload {} B, user headers {} B", + payload.len(), + user_headers.len(), + ); + } + } + + #[test] + fn batch_checksum_v2_pins_header_fields_then_message_checksum_fields() { + // Formula pin for batch checksum v2: XxHash3-64 (default seed) streaming + // over the six batch header meta fields (LE, in field order) then each + // message's stored 8-byte checksum field in message order - never the + // bodies. This reference walks the blob by the KNOWN input message sizes, + // independent of the production frame decoder, and must equal what the + // encoder stamped, or a stamp will not verify against a read-back + // recompute. + let namespace = IggyNamespace::new(1, 1, 7); + let messages = sample_messages(); + let mut owned = + SendMessages2Owned::from_messages(namespace, &messages).expect("build batch"); + owned.header.base_offset = 100; + owned.header.base_timestamp = 200; + owned.header.batch_checksum = owned.header.checksum_for_blob(&owned.blob); + + let mut hasher = XxHash3_64::new(); + hasher.write(&owned.header.partition_id.to_le_bytes()); + hasher.write(&owned.header.base_offset.to_le_bytes()); + hasher.write(&owned.header.base_timestamp.to_le_bytes()); + hasher.write(&owned.header.origin_timestamp.to_le_bytes()); + hasher.write(&owned.header.batch_length.to_le_bytes()); + hasher.write(&owned.header.message_count.to_le_bytes()); + let mut frame_start = 0usize; + for message in messages.iter() { + hasher.write(&owned.blob[frame_start..frame_start + 8]); + let user_headers = message.user_headers.as_deref().unwrap_or_default(); + frame_start += MESSAGE_HEADER_SIZE + message.payload.len() + user_headers.len(); + } + let reference = hasher.finish(); + + assert_eq!( + frame_start, + owned.blob.len(), + "reference walk must consume the whole blob", + ); + assert_eq!( + owned.header.batch_checksum, reference, + "v2 batch checksum must equal hash(6 header fields || per-message checksum fields)", + ); + } + + #[test] + fn decode_batch_slice_rejects_body_corruption_with_intact_checksum_field() { + // Equal-integrity: v2 binds bodies only through the per-message checksum + // fields, so a flipped body byte that leaves the 8-byte checksum field + // intact keeps the batch value matching. The validating decode must still + // reject it via the per-message verify - the sole at-rest read-back check + // (the poll disk walk) decodes through here. + let namespace = IggyNamespace::new(1, 1, 7); + let owned = + SendMessages2Owned::from_messages(namespace, &sample_messages()).expect("build batch"); + let mut body = vec![0u8; COMMAND_HEADER_SIZE + owned.blob.len()]; + owned.header.encode_into(&mut body[..COMMAND_HEADER_SIZE]); + body[COMMAND_HEADER_SIZE..].copy_from_slice(&owned.blob); + + decode_batch_slice(&body).expect("the clean batch decodes"); + + // First payload byte sits right after the command header and the first + // message's 48B frame header, leaving that frame's checksum field intact. + let payload_index = COMMAND_HEADER_SIZE + MESSAGE_HEADER_SIZE; + body[payload_index] ^= 0xFF; + assert!( + matches!( + decode_batch_slice(&body), + Err(IggyError::InvalidMessageChecksum(..)) + ), + "body corruption with an intact checksum field must fail the per-message verify", + ); + } + + #[test] + fn decode_prepare_slice_rejects_body_corruption_with_intact_checksum_field() { + // The same equal-integrity guarantee at the resident/repair validating + // decode, plus proof that the batch value alone is blind to it. + let mut owned = prepare_with_messages(&sample_messages()); + decode_prepare_slice(owned.as_slice()).expect("the clean prepare decodes"); + + let payload_index = PREPARE_SPLIT_POINT + MESSAGE_HEADER_SIZE; + owned.as_mut_slice()[payload_index] ^= 0xFF; + assert!( + matches!( + decode_prepare_slice(owned.as_slice()), + Err(IggyError::InvalidMessageChecksum(..)) + ), + "body corruption with an intact checksum field must fail the per-message verify", + ); + assert!( + decode_prepare_slice_trusted(owned.as_slice()).is_ok(), + "the intact checksum field leaves the batch value matching, so trusted still passes", + ); + } + + /// Legacy `SendMessages` request body: `[metadata_len=4][message_count]` + /// then `count` skipped index slots, then the 64B-header legacy records. + fn legacy_send_messages_body(messages: &IggyMessages2) -> Vec { + let count = messages.count(); + let mut body = Vec::new(); + body.extend_from_slice(&4u32.to_le_bytes()); + body.extend_from_slice(&count.to_le_bytes()); + body.extend_from_slice(&vec![0u8; count as usize * INDEX_SIZE]); + for message in messages.iter() { + let user_headers = message.user_headers.as_deref().unwrap_or_default(); + let mut header = [0u8; LEGACY_MESSAGE_HEADER_SIZE]; + header[8..24].copy_from_slice(&message.header.id.to_le_bytes()); + header[40..48].copy_from_slice(&message.header.origin_timestamp.to_le_bytes()); + header[48..52].copy_from_slice(&(user_headers.len() as u32).to_le_bytes()); + header[52..56].copy_from_slice(&(message.payload.len() as u32).to_le_bytes()); + body.extend_from_slice(&header); + body.extend_from_slice(&message.payload); + body.extend_from_slice(user_headers); + } + body + } + + fn legacy_request_message(body: &[u8]) -> Message { + let header_size = std::mem::size_of::(); + let total = header_size + body.len(); + let mut buffer = Owned::::zeroed(total); + { + let header: &mut RequestHeader = + bytemuck::checked::try_from_bytes_mut(&mut buffer.as_mut_slice()[..header_size]) + .expect("zeroed bytes form a valid RequestHeader"); + header.command = Command2::Request; + header.operation = Operation::SendMessages; + header.client = 1; + header.session = 1; + header.request = 1; + header.size = u32::try_from(total).expect("size fits u32"); + } + buffer.as_mut_slice()[header_size..].copy_from_slice(body); + Message::try_from(buffer).expect("legacy request message is valid") + } + + #[test] + fn convert_request_message_transcodes_legacy_to_canonical_bytes() { + // Golden: the fused legacy transcode must emit the exact canonical batch + // the native builder (`from_messages`) produces for the same messages - + // command header + blob, byte for byte. Explicit non-zero ids keep it + // deterministic (no `random_id` substitution). + let namespace = IggyNamespace::new(1, 1, 3); + let messages = sample_messages(); + + let owned = + SendMessages2Owned::from_messages(namespace, &messages).expect("build canonical batch"); + let mut expected_body = vec![0u8; COMMAND_HEADER_SIZE + owned.blob.len()]; + owned + .header + .encode_into(&mut expected_body[..COMMAND_HEADER_SIZE]); + expected_body[COMMAND_HEADER_SIZE..].copy_from_slice(&owned.blob); + + let legacy = legacy_request_message(&legacy_send_messages_body(&messages)); + let converted = convert_request_message(namespace, legacy, ChecksumMode::Compute) + .expect("legacy body transcodes"); + let header_size = std::mem::size_of::(); + let actual_body = &converted.as_slice()[header_size..converted.header().size as usize]; + + assert_eq!( + actual_body, expected_body, + "legacy transcode must be byte-identical to the canonical native batch", + ); + + // And the emitted batch is self-consistent: it validates through the + // batch-checksum decode and yields the original messages. + let decoded = decode_batch_slice(actual_body).expect("transcoded batch checksum is valid"); + assert_eq!(decoded.message_count(), messages.count()); + let payloads: Vec<&[u8]> = decoded.iter().map(|view| view.payload).collect(); + assert_eq!( + payloads, + vec![&b"first-payload"[..], &b"second-payload"[..]] + ); + } + + #[test] + fn convert_request_message_skip_leaves_batch_checksum_zero_until_stamp() { + // The partition ingest path passes Skip: the transcoded batch must carry + // a zero checksum (stamp fills it) and be otherwise byte-identical to the + // Compute output - the flag toggles nothing but that one hash. + let namespace = IggyNamespace::new(1, 1, 3); + let messages = sample_messages(); + let body = legacy_send_messages_body(&messages); + let header_size = std::mem::size_of::(); + + let computed = convert_request_message( + namespace, + legacy_request_message(&body), + ChecksumMode::Compute, + ) + .expect("compute transcode"); + let skipped = + convert_request_message(namespace, legacy_request_message(&body), ChecksumMode::Skip) + .expect("skip transcode"); + + let computed_body = &computed.as_slice()[header_size..computed.header().size as usize]; + let skipped_body = &skipped.as_slice()[header_size..skipped.header().size as usize]; + + let skipped_header = SendMessages2Header::decode(&skipped_body[..COMMAND_HEADER_SIZE]) + .expect("decode skipped header"); + assert_eq!( + skipped_header.batch_checksum, 0, + "skip leaves the batch checksum zero until stamp", + ); + + // Patch only the 8-byte batch_checksum field into the skipped body; it + // must then equal the computed body, proving nothing else diverges. + let mut patched = skipped_body.to_vec(); + patched[BATCH_CHECKSUM_OFFSET..BATCH_CHECKSUM_OFFSET + 8] + .copy_from_slice(&computed_body[BATCH_CHECKSUM_OFFSET..BATCH_CHECKSUM_OFFSET + 8]); + assert_eq!( + patched.as_slice(), + computed_body, + "skip and compute differ only in the batch_checksum field", + ); + } + + #[test] + fn encrypt_ingest_path_stays_canonical_through_flag_split() { + // Mirror the plane encrypt ingest sequence: convert(Compute) -> the + // validating decode encrypt performs on its input -> encrypt -> the + // validating decode the second convert performs as its discriminator -> + // convert(Skip) (the partition convert), which sees an already-canonical + // batch and returns it unchanged. Every decode must succeed. + let namespace = IggyNamespace::new(1, 1, 3); + let messages = sample_messages(); + let header_size = std::mem::size_of::(); + + let legacy = legacy_request_message(&legacy_send_messages_body(&messages)); + let canonical = convert_request_message(namespace, legacy, ChecksumMode::Compute) + .expect("pre-encrypt transcode"); + let canonical_body = &canonical.as_slice()[header_size..canonical.header().size as usize]; + decode_batch_slice(canonical_body).expect("encrypt input decode validates the checksum"); + + let encryptor = + EncryptorKind::Aes256Gcm(Aes256GcmEncryptor::new(&[7u8; 32]).expect("valid 32B key")); + let encrypted = encrypt_batch_request(canonical, &encryptor).expect("encrypt batch"); + let encrypted_body: Vec = + encrypted.as_slice()[header_size..encrypted.header().size as usize].to_vec(); + decode_batch_slice(&encrypted_body) + .expect("encrypt output drives the 2nd-convert discriminator"); + + let repassed = convert_request_message(namespace, encrypted, ChecksumMode::Skip) + .expect("second convert passes the canonical batch"); + let repassed_body = &repassed.as_slice()[header_size..repassed.header().size as usize]; + assert_eq!( + repassed_body, + encrypted_body.as_slice(), + "an already-canonical encrypted batch passes the partition convert untouched", + ); + } + + /// Junk suffixes that must be refused at both ingest boundaries: one below a + /// frame header (the frame walk stops on a short read) and one frame-sized + /// but undecodable (`reserved != 0`). Neither is covered by any checksum, so + /// a walk that stops at the last decodable frame cannot see them. + const TRAILING_JUNK_CASES: [&[u8]; 2] = [&[0xAA], &[0xFF; 64]]; + + /// Canonical `SendMessages` request carrying `junk` past `batch_length`, with + /// `RequestHeader.size` inflated to cover it. `size` and `batch_length` are + /// independent wire fields, so a non-conforming client can emit this. + fn canonical_request_with_trailing_bytes(junk: &[u8]) -> Message { + let namespace = IggyNamespace::new(1, 1, 3); + let owned = + SendMessages2Owned::from_messages(namespace, &sample_messages()).expect("build batch"); + let header_size = std::mem::size_of::(); + let total = header_size + owned.header.total_size() + junk.len(); + let mut buffer = Owned::::zeroed(total); + { + let header: &mut RequestHeader = + bytemuck::checked::try_from_bytes_mut(&mut buffer.as_mut_slice()[..header_size]) + .expect("zeroed bytes form a valid RequestHeader"); + header.command = Command2::Request; + header.operation = Operation::SendMessages; + header.client = 1; + header.session = 1; + header.request = 1; + header.size = u32::try_from(total).expect("size fits u32"); + } + let bytes = buffer.as_mut_slice(); + owned + .header + .encode_into(&mut bytes[header_size..header_size + COMMAND_HEADER_SIZE]); + let blob_end = PREPARE_SPLIT_POINT + owned.blob.len(); + bytes[PREPARE_SPLIT_POINT..blob_end].copy_from_slice(&owned.blob); + bytes[blob_end..].copy_from_slice(junk); + Message::try_from(buffer).expect("request message is valid") + } + + /// The replicated counterpart: a pre-stamp `Prepare` whose `size` covers + /// `junk` past `batch_length`. + fn prepare_with_trailing_bytes(junk: &[u8]) -> Owned { + let namespace = IggyNamespace::new(1, 1, 7); + let owned = + SendMessages2Owned::from_messages(namespace, &sample_messages()).expect("build batch"); + let header_size = std::mem::size_of::(); + let total = header_size + owned.header.total_size() + junk.len(); + let mut buffer = Owned::::zeroed(total); + { + let prepare: &mut PrepareHeader = + bytemuck::checked::try_from_bytes_mut(&mut buffer.as_mut_slice()[..header_size]) + .expect("zeroed bytes form a valid PrepareHeader"); + prepare.command = Command2::Prepare; + prepare.size = u32::try_from(total).expect("prepare size fits u32"); + } + let bytes = buffer.as_mut_slice(); + owned + .header + .encode_into(&mut bytes[header_size..header_size + COMMAND_HEADER_SIZE]); + let blob_end = PREPARE_SPLIT_POINT + owned.blob.len(); + bytes[PREPARE_SPLIT_POINT..blob_end].copy_from_slice(&owned.blob); + bytes[blob_end..].copy_from_slice(junk); + buffer + } + + #[test] + fn convert_request_message_rejects_canonical_batch_with_trailing_bytes() { + // Client ingest boundary. Accepting the request would carry the suffix + // into the journal and onto disk: the flush writes the whole frame while + // every reader advances by `batch_length`, so the segment walk lands + // inside the junk and every later batch becomes unreadable. + let namespace = IggyNamespace::new(1, 1, 3); + for junk in TRAILING_JUNK_CASES { + for mode in [ChecksumMode::Compute, ChecksumMode::Skip] { + let message = canonical_request_with_trailing_bytes(junk); + let result = convert_request_message(namespace, message, mode); + assert!( + matches!(result, Err(IggyError::InvalidCommand)), + "{} trailing bytes ({mode:?}) must be rejected, got {result:?}", + junk.len(), + ); + } + } + } + + #[test] + fn convert_request_message_accepts_exact_canonical_batch() { + // The same builder with no suffix must still pass untouched, so the + // rejection above is the suffix and not the fixture. + let namespace = IggyNamespace::new(1, 1, 3); + let message = canonical_request_with_trailing_bytes(&[]); + let expected = message.as_slice().to_vec(); + let converted = convert_request_message(namespace, message, ChecksumMode::Skip) + .expect("an exact canonical batch passes untouched"); + assert_eq!(converted.as_slice(), expected.as_slice()); + } + + #[test] + fn verify_received_send_messages_rejects_trailing_bytes_past_batch_length() { + // Replica ingest boundary. The gate clamps the blob to `batch_length` + // before verifying, so without an exact-frame check a primary could plant + // bytes that no per-message checksum covers on every backup. + for junk in TRAILING_JUNK_CASES { + let owned = prepare_with_trailing_bytes(junk); + let result = verify_received_send_messages(owned.as_slice()); + assert!( + matches!(result, Err(IggyError::InvalidCommand)), + "{} trailing bytes must fail the receive gate, got {result:?}", + junk.len(), + ); + assert!( + matches!( + decode_prepare_slice(owned.as_slice()), + Err(IggyError::InvalidCommand) + ), + "{} trailing bytes must fail the validating decode", + junk.len(), + ); + } + } } diff --git a/core/simulator/src/client.rs b/core/simulator/src/client.rs index f511c10006..028adb38cf 100644 --- a/core/simulator/src/client.rs +++ b/core/simulator/src/client.rs @@ -72,7 +72,7 @@ pub struct SimClient { partition_counter: Cell, /// Deterministic per-message id source for produced messages. The real SDK /// sends `id: 0` and lets the server mint a random UUID - /// (`SendMessages2::from_legacy_request` -> `random_id::get_uuid`); that + /// (`transcode_legacy_request` -> `random_id::get_uuid`); that /// mint is unseeded, so under the deterministic executor a produce's /// replicated body bytes (and their checksums) would differ run to run, /// silently breaking seeded replay. Stamping a deterministic id here keeps @@ -516,7 +516,7 @@ impl SimClient { /// client). VSR clients resolve to an explicit partition before sending, so /// the sim always emits `WirePartitioning::PartitionId`: that is the shape /// the shell's `resolve_partition_request_namespace` decodes, and the raw - /// path converts it to `SendMessages2` via `from_legacy_request`. + /// path converts it to `SendMessages2` via `transcode_legacy_request`. /// /// # Panics /// Panics if a namespace id exceeds `u32` or the request buffer is invalid.