Skip to main content

calimero_node_primitives/sync/
snapshot.rs

1//! Snapshot sync types (CIP §6 - Snapshot Sync Constraints).
2//!
3//! Types for snapshot-based synchronization.
4//!
5//! Wire protocol types (StreamMessage, InitPayload, MessagePayload) are in [`super::wire`].
6//!
7//! # When to Use
8//!
9//! - **ONLY** for fresh nodes with NO existing state (Invariant I5)
10//! - When delta history is pruned and state-based sync is impossible
11//! - For initial bootstrap of new nodes joining a context
12//!
13//! # Critical Invariants
14//!
15//! - **I5**: Initialized nodes MUST use CRDT merge, NEVER snapshot overwrite
16//! - **I7**: Root hash MUST be verified BEFORE applying any snapshot data
17//!
18//! # Validation
19//!
20//! All types have `is_valid()` methods that should be called after deserializing
21//! from untrusted sources to prevent resource exhaustion attacks.
22
23use std::borrow::Cow;
24
25use borsh::{BorshDeserialize, BorshSerialize};
26use calimero_crypto::Nonce;
27use calimero_network_primitives::specialized_node_invite::SpecializedNodeType;
28use calimero_primitives::context::ContextId;
29use calimero_primitives::hash::Hash;
30use calimero_primitives::identity::PublicKey;
31
32use super::hash_comparison::LeafMetadata;
33
34// =============================================================================
35// Constants
36// =============================================================================
37
38/// Default page size for snapshot transfer (256 KB).
39///
40/// Balances between memory usage and transfer efficiency.
41pub const DEFAULT_SNAPSHOT_PAGE_SIZE: u32 = 256 * 1024;
42
43/// Maximum page size for snapshot transfer (4 MB).
44///
45/// Limits memory usage for individual pages to prevent DoS attacks.
46pub const MAX_SNAPSHOT_PAGE_SIZE: u32 = 4 * 1024 * 1024;
47
48/// Maximum entities per snapshot page.
49///
50/// Limits the size of `SnapshotEntityPage::entities` to prevent
51/// memory exhaustion from malicious peers.
52pub const MAX_ENTITIES_PER_PAGE: usize = 1_000;
53
54/// Maximum total pages in a snapshot transfer.
55///
56/// Prevents unbounded memory allocation during snapshot reception.
57/// At 256KB per page, this allows ~2.5GB total transfer.
58pub const MAX_SNAPSHOT_PAGES: usize = 10_000;
59
60/// Maximum entity data size (1 MB).
61///
62/// Limits individual entity payload to prevent memory exhaustion.
63pub const MAX_ENTITY_DATA_SIZE: usize = 1_048_576;
64
65/// Maximum DAG heads in a snapshot completion message.
66///
67/// Limits the size of `SnapshotComplete::dag_heads`.
68pub const MAX_DAG_HEADS: usize = 100;
69
70/// Maximum compressed payload size (8 MB).
71///
72/// Limits the size of compressed snapshot page payloads to prevent
73/// memory exhaustion before decompression. Set higher than uncompressed
74/// limit to allow for edge cases where compression expands data.
75pub const MAX_COMPRESSED_PAYLOAD_SIZE: usize = 8 * 1024 * 1024;
76
77// =============================================================================
78// Snapshot Boundary Types
79// =============================================================================
80
81/// Request to negotiate a snapshot boundary for sync.
82#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
83pub struct SnapshotBoundaryRequest {
84    /// Context being synchronized.
85    pub context_id: ContextId,
86
87    /// Optional hint for boundary timestamp (nanoseconds since epoch).
88    pub requested_cutoff_timestamp: Option<u64>,
89}
90
91/// Response to snapshot boundary negotiation.
92///
93/// Contains the authoritative boundary state that the responder will serve
94/// for the duration of this sync session.
95#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
96pub struct SnapshotBoundaryResponse {
97    /// Authoritative boundary timestamp (nanoseconds since epoch).
98    pub boundary_timestamp: u64,
99
100    /// Root hash for the boundary state; must be verified after apply.
101    pub boundary_root_hash: Hash,
102
103    /// Peer's DAG heads at the boundary; used for fine-sync after snapshot.
104    pub dag_heads: Vec<[u8; 32]>,
105}
106
107impl SnapshotBoundaryResponse {
108    /// Check if response is within valid bounds.
109    ///
110    /// Call this after deserializing from untrusted sources.
111    #[must_use]
112    pub fn is_valid(&self) -> bool {
113        self.dag_heads.len() <= MAX_DAG_HEADS
114    }
115}
116
117/// Request to stream snapshot pages.
118#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
119pub struct SnapshotStreamRequest {
120    /// Context being synchronized.
121    pub context_id: ContextId,
122
123    /// Boundary root hash from the negotiated boundary.
124    pub boundary_root_hash: Hash,
125
126    /// Maximum number of pages to send in a burst.
127    pub page_limit: u16,
128
129    /// Maximum uncompressed bytes per page.
130    pub byte_limit: u32,
131
132    /// Optional cursor to resume paging.
133    pub resume_cursor: Option<Vec<u8>>,
134}
135
136impl SnapshotStreamRequest {
137    /// Get the validated byte limit.
138    ///
139    /// Clamps to MAX_SNAPSHOT_PAGE_SIZE to prevent memory exhaustion.
140    #[must_use]
141    pub fn validated_byte_limit(&self) -> u32 {
142        if self.byte_limit == 0 {
143            DEFAULT_SNAPSHOT_PAGE_SIZE
144        } else {
145            self.byte_limit.min(MAX_SNAPSHOT_PAGE_SIZE)
146        }
147    }
148}
149
150/// A page of snapshot data (raw bytes format).
151#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
152pub struct SnapshotPage {
153    /// Compressed payload (lz4).
154    pub payload: Vec<u8>,
155    /// Expected size after decompression.
156    pub uncompressed_len: u32,
157    /// Next cursor; `None` indicates completion.
158    pub cursor: Option<Vec<u8>>,
159    /// Total pages in this stream (estimate).
160    pub page_count: u64,
161    /// Pages sent so far.
162    pub sent_count: u64,
163}
164
165impl SnapshotPage {
166    /// Check if this is the last page.
167    #[must_use]
168    pub fn is_last(&self) -> bool {
169        self.cursor.is_none()
170    }
171
172    /// Check if page is within valid bounds.
173    #[must_use]
174    pub fn is_valid(&self) -> bool {
175        self.uncompressed_len <= MAX_SNAPSHOT_PAGE_SIZE
176            && self.page_count <= MAX_SNAPSHOT_PAGES as u64
177            && self.sent_count <= self.page_count
178            && self.payload.len() <= MAX_COMPRESSED_PAYLOAD_SIZE
179    }
180}
181
182/// Cursor for resuming snapshot pagination.
183#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
184pub struct SnapshotCursor {
185    /// Last key sent in canonical order.
186    pub last_key: [u8; 32],
187}
188
189// =============================================================================
190// Snapshot Bootstrap Types (CIP §6 - Snapshot Sync Constraints)
191// =============================================================================
192
193/// Request to initiate a full snapshot transfer.
194///
195/// CRITICAL: This is ONLY for fresh nodes with NO existing state.
196/// Invariant I5: Initialized nodes MUST use CRDT merge, not snapshot overwrite.
197#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
198pub struct SnapshotRequest {
199    /// Whether to compress the snapshot data.
200    pub compressed: bool,
201
202    /// Maximum page size in bytes (0 = use responder's default).
203    pub max_page_size: u32,
204
205    /// Whether the initiator is definitely a fresh node (for safety check).
206    /// If false, responder SHOULD verify this claim.
207    pub is_fresh_node: bool,
208}
209
210impl SnapshotRequest {
211    /// Create a request for compressed snapshot.
212    #[must_use]
213    pub fn compressed() -> Self {
214        Self {
215            compressed: true,
216            max_page_size: 0,
217            is_fresh_node: true,
218        }
219    }
220
221    /// Create a request for uncompressed snapshot.
222    #[must_use]
223    pub fn uncompressed() -> Self {
224        Self {
225            compressed: false,
226            max_page_size: 0,
227            is_fresh_node: true,
228        }
229    }
230
231    /// Set maximum page size.
232    #[must_use]
233    pub fn with_max_page_size(mut self, size: u32) -> Self {
234        self.max_page_size = size;
235        self
236    }
237
238    /// Get the validated page size.
239    ///
240    /// Returns DEFAULT_SNAPSHOT_PAGE_SIZE if 0, otherwise clamps to MAX.
241    #[must_use]
242    pub fn validated_page_size(&self) -> u32 {
243        if self.max_page_size == 0 {
244            DEFAULT_SNAPSHOT_PAGE_SIZE
245        } else {
246            self.max_page_size.min(MAX_SNAPSHOT_PAGE_SIZE)
247        }
248    }
249}
250
251/// A single entity in a snapshot.
252///
253/// Contains all information needed to reconstruct the entity.
254#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
255pub struct SnapshotEntity {
256    /// Entity ID (deterministic, based on path).
257    pub id: [u8; 32],
258
259    /// Serialized entity data.
260    pub data: Vec<u8>,
261
262    /// Entity metadata (crdt_type, timestamps, etc.).
263    pub metadata: LeafMetadata,
264
265    /// Collection ID this entity belongs to.
266    pub collection_id: [u8; 32],
267
268    /// Parent entity ID (for nested structures).
269    pub parent_id: Option<[u8; 32]>,
270}
271
272impl SnapshotEntity {
273    /// Create a new snapshot entity.
274    #[must_use]
275    pub fn new(
276        id: [u8; 32],
277        data: Vec<u8>,
278        metadata: LeafMetadata,
279        collection_id: [u8; 32],
280    ) -> Self {
281        Self {
282            id,
283            data,
284            metadata,
285            collection_id,
286            parent_id: None,
287        }
288    }
289
290    /// Set parent entity ID.
291    #[must_use]
292    pub fn with_parent(mut self, parent_id: [u8; 32]) -> Self {
293        self.parent_id = Some(parent_id);
294        self
295    }
296
297    /// Check if this is a root-level entity.
298    #[must_use]
299    pub fn is_root(&self) -> bool {
300        self.parent_id.is_none()
301    }
302
303    /// Check if entity is within valid bounds.
304    ///
305    /// Validates data size to prevent memory exhaustion from malicious peers.
306    /// LeafMetadata has fixed-size fields, so it's always valid if present.
307    #[must_use]
308    pub fn is_valid(&self) -> bool {
309        self.data.len() <= MAX_ENTITY_DATA_SIZE
310    }
311}
312
313/// A page of snapshot entities for paginated transfer.
314#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
315pub struct SnapshotEntityPage {
316    /// Page number (0-indexed).
317    pub page_number: usize,
318
319    /// Total number of pages (may be estimated).
320    pub total_pages: usize,
321
322    /// Entities in this page.
323    pub entities: Vec<SnapshotEntity>,
324
325    /// Whether this is the last page.
326    pub is_last: bool,
327}
328
329impl SnapshotEntityPage {
330    /// Create a new snapshot page.
331    #[must_use]
332    pub fn new(
333        page_number: usize,
334        total_pages: usize,
335        entities: Vec<SnapshotEntity>,
336        is_last: bool,
337    ) -> Self {
338        Self {
339            page_number,
340            total_pages,
341            entities,
342            is_last,
343        }
344    }
345
346    /// Number of entities in this page.
347    #[must_use]
348    pub fn entity_count(&self) -> usize {
349        self.entities.len()
350    }
351
352    /// Check if this page is empty.
353    #[must_use]
354    pub fn is_empty(&self) -> bool {
355        self.entities.is_empty()
356    }
357
358    /// Check if page is within valid bounds.
359    ///
360    /// Call this after deserializing from untrusted sources.
361    #[must_use]
362    pub fn is_valid(&self) -> bool {
363        // Check entity count limit
364        if self.entities.len() > MAX_ENTITIES_PER_PAGE {
365            return false;
366        }
367
368        // Check total pages limit
369        if self.total_pages > MAX_SNAPSHOT_PAGES {
370            return false;
371        }
372
373        // Check page number is within bounds (page_number is 0-indexed)
374        if self.total_pages > 0 && self.page_number >= self.total_pages {
375            return false;
376        }
377
378        // Check is_last coherence: if is_last, must be the final page
379        if self.is_last && self.total_pages > 0 && self.page_number + 1 != self.total_pages {
380            return false;
381        }
382
383        // Validate all entities
384        self.entities.iter().all(SnapshotEntity::is_valid)
385    }
386}
387
388/// Completion marker for snapshot transfer.
389///
390/// Sent after all pages have been transferred.
391/// Contains verification information for Invariant I7.
392#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
393pub struct SnapshotComplete {
394    /// Root hash of the complete snapshot.
395    /// INVARIANT I7: MUST be verified before applying any entities.
396    pub root_hash: [u8; 32],
397
398    /// Total number of entities transferred.
399    pub total_entities: usize,
400
401    /// Total number of pages transferred.
402    pub total_pages: usize,
403
404    /// Uncompressed size in bytes.
405    pub uncompressed_size: u64,
406
407    /// Compressed size in bytes (if compression was used).
408    pub compressed_size: Option<u64>,
409
410    /// DAG heads at the time of snapshot.
411    /// Used to create checkpoint delta after apply.
412    pub dag_heads: Vec<[u8; 32]>,
413}
414
415impl SnapshotComplete {
416    /// Create a new snapshot completion marker.
417    #[must_use]
418    pub fn new(
419        root_hash: [u8; 32],
420        total_entities: usize,
421        total_pages: usize,
422        uncompressed_size: u64,
423    ) -> Self {
424        Self {
425            root_hash,
426            total_entities,
427            total_pages,
428            uncompressed_size,
429            compressed_size: None,
430            dag_heads: vec![],
431        }
432    }
433
434    /// Set compressed size.
435    #[must_use]
436    pub fn with_compressed_size(mut self, size: u64) -> Self {
437        self.compressed_size = Some(size);
438        self
439    }
440
441    /// Set DAG heads.
442    #[must_use]
443    pub fn with_dag_heads(mut self, heads: Vec<[u8; 32]>) -> Self {
444        self.dag_heads = heads;
445        self
446    }
447
448    /// Calculate compression ratio (if compression was used).
449    #[must_use]
450    pub fn compression_ratio(&self) -> Option<f64> {
451        self.compressed_size
452            .map(|c| c as f64 / self.uncompressed_size.max(1) as f64)
453    }
454
455    /// Check if completion is within valid bounds.
456    #[must_use]
457    pub fn is_valid(&self) -> bool {
458        self.total_pages <= MAX_SNAPSHOT_PAGES && self.dag_heads.len() <= MAX_DAG_HEADS
459    }
460}
461
462// =============================================================================
463// Snapshot Verification (Invariant I7)
464// =============================================================================
465
466/// Result of verifying a snapshot.
467#[derive(Clone, Debug, PartialEq)]
468pub enum SnapshotVerifyResult {
469    /// Verification passed - safe to apply.
470    Valid,
471
472    /// Root hash mismatch - DO NOT apply.
473    RootHashMismatch {
474        expected: [u8; 32],
475        computed: [u8; 32],
476    },
477
478    /// Entity count mismatch.
479    EntityCountMismatch { expected: usize, actual: usize },
480
481    /// Missing pages detected.
482    MissingPages { missing: Vec<usize> },
483}
484
485impl SnapshotVerifyResult {
486    /// Check if verification passed.
487    #[must_use]
488    pub fn is_valid(&self) -> bool {
489        matches!(self, Self::Valid)
490    }
491
492    /// Convert to error if invalid.
493    #[must_use]
494    pub fn to_error(&self) -> Option<SnapshotError> {
495        match self {
496            Self::Valid => None,
497            Self::RootHashMismatch { expected, computed } => {
498                Some(SnapshotError::RootHashMismatch {
499                    expected: *expected,
500                    computed: *computed,
501                })
502            }
503            Self::EntityCountMismatch { expected, actual } => {
504                Some(SnapshotError::EntityCountMismatch {
505                    expected: *expected,
506                    actual: *actual,
507                })
508            }
509            Self::MissingPages { missing } => Some(SnapshotError::MissingPages {
510                missing: missing.clone(),
511            }),
512        }
513    }
514}
515
516// =============================================================================
517// Snapshot Errors
518// =============================================================================
519
520/// Errors that can occur during snapshot sync.
521#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)]
522pub enum SnapshotError {
523    /// Peer's delta history is pruned; full snapshot required.
524    SnapshotRequired,
525
526    /// The requested boundary is invalid or no longer available.
527    InvalidBoundary,
528
529    /// Resume cursor is invalid or expired.
530    ResumeCursorInvalid,
531
532    /// Attempted to apply snapshot on a node with existing state.
533    /// INVARIANT I5: Snapshot is ONLY for fresh nodes.
534    SnapshotOnInitializedNode,
535
536    /// Root hash verification failed.
537    /// INVARIANT I7: Verification REQUIRED before apply.
538    RootHashMismatch {
539        expected: [u8; 32],
540        computed: [u8; 32],
541    },
542
543    /// Snapshot transfer was interrupted.
544    TransferInterrupted { pages_received: usize },
545
546    /// Decompression failed.
547    DecompressionFailed,
548
549    /// Entity count does not match expected count.
550    EntityCountMismatch { expected: usize, actual: usize },
551
552    /// Some pages are missing from the snapshot transfer.
553    MissingPages { missing: Vec<usize> },
554}
555
556// =============================================================================
557// Safety Functions
558// =============================================================================
559
560/// Safety check before applying snapshot.
561///
562/// Returns error if the local node has existing state.
563/// INVARIANT I5: Snapshot is ONLY for fresh nodes.
564pub fn check_snapshot_safety(has_local_state: bool) -> Result<(), SnapshotError> {
565    if has_local_state {
566        Err(SnapshotError::SnapshotOnInitializedNode)
567    } else {
568        Ok(())
569    }
570}
571
572// =============================================================================
573// Wire Protocol Messages
574// =============================================================================
575
576#[derive(Debug, BorshSerialize, BorshDeserialize)]
577#[non_exhaustive]
578#[expect(clippy::large_enum_variant, reason = "Of no consequence here")]
579pub enum BroadcastMessage<'a> {
580    StateDelta {
581        context_id: ContextId,
582        author_id: PublicKey,
583
584        /// DAG: Unique delta ID (content hash)
585        delta_id: [u8; 32],
586
587        /// DAG: Parent delta IDs (for causal ordering)
588        parent_ids: Vec<[u8; 32]>,
589
590        /// Hybrid Logical Clock timestamp for causal ordering
591        hlc: calimero_storage::logical_clock::HybridTimestamp,
592
593        root_hash: Hash, // todo! shouldn't be cleartext
594        artifact: Cow<'a, [u8]>,
595        nonce: Nonce,
596
597        /// Execution events that were emitted during the state change.
598        /// This field is encrypted along with the artifact.
599        events: Option<Cow<'a, [u8]>>,
600    },
601
602    /// Hash heartbeat for divergence detection
603    ///
604    /// Periodically broadcast by nodes to allow peers to detect silent divergence.
605    /// If a peer has a different hash for the same DAG heads, it indicates a problem.
606    HashHeartbeat {
607        context_id: ContextId,
608        /// Current root hash
609        root_hash: Hash,
610        /// Current DAG head(s)
611        dag_heads: Vec<[u8; 32]>,
612    },
613
614    /// Specialized node discovery request
615    ///
616    /// Broadcast by a node to discover and invite specialized nodes (e.g., read-only TEE nodes).
617    /// Specialized nodes receiving this will respond via request-response protocol
618    /// to the message source (available from gossipsub message).
619    ///
620    /// Note: context_id is NOT included - it's tracked internally by the requesting
621    /// node using the nonce as the lookup key.
622    SpecializedNodeDiscovery {
623        /// Random nonce to bind verification to this request
624        nonce: [u8; 32],
625        /// Type of specialized node being invited
626        node_type: SpecializedNodeType,
627    },
628
629    /// Confirmation that a specialized node has joined a context
630    ///
631    /// Broadcast by specialized nodes on the context topic after successfully joining.
632    /// The inviting node receives this and removes the pending invite entry.
633    SpecializedNodeJoinConfirmation {
634        /// The nonce from the original discovery request
635        nonce: [u8; 32],
636    },
637}
638
639// Wire protocol types (StreamMessage, InitPayload, MessagePayload) are in wire.rs
640
641// =============================================================================
642// Tests
643// =============================================================================
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use crate::sync::hash_comparison::CrdtType;
649
650    // =========================================================================
651    // Helper Functions
652    // =========================================================================
653
654    fn make_metadata() -> LeafMetadata {
655        LeafMetadata::new(CrdtType::lww_register("test"), 100, [1; 32])
656    }
657
658    fn make_entity(id: u8, data: Vec<u8>) -> SnapshotEntity {
659        SnapshotEntity::new([id; 32], data, make_metadata(), [2; 32])
660    }
661
662    // =========================================================================
663    // SnapshotRequest Tests
664    // =========================================================================
665
666    #[test]
667    fn test_snapshot_request_compressed() {
668        let request = SnapshotRequest::compressed();
669
670        assert!(request.compressed);
671        assert!(request.is_fresh_node);
672        assert_eq!(request.max_page_size, 0);
673        assert_eq!(request.validated_page_size(), DEFAULT_SNAPSHOT_PAGE_SIZE);
674    }
675
676    #[test]
677    fn test_snapshot_request_uncompressed() {
678        let request = SnapshotRequest::uncompressed().with_max_page_size(1024 * 1024);
679
680        assert!(!request.compressed);
681        assert_eq!(request.max_page_size, 1024 * 1024);
682        assert_eq!(request.validated_page_size(), 1024 * 1024);
683    }
684
685    #[test]
686    fn test_snapshot_request_page_size_clamping() {
687        let request = SnapshotRequest::compressed().with_max_page_size(u32::MAX);
688
689        // Should clamp to MAX_SNAPSHOT_PAGE_SIZE
690        assert_eq!(request.validated_page_size(), MAX_SNAPSHOT_PAGE_SIZE);
691    }
692
693    #[test]
694    fn test_snapshot_request_roundtrip() {
695        let request = SnapshotRequest::compressed().with_max_page_size(65536);
696
697        let encoded = borsh::to_vec(&request).expect("serialize");
698        let decoded: SnapshotRequest = borsh::from_slice(&encoded).expect("deserialize");
699
700        assert_eq!(request, decoded);
701    }
702
703    // =========================================================================
704    // SnapshotEntity Tests
705    // =========================================================================
706
707    #[test]
708    fn test_snapshot_entity_new() {
709        let entity = make_entity(1, vec![1, 2, 3]);
710
711        assert_eq!(entity.id, [1; 32]);
712        assert!(entity.is_root());
713        assert!(entity.parent_id.is_none());
714        assert!(entity.is_valid());
715    }
716
717    #[test]
718    fn test_snapshot_entity_with_parent() {
719        let entity = make_entity(2, vec![4, 5, 6]).with_parent([1; 32]);
720
721        assert!(!entity.is_root());
722        assert_eq!(entity.parent_id, Some([1; 32]));
723        assert!(entity.is_valid());
724    }
725
726    #[test]
727    fn test_snapshot_entity_validation() {
728        // Valid entity
729        let valid = make_entity(1, vec![1, 2, 3]);
730        assert!(valid.is_valid());
731
732        // Invalid entity: oversized data
733        let oversized = make_entity(1, vec![0u8; MAX_ENTITY_DATA_SIZE + 1]);
734        assert!(!oversized.is_valid());
735    }
736
737    #[test]
738    fn test_snapshot_entity_roundtrip() {
739        let entity = make_entity(3, vec![7, 8, 9]).with_parent([2; 32]);
740
741        let encoded = borsh::to_vec(&entity).expect("serialize");
742        let decoded: SnapshotEntity = borsh::from_slice(&encoded).expect("deserialize");
743
744        assert_eq!(entity, decoded);
745    }
746
747    // =========================================================================
748    // SnapshotEntityPage Tests
749    // =========================================================================
750
751    #[test]
752    fn test_snapshot_entity_page() {
753        let entity1 = make_entity(1, vec![1, 2]);
754        let entity2 = make_entity(2, vec![3, 4]);
755
756        let page = SnapshotEntityPage::new(0, 3, vec![entity1, entity2], false);
757
758        assert_eq!(page.page_number, 0);
759        assert_eq!(page.total_pages, 3);
760        assert_eq!(page.entity_count(), 2);
761        assert!(!page.is_last);
762        assert!(!page.is_empty());
763        assert!(page.is_valid());
764    }
765
766    #[test]
767    fn test_snapshot_entity_page_last() {
768        let entity = make_entity(1, vec![1, 2, 3]);
769        let page = SnapshotEntityPage::new(2, 3, vec![entity], true);
770
771        assert!(page.is_last);
772        assert!(page.is_valid());
773    }
774
775    #[test]
776    fn test_snapshot_entity_page_empty() {
777        let page = SnapshotEntityPage::new(0, 1, vec![], true);
778
779        assert!(page.is_empty());
780        assert_eq!(page.entity_count(), 0);
781        assert!(page.is_valid());
782    }
783
784    #[test]
785    fn test_snapshot_entity_page_validation() {
786        // Valid page at entity limit
787        let entities: Vec<SnapshotEntity> = (0..MAX_ENTITIES_PER_PAGE)
788            .map(|i| make_entity(i as u8, vec![i as u8]))
789            .collect();
790        let at_limit = SnapshotEntityPage::new(0, 1, entities, true);
791        assert!(at_limit.is_valid());
792
793        // Invalid page: over entity limit
794        let entities: Vec<SnapshotEntity> = (0..=MAX_ENTITIES_PER_PAGE)
795            .map(|i| make_entity(i as u8, vec![i as u8]))
796            .collect();
797        let over_limit = SnapshotEntityPage::new(0, 1, entities, true);
798        assert!(!over_limit.is_valid());
799
800        // Invalid page: over total pages limit
801        let entity = make_entity(1, vec![1]);
802        let over_pages = SnapshotEntityPage::new(0, MAX_SNAPSHOT_PAGES + 1, vec![entity], false);
803        assert!(!over_pages.is_valid());
804
805        // Invalid page: page_number >= total_pages
806        let entity = make_entity(1, vec![1]);
807        let invalid_page_num = SnapshotEntityPage::new(5, 3, vec![entity], false);
808        assert!(!invalid_page_num.is_valid());
809
810        // Invalid page: is_last but not the final page
811        let entity = make_entity(1, vec![1]);
812        let invalid_last = SnapshotEntityPage::new(0, 3, vec![entity], true);
813        assert!(!invalid_last.is_valid());
814
815        // Valid page: is_last and is the final page
816        let entity = make_entity(1, vec![1]);
817        let valid_last = SnapshotEntityPage::new(2, 3, vec![entity], true);
818        assert!(valid_last.is_valid());
819    }
820
821    #[test]
822    fn test_snapshot_entity_page_roundtrip() {
823        let entity = make_entity(4, vec![10, 11]);
824        let page = SnapshotEntityPage::new(1, 5, vec![entity], false);
825
826        let encoded = borsh::to_vec(&page).expect("serialize");
827        let decoded: SnapshotEntityPage = borsh::from_slice(&encoded).expect("deserialize");
828
829        assert_eq!(page, decoded);
830    }
831
832    // =========================================================================
833    // SnapshotComplete Tests
834    // =========================================================================
835
836    #[test]
837    fn test_snapshot_complete() {
838        let complete = SnapshotComplete::new([1; 32], 1000, 10, 1024 * 1024)
839            .with_compressed_size(256 * 1024)
840            .with_dag_heads(vec![[2; 32], [3; 32]]);
841
842        assert_eq!(complete.root_hash, [1; 32]);
843        assert_eq!(complete.total_entities, 1000);
844        assert_eq!(complete.total_pages, 10);
845        assert_eq!(complete.dag_heads.len(), 2);
846        assert!(complete.is_valid());
847
848        // Compression ratio: 256KB / 1MB = 0.25
849        let ratio = complete.compression_ratio().unwrap();
850        assert!((ratio - 0.25).abs() < 0.01);
851    }
852
853    #[test]
854    fn test_snapshot_complete_no_compression() {
855        let complete = SnapshotComplete::new([1; 32], 100, 1, 10000);
856
857        assert!(complete.compression_ratio().is_none());
858        assert!(complete.is_valid());
859    }
860
861    #[test]
862    fn test_snapshot_complete_validation() {
863        // Valid completion
864        let valid = SnapshotComplete::new([1; 32], 1000, 10, 1024 * 1024);
865        assert!(valid.is_valid());
866
867        // Invalid: too many pages
868        let over_pages = SnapshotComplete::new([1; 32], 1000, MAX_SNAPSHOT_PAGES + 1, 1024);
869        assert!(!over_pages.is_valid());
870
871        // Invalid: too many DAG heads
872        let heads: Vec<[u8; 32]> = (0..=MAX_DAG_HEADS).map(|i| [i as u8; 32]).collect();
873        let over_heads = SnapshotComplete::new([1; 32], 1000, 10, 1024).with_dag_heads(heads);
874        assert!(!over_heads.is_valid());
875    }
876
877    #[test]
878    fn test_snapshot_complete_roundtrip() {
879        let complete = SnapshotComplete::new([1; 32], 500, 5, 512 * 1024)
880            .with_compressed_size(128 * 1024)
881            .with_dag_heads(vec![[2; 32]]);
882
883        let encoded = borsh::to_vec(&complete).expect("serialize");
884        let decoded: SnapshotComplete = borsh::from_slice(&encoded).expect("deserialize");
885
886        assert_eq!(complete, decoded);
887    }
888
889    // =========================================================================
890    // SnapshotVerifyResult Tests
891    // =========================================================================
892
893    #[test]
894    fn test_snapshot_verify_result_valid() {
895        let result = SnapshotVerifyResult::Valid;
896        assert!(result.is_valid());
897        assert!(result.to_error().is_none());
898    }
899
900    #[test]
901    fn test_snapshot_verify_result_hash_mismatch() {
902        let result = SnapshotVerifyResult::RootHashMismatch {
903            expected: [1; 32],
904            computed: [2; 32],
905        };
906        assert!(!result.is_valid());
907
908        let error = result.to_error().unwrap();
909        assert!(matches!(error, SnapshotError::RootHashMismatch { .. }));
910    }
911
912    #[test]
913    fn test_snapshot_verify_result_entity_count() {
914        let result = SnapshotVerifyResult::EntityCountMismatch {
915            expected: 100,
916            actual: 99,
917        };
918        assert!(!result.is_valid());
919        let error = result.to_error().unwrap();
920        assert!(matches!(
921            error,
922            SnapshotError::EntityCountMismatch {
923                expected: 100,
924                actual: 99
925            }
926        ));
927    }
928
929    #[test]
930    fn test_snapshot_verify_result_missing_pages() {
931        let result = SnapshotVerifyResult::MissingPages {
932            missing: vec![3, 5, 7],
933        };
934        assert!(!result.is_valid());
935        let error = result.to_error().unwrap();
936        match error {
937            SnapshotError::MissingPages { missing } => {
938                assert_eq!(missing, vec![3, 5, 7]);
939            }
940            _ => panic!("Expected MissingPages error"),
941        }
942    }
943
944    // =========================================================================
945    // Safety Function Tests (Invariant I5)
946    // =========================================================================
947
948    #[test]
949    fn test_check_snapshot_safety_fresh_node() {
950        // Fresh node (no state) - OK
951        assert!(check_snapshot_safety(false).is_ok());
952    }
953
954    #[test]
955    fn test_check_snapshot_safety_initialized_node() {
956        // Initialized node (has state) - ERROR
957        let result = check_snapshot_safety(true);
958        assert!(result.is_err());
959        assert!(matches!(
960            result.unwrap_err(),
961            SnapshotError::SnapshotOnInitializedNode
962        ));
963    }
964
965    // =========================================================================
966    // SnapshotError Tests
967    // =========================================================================
968
969    #[test]
970    fn test_snapshot_error_roundtrip() {
971        let errors = vec![
972            SnapshotError::SnapshotRequired,
973            SnapshotError::InvalidBoundary,
974            SnapshotError::ResumeCursorInvalid,
975            SnapshotError::SnapshotOnInitializedNode,
976            SnapshotError::RootHashMismatch {
977                expected: [1; 32],
978                computed: [2; 32],
979            },
980            SnapshotError::TransferInterrupted { pages_received: 5 },
981            SnapshotError::DecompressionFailed,
982            SnapshotError::EntityCountMismatch {
983                expected: 100,
984                actual: 99,
985            },
986            SnapshotError::MissingPages {
987                missing: vec![3, 5, 7],
988            },
989        ];
990
991        for error in errors {
992            let encoded = borsh::to_vec(&error).expect("serialize");
993            let decoded: SnapshotError = borsh::from_slice(&encoded).expect("deserialize");
994            assert_eq!(error, decoded);
995        }
996    }
997
998    // =========================================================================
999    // SnapshotBoundaryResponse Tests
1000    // =========================================================================
1001
1002    #[test]
1003    fn test_snapshot_boundary_response_validation() {
1004        // Valid response
1005        let valid = SnapshotBoundaryResponse {
1006            boundary_timestamp: 12345,
1007            boundary_root_hash: Hash::default(),
1008            dag_heads: vec![[1; 32], [2; 32]],
1009        };
1010        assert!(valid.is_valid());
1011
1012        // Invalid: too many DAG heads
1013        let heads: Vec<[u8; 32]> = (0..=MAX_DAG_HEADS).map(|i| [i as u8; 32]).collect();
1014        let invalid = SnapshotBoundaryResponse {
1015            boundary_timestamp: 12345,
1016            boundary_root_hash: Hash::default(),
1017            dag_heads: heads,
1018        };
1019        assert!(!invalid.is_valid());
1020    }
1021
1022    // =========================================================================
1023    // SnapshotStreamRequest Tests
1024    // =========================================================================
1025
1026    #[test]
1027    fn test_snapshot_stream_request_byte_limit() {
1028        // Zero returns default
1029        let request = SnapshotStreamRequest {
1030            context_id: ContextId::zero(),
1031            boundary_root_hash: Hash::default(),
1032            page_limit: 10,
1033            byte_limit: 0,
1034            resume_cursor: None,
1035        };
1036        assert_eq!(request.validated_byte_limit(), DEFAULT_SNAPSHOT_PAGE_SIZE);
1037
1038        // Normal value passes through
1039        let request2 = SnapshotStreamRequest {
1040            context_id: ContextId::zero(),
1041            boundary_root_hash: Hash::default(),
1042            page_limit: 10,
1043            byte_limit: 100_000,
1044            resume_cursor: None,
1045        };
1046        assert_eq!(request2.validated_byte_limit(), 100_000);
1047
1048        // Excessive value is clamped
1049        let request3 = SnapshotStreamRequest {
1050            context_id: ContextId::zero(),
1051            boundary_root_hash: Hash::default(),
1052            page_limit: 10,
1053            byte_limit: u32::MAX,
1054            resume_cursor: None,
1055        };
1056        assert_eq!(request3.validated_byte_limit(), MAX_SNAPSHOT_PAGE_SIZE);
1057    }
1058
1059    // =========================================================================
1060    // SnapshotPage Tests
1061    // =========================================================================
1062
1063    #[test]
1064    fn test_snapshot_page_is_last() {
1065        let page_not_last = SnapshotPage {
1066            payload: vec![1, 2, 3],
1067            uncompressed_len: 100,
1068            cursor: Some(vec![4, 5]),
1069            page_count: 10,
1070            sent_count: 5,
1071        };
1072        assert!(!page_not_last.is_last());
1073
1074        let page_is_last = SnapshotPage {
1075            payload: vec![1, 2, 3],
1076            uncompressed_len: 100,
1077            cursor: None,
1078            page_count: 10,
1079            sent_count: 10,
1080        };
1081        assert!(page_is_last.is_last());
1082    }
1083
1084    #[test]
1085    fn test_snapshot_page_validation() {
1086        // Valid page
1087        let valid = SnapshotPage {
1088            payload: vec![1, 2, 3],
1089            uncompressed_len: 100,
1090            cursor: None,
1091            page_count: 10,
1092            sent_count: 10,
1093        };
1094        assert!(valid.is_valid());
1095
1096        // Invalid: oversized uncompressed_len
1097        let oversized = SnapshotPage {
1098            payload: vec![1, 2, 3],
1099            uncompressed_len: MAX_SNAPSHOT_PAGE_SIZE + 1,
1100            cursor: None,
1101            page_count: 10,
1102            sent_count: 10,
1103        };
1104        assert!(!oversized.is_valid());
1105
1106        // Invalid: too many pages
1107        let too_many = SnapshotPage {
1108            payload: vec![1, 2, 3],
1109            uncompressed_len: 100,
1110            cursor: None,
1111            page_count: MAX_SNAPSHOT_PAGES as u64 + 1,
1112            sent_count: 10,
1113        };
1114        assert!(!too_many.is_valid());
1115
1116        // Invalid: sent_count > page_count
1117        let invalid_sent = SnapshotPage {
1118            payload: vec![1, 2, 3],
1119            uncompressed_len: 100,
1120            cursor: None,
1121            page_count: 5,
1122            sent_count: 10,
1123        };
1124        assert!(!invalid_sent.is_valid());
1125
1126        // Invalid: oversized compressed payload
1127        let oversized_payload = SnapshotPage {
1128            payload: vec![0u8; MAX_COMPRESSED_PAYLOAD_SIZE + 1],
1129            uncompressed_len: 100,
1130            cursor: None,
1131            page_count: 10,
1132            sent_count: 10,
1133        };
1134        assert!(!oversized_payload.is_valid());
1135    }
1136
1137    // =========================================================================
1138    // Boundary Condition Tests
1139    // =========================================================================
1140
1141    #[test]
1142    fn test_snapshot_entity_data_at_limit() {
1143        // Exactly at MAX_ENTITY_DATA_SIZE - should be valid
1144        let at_limit = make_entity(1, vec![0u8; MAX_ENTITY_DATA_SIZE]);
1145        assert!(at_limit.is_valid());
1146
1147        // One byte over - should be invalid
1148        let over_limit = make_entity(1, vec![0u8; MAX_ENTITY_DATA_SIZE + 1]);
1149        assert!(!over_limit.is_valid());
1150    }
1151
1152    #[test]
1153    fn test_snapshot_entity_page_at_entity_limit() {
1154        // Exactly at MAX_ENTITIES_PER_PAGE - should be valid
1155        let entities: Vec<SnapshotEntity> = (0..MAX_ENTITIES_PER_PAGE)
1156            .map(|i| make_entity((i % 256) as u8, vec![(i % 256) as u8]))
1157            .collect();
1158        let at_limit = SnapshotEntityPage::new(0, 1, entities, true);
1159        assert!(at_limit.is_valid());
1160        assert_eq!(at_limit.entity_count(), MAX_ENTITIES_PER_PAGE);
1161
1162        // One entity over - should be invalid
1163        let entities: Vec<SnapshotEntity> = (0..=MAX_ENTITIES_PER_PAGE)
1164            .map(|i| make_entity((i % 256) as u8, vec![(i % 256) as u8]))
1165            .collect();
1166        let over_limit = SnapshotEntityPage::new(0, 1, entities, true);
1167        assert!(!over_limit.is_valid());
1168    }
1169
1170    #[test]
1171    fn test_snapshot_complete_at_page_limit() {
1172        // Exactly at MAX_SNAPSHOT_PAGES - should be valid
1173        let at_limit = SnapshotComplete::new([1; 32], 1000, MAX_SNAPSHOT_PAGES, 1024);
1174        assert!(at_limit.is_valid());
1175
1176        // One page over - should be invalid
1177        let over_limit = SnapshotComplete::new([1; 32], 1000, MAX_SNAPSHOT_PAGES + 1, 1024);
1178        assert!(!over_limit.is_valid());
1179    }
1180
1181    #[test]
1182    fn test_snapshot_complete_at_dag_heads_limit() {
1183        // Exactly at MAX_DAG_HEADS - should be valid
1184        let heads: Vec<[u8; 32]> = (0..MAX_DAG_HEADS).map(|i| [(i % 256) as u8; 32]).collect();
1185        let at_limit = SnapshotComplete::new([1; 32], 1000, 10, 1024).with_dag_heads(heads);
1186        assert!(at_limit.is_valid());
1187
1188        // One head over - should be invalid
1189        let heads: Vec<[u8; 32]> = (0..=MAX_DAG_HEADS).map(|i| [(i % 256) as u8; 32]).collect();
1190        let over_limit = SnapshotComplete::new([1; 32], 1000, 10, 1024).with_dag_heads(heads);
1191        assert!(!over_limit.is_valid());
1192    }
1193
1194    #[test]
1195    fn test_snapshot_page_at_size_limit() {
1196        // Exactly at MAX_SNAPSHOT_PAGE_SIZE - should be valid
1197        let at_limit = SnapshotPage {
1198            payload: vec![1, 2, 3],
1199            uncompressed_len: MAX_SNAPSHOT_PAGE_SIZE,
1200            cursor: None,
1201            page_count: 10,
1202            sent_count: 10,
1203        };
1204        assert!(at_limit.is_valid());
1205
1206        // One byte over - should be invalid
1207        let over_limit = SnapshotPage {
1208            payload: vec![1, 2, 3],
1209            uncompressed_len: MAX_SNAPSHOT_PAGE_SIZE + 1,
1210            cursor: None,
1211            page_count: 10,
1212            sent_count: 10,
1213        };
1214        assert!(!over_limit.is_valid());
1215    }
1216
1217    // =========================================================================
1218    // Security / Exploit Prevention Tests
1219    // =========================================================================
1220
1221    #[test]
1222    fn test_snapshot_request_memory_exhaustion_prevention() {
1223        // Attempt to request extremely large page size - should be clamped
1224        let request = SnapshotRequest::compressed().with_max_page_size(u32::MAX);
1225        assert_eq!(request.validated_page_size(), MAX_SNAPSHOT_PAGE_SIZE);
1226    }
1227
1228    #[test]
1229    fn test_snapshot_stream_request_memory_exhaustion_prevention() {
1230        // Attempt extremely large byte limit - should be clamped
1231        let request = SnapshotStreamRequest {
1232            context_id: ContextId::zero(),
1233            boundary_root_hash: Hash::default(),
1234            page_limit: u16::MAX,
1235            byte_limit: u32::MAX,
1236            resume_cursor: None,
1237        };
1238        assert_eq!(request.validated_byte_limit(), MAX_SNAPSHOT_PAGE_SIZE);
1239    }
1240
1241    #[test]
1242    fn test_snapshot_entity_page_cross_validation() {
1243        // Page containing an invalid entity should be invalid
1244        let invalid_entity = make_entity(1, vec![0u8; MAX_ENTITY_DATA_SIZE + 1]);
1245        let page = SnapshotEntityPage::new(0, 1, vec![invalid_entity], true);
1246        assert!(!page.is_valid());
1247
1248        // Page with mix of valid and invalid entities
1249        let valid_entity = make_entity(1, vec![1, 2, 3]);
1250        let invalid_entity = make_entity(2, vec![0u8; MAX_ENTITY_DATA_SIZE + 1]);
1251        let mixed_page = SnapshotEntityPage::new(0, 1, vec![valid_entity, invalid_entity], true);
1252        assert!(!mixed_page.is_valid());
1253    }
1254
1255    #[test]
1256    fn test_snapshot_complete_compression_ratio_zero_uncompressed() {
1257        // Edge case: zero uncompressed size (uses max(1) to prevent division by zero)
1258        let complete = SnapshotComplete::new([1; 32], 0, 0, 0).with_compressed_size(100);
1259
1260        let ratio = complete.compression_ratio().unwrap();
1261        // 100 / max(0, 1) = 100.0
1262        assert_eq!(ratio, 100.0);
1263    }
1264
1265    // =========================================================================
1266    // Special Values Tests
1267    // =========================================================================
1268
1269    #[test]
1270    fn test_snapshot_entity_all_zeros() {
1271        let entity = SnapshotEntity::new([0u8; 32], vec![], make_metadata(), [0u8; 32]);
1272        assert!(entity.is_valid());
1273        assert!(entity.is_root());
1274        assert!(entity.data.is_empty());
1275
1276        // Roundtrip
1277        let encoded = borsh::to_vec(&entity).expect("serialize");
1278        let decoded: SnapshotEntity = borsh::from_slice(&encoded).expect("deserialize");
1279        assert_eq!(entity, decoded);
1280    }
1281
1282    #[test]
1283    fn test_snapshot_entity_all_ones() {
1284        let entity = SnapshotEntity::new([0xFF; 32], vec![0xFF; 100], make_metadata(), [0xFF; 32])
1285            .with_parent([0xFF; 32]);
1286        assert!(entity.is_valid());
1287        assert!(!entity.is_root());
1288
1289        // Roundtrip
1290        let encoded = borsh::to_vec(&entity).expect("serialize");
1291        let decoded: SnapshotEntity = borsh::from_slice(&encoded).expect("deserialize");
1292        assert_eq!(entity, decoded);
1293    }
1294
1295    #[test]
1296    fn test_snapshot_complete_all_zeros() {
1297        let complete = SnapshotComplete::new([0u8; 32], 0, 0, 0);
1298        assert!(complete.is_valid());
1299        assert!(complete.compression_ratio().is_none());
1300        assert!(complete.dag_heads.is_empty());
1301
1302        // Roundtrip
1303        let encoded = borsh::to_vec(&complete).expect("serialize");
1304        let decoded: SnapshotComplete = borsh::from_slice(&encoded).expect("deserialize");
1305        assert_eq!(complete, decoded);
1306    }
1307
1308    #[test]
1309    fn test_snapshot_complete_max_values() {
1310        let complete = SnapshotComplete::new([0xFF; 32], usize::MAX, MAX_SNAPSHOT_PAGES, u64::MAX)
1311            .with_compressed_size(u64::MAX);
1312        assert!(complete.is_valid());
1313
1314        // Roundtrip
1315        let encoded = borsh::to_vec(&complete).expect("serialize");
1316        let decoded: SnapshotComplete = borsh::from_slice(&encoded).expect("deserialize");
1317        assert_eq!(complete, decoded);
1318    }
1319
1320    #[test]
1321    fn test_snapshot_request_all_flags() {
1322        // Test both compressed and uncompressed
1323        let compressed = SnapshotRequest::compressed();
1324        assert!(compressed.compressed);
1325        assert!(compressed.is_fresh_node);
1326
1327        let uncompressed = SnapshotRequest::uncompressed();
1328        assert!(!uncompressed.compressed);
1329        assert!(uncompressed.is_fresh_node);
1330
1331        // Test non-fresh node flag (edge case)
1332        let mut not_fresh = SnapshotRequest::compressed();
1333        not_fresh.is_fresh_node = false;
1334        assert!(!not_fresh.is_fresh_node);
1335    }
1336
1337    // =========================================================================
1338    // Serialization Edge Cases
1339    // =========================================================================
1340
1341    #[test]
1342    fn test_snapshot_entity_page_with_many_entities_roundtrip() {
1343        // Test serialization with many entities (but within limit)
1344        let entities: Vec<SnapshotEntity> = (0..1000)
1345            .map(|i| make_entity((i % 256) as u8, vec![(i % 256) as u8; 10]))
1346            .collect();
1347        let page = SnapshotEntityPage::new(5, 100, entities, false);
1348        assert!(page.is_valid());
1349
1350        let encoded = borsh::to_vec(&page).expect("serialize");
1351        let decoded: SnapshotEntityPage = borsh::from_slice(&encoded).expect("deserialize");
1352
1353        assert_eq!(page, decoded);
1354        assert_eq!(decoded.entity_count(), 1000);
1355    }
1356
1357    #[test]
1358    fn test_snapshot_page_with_large_cursor_roundtrip() {
1359        let page = SnapshotPage {
1360            payload: vec![1; 1000],
1361            uncompressed_len: 5000,
1362            cursor: Some(vec![0xAB; 256]), // Large cursor
1363            page_count: 1000,
1364            sent_count: 500,
1365        };
1366        assert!(page.is_valid());
1367
1368        let encoded = borsh::to_vec(&page).expect("serialize");
1369        let decoded: SnapshotPage = borsh::from_slice(&encoded).expect("deserialize");
1370
1371        assert_eq!(page, decoded);
1372        assert!(!decoded.is_last());
1373    }
1374
1375    #[test]
1376    fn test_snapshot_verify_result_all_variants_behavior() {
1377        // Test is_valid returns correctly for all variants
1378        assert!(SnapshotVerifyResult::Valid.is_valid());
1379        assert!(!SnapshotVerifyResult::RootHashMismatch {
1380            expected: [1; 32],
1381            computed: [2; 32]
1382        }
1383        .is_valid());
1384        assert!(!SnapshotVerifyResult::EntityCountMismatch {
1385            expected: 100,
1386            actual: 50
1387        }
1388        .is_valid());
1389        assert!(!SnapshotVerifyResult::MissingPages { missing: vec![1] }.is_valid());
1390
1391        // Test to_error returns None only for Valid
1392        assert!(SnapshotVerifyResult::Valid.to_error().is_none());
1393        assert!(SnapshotVerifyResult::RootHashMismatch {
1394            expected: [1; 32],
1395            computed: [2; 32]
1396        }
1397        .to_error()
1398        .is_some());
1399        assert!(SnapshotVerifyResult::EntityCountMismatch {
1400            expected: 100,
1401            actual: 50
1402        }
1403        .to_error()
1404        .is_some());
1405        assert!(SnapshotVerifyResult::MissingPages { missing: vec![1] }
1406            .to_error()
1407            .is_some());
1408    }
1409
1410    // =========================================================================
1411    // Zero-Length Collection Tests
1412    // =========================================================================
1413
1414    #[test]
1415    fn test_snapshot_entity_empty_data() {
1416        let entity = make_entity(1, vec![]);
1417        assert!(entity.is_valid());
1418        assert!(entity.data.is_empty());
1419    }
1420
1421    #[test]
1422    fn test_snapshot_complete_empty_dag_heads() {
1423        let complete = SnapshotComplete::new([1; 32], 100, 1, 1000);
1424        assert!(complete.dag_heads.is_empty());
1425        assert!(complete.is_valid());
1426    }
1427
1428    #[test]
1429    fn test_snapshot_boundary_response_empty_dag_heads() {
1430        let response = SnapshotBoundaryResponse {
1431            boundary_timestamp: 12345,
1432            boundary_root_hash: Hash::default(),
1433            dag_heads: vec![],
1434        };
1435        assert!(response.is_valid());
1436    }
1437
1438    #[test]
1439    fn test_snapshot_verify_result_missing_pages_empty() {
1440        // Empty missing pages list
1441        let result = SnapshotVerifyResult::MissingPages { missing: vec![] };
1442        assert!(!result.is_valid()); // Still invalid even with empty list
1443        assert!(result.to_error().is_some());
1444    }
1445
1446    // =========================================================================
1447    // Invariant Enforcement Tests
1448    // =========================================================================
1449
1450    #[test]
1451    fn test_invariant_i5_snapshot_safety() {
1452        // I5: Snapshot ONLY for fresh nodes
1453
1454        // Fresh node - allowed
1455        assert!(check_snapshot_safety(false).is_ok());
1456
1457        // Initialized node - rejected with specific error
1458        let err = check_snapshot_safety(true).unwrap_err();
1459        assert!(matches!(err, SnapshotError::SnapshotOnInitializedNode));
1460    }
1461
1462    #[test]
1463    fn test_invariant_i7_verification_errors() {
1464        // I7: Root hash verification required
1465
1466        // Hash mismatch should produce RootHashMismatch error
1467        let result = SnapshotVerifyResult::RootHashMismatch {
1468            expected: [1; 32],
1469            computed: [2; 32],
1470        };
1471        let error = result.to_error().unwrap();
1472        match error {
1473            SnapshotError::RootHashMismatch { expected, computed } => {
1474                assert_eq!(expected, [1; 32]);
1475                assert_eq!(computed, [2; 32]);
1476            }
1477            _ => panic!("Expected RootHashMismatch error"),
1478        }
1479    }
1480
1481    #[test]
1482    fn test_snapshot_error_transfer_interrupted_preserves_count() {
1483        let error = SnapshotError::TransferInterrupted { pages_received: 42 };
1484        let encoded = borsh::to_vec(&error).expect("serialize");
1485        let decoded: SnapshotError = borsh::from_slice(&encoded).expect("deserialize");
1486
1487        match decoded {
1488            SnapshotError::TransferInterrupted { pages_received } => {
1489                assert_eq!(pages_received, 42);
1490            }
1491            _ => panic!("Expected TransferInterrupted"),
1492        }
1493    }
1494
1495    #[test]
1496    fn test_snapshot_cursor_roundtrip() {
1497        let cursor = SnapshotCursor {
1498            last_key: [0xAB; 32],
1499        };
1500
1501        let encoded = borsh::to_vec(&cursor).expect("serialize");
1502        let decoded: SnapshotCursor = borsh::from_slice(&encoded).expect("deserialize");
1503
1504        assert_eq!(cursor, decoded);
1505        assert_eq!(decoded.last_key, [0xAB; 32]);
1506    }
1507
1508    #[test]
1509    fn test_snapshot_boundary_request_roundtrip() {
1510        let request = SnapshotBoundaryRequest {
1511            context_id: ContextId::zero(),
1512            requested_cutoff_timestamp: Some(1234567890),
1513        };
1514
1515        let encoded = borsh::to_vec(&request).expect("serialize");
1516        let decoded: SnapshotBoundaryRequest = borsh::from_slice(&encoded).expect("deserialize");
1517
1518        assert_eq!(request, decoded);
1519        assert_eq!(decoded.requested_cutoff_timestamp, Some(1234567890));
1520
1521        // Also test with None
1522        let request_none = SnapshotBoundaryRequest {
1523            context_id: ContextId::zero(),
1524            requested_cutoff_timestamp: None,
1525        };
1526        let encoded = borsh::to_vec(&request_none).expect("serialize");
1527        let decoded: SnapshotBoundaryRequest = borsh::from_slice(&encoded).expect("deserialize");
1528        assert_eq!(request_none, decoded);
1529        assert!(decoded.requested_cutoff_timestamp.is_none());
1530    }
1531}