Skip to main content

blockworx/store/
replay.rs

1//! Replaying `log.jsonl` into a [`Repo`], verifying D12 as it goes.
2//!
3//! Two passes, and the order is the point. The chain is verified first,
4//! over parsed records alone: a rewritten record is detected by its
5//! *successor's* `parent` link, so checking links before folding is what
6//! lets the second pass say something the first cannot — a state stamp
7//! that disagrees under an intact chain is not a tampered file, it is the
8//! app's fold having changed since the record was written.
9//!
10//! The stamps are [`Verify::Sampled`] at load and [`Verify::Full`] under
11//! `blockworx verify`, per D12's reserved escalation: recomputing every
12//! stamp re-serializes the whole document once per record, which cost
13//! 16.6 s of a cold open on a 301-record, 2500-block container
14//! (`TUNING.md`, Finding 7).
15//!
16//! The second pass also rebuilds the undo journal (F6). Each record's
17//! `kind` crosses into the headless spelling here and is handed to
18//! `Repo::replay_one`, which runs the live session's journalling policy —
19//! so the depth a document reopens with is the depth it closed with, and
20//! one fold per record still serves both the stamp check and the journal.
21//!
22//! The second pass is also where a record's artwork is read back out of
23//! the container ([`super::assets`]) — after the chain, which covers the
24//! record as the file holds it, and before the stamp, which then witnesses
25//! the payload independently. See [`replay`].
26//!
27//! Target-independent: the browser replays the same text, and reaches its
28//! payloads through the same [`AssetSource`].
29
30use blockworx_doc::{document::FoldError, repo::Repo, rev::Rev};
31
32use super::assets::{AssetFault, AssetSource};
33use super::notes::Notes;
34use super::record::{Digest, Entry, LogRecord};
35use super::tags::Tags;
36
37/// How many records one verified state stamp stands for under
38/// [`Verify::Sampled`]. Every record is still folded and every chain link
39/// still checked; what this spaces out is `Document::content_hash`, whose
40/// cost is the whole document rather than the record.
41pub const STAMP_SAMPLE: usize = 32;
42
43/// How much of D12 a replay checks. The chain is always checked in full —
44/// it is a hash over one line. The state stamps are what this chooses
45/// between, because each one costs a re-serialization of the entire
46/// document.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum Verify {
49    /// The head's stamp and one in every [`STAMP_SAMPLE`]. The head is
50    /// never skipped: it is the document the session opens on, and fold
51    /// drift that reaches the present reaches it.
52    Sampled,
53    /// Every stamp — the fsck, and what the editor's opens no longer do.
54    Full,
55}
56
57impl Verify {
58    fn checks_stamp(self, at: usize, head: usize) -> bool {
59        match self {
60            Verify::Full => true,
61            Verify::Sampled => at == head || at.is_multiple_of(STAMP_SAMPLE),
62        }
63    }
64}
65
66/// Where in the file something went wrong — enough to render a span-style
67/// report without re-reading it.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub struct Located {
70    /// 1-based.
71    pub line: usize,
72    /// 1-based, in bytes from the start of the line.
73    pub column: usize,
74    /// Byte offset of the line's first byte from the start of the file.
75    pub offset: usize,
76    /// The line's length in bytes, its newline excluded.
77    pub len: usize,
78}
79
80impl std::fmt::Display for Located {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(f, "line {}, column {}", self.line, self.column)
83    }
84}
85
86/// What a record failed on.
87#[derive(Debug)]
88pub enum Fault {
89    Malformed(serde_json::Error),
90    /// D12: the record does not link to the one before it, so the file has
91    /// been rewritten, reordered, or spliced at this point.
92    Chained {
93        expected: Digest,
94        found: Digest,
95    },
96    /// The record claims a position the fold did not assign it.
97    Misnumbered {
98        expected: Rev,
99        found: Rev,
100    },
101    /// The fold refuses a record it once accepted.
102    Refused(FoldError),
103    /// The record names artwork in `assets/` that is not there, or is not
104    /// what its hash says. The chain still checks out — the log is the
105    /// log it always was; what is missing is beside it.
106    Artwork(AssetFault),
107    /// The chain is intact and the record folds, but to a different
108    /// document than the one that wrote it: this is a fold regression in
109    /// the *app*, not damage to the file.
110    Drift {
111        expected: Digest,
112        found: Digest,
113        blame: Blame,
114    },
115    /// D18: a tag record stamps the head it was appended under, and this
116    /// one does not.
117    Mistagged {
118        expected: Digest,
119        found: Digest,
120    },
121}
122
123/// Which record a stamp mismatch may be blamed on. Under
124/// [`Verify::Sampled`] the stamps between two samples were never
125/// recomputed, so the sample that fails is the first *evidence* of the
126/// drift rather than its origin, and the report says so.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum Blame {
129    /// Every stamp before this one verified: the drift is this record.
130    ThisRecord,
131    /// The drift is at or before this record, and after `verified` — the
132    /// last rev whose stamp was recomputed.
133    AtOrBefore { verified: Rev },
134}
135
136impl std::fmt::Display for Fault {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            Fault::Malformed(error) => write!(f, "the record does not parse: {error}"),
140            Fault::Chained { expected, found } => write!(
141                f,
142                "the record links to {found}, but the record before it hashes to {expected} — \
143                 the log has been rewritten here",
144            ),
145            Fault::Misnumbered { expected, found } => {
146                write!(
147                    f,
148                    "the record claims rev {found}, but folds at {expected}",
149                    found = found.get(),
150                    expected = expected.get()
151                )
152            }
153            Fault::Refused(refusal) => write!(f, "the fold refuses the record: {refusal}"),
154            Fault::Artwork(fault) => write!(f, "{fault}"),
155            Fault::Drift {
156                expected,
157                found,
158                blame,
159            } => write!(
160                f,
161                "the record folds to {found} but was written at {expected}; the chain is intact, \
162                 so this build's fold no longer reproduces what wrote the log{blame}",
163            ),
164            Fault::Mistagged { expected, found } => write!(
165                f,
166                "the tag stamps {expected}, but the log it was appended to stands at {found} — \
167                 it was written against a history this is not",
168            ),
169        }
170    }
171}
172
173impl std::fmt::Display for Blame {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self {
176            Blame::ThisRecord => Ok(()),
177            Blame::AtOrBefore { verified } => write!(
178                f,
179                " — the drift is at or before this record, and after rev {verified}, whose stamp \
180                 was the last one recomputed; `blockworx verify` checks every stamp and names it \
181                 exactly",
182                verified = verified.get(),
183            ),
184        }
185    }
186}
187
188/// A fault and where it is. Kept apart from [`Broken`] so a container can
189/// hold on to the report as its read-only reason without also holding the
190/// repo it came with.
191#[derive(Debug)]
192pub struct BreakReport {
193    pub at: Located,
194    pub fault: Fault,
195}
196
197impl std::fmt::Display for BreakReport {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        write!(f, "{}: {}", self.at, self.fault)
200    }
201}
202
203/// A final record the writer never finished. Appends are one `write_all`
204/// of the line and its newline followed by an fsync, so a log that does
205/// not end in a newline ends in a write that was never made durable: the
206/// record is dropped and the file truncated back to the last whole line.
207#[derive(Clone, Copy, Debug)]
208pub struct DroppedTail {
209    pub at: Located,
210    /// Where the whole log ends.
211    pub good_bytes: u64,
212}
213
214/// Whether the log ended where it should have. Not a bool: a dropped tail
215/// is something the user is told about.
216#[derive(Clone, Copy, Debug)]
217pub enum Tail {
218    Whole,
219    Dropped(DroppedTail),
220}
221
222/// A log that verified end to end.
223pub struct Replayed {
224    pub repo: Repo,
225    /// The digest the next appended record must name as its parent.
226    pub head: Digest,
227    pub tail: Tail,
228    /// One per commit in `repo`'s log, in the same order — the audit
229    /// columns the fold itself has no use for.
230    pub entries: Vec<Entry>,
231    /// What the log's tag records name its revs (D18). Never in `repo`.
232    pub tags: Tags,
233    /// What the log's note records say, keyed by the rev each occupies
234    /// (D17). Unlike the tags, these *are* in `repo` — as the empty
235    /// commits they were written as.
236    pub notes: Notes,
237    /// How many records were read, tags included — which `entries` is not,
238    /// since a tag consumes no rev.
239    pub verified: usize,
240}
241
242/// A log that did not. The prefix that *did* verify is still here: a
243/// damaged log has a real past, and the reader is owed a look at it —
244/// which is also why it is returned boxed, since it carries a whole
245/// document and the success path should not widen to hold one.
246pub struct Broken {
247    pub repo: Repo,
248    pub head: Digest,
249    pub report: BreakReport,
250    /// As [`Replayed::entries`], for the verified prefix.
251    pub entries: Vec<Entry>,
252    /// As [`Replayed::tags`], for the verified prefix.
253    pub tags: Tags,
254    /// As [`Replayed::notes`], for the verified prefix.
255    pub notes: Notes,
256    /// As [`Replayed::verified`], for the verified prefix.
257    pub verified: usize,
258}
259
260/// Fold `log` record by record, checking every link and the stamps
261/// `depth` asks for. Artwork the records only reference is read back out
262/// of `assets`.
263///
264/// The three checks nest, and the nesting is what makes each one mean
265/// something. The chain covers the record *as the file holds it* — a
266/// hollowed asset op included — so it is settled by the first pass
267/// before a payload is read. Hydration then rebuilds the ops, verifying each
268/// payload against the hash the record addresses it by. The state stamp
269/// comes last, over the document that fold produced, and was written
270/// against a document folded from the same whole ops — so it is an
271/// independent second witness that the bytes beside the log are the bytes
272/// that wrote it.
273///
274/// # Errors
275/// [`Broken`], carrying the verified prefix as a repo, when an interior
276/// record does not parse, does not link, names artwork that is not there,
277/// does not fold, or does not reproduce the document it was written at.
278pub fn replay(log: &str, depth: Verify, assets: &dyn AssetSource) -> Result<Replayed, Box<Broken>> {
279    let scan = scan(log);
280    let last = scan.records.len().saturating_sub(1);
281    let mut repo = Repo::default();
282    let mut head = Digest::genesis();
283    let mut stamped = Rev::ZERO;
284
285    for (taken, verified) in scan.records.iter().enumerate() {
286        let record = &verified.record;
287        // D18: a tag is about history rather than part of it. It folds
288        // nothing — which is what exempts it from the sequence check — and
289        // claims only that the log stood where it says when it was
290        // appended, checked on the same schedule as its neighbours' stamps.
291        let Some(journal) = record.kind.journals_as() else {
292            if depth.checks_stamp(taken, last) {
293                let state: Digest = repo.document().content_hash().into();
294                if state != record.state {
295                    let fault = Fault::Mistagged {
296                        expected: record.state,
297                        found: state,
298                    };
299                    return Err(broken(
300                        &scan,
301                        assets,
302                        taken,
303                        head,
304                        report(verified.at, fault),
305                    ));
306                }
307            }
308            head = verified.digest;
309            continue;
310        };
311        let commit = match record.commit(assets) {
312            Ok(commit) => commit,
313            Err(fault) => {
314                let report = report(verified.at, Fault::Artwork(fault));
315                return Err(broken(&scan, assets, taken, head, report));
316            }
317        };
318        let document = match repo.replay_one(commit, journal) {
319            Ok(document) => document,
320            Err(refusal) => {
321                let report = report(verified.at, Fault::Refused(refusal));
322                return Err(broken(&scan, assets, taken, head, report));
323            }
324        };
325        if document.rev() != record.rev {
326            let fault = Fault::Misnumbered {
327                expected: document.rev(),
328                found: record.rev,
329            };
330            return Err(broken(
331                &scan,
332                assets,
333                taken,
334                head,
335                report(verified.at, fault),
336            ));
337        }
338        if depth.checks_stamp(taken, last) {
339            let state: Digest = document.content_hash().into();
340            if state != record.state {
341                let fault = Fault::Drift {
342                    expected: record.state,
343                    found: state,
344                    blame: blame(stamped, record.rev),
345                };
346                return Err(broken(
347                    &scan,
348                    assets,
349                    taken,
350                    head,
351                    report(verified.at, fault),
352                ));
353            }
354            stamped = record.rev;
355        }
356        head = verified.digest;
357    }
358
359    let entries = entries(&scan.records);
360    let (tags, notes) = projections(&scan.records);
361    let verified = scan.records.len();
362    match scan.end {
363        End::Whole => Ok(Replayed {
364            repo,
365            head,
366            tail: Tail::Whole,
367            entries,
368            tags,
369            notes,
370            verified,
371        }),
372        End::Truncated(dropped) => Ok(Replayed {
373            repo,
374            head,
375            tail: Tail::Dropped(dropped),
376            entries,
377            tags,
378            notes,
379            verified,
380        }),
381        End::Broken(report) => Err(Box::new(Broken {
382            repo,
383            head,
384            report,
385            entries,
386            tags,
387            notes,
388            verified,
389        })),
390    }
391}
392
393/// The audit columns, one per *commit* — a tag consumes no rev, so it
394/// contributes no entry and the pairing with the repo's log holds.
395fn entries(verified: &[Verified]) -> Vec<Entry> {
396    verified
397        .iter()
398        .filter(|v| v.record.kind.journals_as().is_some())
399        .map(|v| Entry::from(&v.record))
400        .collect()
401}
402
403/// The two projections the fold does not keep: what the tag records name
404/// (D18) and what the note records say (D17). Built from the records
405/// rather than beside the fold, so a whole log and a verified prefix are
406/// projected by the same pass.
407fn projections(verified: &[Verified]) -> (Tags, Notes) {
408    let mut tags = Tags::default();
409    let mut notes = Notes::default();
410    for record in verified.iter().map(|v| &v.record) {
411        if record.kind.journals_as().is_none() {
412            tags.set(record.rev, &record.label);
413        }
414        notes.take(record);
415    }
416    (tags, notes)
417}
418
419fn blame(stamped: Rev, at: Rev) -> Blame {
420    if stamped.next() == at {
421        Blame::ThisRecord
422    } else {
423        Blame::AtOrBefore { verified: stamped }
424    }
425}
426
427/// The break at record `taken`, whose repo is the prefix *before* it —
428/// re-replayed, because the growing one has already taken the offending
429/// record (it folded legally; what it failed was its own stamp). Only the
430/// failure path pays for that, and the records being re-folded have each
431/// folded once already — which is also why the refusal arms cannot be
432/// reached and simply stop.
433fn broken(
434    scan: &Scan,
435    assets: &dyn AssetSource,
436    taken: usize,
437    head: Digest,
438    report: BreakReport,
439) -> Box<Broken> {
440    let mut repo = Repo::default();
441    for verified in &scan.records[..taken] {
442        let Some(journal) = verified.record.kind.journals_as() else {
443            continue;
444        };
445        let Ok(commit) = verified.record.commit(assets) else {
446            break;
447        };
448        if repo.replay_one(commit, journal).is_err() {
449            break;
450        }
451    }
452    let (tags, notes) = projections(&scan.records[..taken]);
453    Box::new(Broken {
454        repo,
455        head,
456        report,
457        entries: entries(&scan.records[..taken]),
458        tags,
459        notes,
460        verified: taken,
461    })
462}
463
464fn report(at: Located, fault: Fault) -> BreakReport {
465    BreakReport { at, fault }
466}
467
468struct Verified {
469    record: LogRecord,
470    at: Located,
471    digest: Digest,
472}
473
474/// How the record sequence stopped.
475enum End {
476    Whole,
477    Truncated(DroppedTail),
478    Broken(BreakReport),
479}
480
481struct Scan {
482    records: Vec<Verified>,
483    end: End,
484}
485
486/// Pass one: parse and link. Stops at the first record that does not
487/// belong, keeping everything before it.
488fn scan(log: &str) -> Scan {
489    let mut records: Vec<Verified> = Vec::new();
490    let mut parent = Digest::genesis();
491    let mut offset = 0;
492
493    for (number, chunk) in log.split_inclusive('\n').enumerate() {
494        let line = chunk.strip_suffix('\n');
495        let text = line.unwrap_or(chunk);
496        let at = Located {
497            line: number + 1,
498            column: 1,
499            offset,
500            len: text.len(),
501        };
502        if line.is_none() {
503            return Scan {
504                records,
505                end: End::Truncated(DroppedTail {
506                    at,
507                    good_bytes: offset as u64,
508                }),
509            };
510        }
511        offset += chunk.len();
512
513        let record: LogRecord = match serde_json::from_str(text) {
514            Ok(record) => record,
515            Err(error) => {
516                let at = Located {
517                    column: error.column().max(1),
518                    ..at
519                };
520                return Scan {
521                    records,
522                    end: End::Broken(BreakReport {
523                        at,
524                        fault: Fault::Malformed(error),
525                    }),
526                };
527            }
528        };
529        if record.parent != parent {
530            let fault = Fault::Chained {
531                expected: parent,
532                found: record.parent,
533            };
534            return Scan {
535                records,
536                end: End::Broken(BreakReport { at, fault }),
537            };
538        }
539        parent = record.digest_as_written(text);
540        records.push(Verified {
541            record,
542            at,
543            digest: parent,
544        });
545    }
546
547    Scan {
548        records,
549        end: End::Whole,
550    }
551}