diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs index aded816846..5c6b148821 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, }; @@ -1149,7 +1150,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( @@ -1532,6 +1539,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 @@ -1941,11 +1974,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(), @@ -2345,8 +2382,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 c16b148435..c73e8a93cc 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; @@ -512,7 +512,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.