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    /// Read only the block hash of an entry: the version byte followed by the
153    /// 32-byte hash at offset 1. Skips the reverse diffs, which can run to
154    /// megabytes for a state-heavy block, and does not depend on their
155    /// integrity. Rejects a foreign version byte like [`Self::decode`].
156    pub fn decode_block_hash(bytes: &[u8]) -> Result<H256, JournalDecodeError> {
157        let Some(&version) = bytes.first() else {
158            return Err(JournalDecodeError::Truncated {
159                offset: 0,
160                expected: 1,
161            });
162        };
163        if version != JOURNAL_VERSION {
164            return Err(JournalDecodeError::VersionMismatch {
165                expected: JOURNAL_VERSION,
166                found: version,
167            });
168        }
169        let Some(hash) = bytes.get(1..33) else {
170            return Err(JournalDecodeError::Truncated {
171                offset: 1,
172                expected: 32,
173            });
174        };
175        Ok(H256::from_slice(hash))
176    }
177
178    /// Decode an entry from its on-disk byte representation.
179    ///
180    /// Returns [`JournalDecodeError::VersionMismatch`] if the version byte is
181    /// not [`JOURNAL_VERSION`]. The current binary deliberately refuses to
182    /// interpret entries written by a future codec version rather than silently
183    /// producing a malformed reverse-diff.
184    pub fn decode(bytes: &[u8]) -> Result<Self, JournalDecodeError> {
185        let mut cur = Cursor::new(bytes);
186
187        let version = cur.read_byte()?;
188        if version != JOURNAL_VERSION {
189            return Err(JournalDecodeError::VersionMismatch {
190                expected: JOURNAL_VERSION,
191                found: version,
192            });
193        }
194
195        let block_hash = cur.read_h256()?;
196        let parent_state_root = cur.read_h256()?;
197
198        let account_trie_diff = decode_flat_diff(&mut cur)?;
199        let storage_trie_diff = decode_flat_diff(&mut cur)?;
200        let account_flat_diff = decode_flat_diff(&mut cur)?;
201        let storage_flat_diff = decode_flat_diff(&mut cur)?;
202
203        // Reject trailing bytes: a corrupt or mixed-version record that happens to
204        // have a valid prefix must not be silently treated as valid.
205        if cur.offset != bytes.len() {
206            return Err(JournalDecodeError::TrailingBytes {
207                offset: cur.offset,
208                trailing: bytes.len() - cur.offset,
209            });
210        }
211
212        Ok(Self {
213            block_hash,
214            parent_state_root,
215            account_trie_diff,
216            storage_trie_diff,
217            account_flat_diff,
218            storage_flat_diff,
219        })
220    }
221}
222
223fn encode_varint(out: &mut Vec<u8>, mut value: u64) {
224    while value >= 0x80 {
225        out.push((value as u8 & 0x7f) | 0x80);
226        value >>= 7;
227    }
228    out.push(value as u8);
229}
230
231fn encode_flat_diff(out: &mut Vec<u8>, diff: &[ReverseDiffEntry]) {
232    encode_varint(out, diff.len() as u64);
233    for (path, value) in diff {
234        encode_varint(out, path.len() as u64);
235        out.extend_from_slice(path);
236        match value {
237            None => out.push(0),
238            Some(v) => {
239                out.push(1);
240                encode_varint(out, v.len() as u64);
241                out.extend_from_slice(v);
242            }
243        }
244    }
245}
246
247/// Returns the encoded LEB128 length of `value`. 1 byte per 7 bits, with bytes
248/// 1-9 having the continuation bit set.
249fn varint_len(value: u64) -> usize {
250    let bits = 64 - value.leading_zeros() as usize;
251    bits.div_ceil(7).max(1)
252}
253
254fn diff_byte_estimate(diff: &[ReverseDiffEntry]) -> usize {
255    // varint(path_len) + path + presence_byte + (value_section if Some).
256    // value_section = varint(value_len) + value.
257    diff.iter()
258        .map(|(p, v)| {
259            varint_len(p.len() as u64)
260                + p.len()
261                + 1
262                + v.as_ref()
263                    .map_or(0, |v| varint_len(v.len() as u64) + v.len())
264        })
265        .sum::<usize>()
266        + varint_len(diff.len() as u64)
267}
268
269struct Cursor<'a> {
270    bytes: &'a [u8],
271    offset: usize,
272}
273
274impl<'a> Cursor<'a> {
275    fn new(bytes: &'a [u8]) -> Self {
276        Self { bytes, offset: 0 }
277    }
278
279    fn read_byte(&mut self) -> Result<u8, JournalDecodeError> {
280        if self.offset >= self.bytes.len() {
281            return Err(JournalDecodeError::Truncated {
282                offset: self.offset,
283                expected: 1,
284            });
285        }
286        let b = self.bytes[self.offset];
287        self.offset += 1;
288        Ok(b)
289    }
290
291    fn read_slice(&mut self, n: usize) -> Result<&'a [u8], JournalDecodeError> {
292        // Saturating form: explicit even though `offset <= bytes.len()` is an
293        // invariant maintained by `read_byte` / `read_slice` themselves.
294        let remaining = self.bytes.len().saturating_sub(self.offset);
295        if remaining < n {
296            return Err(JournalDecodeError::Truncated {
297                offset: self.offset,
298                expected: n,
299            });
300        }
301        let s = &self.bytes[self.offset..self.offset + n];
302        self.offset += n;
303        Ok(s)
304    }
305
306    /// Returns the number of unread bytes in the cursor.
307    fn remaining(&self) -> usize {
308        self.bytes.len().saturating_sub(self.offset)
309    }
310
311    fn read_h256(&mut self) -> Result<H256, JournalDecodeError> {
312        let s = self.read_slice(32)?;
313        Ok(H256::from_slice(s))
314    }
315
316    fn read_varint(&mut self) -> Result<u64, JournalDecodeError> {
317        let mut result: u64 = 0;
318        let mut shift: u32 = 0;
319        loop {
320            let b = self.read_byte()?;
321            // Maximum 10 bytes for u64 LEB128 (10 * 7 = 70 > 64). Reject the
322            // 11th byte unconditionally.
323            if shift >= 64 {
324                return Err(JournalDecodeError::VarintOverflow {
325                    offset: self.offset - 1,
326                });
327            }
328            // At shift==63 only bit 0 of the final byte fits into a u64; bits 1-6
329            // would shift past position 63 and be silently dropped. A continuation
330            // bit at this point is also invalid: a u64 LEB128 is at most 10 bytes.
331            if shift == 63 && (b & 0x7e != 0 || b & 0x80 != 0) {
332                return Err(JournalDecodeError::VarintOverflow {
333                    offset: self.offset - 1,
334                });
335            }
336            result |= ((b & 0x7f) as u64) << shift;
337            if b & 0x80 == 0 {
338                return Ok(result);
339            }
340            shift += 7;
341        }
342    }
343}
344
345/// Smallest possible per-entry overhead in bytes: `varint(path_len=0)` + 0 path
346/// bytes + 1 presence byte. Used to bound `Vec::with_capacity(count)` against the
347/// actual payload size so a corrupt count prefix can't trigger OOM.
348const MIN_ENTRY_BYTES: usize = 2;
349
350fn decode_flat_diff(cur: &mut Cursor<'_>) -> Result<FlatDiff, JournalDecodeError> {
351    let count_offset = cur.offset;
352    let count_u64 = cur.read_varint()?;
353    let remaining = cur.remaining();
354    // Each entry needs at least MIN_ENTRY_BYTES of payload. Reject a count that
355    // can't possibly fit in the remaining buffer ; otherwise `Vec::with_capacity`
356    // could request near-`usize::MAX` and panic with OOM.
357    let max_possible = remaining / MIN_ENTRY_BYTES;
358    if count_u64 as usize > max_possible {
359        return Err(JournalDecodeError::LengthExceedsRemaining {
360            offset: count_offset,
361            claimed: count_u64,
362            remaining,
363        });
364    }
365    let count = count_u64 as usize;
366    let mut out = Vec::with_capacity(count);
367    for _ in 0..count {
368        let path_len_offset = cur.offset;
369        let path_len_u64 = cur.read_varint()?;
370        // Guard the path-length allocation against malformed input the same way.
371        let remaining = cur.remaining();
372        if path_len_u64 > remaining as u64 {
373            return Err(JournalDecodeError::LengthExceedsRemaining {
374                offset: path_len_offset,
375                claimed: path_len_u64,
376                remaining,
377            });
378        }
379        let path_len = path_len_u64 as usize;
380        let path = cur.read_slice(path_len)?.to_vec();
381        let presence_offset = cur.offset;
382        let presence = cur.read_byte()?;
383        let value = match presence {
384            0 => None,
385            1 => {
386                let value_len_offset = cur.offset;
387                let value_len_u64 = cur.read_varint()?;
388                let remaining = cur.remaining();
389                if value_len_u64 > remaining as u64 {
390                    return Err(JournalDecodeError::LengthExceedsRemaining {
391                        offset: value_len_offset,
392                        claimed: value_len_u64,
393                        remaining,
394                    });
395                }
396                let value_len = value_len_u64 as usize;
397                Some(cur.read_slice(value_len)?.to_vec())
398            }
399            other => {
400                return Err(JournalDecodeError::InvalidPresenceByte {
401                    offset: presence_offset,
402                    found: other,
403                });
404            }
405        };
406        out.push((path, value));
407    }
408    Ok(out)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn h(b: u8) -> H256 {
416        H256::repeat_byte(b)
417    }
418
419    fn round_trip(entry: &JournalEntry) {
420        let bytes = entry.encode();
421        let decoded = JournalEntry::decode(&bytes).unwrap();
422        assert_eq!(&decoded, entry);
423    }
424
425    #[test]
426    fn empty_entry_round_trips() {
427        let entry = JournalEntry {
428            block_hash: h(0xaa),
429            parent_state_root: h(0xbb),
430            account_trie_diff: vec![],
431            storage_trie_diff: vec![],
432            account_flat_diff: vec![],
433            storage_flat_diff: vec![],
434        };
435        round_trip(&entry);
436        // 1 (version) + 32 + 32 + 1 (count=0) * 4 = 69 bytes.
437        assert_eq!(entry.encode().len(), 69);
438    }
439
440    #[test]
441    fn typical_entry_round_trips() {
442        let entry = JournalEntry {
443            block_hash: h(0x11),
444            parent_state_root: h(0x22),
445            account_trie_diff: vec![
446                (vec![0x00, 0x01], Some(vec![0xde, 0xad, 0xbe, 0xef])),
447                (vec![0x02], None),
448            ],
449            storage_trie_diff: vec![(vec![0x0a; 67], Some(vec![0xff])), (vec![0x0b; 68], None)],
450            account_flat_diff: vec![(vec![0xaa; 65], Some(vec![0x01, 0x02, 0x03]))],
451            storage_flat_diff: vec![(vec![0xbb; 131], None)],
452        };
453        round_trip(&entry);
454    }
455
456    #[test]
457    fn entry_with_only_absences_round_trips() {
458        let entry = JournalEntry {
459            block_hash: h(0x55),
460            parent_state_root: h(0x66),
461            account_trie_diff: vec![(vec![0x00], None), (vec![0x01], None), (vec![0x02], None)],
462            storage_trie_diff: vec![],
463            account_flat_diff: vec![(vec![0xaa; 32], None)],
464            storage_flat_diff: vec![],
465        };
466        round_trip(&entry);
467    }
468
469    #[test]
470    fn large_entry_round_trips() {
471        let mut account_trie_diff = Vec::with_capacity(10_000);
472        for i in 0u32..10_000 {
473            let path = i.to_be_bytes().to_vec();
474            let value = if i % 7 == 0 {
475                None
476            } else {
477                Some(vec![(i & 0xff) as u8; (i % 200) as usize])
478            };
479            account_trie_diff.push((path, value));
480        }
481        let entry = JournalEntry {
482            block_hash: h(0xee),
483            parent_state_root: h(0xff),
484            account_trie_diff,
485            storage_trie_diff: vec![],
486            account_flat_diff: vec![],
487            storage_flat_diff: vec![],
488        };
489        round_trip(&entry);
490    }
491
492    #[test]
493    fn rejects_unknown_version() {
494        let mut bytes = vec![0xff];
495        bytes.extend_from_slice(&[0; 32]);
496        bytes.extend_from_slice(&[0; 32]);
497        bytes.extend_from_slice(&[0, 0, 0, 0]);
498        let err = JournalEntry::decode(&bytes).unwrap_err();
499        assert_eq!(
500            err,
501            JournalDecodeError::VersionMismatch {
502                expected: JOURNAL_VERSION,
503                found: 0xff,
504            }
505        );
506    }
507
508    #[test]
509    fn rejects_truncated_input() {
510        let entry = JournalEntry {
511            block_hash: h(0x77),
512            parent_state_root: h(0x88),
513            account_trie_diff: vec![(vec![0x00], Some(vec![0xff]))],
514            storage_trie_diff: vec![],
515            account_flat_diff: vec![],
516            storage_flat_diff: vec![],
517        };
518        let bytes = entry.encode();
519        let err = JournalEntry::decode(&bytes[..bytes.len() - 1]).unwrap_err();
520        assert!(matches!(err, JournalDecodeError::Truncated { .. }));
521    }
522
523    #[test]
524    fn rejects_invalid_presence_byte() {
525        let mut bytes = Vec::new();
526        bytes.push(JOURNAL_VERSION);
527        bytes.extend_from_slice(&[0; 32]);
528        bytes.extend_from_slice(&[0; 32]);
529        bytes.push(1); // account_trie_diff count = 1
530        bytes.push(1); // path_len = 1
531        bytes.push(0xab); // path
532        bytes.push(2); // presence = 2 (invalid)
533        let err = JournalEntry::decode(&bytes).unwrap_err();
534        assert!(matches!(
535            err,
536            JournalDecodeError::InvalidPresenceByte { found: 2, .. }
537        ));
538    }
539
540    #[test]
541    fn varint_round_trip() {
542        for &v in &[0u64, 1, 127, 128, 16_383, 16_384, u32::MAX as u64, u64::MAX] {
543            let mut buf = Vec::new();
544            encode_varint(&mut buf, v);
545            let mut cur = Cursor::new(&buf);
546            assert_eq!(cur.read_varint().unwrap(), v);
547        }
548    }
549
550    /// At shift==63 only bit 0 of the 10th byte fits in a u64. A 10-byte LEB128
551    /// where the 10th byte has bits 1-6 set encodes a value > u64::MAX; the
552    /// decoder must reject it rather than silently truncate the extra bits.
553    #[test]
554    fn rejects_varint_with_truncating_10th_byte() {
555        // 9 continuation bytes carrying zero data + 10th byte 0x7e (bits 1-5 set,
556        // no continuation). Without the guard, this decodes the same as 0x00 at
557        // shift==63 because the shifted bits fall outside the u64.
558        let mut buf = vec![0x80; 9];
559        buf.push(0x7e);
560        let mut cur = Cursor::new(&buf);
561        let err = cur.read_varint().unwrap_err();
562        assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
563    }
564
565    /// An 11th byte must be rejected regardless of its bits.
566    #[test]
567    fn rejects_varint_with_11th_byte() {
568        let mut buf = vec![0x80; 10];
569        buf.push(0x00);
570        let mut cur = Cursor::new(&buf);
571        let err = cur.read_varint().unwrap_err();
572        assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
573    }
574
575    /// A 10th byte with the continuation bit set claims an 11th byte and must
576    /// be rejected even if its data bits are valid.
577    #[test]
578    fn rejects_varint_with_continuation_at_byte_10() {
579        let mut buf = vec![0x80; 9];
580        buf.push(0x81); // bit 0 set + continuation
581        let mut cur = Cursor::new(&buf);
582        let err = cur.read_varint().unwrap_err();
583        assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
584    }
585
586    /// u64::MAX must still round-trip after the tightened decoder (its 10-byte
587    /// LEB128 has 10th byte 0x01, which has zero in bits 1-6 and no continuation).
588    #[test]
589    fn u64_max_round_trips_after_tighter_decoder() {
590        let mut buf = Vec::new();
591        encode_varint(&mut buf, u64::MAX);
592        assert_eq!(buf.len(), 10);
593        let mut cur = Cursor::new(&buf);
594        assert_eq!(cur.read_varint().unwrap(), u64::MAX);
595    }
596
597    /// A corrupt count prefix (e.g. u64::MAX) must NOT cause a near-`usize::MAX`
598    /// allocation. We expect `LengthExceedsRemaining` before any vec is allocated.
599    #[test]
600    fn rejects_oom_via_malformed_count() {
601        // Manually craft a payload: version + 32B block_hash + 32B parent_state_root
602        // + account_trie_diff count = u64::MAX (10-byte LEB128). The remaining
603        // payload is too small to hold that many entries.
604        let mut bytes = vec![JOURNAL_VERSION];
605        bytes.extend_from_slice(&[0; 32]);
606        bytes.extend_from_slice(&[0; 32]);
607        encode_varint(&mut bytes, u64::MAX);
608        let err = JournalEntry::decode(&bytes).unwrap_err();
609        assert!(
610            matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
611            "expected LengthExceedsRemaining, got {err:?}"
612        );
613    }
614
615    /// A corrupt path-length must NOT cause a near-`usize::MAX` allocation.
616    #[test]
617    fn rejects_oom_via_malformed_path_len() {
618        let mut bytes = vec![JOURNAL_VERSION];
619        bytes.extend_from_slice(&[0; 32]);
620        bytes.extend_from_slice(&[0; 32]);
621        encode_varint(&mut bytes, 1); // count = 1
622        encode_varint(&mut bytes, u64::MAX); // path_len = u64::MAX
623        let err = JournalEntry::decode(&bytes).unwrap_err();
624        assert!(
625            matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
626            "expected LengthExceedsRemaining, got {err:?}"
627        );
628    }
629
630    /// A corrupt value-length must NOT cause a near-`usize::MAX` allocation.
631    #[test]
632    fn rejects_oom_via_malformed_value_len() {
633        let mut bytes = vec![JOURNAL_VERSION];
634        bytes.extend_from_slice(&[0; 32]);
635        bytes.extend_from_slice(&[0; 32]);
636        encode_varint(&mut bytes, 1); // count = 1
637        encode_varint(&mut bytes, 1); // path_len = 1
638        bytes.push(0xaa); // path
639        bytes.push(1); // presence = 1
640        encode_varint(&mut bytes, u64::MAX); // value_len = u64::MAX
641        let err = JournalEntry::decode(&bytes).unwrap_err();
642        assert!(
643            matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
644            "expected LengthExceedsRemaining, got {err:?}"
645        );
646    }
647
648    /// Trailing bytes after a valid prefix must be rejected. A corrupt or
649    /// mixed-version record could otherwise be silently treated as valid.
650    #[test]
651    fn rejects_trailing_bytes() {
652        let entry = JournalEntry {
653            block_hash: h(0xaa),
654            parent_state_root: h(0xbb),
655            account_trie_diff: vec![],
656            storage_trie_diff: vec![],
657            account_flat_diff: vec![],
658            storage_flat_diff: vec![],
659        };
660        let mut bytes = entry.encode();
661        bytes.push(0xff); // unexpected trailing byte
662        let err = JournalEntry::decode(&bytes).unwrap_err();
663        match err {
664            JournalDecodeError::TrailingBytes { trailing, .. } => assert_eq!(trailing, 1),
665            other => panic!("expected TrailingBytes, got {other:?}"),
666        }
667    }
668
669    /// Round-trip property: any structurally-valid `JournalEntry` must decode
670    /// back to itself after `encode`. Complements the hand-written cases above
671    /// by covering shapes the author did not think to enumerate (empty paths,
672    /// large counts, mixed presence patterns, paths/values that straddle the
673    /// varint width boundary, etc.).
674    #[test]
675    fn proptest_encode_decode_round_trip() {
676        use proptest::collection::vec;
677        use proptest::prelude::*;
678        let entry_strategy = (
679            any::<[u8; 32]>(),
680            any::<[u8; 32]>(),
681            vec(flat_diff_entry(), 0..16),
682            vec(flat_diff_entry(), 0..16),
683            vec(flat_diff_entry(), 0..16),
684            vec(flat_diff_entry(), 0..16),
685        )
686            .prop_map(|(bh, psr, a, b, c, d)| JournalEntry {
687                block_hash: H256::from(bh),
688                parent_state_root: H256::from(psr),
689                account_trie_diff: a,
690                storage_trie_diff: b,
691                account_flat_diff: c,
692                storage_flat_diff: d,
693            });
694        proptest!(|(entry in entry_strategy)| {
695            let bytes = entry.encode();
696            let decoded = JournalEntry::decode(&bytes).expect("encoded entry must decode");
697            prop_assert_eq!(decoded, entry);
698        });
699    }
700
701    /// Safety property: the decoder must never panic, abort, or hang on
702    /// arbitrary input. Only `Ok` or `Err` outcomes are permitted. This is the
703    /// minimum bar for code that reads possibly-corrupted bytes off disk and
704    /// motivated the OOM / varint-truncation / trailing-bytes hardening above.
705    #[test]
706    fn proptest_decoder_never_panics_on_arbitrary_bytes() {
707        use proptest::collection::vec;
708        use proptest::prelude::*;
709        proptest!(|(bytes in vec(any::<u8>(), 0..1024))| {
710            let _ = JournalEntry::decode(&bytes);
711        });
712    }
713
714    /// Mutation property: flipping a byte in a valid encoding must either
715    /// produce `Err` or decode to a *different* entry. A silent
716    /// "corrupted-but-still-valid" result would be a hole in the decoder's
717    /// validation.
718    #[test]
719    fn proptest_single_byte_mutation_never_silently_accepted() {
720        use proptest::prelude::*;
721        let entry = JournalEntry {
722            block_hash: h(0x42),
723            parent_state_root: h(0x43),
724            account_trie_diff: vec![(vec![0x01, 0x02], Some(vec![0xaa, 0xbb]))],
725            storage_trie_diff: vec![(vec![0x0a; 67], None)],
726            account_flat_diff: vec![(vec![0xcc; 65], Some(vec![0xdd]))],
727            storage_flat_diff: vec![],
728        };
729        let baseline = entry.encode();
730        proptest!(|(idx in 0..baseline.len(), bit in 0u8..8)| {
731            let mut mutated = baseline.clone();
732            mutated[idx] ^= 1 << bit;
733            match JournalEntry::decode(&mutated) {
734                Err(_) => {}
735                Ok(decoded) => prop_assert_ne!(decoded, entry.clone()),
736            }
737        });
738    }
739
740    fn flat_diff_entry() -> impl proptest::strategy::Strategy<Value = ReverseDiffEntry> {
741        use proptest::collection::vec;
742        use proptest::prelude::*;
743        (
744            vec(any::<u8>(), 0..200),
745            proptest::option::of(vec(any::<u8>(), 0..200)),
746        )
747    }
748
749    /// `diff_byte_estimate` must be a lower-bound that matches the actual encoded
750    /// length when paths/values cross varint width boundaries (>= 128 bytes).
751    #[test]
752    fn diff_byte_estimate_handles_large_lengths() {
753        let diff: Vec<ReverseDiffEntry> = vec![
754            (vec![0xaa; 200], Some(vec![0xbb; 300])), // both > 128, 2-byte varints
755            (vec![0xcc; 50], Some(vec![0xdd; 50])),   // < 128, 1-byte varints
756        ];
757        // The estimate is consumed via diff_byte_estimate() in encode()'s
758        // Vec::with_capacity hint. Verify it matches the actual encoded length
759        // for one diff section so reallocations don't fire on the hot path.
760        let mut buf = Vec::new();
761        encode_flat_diff(&mut buf, &diff);
762        assert_eq!(
763            diff_byte_estimate(&diff),
764            buf.len(),
765            "estimate must equal actual encoded length so encode() avoids realloc"
766        );
767    }
768    #[test]
769    fn decode_block_hash_reads_the_hash_without_the_diffs() {
770        let entry = JournalEntry {
771            block_hash: H256::repeat_byte(0xab),
772            parent_state_root: H256::repeat_byte(0xcd),
773            account_trie_diff: vec![(vec![1, 2, 3], Some(vec![4]))],
774            storage_trie_diff: vec![],
775            account_flat_diff: vec![(vec![9], None)],
776            storage_flat_diff: vec![],
777        };
778        let bytes = entry.encode();
779        assert_eq!(
780            JournalEntry::decode_block_hash(&bytes).unwrap(),
781            H256::repeat_byte(0xab)
782        );
783        // Diffs are not looked at: a truncated tail still yields the hash ...
784        assert_eq!(
785            JournalEntry::decode_block_hash(&bytes[..40]).unwrap(),
786            H256::repeat_byte(0xab)
787        );
788        // ... but a foreign version byte or a record shorter than the hash is rejected.
789        let mut foreign = bytes.clone();
790        foreign[0] = JOURNAL_VERSION + 1;
791        assert!(matches!(
792            JournalEntry::decode_block_hash(&foreign),
793            Err(JournalDecodeError::VersionMismatch { .. })
794        ));
795        assert!(matches!(
796            JournalEntry::decode_block_hash(&bytes[..20]),
797            Err(JournalDecodeError::Truncated { .. })
798        ));
799    }
800}