Skip to main content

rvm_memory/
reconstruction.rs

1//! Dormant state reconstruction pipeline (ADR-136).
2//!
3//! Dormant memory is not stored as raw bytes. Instead, it is stored as a
4//! checkpoint snapshot plus a sequence of witness-recorded deltas. To restore
5//! a dormant region to the warm tier, the reconstruction pipeline:
6//!
7//! 1. Loads the checkpoint (compressed with LZ4).
8//! 2. Applies the witness delta log in sequence order.
9//! 3. Validates the final state hash against the expected value.
10//!
11//! ## Compression
12//!
13//! The pipeline uses a simple byte-level compression stub. In production,
14//! this would be backed by `lz4_flex` or a hardware compression engine.
15//! The stub is sufficient for correctness testing.
16//!
17//! ## No-std Compatibility
18//!
19//! All operations work on caller-provided fixed-size buffers. No heap
20//! allocation occurs.
21
22use rvm_types::{OwnedRegionId, RvmError, RvmResult};
23
24/// A checkpoint identifier (references a stored compressed snapshot).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub struct CheckpointId(u64);
27
28impl CheckpointId {
29    /// Create a new checkpoint identifier.
30    #[must_use]
31    pub const fn new(id: u64) -> Self {
32        Self(id)
33    }
34
35    /// Return the raw identifier value.
36    #[must_use]
37    pub const fn as_u64(self) -> u64 {
38        self.0
39    }
40}
41
42/// A single delta entry from the witness log.
43///
44/// Represents a write operation that occurred between the checkpoint
45/// and the current state. Applied in sequence order during reconstruction.
46#[derive(Debug, Clone, Copy)]
47pub struct WitnessDelta {
48    /// Sequence number in the witness log.
49    pub sequence: u64,
50    /// Offset within the region (in bytes) where the write occurred.
51    pub offset: u32,
52    /// Length of the data written (in bytes).
53    pub length: u16,
54    /// FNV-1a hash of the written data for integrity verification.
55    pub data_hash: u64,
56}
57
58/// A compressed checkpoint snapshot.
59///
60/// Contains the compressed region contents at a known-good state,
61/// plus metadata for verification.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct CompressedCheckpoint {
64    /// Checkpoint identifier.
65    pub id: CheckpointId,
66    /// Region this checkpoint belongs to.
67    pub region_id: OwnedRegionId,
68    /// Witness sequence number at checkpoint creation time.
69    pub witness_sequence: u64,
70    /// FNV-1a hash of the uncompressed data.
71    pub uncompressed_hash: u64,
72    /// Size of the uncompressed data in bytes.
73    pub uncompressed_size: u32,
74    /// Size of the compressed data in bytes.
75    pub compressed_size: u32,
76}
77
78/// Result of a reconstruction operation.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct ReconstructionResult {
81    /// The region that was reconstructed.
82    pub region_id: OwnedRegionId,
83    /// Number of bytes in the reconstructed state.
84    pub size_bytes: u32,
85    /// Number of deltas applied.
86    pub deltas_applied: u32,
87    /// Hash of the final reconstructed state.
88    pub final_hash: u64,
89}
90
91/// The reconstruction pipeline.
92///
93/// Orchestrates checkpoint decompression and delta application to
94/// reconstruct dormant memory regions.
95///
96/// `MAX_DELTAS` is the maximum number of witness deltas that can be
97/// buffered during a single reconstruction operation.
98pub struct ReconstructionPipeline<const MAX_DELTAS: usize> {
99    /// Pending deltas to apply during reconstruction.
100    deltas: [Option<WitnessDelta>; MAX_DELTAS],
101    /// Number of deltas currently buffered.
102    delta_count: usize,
103}
104
105impl<const MAX_DELTAS: usize> Default for ReconstructionPipeline<MAX_DELTAS> {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl<const MAX_DELTAS: usize> ReconstructionPipeline<MAX_DELTAS> {
112    /// Sentinel value for empty delta slots.
113    const EMPTY_DELTA: Option<WitnessDelta> = None;
114
115    /// Create a new reconstruction pipeline.
116    #[must_use]
117    pub const fn new() -> Self {
118        Self {
119            deltas: [Self::EMPTY_DELTA; MAX_DELTAS],
120            delta_count: 0,
121        }
122    }
123
124    /// Return the number of buffered deltas.
125    #[must_use]
126    pub const fn delta_count(&self) -> usize {
127        self.delta_count
128    }
129
130    /// Clear all buffered deltas.
131    pub fn clear(&mut self) {
132        self.deltas.fill(None);
133        self.delta_count = 0;
134    }
135
136    /// Add a witness delta to the reconstruction buffer.
137    ///
138    /// Deltas must be added in sequence order.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`RvmError::ResourceLimitExceeded`] if the buffer is full.
143    /// Returns [`RvmError::WitnessChainBroken`] if the delta is out of sequence.
144    pub fn add_delta(&mut self, delta: WitnessDelta) -> RvmResult<()> {
145        if self.delta_count >= MAX_DELTAS {
146            return Err(RvmError::ResourceLimitExceeded);
147        }
148
149        // Verify sequence ordering.
150        if self.delta_count > 0 {
151            if let Some(last) = &self.deltas[self.delta_count - 1] {
152                if delta.sequence <= last.sequence {
153                    return Err(RvmError::WitnessChainBroken);
154                }
155            }
156        }
157
158        self.deltas[self.delta_count] = Some(delta);
159        self.delta_count += 1;
160        Ok(())
161    }
162
163    /// Reconstruct a dormant region from a checkpoint and the buffered deltas.
164    ///
165    /// # Parameters
166    ///
167    /// - `checkpoint`: Metadata about the compressed checkpoint.
168    /// - `compressed_data`: The compressed checkpoint bytes.
169    /// - `output`: Buffer to write the reconstructed region into. Must be
170    ///   at least `checkpoint.uncompressed_size` bytes.
171    /// - `delta_data_fn`: A function that, given a `WitnessDelta`, returns
172    ///   a slice of the delta's data bytes.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`RvmError::CheckpointCorrupted`] if decompression fails, the
177    /// hash does not match, or a delta is out of bounds.
178    /// Returns [`RvmError::ResourceLimitExceeded`] if buffers are too small.
179    /// Returns [`RvmError::WitnessVerificationFailed`] if a delta's data hash
180    /// does not match.
181    pub fn reconstruct<F>(
182        &self,
183        checkpoint: &CompressedCheckpoint,
184        compressed_data: &[u8],
185        output: &mut [u8],
186        delta_data_fn: F,
187    ) -> RvmResult<ReconstructionResult>
188    where
189        F: Fn(&WitnessDelta) -> &[u8],
190    {
191        let uncompressed_size = checkpoint.uncompressed_size as usize;
192
193        // Validate buffer sizes.
194        if compressed_data.len() < checkpoint.compressed_size as usize {
195            return Err(RvmError::CheckpointCorrupted);
196        }
197        if output.len() < uncompressed_size {
198            return Err(RvmError::ResourceLimitExceeded);
199        }
200
201        // Step 1: Decompress the checkpoint into the output buffer.
202        let decompressed_size = decompress(
203            &compressed_data[..checkpoint.compressed_size as usize],
204            &mut output[..uncompressed_size],
205        )?;
206
207        if decompressed_size != uncompressed_size {
208            return Err(RvmError::CheckpointCorrupted);
209        }
210
211        // Step 2: Verify the checkpoint hash.
212        let hash = fnv1a_hash(&output[..uncompressed_size]);
213        if hash != checkpoint.uncompressed_hash {
214            return Err(RvmError::CheckpointCorrupted);
215        }
216
217        // Step 3: Apply deltas in sequence order.
218        let mut deltas_applied = 0u32;
219        for i in 0..self.delta_count {
220            if let Some(delta) = &self.deltas[i] {
221                let data = delta_data_fn(delta);
222
223                // Validate delta bounds.
224                let end = delta.offset as usize + delta.length as usize;
225                if end > uncompressed_size {
226                    return Err(RvmError::CheckpointCorrupted);
227                }
228                if data.len() < delta.length as usize {
229                    return Err(RvmError::CheckpointCorrupted);
230                }
231
232                // Verify delta data integrity.
233                let data_hash = fnv1a_hash(&data[..delta.length as usize]);
234                if data_hash != delta.data_hash {
235                    return Err(RvmError::WitnessVerificationFailed);
236                }
237
238                // Apply the delta.
239                let offset = delta.offset as usize;
240                let length = delta.length as usize;
241                output[offset..offset + length].copy_from_slice(&data[..length]);
242                deltas_applied += 1;
243            }
244        }
245
246        // Compute final hash.
247        let final_hash = fnv1a_hash(&output[..uncompressed_size]);
248
249        #[allow(clippy::cast_possible_truncation)]
250        Ok(ReconstructionResult {
251            region_id: checkpoint.region_id,
252            size_bytes: uncompressed_size as u32,
253            deltas_applied,
254            final_hash,
255        })
256    }
257}
258
259/// Create a compressed checkpoint from raw region data.
260///
261/// # Parameters
262///
263/// - `region_id`: The region being checkpointed.
264/// - `checkpoint_id`: Unique ID for this checkpoint.
265/// - `witness_sequence`: Current witness sequence number.
266/// - `data`: The uncompressed region contents.
267/// - `compressed_out`: Buffer to write compressed data into.
268///
269/// # Returns
270///
271/// A tuple of (`CompressedCheckpoint`, compressed byte count).
272///
273/// # Errors
274///
275/// Returns [`RvmError::ResourceLimitExceeded`] if the data is empty or
276/// the output buffer is too small.
277pub fn create_checkpoint(
278    region_id: OwnedRegionId,
279    checkpoint_id: CheckpointId,
280    witness_sequence: u64,
281    data: &[u8],
282    compressed_out: &mut [u8],
283) -> RvmResult<(CompressedCheckpoint, usize)> {
284    if data.is_empty() {
285        return Err(RvmError::ResourceLimitExceeded);
286    }
287
288    let uncompressed_hash = fnv1a_hash(data);
289    let compressed_size = compress(data, compressed_out)?;
290
291    #[allow(clippy::cast_possible_truncation)]
292    let checkpoint = CompressedCheckpoint {
293        id: checkpoint_id,
294        region_id,
295        witness_sequence,
296        uncompressed_hash,
297        uncompressed_size: data.len() as u32,
298        compressed_size: compressed_size as u32,
299    };
300
301    Ok((checkpoint, compressed_size))
302}
303
304// --- LZ4-style RLE Compression ---
305//
306// A simplified LZ4-inspired compressor for dormant tier data.
307// Uses run-length encoding for zero runs and literal copy for non-zero
308// segments. This provides meaningful compression for memory snapshots
309// (which tend to be zero-heavy) without requiring the full lz4_flex
310// dependency.
311//
312// Format:
313//   [4-byte uncompressed length (LE)]
314//   Sequence of blocks:
315//     Tag byte:
316//       0x00 = Zero run:  next 2 bytes (LE u16) = run length
317//       0x01 = Literal:   next 2 bytes (LE u16) = literal length, then N literal bytes
318//
319// This is a v1 compressor suitable for correctness; a future version
320// may use full LZ4 frame format with match copying.
321
322/// Tag byte for a zero-run block.
323const TAG_ZERO_RUN: u8 = 0x00;
324/// Tag byte for a literal block.
325const TAG_LITERAL: u8 = 0x01;
326
327/// Compress `input` into `output` using simplified RLE compression.
328///
329/// Returns the number of bytes written to `output`.
330fn compress(input: &[u8], output: &mut [u8]) -> RvmResult<usize> {
331    // Minimum output: 4-byte header. Even empty-ish data needs the header.
332    if output.len() < 4 {
333        return Err(RvmError::ResourceLimitExceeded);
334    }
335
336    // Write uncompressed length header.
337    #[allow(clippy::cast_possible_truncation)]
338    let len_bytes = (input.len() as u32).to_le_bytes();
339    output[0..4].copy_from_slice(&len_bytes);
340
341    let mut out_pos = 4;
342    let mut in_pos = 0;
343
344    while in_pos < input.len() {
345        if input[in_pos] == 0 {
346            // Count consecutive zeros.
347            let run_start = in_pos;
348            while in_pos < input.len() && input[in_pos] == 0 && (in_pos - run_start) < 0xFFFF {
349                in_pos += 1;
350            }
351            let run_len = in_pos - run_start;
352
353            // Write zero-run block: tag + u16 length.
354            if out_pos + 3 > output.len() {
355                return Err(RvmError::ResourceLimitExceeded);
356            }
357            output[out_pos] = TAG_ZERO_RUN;
358            #[allow(clippy::cast_possible_truncation)]
359            let rl = (run_len as u16).to_le_bytes();
360            output[out_pos + 1] = rl[0];
361            output[out_pos + 2] = rl[1];
362            out_pos += 3;
363        } else {
364            // Collect non-zero literal bytes.
365            let lit_start = in_pos;
366            while in_pos < input.len() && input[in_pos] != 0 && (in_pos - lit_start) < 0xFFFF {
367                in_pos += 1;
368            }
369            let lit_len = in_pos - lit_start;
370
371            // Write literal block: tag + u16 length + data.
372            if out_pos + 3 + lit_len > output.len() {
373                return Err(RvmError::ResourceLimitExceeded);
374            }
375            output[out_pos] = TAG_LITERAL;
376            #[allow(clippy::cast_possible_truncation)]
377            let ll = (lit_len as u16).to_le_bytes();
378            output[out_pos + 1] = ll[0];
379            output[out_pos + 2] = ll[1];
380            output[out_pos + 3..out_pos + 3 + lit_len]
381                .copy_from_slice(&input[lit_start..lit_start + lit_len]);
382            out_pos += 3 + lit_len;
383        }
384    }
385
386    Ok(out_pos)
387}
388
389/// Decompress `input` into `output`. Returns the number of bytes written.
390fn decompress(input: &[u8], output: &mut [u8]) -> RvmResult<usize> {
391    if input.len() < 4 {
392        return Err(RvmError::CheckpointCorrupted);
393    }
394
395    let mut len_bytes = [0u8; 4];
396    len_bytes.copy_from_slice(&input[0..4]);
397    let uncompressed_len = u32::from_le_bytes(len_bytes) as usize;
398
399    if output.len() < uncompressed_len {
400        return Err(RvmError::ResourceLimitExceeded);
401    }
402
403    let mut in_pos = 4;
404    let mut out_pos = 0;
405
406    while in_pos < input.len() && out_pos < uncompressed_len {
407        if in_pos + 3 > input.len() {
408            return Err(RvmError::CheckpointCorrupted);
409        }
410        let tag = input[in_pos];
411        let block_len = u16::from_le_bytes([input[in_pos + 1], input[in_pos + 2]]) as usize;
412        in_pos += 3;
413
414        match tag {
415            TAG_ZERO_RUN => {
416                if out_pos + block_len > uncompressed_len {
417                    return Err(RvmError::CheckpointCorrupted);
418                }
419                for b in &mut output[out_pos..out_pos + block_len] {
420                    *b = 0;
421                }
422                out_pos += block_len;
423            }
424            TAG_LITERAL => {
425                if in_pos + block_len > input.len() {
426                    return Err(RvmError::CheckpointCorrupted);
427                }
428                if out_pos + block_len > uncompressed_len {
429                    return Err(RvmError::CheckpointCorrupted);
430                }
431                output[out_pos..out_pos + block_len]
432                    .copy_from_slice(&input[in_pos..in_pos + block_len]);
433                in_pos += block_len;
434                out_pos += block_len;
435            }
436            _ => {
437                return Err(RvmError::CheckpointCorrupted);
438            }
439        }
440    }
441
442    if out_pos != uncompressed_len {
443        return Err(RvmError::CheckpointCorrupted);
444    }
445
446    Ok(uncompressed_len)
447}
448
449/// FNV-1a 64-bit hash (same algorithm as `rvm-types`).
450fn fnv1a_hash(data: &[u8]) -> u64 {
451    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
452    const FNV_PRIME: u64 = 0x0100_0000_01b3;
453
454    let mut hash = FNV_OFFSET;
455    for &byte in data {
456        hash ^= u64::from(byte);
457        hash = hash.wrapping_mul(FNV_PRIME);
458    }
459    hash
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn rid(id: u64) -> OwnedRegionId {
467        OwnedRegionId::new(id)
468    }
469
470    #[test]
471    fn compress_decompress_round_trip() {
472        let data = b"Hello, dormant memory reconstruction!";
473        let mut compressed = [0u8; 256];
474        let compressed_len = compress(data, &mut compressed).unwrap();
475        // RLE format: 4-byte header + literal block(3 + data.len()).
476        // All non-zero ASCII text → one literal block.
477        assert_eq!(compressed_len, data.len() + 4 + 3);
478
479        let mut decompressed = [0u8; 256];
480        let decompressed_len =
481            decompress(&compressed[..compressed_len], &mut decompressed).unwrap();
482        assert_eq!(decompressed_len, data.len());
483        assert_eq!(&decompressed[..decompressed_len], data.as_slice());
484    }
485
486    #[test]
487    fn compress_empty_output_fails() {
488        let data = b"data";
489        let mut out = [0u8; 2];
490        assert_eq!(
491            compress(data, &mut out),
492            Err(RvmError::ResourceLimitExceeded)
493        );
494    }
495
496    #[test]
497    fn decompress_truncated_fails() {
498        let input = [0u8; 2]; // Too short for header.
499        let mut out = [0u8; 256];
500        assert_eq!(
501            decompress(&input, &mut out),
502            Err(RvmError::CheckpointCorrupted)
503        );
504    }
505
506    #[test]
507    fn fnv1a_hash_deterministic() {
508        let data = b"test data";
509        let h1 = fnv1a_hash(data);
510        let h2 = fnv1a_hash(data);
511        assert_eq!(h1, h2);
512    }
513
514    #[test]
515    fn fnv1a_hash_different_data() {
516        let h1 = fnv1a_hash(b"alpha");
517        let h2 = fnv1a_hash(b"beta");
518        assert_ne!(h1, h2);
519    }
520
521    #[test]
522    fn checkpoint_creation() {
523        let data = b"region state snapshot";
524        let mut compressed = [0u8; 256];
525        let (ckpt, csize) =
526            create_checkpoint(rid(1), CheckpointId::new(100), 42, data, &mut compressed).unwrap();
527
528        assert_eq!(ckpt.id, CheckpointId::new(100));
529        assert_eq!(ckpt.region_id, rid(1));
530        assert_eq!(ckpt.witness_sequence, 42);
531        assert_eq!(ckpt.uncompressed_size, u32::try_from(data.len()).unwrap());
532        assert_eq!(ckpt.compressed_size, u32::try_from(csize).unwrap());
533        assert_eq!(ckpt.uncompressed_hash, fnv1a_hash(data));
534    }
535
536    #[test]
537    fn checkpoint_empty_data_fails() {
538        let mut compressed = [0u8; 256];
539        assert!(matches!(
540            create_checkpoint(rid(1), CheckpointId::new(1), 0, &[], &mut compressed),
541            Err(RvmError::ResourceLimitExceeded)
542        ));
543    }
544
545    #[test]
546    fn pipeline_no_deltas() {
547        let pipeline = ReconstructionPipeline::<16>::new();
548
549        let data = b"original state";
550        let mut compressed = [0u8; 256];
551        let (ckpt, csize) =
552            create_checkpoint(rid(1), CheckpointId::new(1), 0, data, &mut compressed).unwrap();
553
554        let mut output = [0u8; 256];
555        let result = pipeline
556            .reconstruct(&ckpt, &compressed[..csize], &mut output, |_| &[])
557            .unwrap();
558
559        assert_eq!(result.region_id, rid(1));
560        assert_eq!(result.size_bytes, u32::try_from(data.len()).unwrap());
561        assert_eq!(result.deltas_applied, 0);
562        assert_eq!(&output[..data.len()], data.as_slice());
563    }
564
565    #[test]
566    fn pipeline_with_deltas() {
567        let mut pipeline = ReconstructionPipeline::<16>::new();
568
569        let data = b"Hello, World!!!"; // 15 bytes
570        let mut compressed = [0u8; 256];
571        let (ckpt, csize) =
572            create_checkpoint(rid(1), CheckpointId::new(1), 0, data, &mut compressed).unwrap();
573
574        // Create a delta that overwrites "World" with "Rust!"
575        let patch = b"Rust!";
576        let delta = WitnessDelta {
577            sequence: 1,
578            offset: 7,
579            length: 5,
580            data_hash: fnv1a_hash(patch),
581        };
582        pipeline.add_delta(delta).unwrap();
583
584        let mut output = [0u8; 256];
585        let result = pipeline
586            .reconstruct(&ckpt, &compressed[..csize], &mut output, |_d| {
587                patch.as_slice()
588            })
589            .unwrap();
590
591        assert_eq!(result.deltas_applied, 1);
592        assert_eq!(&output[..15], b"Hello, Rust!!!!");
593    }
594
595    #[test]
596    fn pipeline_multiple_deltas() {
597        static PATCH1: [u8; 4] = [0xAA, 0xAA, 0xAA, 0xAA];
598        static PATCH2: [u8; 4] = [0xBB, 0xBB, 0xBB, 0xBB];
599
600        let mut pipeline = ReconstructionPipeline::<16>::new();
601
602        let data = [0u8; 16];
603        let mut compressed = [0u8; 256];
604        let (ckpt, csize) =
605            create_checkpoint(rid(1), CheckpointId::new(1), 0, &data, &mut compressed).unwrap();
606
607        // Delta 1: write 0xAA at offset 0, length 4.
608        pipeline
609            .add_delta(WitnessDelta {
610                sequence: 1,
611                offset: 0,
612                length: 4,
613                data_hash: fnv1a_hash(&PATCH1),
614            })
615            .unwrap();
616
617        // Delta 2: write 0xBB at offset 8, length 4.
618        pipeline
619            .add_delta(WitnessDelta {
620                sequence: 2,
621                offset: 8,
622                length: 4,
623                data_hash: fnv1a_hash(&PATCH2),
624            })
625            .unwrap();
626
627        let mut output = [0u8; 256];
628        let result = pipeline
629            .reconstruct(&ckpt, &compressed[..csize], &mut output, |d| {
630                // Return data based on sequence number.
631                if d.sequence == 1 {
632                    &PATCH1
633                } else {
634                    &PATCH2
635                }
636            })
637            .unwrap();
638
639        assert_eq!(result.deltas_applied, 2);
640        assert_eq!(&output[0..4], &[0xAA; 4]);
641        assert_eq!(&output[4..8], &[0x00; 4]);
642        assert_eq!(&output[8..12], &[0xBB; 4]);
643        assert_eq!(&output[12..16], &[0x00; 4]);
644    }
645
646    #[test]
647    fn pipeline_out_of_order_delta_fails() {
648        let mut pipeline = ReconstructionPipeline::<16>::new();
649        pipeline
650            .add_delta(WitnessDelta {
651                sequence: 5,
652                offset: 0,
653                length: 1,
654                data_hash: 0,
655            })
656            .unwrap();
657        // Adding a delta with sequence <= 5 should fail.
658        assert_eq!(
659            pipeline.add_delta(WitnessDelta {
660                sequence: 3,
661                offset: 0,
662                length: 1,
663                data_hash: 0,
664            }),
665            Err(RvmError::WitnessChainBroken)
666        );
667    }
668
669    #[test]
670    fn pipeline_overflow_fails() {
671        let mut pipeline = ReconstructionPipeline::<2>::new();
672        pipeline
673            .add_delta(WitnessDelta {
674                sequence: 1,
675                offset: 0,
676                length: 1,
677                data_hash: 0,
678            })
679            .unwrap();
680        pipeline
681            .add_delta(WitnessDelta {
682                sequence: 2,
683                offset: 0,
684                length: 1,
685                data_hash: 0,
686            })
687            .unwrap();
688        assert_eq!(
689            pipeline.add_delta(WitnessDelta {
690                sequence: 3,
691                offset: 0,
692                length: 1,
693                data_hash: 0,
694            }),
695            Err(RvmError::ResourceLimitExceeded)
696        );
697    }
698
699    #[test]
700    fn pipeline_clear() {
701        let mut pipeline = ReconstructionPipeline::<4>::new();
702        pipeline
703            .add_delta(WitnessDelta {
704                sequence: 1,
705                offset: 0,
706                length: 1,
707                data_hash: 0,
708            })
709            .unwrap();
710        assert_eq!(pipeline.delta_count(), 1);
711
712        pipeline.clear();
713        assert_eq!(pipeline.delta_count(), 0);
714    }
715
716    #[test]
717    fn reconstruction_corrupted_checkpoint_hash() {
718        let pipeline = ReconstructionPipeline::<16>::new();
719
720        let data = b"valid data";
721        let mut compressed = [0u8; 256];
722        let (mut ckpt, csize) =
723            create_checkpoint(rid(1), CheckpointId::new(1), 0, data, &mut compressed).unwrap();
724
725        // Corrupt the expected hash.
726        ckpt.uncompressed_hash = 0xDEAD_BEEF;
727
728        let mut output = [0u8; 256];
729        assert_eq!(
730            pipeline.reconstruct(&ckpt, &compressed[..csize], &mut output, |_| &[]),
731            Err(RvmError::CheckpointCorrupted)
732        );
733    }
734
735    #[test]
736    fn reconstruction_delta_hash_mismatch() {
737        let mut pipeline = ReconstructionPipeline::<16>::new();
738
739        let data = b"some state";
740        let mut compressed = [0u8; 256];
741        let (ckpt, csize) =
742            create_checkpoint(rid(1), CheckpointId::new(1), 0, data, &mut compressed).unwrap();
743
744        pipeline
745            .add_delta(WitnessDelta {
746                sequence: 1,
747                offset: 0,
748                length: 4,
749                data_hash: 0xBAD_0000, // Wrong hash.
750            })
751            .unwrap();
752
753        let patch = b"good";
754        let mut output = [0u8; 256];
755        assert_eq!(
756            pipeline.reconstruct(&ckpt, &compressed[..csize], &mut output, |_| patch
757                .as_slice()),
758            Err(RvmError::WitnessVerificationFailed)
759        );
760    }
761
762    #[test]
763    fn reconstruction_delta_out_of_bounds() {
764        let mut pipeline = ReconstructionPipeline::<16>::new();
765
766        let data = b"short";
767        let mut compressed = [0u8; 256];
768        let (ckpt, csize) =
769            create_checkpoint(rid(1), CheckpointId::new(1), 0, data, &mut compressed).unwrap();
770
771        let patch = b"overrun!";
772        pipeline
773            .add_delta(WitnessDelta {
774                sequence: 1,
775                offset: 3,
776                length: 8, // Would extend past end of 5-byte region.
777                data_hash: fnv1a_hash(patch),
778            })
779            .unwrap();
780
781        let mut output = [0u8; 256];
782        assert_eq!(
783            pipeline.reconstruct(&ckpt, &compressed[..csize], &mut output, |_| patch
784                .as_slice()),
785            Err(RvmError::CheckpointCorrupted)
786        );
787    }
788
789    #[test]
790    fn checkpoint_id_accessors() {
791        let id = CheckpointId::new(42);
792        assert_eq!(id.as_u64(), 42);
793    }
794
795    // ---------------------------------------------------------------
796    // Reconstruction with maximum delta count
797    // ---------------------------------------------------------------
798
799    #[test]
800    fn reconstruction_at_max_delta_capacity() {
801        static PATCHES: [[u8; 1]; 4] = [[0xAA], [0xBB], [0xCC], [0xDD]];
802        // Pipeline with capacity 4, fill it to max.
803        let mut pipeline = ReconstructionPipeline::<4>::new();
804
805        let data = [0u8; 32];
806        let mut compressed = [0u8; 256];
807        let (ckpt, csize) =
808            create_checkpoint(rid(1), CheckpointId::new(1), 0, &data, &mut compressed).unwrap();
809
810        // Add exactly 4 deltas (each writes 1 byte at a different offset).
811        for (i, patch) in PATCHES.iter().enumerate() {
812            pipeline
813                .add_delta(WitnessDelta {
814                    sequence: u64::try_from(i + 1).unwrap(),
815                    offset: u32::try_from(i * 4).unwrap(),
816                    length: 1,
817                    data_hash: fnv1a_hash(patch),
818                })
819                .unwrap();
820        }
821        assert_eq!(pipeline.delta_count(), 4);
822
823        // Adding one more should fail.
824        assert_eq!(
825            pipeline.add_delta(WitnessDelta {
826                sequence: 5,
827                offset: 20,
828                length: 1,
829                data_hash: 0,
830            }),
831            Err(RvmError::ResourceLimitExceeded)
832        );
833
834        // Reconstruct with all 4 deltas.
835        let mut output = [0u8; 256];
836        let result = pipeline
837            .reconstruct(&ckpt, &compressed[..csize], &mut output, |d| {
838                &PATCHES[usize::try_from(d.sequence - 1).unwrap()]
839            })
840            .unwrap();
841
842        assert_eq!(result.deltas_applied, 4);
843        assert_eq!(output[0], 0xAA);
844        assert_eq!(output[4], 0xBB);
845        assert_eq!(output[8], 0xCC);
846        assert_eq!(output[12], 0xDD);
847    }
848
849    #[test]
850    fn reconstruction_single_delta_capacity() {
851        static PATCH_ZERO: [u8; 1] = [0x00];
852        let mut pipeline = ReconstructionPipeline::<1>::new();
853
854        let data = [0xFF; 8];
855        let mut compressed = [0u8; 64];
856        let (ckpt, csize) =
857            create_checkpoint(rid(1), CheckpointId::new(1), 0, &data, &mut compressed).unwrap();
858        pipeline
859            .add_delta(WitnessDelta {
860                sequence: 1,
861                offset: 0,
862                length: 1,
863                data_hash: fnv1a_hash(&PATCH_ZERO),
864            })
865            .unwrap();
866
867        // Second delta overflows.
868        assert_eq!(
869            pipeline.add_delta(WitnessDelta {
870                sequence: 2,
871                offset: 1,
872                length: 1,
873                data_hash: 0,
874            }),
875            Err(RvmError::ResourceLimitExceeded)
876        );
877
878        let mut output = [0u8; 64];
879        let result = pipeline
880            .reconstruct(&ckpt, &compressed[..csize], &mut output, |_| &PATCH_ZERO)
881            .unwrap();
882        assert_eq!(result.deltas_applied, 1);
883        assert_eq!(output[0], 0x00);
884        assert_eq!(output[1], 0xFF); // Unchanged.
885    }
886
887    #[test]
888    fn reconstruction_clear_allows_reuse() {
889        let mut pipeline = ReconstructionPipeline::<2>::new();
890
891        pipeline
892            .add_delta(WitnessDelta {
893                sequence: 1,
894                offset: 0,
895                length: 1,
896                data_hash: 0,
897            })
898            .unwrap();
899        pipeline
900            .add_delta(WitnessDelta {
901                sequence: 2,
902                offset: 0,
903                length: 1,
904                data_hash: 0,
905            })
906            .unwrap();
907        assert_eq!(pipeline.delta_count(), 2);
908
909        pipeline.clear();
910        assert_eq!(pipeline.delta_count(), 0);
911
912        // Should be able to add 2 more after clear.
913        pipeline
914            .add_delta(WitnessDelta {
915                sequence: 10,
916                offset: 0,
917                length: 1,
918                data_hash: 0,
919            })
920            .unwrap();
921        pipeline
922            .add_delta(WitnessDelta {
923                sequence: 11,
924                offset: 0,
925                length: 1,
926                data_hash: 0,
927            })
928            .unwrap();
929        assert_eq!(pipeline.delta_count(), 2);
930    }
931
932    #[test]
933    fn reconstruction_output_buffer_too_small() {
934        let pipeline = ReconstructionPipeline::<4>::new();
935
936        let data = [0u8; 32];
937        let mut compressed = [0u8; 256];
938        let (ckpt, csize) =
939            create_checkpoint(rid(1), CheckpointId::new(1), 0, &data, &mut compressed).unwrap();
940
941        // Output buffer smaller than uncompressed size.
942        let mut small_output = [0u8; 16];
943        assert_eq!(
944            pipeline.reconstruct(&ckpt, &compressed[..csize], &mut small_output, |_| &[]),
945            Err(RvmError::ResourceLimitExceeded)
946        );
947    }
948
949    #[test]
950    fn reconstruction_compressed_data_truncated() {
951        let pipeline = ReconstructionPipeline::<4>::new();
952
953        let data = [0u8; 32];
954        let mut compressed = [0u8; 256];
955        let (ckpt, _csize) =
956            create_checkpoint(rid(1), CheckpointId::new(1), 0, &data, &mut compressed).unwrap();
957
958        // Pass truncated compressed data.
959        let mut output = [0u8; 256];
960        assert_eq!(
961            pipeline.reconstruct(&ckpt, &compressed[..2], &mut output, |_| &[]),
962            Err(RvmError::CheckpointCorrupted)
963        );
964    }
965
966    #[test]
967    fn reconstruction_delta_data_shorter_than_length() {
968        static SHORT_PATCH: [u8; 2] = [0xAA, 0xBB];
969        let mut pipeline = ReconstructionPipeline::<4>::new();
970
971        let data = [0u8; 16];
972        let mut compressed = [0u8; 256];
973        let (ckpt, csize) =
974            create_checkpoint(rid(1), CheckpointId::new(1), 0, &data, &mut compressed).unwrap();
975
976        // Delta says length=4 but we return only 2 bytes.
977        pipeline
978            .add_delta(WitnessDelta {
979                sequence: 1,
980                offset: 0,
981                length: 4,
982                data_hash: fnv1a_hash(&SHORT_PATCH),
983            })
984            .unwrap();
985
986        let mut output = [0u8; 256];
987        assert_eq!(
988            pipeline.reconstruct(&ckpt, &compressed[..csize], &mut output, |_| &SHORT_PATCH),
989            Err(RvmError::CheckpointCorrupted)
990        );
991    }
992
993    #[test]
994    fn reconstruction_final_hash_changes_with_deltas() {
995        static XPATCH: [u8; 1] = [b'X'];
996        let data = b"original data!!"; // 15 bytes
997        let mut compressed = [0u8; 256];
998        let (ckpt, csize) =
999            create_checkpoint(rid(1), CheckpointId::new(1), 0, data, &mut compressed).unwrap();
1000
1001        // Reconstruct without deltas.
1002        let pipeline_no_deltas = ReconstructionPipeline::<4>::new();
1003        let mut out1 = [0u8; 256];
1004        let r1 = pipeline_no_deltas
1005            .reconstruct(&ckpt, &compressed[..csize], &mut out1, |_| &[])
1006            .unwrap();
1007
1008        // Reconstruct with one delta.
1009        let mut pipeline_with_delta = ReconstructionPipeline::<4>::new();
1010        pipeline_with_delta
1011            .add_delta(WitnessDelta {
1012                sequence: 1,
1013                offset: 0,
1014                length: 1,
1015                data_hash: fnv1a_hash(&XPATCH),
1016            })
1017            .unwrap();
1018        let mut out2 = [0u8; 256];
1019        let r2 = pipeline_with_delta
1020            .reconstruct(&ckpt, &compressed[..csize], &mut out2, |_| &XPATCH)
1021            .unwrap();
1022
1023        // The final hashes should differ.
1024        assert_ne!(r1.final_hash, r2.final_hash);
1025    }
1026
1027    #[test]
1028    fn reconstruction_overlapping_deltas() {
1029        static FIRST: [u8; 2] = [0xAA, 0xAA];
1030        static SECOND: [u8; 2] = [0xBB, 0xBB];
1031        // Two deltas that write to the same offset -- second one wins.
1032        let mut pipeline = ReconstructionPipeline::<4>::new();
1033
1034        let data = [0u8; 8];
1035        let mut compressed = [0u8; 64];
1036        let (ckpt, csize) =
1037            create_checkpoint(rid(1), CheckpointId::new(1), 0, &data, &mut compressed).unwrap();
1038
1039        pipeline
1040            .add_delta(WitnessDelta {
1041                sequence: 1,
1042                offset: 0,
1043                length: 2,
1044                data_hash: fnv1a_hash(&FIRST),
1045            })
1046            .unwrap();
1047        pipeline
1048            .add_delta(WitnessDelta {
1049                sequence: 2,
1050                offset: 0,
1051                length: 2,
1052                data_hash: fnv1a_hash(&SECOND),
1053            })
1054            .unwrap();
1055
1056        let mut output = [0u8; 64];
1057        let result = pipeline
1058            .reconstruct(&ckpt, &compressed[..csize], &mut output, |d| {
1059                if d.sequence == 1 {
1060                    &FIRST
1061                } else {
1062                    &SECOND
1063                }
1064            })
1065            .unwrap();
1066
1067        assert_eq!(result.deltas_applied, 2);
1068        // Second delta overwrites the first.
1069        assert_eq!(&output[0..2], &[0xBB, 0xBB]);
1070    }
1071
1072    // ---------------------------------------------------------------
1073    // RLE compression tests
1074    // ---------------------------------------------------------------
1075
1076    #[test]
1077    fn compress_decompress_rle_round_trip() {
1078        let data = b"Hello, dormant memory reconstruction!";
1079        let mut compressed = [0u8; 256];
1080        let compressed_len = compress(data, &mut compressed).unwrap();
1081
1082        let mut decompressed = [0u8; 256];
1083        let decompressed_len =
1084            decompress(&compressed[..compressed_len], &mut decompressed).unwrap();
1085        assert_eq!(decompressed_len, data.len());
1086        assert_eq!(&decompressed[..decompressed_len], data.as_slice());
1087    }
1088
1089    #[test]
1090    fn compress_zero_heavy_data_achieves_ratio() {
1091        // 1024 bytes of mostly zeros should compress significantly.
1092        let mut data = [0u8; 1024];
1093        // Sprinkle some non-zero bytes.
1094        data[0] = 0xAA;
1095        data[512] = 0xBB;
1096        data[1023] = 0xCC;
1097
1098        let mut compressed = [0u8; 1024];
1099        let compressed_len = compress(&data, &mut compressed).unwrap();
1100
1101        // Should be much smaller than 1024 bytes.
1102        // Header(4) + zero_run(3) for first run of 0s is negligible vs 1024 raw.
1103        assert!(
1104            compressed_len < data.len() / 2,
1105            "compressed {compressed_len} should be less than {}",
1106            data.len() / 2
1107        );
1108
1109        // Round-trip verification.
1110        let mut decompressed = [0u8; 1024];
1111        let decompressed_len =
1112            decompress(&compressed[..compressed_len], &mut decompressed).unwrap();
1113        assert_eq!(decompressed_len, 1024);
1114        assert_eq!(&decompressed[..], &data[..]);
1115    }
1116
1117    #[test]
1118    fn compress_all_zeros() {
1119        let data = [0u8; 512];
1120        let mut compressed = [0u8; 64];
1121        let compressed_len = compress(&data, &mut compressed).unwrap();
1122
1123        // Should be very small: header(4) + one zero-run block(3) = 7 bytes.
1124        assert_eq!(compressed_len, 7);
1125
1126        let mut decompressed = [0u8; 512];
1127        let decompressed_len =
1128            decompress(&compressed[..compressed_len], &mut decompressed).unwrap();
1129        assert_eq!(decompressed_len, 512);
1130        assert_eq!(&decompressed[..], &data[..]);
1131    }
1132
1133    #[test]
1134    fn compress_all_nonzero() {
1135        // All non-zero data should still round-trip, just with no compression gain.
1136        let data = [0xFFu8; 64];
1137        let mut compressed = [0u8; 256];
1138        let compressed_len = compress(&data, &mut compressed).unwrap();
1139
1140        // Header(4) + literal block(3 + 64) = 71 bytes.
1141        assert_eq!(compressed_len, 4 + 3 + 64);
1142
1143        let mut decompressed = [0u8; 64];
1144        let decompressed_len =
1145            decompress(&compressed[..compressed_len], &mut decompressed).unwrap();
1146        assert_eq!(decompressed_len, 64);
1147        assert_eq!(&decompressed[..], &data[..]);
1148    }
1149
1150    #[test]
1151    fn compress_alternating_zero_nonzero() {
1152        // Pattern: [0, 0xAA, 0, 0xBB, 0, 0xCC] -- alternating.
1153        let data = [0, 0xAA, 0, 0xBB, 0, 0xCC];
1154        let mut compressed = [0u8; 128];
1155        let compressed_len = compress(&data, &mut compressed).unwrap();
1156
1157        let mut decompressed = [0u8; 128];
1158        let decompressed_len =
1159            decompress(&compressed[..compressed_len], &mut decompressed).unwrap();
1160        assert_eq!(decompressed_len, data.len());
1161        assert_eq!(&decompressed[..data.len()], &data[..]);
1162    }
1163
1164    #[test]
1165    fn decompress_invalid_tag() {
1166        // Craft invalid compressed data with an unknown tag byte.
1167        let mut bad = [0u8; 16];
1168        // Header: uncompressed length = 4.
1169        bad[0..4].copy_from_slice(&4u32.to_le_bytes());
1170        bad[4] = 0xFF; // Invalid tag.
1171        bad[5] = 4;
1172        bad[6] = 0;
1173
1174        let mut output = [0u8; 16];
1175        assert_eq!(
1176            decompress(&bad[..7], &mut output),
1177            Err(RvmError::CheckpointCorrupted)
1178        );
1179    }
1180
1181    #[test]
1182    fn compress_empty_input_round_trip() {
1183        // Empty input should produce just the 4-byte header.
1184        let data: [u8; 0] = [];
1185        let mut compressed = [0u8; 16];
1186        let compressed_len = compress(&data, &mut compressed).unwrap();
1187        assert_eq!(compressed_len, 4);
1188
1189        let mut decompressed = [0u8; 1];
1190        let decompressed_len =
1191            decompress(&compressed[..compressed_len], &mut decompressed).unwrap();
1192        assert_eq!(decompressed_len, 0);
1193    }
1194}