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}