Skip to main content

ethrex_storage/
journal.rs

1//! # State-history journal
2//!
3//! Per-block reverse-diff entries persisted to disk so reorgs deeper than the
4//! in-memory `TrieLayerCache` become possible up to the finalized boundary.
5//!
6//! Each entry captures the previous on-disk values (or absence markers) for every
7//! account-trie node, storage-trie node, account flat-key-value, and storage
8//! flat-key-value path that a single layer commit overwrites. Codes are
9//! content-addressed and not journaled.
10//!
11//! Entries are keyed by `block_number.to_be_bytes()` in the
12//! [`STATE_HISTORY`](crate::api::tables::STATE_HISTORY) column family. Big-endian
13//! ensures lexicographic order matches numeric order, which lets finality
14//! pruning use a single `delete_range`.
15//!
16//! ## Pruning model
17//!
18//! When a `forkchoice_update` advances the finalized block, `forkchoice_update_inner`
19//! calls `delete_range(STATE_HISTORY, 0, finalized_number + 1)`, removing all journal
20//! entries at or below the new finality boundary. The surviving entries cover
21//! `[finalized_number+1, cache_edge_D]`, which is exactly the window a future
22//! deep reorg could need. After pruning, `Store::lowest_state_history_block_number`
23//! reflects the new floor.
24//!
25//! ## Batch mode (full sync)
26//!
27//! When `batch_mode == true` (full sync), the commit path skips journaling
28//! entirely. A full-sync import writes one layer per ~1024 blocks and does
29//! not produce the per-block reverse-diffs needed for deep reorgs. Reorg
30//! support is only active after the node transitions to normal block-by-block
31//! execution.
32//!
33//! ## Codec
34//!
35//! Entries use a hand-rolled compact format: a version byte at offset 0, then
36//! `block_hash` (32 bytes), `parent_state_root` (32 bytes), then four
37//! varint-prefixed reverse-diff sections in order: account-trie, storage-trie,
38//! account flat-KV, storage flat-KV. RLP/bincode/postcard are skipped — the
39//! access pattern (write-once, read-on-reorg, large volume) makes encode/decode
40//! cost matter.
41//!
42//! ## Version strategy
43//!
44//! [`JOURNAL_VERSION`] is a single byte at offset 0 of every entry. The decoder
45//! rejects any version other than the current one with
46//! [`JournalDecodeError::VersionMismatch`]. On a codec bump, the journal should
47//! be drained at a finality boundary so the new binary starts with an empty
48//! journal and never encounters old-format entries. A future bump that needs to
49//! keep history across the upgrade should introduce per-version `decode_vN` arms
50//! rather than re-encoding existing entries.
51
52use ethrex_common::H256;
53
54/// Current version of the journal entry codec.
55///
56/// Bumping this constant changes the wire format. The decoder rejects any
57/// other version with [`JournalDecodeError::VersionMismatch`]: a v(N) binary
58/// will refuse to interpret v(N+1) entries (forward safety) and will also
59/// refuse to read v(N-1) entries written by a previous binary (no implicit
60/// fallback). The plan for the rollback consumer (the deep-reorg overlay) is to drain
61/// the journal at a finality boundary on upgrade, so the v(N) journal
62/// starts empty after the bump; a future bump that needs to keep history
63/// across the upgrade should introduce per-version `decode_vN` arms here
64/// rather than re-encoding existing entries.
65pub const JOURNAL_VERSION: u8 = 1;
66
67/// A single reverse-diff entry: `(on_disk_key, previous_value_or_none)`.
68///
69/// `on_disk_key` is the exact key written to its column family — for storage
70/// CFs this includes the nibble-encoded account-hash prefix. `Some(prev)`
71/// means the key existed on disk with `prev` before the commit; `None` means
72/// the key did not exist on disk (i.e., the commit added it, and a rollback
73/// should remove it).
74pub type ReverseDiffEntry = (Vec<u8>, Option<Vec<u8>>);
75
76/// A flat list of reverse-diff entries.
77pub type FlatDiff = Vec<ReverseDiffEntry>;
78
79/// A single reverse-diff entry covering one block's commit.
80///
81/// All four diff sections are flat lists of `(on_disk_key, prev_value)` tuples.
82/// On rollback, each entry can be applied directly to its column family
83/// without further interpretation: `Some(prev)` becomes a `put`, `None`
84/// becomes a `delete`.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct JournalEntry {
87    /// Hash of the block whose commit this entry reverses.
88    pub block_hash: H256,
89    /// Post-state root of the parent block (the state we'd return to on rollback).
90    pub parent_state_root: H256,
91    /// Reverse diff for `ACCOUNT_TRIE_NODES`.
92    pub account_trie_diff: FlatDiff,
93    /// Reverse diff for `STORAGE_TRIE_NODES`. Keys carry the nibble-encoded
94    /// account-hash prefix as written on disk; no separate grouping is needed.
95    pub storage_trie_diff: FlatDiff,
96    /// Reverse diff for `ACCOUNT_FLATKEYVALUE`.
97    pub account_flat_diff: FlatDiff,
98    /// Reverse diff for `STORAGE_FLATKEYVALUE`. Keys carry the nibble-encoded
99    /// account-hash prefix as written on disk.
100    pub storage_flat_diff: FlatDiff,
101}
102
103/// Errors that can occur when decoding a journal entry from disk.
104#[derive(Debug, thiserror::Error, PartialEq, Eq)]
105pub enum JournalDecodeError {
106    #[error("journal entry truncated: expected {expected} more bytes at offset {offset}")]
107    Truncated { offset: usize, expected: usize },
108    #[error("journal entry version mismatch: expected {expected}, found {found}")]
109    VersionMismatch { expected: u8, found: u8 },
110    #[error("journal entry varint overflow at offset {offset}")]
111    VarintOverflow { offset: usize },
112    #[error(
113        "journal entry presence byte invalid: expected 0 or 1, found {found} at offset {offset}"
114    )]
115    InvalidPresenceByte { offset: usize, found: u8 },
116    #[error("journal entry has {trailing} trailing bytes after offset {offset}")]
117    TrailingBytes { offset: usize, trailing: usize },
118    #[error(
119        "journal entry length prefix {claimed} at offset {offset} exceeds remaining bytes {remaining}"
120    )]
121    LengthExceedsRemaining {
122        offset: usize,
123        claimed: u64,
124        remaining: usize,
125    },
126}
127
128impl JournalEntry {
129    /// Encode this entry into its on-disk byte representation.
130    pub fn encode(&self) -> Vec<u8> {
131        let approx = 1
132            + 32
133            + 32
134            + diff_byte_estimate(&self.account_trie_diff)
135            + diff_byte_estimate(&self.storage_trie_diff)
136            + diff_byte_estimate(&self.account_flat_diff)
137            + diff_byte_estimate(&self.storage_flat_diff);
138        let mut out = Vec::with_capacity(approx);
139
140        out.push(JOURNAL_VERSION);
141        out.extend_from_slice(self.block_hash.as_bytes());
142        out.extend_from_slice(self.parent_state_root.as_bytes());
143
144        encode_flat_diff(&mut out, &self.account_trie_diff);
145        encode_flat_diff(&mut out, &self.storage_trie_diff);
146        encode_flat_diff(&mut out, &self.account_flat_diff);
147        encode_flat_diff(&mut out, &self.storage_flat_diff);
148
149        out
150    }
151
152    /// Decode an entry from its on-disk byte representation.
153    ///
154    /// Returns [`JournalDecodeError::VersionMismatch`] if the version byte is
155    /// not [`JOURNAL_VERSION`]. The current binary deliberately refuses to
156    /// interpret entries written by a future codec version rather than silently
157    /// producing a malformed reverse-diff.
158    pub fn decode(bytes: &[u8]) -> Result<Self, JournalDecodeError> {
159        let mut cur = Cursor::new(bytes);
160
161        let version = cur.read_byte()?;
162        if version != JOURNAL_VERSION {
163            return Err(JournalDecodeError::VersionMismatch {
164                expected: JOURNAL_VERSION,
165                found: version,
166            });
167        }
168
169        let block_hash = cur.read_h256()?;
170        let parent_state_root = cur.read_h256()?;
171
172        let account_trie_diff = decode_flat_diff(&mut cur)?;
173        let storage_trie_diff = decode_flat_diff(&mut cur)?;
174        let account_flat_diff = decode_flat_diff(&mut cur)?;
175        let storage_flat_diff = decode_flat_diff(&mut cur)?;
176
177        // Reject trailing bytes: a corrupt or mixed-version record that happens to
178        // have a valid prefix must not be silently treated as valid.
179        if cur.offset != bytes.len() {
180            return Err(JournalDecodeError::TrailingBytes {
181                offset: cur.offset,
182                trailing: bytes.len() - cur.offset,
183            });
184        }
185
186        Ok(Self {
187            block_hash,
188            parent_state_root,
189            account_trie_diff,
190            storage_trie_diff,
191            account_flat_diff,
192            storage_flat_diff,
193        })
194    }
195}
196
197fn encode_varint(out: &mut Vec<u8>, mut value: u64) {
198    while value >= 0x80 {
199        out.push((value as u8 & 0x7f) | 0x80);
200        value >>= 7;
201    }
202    out.push(value as u8);
203}
204
205fn encode_flat_diff(out: &mut Vec<u8>, diff: &[ReverseDiffEntry]) {
206    encode_varint(out, diff.len() as u64);
207    for (path, value) in diff {
208        encode_varint(out, path.len() as u64);
209        out.extend_from_slice(path);
210        match value {
211            None => out.push(0),
212            Some(v) => {
213                out.push(1);
214                encode_varint(out, v.len() as u64);
215                out.extend_from_slice(v);
216            }
217        }
218    }
219}
220
221/// Returns the encoded LEB128 length of `value`. 1 byte per 7 bits, with bytes
222/// 1-9 having the continuation bit set.
223fn varint_len(value: u64) -> usize {
224    let bits = 64 - value.leading_zeros() as usize;
225    bits.div_ceil(7).max(1)
226}
227
228fn diff_byte_estimate(diff: &[ReverseDiffEntry]) -> usize {
229    // varint(path_len) + path + presence_byte + (value_section if Some).
230    // value_section = varint(value_len) + value.
231    diff.iter()
232        .map(|(p, v)| {
233            varint_len(p.len() as u64)
234                + p.len()
235                + 1
236                + v.as_ref()
237                    .map_or(0, |v| varint_len(v.len() as u64) + v.len())
238        })
239        .sum::<usize>()
240        + varint_len(diff.len() as u64)
241}
242
243struct Cursor<'a> {
244    bytes: &'a [u8],
245    offset: usize,
246}
247
248impl<'a> Cursor<'a> {
249    fn new(bytes: &'a [u8]) -> Self {
250        Self { bytes, offset: 0 }
251    }
252
253    fn read_byte(&mut self) -> Result<u8, JournalDecodeError> {
254        if self.offset >= self.bytes.len() {
255            return Err(JournalDecodeError::Truncated {
256                offset: self.offset,
257                expected: 1,
258            });
259        }
260        let b = self.bytes[self.offset];
261        self.offset += 1;
262        Ok(b)
263    }
264
265    fn read_slice(&mut self, n: usize) -> Result<&'a [u8], JournalDecodeError> {
266        // Saturating form: explicit even though `offset <= bytes.len()` is an
267        // invariant maintained by `read_byte` / `read_slice` themselves.
268        let remaining = self.bytes.len().saturating_sub(self.offset);
269        if remaining < n {
270            return Err(JournalDecodeError::Truncated {
271                offset: self.offset,
272                expected: n,
273            });
274        }
275        let s = &self.bytes[self.offset..self.offset + n];
276        self.offset += n;
277        Ok(s)
278    }
279
280    /// Returns the number of unread bytes in the cursor.
281    fn remaining(&self) -> usize {
282        self.bytes.len().saturating_sub(self.offset)
283    }
284
285    fn read_h256(&mut self) -> Result<H256, JournalDecodeError> {
286        let s = self.read_slice(32)?;
287        Ok(H256::from_slice(s))
288    }
289
290    fn read_varint(&mut self) -> Result<u64, JournalDecodeError> {
291        let mut result: u64 = 0;
292        let mut shift: u32 = 0;
293        loop {
294            let b = self.read_byte()?;
295            // Maximum 10 bytes for u64 LEB128 (10 * 7 = 70 > 64). Reject the
296            // 11th byte unconditionally.
297            if shift >= 64 {
298                return Err(JournalDecodeError::VarintOverflow {
299                    offset: self.offset - 1,
300                });
301            }
302            // At shift==63 only bit 0 of the final byte fits into a u64; bits 1-6
303            // would shift past position 63 and be silently dropped. A continuation
304            // bit at this point is also invalid: a u64 LEB128 is at most 10 bytes.
305            if shift == 63 && (b & 0x7e != 0 || b & 0x80 != 0) {
306                return Err(JournalDecodeError::VarintOverflow {
307                    offset: self.offset - 1,
308                });
309            }
310            result |= ((b & 0x7f) as u64) << shift;
311            if b & 0x80 == 0 {
312                return Ok(result);
313            }
314            shift += 7;
315        }
316    }
317}
318
319/// Smallest possible per-entry overhead in bytes: `varint(path_len=0)` + 0 path
320/// bytes + 1 presence byte. Used to bound `Vec::with_capacity(count)` against the
321/// actual payload size so a corrupt count prefix can't trigger OOM.
322const MIN_ENTRY_BYTES: usize = 2;
323
324fn decode_flat_diff(cur: &mut Cursor<'_>) -> Result<FlatDiff, JournalDecodeError> {
325    let count_offset = cur.offset;
326    let count_u64 = cur.read_varint()?;
327    let remaining = cur.remaining();
328    // Each entry needs at least MIN_ENTRY_BYTES of payload. Reject a count that
329    // can't possibly fit in the remaining buffer ; otherwise `Vec::with_capacity`
330    // could request near-`usize::MAX` and panic with OOM.
331    let max_possible = remaining / MIN_ENTRY_BYTES;
332    if count_u64 as usize > max_possible {
333        return Err(JournalDecodeError::LengthExceedsRemaining {
334            offset: count_offset,
335            claimed: count_u64,
336            remaining,
337        });
338    }
339    let count = count_u64 as usize;
340    let mut out = Vec::with_capacity(count);
341    for _ in 0..count {
342        let path_len_offset = cur.offset;
343        let path_len_u64 = cur.read_varint()?;
344        // Guard the path-length allocation against malformed input the same way.
345        let remaining = cur.remaining();
346        if path_len_u64 > remaining as u64 {
347            return Err(JournalDecodeError::LengthExceedsRemaining {
348                offset: path_len_offset,
349                claimed: path_len_u64,
350                remaining,
351            });
352        }
353        let path_len = path_len_u64 as usize;
354        let path = cur.read_slice(path_len)?.to_vec();
355        let presence_offset = cur.offset;
356        let presence = cur.read_byte()?;
357        let value = match presence {
358            0 => None,
359            1 => {
360                let value_len_offset = cur.offset;
361                let value_len_u64 = cur.read_varint()?;
362                let remaining = cur.remaining();
363                if value_len_u64 > remaining as u64 {
364                    return Err(JournalDecodeError::LengthExceedsRemaining {
365                        offset: value_len_offset,
366                        claimed: value_len_u64,
367                        remaining,
368                    });
369                }
370                let value_len = value_len_u64 as usize;
371                Some(cur.read_slice(value_len)?.to_vec())
372            }
373            other => {
374                return Err(JournalDecodeError::InvalidPresenceByte {
375                    offset: presence_offset,
376                    found: other,
377                });
378            }
379        };
380        out.push((path, value));
381    }
382    Ok(out)
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    fn h(b: u8) -> H256 {
390        H256::repeat_byte(b)
391    }
392
393    fn round_trip(entry: &JournalEntry) {
394        let bytes = entry.encode();
395        let decoded = JournalEntry::decode(&bytes).unwrap();
396        assert_eq!(&decoded, entry);
397    }
398
399    #[test]
400    fn empty_entry_round_trips() {
401        let entry = JournalEntry {
402            block_hash: h(0xaa),
403            parent_state_root: h(0xbb),
404            account_trie_diff: vec![],
405            storage_trie_diff: vec![],
406            account_flat_diff: vec![],
407            storage_flat_diff: vec![],
408        };
409        round_trip(&entry);
410        // 1 (version) + 32 + 32 + 1 (count=0) * 4 = 69 bytes.
411        assert_eq!(entry.encode().len(), 69);
412    }
413
414    #[test]
415    fn typical_entry_round_trips() {
416        let entry = JournalEntry {
417            block_hash: h(0x11),
418            parent_state_root: h(0x22),
419            account_trie_diff: vec![
420                (vec![0x00, 0x01], Some(vec![0xde, 0xad, 0xbe, 0xef])),
421                (vec![0x02], None),
422            ],
423            storage_trie_diff: vec![(vec![0x0a; 67], Some(vec![0xff])), (vec![0x0b; 68], None)],
424            account_flat_diff: vec![(vec![0xaa; 65], Some(vec![0x01, 0x02, 0x03]))],
425            storage_flat_diff: vec![(vec![0xbb; 131], None)],
426        };
427        round_trip(&entry);
428    }
429
430    #[test]
431    fn entry_with_only_absences_round_trips() {
432        let entry = JournalEntry {
433            block_hash: h(0x55),
434            parent_state_root: h(0x66),
435            account_trie_diff: vec![(vec![0x00], None), (vec![0x01], None), (vec![0x02], None)],
436            storage_trie_diff: vec![],
437            account_flat_diff: vec![(vec![0xaa; 32], None)],
438            storage_flat_diff: vec![],
439        };
440        round_trip(&entry);
441    }
442
443    #[test]
444    fn large_entry_round_trips() {
445        let mut account_trie_diff = Vec::with_capacity(10_000);
446        for i in 0u32..10_000 {
447            let path = i.to_be_bytes().to_vec();
448            let value = if i % 7 == 0 {
449                None
450            } else {
451                Some(vec![(i & 0xff) as u8; (i % 200) as usize])
452            };
453            account_trie_diff.push((path, value));
454        }
455        let entry = JournalEntry {
456            block_hash: h(0xee),
457            parent_state_root: h(0xff),
458            account_trie_diff,
459            storage_trie_diff: vec![],
460            account_flat_diff: vec![],
461            storage_flat_diff: vec![],
462        };
463        round_trip(&entry);
464    }
465
466    #[test]
467    fn rejects_unknown_version() {
468        let mut bytes = vec![0xff];
469        bytes.extend_from_slice(&[0; 32]);
470        bytes.extend_from_slice(&[0; 32]);
471        bytes.extend_from_slice(&[0, 0, 0, 0]);
472        let err = JournalEntry::decode(&bytes).unwrap_err();
473        assert_eq!(
474            err,
475            JournalDecodeError::VersionMismatch {
476                expected: JOURNAL_VERSION,
477                found: 0xff,
478            }
479        );
480    }
481
482    #[test]
483    fn rejects_truncated_input() {
484        let entry = JournalEntry {
485            block_hash: h(0x77),
486            parent_state_root: h(0x88),
487            account_trie_diff: vec![(vec![0x00], Some(vec![0xff]))],
488            storage_trie_diff: vec![],
489            account_flat_diff: vec![],
490            storage_flat_diff: vec![],
491        };
492        let bytes = entry.encode();
493        let err = JournalEntry::decode(&bytes[..bytes.len() - 1]).unwrap_err();
494        assert!(matches!(err, JournalDecodeError::Truncated { .. }));
495    }
496
497    #[test]
498    fn rejects_invalid_presence_byte() {
499        let mut bytes = Vec::new();
500        bytes.push(JOURNAL_VERSION);
501        bytes.extend_from_slice(&[0; 32]);
502        bytes.extend_from_slice(&[0; 32]);
503        bytes.push(1); // account_trie_diff count = 1
504        bytes.push(1); // path_len = 1
505        bytes.push(0xab); // path
506        bytes.push(2); // presence = 2 (invalid)
507        let err = JournalEntry::decode(&bytes).unwrap_err();
508        assert!(matches!(
509            err,
510            JournalDecodeError::InvalidPresenceByte { found: 2, .. }
511        ));
512    }
513
514    #[test]
515    fn varint_round_trip() {
516        for &v in &[0u64, 1, 127, 128, 16_383, 16_384, u32::MAX as u64, u64::MAX] {
517            let mut buf = Vec::new();
518            encode_varint(&mut buf, v);
519            let mut cur = Cursor::new(&buf);
520            assert_eq!(cur.read_varint().unwrap(), v);
521        }
522    }
523
524    /// At shift==63 only bit 0 of the 10th byte fits in a u64. A 10-byte LEB128
525    /// where the 10th byte has bits 1-6 set encodes a value > u64::MAX; the
526    /// decoder must reject it rather than silently truncate the extra bits.
527    #[test]
528    fn rejects_varint_with_truncating_10th_byte() {
529        // 9 continuation bytes carrying zero data + 10th byte 0x7e (bits 1-5 set,
530        // no continuation). Without the guard, this decodes the same as 0x00 at
531        // shift==63 because the shifted bits fall outside the u64.
532        let mut buf = vec![0x80; 9];
533        buf.push(0x7e);
534        let mut cur = Cursor::new(&buf);
535        let err = cur.read_varint().unwrap_err();
536        assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
537    }
538
539    /// An 11th byte must be rejected regardless of its bits.
540    #[test]
541    fn rejects_varint_with_11th_byte() {
542        let mut buf = vec![0x80; 10];
543        buf.push(0x00);
544        let mut cur = Cursor::new(&buf);
545        let err = cur.read_varint().unwrap_err();
546        assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
547    }
548
549    /// A 10th byte with the continuation bit set claims an 11th byte and must
550    /// be rejected even if its data bits are valid.
551    #[test]
552    fn rejects_varint_with_continuation_at_byte_10() {
553        let mut buf = vec![0x80; 9];
554        buf.push(0x81); // bit 0 set + continuation
555        let mut cur = Cursor::new(&buf);
556        let err = cur.read_varint().unwrap_err();
557        assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
558    }
559
560    /// u64::MAX must still round-trip after the tightened decoder (its 10-byte
561    /// LEB128 has 10th byte 0x01, which has zero in bits 1-6 and no continuation).
562    #[test]
563    fn u64_max_round_trips_after_tighter_decoder() {
564        let mut buf = Vec::new();
565        encode_varint(&mut buf, u64::MAX);
566        assert_eq!(buf.len(), 10);
567        let mut cur = Cursor::new(&buf);
568        assert_eq!(cur.read_varint().unwrap(), u64::MAX);
569    }
570
571    /// A corrupt count prefix (e.g. u64::MAX) must NOT cause a near-`usize::MAX`
572    /// allocation. We expect `LengthExceedsRemaining` before any vec is allocated.
573    #[test]
574    fn rejects_oom_via_malformed_count() {
575        // Manually craft a payload: version + 32B block_hash + 32B parent_state_root
576        // + account_trie_diff count = u64::MAX (10-byte LEB128). The remaining
577        // payload is too small to hold that many entries.
578        let mut bytes = vec![JOURNAL_VERSION];
579        bytes.extend_from_slice(&[0; 32]);
580        bytes.extend_from_slice(&[0; 32]);
581        encode_varint(&mut bytes, u64::MAX);
582        let err = JournalEntry::decode(&bytes).unwrap_err();
583        assert!(
584            matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
585            "expected LengthExceedsRemaining, got {err:?}"
586        );
587    }
588
589    /// A corrupt path-length must NOT cause a near-`usize::MAX` allocation.
590    #[test]
591    fn rejects_oom_via_malformed_path_len() {
592        let mut bytes = vec![JOURNAL_VERSION];
593        bytes.extend_from_slice(&[0; 32]);
594        bytes.extend_from_slice(&[0; 32]);
595        encode_varint(&mut bytes, 1); // count = 1
596        encode_varint(&mut bytes, u64::MAX); // path_len = u64::MAX
597        let err = JournalEntry::decode(&bytes).unwrap_err();
598        assert!(
599            matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
600            "expected LengthExceedsRemaining, got {err:?}"
601        );
602    }
603
604    /// A corrupt value-length must NOT cause a near-`usize::MAX` allocation.
605    #[test]
606    fn rejects_oom_via_malformed_value_len() {
607        let mut bytes = vec![JOURNAL_VERSION];
608        bytes.extend_from_slice(&[0; 32]);
609        bytes.extend_from_slice(&[0; 32]);
610        encode_varint(&mut bytes, 1); // count = 1
611        encode_varint(&mut bytes, 1); // path_len = 1
612        bytes.push(0xaa); // path
613        bytes.push(1); // presence = 1
614        encode_varint(&mut bytes, u64::MAX); // value_len = u64::MAX
615        let err = JournalEntry::decode(&bytes).unwrap_err();
616        assert!(
617            matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
618            "expected LengthExceedsRemaining, got {err:?}"
619        );
620    }
621
622    /// Trailing bytes after a valid prefix must be rejected. A corrupt or
623    /// mixed-version record could otherwise be silently treated as valid.
624    #[test]
625    fn rejects_trailing_bytes() {
626        let entry = JournalEntry {
627            block_hash: h(0xaa),
628            parent_state_root: h(0xbb),
629            account_trie_diff: vec![],
630            storage_trie_diff: vec![],
631            account_flat_diff: vec![],
632            storage_flat_diff: vec![],
633        };
634        let mut bytes = entry.encode();
635        bytes.push(0xff); // unexpected trailing byte
636        let err = JournalEntry::decode(&bytes).unwrap_err();
637        match err {
638            JournalDecodeError::TrailingBytes { trailing, .. } => assert_eq!(trailing, 1),
639            other => panic!("expected TrailingBytes, got {other:?}"),
640        }
641    }
642
643    /// Round-trip property: any structurally-valid `JournalEntry` must decode
644    /// back to itself after `encode`. Complements the hand-written cases above
645    /// by covering shapes the author did not think to enumerate (empty paths,
646    /// large counts, mixed presence patterns, paths/values that straddle the
647    /// varint width boundary, etc.).
648    #[test]
649    fn proptest_encode_decode_round_trip() {
650        use proptest::collection::vec;
651        use proptest::prelude::*;
652        let entry_strategy = (
653            any::<[u8; 32]>(),
654            any::<[u8; 32]>(),
655            vec(flat_diff_entry(), 0..16),
656            vec(flat_diff_entry(), 0..16),
657            vec(flat_diff_entry(), 0..16),
658            vec(flat_diff_entry(), 0..16),
659        )
660            .prop_map(|(bh, psr, a, b, c, d)| JournalEntry {
661                block_hash: H256::from(bh),
662                parent_state_root: H256::from(psr),
663                account_trie_diff: a,
664                storage_trie_diff: b,
665                account_flat_diff: c,
666                storage_flat_diff: d,
667            });
668        proptest!(|(entry in entry_strategy)| {
669            let bytes = entry.encode();
670            let decoded = JournalEntry::decode(&bytes).expect("encoded entry must decode");
671            prop_assert_eq!(decoded, entry);
672        });
673    }
674
675    /// Safety property: the decoder must never panic, abort, or hang on
676    /// arbitrary input. Only `Ok` or `Err` outcomes are permitted. This is the
677    /// minimum bar for code that reads possibly-corrupted bytes off disk and
678    /// motivated the OOM / varint-truncation / trailing-bytes hardening above.
679    #[test]
680    fn proptest_decoder_never_panics_on_arbitrary_bytes() {
681        use proptest::collection::vec;
682        use proptest::prelude::*;
683        proptest!(|(bytes in vec(any::<u8>(), 0..1024))| {
684            let _ = JournalEntry::decode(&bytes);
685        });
686    }
687
688    /// Mutation property: flipping a byte in a valid encoding must either
689    /// produce `Err` or decode to a *different* entry. A silent
690    /// "corrupted-but-still-valid" result would be a hole in the decoder's
691    /// validation.
692    #[test]
693    fn proptest_single_byte_mutation_never_silently_accepted() {
694        use proptest::prelude::*;
695        let entry = JournalEntry {
696            block_hash: h(0x42),
697            parent_state_root: h(0x43),
698            account_trie_diff: vec![(vec![0x01, 0x02], Some(vec![0xaa, 0xbb]))],
699            storage_trie_diff: vec![(vec![0x0a; 67], None)],
700            account_flat_diff: vec![(vec![0xcc; 65], Some(vec![0xdd]))],
701            storage_flat_diff: vec![],
702        };
703        let baseline = entry.encode();
704        proptest!(|(idx in 0..baseline.len(), bit in 0u8..8)| {
705            let mut mutated = baseline.clone();
706            mutated[idx] ^= 1 << bit;
707            match JournalEntry::decode(&mutated) {
708                Err(_) => {}
709                Ok(decoded) => prop_assert_ne!(decoded, entry.clone()),
710            }
711        });
712    }
713
714    fn flat_diff_entry() -> impl proptest::strategy::Strategy<Value = ReverseDiffEntry> {
715        use proptest::collection::vec;
716        use proptest::prelude::*;
717        (
718            vec(any::<u8>(), 0..200),
719            proptest::option::of(vec(any::<u8>(), 0..200)),
720        )
721    }
722
723    /// `diff_byte_estimate` must be a lower-bound that matches the actual encoded
724    /// length when paths/values cross varint width boundaries (>= 128 bytes).
725    #[test]
726    fn diff_byte_estimate_handles_large_lengths() {
727        let diff: Vec<ReverseDiffEntry> = vec![
728            (vec![0xaa; 200], Some(vec![0xbb; 300])), // both > 128, 2-byte varints
729            (vec![0xcc; 50], Some(vec![0xdd; 50])),   // < 128, 1-byte varints
730        ];
731        // The estimate is consumed via diff_byte_estimate() in encode()'s
732        // Vec::with_capacity hint. Verify it matches the actual encoded length
733        // for one diff section so reallocations don't fire on the hot path.
734        let mut buf = Vec::new();
735        encode_flat_diff(&mut buf, &diff);
736        assert_eq!(
737            diff_byte_estimate(&diff),
738            buf.len(),
739            "estimate must equal actual encoded length so encode() avoids realloc"
740        );
741    }
742}