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. A peer that self-graces on every audit remains uncredited for the
442/// pinned commitments it refuses to prove; an honest peer that genuinely rotated
443/// simply re-earns credit on the next audit of its current commitment. The grace
444/// removes only the false TRUST PENALTY for the genuinely-ambiguous
445/// rotated/transient case; it does not remove the possession requirement.
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
447pub enum RejectKind {
448    /// The responder does not retain the pinned commitment (rotated past it).
449    /// GRACED — indistinguishable from legitimate rotation the auditor missed.
450    UnknownCommitment,
451    /// A transient, recoverable condition on the responder (e.g. a storage read
452    /// error) that is NOT evidence of missing data. GRACED like a timeout so a
453    /// flaky disk never manufactures a confirmed possession failure.
454    Transient,
455    /// Any other rejection (wrong target peer, no commitment state, malformed
456    /// proof plan, oversized byte challenge, …). CONFIRMED failure.
457    Protocol,
458}
459
460impl RejectKind {
461    /// Whether the auditor should GRACE this rejection (treat like a timeout —
462    /// no confirmed penalty, no holder-credit revocation) rather than confirm
463    /// it. Only genuine protocol faults are confirmed; rotation/transient
464    /// conditions are graced because they are not provable misbehaviour.
465    #[must_use]
466    pub fn is_graced(self) -> bool {
467        matches!(self, Self::UnknownCommitment | Self::Transient)
468    }
469}
470
471/// Round 2 of the storage audit (ADR-0002): the **surprise byte challenge**.
472///
473/// After the auditor has structurally verified a [`SubtreeAuditResponse::Proof`]
474/// it picks a small sample of that subtree's just-proven leaves with FRESH
475/// randomness (chosen now, after the proof is committed — NOT derived from the
476/// round-1 nonce, so the responder could not have predicted it at proof-build
477/// time) and asks the responder to return the ORIGINAL chunk bytes for exactly
478/// those keys. The auditor then checks each returned chunk against the committed
479/// leaf:
480///   - `BLAKE3(bytes) == leaf.bytes_hash` (the chunk's content address), AND
481///   - `compute_audit_digest(nonce, peer, key, bytes) == leaf.nonced_hash`.
482///
483/// This makes possession non-delegable to the auditor: the auditor needs to
484/// hold NONE of the responder's chunks. A responder that committed to a chunk it
485/// no longer holds cannot fabricate bytes that hash to the committed address (a
486/// preimage break), so it is caught regardless of who audits it.
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct SubtreeByteChallenge {
489    /// The same `challenge_id` as the round-1 [`SubtreeAuditChallenge`], so the
490    /// responder/auditor correlate the two rounds.
491    pub challenge_id: u64,
492    /// The same nonce as round 1 — needed for the freshness (`nonced_hash`)
493    /// check and to bind these bytes to this audit.
494    pub nonce: [u8; 32],
495    /// The challenged peer ID (bound into each leaf's possession hash).
496    pub challenged_peer_id: [u8; 32],
497    /// The pinned commitment hash from round 1, so the responder resolves the
498    /// SAME tree it just proved and serves bytes only for keys it committed to.
499    pub expected_commitment_hash: [u8; 32],
500    /// The exact keys whose original bytes the responder must return. These are
501    /// the auditor's freshly-randomised spot-check sample of the round-1 subtree
502    /// (chosen after the proof was received; not nonce-derived).
503    pub keys: Vec<XorName>,
504}
505
506/// One requested chunk in a [`SubtreeByteResponse`].
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
508pub enum SubtreeByteItem {
509    /// The responder holds this committed key and returns its original bytes.
510    Present {
511        /// The requested key.
512        key: XorName,
513        /// The original chunk bytes (the auditor re-hashes to verify).
514        bytes: Vec<u8>,
515    },
516    /// The responder committed to this key but cannot serve its bytes. This is a
517    /// PROVABLE cheat (it published a commitment over a chunk it does not hold),
518    /// so the auditor counts it as a confirmed failure — NOT a graced timeout.
519    /// Distinguishing this explicit signal from silence is what separates a
520    /// deleter (instant fail) from a dropped packet (timeout).
521    Absent {
522        /// The committed key the responder could not serve.
523        key: XorName,
524    },
525}
526
527/// Response to a [`SubtreeByteChallenge`] (round 2). One item per requested key,
528/// in the requested order.
529///
530/// Sizing rule: a challenge carries at most
531/// [`MAX_BYTE_CHALLENGE_KEYS`](super::config::MAX_BYTE_CHALLENGE_KEYS) keys —
532/// the auditor batches its sample, the responder rejects larger requests — so
533/// the WORST-CASE `Items` response (every chunk at `MAX_CHUNK_SIZE`) always
534/// encodes under [`MAX_REPLICATION_MESSAGE_SIZE`].
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub enum SubtreeByteResponse {
537    /// The responder's per-key answers (bytes or an explicit absent signal).
538    Items {
539        /// The challenge this response answers.
540        challenge_id: u64,
541        /// One entry per requested key.
542        items: Vec<SubtreeByteItem>,
543    },
544    /// Peer is still bootstrapping (should not happen mid-audit, but handled).
545    Bootstrapping {
546        /// The challenge this response answers.
547        challenge_id: u64,
548    },
549    /// The responder rejects the byte challenge outright. `kind` drives the
550    /// auditor's accounting: [`RejectKind::UnknownCommitment`] (rotated past the
551    /// pin) is graced; everything else is a confirmed failure, like round 1.
552    Rejected {
553        /// The challenge this response answers.
554        challenge_id: u64,
555        /// Machine-readable rejection class (accounting).
556        kind: RejectKind,
557        /// Human-readable rejection reason.
558        reason: String,
559    },
560}
561
562// ---------------------------------------------------------------------------
563// Audit digest helper
564// ---------------------------------------------------------------------------
565
566/// Compute `AuditKeyDigest(K_i) = BLAKE3(nonce || challenged_peer_id || K_i || record_bytes_i)`.
567///
568/// Returns the 32-byte BLAKE3 digest binding the nonce, peer identity, key,
569/// and record content together so a peer cannot forge proofs without holding
570/// the actual data.
571#[must_use]
572pub fn compute_audit_digest(
573    nonce: &[u8; 32],
574    challenged_peer_id: &[u8; 32],
575    key: &XorName,
576    record_bytes: &[u8],
577) -> [u8; 32] {
578    let mut hasher = blake3::Hasher::new();
579    hasher.update(nonce);
580    hasher.update(challenged_peer_id);
581    hasher.update(key);
582    hasher.update(record_bytes);
583    *hasher.finalize().as_bytes()
584}
585
586// ---------------------------------------------------------------------------
587// Error type
588// ---------------------------------------------------------------------------
589
590/// Errors from replication protocol encode/decode operations.
591#[derive(Debug, Clone)]
592pub enum ReplicationProtocolError {
593    /// Postcard serialization failed.
594    SerializationFailed(String),
595    /// Postcard deserialization failed.
596    DeserializationFailed(String),
597    /// Wire message exceeds the maximum allowed size.
598    MessageTooLarge {
599        /// Actual size of the message in bytes.
600        size: usize,
601        /// Maximum allowed size.
602        max_size: usize,
603    },
604}
605
606impl std::fmt::Display for ReplicationProtocolError {
607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608        match self {
609            Self::SerializationFailed(msg) => {
610                write!(f, "replication serialization failed: {msg}")
611            }
612            Self::DeserializationFailed(msg) => {
613                write!(f, "replication deserialization failed: {msg}")
614            }
615            Self::MessageTooLarge { size, max_size } => {
616                write!(
617                    f,
618                    "replication message size {size} exceeds maximum {max_size}"
619                )
620            }
621        }
622    }
623}
624
625impl std::error::Error for ReplicationProtocolError {}
626
627// ---------------------------------------------------------------------------
628// Tests
629// ---------------------------------------------------------------------------
630
631#[cfg(test)]
632#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
633mod tests {
634    use super::*;
635
636    // === Round-2 byte response sizing ===
637
638    #[test]
639    fn max_batch_worst_case_byte_response_fits_wire_cap() {
640        // The auditor batches its round-2 sample to MAX_BYTE_CHALLENGE_KEYS per
641        // challenge precisely so this worst case — every requested chunk at
642        // MAX_CHUNK_SIZE — still encodes. If this fails, honest responders
643        // would hit encode errors and fail otherwise valid byte challenges.
644        let items: Vec<SubtreeByteItem> = (0..crate::replication::config::MAX_BYTE_CHALLENGE_KEYS)
645            .map(|i| SubtreeByteItem::Present {
646                key: [u8::try_from(i).unwrap_or(u8::MAX); 32],
647                bytes: vec![0xAB; crate::ant_protocol::MAX_CHUNK_SIZE],
648            })
649            .collect();
650        let msg = ReplicationMessage {
651            request_id: 7,
652            body: ReplicationMessageBody::SubtreeByteResponse(SubtreeByteResponse::Items {
653                challenge_id: 7,
654                items,
655            }),
656        };
657        let encoded = msg
658            .encode()
659            .expect("worst-case max-batch byte response must fit the wire cap");
660        assert!(encoded.len() <= MAX_REPLICATION_MESSAGE_SIZE);
661    }
662
663    // === Fresh Replication roundtrip ===
664
665    #[test]
666    fn fresh_replication_offer_roundtrip() {
667        let msg = ReplicationMessage {
668            request_id: 1,
669            body: ReplicationMessageBody::FreshReplicationOffer(FreshReplicationOffer {
670                key: [0xAA; 32],
671                data: vec![1, 2, 3, 4, 5],
672                proof_of_payment: vec![10, 20, 30],
673            }),
674        };
675        let encoded = msg.encode().expect("encode should succeed");
676        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
677
678        assert_eq!(decoded.request_id, 1);
679        if let ReplicationMessageBody::FreshReplicationOffer(offer) = decoded.body {
680            assert_eq!(offer.key, [0xAA; 32]);
681            assert_eq!(offer.data, vec![1, 2, 3, 4, 5]);
682            assert_eq!(offer.proof_of_payment, vec![10, 20, 30]);
683        } else {
684            panic!("expected FreshReplicationOffer");
685        }
686    }
687
688    #[test]
689    fn fresh_replication_response_accepted_roundtrip() {
690        let msg = ReplicationMessage {
691            request_id: 2,
692            body: ReplicationMessageBody::FreshReplicationResponse(
693                FreshReplicationResponse::Accepted { key: [0xBB; 32] },
694            ),
695        };
696        let encoded = msg.encode().expect("encode should succeed");
697        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
698
699        assert_eq!(decoded.request_id, 2);
700        if let ReplicationMessageBody::FreshReplicationResponse(
701            FreshReplicationResponse::Accepted { key },
702        ) = decoded.body
703        {
704            assert_eq!(key, [0xBB; 32]);
705        } else {
706            panic!("expected FreshReplicationResponse::Accepted");
707        }
708    }
709
710    #[test]
711    fn fresh_replication_response_rejected_roundtrip() {
712        let msg = ReplicationMessage {
713            request_id: 3,
714            body: ReplicationMessageBody::FreshReplicationResponse(
715                FreshReplicationResponse::Rejected {
716                    key: [0xCC; 32],
717                    reason: "out of range".to_string(),
718                },
719            ),
720        };
721        let encoded = msg.encode().expect("encode should succeed");
722        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
723
724        assert_eq!(decoded.request_id, 3);
725        if let ReplicationMessageBody::FreshReplicationResponse(
726            FreshReplicationResponse::Rejected { key, reason },
727        ) = decoded.body
728        {
729            assert_eq!(key, [0xCC; 32]);
730            assert_eq!(reason, "out of range");
731        } else {
732            panic!("expected FreshReplicationResponse::Rejected");
733        }
734    }
735
736    // === PaidNotify roundtrip ===
737
738    #[test]
739    fn paid_notify_roundtrip() {
740        let msg = ReplicationMessage {
741            request_id: 4,
742            body: ReplicationMessageBody::PaidNotify(PaidNotify {
743                key: [0xDD; 32],
744                proof_of_payment: vec![99, 100],
745            }),
746        };
747        let encoded = msg.encode().expect("encode should succeed");
748        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
749
750        assert_eq!(decoded.request_id, 4);
751        if let ReplicationMessageBody::PaidNotify(notify) = decoded.body {
752            assert_eq!(notify.key, [0xDD; 32]);
753            assert_eq!(notify.proof_of_payment, vec![99, 100]);
754        } else {
755            panic!("expected PaidNotify");
756        }
757    }
758
759    // === Neighbor Sync roundtrips ===
760
761    // -- backwards compat across the wire-type extension --------------------
762
763    /// Backwards-compat: an old peer that has the v0 layout of
764    /// `NeighborSyncRequest` (no `commitment` field) can still decode a
765    /// message encoded by a new peer that emits `commitment: None`. This
766    /// is the realistic mixed-version case during rollout: new peers
767    /// gossip with the field; old peers must not crash.
768    ///
769    /// The check works because postcard's [`from_bytes`] is lenient on
770    /// trailing bytes — the old decoder reads what it knows about and
771    /// stops, the new fields are silently ignored. This test pins that
772    /// invariant so any future codec/library swap that breaks it is
773    /// caught immediately.
774    #[test]
775    fn old_decoder_tolerates_new_neighbor_sync_request() {
776        use serde::Deserialize;
777        #[derive(Deserialize)]
778        struct OldNeighborSyncRequest {
779            #[allow(dead_code)]
780            pub replica_hints: Vec<XorName>,
781            #[allow(dead_code)]
782            pub paid_hints: Vec<XorName>,
783            #[allow(dead_code)]
784            pub bootstrapping: bool,
785        }
786
787        let new_req = NeighborSyncRequest {
788            replica_hints: vec![[0x01; 32], [0x02; 32]],
789            paid_hints: vec![[0x03; 32]],
790            bootstrapping: true,
791            commitment: None,
792        };
793        let encoded = postcard::to_stdvec(&new_req).expect("encode");
794        let old_decoded: OldNeighborSyncRequest =
795            postcard::from_bytes(&encoded).expect("old decoder accepts");
796        // Field-by-field check would fail if old peer misaligned on the
797        // length prefix — passing decode is the structural check.
798        assert_eq!(old_decoded.replica_hints.len(), 2);
799        assert_eq!(old_decoded.paid_hints.len(), 1);
800        assert!(old_decoded.bootstrapping);
801    }
802
803    /// Same property for `NeighborSyncResponse`.
804    #[test]
805    fn old_decoder_tolerates_new_neighbor_sync_response() {
806        use serde::Deserialize;
807        #[derive(Deserialize)]
808        struct OldNeighborSyncResponse {
809            #[allow(dead_code)]
810            pub replica_hints: Vec<XorName>,
811            #[allow(dead_code)]
812            pub paid_hints: Vec<XorName>,
813            #[allow(dead_code)]
814            pub bootstrapping: bool,
815            #[allow(dead_code)]
816            pub rejected_keys: Vec<XorName>,
817        }
818
819        let new_resp = NeighborSyncResponse {
820            replica_hints: vec![[0x04; 32]],
821            paid_hints: vec![],
822            bootstrapping: false,
823            rejected_keys: vec![[0x05; 32]],
824            commitment: None,
825        };
826        let encoded = postcard::to_stdvec(&new_resp).expect("encode");
827        let old_decoded: OldNeighborSyncResponse =
828            postcard::from_bytes(&encoded).expect("old decoder accepts");
829        assert_eq!(old_decoded.replica_hints.len(), 1);
830        assert_eq!(old_decoded.rejected_keys.len(), 1);
831    }
832
833    /// Roundtrip: a new peer can decode its own message including the
834    /// commitment field. Catches accidental serde annotation breakage
835    /// (e.g. forgetting `#[serde(default)]` on the new field).
836    #[test]
837    fn new_peer_roundtrips_with_commitment_some() {
838        use crate::replication::commitment::{sign_commitment, StorageCommitment};
839        use saorsa_pqc::api::sig::ml_dsa_65;
840
841        let (pk, sk) = ml_dsa_65().generate_keypair().expect("keygen");
842        let root = [0x7Fu8; 32];
843        let sender = [0xCCu8; 32];
844        let pk_bytes = pk.to_bytes();
845        let sig = sign_commitment(&sk, &root, 3, &sender, &pk_bytes).expect("sign");
846        let commitment = StorageCommitment {
847            root,
848            key_count: 3,
849            sender_peer_id: sender,
850            sender_public_key: pk_bytes,
851            signature: sig,
852        };
853
854        let req = NeighborSyncRequest {
855            replica_hints: vec![[0x01; 32]],
856            paid_hints: vec![],
857            bootstrapping: false,
858            commitment: Some(commitment.clone()),
859        };
860        let encoded = postcard::to_stdvec(&req).expect("encode");
861        let decoded: NeighborSyncRequest = postcard::from_bytes(&encoded).expect("new decoder");
862        assert_eq!(decoded.commitment, Some(commitment));
863    }
864
865    #[test]
866    fn neighbor_sync_request_roundtrip() {
867        let msg = ReplicationMessage {
868            request_id: 5,
869            body: ReplicationMessageBody::NeighborSyncRequest(NeighborSyncRequest {
870                replica_hints: vec![[0x01; 32], [0x02; 32]],
871                paid_hints: vec![[0x03; 32]],
872                bootstrapping: true,
873                commitment: None,
874            }),
875        };
876        let encoded = msg.encode().expect("encode should succeed");
877        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
878
879        assert_eq!(decoded.request_id, 5);
880        if let ReplicationMessageBody::NeighborSyncRequest(req) = decoded.body {
881            assert_eq!(req.replica_hints.len(), 2);
882            assert_eq!(req.paid_hints.len(), 1);
883            assert!(req.bootstrapping);
884        } else {
885            panic!("expected NeighborSyncRequest");
886        }
887    }
888
889    #[test]
890    fn neighbor_sync_response_roundtrip() {
891        let msg = ReplicationMessage {
892            request_id: 6,
893            body: ReplicationMessageBody::NeighborSyncResponse(NeighborSyncResponse {
894                replica_hints: vec![[0x04; 32]],
895                paid_hints: vec![],
896                bootstrapping: false,
897                rejected_keys: vec![[0x05; 32], [0x06; 32]],
898                commitment: None,
899            }),
900        };
901        let encoded = msg.encode().expect("encode should succeed");
902        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
903
904        assert_eq!(decoded.request_id, 6);
905        if let ReplicationMessageBody::NeighborSyncResponse(resp) = decoded.body {
906            assert_eq!(resp.replica_hints.len(), 1);
907            assert!(resp.paid_hints.is_empty());
908            assert!(!resp.bootstrapping);
909            assert_eq!(resp.rejected_keys.len(), 2);
910        } else {
911            panic!("expected NeighborSyncResponse");
912        }
913    }
914
915    // === Verification roundtrips ===
916
917    #[test]
918    fn verification_request_roundtrip() {
919        let msg = ReplicationMessage {
920            request_id: 7,
921            body: ReplicationMessageBody::VerificationRequest(VerificationRequest {
922                keys: vec![[0x10; 32], [0x20; 32], [0x30; 32]],
923                paid_list_check_indices: vec![0, 2],
924            }),
925        };
926        let encoded = msg.encode().expect("encode should succeed");
927        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
928
929        assert_eq!(decoded.request_id, 7);
930        if let ReplicationMessageBody::VerificationRequest(req) = decoded.body {
931            assert_eq!(req.keys.len(), 3);
932            assert_eq!(req.paid_list_check_indices, vec![0, 2]);
933        } else {
934            panic!("expected VerificationRequest");
935        }
936    }
937
938    #[test]
939    fn verification_response_roundtrip() {
940        let results = vec![
941            KeyVerificationResult {
942                key: [0x10; 32],
943                present: true,
944                paid: Some(true),
945            },
946            KeyVerificationResult {
947                key: [0x20; 32],
948                present: false,
949                paid: None,
950            },
951            KeyVerificationResult {
952                key: [0x30; 32],
953                present: true,
954                paid: Some(false),
955            },
956        ];
957        let msg = ReplicationMessage {
958            request_id: 8,
959            body: ReplicationMessageBody::VerificationResponse(VerificationResponse { results }),
960        };
961        let encoded = msg.encode().expect("encode should succeed");
962        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
963
964        assert_eq!(decoded.request_id, 8);
965        if let ReplicationMessageBody::VerificationResponse(resp) = decoded.body {
966            assert_eq!(resp.results.len(), 3);
967            assert!(resp.results[0].present);
968            assert_eq!(resp.results[0].paid, Some(true));
969            assert!(!resp.results[1].present);
970            assert_eq!(resp.results[1].paid, None);
971            assert!(resp.results[2].present);
972            assert_eq!(resp.results[2].paid, Some(false));
973        } else {
974            panic!("expected VerificationResponse");
975        }
976    }
977
978    // === Fetch roundtrips ===
979
980    #[test]
981    fn fetch_request_roundtrip() {
982        let msg = ReplicationMessage {
983            request_id: 9,
984            body: ReplicationMessageBody::FetchRequest(FetchRequest { key: [0x40; 32] }),
985        };
986        let encoded = msg.encode().expect("encode should succeed");
987        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
988
989        assert_eq!(decoded.request_id, 9);
990        if let ReplicationMessageBody::FetchRequest(req) = decoded.body {
991            assert_eq!(req.key, [0x40; 32]);
992        } else {
993            panic!("expected FetchRequest");
994        }
995    }
996
997    #[test]
998    fn fetch_response_success_roundtrip() {
999        let msg = ReplicationMessage {
1000            request_id: 10,
1001            body: ReplicationMessageBody::FetchResponse(FetchResponse::Success {
1002                key: [0x50; 32],
1003                data: vec![7, 8, 9],
1004            }),
1005        };
1006        let encoded = msg.encode().expect("encode should succeed");
1007        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1008
1009        assert_eq!(decoded.request_id, 10);
1010        if let ReplicationMessageBody::FetchResponse(FetchResponse::Success { key, data }) =
1011            decoded.body
1012        {
1013            assert_eq!(key, [0x50; 32]);
1014            assert_eq!(data, vec![7, 8, 9]);
1015        } else {
1016            panic!("expected FetchResponse::Success");
1017        }
1018    }
1019
1020    #[test]
1021    fn fetch_response_not_found_roundtrip() {
1022        let msg = ReplicationMessage {
1023            request_id: 11,
1024            body: ReplicationMessageBody::FetchResponse(FetchResponse::NotFound {
1025                key: [0x60; 32],
1026            }),
1027        };
1028        let encoded = msg.encode().expect("encode should succeed");
1029        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1030
1031        assert_eq!(decoded.request_id, 11);
1032        if let ReplicationMessageBody::FetchResponse(FetchResponse::NotFound { key }) = decoded.body
1033        {
1034            assert_eq!(key, [0x60; 32]);
1035        } else {
1036            panic!("expected FetchResponse::NotFound");
1037        }
1038    }
1039
1040    #[test]
1041    fn fetch_response_error_roundtrip() {
1042        let msg = ReplicationMessage {
1043            request_id: 12,
1044            body: ReplicationMessageBody::FetchResponse(FetchResponse::Error {
1045                key: [0x70; 32],
1046                reason: "disk full".to_string(),
1047            }),
1048        };
1049        let encoded = msg.encode().expect("encode should succeed");
1050        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1051
1052        assert_eq!(decoded.request_id, 12);
1053        if let ReplicationMessageBody::FetchResponse(FetchResponse::Error { key, reason }) =
1054            decoded.body
1055        {
1056            assert_eq!(key, [0x70; 32]);
1057            assert_eq!(reason, "disk full");
1058        } else {
1059            panic!("expected FetchResponse::Error");
1060        }
1061    }
1062
1063    // === Audit roundtrips ===
1064
1065    #[test]
1066    fn audit_challenge_roundtrip() {
1067        let msg = ReplicationMessage {
1068            request_id: 13,
1069            body: ReplicationMessageBody::AuditChallenge(AuditChallenge {
1070                challenge_id: 999,
1071                nonce: [0xAB; 32],
1072                challenged_peer_id: [0xCD; 32],
1073                keys: vec![[0x01; 32], [0x02; 32]],
1074            }),
1075        };
1076        let encoded = msg.encode().expect("encode should succeed");
1077        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1078
1079        assert_eq!(decoded.request_id, 13);
1080        if let ReplicationMessageBody::AuditChallenge(challenge) = decoded.body {
1081            assert_eq!(challenge.challenge_id, 999);
1082            assert_eq!(challenge.nonce, [0xAB; 32]);
1083            assert_eq!(challenge.challenged_peer_id, [0xCD; 32]);
1084            assert_eq!(challenge.keys.len(), 2);
1085        } else {
1086            panic!("expected AuditChallenge");
1087        }
1088    }
1089
1090    #[test]
1091    fn audit_response_digests_roundtrip() {
1092        let digests = vec![[0x11; 32], ABSENT_KEY_DIGEST];
1093        let msg = ReplicationMessage {
1094            request_id: 14,
1095            body: ReplicationMessageBody::AuditResponse(AuditResponse::Digests {
1096                challenge_id: 999,
1097                digests: digests.clone(),
1098            }),
1099        };
1100        let encoded = msg.encode().expect("encode should succeed");
1101        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1102
1103        assert_eq!(decoded.request_id, 14);
1104        if let ReplicationMessageBody::AuditResponse(AuditResponse::Digests {
1105            challenge_id,
1106            digests: decoded_digests,
1107        }) = decoded.body
1108        {
1109            assert_eq!(challenge_id, 999);
1110            assert_eq!(decoded_digests, digests);
1111        } else {
1112            panic!("expected AuditResponse::Digests");
1113        }
1114    }
1115
1116    #[test]
1117    fn audit_response_bootstrapping_roundtrip() {
1118        let msg = ReplicationMessage {
1119            request_id: 15,
1120            body: ReplicationMessageBody::AuditResponse(AuditResponse::Bootstrapping {
1121                challenge_id: 42,
1122            }),
1123        };
1124        let encoded = msg.encode().expect("encode should succeed");
1125        let decoded = ReplicationMessage::decode(&encoded).expect("decode should succeed");
1126
1127        assert_eq!(decoded.request_id, 15);
1128        if let ReplicationMessageBody::AuditResponse(AuditResponse::Bootstrapping {
1129            challenge_id,
1130        }) = decoded.body
1131        {
1132            assert_eq!(challenge_id, 42);
1133        } else {
1134            panic!("expected AuditResponse::Bootstrapping");
1135        }
1136    }
1137
1138    // === Oversized message rejection ===
1139
1140    #[test]
1141    fn decode_rejects_oversized_payload() {
1142        let oversized = vec![0u8; MAX_REPLICATION_MESSAGE_SIZE + 1];
1143        let result = ReplicationMessage::decode(&oversized);
1144        assert!(result.is_err());
1145        let err = result.unwrap_err();
1146        assert!(
1147            matches!(err, ReplicationProtocolError::MessageTooLarge { .. }),
1148            "expected MessageTooLarge, got {err:?}"
1149        );
1150    }
1151
1152    #[test]
1153    fn encode_rejects_oversized_message() {
1154        // Build a message whose serialized form exceeds the limit.
1155        let msg = ReplicationMessage {
1156            request_id: 0,
1157            body: ReplicationMessageBody::FreshReplicationOffer(FreshReplicationOffer {
1158                key: [0; 32],
1159                data: vec![0xFF; MAX_REPLICATION_MESSAGE_SIZE],
1160                proof_of_payment: vec![],
1161            }),
1162        };
1163        let result = msg.encode();
1164        assert!(result.is_err());
1165        let err = result.unwrap_err();
1166        assert!(
1167            matches!(err, ReplicationProtocolError::MessageTooLarge { .. }),
1168            "expected MessageTooLarge, got {err:?}"
1169        );
1170    }
1171
1172    // === Invalid data rejection ===
1173
1174    #[test]
1175    fn decode_rejects_invalid_data() {
1176        let invalid = vec![0xFF, 0xFF, 0xFF];
1177        let result = ReplicationMessage::decode(&invalid);
1178        assert!(result.is_err());
1179        let err = result.unwrap_err();
1180        assert!(
1181            matches!(err, ReplicationProtocolError::DeserializationFailed(_)),
1182            "expected DeserializationFailed, got {err:?}"
1183        );
1184    }
1185
1186    // === Audit digest computation ===
1187
1188    #[test]
1189    fn audit_digest_is_deterministic() {
1190        let nonce = [0x01; 32];
1191        let peer_id = [0x02; 32];
1192        let key: XorName = [0x03; 32];
1193        let record_bytes = b"hello world";
1194
1195        let digest_a = compute_audit_digest(&nonce, &peer_id, &key, record_bytes);
1196        let digest_b = compute_audit_digest(&nonce, &peer_id, &key, record_bytes);
1197
1198        assert_eq!(digest_a, digest_b, "same inputs must produce same digest");
1199    }
1200
1201    #[test]
1202    fn audit_digest_differs_with_different_nonce() {
1203        let peer_id = [0x02; 32];
1204        let key: XorName = [0x03; 32];
1205        let record_bytes = b"hello world";
1206
1207        let digest_a = compute_audit_digest(&[0x01; 32], &peer_id, &key, record_bytes);
1208        let digest_b = compute_audit_digest(&[0xFF; 32], &peer_id, &key, record_bytes);
1209
1210        assert_ne!(
1211            digest_a, digest_b,
1212            "different nonces must produce different digests"
1213        );
1214    }
1215
1216    #[test]
1217    fn audit_digest_differs_with_different_data() {
1218        let nonce = [0x01; 32];
1219        let peer_id = [0x02; 32];
1220        let key: XorName = [0x03; 32];
1221
1222        let digest_a = compute_audit_digest(&nonce, &peer_id, &key, b"data-A");
1223        let digest_b = compute_audit_digest(&nonce, &peer_id, &key, b"data-B");
1224
1225        assert_ne!(
1226            digest_a, digest_b,
1227            "different data must produce different digests"
1228        );
1229    }
1230
1231    #[test]
1232    fn audit_digest_differs_with_different_peer() {
1233        let nonce = [0x01; 32];
1234        let key: XorName = [0x03; 32];
1235        let record_bytes = b"hello";
1236
1237        let digest_a = compute_audit_digest(&nonce, &[0x02; 32], &key, record_bytes);
1238        let digest_b = compute_audit_digest(&nonce, &[0xFF; 32], &key, record_bytes);
1239
1240        assert_ne!(
1241            digest_a, digest_b,
1242            "different peer IDs must produce different digests"
1243        );
1244    }
1245
1246    #[test]
1247    fn audit_digest_differs_with_different_key() {
1248        let nonce = [0x01; 32];
1249        let peer_id = [0x02; 32];
1250        let record_bytes = b"hello";
1251
1252        let digest_a = compute_audit_digest(&nonce, &peer_id, &[0x03; 32], record_bytes);
1253        let digest_b = compute_audit_digest(&nonce, &peer_id, &[0xFF; 32], record_bytes);
1254
1255        assert_ne!(
1256            digest_a, digest_b,
1257            "different keys must produce different digests"
1258        );
1259    }
1260
1261    // === Absent key digest sentinel ===
1262
1263    #[test]
1264    fn absent_key_digest_is_all_zeros() {
1265        assert_eq!(ABSENT_KEY_DIGEST, [0u8; 32]);
1266    }
1267
1268    #[test]
1269    fn real_digest_differs_from_absent_sentinel() {
1270        let nonce = [0x01; 32];
1271        let peer_id = [0x02; 32];
1272        let key: XorName = [0x03; 32];
1273        let record_bytes = b"non-empty data";
1274
1275        let digest = compute_audit_digest(&nonce, &peer_id, &key, record_bytes);
1276        assert_ne!(
1277            digest, ABSENT_KEY_DIGEST,
1278            "a real digest should not collide with the all-zeros sentinel"
1279        );
1280    }
1281
1282    // === Error Display ===
1283
1284    #[test]
1285    fn error_display_serialization_failed() {
1286        let err = ReplicationProtocolError::SerializationFailed("boom".to_string());
1287        assert_eq!(err.to_string(), "replication serialization failed: boom");
1288    }
1289
1290    #[test]
1291    fn error_display_deserialization_failed() {
1292        let err = ReplicationProtocolError::DeserializationFailed("bad data".to_string());
1293        assert_eq!(
1294            err.to_string(),
1295            "replication deserialization failed: bad data"
1296        );
1297    }
1298
1299    #[test]
1300    fn error_display_message_too_large() {
1301        let err = ReplicationProtocolError::MessageTooLarge {
1302            size: 20_000_000,
1303            max_size: MAX_REPLICATION_MESSAGE_SIZE,
1304        };
1305        let display = err.to_string();
1306        assert!(display.contains("20000000"));
1307        assert!(display.contains(&MAX_REPLICATION_MESSAGE_SIZE.to_string()));
1308    }
1309}