This is an automated email from the ASF dual-hosted git repository.

krishvishal pushed a commit to branch vsr-dvc-headers
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/vsr-dvc-headers by this push:
     new dd294bce2 fix(consensus): pin the verified body range and stamp 
requests
dd294bce2 is described below

commit dd294bce2e25f82bae8b5e1d629983683ce67b2a
Author: Krishna Vishal <[email protected]>
AuthorDate: Fri Aug 7 12:13:38 2026 +0530

    fix(consensus): pin the verified body range and stamp requests
---
 core/binary_protocol/src/consensus/header.rs |  14 +++
 core/binary_protocol/src/consensus/mod.rs    |   2 +-
 core/binary_protocol/src/lib.rs              |   2 +-
 core/consensus/src/impls.rs                  |  11 +-
 core/consensus/src/plane_helpers.rs          |  76 ++++++++---
 core/metadata/src/impls/metadata.rs          |   5 +-
 core/metadata/src/impls/recovery.rs          | 182 ++++++++++++++++++++++++++-
 core/partitions/src/iggy_partition.rs        |   5 +-
 core/sdk/src/vsr.rs                          |   8 +-
 core/server-ng/src/dispatch.rs               |  33 ++++-
 core/server-ng/src/wire.rs                   |  94 +++++++++++++-
 core/shard/src/lib.rs                        |   8 +-
 12 files changed, 396 insertions(+), 44 deletions(-)

