Skip to main content

graph_r/format/
wal.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! `graph.wal` — the append-only log that extends a base generation.
5//!
6//! ```text
7//! [WalHeader 64 B] [record]*   one record = one transaction, whole or absent
8//! ```
9//!
10//! A transaction is appended as one checksummed record (one write, one
11//! fsync), so a crash mid-append leaves a torn tail that holds none of it:
12//! replay stops at the first torn record and the next writer cuts it off. A
13//! record whose checksum holds but whose contents break a rule is
14//! [`Error::Corrupt`], never cut, because cutting it would drop every later
15//! commit. Ops speak [`UrlKey`], never dense node ids, because ids are
16//! reassigned at every compaction; ops whose `seq` is already folded into the
17//! base (`seq <= wal_applied_seq`) are skipped, which is what makes a crash
18//! between "new base renamed" and "wal reset" harmless.
19//!
20//! The record and op layouts are under "format 3" below. Everything is read
21//! in place, through views borrowed from the validated bytes.
22
23use crate::bytesio::{get_u16, get_u32, get_u64, put_u16, put_u32, put_u64};
24use crate::error::{Error, Result};
25use crate::hash::xxh3_64;
26use crate::key::UrlKey;
27
28/// Magic: `"GRPW"` little-endian.
29pub const MAGIC: u32 = 0x5750_5247;
30/// WAL format version; strict-equal on read. 3: one checksummed record per
31/// transaction (2: one frame per op).
32pub const VERSION: u16 = 3;
33/// Header width.
34pub const HEADER_LEN: usize = 64;
35/// Most outbound edges per node.
36pub const MAX_EDGES: usize = 4096;
37/// Most attributes per node.
38pub const MAX_ATTRS: usize = 64;
39/// Longest attribute value (bytes of UTF-8).
40pub const MAX_ATTR_LEN: usize = 256 << 10;
41
42const OP_UPSERT: u8 = 1;
43const OP_SEGS: u8 = 2;
44const OP_EDGES: u8 = 3;
45const OP_TOUCH: u8 = 4;
46const OP_REMOVE: u8 = 5;
47const OP_PIN: u8 = 6;
48// 7 was segment importance (enrichment), retired.
49const OP_ATTRS: u8 = 8;
50
51/// Serialize a WAL header for `base_generation`.
52#[must_use]
53pub fn encode_header(base_generation: u64, created_at_ms: u64) -> Vec<u8> {
54    let mut out = Vec::with_capacity(HEADER_LEN);
55    put_u32(&mut out, MAGIC);
56    put_u16(&mut out, VERSION);
57    put_u16(&mut out, 0);
58    put_u32(&mut out, HEADER_LEN as u32);
59    // pad to 16 for field alignment
60    put_u32(&mut out, 0);
61    put_u64(&mut out, base_generation);
62    put_u64(&mut out, created_at_ms);
63    out.resize(HEADER_LEN - 8, 0);
64    let sum = xxh3_64(&out[..HEADER_LEN - 8]);
65    put_u64(&mut out, sum);
66    out
67}
68
69/// Parse a WAL header, returning `base_generation`.
70pub fn parse_header(bytes: &[u8]) -> Result<u64> {
71    if bytes.len() < HEADER_LEN {
72        return Err(Error::format("wal shorter than header"));
73    }
74    if crate::bytesio::get_u32(bytes, 0) != MAGIC {
75        return Err(Error::format("bad wal magic"));
76    }
77    let version = crate::bytesio::get_u16(bytes, 4);
78    if version != VERSION {
79        return Err(Error::Version { found: version, supported: VERSION });
80    }
81    let stored = crate::bytesio::get_u64(bytes, HEADER_LEN - 8);
82    if xxh3_64(&bytes[..HEADER_LEN - 8]) != stored {
83        return Err(Error::format("wal header checksum mismatch"));
84    }
85    Ok(crate::bytesio::get_u64(bytes, 16))
86}
87
88// ---- format 3: one record per transaction ---------------------------------
89//
90// ```text
91// record: 0 len u32 | 4 tag u16 "TX" | 6 rflags u8 = COMMIT | 7 0
92//         8 first_seq u64 | 16 op_count u32 | 20 0 u32
93//         24 xxh3(rec[0..24] ++ rec[32..len]) u64 | 32.. ops, back to back
94// op:     0 op_len u32 | 4 kind u8 | 5 oflags u8 | 6 0 u16 | 8 key u64 | 16.. body
95// ```
96//
97// Every field sits at a fixed offset and is read in place; a record is
98// validated once, whole, when it is accepted, and only then read through the
99// borrowed views below. A record that is short, mis-tagged or fails its
100// checksum is torn (a crash mid-append: never committed); one whose checksum
101// holds but whose body breaks a rule is corrupt and is reported, never cut.
102
103/// Width of a record's header.
104pub const RECORD_HEADER_LEN: usize = 32;
105/// Width of an op's header.
106pub const OP_HEADER_LEN: usize = 16;
107/// Largest record, which is one whole transaction (about a million upserts).
108/// A larger transaction is refused at commit, before anything is written.
109pub const MAX_RECORD: usize = 256 << 20;
110/// Longest label, URL, title, snippet or etag (bytes of UTF-8).
111pub const MAX_LABEL: usize = 2048;
112/// Most segments per document.
113pub const MAX_SEGS: usize = 128;
114/// Largest op: a maximal attribute set (its fixed fields, 64 entry headers
115/// and 1 048 246 value bytes) and so every other op.
116pub const MAX_OP: usize = 1_048_246 + ATTRS_FIXED + MAX_ATTRS * ATTR_ENTRY;
117
118const TAG: u16 = 0x5854; // "TX"
119const COMMIT: u8 = 0x01;
120const MIN_RECORD: usize = RECORD_HEADER_LEN + OP_HEADER_LEN;
121
122const UPSERT_FIXED: usize = OP_HEADER_LEN + 32;
123const SEGS_FIXED: usize = OP_HEADER_LEN + 8;
124const SEG_ENTRY: usize = 16;
125const EDGES_FIXED: usize = OP_HEADER_LEN + 8;
126const EDGE_ENTRY: usize = 12;
127const TOUCH_FIXED: usize = OP_HEADER_LEN + 40;
128const ATTRS_FIXED: usize = OP_HEADER_LEN + 8;
129const ATTR_ENTRY: usize = 8;
130
131/// Upsert `present` bits: which optional strings follow the URL.
132const HAS_TITLE: u8 = 1;
133const HAS_SNIPPET: u8 = 2;
134const HAS_ETAG: u8 = 4;
135/// Touch `bits`.
136const T_HASH: u8 = 1;
137const T_ETAG: u8 = 2;
138const T_TOMBSTONE: u8 = 4;
139
140/// One outbound edge: destination key, type tag, edge flags, weight.
141pub type Edge = (UrlKey, u8, u8, u16);
142
143/// The record checksum: xxh3 over the header's first 24 bytes and the ops,
144/// streamed so neither side builds a concatenated copy.
145fn record_sum(rec: &[u8]) -> u64 {
146    let mut h = crate::hash::Hasher::new();
147    h.update(&rec[..24]);
148    h.update(&rec[RECORD_HEADER_LEN..]);
149    h.digest64()
150}
151
152/// A revalidation outcome with the writer-computed freshness state it
153/// leaves behind (replay never re-derives policy).
154#[derive(Clone, Copy, Debug)]
155pub struct TouchRecord<'a> {
156    /// When the check happened, epoch ms.
157    pub checked_at_ms: u64,
158    /// The outcome tag (`crate::ttl::Outcome::as_tag`).
159    pub outcome: u8,
160    /// The node's content hash after the check.
161    pub content_hash: Option<u64>,
162    /// The node's entity tag after the check.
163    pub etag: Option<&'a str>,
164    /// The revalidation interval it leads to, seconds.
165    pub interval_s: u32,
166    /// Revalidations observed (saturating).
167    pub checks: u16,
168    /// Content changes observed (saturating).
169    pub changes: u16,
170    /// Last observed content change, epoch ms.
171    pub last_change_ms: u64,
172    /// The source was observed gone.
173    pub tombstone: bool,
174}
175
176/// A position in a [`RecordBuf`] to truncate back to.
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178pub struct Mark {
179    len: usize,
180    ops: u32,
181}
182
183/// The encoder: a transaction's ops, encoded in place as they are staged,
184/// and sealed into one record at commit. The sealed buffer is what the log
185/// receives and what the overlay then serves, unchanged. Every `push_*`
186/// validates first and appends nothing when it refuses.
187#[derive(Debug, Default)]
188pub struct RecordBuf {
189    buf: Vec<u8>,
190    ops: u32,
191}
192
193impl RecordBuf {
194    /// An empty buffer (allocates nothing until the first op).
195    #[must_use]
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Ops staged.
201    #[must_use]
202    pub fn ops(&self) -> u32 {
203        self.ops
204    }
205
206    /// True when no op is staged.
207    #[must_use]
208    pub fn is_empty(&self) -> bool {
209        self.ops == 0
210    }
211
212    /// Encoded length so far, header included (0 when empty).
213    #[must_use]
214    pub fn len(&self) -> usize {
215        self.buf.len()
216    }
217
218    /// The current position, for [`RecordBuf::truncate`].
219    #[must_use]
220    pub fn mark(&self) -> Mark {
221        Mark { len: self.buf.len(), ops: self.ops }
222    }
223
224    /// Drop every op staged after `mark`.
225    pub fn truncate(&mut self, mark: Mark) {
226        if mark.ops == 0 {
227            self.buf.clear();
228            self.ops = 0;
229        } else if mark.len <= self.buf.len() && mark.ops <= self.ops {
230            self.buf.truncate(mark.len);
231            self.ops = mark.ops;
232        }
233    }
234
235    /// The encoded bytes (a record once sealed).
236    #[must_use]
237    pub fn as_bytes(&self) -> &[u8] {
238        &self.buf
239    }
240
241    /// The encoded bytes, moved out.
242    #[must_use]
243    pub fn into_vec(self) -> Vec<u8> {
244        self.buf
245    }
246
247    /// The staged ops, in order.
248    pub fn op_views(&self) -> impl Iterator<Item = OpView<'_>> + '_ {
249        OpWalk { bytes: &self.buf, pos: RECORD_HEADER_LEN.min(self.buf.len()) }
250            .filter_map(|(_, op)| OpView::trusted(op))
251    }
252
253    /// Start an op: the record header on first use, then the op header with
254    /// its length to be patched by [`RecordBuf::finish`].
255    fn begin(&mut self, kind: u8, oflags: u8, key: UrlKey, body: usize) -> usize {
256        if self.buf.is_empty() {
257            self.buf.reserve(RECORD_HEADER_LEN + OP_HEADER_LEN + body);
258            self.buf.resize(RECORD_HEADER_LEN, 0);
259        }
260        let at = self.buf.len();
261        self.buf.reserve(OP_HEADER_LEN + body);
262        put_u32(&mut self.buf, 0);
263        self.buf.extend_from_slice(&[kind, oflags, 0, 0]);
264        put_u64(&mut self.buf, key.0);
265        at
266    }
267
268    fn finish(&mut self, at: usize) {
269        let len = (self.buf.len() - at) as u32;
270        self.buf[at..at + 4].copy_from_slice(&len.to_le_bytes());
271        self.ops += 1;
272    }
273
274    /// Stage a node create/replace with node flags `flags`.
275    pub fn push_upsert(&mut self, doc: &crate::writer::DocRecord<'_>, flags: u16) -> Result<()> {
276        if doc.url.is_empty() || doc.url.len() > MAX_LABEL {
277            return Err(Error::format(format!("url must be 1..={MAX_LABEL} bytes")));
278        }
279        let opt = [doc.title, doc.snippet, doc.etag];
280        if opt.iter().flatten().any(|s| s.len() > MAX_LABEL) {
281            return Err(Error::format(format!("a label exceeds {MAX_LABEL} bytes")));
282        }
283        let text = doc.url.len() + opt.iter().flatten().map(|s| s.len()).sum::<usize>();
284        let present = opt.iter().zip([HAS_TITLE, HAS_SNIPPET, HAS_ETAG]).fold(0, |p, (s, bit)| {
285            if s.is_some() {
286                p | bit
287            } else {
288                p
289            }
290        });
291        let at = self.begin(OP_UPSERT, 0, doc.url_key, 32 + text);
292        put_u64(&mut self.buf, doc.content_hash);
293        put_u64(&mut self.buf, doc.fetched_at_ms);
294        put_u16(&mut self.buf, flags);
295        self.buf.extend_from_slice(&[present, 0]);
296        put_u16(&mut self.buf, doc.url.len() as u16);
297        for s in opt {
298            put_u16(&mut self.buf, s.map_or(0, |s| s.len() as u16));
299        }
300        put_u32(&mut self.buf, 0);
301        self.buf.extend_from_slice(doc.url.as_bytes());
302        for s in opt.into_iter().flatten() {
303            self.buf.extend_from_slice(s.as_bytes());
304        }
305        self.finish(at);
306        Ok(())
307    }
308
309    /// Stage a whole-set segment replacement for `key` (depth at least 1;
310    /// a segment without a byte range is flagged `NO_RANGE`).
311    pub fn push_segments(
312        &mut self,
313        key: UrlKey,
314        segs: &[crate::writer::SegmentRecord<'_>],
315    ) -> Result<()> {
316        if segs.len() > MAX_SEGS {
317            return Err(Error::format(format!("more than {MAX_SEGS} segments")));
318        }
319        if segs.iter().any(|s| s.label.len() > MAX_LABEL) {
320            return Err(Error::format(format!("segment label exceeds {MAX_LABEL} bytes")));
321        }
322        let text: usize = segs.iter().map(|s| s.label.len()).sum();
323        let at = self.begin(OP_SEGS, 0, key, 8 + segs.len() * SEG_ENTRY + text);
324        self.buf.extend_from_slice(&[segs.len() as u8, 0, 0, 0, 0, 0, 0, 0]);
325        for s in segs {
326            let (start, len, flags) = match s.byte_range {
327                Some((start, len)) => (start, len, 0),
328                None => (0, 0, crate::format::base::sflags::NO_RANGE),
329            };
330            put_u32(&mut self.buf, start);
331            put_u32(&mut self.buf, len);
332            put_u16(&mut self.buf, s.importance);
333            put_u16(&mut self.buf, s.label.len() as u16);
334            self.buf.extend_from_slice(&[s.depth.max(1), flags, 0, 0]);
335        }
336        for s in segs {
337            self.buf.extend_from_slice(s.label.as_bytes());
338        }
339        self.finish(at);
340        Ok(())
341    }
342
343    /// Stage a whole-set outbound-edge replacement for `key`: at most
344    /// [`MAX_EDGES`], destinations strictly ascending.
345    pub fn push_edges(&mut self, key: UrlKey, edges: &[Edge]) -> Result<()> {
346        if edges.len() > MAX_EDGES {
347            return Err(Error::format(format!("more than {MAX_EDGES} edges")));
348        }
349        if edges.windows(2).any(|w| w[0].0 .0 >= w[1].0 .0) {
350            return Err(Error::format("edge destinations must be strictly ascending"));
351        }
352        let at = self.begin(OP_EDGES, 0, key, 8 + edges.len() * EDGE_ENTRY);
353        put_u16(&mut self.buf, edges.len() as u16);
354        self.buf.extend_from_slice(&[0; 6]);
355        for &(dst, etype, eflags, weight) in edges {
356            put_u64(&mut self.buf, dst.0);
357            self.buf.extend_from_slice(&[etype, eflags]);
358            put_u16(&mut self.buf, weight);
359        }
360        self.finish(at);
361        Ok(())
362    }
363
364    /// Stage a revalidation outcome for `key`.
365    pub fn push_touch(&mut self, key: UrlKey, t: &TouchRecord<'_>) -> Result<()> {
366        let etag = t.etag.unwrap_or("");
367        if etag.len() > MAX_LABEL {
368            return Err(Error::format(format!("etag exceeds {MAX_LABEL} bytes")));
369        }
370        let at = self.begin(OP_TOUCH, 0, key, 40 + etag.len());
371        put_u64(&mut self.buf, t.checked_at_ms);
372        put_u64(&mut self.buf, t.content_hash.unwrap_or(0));
373        put_u64(&mut self.buf, t.last_change_ms);
374        put_u32(&mut self.buf, t.interval_s);
375        put_u16(&mut self.buf, t.checks);
376        put_u16(&mut self.buf, t.changes);
377        let bits = (u8::from(t.content_hash.is_some()) * T_HASH)
378            | (u8::from(t.etag.is_some()) * T_ETAG)
379            | (u8::from(t.tombstone) * T_TOMBSTONE);
380        self.buf.extend_from_slice(&[t.outcome, bits]);
381        put_u16(&mut self.buf, etag.len() as u16);
382        put_u32(&mut self.buf, 0);
383        self.buf.extend_from_slice(etag.as_bytes());
384        self.finish(at);
385        Ok(())
386    }
387
388    /// Stage a hard delete of `key`.
389    pub fn push_remove(&mut self, key: UrlKey) {
390        let at = self.begin(OP_REMOVE, 0, key, 0);
391        self.finish(at);
392    }
393
394    /// Stage a pin or unpin of `key`.
395    pub fn push_pin(&mut self, key: UrlKey, pinned: bool) {
396        let at = self.begin(OP_PIN, u8::from(pinned), key, 0);
397        self.finish(at);
398    }
399
400    /// Stage a whole-set attribute replacement for `key`: keys strictly
401    /// ascending, at most [`MAX_ATTRS`], each value at most
402    /// [`MAX_ATTR_LEN`], the op at most [`MAX_OP`].
403    pub fn push_attrs(&mut self, key: UrlKey, attrs: &[(u8, &str)]) -> Result<()> {
404        let parts: Vec<(u8, [&str; 3])> = attrs.iter().map(|&(k, v)| (k, [v, "", ""])).collect();
405        self.push_attr_parts(key, &parts)
406    }
407
408    /// [`RecordBuf::push_attrs`] with each value the concatenation of three
409    /// parts, written straight into the record (a named attribute is
410    /// `name`, NUL, `value`): no value is assembled anywhere else first.
411    pub(crate) fn push_attr_parts(&mut self, key: UrlKey, attrs: &[(u8, [&str; 3])]) -> Result<()> {
412        let n = attrs.len();
413        if n > MAX_ATTRS {
414            return Err(Error::format(format!("more than {MAX_ATTRS} attributes")));
415        }
416        let mut total = 0usize;
417        let mut prev: Option<u8> = None;
418        for &(k, parts) in attrs {
419            if prev.is_some_and(|p| p >= k) {
420                return Err(Error::format("attribute keys must be strictly ascending"));
421            }
422            prev = Some(k);
423            let len: usize = parts.iter().map(|p| p.len()).sum();
424            if len > MAX_ATTR_LEN {
425                return Err(Error::format("attribute too long"));
426            }
427            total += len;
428        }
429        if ATTRS_FIXED + n * ATTR_ENTRY + total > MAX_OP {
430            return Err(Error::format(format!("attributes exceed {MAX_OP} bytes in one op")));
431        }
432        let at = self.begin(OP_ATTRS, 0, key, 8 + n * ATTR_ENTRY + total);
433        self.buf.extend_from_slice(&[n as u8, 0, 0, 0, 0, 0, 0, 0]);
434        for &(k, parts) in attrs {
435            self.buf.extend_from_slice(&[k, 0, 0, 0]);
436            put_u32(&mut self.buf, parts.iter().map(|p| p.len() as u32).sum());
437        }
438        for (_, parts) in attrs {
439            for p in parts {
440                self.buf.extend_from_slice(p.as_bytes());
441            }
442        }
443        self.finish(at);
444        Ok(())
445    }
446
447    /// Seal the staged ops into one record whose ops take sequence numbers
448    /// `first_seq..`. Refused, with the buffer left as it was, when there is
449    /// nothing staged, when the record would exceed [`MAX_RECORD`], or when
450    /// the sequence numbers would overflow. Sealing again (a retried commit)
451    /// rewrites the header.
452    pub fn seal(&mut self, first_seq: u64) -> Result<()> {
453        if self.ops == 0 {
454            return Err(Error::format("a record needs at least one op"));
455        }
456        if self.buf.len() > MAX_RECORD {
457            return Err(Error::format(format!(
458                "a transaction of {} bytes exceeds the {MAX_RECORD}-byte record limit; \
459                 commit it in parts",
460                self.buf.len()
461            )));
462        }
463        if first_seq == 0 || first_seq.checked_add(u64::from(self.ops) - 1).is_none() {
464            return Err(Error::format("record sequence numbers out of range"));
465        }
466        let len = self.buf.len() as u32;
467        let head = &mut self.buf[..RECORD_HEADER_LEN];
468        head[0..4].copy_from_slice(&len.to_le_bytes());
469        head[4..6].copy_from_slice(&TAG.to_le_bytes());
470        head[6..8].copy_from_slice(&[COMMIT, 0]);
471        head[8..16].copy_from_slice(&first_seq.to_le_bytes());
472        head[16..20].copy_from_slice(&self.ops.to_le_bytes());
473        head[20..24].copy_from_slice(&[0; 4]);
474        let sum = record_sum(&self.buf);
475        self.buf[24..32].copy_from_slice(&sum.to_le_bytes());
476        Ok(())
477    }
478}
479
480/// Walks op headers through bytes already validated (or encoded here):
481/// `(offset, op bytes)`.
482struct OpWalk<'a> {
483    bytes: &'a [u8],
484    pos: usize,
485}
486
487impl<'a> Iterator for OpWalk<'a> {
488    type Item = (usize, &'a [u8]);
489
490    #[inline]
491    fn next(&mut self) -> Option<Self::Item> {
492        let rest = self.bytes.get(self.pos..)?;
493        if rest.len() < OP_HEADER_LEN {
494            return None;
495        }
496        let len = get_u32(rest, 0) as usize;
497        let op = rest.get(..len).filter(|_| len >= OP_HEADER_LEN)?;
498        let at = self.pos;
499        self.pos += len;
500        Some((at, op))
501    }
502}
503
504/// What lies at a position in a log image.
505#[derive(Clone, Copy, Debug)]
506pub enum Next<'a> {
507    /// A whole, validated record.
508    Record(Record<'a>),
509    /// Not a committed record: short, mis-tagged or failing its checksum —
510    /// what a crash mid-append leaves. Nothing at or after it is committed.
511    Torn,
512}
513
514/// The record at `pos` of `bytes`, validated whole: its header, checksum
515/// and every op. `Err` is [`Error::Corrupt`] only: a record whose checksum
516/// holds but whose contents break a rule was written that way, and is never
517/// mistaken for a torn tail (cutting it would drop every later commit).
518pub fn next_record(bytes: &[u8], pos: usize) -> Result<Next<'_>> {
519    let Some(rest) = bytes.get(pos..) else { return Ok(Next::Torn) };
520    if rest.len() < RECORD_HEADER_LEN {
521        return Ok(Next::Torn);
522    }
523    let len = get_u32(rest, 0) as usize;
524    if !(MIN_RECORD..=MAX_RECORD).contains(&len)
525        || len > rest.len()
526        || get_u16(rest, 4) != TAG
527        || rest[6] != COMMIT
528    {
529        return Ok(Next::Torn);
530    }
531    let rec = &rest[..len];
532    if record_sum(rec) != get_u64(rec, 24) {
533        return Ok(Next::Torn);
534    }
535    let bad = |what: &str| Error::corrupt(format!("wal record at byte {pos}: {what}"));
536    if rec[7] != 0 || get_u32(rec, 20) != 0 {
537        return Err(bad("non-zero reserved header bytes"));
538    }
539    let first_seq = get_u64(rec, 8);
540    let count = get_u32(rec, 16);
541    if first_seq == 0 || count == 0 || first_seq.checked_add(u64::from(count) - 1).is_none() {
542        return Err(bad("sequence numbers out of range"));
543    }
544    let mut at = RECORD_HEADER_LEN;
545    for i in 0..count {
546        if len - at < OP_HEADER_LEN {
547            return Err(bad(&format!("op {i} of {count} missing")));
548        }
549        let op_len = get_u32(rec, at) as usize;
550        if !(OP_HEADER_LEN..=MAX_OP).contains(&op_len) || op_len > len - at {
551            return Err(bad(&format!("op {i} length {op_len} out of range")));
552        }
553        validate_op(&rec[at..at + op_len])
554            .map_err(|e| bad(&format!("op {i} at byte {}: {e}", pos + at)))?;
555        at += op_len;
556    }
557    if at != len {
558        return Err(bad("bytes after the last op"));
559    }
560    Ok(Next::Record(Record { rec, at: pos }))
561}
562
563/// A validated record (one transaction), borrowed from its image.
564#[derive(Clone, Copy, Debug)]
565pub struct Record<'a> {
566    rec: &'a [u8],
567    at: usize,
568}
569
570impl<'a> Record<'a> {
571    /// Sequence number of its first op; op `i` has `first_seq() + i`.
572    #[must_use]
573    #[inline]
574    pub fn first_seq(&self) -> u64 {
575        get_u64(self.rec, 8)
576    }
577
578    /// Ops it carries (at least one).
579    #[must_use]
580    #[inline]
581    pub fn op_count(&self) -> u32 {
582        get_u32(self.rec, 16)
583    }
584
585    /// Sequence number of its last op.
586    #[must_use]
587    #[inline]
588    pub fn last_seq(&self) -> u64 {
589        self.first_seq() + u64::from(self.op_count()) - 1
590    }
591
592    /// Its length in bytes, header included.
593    #[must_use]
594    pub fn len(&self) -> usize {
595        self.rec.len()
596    }
597
598    /// Always false: a record holds at least one op.
599    #[must_use]
600    pub fn is_empty(&self) -> bool {
601        false
602    }
603
604    /// Its offset in the image it was read from.
605    #[must_use]
606    pub fn offset(&self) -> usize {
607        self.at
608    }
609
610    /// Its bytes.
611    #[must_use]
612    pub fn bytes(&self) -> &'a [u8] {
613        self.rec
614    }
615
616    /// Its ops in order, each with its sequence number and its offset in the
617    /// image the record was read from.
618    pub fn ops(&self) -> impl Iterator<Item = (u64, usize, OpView<'a>)> + 'a {
619        let (first, at) = (self.first_seq(), self.at);
620        OpWalk { bytes: self.rec, pos: RECORD_HEADER_LEN }
621            .zip(0..u64::from(self.op_count()))
622            .filter_map(move |((off, op), i)| OpView::trusted(op).map(|v| (first + i, at + off, v)))
623    }
624}
625
626/// Walks the records of a range already validated by [`next_record`],
627/// reading only their headers.
628#[derive(Clone, Debug)]
629pub struct Records<'a> {
630    image: &'a [u8],
631    pos: usize,
632    end: usize,
633}
634
635impl<'a> Records<'a> {
636    /// Records of `image[pos..end]`, which must be whole records validated
637    /// in this process (the log the store replayed, or the record it
638    /// sealed): headers are trusted, nothing is re-checked.
639    pub(crate) fn trusted(image: &'a [u8], pos: usize, end: usize) -> Self {
640        Self { image, pos, end: end.min(image.len()) }
641    }
642}
643
644impl<'a> Iterator for Records<'a> {
645    type Item = Record<'a>;
646
647    #[inline]
648    fn next(&mut self) -> Option<Record<'a>> {
649        if self.end - self.pos.min(self.end) < MIN_RECORD {
650            return None;
651        }
652        let len = get_u32(self.image, self.pos) as usize;
653        let rec = self
654            .image
655            .get(self.pos..self.pos + len)
656            .filter(|_| len >= MIN_RECORD && self.pos + len <= self.end)?;
657        let at = self.pos;
658        self.pos += len;
659        Some(Record { rec, at })
660    }
661}
662
663/// How far a run of records goes from `pos`: the end of the last whole
664/// record before a tear, the last sequence number seen, the ops counted, and
665/// the corruption that stopped it, if one did. Sequence numbers must rise
666/// across records, starting above `after`.
667pub(crate) struct Scan {
668    pub(crate) end: usize,
669    pub(crate) last_seq: Option<u64>,
670    pub(crate) ops: usize,
671    pub(crate) corrupt: Option<Error>,
672}
673
674pub(crate) fn scan(bytes: &[u8], mut pos: usize, after: Option<u64>) -> Scan {
675    let mut out = Scan { end: pos, last_seq: after, ops: 0, corrupt: None };
676    loop {
677        match next_record(bytes, pos) {
678            Ok(Next::Record(r)) => {
679                if out.last_seq.is_some_and(|l| r.first_seq() <= l) {
680                    out.corrupt = Some(Error::corrupt(format!(
681                        "wal record at byte {pos}: sequence regression"
682                    )));
683                    return out;
684                }
685                out.last_seq = Some(r.last_seq());
686                out.ops += r.op_count() as usize;
687                pos += r.len();
688                out.end = pos;
689            }
690            Ok(Next::Torn) => return out,
691            Err(e) => {
692                out.corrupt = Some(e);
693                return out;
694            }
695        }
696    }
697}
698
699/// A replayed log image: its header's generation and the length of its
700/// committed prefix (header plus whole records), whose records were all
701/// validated.
702#[derive(Clone, Debug)]
703pub struct Replay<'a> {
704    /// Base generation this log extends.
705    pub base_generation: u64,
706    /// Byte length of the committed prefix; a writable opener truncates the
707    /// file to this length.
708    pub good_len: usize,
709    image: &'a [u8],
710}
711
712impl<'a> Replay<'a> {
713    /// The committed records, in order.
714    #[must_use]
715    pub fn records(&self) -> Records<'a> {
716        Records::trusted(self.image, HEADER_LEN, self.good_len)
717    }
718}
719
720/// Replay a log image: validate its header and every record up to the first
721/// torn one (that is the crash model, not an error). A corrupt record or a
722/// sequence regression is [`Error::Corrupt`]; a bad header is an error too
723/// ([`Error::Version`] for a well-formed one of another version).
724pub fn replay(image: &[u8]) -> Result<Replay<'_>> {
725    let base_generation = parse_header(image)?;
726    let scanned = scan(image, HEADER_LEN, None);
727    if let Some(e) = scanned.corrupt {
728        return Err(e);
729    }
730    Ok(Replay { base_generation, good_len: scanned.end, image })
731}
732
733/// Byte length of the committed prefix of a log image — exactly the
734/// [`Replay::good_len`] that [`replay`] would report when it succeeds — or
735/// 0 when the header itself is invalid. This is the length that is safe to
736/// copy elsewhere: it ends on a record boundary, so it never holds part of
737/// a transaction.
738#[must_use]
739pub fn committed_len(image: &[u8]) -> usize {
740    if parse_header(image).is_err() {
741        return 0;
742    }
743    HEADER_LEN + committed_frames_len(&image[HEADER_LEN..])
744}
745
746/// Like [`committed_len`] for a run of records with no header (for example
747/// the bytes appended since a previously measured record boundary): the
748/// length of its whole, valid records with rising sequence numbers.
749#[must_use]
750pub fn committed_frames_len(frames: &[u8]) -> usize {
751    scan(frames, 0, None).end
752}
753
754/// One op, validated (see [`validate_op`]) and read in place.
755#[derive(Clone, Copy, Debug)]
756pub enum OpView<'a> {
757    /// Create or replace a document node's core record.
758    Upsert(UpsertView<'a>),
759    /// Replace the node's segment set.
760    Segs(SegsView<'a>),
761    /// Replace the node's outbound edge set.
762    Edges(EdgesView<'a>),
763    /// A revalidation outcome with its freshness state.
764    Touch(TouchView<'a>),
765    /// Hard-delete the node.
766    Remove(UrlKey),
767    /// Pin (`true`) or unpin the node.
768    Pin(UrlKey, bool),
769    /// Replace the node's attribute set.
770    Attrs(AttrsView<'a>),
771}
772
773impl<'a> OpView<'a> {
774    /// The view of one op validated earlier (or encoded here): no checks
775    /// beyond the kind. `None` only for bytes that were never an op.
776    #[inline]
777    pub(crate) fn trusted(op: &'a [u8]) -> Option<Self> {
778        if op.len() < OP_HEADER_LEN {
779            return None;
780        }
781        let key = UrlKey(get_u64(op, 8));
782        Some(match op[4] {
783            OP_UPSERT => Self::Upsert(UpsertView { op }),
784            OP_SEGS => Self::Segs(SegsView { op }),
785            OP_EDGES => Self::Edges(EdgesView { op }),
786            OP_TOUCH => Self::Touch(TouchView { op }),
787            OP_REMOVE => Self::Remove(key),
788            OP_PIN => Self::Pin(key, op[5] & 1 != 0),
789            OP_ATTRS => Self::Attrs(AttrsView { op }),
790            _ => return None,
791        })
792    }
793
794    /// The key this op targets.
795    #[must_use]
796    #[inline]
797    pub fn key(&self) -> UrlKey {
798        match self {
799            Self::Upsert(v) => v.key(),
800            Self::Segs(v) => v.key(),
801            Self::Edges(v) => v.key(),
802            Self::Touch(v) => v.key(),
803            Self::Attrs(v) => v.key(),
804            Self::Remove(k) | Self::Pin(k, _) => *k,
805        }
806    }
807}
808
809/// UTF-8 at `op[at..at + len]`, validated when the op was accepted (so the
810/// fallback is never taken).
811#[inline]
812fn text(op: &[u8], at: usize, len: usize) -> &str {
813    std::str::from_utf8(&op[at..at + len]).unwrap_or("")
814}
815
816#[inline]
817fn utf8(bytes: &[u8], what: &str) -> Result<()> {
818    std::str::from_utf8(bytes)
819        .map(|_| ())
820        .map_err(|_| Error::format(format!("{what}: invalid utf-8")))
821}
822
823/// Validate one op (exactly `bytes`): its header, every length, count, sort
824/// order, reserved byte and string against the rules of its kind. Fuzz
825/// entry point: any input is `Ok` or [`Error::Format`], never a panic, and
826/// nothing is allocated on success.
827pub fn validate_op(bytes: &[u8]) -> Result<OpView<'_>> {
828    let n = bytes.len();
829    if !(OP_HEADER_LEN..=MAX_OP).contains(&n) || get_u32(bytes, 0) as usize != n {
830        return Err(Error::format("op length disagrees with its header"));
831    }
832    let (kind, oflags) = (bytes[4], bytes[5]);
833    if get_u16(bytes, 6) != 0 {
834        return Err(Error::format("non-zero reserved op bytes"));
835    }
836    if oflags & !u8::from(kind == OP_PIN) != 0 {
837        return Err(Error::format("unknown op flags"));
838    }
839    match kind {
840        OP_UPSERT => check_upsert(bytes)?,
841        OP_SEGS => check_segs(bytes)?,
842        OP_EDGES => check_edges(bytes)?,
843        OP_TOUCH => check_touch(bytes)?,
844        OP_REMOVE | OP_PIN if n == OP_HEADER_LEN => {}
845        OP_REMOVE | OP_PIN => return Err(Error::format("remove/pin carries a body")),
846        OP_ATTRS => check_attrs(bytes)?,
847        other => return Err(Error::format(format!("unknown op kind {other}"))),
848    }
849    OpView::trusted(bytes).ok_or_else(|| Error::format("unknown op kind"))
850}
851
852#[inline]
853fn zeros(bytes: &[u8]) -> bool {
854    bytes.iter().all(|&b| b == 0)
855}
856
857#[inline]
858fn check_upsert(op: &[u8]) -> Result<()> {
859    let n = op.len();
860    if n < UPSERT_FIXED {
861        return Err(Error::format("upsert too short"));
862    }
863    let present = op[34];
864    if present & !(HAS_TITLE | HAS_SNIPPET | HAS_ETAG) != 0 || op[35] != 0 || !zeros(&op[44..48]) {
865        return Err(Error::format("upsert: unknown bits"));
866    }
867    let lens = [36, 38, 40, 42].map(|at| usize::from(get_u16(op, at)));
868    if lens[0] == 0 || lens.iter().any(|&l| l > MAX_LABEL) {
869        return Err(Error::format("upsert: string length out of range"));
870    }
871    for (len, bit) in lens[1..].iter().zip([HAS_TITLE, HAS_SNIPPET, HAS_ETAG]) {
872        if present & bit == 0 && *len != 0 {
873            return Err(Error::format("upsert: an absent string has a length"));
874        }
875    }
876    if lens.iter().sum::<usize>() != n - UPSERT_FIXED {
877        return Err(Error::format("upsert: string lengths disagree with the op"));
878    }
879    let mut at = UPSERT_FIXED;
880    for len in lens {
881        utf8(&op[at..at + len], "upsert")?;
882        at += len;
883    }
884    Ok(())
885}
886
887#[inline]
888fn check_segs(op: &[u8]) -> Result<()> {
889    let n = op.len();
890    if n < SEGS_FIXED || !zeros(&op[17..24]) || usize::from(op[16]) > MAX_SEGS {
891        return Err(Error::format("segments: bad header"));
892    }
893    let count = usize::from(op[16]);
894    let mut at = SEGS_FIXED + count * SEG_ENTRY;
895    if at > n {
896        return Err(Error::format("segments: entries overrun the op"));
897    }
898    for i in 0..count {
899        let e = SEGS_FIXED + i * SEG_ENTRY;
900        let len = usize::from(get_u16(op, e + 10));
901        if len > MAX_LABEL || get_u16(op, e + 14) != 0 || len > n - at {
902            return Err(Error::format("segments: bad entry"));
903        }
904        utf8(&op[at..at + len], "segment label")?;
905        at += len;
906    }
907    if at != n {
908        return Err(Error::format("segments: label lengths disagree with the op"));
909    }
910    Ok(())
911}
912
913#[inline]
914fn check_edges(op: &[u8]) -> Result<()> {
915    let n = op.len();
916    if n < EDGES_FIXED || !zeros(&op[18..24]) {
917        return Err(Error::format("edges: bad header"));
918    }
919    let count = usize::from(get_u16(op, 16));
920    if count > MAX_EDGES || n != EDGES_FIXED + count * EDGE_ENTRY {
921        return Err(Error::format("edges: count disagrees with the op"));
922    }
923    let mut prev: Option<u64> = None;
924    for i in 0..count {
925        let dst = get_u64(op, EDGES_FIXED + i * EDGE_ENTRY);
926        if prev.is_some_and(|p| p >= dst) {
927            return Err(Error::format("edges: destinations not strictly ascending"));
928        }
929        prev = Some(dst);
930    }
931    Ok(())
932}
933
934#[inline]
935fn check_touch(op: &[u8]) -> Result<()> {
936    let n = op.len();
937    if n < TOUCH_FIXED {
938        return Err(Error::format("touch too short"));
939    }
940    let bits = op[49];
941    let len = usize::from(get_u16(op, 50));
942    if bits & !(T_HASH | T_ETAG | T_TOMBSTONE) != 0
943        || !zeros(&op[52..56])
944        || (bits & T_HASH == 0 && get_u64(op, 24) != 0)
945        || (bits & T_ETAG == 0 && len != 0)
946        || len > MAX_LABEL
947        || n != TOUCH_FIXED + len
948    {
949        return Err(Error::format("touch: bad fields"));
950    }
951    utf8(&op[TOUCH_FIXED..], "touch etag")
952}
953
954#[inline]
955fn check_attrs(op: &[u8]) -> Result<()> {
956    let n = op.len();
957    if n < ATTRS_FIXED || !zeros(&op[17..24]) || usize::from(op[16]) > MAX_ATTRS {
958        return Err(Error::format("attributes: bad header"));
959    }
960    let count = usize::from(op[16]);
961    let mut at = ATTRS_FIXED + count * ATTR_ENTRY;
962    if at > n {
963        return Err(Error::format("attributes: entries overrun the op"));
964    }
965    let mut prev: Option<u8> = None;
966    for i in 0..count {
967        let e = ATTRS_FIXED + i * ATTR_ENTRY;
968        let (k, len) = (op[e], get_u32(op, e + 4) as usize);
969        if prev.is_some_and(|p| p >= k)
970            || !zeros(&op[e + 1..e + 4])
971            || len > MAX_ATTR_LEN
972            || len > n - at
973        {
974            return Err(Error::format("attributes: bad entry"));
975        }
976        prev = Some(k);
977        utf8(&op[at..at + len], "attribute")?;
978        at += len;
979    }
980    if at != n {
981        return Err(Error::format("attributes: value lengths disagree with the op"));
982    }
983    Ok(())
984}
985
986/// A node upsert, read in place.
987#[derive(Clone, Copy, Debug)]
988pub struct UpsertView<'a> {
989    op: &'a [u8],
990}
991
992impl<'a> UpsertView<'a> {
993    /// The node's key.
994    #[must_use]
995    pub fn key(&self) -> UrlKey {
996        UrlKey(get_u64(self.op, 8))
997    }
998    /// xxh3 of the fetched body.
999    #[must_use]
1000    pub fn content_hash(&self) -> u64 {
1001        get_u64(self.op, 16)
1002    }
1003    /// Fetch stamp, epoch ms.
1004    #[must_use]
1005    pub fn fetched_at_ms(&self) -> u64 {
1006        get_u64(self.op, 24)
1007    }
1008    /// Node flags (`format::base::nflags`).
1009    #[must_use]
1010    pub fn flags(&self) -> u16 {
1011        get_u16(self.op, 32)
1012    }
1013    fn len_at(&self, i: usize) -> usize {
1014        usize::from(get_u16(self.op, 36 + 2 * i))
1015    }
1016    #[inline]
1017    fn field(&self, i: usize, bit: u8) -> Option<&'a str> {
1018        if i > 0 && self.op[34] & bit == 0 {
1019            return None;
1020        }
1021        let at = UPSERT_FIXED + (0..i).map(|j| self.len_at(j)).sum::<usize>();
1022        Some(text(self.op, at, self.len_at(i)))
1023    }
1024    /// Canonical URL.
1025    #[must_use]
1026    pub fn url(&self) -> &'a str {
1027        self.field(0, 0).unwrap_or("")
1028    }
1029    /// Title, if any.
1030    #[must_use]
1031    pub fn title(&self) -> Option<&'a str> {
1032        self.field(1, HAS_TITLE)
1033    }
1034    /// Snippet, if any.
1035    #[must_use]
1036    pub fn snippet(&self) -> Option<&'a str> {
1037        self.field(2, HAS_SNIPPET)
1038    }
1039    /// Entity tag, if any.
1040    #[must_use]
1041    pub fn etag(&self) -> Option<&'a str> {
1042        self.field(3, HAS_ETAG)
1043    }
1044}
1045
1046/// A segment set, read in place.
1047#[derive(Clone, Copy, Debug)]
1048pub struct SegsView<'a> {
1049    op: &'a [u8],
1050}
1051
1052/// One segment of a [`SegsView`].
1053#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1054pub struct SegView<'a> {
1055    /// Heading-path label.
1056    pub label: &'a str,
1057    /// Byte offset in the source document.
1058    pub byte_start: u32,
1059    /// Byte length in the source document.
1060    pub byte_len: u32,
1061    /// Importance in 1/65535 units.
1062    pub importance: u16,
1063    /// Heading depth.
1064    pub depth: u8,
1065    /// Segment flags (`format::base::sflags`).
1066    pub flags: u8,
1067}
1068
1069impl<'a> SegsView<'a> {
1070    /// The node's key.
1071    #[must_use]
1072    pub fn key(&self) -> UrlKey {
1073        UrlKey(get_u64(self.op, 8))
1074    }
1075    /// Segment count.
1076    #[must_use]
1077    pub fn len(&self) -> usize {
1078        usize::from(self.op[16])
1079    }
1080    /// True for an empty set.
1081    #[must_use]
1082    pub fn is_empty(&self) -> bool {
1083        self.len() == 0
1084    }
1085    /// The segments, in document order.
1086    pub fn iter(&self) -> impl Iterator<Item = SegView<'a>> + 'a {
1087        let (op, n) = (self.op, self.len());
1088        let mut label = SEGS_FIXED + n * SEG_ENTRY;
1089        (0..n).map(move |i| {
1090            let e = SEGS_FIXED + i * SEG_ENTRY;
1091            let len = usize::from(get_u16(op, e + 10));
1092            let s = SegView {
1093                label: text(op, label, len),
1094                byte_start: get_u32(op, e),
1095                byte_len: get_u32(op, e + 4),
1096                importance: get_u16(op, e + 8),
1097                depth: op[e + 12],
1098                flags: op[e + 13],
1099            };
1100            label += len;
1101            s
1102        })
1103    }
1104}
1105
1106/// An outbound edge set, read in place: fixed 12-byte entries.
1107#[derive(Clone, Copy, Debug)]
1108pub struct EdgesView<'a> {
1109    op: &'a [u8],
1110}
1111
1112impl<'a> EdgesView<'a> {
1113    /// The node's key.
1114    #[must_use]
1115    pub fn key(&self) -> UrlKey {
1116        UrlKey(get_u64(self.op, 8))
1117    }
1118    /// Edge count.
1119    #[must_use]
1120    pub fn len(&self) -> usize {
1121        usize::from(get_u16(self.op, 16))
1122    }
1123    /// True for an empty set.
1124    #[must_use]
1125    pub fn is_empty(&self) -> bool {
1126        self.len() == 0
1127    }
1128    /// Edge `i`, if in range.
1129    #[must_use]
1130    pub fn get(&self, i: usize) -> Option<Edge> {
1131        (i < self.len()).then(|| edge_at(self.entries(), i))
1132    }
1133    /// The edges, destinations ascending.
1134    pub fn iter(&self) -> impl Iterator<Item = Edge> + 'a {
1135        let e = self.entries();
1136        (0..e.len() / EDGE_ENTRY).map(move |i| edge_at(e, i))
1137    }
1138    /// The raw entries, for [`edge_at`].
1139    #[must_use]
1140    pub(crate) fn entries(&self) -> &'a [u8] {
1141        &self.op[EDGES_FIXED..]
1142    }
1143}
1144
1145/// Edge `i` of an [`EdgesView`]'s entries.
1146#[inline]
1147pub(crate) fn edge_at(entries: &[u8], i: usize) -> Edge {
1148    let e = i * EDGE_ENTRY;
1149    (UrlKey(get_u64(entries, e)), entries[e + 8], entries[e + 9], get_u16(entries, e + 10))
1150}
1151
1152/// Edges in an [`EdgesView`]'s entries.
1153#[inline]
1154pub(crate) fn edge_count(entries: &[u8]) -> usize {
1155    entries.len() / EDGE_ENTRY
1156}
1157
1158/// A revalidation outcome, read in place.
1159#[derive(Clone, Copy, Debug)]
1160pub struct TouchView<'a> {
1161    op: &'a [u8],
1162}
1163
1164impl<'a> TouchView<'a> {
1165    /// The node's key.
1166    #[must_use]
1167    pub fn key(&self) -> UrlKey {
1168        UrlKey(get_u64(self.op, 8))
1169    }
1170    /// When the check happened, epoch ms.
1171    #[must_use]
1172    pub fn checked_at_ms(&self) -> u64 {
1173        get_u64(self.op, 16)
1174    }
1175    /// The content hash after the check.
1176    #[must_use]
1177    pub fn content_hash(&self) -> Option<u64> {
1178        (self.op[49] & T_HASH != 0).then(|| get_u64(self.op, 24))
1179    }
1180    /// Last observed content change, epoch ms.
1181    #[must_use]
1182    pub fn last_change_ms(&self) -> u64 {
1183        get_u64(self.op, 32)
1184    }
1185    /// The revalidation interval, seconds.
1186    #[must_use]
1187    pub fn interval_s(&self) -> u32 {
1188        get_u32(self.op, 40)
1189    }
1190    /// Revalidations observed.
1191    #[must_use]
1192    pub fn checks(&self) -> u16 {
1193        get_u16(self.op, 44)
1194    }
1195    /// Content changes observed.
1196    #[must_use]
1197    pub fn changes(&self) -> u16 {
1198        get_u16(self.op, 46)
1199    }
1200    /// The outcome tag.
1201    #[must_use]
1202    pub fn outcome(&self) -> u8 {
1203        self.op[48]
1204    }
1205    /// The entity tag after the check.
1206    #[must_use]
1207    #[inline]
1208    pub fn etag(&self) -> Option<&'a str> {
1209        (self.op[49] & T_ETAG != 0)
1210            .then(|| text(self.op, TOUCH_FIXED, usize::from(get_u16(self.op, 50))))
1211    }
1212    /// The source was observed gone.
1213    #[must_use]
1214    pub fn tombstone(&self) -> bool {
1215        self.op[49] & T_TOMBSTONE != 0
1216    }
1217}
1218
1219/// An attribute set, read in place.
1220#[derive(Clone, Copy, Debug)]
1221pub struct AttrsView<'a> {
1222    op: &'a [u8],
1223}
1224
1225impl<'a> AttrsView<'a> {
1226    /// The node's key.
1227    #[must_use]
1228    pub fn key(&self) -> UrlKey {
1229        UrlKey(get_u64(self.op, 8))
1230    }
1231    /// Attribute count.
1232    #[must_use]
1233    pub fn len(&self) -> usize {
1234        usize::from(self.op[16])
1235    }
1236    /// True for an empty set.
1237    #[must_use]
1238    pub fn is_empty(&self) -> bool {
1239        self.len() == 0
1240    }
1241    /// The attributes, keys ascending.
1242    pub fn iter(&self) -> impl Iterator<Item = (u8, &'a str)> + 'a {
1243        let (op, n) = (self.op, self.len());
1244        let mut value = ATTRS_FIXED + n * ATTR_ENTRY;
1245        (0..n).map(move |i| {
1246            let e = ATTRS_FIXED + i * ATTR_ENTRY;
1247            let len = get_u32(op, e + 4) as usize;
1248            let v = (op[e], text(op, value, len));
1249            value += len;
1250            v
1251        })
1252    }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258
1259    // ---- format 3 ----------------------------------------------------------
1260
1261    use crate::writer::{DocRecord, SegmentRecord};
1262
1263    fn doc(url: &str) -> DocRecord<'_> {
1264        DocRecord {
1265            url,
1266            url_key: UrlKey::of(url),
1267            content_hash: 0xAB,
1268            fetched_at_ms: 77,
1269            title: Some("Title"),
1270            snippet: None,
1271            etag: Some("\"e1\""),
1272            pinned: false,
1273        }
1274    }
1275
1276    /// One record holding each of the seven op kinds.
1277    fn every_kind() -> RecordBuf {
1278        let mut b = RecordBuf::new();
1279        b.push_upsert(&doc("https://x.dev/a"), 1).unwrap();
1280        let segs = [
1281            SegmentRecord {
1282                label: "Install",
1283                byte_range: Some((10, 200)),
1284                depth: 1,
1285                importance: 60_000,
1286            },
1287            SegmentRecord {
1288                label: "Ünïcode › Linux", byte_range: None, depth: 0, importance: 7
1289            },
1290        ];
1291        b.push_segments(UrlKey(9), &segs).unwrap();
1292        b.push_edges(UrlKey(9), &[(UrlKey(11), 0, 0, 65_535), (UrlKey(400), 2, 3, 100)]).unwrap();
1293        let touch = TouchRecord {
1294            checked_at_ms: 5,
1295            outcome: 1,
1296            content_hash: Some(42),
1297            etag: Some("\"v2\""),
1298            interval_s: 3600,
1299            checks: 3,
1300            changes: 1,
1301            last_change_ms: 4,
1302            tombstone: true,
1303        };
1304        b.push_touch(UrlKey(9), &touch).unwrap();
1305        b.push_remove(UrlKey(11));
1306        b.push_pin(UrlKey(9), true);
1307        b.push_attrs(UrlKey(9), &[(1, "INT64"), (2, &"SELECT 1".repeat(1000))]).unwrap();
1308        b
1309    }
1310
1311    fn record(b: &RecordBuf) -> Record<'_> {
1312        match next_record(b.as_bytes(), 0).unwrap() {
1313            Next::Record(r) => r,
1314            Next::Torn => panic!("a sealed record reads as torn"),
1315        }
1316    }
1317
1318    #[test]
1319    fn a_record_round_trips_every_op_kind() {
1320        let mut buf = every_kind();
1321        buf.seal(41).unwrap();
1322        let rec = record(&buf);
1323        assert_eq!(
1324            (rec.first_seq(), rec.op_count(), rec.last_seq(), rec.len()),
1325            (41, 7, 47, buf.len())
1326        );
1327        let ops: Vec<(u64, usize, OpView<'_>)> = rec.ops().collect();
1328        assert_eq!(ops.iter().map(|o| o.0).collect::<Vec<_>>(), (41..=47).collect::<Vec<_>>());
1329        for &(_, off, _) in &ops {
1330            let len = get_u32(buf.as_bytes(), off) as usize;
1331            validate_op(&buf.as_bytes()[off..off + len]).expect("each op validates alone");
1332        }
1333        let OpView::Upsert(up) = ops[0].2 else { panic!("upsert") };
1334        assert_eq!(up.key(), UrlKey::of("https://x.dev/a"));
1335        assert_eq!((up.content_hash(), up.fetched_at_ms(), up.flags()), (0xAB, 77, 1));
1336        assert_eq!(
1337            (up.url(), up.title(), up.snippet(), up.etag()),
1338            ("https://x.dev/a", Some("Title"), None, Some("\"e1\""))
1339        );
1340        let OpView::Segs(sv) = ops[1].2 else { panic!("segments") };
1341        let segs: Vec<SegView<'_>> = sv.iter().collect();
1342        assert_eq!(segs.len(), 2);
1343        assert_eq!(
1344            (segs[0].label, segs[0].byte_start, segs[0].byte_len, segs[0].depth, segs[0].flags),
1345            ("Install", 10, 200, 1, 0)
1346        );
1347        assert_eq!((segs[1].label, segs[1].depth, segs[1].importance), ("Ünïcode › Linux", 1, 7));
1348        assert_eq!(segs[1].flags, crate::format::base::sflags::NO_RANGE);
1349        let OpView::Edges(ev) = ops[2].2 else { panic!("edges") };
1350        assert_eq!(
1351            ev.iter().collect::<Vec<_>>(),
1352            [(UrlKey(11), 0, 0, 65_535), (UrlKey(400), 2, 3, 100)]
1353        );
1354        assert_eq!(ev.get(1), Some((UrlKey(400), 2, 3, 100)));
1355        assert_eq!(ev.get(2), None);
1356        let OpView::Touch(tv) = ops[3].2 else { panic!("touch") };
1357        assert_eq!(
1358            (tv.checked_at_ms(), tv.outcome(), tv.content_hash(), tv.etag()),
1359            (5, 1, Some(42), Some("\"v2\""))
1360        );
1361        assert_eq!(
1362            (tv.interval_s(), tv.checks(), tv.changes(), tv.last_change_ms(), tv.tombstone()),
1363            (3600, 3, 1, 4, true)
1364        );
1365        assert!(matches!(ops[4].2, OpView::Remove(UrlKey(11))));
1366        assert!(matches!(ops[5].2, OpView::Pin(UrlKey(9), true)));
1367        let OpView::Attrs(av) = ops[6].2 else { panic!("attributes") };
1368        let attrs: Vec<(u8, &str)> = av.iter().collect();
1369        assert_eq!(attrs[0], (1, "INT64"));
1370        assert_eq!(attrs[1].1.len(), 8000);
1371        // The staged view walks the same ops.
1372        assert_eq!(buf.op_views().count(), 7);
1373    }
1374
1375    /// Three records, cut at every byte: a whole record is read, the record
1376    /// the cut lands in is torn, and the committed prefix ends exactly where
1377    /// the last whole record does.
1378    #[test]
1379    fn a_cut_at_every_byte_tears_only_the_record_it_lands_in() {
1380        let mut image = Vec::new();
1381        let mut ends = Vec::new();
1382        for (i, n) in [3u64, 1, 5].into_iter().enumerate() {
1383            let mut b = RecordBuf::new();
1384            for k in 0..n {
1385                b.push_remove(UrlKey(k));
1386            }
1387            b.push_upsert(&doc("https://x.dev/z"), 0).unwrap();
1388            b.seal(1 + 10 * i as u64).unwrap();
1389            image.extend_from_slice(b.as_bytes());
1390            ends.push(image.len());
1391        }
1392        for cut in 0..=image.len() {
1393            let bytes = &image[..cut];
1394            let mut pos = 0;
1395            while let Next::Record(r) = next_record(bytes, pos).unwrap() {
1396                pos += r.len();
1397            }
1398            let want = ends.iter().copied().filter(|&e| e <= cut).max().unwrap_or(0);
1399            assert_eq!(pos, want, "cut {cut}");
1400        }
1401    }
1402
1403    /// Rewrite a record's checksum after tampering with it, so it reads as
1404    /// written that way rather than torn.
1405    fn reseal(rec: &mut [u8]) {
1406        let sum = record_sum(rec);
1407        rec[24..32].copy_from_slice(&sum.to_le_bytes());
1408    }
1409
1410    fn corrupt_after(mutate: impl FnOnce(&mut Vec<u8>)) -> Error {
1411        let mut b = every_kind();
1412        b.seal(1).unwrap();
1413        let mut rec = b.into_vec();
1414        mutate(&mut rec);
1415        let n = rec.len() as u32;
1416        rec[0..4].copy_from_slice(&n.to_le_bytes());
1417        reseal(&mut rec);
1418        match next_record(&rec, 0) {
1419            Err(e @ Error::Corrupt { .. }) => e,
1420            Ok(Next::Torn) => panic!("a checksummed record read as torn"),
1421            Ok(Next::Record(_)) => panic!("accepted a malformed record"),
1422            Err(e) => panic!("not corrupt: {e}"),
1423        }
1424    }
1425
1426    /// Offsets of the seven ops in a sealed `every_kind` record.
1427    fn op_offsets() -> Vec<usize> {
1428        let mut b = every_kind();
1429        b.seal(1).unwrap();
1430        let offs = record(&b).ops().map(|o| o.1).collect();
1431        offs
1432    }
1433
1434    /// One tampering per rule, given the op offsets.
1435    type Tamper = fn(&mut Vec<u8>, &[usize]);
1436
1437    #[test]
1438    fn a_checksummed_but_malformed_record_is_corrupt_never_torn() {
1439        let cases: [(&str, Tamper); 16] = [
1440            ("unknown kind", |r, o| r[o[0] + 4] = 7),
1441            ("reserved op bytes", |r, o| r[o[2] + 6] = 1),
1442            ("reserved record byte", |r, _| r[7] = 1),
1443            ("reserved record word", |r, _| r[21] = 1),
1444            ("unknown op flags", |r, o| r[o[1] + 5] = 1),
1445            ("bad utf-8", |r, o| r[o[0] + 48] = 0xff),
1446            ("descending edges", |r, o| {
1447                r[o[2] + 24..o[2] + 32].copy_from_slice(&u64::MAX.to_le_bytes());
1448            }),
1449            ("descending attribute keys", |r, o| r[o[6] + 24] = 9),
1450            ("string lengths", |r, o| r[o[0] + 36] += 1),
1451            ("absent string with a length", |r, o| r[o[0] + 40] = 1),
1452            ("touch hash without its bit", |r, o| r[o[3] + 49] &= !1),
1453            ("op count too high", |r, _| r[16] = 8),
1454            ("op count too low", |r, _| r[16] = 6),
1455            ("first seq zero", |r, _| r[8..16].copy_from_slice(&0u64.to_le_bytes())),
1456            ("seq overflow", |r, _| r[8..16].copy_from_slice(&u64::MAX.to_le_bytes())),
1457            ("trailing bytes", |r, _| r.extend_from_slice(&[0; 3])),
1458        ];
1459        let offs = op_offsets();
1460        for (what, tamper) in cases {
1461            let e = corrupt_after(|r| tamper(r, &offs));
1462            assert!(e.to_string().contains("wal record at byte 0"), "{what}: {e}");
1463        }
1464    }
1465
1466    #[test]
1467    fn a_short_mistagged_or_mismatched_record_is_torn() {
1468        let mut b = every_kind();
1469        b.seal(1).unwrap();
1470        let good = b.into_vec();
1471        let torn = |mutate: fn(&mut Vec<u8>)| {
1472            let mut r = good.clone();
1473            mutate(&mut r);
1474            matches!(next_record(&r, 0), Ok(Next::Torn))
1475        };
1476        assert!(torn(|r| r[4] ^= 1), "tag");
1477        assert!(torn(|r| r[6] = 2), "flags");
1478        assert!(torn(|r| r[100] ^= 1), "checksum");
1479        assert!(
1480            torn(|r| r[0..4].copy_from_slice(&(MIN_RECORD as u32 - 1).to_le_bytes())),
1481            "too short"
1482        );
1483        assert!(torn(|r| r[0..4].copy_from_slice(&u32::MAX.to_le_bytes())), "too long");
1484        assert!(torn(|r| r.truncate(RECORD_HEADER_LEN - 1)), "no header");
1485        assert!(matches!(next_record(&good, good.len() + 5), Ok(Next::Torn)), "past the end");
1486    }
1487
1488    #[test]
1489    fn staging_refuses_bad_input_and_appends_nothing() {
1490        let mut b = RecordBuf::new();
1491        b.push_remove(UrlKey(1));
1492        let mark = b.mark();
1493        let long = "x".repeat(MAX_LABEL + 1);
1494        assert!(b.push_upsert(&doc(""), 0).is_err());
1495        assert!(b
1496            .push_upsert(&DocRecord { title: Some(&long), ..doc("https://x.dev/a") }, 0)
1497            .is_err());
1498        assert!(b.push_edges(UrlKey(1), &[(UrlKey(5), 0, 0, 0), (UrlKey(5), 0, 0, 0)]).is_err());
1499        assert!(b.push_edges(UrlKey(1), &vec![(UrlKey(5), 0, 0, 0); MAX_EDGES + 1]).is_err());
1500        assert!(b.push_attrs(UrlKey(1), &[(2, "a"), (1, "b")]).is_err());
1501        let touch = TouchRecord {
1502            checked_at_ms: 0,
1503            outcome: 0,
1504            content_hash: None,
1505            etag: Some(&long),
1506            interval_s: 0,
1507            checks: 0,
1508            changes: 0,
1509            last_change_ms: 0,
1510            tombstone: false,
1511        };
1512        assert!(b.push_touch(UrlKey(1), &touch).is_err());
1513        assert_eq!(b.mark(), mark, "a refused push appended something");
1514        b.push_pin(UrlKey(2), false);
1515        b.truncate(mark);
1516        assert_eq!(b.ops(), 1);
1517        b.truncate(RecordBuf::new().mark());
1518        assert!(b.is_empty() && b.as_bytes().is_empty());
1519        assert!(b.seal(1).is_err(), "an empty record");
1520    }
1521
1522    /// The op cap admits a maximal attribute set and nothing larger, and
1523    /// keeps `writer::MAX_ATTR_BYTES` where it was.
1524    #[test]
1525    fn the_op_cap_admits_a_maximal_attribute_set_exactly() {
1526        assert_eq!(MAX_OP - ATTRS_FIXED - MAX_ATTRS * ATTR_ENTRY, 1_048_246);
1527        let big = "v".repeat(MAX_ATTR_LEN);
1528        let last = "w".repeat(1_048_246 - 3 * MAX_ATTR_LEN);
1529        let mut set: Vec<(u8, &str)> = (0..64u8).map(|k| (k, "")).collect();
1530        for (k, v) in [big.as_str(), &big, &big, &last].into_iter().enumerate() {
1531            set[k].1 = v;
1532        }
1533        let mut b = RecordBuf::new();
1534        b.push_attrs(UrlKey(1), &set).expect("exactly MAX_OP is accepted");
1535        assert_eq!(b.len(), RECORD_HEADER_LEN + MAX_OP);
1536        let one_more = format!("{last}w");
1537        set[3].1 = &one_more;
1538        assert!(RecordBuf::new().push_attrs(UrlKey(1), &set).is_err(), "MAX_OP + 1 is refused");
1539        b.seal(1).unwrap();
1540        assert!(matches!(next_record(b.as_bytes(), 0), Ok(Next::Record(_))));
1541    }
1542
1543    /// The record cap admits a record of exactly `MAX_RECORD` bytes and
1544    /// refuses one byte more, leaving the buffer as it was.
1545    #[test]
1546    fn the_record_cap_admits_exactly_max_record() {
1547        let big = "v".repeat(MAX_ATTR_LEN);
1548        let fixed = ATTRS_FIXED + ATTR_ENTRY;
1549        let mut b = RecordBuf::new();
1550        let mut key = 0;
1551        while b.len() < MAX_RECORD {
1552            let room = MAX_RECORD - b.len().max(RECORD_HEADER_LEN);
1553            b.push_attrs(UrlKey(key), &[(0, &big[..(room - fixed).min(MAX_ATTR_LEN)])]).unwrap();
1554            key += 1;
1555        }
1556        assert_eq!(b.len(), MAX_RECORD);
1557        b.seal(1).expect("exactly MAX_RECORD is accepted");
1558        assert!(matches!(next_record(b.as_bytes(), 0), Ok(Next::Record(_))));
1559        let before = b.mark();
1560        b.push_pin(UrlKey(1), true);
1561        let e = b.seal(1).unwrap_err();
1562        assert!(matches!(e, Error::Format { .. }), "{e}");
1563        assert_eq!(b.ops(), before.ops + 1, "a refused seal keeps the ops");
1564    }
1565
1566    // ---- replay (ported from the frame-format tests) -----------------------
1567
1568    /// A log image of seven single-op records, one per op kind.
1569    fn image(generation: u64) -> Vec<u8> {
1570        let mut image = encode_header(generation, 0);
1571        let mut all = every_kind();
1572        all.seal(1).unwrap();
1573        for (i, (_, off, _)) in record(&all).ops().enumerate() {
1574            let len = get_u32(all.as_bytes(), off) as usize;
1575            let mut b = RecordBuf::new();
1576            b.buf.extend_from_slice(&[0; RECORD_HEADER_LEN]);
1577            b.buf.extend_from_slice(&all.as_bytes()[off..off + len]);
1578            b.ops = 1;
1579            b.seal(i as u64 + 1).unwrap();
1580            image.extend_from_slice(b.as_bytes());
1581        }
1582        image
1583    }
1584
1585    fn seqs(r: &Replay<'_>) -> Vec<u64> {
1586        r.records().flat_map(|r| r.first_seq()..=r.last_seq()).collect()
1587    }
1588
1589    #[test]
1590    fn wal_replays_and_stops_at_tear() {
1591        let image = image(7);
1592        let full = replay(&image).unwrap();
1593        assert_eq!(seqs(&full), (1..=7).collect::<Vec<_>>());
1594        assert_eq!(full.good_len, image.len());
1595        assert_eq!(full.base_generation, 7);
1596        // Truncate at every byte: never a panic, always a valid prefix.
1597        for cut in HEADER_LEN..image.len() {
1598            let r = replay(&image[..cut]).unwrap();
1599            assert!(seqs(&r).len() <= 7);
1600            assert!(r.good_len <= cut);
1601        }
1602    }
1603
1604    #[test]
1605    fn a_bad_checksum_ends_replay_and_a_bad_header_errors() {
1606        let mut image = encode_header(1, 0);
1607        let mut b = RecordBuf::new();
1608        b.push_remove(UrlKey(1));
1609        b.seal(1).unwrap();
1610        image.extend_from_slice(b.as_bytes());
1611        let good = image.len();
1612        b.seal(2).unwrap();
1613        image.extend_from_slice(b.as_bytes());
1614        image[good + 26] ^= 0xff; // the second record's checksum
1615        let r = replay(&image).unwrap();
1616        assert_eq!(seqs(&r), [1]);
1617        assert_eq!(r.good_len, good);
1618
1619        let mut bad_header = encode_header(1, 0);
1620        bad_header[3] ^= 0xff;
1621        assert!(replay(&bad_header).is_err());
1622    }
1623
1624    #[test]
1625    fn committed_len_matches_replay_at_every_cut() {
1626        let image = image(3);
1627        for cut in 0..=image.len() {
1628            let want = replay(&image[..cut]).map_or(0, |r| r.good_len);
1629            assert_eq!(committed_len(&image[..cut]), want, "cut {cut}");
1630            if cut >= HEADER_LEN {
1631                assert_eq!(committed_frames_len(&image[HEADER_LEN..cut]) + HEADER_LEN, want);
1632            }
1633        }
1634    }
1635
1636    #[test]
1637    fn streamed_record_checksum_equals_the_one_shot_hash() {
1638        let mut b = every_kind();
1639        b.seal(7).unwrap();
1640        let rec = b.as_bytes();
1641        let mut one_shot = rec[..24].to_vec();
1642        one_shot.extend_from_slice(&rec[RECORD_HEADER_LEN..]);
1643        assert_eq!(get_u64(rec, 24), xxh3_64(&one_shot));
1644    }
1645
1646    #[test]
1647    fn sequence_regression_is_corrupt() {
1648        let mut image = encode_header(1, 0);
1649        for first in [5, 4] {
1650            let mut b = RecordBuf::new();
1651            b.push_remove(UrlKey(first));
1652            b.seal(first).unwrap();
1653            image.extend_from_slice(b.as_bytes());
1654        }
1655        assert!(matches!(replay(&image), Err(Error::Corrupt { .. })));
1656        // A copy never takes the regressed record either.
1657        assert_eq!(committed_len(&image), image.len() - (image.len() - HEADER_LEN) / 2);
1658        // An overlap inside one record pair is a regression too.
1659        let mut image = encode_header(1, 0);
1660        for (first, n) in [(1, 3), (3, 1)] {
1661            let mut b = RecordBuf::new();
1662            for k in 0..n {
1663                b.push_remove(UrlKey(k));
1664            }
1665            b.seal(first).unwrap();
1666            image.extend_from_slice(b.as_bytes());
1667        }
1668        assert!(matches!(replay(&image), Err(Error::Corrupt { .. })));
1669    }
1670
1671    /// A log written by the frame format (0.4, 0.5) is a typed refusal
1672    /// naming both versions, read from its header alone.
1673    #[test]
1674    fn a_version_2_log_is_a_typed_refusal() {
1675        let mut v2 = encode_header(1, 0);
1676        v2[4..6].copy_from_slice(&2u16.to_le_bytes());
1677        let sum = xxh3_64(&v2[..HEADER_LEN - 8]);
1678        v2[HEADER_LEN - 8..].copy_from_slice(&sum.to_le_bytes());
1679        v2.extend_from_slice(&[0x55; 40]); // frames this build cannot read
1680        assert!(matches!(replay(&v2), Err(Error::Version { found: 2, supported: 3 })));
1681        assert!(matches!(parse_header(&v2), Err(Error::Version { found: 2, supported: 3 })));
1682        assert_eq!(committed_len(&v2), 0, "nothing of it is shipped as committed");
1683    }
1684}