Skip to main content

ant_node/replication/
protocol.rs

1//! Wire protocol messages for the replication subsystem.
2//!
3//! All messages use postcard serialization for compact, fast encoding.
4//! Peer IDs are transmitted as raw `[u8; 32]` byte arrays.
5
6use serde::{Deserialize, Serialize};
7
8use crate::ant_protocol::XorName;
9
10pub use super::config::MAX_REPLICATION_MESSAGE_SIZE;
11
12/// Sentinel digest value indicating the challenged key is absent from storage.
13///
14/// Used in [`AuditResponse::Digests`] for keys the peer does not hold.
15pub const ABSENT_KEY_DIGEST: [u8; 32] = [0u8; 32];
16
17// ---------------------------------------------------------------------------
18// Top-level envelope
19// ---------------------------------------------------------------------------
20
21/// Top-level replication message envelope.
22///
23/// Every replication wire message carries a sender-assigned `request_id` so
24/// that the receiver can correlate responses without relying on transport-layer
25/// ordering.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ReplicationMessage {
28    /// Sender-assigned request ID for correlation.
29    pub request_id: u64,
30    /// The message body.
31    pub body: ReplicationMessageBody,
32}
33
34impl ReplicationMessage {
35    /// Encode the message to bytes using postcard.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`ReplicationProtocolError::SerializationFailed`] if postcard
40    /// serialization fails.
41    pub fn encode(&self) -> Result<Vec<u8>, ReplicationProtocolError> {
42        let bytes = postcard::to_stdvec(self)
43            .map_err(|e| ReplicationProtocolError::SerializationFailed(e.to_string()))?;
44
45        if bytes.len() > MAX_REPLICATION_MESSAGE_SIZE {
46            return Err(ReplicationProtocolError::MessageTooLarge {
47                size: bytes.len(),
48                max_size: MAX_REPLICATION_MESSAGE_SIZE,
49            });
50        }
51
52        Ok(bytes)
53    }
54
55    /// Decode a message from bytes using postcard.
56    ///
57    /// Rejects payloads larger than [`MAX_REPLICATION_MESSAGE_SIZE`] before
58    /// attempting deserialization.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`ReplicationProtocolError::MessageTooLarge`] if the input
63    /// exceeds the size limit, or
64    /// [`ReplicationProtocolError::DeserializationFailed`] if postcard cannot
65    /// parse the data.
66    pub fn decode(data: &[u8]) -> Result<Self, ReplicationProtocolError> {
67        if data.len() > MAX_REPLICATION_MESSAGE_SIZE {
68            return Err(ReplicationProtocolError::MessageTooLarge {
69                size: data.len(),
70                max_size: MAX_REPLICATION_MESSAGE_SIZE,
71            });
72        }
73        postcard::from_bytes(data)
74            .map_err(|e| ReplicationProtocolError::DeserializationFailed(e.to_string()))
75    }
76}
77
78// ---------------------------------------------------------------------------
79// Message body enum
80// ---------------------------------------------------------------------------
81
82/// All replication protocol message types.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub enum ReplicationMessageBody {
85    // === Fresh Replication (Section 6.1) ===
86    /// Fresh replication offer with `PoP` (sent to close group members).
87    FreshReplicationOffer(FreshReplicationOffer),
88    /// Response to a fresh replication offer.
89    FreshReplicationResponse(FreshReplicationResponse),
90
91    /// Paid-list notification with `PoP` (sent to `PaidCloseGroup` members).
92    PaidNotify(PaidNotify),
93
94    // === Neighbor Sync (Section 6.2) ===
95    /// Neighbor sync hint exchange (bidirectional).
96    NeighborSyncRequest(NeighborSyncRequest),
97    /// Response to neighbor sync with own hints.
98    NeighborSyncResponse(NeighborSyncResponse),
99
100    // === Verification (Section 9) ===
101    /// Batched verification request (presence + paid-list queries).
102    VerificationRequest(VerificationRequest),
103    /// Response to verification request with per-key evidence.
104    VerificationResponse(VerificationResponse),
105
106    // === Fetch (record retrieval) ===
107    /// Request to fetch a record by key.
108    FetchRequest(FetchRequest),
109    /// Response with the record data.
110    FetchResponse(FetchResponse),
111
112    // === Responsible-chunk audit (per-key digests) ===
113    /// Per-key audit challenge: used by the responsible-chunk audit and the
114    /// prune-confirmation path.
115    AuditChallenge(AuditChallenge),
116    /// Response to a per-key audit challenge.
117    AuditResponse(AuditResponse),
118
119    // === Storage-bound subtree audit (ADR-0002) ===
120    /// Gossip-triggered contiguous-subtree storage audit challenge (round 1).
121    SubtreeAuditChallenge(SubtreeAuditChallenge),
122    /// Response to a contiguous-subtree storage audit challenge (round 1).
123    SubtreeAuditResponse(SubtreeAuditResponse),
124    /// Surprise byte challenge for the spot-checked leaves (round 2).
125    SubtreeByteChallenge(SubtreeByteChallenge),
126    /// Response carrying the requested chunks' original bytes (round 2).
127    SubtreeByteResponse(SubtreeByteResponse),
128}
129
130// ---------------------------------------------------------------------------
131// Fresh Replication Messages
132// ---------------------------------------------------------------------------
133
134/// Fresh replication offer (includes record + `PoP`).
135///
136/// Sent to close-group members when a node receives a new chunk via client PUT.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct FreshReplicationOffer {
139    /// The record key.
140    pub key: XorName,
141    /// The record data.
142    pub data: Vec<u8>,
143    /// Proof of Payment (required, validated by receiver).
144    pub proof_of_payment: Vec<u8>,
145}
146
147/// Response to a fresh replication offer.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub enum FreshReplicationResponse {
150    /// Record accepted and stored.
151    Accepted {
152        /// The accepted record key.
153        key: XorName,
154    },
155    /// Record rejected (with reason).
156    Rejected {
157        /// The rejected record key.
158        key: XorName,
159        /// Human-readable rejection reason.
160        reason: String,
161    },
162}
163
164/// Paid-list notification carrying key + `PoP` (Section 7.3).
165///
166/// Sent to `PaidCloseGroup` members so they record the key in their
167/// `PaidForList` without needing to hold the record data.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct PaidNotify {
170    /// The record key.
171    pub key: XorName,
172    /// Proof of Payment for receiver-side verification.
173    pub proof_of_payment: Vec<u8>,
174}
175
176// ---------------------------------------------------------------------------
177// Neighbor Sync Messages
178// ---------------------------------------------------------------------------
179
180/// Neighbor sync request carrying hint sets (Section 6.2).
181///
182/// Exchanged between close neighbors to detect and repair missing replicas.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct NeighborSyncRequest {
185    /// Keys sender believes receiver should hold (replica hints).
186    pub replica_hints: Vec<XorName>,
187    /// Keys sender believes receiver should track in `PaidForList` (paid hints).
188    pub paid_hints: Vec<XorName>,
189    /// Whether sender is currently bootstrapping.
190    pub bootstrapping: bool,
191    /// Sender's signed storage commitment (optional, see
192    /// [`crate::replication::commitment`]). `None` from old peers; from
193    /// new peers this carries the Merkle-root commitment over the
194    /// sender's claimed keys. Receivers that recognize it store it as
195    /// the per-peer "last known commitment" used to pin commitment-bound
196    /// audits.
197    #[serde(default)]
198    pub commitment: Option<crate::replication::commitment::StorageCommitment>,
199}
200
201/// Neighbor sync response carrying own hint sets.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct NeighborSyncResponse {
204    /// Keys receiver believes sender should hold (replica hints).
205    pub replica_hints: Vec<XorName>,
206    /// Keys receiver believes sender should track in `PaidForList` (paid hints).
207    pub paid_hints: Vec<XorName>,
208    /// Whether receiver is currently bootstrapping.
209    pub bootstrapping: bool,
210    /// Keys that receiver rejected (optional feedback to sender).
211    pub rejected_keys: Vec<XorName>,
212    /// Receiver's signed storage commitment (optional, see
213    /// [`NeighborSyncRequest::commitment`]).
214    #[serde(default)]
215    pub commitment: Option<crate::replication::commitment::StorageCommitment>,
216}
217
218// ---------------------------------------------------------------------------
219// Verification Messages
220// ---------------------------------------------------------------------------
221
222/// Batched verification request for multiple keys (Section 9).
223///
224/// Sent to peers in `VerifyTargets` (union of `QuorumTargets` and
225/// `PaidTargets`). Each peer returns per-key presence and optionally
226/// paid-list status.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct VerificationRequest {
229    /// Keys to verify (batched).
230    pub keys: Vec<XorName>,
231    /// Which keys need paid-list status in addition to presence.
232    /// Each value is an index into the `keys` vector.
233    pub paid_list_check_indices: Vec<u32>,
234}
235
236/// Per-key verification result from a peer.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct KeyVerificationResult {
239    /// The key being verified.
240    pub key: XorName,
241    /// Whether this peer holds the record.
242    pub present: bool,
243    /// Paid-list status (only set if peer was asked for paid-list check).
244    ///
245    /// - `Some(true)` -- key is in peer's `PaidForList`.
246    /// - `Some(false)` -- key is NOT in peer's `PaidForList`.
247    /// - `None` -- paid-list check was not requested for this key.
248    pub paid: Option<bool>,
249}
250
251/// Batched verification response with per-key results.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct VerificationResponse {
254    /// Per-key results (one per requested key, in request order).
255    pub results: Vec<KeyVerificationResult>,
256}
257
258// ---------------------------------------------------------------------------
259// Fetch Messages
260// ---------------------------------------------------------------------------
261
262/// Request to fetch a specific record by key.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct FetchRequest {
265    /// The key of the record to fetch.
266    pub key: XorName,
267}
268
269/// Response to a fetch request.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub enum FetchResponse {
272    /// Record found and returned.
273    Success {
274        /// The record key.
275        key: XorName,
276        /// The record data.
277        data: Vec<u8>,
278    },
279    /// Record not found on this peer.
280    NotFound {
281        /// The requested key.
282        key: XorName,
283    },
284    /// Error during fetch.
285    Error {
286        /// The requested key.
287        key: XorName,
288        /// Human-readable error description.
289        reason: String,
290    },
291}
292
293// ---------------------------------------------------------------------------
294// Audit Messages
295// ---------------------------------------------------------------------------
296
297/// Per-key audit challenge.
298///
299/// The challenger picks a random nonce and a set of keys the challenged peer
300/// should hold, then sends this challenge. The challenged peer proves storage
301/// by returning per-key BLAKE3 digests. Used by the responsible-chunk audit
302/// (audit #2: a node samples keys a close peer should hold) and by the
303/// prune-confirmation path (a node checks a peer still holds a key before
304/// pruning its own copy).
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct AuditChallenge {
307    /// Unique challenge identifier.
308    pub challenge_id: u64,
309    /// Random nonce for digest computation.
310    pub nonce: [u8; 32],
311    /// Challenged peer ID (included in digest computation).
312    pub challenged_peer_id: [u8; 32],
313    /// Ordered list of keys to prove storage of.
314    pub keys: Vec<XorName>,
315}
316
317/// Response to a per-key audit challenge.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub enum AuditResponse {
320    /// Per-key digests proving storage.
321    ///
322    /// `digests[i]` corresponds to `challenge.keys[i]`.
323    /// An [`ABSENT_KEY_DIGEST`] sentinel signals key absence.
324    Digests {
325        /// The challenge this response answers.
326        challenge_id: u64,
327        /// One 32-byte digest per challenged key, in challenge order.
328        digests: Vec<[u8; 32]>,
329    },
330    /// Peer is still bootstrapping (not ready for audit).
331    Bootstrapping {
332        /// The challenge this response answers.
333        challenge_id: u64,
334    },
335    /// Challenge rejected (wrong target peer or too many keys).
336    ///
337    /// Distinct from empty `Digests` so the challenger can distinguish a
338    /// legitimate rejection from misbehavior.
339    Rejected {
340        /// The challenge this response answers.
341        challenge_id: u64,
342        /// Human-readable rejection reason.
343        reason: String,
344    },
345}
346
347/// Gossip-triggered contiguous-subtree storage audit challenge (ADR-0002).
348///
349/// The auditor pins the commitment a peer just gossiped and sends a fresh
350/// random nonce. The nonce alone deterministically selects one contiguous
351/// subtree of the peer's committed Merkle tree (see
352/// [`crate::replication::subtree::select_subtree_path`]); the auditor does
353/// **not** name keys. The responder must reply with a
354/// [`SubtreeAuditResponse::Proof`] for that selected subtree against the pinned
355/// commitment, or a [`SubtreeAuditResponse::Rejected`] if it genuinely cannot
356/// (for a recently gossiped pinned commitment a rejection is a confirmed
357/// failure, since the responder retains its last two gossiped commitments).
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct SubtreeAuditChallenge {
360    /// Unique challenge identifier.
361    pub challenge_id: u64,
362    /// Random nonce. Selects the subtree AND freshens each leaf's possession
363    /// hash, so a stored answer cannot be replayed.
364    pub nonce: [u8; 32],
365    /// Challenged peer ID. Bound into each leaf's possession hash.
366    pub challenged_peer_id: [u8; 32],
367    /// The auditor's pin: the [`crate::replication::commitment::commitment_hash`]
368    /// of the commitment the peer just gossiped. The response's commitment must
369    /// hash to exactly this value.
370    pub expected_commitment_hash: [u8; 32],
371}
372
373/// Response to a contiguous-subtree storage audit challenge (ADR-0002).
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub enum SubtreeAuditResponse {
376    /// The single-contiguous-subtree proof.
377    ///
378    /// Carries the responder's signed commitment (so the auditor re-derives
379    /// `key_count` and confirms the pin and signature) and the
380    /// nonce-selected subtree expanded to its leaves plus the sibling
381    /// cut-hashes on the path to the root. This is **round 1** of the
382    /// two-round audit. The auditor:
383    ///   1. confirms `commitment_hash(commitment) == expected_commitment_hash`
384    ///      and the signature is valid;
385    ///   2. re-derives the selected subtree from `(nonce, key_count)`, rebuilds
386    ///      the root from the proof, and requires it to equal the commitment
387    ///      root (structure).
388    ///
389    /// The leaves carry only hashes (`bytes_hash`, `nonced_hash`), so this round
390    /// proves the tree SHAPE is committed — not that the bytes are still held.
391    /// Real possession is proven in **round 2**: the auditor picks a few of the
392    /// just-verified leaves and sends a [`SubtreeByteChallenge`] requesting their
393    /// original chunk bytes FROM the responder (see that type).
394    Proof {
395        /// The challenge this response answers.
396        challenge_id: u64,
397        /// The signed commitment whose root the proof is against.
398        commitment: crate::replication::commitment::StorageCommitment,
399        /// The nonce-selected contiguous subtree proof.
400        proof: crate::replication::subtree::SubtreeProof,
401    },
402    /// Peer is still bootstrapping (not ready for audit).
403    Bootstrapping {
404        /// The challenge this response answers.
405        challenge_id: u64,
406    },
407    /// Challenge rejected. `kind` drives the auditor's accounting (confirmed vs
408    /// graced); `reason` is the human-readable detail for logs.
409    Rejected {
410        /// The challenge this response answers.
411        challenge_id: u64,
412        /// Machine-readable rejection class (accounting).
413        kind: RejectKind,
414        /// Human-readable rejection reason.
415        reason: String,
416    },
417}
418
419/// Why a responder rejected an audit challenge, in a form the auditor can act
420/// on without string-matching.
421///
422/// The distinction matters for accounting: a responder that no longer RETAINS
423/// the pinned commitment may simply have rotated past it legitimately — the
424/// auditor can pin a root it gossiped a while ago, or one whose newer
425/// replacements the auditor never observed (retention is capped at the last two
426/// *gossiped* roots, so a peer that rotated several times within the
427/// answerability window can honestly drop an older one). That is NOT provable
428/// misbehaviour, so it is GRACED like a timeout. Every other rejection is a
429/// genuine protocol fault the auditor confirms.
430///
431/// **Self-grace is bounded and does not preserve stale credit.** A Byzantine
432/// peer can deliberately claim `UnknownCommitment`/`Transient` to dodge the
433/// confirmed-failure *trust penalty*. But the auditor still REVOKES the holder
434/// credit for the PINNED commitment on any graced rejection (it answered and
435/// could not prove possession now — see the credit revocation in
436/// `storage_commitment_audit`'s rejection handling). The revocation is scoped to
437/// the pinned commitment hash, so it strips exactly the credit for the root the
438/// peer would not prove without touching credit it legitimately re-earned for a
439/// newer commitment. Lying therefore does not let a deleter keep "proven holder"
440/// status for that root until the credit TTL — the loophole a plain timeout
441/// would leave. It also accumulates a timeout strike, so a peer
442/// that self-graces on every audit still crosses the strike threshold once
443/// timeout-eviction is enabled. An honest peer that genuinely rotated simply
444/// re-earns credit on the next audit of its current commitment, so the grace
445/// strips no peer that is actually holding its responsible data. The grace
446/// removes only the false TRUST PENALTY for the genuinely-ambiguous
447/// rotated/transient case; it does not remove the possession requirement.
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
449pub enum RejectKind {
450    /// The responder does not retain the pinned commitment (rotated past it).
451    /// GRACED — indistinguishable from legitimate rotation the auditor missed.
452    UnknownCommitment,
453    /// A transient, recoverable condition on the responder (e.g. a storage read
454    /// error) that is NOT evidence of missing data. GRACED like a timeout so a
455    /// flaky disk never manufactures a confirmed possession failure.
456    Transient,
457    /// Any other rejection (wrong target peer, no commitment state, malformed
458    /// proof plan, oversized byte challenge, …). CONFIRMED failure.
459    Protocol,
460}
461
462impl RejectKind {
463    /// Whether the auditor should GRACE this rejection (treat like a timeout —
464    /// no confirmed penalty, no holder-credit revocation) rather than confirm
465    /// it. Only genuine protocol faults are confirmed; rotation/transient
466    /// conditions are graced because they are not provable misbehaviour.
467    #[must_use]
468    pub fn is_graced(self) -> bool {
469        matches!(self, Self::UnknownCommitment | Self::Transient)
470    }
471}
472
473/// Round 2 of the storage audit (ADR-0002): the **surprise byte challenge**.
474///
475/// After the auditor has structurally verified a [`SubtreeAuditResponse::Proof`]
476/// it picks a small sample of that subtree's just-proven leaves with FRESH
477/// randomness (chosen now, after the proof is committed — NOT derived from the
478/// round-1 nonce, so the responder could not have predicted it at proof-build
479/// time) and asks the responder to return the ORIGINAL chunk bytes for exactly
480/// those keys. The auditor then checks each returned chunk against the committed
481/// leaf:
482///   - `BLAKE3(bytes) == leaf.bytes_hash` (the chunk's content address), AND
483///   - `compute_audit_digest(nonce, peer, key, bytes) == leaf.nonced_hash`.
484///
485/// This makes possession non-delegable to the auditor: the auditor needs to
486/// hold NONE of the responder's chunks. A responder that committed to a chunk it
487/// no longer holds cannot fabricate bytes that hash to the committed address (a
488/// preimage break), so it is caught regardless of who audits it.
489#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct SubtreeByteChallenge {
491    /// The same `challenge_id` as the round-1 [`SubtreeAuditChallenge`], so the
492    /// responder/auditor correlate the two rounds.
493    pub challenge_id: u64,
494    /// The same nonce as round 1 — needed for the freshness (`nonced_hash`)
495    /// check and to bind these bytes to this audit.
496    pub nonce: [u8; 32],
497    /// The challenged peer ID (bound into each leaf's possession hash).
498    pub challenged_peer_id: [u8; 32],
499    /// The pinned commitment hash from round 1, so the responder resolves the
500    /// SAME tree it just proved and serves bytes only for keys it committed to.
501    pub expected_commitment_hash: [u8; 32],
502    /// The exact keys whose original bytes the responder must return. These are
503    /// the auditor's freshly-randomised spot-check sample of the round-1 subtree
504    /// (chosen after the proof was received; not nonce-derived).
505    pub keys: Vec<XorName>,
506}
507
508/// One requested chunk in a [`SubtreeByteResponse`].
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
510pub enum SubtreeByteItem {
511    /// The responder holds this committed key and returns its original bytes.
512    Present {
513        /// The requested key.
514        key: XorName,
515        /// The original chunk bytes (the auditor re-hashes to verify).
516        bytes: Vec<u8>,
517    },
518    /// The responder committed to this key but cannot serve its bytes. This is a
519    /// PROVABLE cheat (it published a commitment over a chunk it does not hold),
520    /// so the auditor counts it as a confirmed failure — NOT a graced timeout.
521    /// Distinguishing this explicit signal from silence is what separates a
522    /// deleter (instant fail) from a dropped packet (timeout).
523    Absent {
524        /// The committed key the responder could not serve.
525        key: XorName,
526    },
527}
528
529/// Response to a [`SubtreeByteChallenge`] (round 2). One item per requested key,
530/// in the requested order.
531///
532/// Sizing rule: a challenge carries at most
533/// [`MAX_BYTE_CHALLENGE_KEYS`](super::config::MAX_BYTE_CHALLENGE_KEYS) keys —
534/// the auditor batches its sample, the responder rejects larger requests — so
535/// the WORST-CASE `Items` response (every chunk at `MAX_CHUNK_SIZE`) always
536/// encodes under [`MAX_REPLICATION_MESSAGE_SIZE`].
537#[derive(Debug, Clone, Serialize, Deserialize)]
538pub enum SubtreeByteResponse {
539    /// The responder's per-key answers (bytes or an explicit absent signal).
540    Items {
541        /// The challenge this response answers.
542        challenge_id: u64,
543        /// One entry per requested key.
544        items: Vec<SubtreeByteItem>,
545    },
546    /// Peer is still bootstrapping (should not happen mid-audit, but handled).
547    Bootstrapping {
548        /// The challenge this response answers.
549        challenge_id: u64,
550    },
551    /// The responder rejects the byte challenge outright. `kind` drives the
552    /// auditor's accounting: [`RejectKind::UnknownCommitment`] (rotated past the
553    /// pin) is graced; everything else is a confirmed failure, like round 1.
554    Rejected {
555        /// The challenge this response answers.
556        challenge_id: u64,
557        /// Machine-readable rejection class (accounting).
558        kind: RejectKind,
559        /// Human-readable rejection reason.
560        reason: String,
561    },
562}
563
564// ---------------------------------------------------------------------------
565// Audit digest helper
566// ---------------------------------------------------------------------------
567
568/// Compute `AuditKeyDigest(K_i) = BLAKE3(nonce || challenged_peer_id || K_i || record_bytes_i)`.
569///
570/// Returns the 32-byte BLAKE3 digest binding the nonce, peer identity, key,
571/// and record content together so a peer cannot forge proofs without holding
572/// the actual data.
573#[must_use]
574pub fn compute_audit_digest(
575    nonce: &[u8; 32],
576    challenged_peer_id: &[u8; 32],
577    key: &XorName,
578    record_bytes: &[u8],
579) -> [u8; 32] {
580    let mut hasher = blake3::Hasher::new();
581    hasher.update(nonce);
582    hasher.update(challenged_peer_id);
583    hasher.update(key);
584    hasher.update(record_bytes);
585    *hasher.finalize().as_bytes()
586}
587
588// ---------------------------------------------------------------------------
589// Error type
590// ---------------------------------------------------------------------------
591
592/// Errors from replication protocol encode/decode operations.
593#[derive(Debug, Clone)]
594pub enum ReplicationProtocolError {
595    /// Postcard serialization failed.
596    SerializationFailed(String),
597    /// Postcard deserialization failed.
598    DeserializationFailed(String),
599    /// Wire message exceeds the maximum allowed size.
600    MessageTooLarge {
601        /// Actual size of the message in bytes.
602        size: usize,
603        /// Maximum allowed size.
604        max_size: usize,
605    },
606}
607
608impl std::fmt::Display for ReplicationProtocolError {
609    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610        match self {
611            Self::SerializationFailed(msg) => {
612                write!(f, "replication serialization failed: {msg}")
613            }
614            Self::DeserializationFailed(msg) => {
615                write!(f, "replication deserialization failed: {msg}")
616            }
617            Self::MessageTooLarge { size, max_size } => {
618                write!(
619                    f,
620                    "replication message size {size} exceeds maximum {max_size}"
621                )
622            }
623        }
624    }
625}
626
627impl std::error::Error for ReplicationProtocolError {}
628
629// ---------------------------------------------------------------------------
630// Tests
631// ---------------------------------------------------------------------------
632
633#[cfg(test)]
634#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
635mod tests {
636    use super::*;
637
638    // === Round-2 byte response sizing ===
639
640    #[test]
641    fn max_batch_worst_case_byte_response_fits_wire_cap() {
642        // The auditor batches its round-2 sample to MAX_BYTE_CHALLENGE_KEYS per
643        // challenge precisely so this worst case — every requested chunk at
644        // MAX_CHUNK_SIZE — still encodes. If this fails, honest responders
645        // would hit encode errors and be penalized as timeouts.
646        let items: Vec<SubtreeByteItem> = (0..crate::replication::config::MAX_BYTE_CHALLENGE_KEYS)
647            .map(|i| SubtreeByteItem::Present {
648                key: [u8::try_from(i).unwrap_or(u8::MAX); 32],
649                bytes: vec![0xAB; crate::ant_protocol::MAX_CHUNK_SIZE],
650            })
651            .collect();
652        let msg = ReplicationMessage {
653            request_id: 7,
654            body: ReplicationMessageBody::SubtreeByteResponse(SubtreeByteResponse::Items {
655                challenge_id: 7,
656                items,
657            }),
658        };
659        let encoded = msg
660            .encode()
661            .expect("worst-case max-batch byte response must fit the wire cap");
662        assert!(encoded.len() <= MAX_REPLICATION_MESSAGE_SIZE);
663    }
664
665    // === Fresh Replication roundtrip ===
666
667    #[test]
668    fn fresh_replication_offer_roundtrip() {
669        let msg = ReplicationMessage {
670            request_id: 1,
671            body: ReplicationMessageBody::FreshReplicationOffer(FreshReplicationOffer {
672                key: [0xAA; 32],
673                data: vec![1, 2, 3, 4, 5],
674                proof_of_payment: vec![10, 20, 30],
675            }),
676        };
677        let encoded = msg.encode().expect("encode should succeed");
678        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
679
680        assert_eq!(decoded.request_id, 1);
681        if let ReplicationMessageBody::FreshReplicationOffer(offer) = decoded.body {
682            assert_eq!(offer.key, [0xAA; 32]);
683            assert_eq!(offer.data, vec![1, 2, 3, 4, 5]);
684            assert_eq!(offer.proof_of_payment, vec![10, 20, 30]);
685        } else {
686            panic!("expected FreshReplicationOffer");
687        }
688    }
689
690    #[test]
691    fn fresh_replication_response_accepted_roundtrip() {
692        let msg = ReplicationMessage {
693            request_id: 2,
694            body: ReplicationMessageBody::FreshReplicationResponse(
695                FreshReplicationResponse::Accepted { key: [0xBB; 32] },
696            ),
697        };
698        let encoded = msg.encode().expect("encode should succeed");
699        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
700
701        assert_eq!(decoded.request_id, 2);
702        if let ReplicationMessageBody::FreshReplicationResponse(
703            FreshReplicationResponse::Accepted { key },
704        ) = decoded.body
705        {
706            assert_eq!(key, [0xBB; 32]);
707        } else {
708            panic!("expected FreshReplicationResponse::Accepted");
709        }
710    }
711
712    #[test]
713    fn fresh_replication_response_rejected_roundtrip() {
714        let msg = ReplicationMessage {
715            request_id: 3,
716            body: ReplicationMessageBody::FreshReplicationResponse(
717                FreshReplicationResponse::Rejected {
718                    key: [0xCC; 32],
719                    reason: "out of range".to_string(),
720                },
721            ),
722        };
723        let encoded = msg.encode().expect("encode should succeed");
724        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
725
726        assert_eq!(decoded.request_id, 3);
727        if let ReplicationMessageBody::FreshReplicationResponse(
728            FreshReplicationResponse::Rejected { key, reason },
729        ) = decoded.body
730        {
731            assert_eq!(key, [0xCC; 32]);
732            assert_eq!(reason, "out of range");
733        } else {
734            panic!("expected FreshReplicationResponse::Rejected");
735        }
736    }
737
738    // === PaidNotify roundtrip ===
739
740    #[test]
741    fn paid_notify_roundtrip() {
742        let msg = ReplicationMessage {
743            request_id: 4,
744            body: ReplicationMessageBody::PaidNotify(PaidNotify {
745                key: [0xDD; 32],
746                proof_of_payment: vec![99, 100],
747            }),
748        };
749        let encoded = msg.encode().expect("encode should succeed");
750        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
751
752        assert_eq!(decoded.request_id, 4);
753        if let ReplicationMessageBody::PaidNotify(notify) = decoded.body {
754            assert_eq!(notify.key, [0xDD; 32]);
755            assert_eq!(notify.proof_of_payment, vec![99, 100]);
756        } else {
757            panic!("expected PaidNotify");
758        }
759    }
760
761    // === Neighbor Sync roundtrips ===
762
763    // -- backwards compat across the wire-type extension --------------------
764
765    /// Backwards-compat: an old peer that has the v0 layout of
766    /// `NeighborSyncRequest` (no `commitment` field) can still decode a
767    /// message encoded by a new peer that emits `commitment: None`. This
768    /// is the realistic mixed-version case during rollout: new peers
769    /// gossip with the field; old peers must not crash.
770    ///
771    /// The check works because postcard's [`from_bytes`] is lenient on
772    /// trailing bytes — the old decoder reads what it knows about and
773    /// stops, the new fields are silently ignored. This test pins that
774    /// invariant so any future codec/library swap that breaks it is
775    /// caught immediately.
776    #[test]
777    fn old_decoder_tolerates_new_neighbor_sync_request() {
778        use serde::Deserialize;
779        #[derive(Deserialize)]
780        struct OldNeighborSyncRequest {
781            #[allow(dead_code)]
782            pub replica_hints: Vec<XorName>,
783            #[allow(dead_code)]
784            pub paid_hints: Vec<XorName>,
785            #[allow(dead_code)]
786            pub bootstrapping: bool,
787        }
788
789        let new_req = NeighborSyncRequest {
790            replica_hints: vec![[0x01; 32], [0x02; 32]],
791            paid_hints: vec![[0x03; 32]],
792            bootstrapping: true,
793            commitment: None,
794        };
795        let encoded = postcard::to_stdvec(&new_req).expect("encode");
796        let old_decoded: OldNeighborSyncRequest =
797            postcard::from_bytes(&encoded).expect("old decoder accepts");
798        // Field-by-field check would fail if old peer misaligned on the
799        // length prefix — passing decode is the structural check.
800        assert_eq!(old_decoded.replica_hints.len(), 2);
801        assert_eq!(old_decoded.paid_hints.len(), 1);
802        assert!(old_decoded.bootstrapping);
803    }
804
805    /// Same property for `NeighborSyncResponse`.
806    #[test]
807    fn old_decoder_tolerates_new_neighbor_sync_response() {
808        use serde::Deserialize;
809        #[derive(Deserialize)]
810        struct OldNeighborSyncResponse {
811            #[allow(dead_code)]
812            pub replica_hints: Vec<XorName>,
813            #[allow(dead_code)]
814            pub paid_hints: Vec<XorName>,
815            #[allow(dead_code)]
816            pub bootstrapping: bool,
817            #[allow(dead_code)]
818            pub rejected_keys: Vec<XorName>,
819        }
820
821        let new_resp = NeighborSyncResponse {
822            replica_hints: vec![[0x04; 32]],
823            paid_hints: vec![],
824            bootstrapping: false,
825            rejected_keys: vec![[0x05; 32]],
826            commitment: None,
827        };
828        let encoded = postcard::to_stdvec(&new_resp).expect("encode");
829        let old_decoded: OldNeighborSyncResponse =
830            postcard::from_bytes(&encoded).expect("old decoder accepts");
831        assert_eq!(old_decoded.replica_hints.len(), 1);
832        assert_eq!(old_decoded.rejected_keys.len(), 1);
833    }
834
835    /// Roundtrip: a new peer can decode its own message including the
836    /// commitment field. Catches accidental serde annotation breakage
837    /// (e.g. forgetting `#[serde(default)]` on the new field).
838    #[test]
839    fn new_peer_roundtrips_with_commitment_some() {
840        use crate::replication::commitment::{sign_commitment, StorageCommitment};
841        use saorsa_pqc::api::sig::ml_dsa_65;
842
843        let (pk, sk) = ml_dsa_65().generate_keypair().expect("keygen");
844        let root = [0x7Fu8; 32];
845        let sender = [0xCCu8; 32];
846        let pk_bytes = pk.to_bytes();
847        let sig = sign_commitment(&sk, &root, 3, &sender, &pk_bytes).expect("sign");
848        let commitment = StorageCommitment {
849            root,
850            key_count: 3,
851            sender_peer_id: sender,
852            sender_public_key: pk_bytes,
853            signature: sig,
854        };
855
856        let req = NeighborSyncRequest {
857            replica_hints: vec![[0x01; 32]],
858            paid_hints: vec![],
859            bootstrapping: false,
860            commitment: Some(commitment.clone()),
861        };
862        let encoded = postcard::to_stdvec(&req).expect("encode");
863        let decoded: NeighborSyncRequest = postcard::from_bytes(&encoded).expect("new decoder");
864        assert_eq!(decoded.commitment, Some(commitment));
865    }
866
867    #[test]
868    fn neighbor_sync_request_roundtrip() {
869        let msg = ReplicationMessage {
870            request_id: 5,
871            body: ReplicationMessageBody::NeighborSyncRequest(NeighborSyncRequest {
872                replica_hints: vec![[0x01; 32], [0x02; 32]],
873                paid_hints: vec![[0x03; 32]],
874                bootstrapping: true,
875                commitment: None,
876            }),
877        };
878        let encoded = msg.encode().expect("encode should succeed");
879        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
880
881        assert_eq!(decoded.request_id, 5);
882        if let ReplicationMessageBody::NeighborSyncRequest(req) = decoded.body {
883            assert_eq!(req.replica_hints.len(), 2);
884            assert_eq!(req.paid_hints.len(), 1);
885            assert!(req.bootstrapping);
886        } else {
887            panic!("expected NeighborSyncRequest");
888        }
889    }
890
891    #[test]
892    fn neighbor_sync_response_roundtrip() {
893        let msg = ReplicationMessage {
894            request_id: 6,
895            body: ReplicationMessageBody::NeighborSyncResponse(NeighborSyncResponse {
896                replica_hints: vec![[0x04; 32]],
897                paid_hints: vec![],
898                bootstrapping: false,
899                rejected_keys: vec![[0x05; 32], [0x06; 32]],
900                commitment: None,
901            }),
902        };
903        let encoded = msg.encode().expect("encode should succeed");
904        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
905
906        assert_eq!(decoded.request_id, 6);
907        if let ReplicationMessageBody::NeighborSyncResponse(resp) = decoded.body {
908            assert_eq!(resp.replica_hints.len(), 1);
909            assert!(resp.paid_hints.is_empty());
910            assert!(!resp.bootstrapping);
911            assert_eq!(resp.rejected_keys.len(), 2);
912        } else {
913            panic!("expected NeighborSyncResponse");
914        }
915    }
916
917    // === Verification roundtrips ===
918
919    #[test]
920    fn verification_request_roundtrip() {
921        let msg = ReplicationMessage {
922            request_id: 7,
923            body: ReplicationMessageBody::VerificationRequest(VerificationRequest {
924                keys: vec![[0x10; 32], [0x20; 32], [0x30; 32]],
925                paid_list_check_indices: vec![0, 2],
926            }),
927        };
928        let encoded = msg.encode().expect("encode should succeed");
929        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
930
931        assert_eq!(decoded.request_id, 7);
932        if let ReplicationMessageBody::VerificationRequest(req) = decoded.body {
933            assert_eq!(req.keys.len(), 3);
934            assert_eq!(req.paid_list_check_indices, vec![0, 2]);
935        } else {
936            panic!("expected VerificationRequest");
937        }
938    }
939
940    #[test]
941    fn verification_response_roundtrip() {
942        let results = vec![
943            KeyVerificationResult {
944                key: [0x10; 32],
945                present: true,
946                paid: Some(true),
947            },
948            KeyVerificationResult {
949                key: [0x20; 32],
950                present: false,
951                paid: None,
952            },
953            KeyVerificationResult {
954                key: [0x30; 32],
955                present: true,
956                paid: Some(false),
957            },
958        ];
959        let msg = ReplicationMessage {
960            request_id: 8,
961            body: ReplicationMessageBody::VerificationResponse(VerificationResponse { results }),
962        };
963        let encoded = msg.encode().expect("encode should succeed");
964        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
965
966        assert_eq!(decoded.request_id, 8);
967        if let ReplicationMessageBody::VerificationResponse(resp) = decoded.body {
968            assert_eq!(resp.results.len(), 3);
969            assert!(resp.results[0].present);
970            assert_eq!(resp.results[0].paid, Some(true));
971            assert!(!resp.results[1].present);
972            assert_eq!(resp.results[1].paid, None);
973            assert!(resp.results[2].present);
974            assert_eq!(resp.results[2].paid, Some(false));
975        } else {
976            panic!("expected VerificationResponse");
977        }
978    }
979
980    // === Fetch roundtrips ===
981
982    #[test]
983    fn fetch_request_roundtrip() {
984        let msg = ReplicationMessage {
985            request_id: 9,
986            body: ReplicationMessageBody::FetchRequest(FetchRequest { key: [0x40; 32] }),
987        };
988        let encoded = msg.encode().expect("encode should succeed");
989        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
990
991        assert_eq!(decoded.request_id, 9);
992        if let ReplicationMessageBody::FetchRequest(req) = decoded.body {
993            assert_eq!(req.key, [0x40; 32]);
994        } else {
995            panic!("expected FetchRequest");
996        }
997    }
998
999    #[test]
1000    fn fetch_response_success_roundtrip() {
1001        let msg = ReplicationMessage {
1002            request_id: 10,
1003            body: ReplicationMessageBody::FetchResponse(FetchResponse::Success {
1004                key: [0x50; 32],
1005                data: vec![7, 8, 9],
1006            }),
1007        };
1008        let encoded = msg.encode().expect("encode should succeed");
1009        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1010
1011        assert_eq!(decoded.request_id, 10);
1012        if let ReplicationMessageBody::FetchResponse(FetchResponse::Success { key, data }) =
1013            decoded.body
1014        {
1015            assert_eq!(key, [0x50; 32]);
1016            assert_eq!(data, vec![7, 8, 9]);
1017        } else {
1018            panic!("expected FetchResponse::Success");
1019        }
1020    }
1021
1022    #[test]
1023    fn fetch_response_not_found_roundtrip() {
1024        let msg = ReplicationMessage {
1025            request_id: 11,
1026            body: ReplicationMessageBody::FetchResponse(FetchResponse::NotFound {
1027                key: [0x60; 32],
1028            }),
1029        };
1030        let encoded = msg.encode().expect("encode should succeed");
1031        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1032
1033        assert_eq!(decoded.request_id, 11);
1034        if let ReplicationMessageBody::FetchResponse(FetchResponse::NotFound { key }) = decoded.body
1035        {
1036            assert_eq!(key, [0x60; 32]);
1037        } else {
1038            panic!("expected FetchResponse::NotFound");
1039        }
1040    }
1041
1042    #[test]
1043    fn fetch_response_error_roundtrip() {
1044        let msg = ReplicationMessage {
1045            request_id: 12,
1046            body: ReplicationMessageBody::FetchResponse(FetchResponse::Error {
1047                key: [0x70; 32],
1048                reason: "disk full".to_string(),
1049            }),
1050        };
1051        let encoded = msg.encode().expect("encode should succeed");
1052        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1053
1054        assert_eq!(decoded.request_id, 12);
1055        if let ReplicationMessageBody::FetchResponse(FetchResponse::Error { key, reason }) =
1056            decoded.body
1057        {
1058            assert_eq!(key, [0x70; 32]);
1059            assert_eq!(reason, "disk full");
1060        } else {
1061            panic!("expected FetchResponse::Error");
1062        }
1063    }
1064
1065    // === Audit roundtrips ===
1066
1067    #[test]
1068    fn audit_challenge_roundtrip() {
1069        let msg = ReplicationMessage {
1070            request_id: 13,
1071            body: ReplicationMessageBody::AuditChallenge(AuditChallenge {
1072                challenge_id: 999,
1073                nonce: [0xAB; 32],
1074                challenged_peer_id: [0xCD; 32],
1075                keys: vec![[0x01; 32], [0x02; 32]],
1076            }),
1077        };
1078        let encoded = msg.encode().expect("encode should succeed");
1079        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1080
1081        assert_eq!(decoded.request_id, 13);
1082        if let ReplicationMessageBody::AuditChallenge(challenge) = decoded.body {
1083            assert_eq!(challenge.challenge_id, 999);
1084            assert_eq!(challenge.nonce, [0xAB; 32]);
1085            assert_eq!(challenge.challenged_peer_id, [0xCD; 32]);
1086            assert_eq!(challenge.keys.len(), 2);
1087        } else {
1088            panic!("expected AuditChallenge");
1089        }
1090    }
1091
1092    #[test]
1093    fn audit_response_digests_roundtrip() {
1094        let digests = vec![[0x11; 32], ABSENT_KEY_DIGEST];
1095        let msg = ReplicationMessage {
1096            request_id: 14,
1097            body: ReplicationMessageBody::AuditResponse(AuditResponse::Digests {
1098                challenge_id: 999,
1099                digests: digests.clone(),
1100            }),
1101        };
1102        let encoded = msg.encode().expect("encode should succeed");
1103        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1104
1105        assert_eq!(decoded.request_id, 14);
1106        if let ReplicationMessageBody::AuditResponse(AuditResponse::Digests {
1107            challenge_id,
1108            digests: decoded_digests,
1109        }) = decoded.body
1110        {
1111            assert_eq!(challenge_id, 999);
1112            assert_eq!(decoded_digests, digests);
1113        } else {
1114            panic!("expected AuditResponse::Digests");
1115        }
1116    }
1117
1118    #[test]
1119    fn audit_response_bootstrapping_roundtrip() {
1120        let msg = ReplicationMessage {
1121            request_id: 15,
1122            body: ReplicationMessageBody::AuditResponse(AuditResponse::Bootstrapping {
1123                challenge_id: 42,
1124            }),
1125        };
1126        let encoded = msg.encode().expect("encode should succeed");
1127        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1128
1129        assert_eq!(decoded.request_id, 15);
1130        if let ReplicationMessageBody::AuditResponse(AuditResponse::Bootstrapping {
1131            challenge_id,
1132        }) = decoded.body
1133        {
1134            assert_eq!(challenge_id, 42);
1135        } else {
1136            panic!("expected AuditResponse::Bootstrapping");
1137        }
1138    }
1139
1140    // === Oversized message rejection ===
1141
1142    #[test]
1143    fn decode_rejects_oversized_payload() {
1144        let oversized = vec![0u8; MAX_REPLICATION_MESSAGE_SIZE + 1];
1145        let result = ReplicationMessage::decode(&oversized);
1146        assert!(result.is_err());
1147        let err = result.unwrap_err();
1148        assert!(
1149            matches!(err, ReplicationProtocolError::MessageTooLarge { .. }),
1150            "expected MessageTooLarge, got {err:?}"
1151        );
1152    }
1153
1154    #[test]
1155    fn encode_rejects_oversized_message() {
1156        // Build a message whose serialized form exceeds the limit.
1157        let msg = ReplicationMessage {
1158            request_id: 0,
1159            body: ReplicationMessageBody::FreshReplicationOffer(FreshReplicationOffer {
1160                key: [0; 32],
1161                data: vec![0xFF; MAX_REPLICATION_MESSAGE_SIZE],
1162                proof_of_payment: vec![],
1163            }),
1164        };
1165        let result = msg.encode();
1166        assert!(result.is_err());
1167        let err = result.unwrap_err();
1168        assert!(
1169            matches!(err, ReplicationProtocolError::MessageTooLarge { .. }),
1170            "expected MessageTooLarge, got {err:?}"
1171        );
1172    }
1173
1174    // === Invalid data rejection ===
1175
1176    #[test]
1177    fn decode_rejects_invalid_data() {
1178        let invalid = vec![0xFF, 0xFF, 0xFF];
1179        let result = ReplicationMessage::decode(&invalid);
1180        assert!(result.is_err());
1181        let err = result.unwrap_err();
1182        assert!(
1183            matches!(err, ReplicationProtocolError::DeserializationFailed(_)),
1184            "expected DeserializationFailed, got {err:?}"
1185        );
1186    }
1187
1188    // === Audit digest computation ===
1189
1190    #[test]
1191    fn audit_digest_is_deterministic() {
1192        let nonce = [0x01; 32];
1193        let peer_id = [0x02; 32];
1194        let key: XorName = [0x03; 32];
1195        let record_bytes = b"hello world";
1196
1197        let digest_a = compute_audit_digest(&nonce, &peer_id, &key, record_bytes);
1198        let digest_b = compute_audit_digest(&nonce, &peer_id, &key, record_bytes);
1199
1200        assert_eq!(digest_a, digest_b, "same inputs must produce same digest");
1201    }
1202
1203    #[test]
1204    fn audit_digest_differs_with_different_nonce() {
1205        let peer_id = [0x02; 32];
1206        let key: XorName = [0x03; 32];
1207        let record_bytes = b"hello world";
1208
1209        let digest_a = compute_audit_digest(&[0x01; 32], &peer_id, &key, record_bytes);
1210        let digest_b = compute_audit_digest(&[0xFF; 32], &peer_id, &key, record_bytes);
1211
1212        assert_ne!(
1213            digest_a, digest_b,
1214            "different nonces must produce different digests"
1215        );
1216    }
1217
1218    #[test]
1219    fn audit_digest_differs_with_different_data() {
1220        let nonce = [0x01; 32];
1221        let peer_id = [0x02; 32];
1222        let key: XorName = [0x03; 32];
1223
1224        let digest_a = compute_audit_digest(&nonce, &peer_id, &key, b"data-A");
1225        let digest_b = compute_audit_digest(&nonce, &peer_id, &key, b"data-B");
1226
1227        assert_ne!(
1228            digest_a, digest_b,
1229            "different data must produce different digests"
1230        );
1231    }
1232
1233    #[test]
1234    fn audit_digest_differs_with_different_peer() {
1235        let nonce = [0x01; 32];
1236        let key: XorName = [0x03; 32];
1237        let record_bytes = b"hello";
1238
1239        let digest_a = compute_audit_digest(&nonce, &[0x02; 32], &key, record_bytes);
1240        let digest_b = compute_audit_digest(&nonce, &[0xFF; 32], &key, record_bytes);
1241
1242        assert_ne!(
1243            digest_a, digest_b,
1244            "different peer IDs must produce different digests"
1245        );
1246    }
1247
1248    #[test]
1249    fn audit_digest_differs_with_different_key() {
1250        let nonce = [0x01; 32];
1251        let peer_id = [0x02; 32];
1252        let record_bytes = b"hello";
1253
1254        let digest_a = compute_audit_digest(&nonce, &peer_id, &[0x03; 32], record_bytes);
1255        let digest_b = compute_audit_digest(&nonce, &peer_id, &[0xFF; 32], record_bytes);
1256
1257        assert_ne!(
1258            digest_a, digest_b,
1259            "different keys must produce different digests"
1260        );
1261    }
1262
1263    // === Absent key digest sentinel ===
1264
1265    #[test]
1266    fn absent_key_digest_is_all_zeros() {
1267        assert_eq!(ABSENT_KEY_DIGEST, [0u8; 32]);
1268    }
1269
1270    #[test]
1271    fn real_digest_differs_from_absent_sentinel() {
1272        let nonce = [0x01; 32];
1273        let peer_id = [0x02; 32];
1274        let key: XorName = [0x03; 32];
1275        let record_bytes = b"non-empty data";
1276
1277        let digest = compute_audit_digest(&nonce, &peer_id, &key, record_bytes);
1278        assert_ne!(
1279            digest, ABSENT_KEY_DIGEST,
1280            "a real digest should not collide with the all-zeros sentinel"
1281        );
1282    }
1283
1284    // === Error Display ===
1285
1286    #[test]
1287    fn error_display_serialization_failed() {
1288        let err = ReplicationProtocolError::SerializationFailed("boom".to_string());
1289        assert_eq!(err.to_string(), "replication serialization failed: boom");
1290    }
1291
1292    #[test]
1293    fn error_display_deserialization_failed() {
1294        let err = ReplicationProtocolError::DeserializationFailed("bad data".to_string());
1295        assert_eq!(
1296            err.to_string(),
1297            "replication deserialization failed: bad data"
1298        );
1299    }
1300
1301    #[test]
1302    fn error_display_message_too_large() {
1303        let err = ReplicationProtocolError::MessageTooLarge {
1304            size: 20_000_000,
1305            max_size: MAX_REPLICATION_MESSAGE_SIZE,
1306        };
1307        let display = err.to_string();
1308        assert!(display.contains("20000000"));
1309        assert!(display.contains(&MAX_REPLICATION_MESSAGE_SIZE.to_string()));
1310    }
1311}