diff --git a/core/binary_protocol/src/consensus/header.rs 
b/core/binary_protocol/src/consensus/header.rs
index 1ada15cf5..c7d511668 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -888,6 +888,20 @@ impl ConsensusHeader for PrepareHeader {
 /// Verification skips such entries so an older build's WAL still replays.
 pub const CHECKSUM_UNSEALED: u128 = 0;
 
+/// The frame's body, bounded by `size`. What `checksum_body` covers.
+///
+/// Not `&frame[HEADER_SIZE..]`: `Message::try_from` accepts a buffer longer 
than
+/// `size` without trimming, while the WAL scan reads exactly `size`, so 
slicing to
+/// the end makes the two disagree. Empty when `size` overruns the buffer.
+#[must_use]
+pub fn frame_body(frame: &[u8], size: u32) -> &[u8] {
+    let end = size as usize;
+    if end <= HEADER_SIZE || end > frame.len() {
+        return &[];
+    }
+    &frame[HEADER_SIZE..end]
+}
+
 impl PrepareHeader {
     /// Which prepare this is, independent of which view re-sent it.
     ///
diff --git a/core/binary_protocol/src/consensus/mod.rs 
b/core/binary_protocol/src/consensus/mod.rs
index 83a299291..0cf8eefa3 100644
--- a/core/binary_protocol/src/consensus/mod.rs
+++ b/core/binary_protocol/src/consensus/mod.rs
@@ -50,7 +50,7 @@ pub use header::{
     RESERVED_COMMAND_LEN, RepairPrepareHeader, RepairRangeReplyHeader, 
ReplyHeader, RequestHeader,
     RequestPreparesHeader, RequestStartViewHeader, RequestStateChunkHeader,
     RequestStateTransferHeader, SIZE_FIELD_OFFSET, StartViewChangeHeader, 
StartViewHeader,
-    StateChunkHeader, StateTransferTargetHeader, frame_checksum_bytes, 
read_size_field,
+    StateChunkHeader, StateTransferTargetHeader, frame_body, 
frame_checksum_bytes, read_size_field,
 };
 pub use operation::Operation;
 pub use reply_result::{RESULT_COUNT_LEN, RESULT_ENTRY_LEN, result_code, 
result_section_len};
diff --git a/core/binary_protocol/src/lib.rs b/core/binary_protocol/src/lib.rs
index 75defea5a..4d66bbc7d 100644
--- a/core/binary_protocol/src/lib.rs
+++ b/core/binary_protocol/src/lib.rs
@@ -77,7 +77,7 @@ pub use consensus::{
     RepairRangeReplyHeader, ReplyHeader, RequestHeader, RequestPreparesHeader,
     RequestStartViewHeader, RequestStateChunkHeader, 
RequestStateTransferHeader, SIZE_FIELD_OFFSET,
     StartViewChangeHeader, StartViewHeader, StateChunkHeader, 
StateTransferTargetHeader,
-    frame_checksum_bytes, read_size_field, result_code, result_section_len,
+    frame_body, frame_checksum_bytes, read_size_field, result_code, 
result_section_len,
 };
 pub use dispatch::{COMMAND_TABLE, CommandMeta, lookup_by_operation, 
lookup_command};
 pub use error::WireError;
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 0386b23ac..4c3f8c1ad 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -29,6 +29,7 @@ use clock::{Clock, IggySystemClock};
 use iggy_binary_protocol::{
     Command2, ConsensusHeader, DoViewChangeHeader, GenericHeader, 
PrepareHeader, PrepareOkHeader,
     ReplyHeader, RequestHeader, RequestStartViewHeader, StartViewChangeHeader, 
StartViewHeader,
+    frame_body,
 };
 use iggy_common::IggyTimestamp;
 use iggy_common::calculate_checksum;
@@ -3490,10 +3491,14 @@ where
         // to the view-change merge. Closing that wants `checksum_body` here 
to BE
         // the batch checksum, recomputed after stamping, so `checksum` covers 
the
         // body for free by hashing this field.
+        //
+        // Bounded by `size`, the range every verifier re-reads; the prepare
+        // inherits it verbatim below.
         let checksum_body = if consensus.namespace == 
METADATA_CONSENSUS_NAMESPACE {
-            u128::from(calculate_checksum(
-                &self.as_slice()[size_of::<PrepareHeader>()..],
-            ))
+            u128::from(calculate_checksum(frame_body(
+                self.as_slice(),
+                self.header().size,
+            )))
         } else {
             0
         };
diff --git a/core/consensus/src/plane_helpers.rs 
b/core/consensus/src/plane_helpers.rs
index 467f77f1f..aeb26c33b 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -21,7 +21,7 @@ use crate::{
 };
 use iggy_binary_protocol::{
     CHECKSUM_UNSEALED, Command2, ConsensusHeader, PrepareHeader, 
PrepareOkHeader, ReplyHeader,
-    RequestHeader,
+    RequestHeader, frame_body,
 };
 use message_bus::{MessageBus, SendError};
 use server_common::{Message, iobuf::Owned};
@@ -125,18 +125,22 @@ where
 /// bytes it arrived with, so a corrupted frame is admitted whenever its 
flipped
 /// value satisfies those comparisons, then journaled and re-served to peers.
 ///
-/// `body` is the frame past the header, empty for a header-only prepare.
-/// [`CHECKSUM_UNSEALED`] skips the partition plane, which seals neither and
-/// carries a verified `batch_checksum` over the same bytes.
+/// `frame` is the whole message. The body range comes from [`frame_body`], 
not the
+/// caller, so no ingress point can verify a different span than the producer 
sealed.
+/// [`CHECKSUM_UNSEALED`] skips the partition plane, which carries 
`batch_checksum`
+/// over the same bytes instead.
 ///
 /// # Errors
 /// Returns a static description of which field failed.
-pub fn verify_prepare_integrity(header: &PrepareHeader, body: &[u8]) -> 
Result<(), &'static str> {
+pub fn verify_prepare_integrity(header: &PrepareHeader, frame: &[u8]) -> 
Result<(), &'static str> {
     if header.checksum != CHECKSUM_UNSEALED && header.identity_checksum() != 
header.checksum {
         return Err("prepare header does not match its own checksum");
     }
     if header.checksum_body != 0
-        && u128::from(iggy_common::calculate_checksum(body)) != 
header.checksum_body
+        && u128::from(iggy_common::calculate_checksum(frame_body(
+            frame,
+            header.size,
+        ))) != header.checksum_body
     {
         return Err("prepare body does not match its checksum");
     }
@@ -1237,16 +1241,60 @@ mod tests {
         assert_eq!(verify_prepare_integrity(&header, &[]), Ok(()));
     }
 
+    /// A frame carrying `body`, with `size` covering exactly header + body and
+    /// `checksum_body` sealed over it, as the metadata projection does.
+    fn sealed_frame(body: &[u8]) -> Vec<u8> {
+        let mut frame = vec![0u8; size_of::<PrepareHeader>() + body.len()];
+        frame[size_of::<PrepareHeader>()..].copy_from_slice(body);
+        let header = bytemuck::checked::from_bytes_mut::<PrepareHeader>(
+            &mut frame[..size_of::<PrepareHeader>()],
+        );
+        header.command = Command2::Prepare;
+        header.op = 7;
+        header.size = u32::try_from(size_of::<PrepareHeader>() + 
body.len()).expect("fits u32");
+        header.checksum_body = u128::from(calculate_checksum(body));
+        frame
+    }
+
     #[test]
     fn given_a_prepare_whose_body_was_altered_when_verifying_should_reject() {
-        let mut header = PrepareHeader {
-            command: Command2::Prepare,
-            op: 7,
-            ..Default::default()
-        };
-        header.checksum_body = u128::from(calculate_checksum(b"body"));
-        assert_eq!(verify_prepare_integrity(&header, b"body"), Ok(()));
-        assert!(verify_prepare_integrity(&header, b"bodY").is_err());
+        let frame = sealed_frame(b"body");
+        let header =
+            
*bytemuck::checked::from_bytes::<PrepareHeader>(&frame[..size_of::<PrepareHeader>()]);
+        assert_eq!(verify_prepare_integrity(&header, &frame), Ok(()));
+
+        let mut altered = frame;
+        *altered.last_mut().expect("the frame carries a body") ^= 1;
+        assert!(verify_prepare_integrity(&header, &altered).is_err());
+    }
+
+    #[test]
+    fn given_bytes_past_the_frame_size_when_verifying_should_ignore_them() {
+        // `try_from` accepts a buffer longer than `size` without trimming; 
hashing to
+        // the end would reject a correctly sealed prepare and disagree with 
the WAL scan.
+        let frame = sealed_frame(b"body");
+        let header =
+            
*bytemuck::checked::from_bytes::<PrepareHeader>(&frame[..size_of::<PrepareHeader>()]);
+
+        let mut padded = frame;
+        padded.extend_from_slice(b"trailing garbage");
+        assert_eq!(
+            verify_prepare_integrity(&header, &padded),
+            Ok(()),
+            "only the bytes `size` covers are the body"
+        );
+    }
+
+    #[test]
+    fn given_a_size_that_overruns_the_buffer_when_verifying_should_reject() {
+        // Truncated frame, header still claims the full length: the body it 
names is
+        // not there to hash.
+        let frame = sealed_frame(b"body");
+        let header =
+            
*bytemuck::checked::from_bytes::<PrepareHeader>(&frame[..size_of::<PrepareHeader>()]);
+
+        let truncated = &frame[..frame.len() - 1];
+        assert!(verify_prepare_integrity(&header, truncated).is_err());
     }
 
     #[test]
diff --git a/core/metadata/src/impls/metadata.rs 
b/core/metadata/src/impls/metadata.rs
index f06af55d9..bafd51cee 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -1059,10 +1059,7 @@ where
         // corrupted between primary and backup is journaled as-is and 
re-served to
         // peers, which the interior-corruption boot refusal turns into an 
unbootable
         // node on the next restart.
-        if let Err(reason) = verify_prepare_integrity(
-            &header,
-            &message.as_slice()[std::mem::size_of::<PrepareHeader>()..],
-        ) {
+        if let Err(reason) = verify_prepare_integrity(&header, 
message.as_slice()) {
             warn!(
                 target: "iggy.metadata.diag",
                 plane = "metadata",
diff --git a/core/metadata/src/impls/recovery.rs 
b/core/metadata/src/impls/recovery.rs
index 286d286e2..3b2e009af 100644
--- a/core/metadata/src/impls/recovery.rs
+++ b/core/metadata/src/impls/recovery.rs
@@ -23,8 +23,9 @@ use consensus::{
     ClientTable, ClientTableDecodeError, VsrState, VsrStateError, 
build_reply_message,
     build_reply_message_with,
 };
-use iggy_binary_protocol::consensus::{Operation, PrepareHeader};
+use iggy_binary_protocol::consensus::{CHECKSUM_UNSEALED, Operation, 
PrepareHeader};
 use iggy_common::IggyError;
+use journal::Journal as _;
 use journal::prepare_journal::{JournalError, PrepareJournal};
 use journal::superblock::{
     PingPongSuperblock, SLOT_FILE_NAMES, SuperblockContents, SuperblockStore,
@@ -307,6 +308,13 @@ pub struct RecoveredMetadata<M> {
     /// they stay journal-only until the recovered primary re-replicates them
     /// (or a backup sees the commit point advance past them).
     pub last_journaled_op: Option<u64>,
+    /// First op replay could not connect to its predecessor, `None` when the
+    /// replayed range is one unbroken chain.
+    ///
+    /// `Some(op)` means entries at and above `op` were truncated and must 
come back
+    /// from the cluster. `last_journaled_op` stops below it, which keeps the 
restored
+    /// head, the re-pipeline range, and the recovery barrier honest.
+    pub chain_break_op: Option<u64>,
 }
 
 /// Recover metadata state from disk.
@@ -545,10 +553,35 @@ where
 
     let mut last_applied_op: Option<u64> = None;
     let mut last_journaled_op: Option<u64> = None;
+    let mut chain_break_op: Option<u64> = None;
+    let mut previous: Option<PrepareHeader> = None;
     for header in &headers_to_replay {
-        // TODO: Check hash chain integrity against `previous_header`. On a
-        // same-view break, stop replay here and mark the remaining entries for
-        // repair via VSR instead of panicking.
+        // Stop at the first op that does not connect to the one before it. 
Applying
+        // across a hole replays effects onto a state machine that never saw 
the
+        // missing op, and nothing downstream re-checks it.
+        //
+        // The WAL scan does not cover this: it only fires on CONSECUTIVE ops 
with
+        // both ends sealed, so a gap reaches here. The first replayed op is 
exempt,
+        // since a snapshot records no checksum for its parent to chain to.
+        if let Some(previous) = previous {
+            let gap = previous.op + 1 != header.op;
+            let broken_chain = previous.checksum != CHECKSUM_UNSEALED
+                && header.checksum != CHECKSUM_UNSEALED
+                && header.parent != previous.checksum;
+            if gap || broken_chain {
+                tracing::error!(
+                    op = header.op,
+                    previous_op = previous.op,
+                    gap,
+                    broken_chain,
+                    "metadata WAL does not connect at this op; stopping replay 
and dropping the \
+                     suffix for VSR repair"
+                );
+                chain_break_op = Some(header.op);
+                break;
+            }
+        }
+        previous = Some(*header);
 
         last_journaled_op = Some(header.op);
         if header.op > commit_watermark {
@@ -626,6 +659,22 @@ where
         last_applied_op = Some(header.op);
     }
 
+    // `truncate_from`, never `drain`: the removed ops must stay refillable, 
so the
+    // snapshot watermark stays put. Leaving them resident would make `append` 
refuse
+    // the slot, failing repair on exactly the ops it exists to fix.
+    if let Some(break_op) = chain_break_op {
+        let removed = journal
+            .truncate_from(break_op)
+            .await
+            .map_err(RecoveryError::Io)?;
+        tracing::warn!(
+            break_op,
+            removed,
+            last_journaled_op,
+            "dropped the disconnected metadata WAL suffix; the cluster 
re-supplies these ops"
+        );
+    }
+
     Ok(RecoveredMetadata {
         journal,
         snapshot,
@@ -636,6 +685,7 @@ where
         client_table,
         last_applied_op,
         last_journaled_op,
+        chain_break_op,
     })
 }
 
@@ -976,6 +1026,130 @@ mod tests {
         assert_eq!(recovered.journal.last_op(), Some(3));
     }
 
+    /// A prepare sealed the way a live primary seals one: `parent` chains to 
the
+    /// previous op's identity and `checksum` is that identity.
+    fn make_chained_prepare(op: u64, commit: u64, parent: u128) -> 
Message<PrepareHeader> {
+        let mut message = make_prepare_with_commit(op, commit, 32);
+        let header = bytemuck::checked::from_bytes_mut::<PrepareHeader>(
+            &mut message.as_mut_slice()[..HEADER_SIZE],
+        );
+        header.parent = parent;
+        let checksum = header.identity_checksum();
+        header.checksum = checksum;
+        message
+    }
+
+    #[compio::test]
+    async fn recover_stops_at_a_gap_and_drops_the_disconnected_suffix() {
+        // Ops 1-3 then 5: op 4 never landed. Replaying 5 over a state machine 
that
+        // never saw 4 diverges silently, and the WAL scan waves this through 
--
+        // its chain check only fires on CONSECUTIVE ops, since a gap is also 
what
+        // ordinary compaction leaves behind.
+        let dir = tempdir().unwrap();
+        let metadata_dir = dir.path().join("metadata");
+        std::fs::create_dir_all(&metadata_dir).unwrap();
+
+        {
+            let journal = 
PrepareJournal::open(&metadata_dir.join("journal.wal"), 0)
+                .await
+                .unwrap();
+            for op in 1..=3u64 {
+                journal
+                    .append(make_prepare_with_commit(op, op, 32))
+                    .await
+                    .unwrap();
+            }
+            journal
+                .append(make_prepare_with_commit(5, 5, 32))
+                .await
+                .unwrap();
+            journal.storage_ref().fsync().await.unwrap();
+        }
+
+        let recovered = recover::<TestStm>(
+            dir.path(),
+            CLUSTERED,
+            journal::prepare_journal::DEFAULT_SLOT_COUNT,
+            CLIENTS_TABLE_MAX,
+            |_| {},
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(recovered.chain_break_op, Some(5));
+        assert_eq!(
+            recovered.last_applied_op,
+            Some(3),
+            "op 5 must not apply across the hole at op 4"
+        );
+        assert_eq!(
+            recovered.last_journaled_op,
+            Some(3),
+            "the restored head stops below the break, so nothing re-pipelines 
it"
+        );
+        assert_eq!(
+            recovered.journal.last_op(),
+            Some(3),
+            "the disconnected entry is dropped so repair can journal the 
cluster's op 5"
+        );
+        assert_eq!(
+            recovered.journal.snapshot_op(),
+            0,
+            "truncating a suffix must leave the watermark, or the ops stop 
being refillable"
+        );
+    }
+
+    #[compio::test]
+    async fn recover_stops_at_a_broken_chain_between_consecutive_ops() {
+        // Consecutive and sealed on both ends, but op 3 names a parent that 
is not
+        // op 2: a fork left by a crash mid view change. Ops are appended out 
of
+        // ascending file order so the scan's own chain check does not fire 
first.
+        let dir = tempdir().unwrap();
+        let metadata_dir = dir.path().join("metadata");
+        std::fs::create_dir_all(&metadata_dir).unwrap();
+
+        {
+            let journal = 
PrepareJournal::open(&metadata_dir.join("journal.wal"), 0)
+                .await
+                .unwrap();
+            let first = make_chained_prepare(1, 1, 0);
+            let first_checksum = first.header().checksum;
+            journal.append(first).await.unwrap();
+            let second = make_chained_prepare(2, 2, first_checksum);
+            journal.append(second).await.unwrap();
+            // Parent of a prepare that is not op 2.
+            journal
+                .append(make_chained_prepare(3, 3, 0xdead_beef))
+                .await
+                .unwrap();
+            journal.storage_ref().fsync().await.unwrap();
+        }
+
+        let recovered = recover::<TestStm>(
+            dir.path(),
+            CLUSTERED,
+            journal::prepare_journal::DEFAULT_SLOT_COUNT,
+            CLIENTS_TABLE_MAX,
+            |_| {},
+        )
+        .await;
+
+        // The WAL scan reaches this first and refuses boot: consecutive ops, 
both
+        // sealed, chain broken, with no entry after it is only a tail. Either
+        // outcome is a refusal to apply the fork; what must never happen is a
+        // clean recovery that replayed op 3.
+        match recovered {
+            Err(RecoveryError::Journal(_) | RecoveryError::Io(_)) => {}
+            Ok(recovered) => {
+                assert!(
+                    recovered.last_applied_op < Some(3),
+                    "op 3 forks the chain and must not be applied"
+                );
+            }
+            Err(other) => panic!("unexpected recovery error: {other:?}"),
+        }
+    }
+
     #[compio::test]
     async fn recover_applies_only_the_committed_prefix() {
         let dir = tempdir().unwrap();
diff --git a/core/partitions/src/iggy_partition.rs 
b/core/partitions/src/iggy_partition.rs
index 54a793555..bb7de4290 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -2027,10 +2027,7 @@ where
         // Same reason as the metadata plane: `checksum` is compared as an 
opaque token
         // downstream, so a corrupted frame passes whenever its flipped value 
satisfies
         // those comparisons.
-        if let Err(reason) = verify_prepare_integrity(
-            &header,
-            &message.as_slice()[std::mem::size_of::<PrepareHeader>()..],
-        ) {
+        if let Err(reason) = verify_prepare_integrity(&header, 
message.as_slice()) {
             emit_partition_diag(
                 tracing::Level::WARN,
                 &PartitionDiagEvent::new(
diff --git a/core/sdk/src/vsr.rs b/core/sdk/src/vsr.rs
index 9bc5d1cd1..1a64d843e 100644
--- a/core/sdk/src/vsr.rs
+++ b/core/sdk/src/vsr.rs
@@ -38,7 +38,7 @@ use iggy_binary_protocol::requests::consumer_offsets::{
 use iggy_binary_protocol::requests::messages::SendMessagesHeader;
 use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
 use iggy_binary_protocol::{WireIdentifier, WirePartitioning};
-use iggy_common::{IggyError, eviction_reason_to_error};
+use iggy_common::{IggyError, calculate_checksum, eviction_reason_to_error};
 
 const NON_REPLICATED_CODE_RANGE: std::ops::Range<usize> = 0..4;
 
@@ -139,6 +139,12 @@ pub(crate) fn encode_request_header(
         request: request_id,
         session: session_id,
         namespace,
+        // Lets the server's client table tell a genuine retry from a `request`
+        // number reused for different arguments. Zero means unstamped, which 
is what
+        // an SDK predating this sends. A server that rewrites the body (PAT,
+        // password) carries this through untouched, so it keeps describing 
what the
+        // client sent.
+        request_checksum: u128::from(calculate_checksum(payload)),
         // Zeroed: the field is "informational" -- the server copies it into
         // `ReplyHeader.timestamp` for RTT but nothing else reads it. Paying
         // a `clock_gettime` syscall per encoded request (formerly held the
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index 8ac2c8d59..cd0ed34ed 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -49,7 +49,7 @@ use crate::responses::{
 use crate::session_manager::SessionManager;
 use crate::snapshot;
 use crate::users::maybe_rewrite_user_password_request;
-use crate::wire::{request_body, usize_to_u32};
+use crate::wire::{request_body, usize_to_u32, verify_request_checksum};
 use bytes::Bytes;
 use configs::server_ng::NgSystemConfig;
 use consensus::{
@@ -761,6 +761,37 @@ async fn handle_client_request<B, MJ, S, SB>(
         }
     };
 
+    // The last point that still sees the body the CLIENT sent; every rewrite 
below
+    // substitutes server-chosen bytes and carries the stamp through unchanged.
+    if let Err(error) = verify_request_checksum(&request) {
+        warn!(
+            transport_client_id,
+            operation = ?request.header().operation,
+            request = request.header().request,
+            "dropping client request whose body does not match its own 
checksum"
+        );
+        let commit = current_metadata_commit(shard);
+        let reply = build_deny_reply(
+            request.header(),
+            transport_client_id,
+            0,
+            commit,
+            error.as_code(),
+        );
+        if let Err(send_error) = shard
+            .bus
+            .send_to_client(transport_client_id, 
reply.into_generic().into_frozen())
+            .await
+        {
+            warn!(
+                transport_client_id,
+                error = %send_error,
+                "failed to send request-checksum deny reply"
+            );
+        }
+        return;
+    }
+
     ensure_transport_connection(shard, sessions, transport_client_id);
 
     // Any request is liveness proof, not just PING: an idle-but-active client
diff --git a/core/server-ng/src/wire.rs b/core/server-ng/src/wire.rs
index e7a047089..639819efc 100644
--- a/core/server-ng/src/wire.rs
+++ b/core/server-ng/src/wire.rs
@@ -30,6 +30,23 @@ pub(crate) fn request_body(request: &Message<RequestHeader>) 
-> &[u8] {
     
&request.as_slice()[std::mem::size_of::<RequestHeader>()..request.header().size 
as usize]
 }
 
+/// Check a client's `request_checksum` against the body it stamps.
+///
+/// Must run BEFORE any body rewrite: PAT / password / consumer-group paths
+/// substitute server-chosen bytes. Zero is "unstamped" and skips the check, 
so an
+/// SDK predating the stamp still works.
+///
+/// # Errors
+/// [`IggyError::InvalidFormat`] when the stamp disagrees with the body.
+pub(crate) fn verify_request_checksum(request: &Message<RequestHeader>) -> 
Result<(), IggyError> {
+    let stamped = request.header().request_checksum;
+    if stamped == 0 || 
u128::from(iggy_common::calculate_checksum(request_body(request))) == stamped
+    {
+        return Ok(());
+    }
+    Err(IggyError::InvalidFormat)
+}
+
 /// Map the transport kind to the legacy wire discriminant
 /// (`1=TCP, 2=QUIC, 4=WebSocket`); TLS variants report their base
 /// transport. `ClientTransportKind` is `#[non_exhaustive]`, so any other
@@ -65,11 +82,78 @@ pub(crate) fn rewrite_request_body(
     .expect("zeroed bytes are a valid request header");
     *header = *request.header();
     header.size = size;
+    // Both describe the body just replaced, and nothing recomputes them for a
+    // `RequestHeader` -- the prepare projection derives its own 
`checksum_body`
+    // downstream. Clear rather than recompute; carrying them forward is a 
stale claim.
+    header.checksum = 0;
+    header.checksum_body = 0;
+    // `request_checksum` is deliberately NOT touched: it stamps what the 
CLIENT sent,
+    // already validated at admission. Re-stamping it over the substituted 
body would
+    // make the client-table reuse check compare a value no client ever 
produced.
     
rewritten.as_mut_slice()[std::mem::size_of::<RequestHeader>()..].copy_from_slice(body);
-    // TODO(vsr): the body changed but `request_checksum` / `checksum` /
-    // `checksum_body` were copied verbatim from the original header. Safe
-    // today because the SDK initializes `request_checksum` to 0 and the
-    // server does not validate it; the moment integrity checking lands,
-    // recompute these here (or zero them and re-sign in a follow-up step).
     Ok(rewritten)
 }
+
+#[cfg(test)]
+mod tests {
+    use super::{request_body, rewrite_request_body};
+    use bytes::Bytes;
+    use iggy_binary_protocol::{Command2, Operation, RequestHeader};
+    use server_common::Message;
+    use std::mem::size_of;
+
+    fn request(body: &[u8], request_checksum: u128) -> Message<RequestHeader> {
+        let total_size = size_of::<RequestHeader>() + body.len();
+        let mut message = 
Message::<RequestHeader>::new(total_size).transmute_header(
+            |_, header: &mut RequestHeader| {
+                header.command = Command2::Request;
+                header.operation = Operation::CreateStream;
+                header.client = 1;
+                header.session = 1;
+                header.request = 9;
+                header.size = u32::try_from(total_size).expect("fits u32");
+                header.request_checksum = request_checksum;
+                header.checksum = 0xdead;
+                header.checksum_body = 0xbeef;
+            },
+        );
+        
message.as_mut_slice()[size_of::<RequestHeader>()..].copy_from_slice(body);
+        message
+    }
+
+    #[test]
+    fn 
given_a_body_rewrite_should_keep_the_client_stamp_and_clear_the_stale_seals() {
+        // The secret-bearing wire body is swapped for the hash-carrying 
replicated
+        // one. `request_checksum` describes what the client sent and 
admission has
+        // already checked it, so it must survive; the other two describe the 
body
+        // that just went away.
+        let original = request(b"plaintext-secret", 0x1234);
+        let rewritten = rewrite_request_body(&original, 
&Bytes::from_static(b"argon2-hash"))
+            .expect("the rewritten body fits a request message");
+
+        assert_eq!(
+            rewritten.header().request_checksum,
+            0x1234,
+            "the client's stamp must not be re-signed over server-substituted 
bytes"
+        );
+        assert_eq!(rewritten.header().checksum, 0);
+        assert_eq!(rewritten.header().checksum_body, 0);
+        assert_eq!(request_body(&rewritten), b"argon2-hash");
+        assert_eq!(
+            rewritten.header().size as usize,
+            size_of::<RequestHeader>() + b"argon2-hash".len(),
+            "`size` follows the new body, so `request_body` bounds it 
correctly"
+        );
+    }
+
+    #[test]
+    fn given_an_unstamped_request_when_rewriting_should_stay_unstamped() {
+        // Zero means "unstamped" all the way through the client table, so a 
rewrite
+        // must not manufacture a stamp for a client that sent none.
+        let original = request(b"plaintext-secret", 0);
+        let rewritten = rewrite_request_body(&original, 
&Bytes::from_static(b"argon2-hash"))
+            .expect("the rewritten body fits a request message");
+
+        assert_eq!(rewritten.header().request_checksum, 0);
+    }
+}
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index c2b6e2c99..f5c5cf837 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -4022,9 +4022,7 @@ where
             // Recompute both integrity fields before durable storage: 
everything
             // above treats `header.checksum` as an opaque token, so a 
corrupted
             // frame passes whenever its flipped value satisfies the 
comparisons.
-            if let Err(reason) =
-                verify_prepare_integrity(&header, 
&msg.as_slice()[size_of::<PrepareHeader>()..])
-            {
+            if let Err(reason) = verify_prepare_integrity(&header, 
msg.as_slice()) {
                 tracing::warn!(
                     shard = self.id,
                     op = header.op,
@@ -4093,9 +4091,7 @@ where
         // The partition arm reaches the WAL via `apply_repaired_prepare` with 
no
         // view fence and no ack, so this is its only integrity gate. Without 
it a
         // repaired partition prepare is journaled on the serving peer's word 
alone.
-        if let Err(reason) =
-            verify_prepare_integrity(&header, 
&msg.as_slice()[size_of::<PrepareHeader>()..])
-        {
+        if let Err(reason) = verify_prepare_integrity(&header, msg.as_slice()) 
{
             tracing::warn!(
                 shard = self.id,
                 op = header.op,

Reply via email to