Skip to main content

storage/jsonl/
verify.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The one per-line check every Ledger reader applies: the envelope
7//! (version direction, chain, seq), the kind split (typed or ignorable),
8//! and the writer-canonical echo. `JsonlLedger::open`'s tail scan and
9//! `runtime::replay` both walk a ledger through a `LineCheck`, so a line
10//! one of them accepts the other cannot refuse (`crates/storage/spec/Jsonl/Verify.lean` ยง8-1).
11//!
12//! The check holds the chain state only - the previous line's hash and
13//! the seq expected next - never a line, so a reader that feeds it one
14//! line at a time holds one line at a time.
15
16use std::borrow::Cow;
17
18use kernel::consts_external::{LogVersion, readable_log_v};
19use kernel::ledger::chain_hash;
20use kernel::{AxCode, AxError, B3Hash, EventKind, EventRecord, GENESIS_PREV, Seq};
21use serde::Deserialize;
22use serde::de::IntoDeserializer;
23
24/// Chain state between two lines: what the next line must continue.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct LineCheck {
27    prev: B3Hash,
28    expected: Seq,
29}
30
31/// A line that passed: a typed record, or an explicitly ignorable line
32/// from a newer vocabulary that still counts in the chain.
33#[derive(Debug, Clone, PartialEq)]
34pub enum CheckedLine {
35    Known(EventRecord),
36    IgnoredUnknown(Seq),
37}
38
39/// Why a line did not pass, in the order the check asks.
40#[derive(Debug, Clone, PartialEq)]
41pub enum LineFault {
42    /// The bytes do not carry a ledger envelope: torn or foreign bytes.
43    NotALine(String),
44    /// Written by a newer build (`readable_log_v` answers `Ahead`).
45    VersionAhead(u64),
46    /// Below the first version any build wrote.
47    NotAVersion(u64),
48    /// `prev` does not hash the previous line.
49    ChainBreak,
50    /// The seq is not the one the chain expects.
51    SeqGap { found: Seq, expected: Seq },
52    /// An unknown kind without `ig:true`: a newer writer's vocabulary.
53    UnknownKind(String),
54    /// A known kind whose record does not parse, or whose bytes are not
55    /// what the writer would have written.
56    NotCanonical(String),
57    /// The seq space is exhausted after this line.
58    SeqExhausted(AxError),
59}
60
61/// Enough of any line to judge version, chain and kind before a typed
62/// parse. Unknown extra fields pass: this shape reads lines from the future.
63#[derive(Deserialize)]
64struct Envelope<'a> {
65    v: u64,
66    seq: Seq,
67    prev: B3Hash,
68    #[serde(borrow)]
69    kind: Cow<'a, str>,
70    #[serde(default)]
71    ig: bool,
72}
73
74impl LineCheck {
75    /// The state before a ledger's first line.
76    pub fn at_genesis() -> Self {
77        Self::after(GENESIS_PREV, Seq::FIRST)
78    }
79
80    /// The state after a line already verified elsewhere.
81    pub(crate) fn after(prev: B3Hash, expected: Seq) -> Self {
82        Self { prev, expected }
83    }
84
85    /// The seq the next line must carry.
86    pub fn expected(&self) -> Seq {
87        self.expected
88    }
89
90    pub(crate) fn prev(&self) -> B3Hash {
91        self.prev
92    }
93
94    /// Whether `raw` carries a ledger envelope at all: a torn write never
95    /// does, so a line that does is history, whatever else is wrong with it.
96    /// A run of zero bytes is preallocated space a write had not reached,
97    /// not part of a line, so the bytes after the last zero are judged on
98    /// their own (`crates/storage/spec/Jsonl/Preallocate.lean`,
99    /// `zeros_before_a_line_are_not_the_end`).
100    pub(crate) fn carries_envelope(raw: &[u8]) -> bool {
101        raw.rsplit(|byte| *byte == 0)
102            .next()
103            .is_some_and(|after| serde_json::from_slice::<Envelope>(after).is_ok())
104    }
105
106    /// Judge one line (without its `\n`) and, when it passes, advance the
107    /// chain past it. A fault leaves the state where it was.
108    pub fn advance(&mut self, raw: &[u8]) -> Result<CheckedLine, LineFault> {
109        let envelope = readable_envelope(raw)?;
110        if envelope.prev != self.prev {
111            return Err(LineFault::ChainBreak);
112        }
113        if envelope.seq != self.expected {
114            return Err(LineFault::SeqGap {
115                found: envelope.seq,
116                expected: self.expected,
117            });
118        }
119        let checked = classify(envelope, raw)?;
120        self.expected = self.expected.next().map_err(LineFault::SeqExhausted)?;
121        self.prev = chain_hash(raw);
122        Ok(checked)
123    }
124
125    /// Everything `advance` asks of one line except where it sits in the
126    /// chain, for a reader that links the chain from the other end
127    /// (`jsonl::tail`): the line passes or fails here exactly as it
128    /// would going forward.
129    pub(super) fn judge(raw: &[u8]) -> Result<Judged, LineFault> {
130        let envelope = readable_envelope(raw)?;
131        let (seq, prev) = (envelope.seq, envelope.prev);
132        let checked = classify(envelope, raw)?;
133        Ok(Judged { seq, prev, checked })
134    }
135}
136
137/// One line judged on its own: the chain position it claims, and what
138/// it is.
139pub(super) struct Judged {
140    pub(super) seq: Seq,
141    pub(super) prev: B3Hash,
142    pub(super) checked: CheckedLine,
143}
144
145/// Judge one line (without its newline) as a record this build reads,
146/// without the chain: the same answer `LineCheck::advance` gives about
147/// the line's own bytes, for a reader that reached the line through an
148/// index whose fold already walked the chain.
149pub fn read_line(raw: &[u8]) -> Result<CheckedLine, LineFault> {
150    readable_envelope(raw).and_then(|envelope| classify(envelope, raw))
151}
152
153/// The seq a line claims in its envelope, read without judging the rest:
154/// where a reader looks for a line, never whether the line is good, which
155/// the chain check that follows decides.
156pub(crate) fn claimed_seq(raw: &[u8]) -> Option<Seq> {
157    readable_envelope(raw).ok().map(|envelope| envelope.seq)
158}
159
160fn readable_envelope(raw: &[u8]) -> Result<Envelope<'_>, LineFault> {
161    let envelope: Envelope = serde_json::from_slice(raw)
162        .map_err(|e| LineFault::NotALine(format!("not a ledger line: {e}")))?;
163    match readable_log_v(envelope.v) {
164        LogVersion::Current | LogVersion::Older => Ok(envelope),
165        LogVersion::Ahead => Err(LineFault::VersionAhead(envelope.v)),
166        LogVersion::NotAVersion => Err(LineFault::NotAVersion(envelope.v)),
167    }
168}
169
170/// The one rule for what a line of a readable envelope is: a record of a
171/// kind this build knows, an ignorable line of a newer kind, or a fault.
172fn classify(envelope: Envelope<'_>, raw: &[u8]) -> Result<CheckedLine, LineFault> {
173    let known = EventKind::deserialize(
174        IntoDeserializer::<serde::de::value::Error>::into_deserializer(envelope.kind.as_ref()),
175    );
176    match known {
177        Ok(_) => canonical_record(raw).map(CheckedLine::Known),
178        Err(_) if envelope.ig => Ok(CheckedLine::IgnoredUnknown(envelope.seq)),
179        Err(_) => Err(LineFault::UnknownKind(envelope.kind.into_owned())),
180    }
181}
182
183fn canonical_record(raw: &[u8]) -> Result<EventRecord, LineFault> {
184    let record = EventRecord::parse_line(raw)
185        .map_err(|e| LineFault::NotCanonical(format!("typed parse failed: {e}")))?;
186    let echo = record
187        .canonical_line()
188        .map_err(|e| LineFault::NotCanonical(format!("no canonical form: {e}")))?;
189    if echo != raw {
190        return Err(LineFault::NotCanonical(
191            "bytes are not writer-canonical".to_string(),
192        ));
193    }
194    Ok(record)
195}
196
197impl LineFault {
198    /// The refusal a reader that walks a whole ledger gives, naming the
199    /// 1-based line: a newer writer speaks direction
200    /// (`E_LOG_VERSION_UNSUPPORTED`), everything else is storage
201    /// integrity (`E_CAS_CORRUPT`).
202    pub fn into_ax(self, line_no: u64) -> AxError {
203        let newer = |detail: String| {
204            AxError::failure(AxCode::LogVersionUnsupported, "verify ledger", detail).with_recovery(
205                "written by a newer sprawling; replay it with the version that wrote it",
206            )
207        };
208        let corrupt = |violation: String| {
209            AxError::failure(
210                AxCode::CasCorrupt,
211                "verify ledger",
212                format!("line {line_no}"),
213            )
214            .with_recovery(violation)
215        };
216        match self {
217            Self::VersionAhead(v) => newer(format!("line {line_no} carries v{v}")),
218            Self::UnknownKind(kind) => AxError::failure(
219                AxCode::LogVersionUnsupported,
220                "verify ledger",
221                format!("line {line_no} kind `{kind}`"),
222            )
223            .with_recovery(
224                "unknown kind without ig:true means a newer writer; use the sprawling that wrote it",
225            ),
226            Self::NotAVersion(v) => corrupt(format!("impossible v{v}")),
227            Self::NotALine(violation) | Self::NotCanonical(violation) => corrupt(violation),
228            Self::ChainBreak => corrupt("prev does not hash the previous line".to_string()),
229            Self::SeqGap { found, expected } => corrupt(format!(
230                "seq {} where {} was expected",
231                found.value(),
232                expected.value()
233            )),
234            Self::SeqExhausted(source) => source,
235        }
236    }
237}