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    pub(crate) fn carries_envelope(raw: &[u8]) -> bool {
97        serde_json::from_slice::<Envelope>(raw).is_ok()
98    }
99
100    /// Judge one line (without its `\n`) and, when it passes, advance the
101    /// chain past it. A fault leaves the state where it was.
102    pub fn advance(&mut self, raw: &[u8]) -> Result<CheckedLine, LineFault> {
103        let envelope = readable_envelope(raw)?;
104        if envelope.prev != self.prev {
105            return Err(LineFault::ChainBreak);
106        }
107        if envelope.seq != self.expected {
108            return Err(LineFault::SeqGap {
109                found: envelope.seq,
110                expected: self.expected,
111            });
112        }
113        let checked = classify(envelope, raw)?;
114        self.expected = self.expected.next().map_err(LineFault::SeqExhausted)?;
115        self.prev = chain_hash(raw);
116        Ok(checked)
117    }
118
119    /// Everything `advance` asks of one line except where it sits in the
120    /// chain, for a reader that links the chain from the other end
121    /// (`jsonl::tail`): the line passes or fails here exactly as it
122    /// would going forward.
123    pub(super) fn judge(raw: &[u8]) -> Result<Judged, LineFault> {
124        let envelope = readable_envelope(raw)?;
125        let (seq, prev) = (envelope.seq, envelope.prev);
126        let checked = classify(envelope, raw)?;
127        Ok(Judged { seq, prev, checked })
128    }
129}
130
131/// One line judged on its own: the chain position it claims, and what
132/// it is.
133pub(super) struct Judged {
134    pub(super) seq: Seq,
135    pub(super) prev: B3Hash,
136    pub(super) checked: CheckedLine,
137}
138
139/// Judge one line (without its newline) as a record this build reads,
140/// without the chain: the same answer `LineCheck::advance` gives about
141/// the line's own bytes, for a reader that reached the line through an
142/// index whose fold already walked the chain.
143pub fn read_line(raw: &[u8]) -> Result<CheckedLine, LineFault> {
144    readable_envelope(raw).and_then(|envelope| classify(envelope, raw))
145}
146
147/// The seq a line claims in its envelope, read without judging the rest:
148/// where a reader looks for a line, never whether the line is good, which
149/// the chain check that follows decides.
150pub(crate) fn claimed_seq(raw: &[u8]) -> Option<Seq> {
151    readable_envelope(raw).ok().map(|envelope| envelope.seq)
152}
153
154fn readable_envelope(raw: &[u8]) -> Result<Envelope<'_>, LineFault> {
155    let envelope: Envelope = serde_json::from_slice(raw)
156        .map_err(|e| LineFault::NotALine(format!("not a ledger line: {e}")))?;
157    match readable_log_v(envelope.v) {
158        LogVersion::Current | LogVersion::Older => Ok(envelope),
159        LogVersion::Ahead => Err(LineFault::VersionAhead(envelope.v)),
160        LogVersion::NotAVersion => Err(LineFault::NotAVersion(envelope.v)),
161    }
162}
163
164/// The one rule for what a line of a readable envelope is: a record of a
165/// kind this build knows, an ignorable line of a newer kind, or a fault.
166fn classify(envelope: Envelope<'_>, raw: &[u8]) -> Result<CheckedLine, LineFault> {
167    let known = EventKind::deserialize(
168        IntoDeserializer::<serde::de::value::Error>::into_deserializer(envelope.kind.as_ref()),
169    );
170    match known {
171        Ok(_) => canonical_record(raw).map(CheckedLine::Known),
172        Err(_) if envelope.ig => Ok(CheckedLine::IgnoredUnknown(envelope.seq)),
173        Err(_) => Err(LineFault::UnknownKind(envelope.kind.into_owned())),
174    }
175}
176
177fn canonical_record(raw: &[u8]) -> Result<EventRecord, LineFault> {
178    let record = EventRecord::parse_line(raw)
179        .map_err(|e| LineFault::NotCanonical(format!("typed parse failed: {e}")))?;
180    let echo = record
181        .canonical_line()
182        .map_err(|e| LineFault::NotCanonical(format!("no canonical form: {e}")))?;
183    if echo != raw {
184        return Err(LineFault::NotCanonical(
185            "bytes are not writer-canonical".to_string(),
186        ));
187    }
188    Ok(record)
189}
190
191impl LineFault {
192    /// The refusal a reader that walks a whole ledger gives, naming the
193    /// 1-based line: a newer writer speaks direction
194    /// (`E_LOG_VERSION_UNSUPPORTED`), everything else is storage
195    /// integrity (`E_CAS_CORRUPT`).
196    pub fn into_ax(self, line_no: u64) -> AxError {
197        let newer = |detail: String| {
198            AxError::failure(AxCode::LogVersionUnsupported, "verify ledger", detail).with_recovery(
199                "written by a newer sprawling; replay it with the version that wrote it",
200            )
201        };
202        let corrupt = |violation: String| {
203            AxError::failure(
204                AxCode::CasCorrupt,
205                "verify ledger",
206                format!("line {line_no}"),
207            )
208            .with_recovery(violation)
209        };
210        match self {
211            Self::VersionAhead(v) => newer(format!("line {line_no} carries v{v}")),
212            Self::UnknownKind(kind) => AxError::failure(
213                AxCode::LogVersionUnsupported,
214                "verify ledger",
215                format!("line {line_no} kind `{kind}`"),
216            )
217            .with_recovery(
218                "unknown kind without ig:true means a newer writer; use the sprawling that wrote it",
219            ),
220            Self::NotAVersion(v) => corrupt(format!("impossible v{v}")),
221            Self::NotALine(violation) | Self::NotCanonical(violation) => corrupt(violation),
222            Self::ChainBreak => corrupt("prev does not hash the previous line".to_string()),
223            Self::SeqGap { found, expected } => corrupt(format!(
224                "seq {} where {} was expected",
225                found.value(),
226                expected.value()
227            )),
228            Self::SeqExhausted(source) => source,
229        }
230    }
231}