Skip to main content

graph_r/format/
base.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! `graph.base` — the immutable snapshot. Layout:
5//!
6//! ```text
7//! [container header 128 B][directory, 32 B per section][sections, 64-aligned]
8//! ```
9//!
10//! Fixed-width, sorted record tables make every lookup a zero-copy binary
11//! search directly against the mmap; the label heap holds all variable-width
12//! strings. Section discriminants are burned: additive evolution happens via
13//! new section kinds (unknown kinds are skipped on read) and header flags,
14//! never by reinterpreting existing bytes.
15
16use crate::bytesio::{self, get_u16, get_u32, get_u64, put_u16, put_u32, put_u64, Reader};
17use crate::container;
18use crate::error::{Error, Result};
19use crate::key::UrlKey;
20
21/// Magic: `"GRPR"` little-endian.
22pub const MAGIC: u32 = 0x5250_5247;
23/// Format version; strict-equal on read. 3: the layout of 2 (the shared
24/// container framing: header, directory and section checksums), paired with
25/// a log of one record per transaction. Bumped with the log because a base
26/// is always read first: a release that cannot read this log refuses the
27/// store there, before it could take an empty log for its own and rewrite it.
28pub const VERSION: u16 = 3;
29
30/// Node record width.
31pub const NODE_LEN: usize = 72;
32/// Segment record width.
33pub const SEG_LEN: usize = 24;
34/// Edge record width.
35pub const EDGE_LEN: usize = 8;
36/// Lexicon record width.
37pub const LEX_LEN: usize = 24;
38/// Rank record width (rank percentile, permille).
39pub const RANK_LEN: usize = 2;
40/// Attribute record width: key u8, 3 zero bytes, blob offset u32, length u32.
41pub const ATTR_LEN: usize = 12;
42/// Trigram record width: trigram (3 bytes, big-endian in a u32), postings
43/// offset and length (in u32 lexicon indices).
44pub const TRI_LEN: usize = 12;
45
46/// Sentinel for "no label" in a label-offset field.
47pub const NO_LABEL: u32 = u32::MAX;
48
49/// Node flags.
50pub mod nflags {
51    /// Survives eviction sweeps and is exempt from TTL-driven eviction.
52    pub const PINNED: u16 = 1;
53    /// Known only as an edge target; never ingested (no URL label yet).
54    pub const STUB: u16 = 2;
55    /// Observed gone at the source; retained for history, excluded from
56    /// due-lists and default query results.
57    pub const TOMBSTONE: u16 = 4;
58}
59
60/// Edge flags.
61pub mod eflags {
62    /// Set = inferred (a `Related` similarity edge); clear = extracted.
63    pub const INFERRED: u8 = 2;
64    /// Set on a `Derives` edge = an unchanged copy (pass-through or rename).
65    pub const EXACT: u8 = 4;
66}
67
68/// Segment flags.
69pub mod sflags {
70    /// The byte range is unknown (heading-path anchor only).
71    pub const NO_RANGE: u8 = 1;
72}
73
74/// Section kinds (discriminants are burned; do not renumber).
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76#[repr(u16)]
77pub enum SectionKind {
78    /// Fixed 72-B node records sorted by `url_key`.
79    Nodes = 1,
80    /// Fixed 24-B segment records grouped per node in document order.
81    Segs = 2,
82    /// CSR offsets: `(node_count + 1) × u32` into `Edges`.
83    EdgeIndex = 3,
84    /// Fixed 8-B edge records, per-node runs sorted by destination id.
85    Edges = 4,
86    /// Length-prefixed UTF-8 label heap.
87    Labels = 5,
88    /// Fixed 24-B token records sorted by `token_hash`.
89    Lexicon = 6,
90    /// LEB128 delta-encoded ascending node-id postings.
91    Postings = 7,
92    /// Fixed 2-B per-node rank percentiles (parallel to `Nodes`).
93    Ranks = 8,
94    /// CSR offsets: `(node_count + 1) × u32` into `InEdges`.
95    InEdgeIndex = 9,
96    /// Fixed 8-B inbound records (source id, type, flags, weight), per-node
97    /// runs sorted by source id: the reverse of `Edges`.
98    InEdges = 10,
99    /// CSR offsets: `(node_count + 1) × u32` into `Attrs`.
100    AttrIndex = 11,
101    /// Fixed 12-B attribute records, per-node runs sorted by key.
102    Attrs = 12,
103    /// UTF-8 attribute values, addressed by (offset, length).
104    Blobs = 13,
105    /// `token_count × u32` lexicon indices in token string order: prefix
106    /// lookups are a binary search.
107    LexOrder = 14,
108    /// Fixed 12-B trigram records sorted by trigram: substring lookups
109    /// intersect the postings of a term's trigrams.
110    Trigrams = 15,
111    /// Ascending u32 lexicon indices per trigram.
112    TriPostings = 16,
113}
114
115/// The base file's container identity (see [`crate::container`]).
116pub const FORMAT: container::Format = container::Format { magic: MAGIC, version: VERSION };
117
118/// Parsed header of a base file: the container's owner area.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub struct Header {
121    /// Feature flags (none defined; must round-trip).
122    pub flags: u16,
123    /// Node record count.
124    pub node_count: u32,
125    /// Segment record count.
126    pub seg_count: u32,
127    /// Edge record count.
128    pub edge_count: u32,
129    /// Lexicon record count.
130    pub token_count: u32,
131    /// Monotonic snapshot generation (bumped each compaction, never reused).
132    pub generation: u64,
133    /// Highest WAL op sequence folded into this base.
134    pub wal_applied_seq: u64,
135    /// Wall-clock creation stamp (informational).
136    pub created_at_ms: u64,
137}
138
139impl Header {
140    fn owner(&self) -> Vec<u8> {
141        let mut o = Vec::with_capacity(40);
142        put_u32(&mut o, self.node_count);
143        put_u32(&mut o, self.seg_count);
144        put_u32(&mut o, self.edge_count);
145        put_u32(&mut o, self.token_count);
146        put_u64(&mut o, self.generation);
147        put_u64(&mut o, self.wal_applied_seq);
148        put_u64(&mut o, self.created_at_ms);
149        o
150    }
151
152    fn from_owner(flags: u16, o: &[u8]) -> Self {
153        Self {
154            flags,
155            node_count: get_u32(o, 0),
156            seg_count: get_u32(o, 4),
157            edge_count: get_u32(o, 8),
158            token_count: get_u32(o, 12),
159            generation: get_u64(o, 16),
160            wal_applied_seq: get_u64(o, 24),
161            created_at_ms: get_u64(o, 32),
162        }
163    }
164
165    /// The header alone (its own checksum verified, nothing else): enough to
166    /// learn a base's generation without validating the whole file.
167    pub(crate) fn parse(bytes: &[u8]) -> Result<Self> {
168        let (flags, owner) = container::peek(bytes, FORMAT)?;
169        Ok(Self::from_owner(flags, &owner))
170    }
171}
172
173/// The generation of a base image from its header alone (header checksum
174/// verified, nothing else): for callers that verify the bytes otherwise and
175/// only need to know which generation they hold.
176pub fn generation_of(bytes: &[u8]) -> Result<u64> {
177    Header::parse(bytes).map(|h| h.generation)
178}
179
180/// Serializes a base file.
181#[derive(Debug)]
182pub struct BaseWriter {
183    inner: container::Writer,
184}
185
186impl BaseWriter {
187    /// Start a writer for the given header.
188    #[must_use]
189    pub fn new(header: Header) -> Self {
190        let mut inner = container::Writer::new(FORMAT);
191        inner.flags(header.flags).owner(&header.owner());
192        Self { inner }
193    }
194
195    /// Append one section (an empty one is omitted; absent reads as empty).
196    pub fn add_section(&mut self, kind: SectionKind, bytes: Vec<u8>) {
197        self.inner.section(kind as u16, bytes);
198    }
199
200    /// Produce the final file bytes.
201    #[must_use]
202    pub fn finish(self) -> Vec<u8> {
203        self.inner.finish()
204    }
205}
206
207/// Parsed directory of a validated base file: the header plus each section's
208/// byte range. Holds offsets, not borrows, so the owner can keep it alongside
209/// the mmap it describes.
210#[derive(Clone, Debug)]
211pub struct BaseDir {
212    /// The validated header.
213    pub header: Header,
214    dir: container::Dir,
215}
216
217impl BaseDir {
218    /// Validate `bytes` end to end (container checksums, then every record
219    /// invariant the zero-copy accessors rely on) and return the directory.
220    /// This is the fuzz entry point: any byte string must produce `Ok`,
221    /// `Error::Format` or `Error::Version`, never a panic.
222    pub fn parse(bytes: &[u8]) -> Result<Self> {
223        let dir = container::Dir::parse(bytes, FORMAT)?;
224        let header = Header::from_owner(dir.flags, dir.owner());
225        let dir = Self { header, dir };
226        dir.check_counts(bytes)?;
227        Ok(dir)
228    }
229
230    /// Cross-check declared counts against actual section byte lengths so
231    /// record accessors can index without per-access bounds errors.
232    #[allow(clippy::too_many_lines)] // one pass over every record invariant
233    fn check_counts(&self, bytes: &[u8]) -> Result<()> {
234        let n = self.header.node_count as usize;
235        let expect = |kind, want: usize, name: &str| -> Result<()> {
236            if self.section(bytes, kind).len() == want {
237                Ok(())
238            } else {
239                Err(Error::format(format!("{name} section length mismatch")))
240            }
241        };
242        expect(SectionKind::Nodes, n * NODE_LEN, "nodes")?;
243        expect(SectionKind::Segs, self.header.seg_count as usize * SEG_LEN, "segs")?;
244        expect(SectionKind::Edges, self.header.edge_count as usize * EDGE_LEN, "edges")?;
245        expect(SectionKind::Lexicon, self.header.token_count as usize * LEX_LEN, "lexicon")?;
246        if n > 0 {
247            expect(SectionKind::EdgeIndex, (n + 1) * 4, "edge index")?;
248            expect(SectionKind::Ranks, n * RANK_LEN, "ranks")?;
249        }
250        // Every label offset must resolve inside the labels heap; walking all
251        // records here keeps NodeRec/SegRec accessors infallible.
252        let labels = self.section(bytes, SectionKind::Labels);
253        let check_label = |off: u32| -> Result<()> {
254            if off == NO_LABEL {
255                return Ok(());
256            }
257            let off = off as usize;
258            if off + 2 > labels.len() {
259                return Err(Error::format("label offset out of bounds"));
260            }
261            let len = usize::from(get_u16(labels, off));
262            if off + 2 + len > labels.len() {
263                return Err(Error::format("label extends past heap"));
264            }
265            std::str::from_utf8(&labels[off + 2..off + 2 + len])
266                .map_err(|_| Error::format("label not utf-8"))?;
267            Ok(())
268        };
269        let nodes = self.section(bytes, SectionKind::Nodes);
270        for i in 0..n {
271            let r = NodeRec(&nodes[i * NODE_LEN..(i + 1) * NODE_LEN]);
272            for off in [r.url_off(), r.title_off(), r.snippet_off(), r.etag_off()] {
273                check_label(off)?;
274            }
275            let (s, c) = (r.seg_start() as usize, usize::from(r.seg_count()));
276            if s + c > self.header.seg_count as usize {
277                return Err(Error::format("segment run out of bounds"));
278            }
279        }
280        let segs = self.section(bytes, SectionKind::Segs);
281        for i in 0..self.header.seg_count as usize {
282            check_label(SegRec(&segs[i * SEG_LEN..(i + 1) * SEG_LEN]).label_off())?;
283        }
284        // Edge destinations and CSR monotonicity.
285        if n > 0 {
286            let idx = self.section(bytes, SectionKind::EdgeIndex);
287            let mut prev = 0u32;
288            for i in 0..=n {
289                let v = get_u32(idx, i * 4);
290                if v < prev || v > self.header.edge_count {
291                    return Err(Error::format("edge index not monotone"));
292                }
293                prev = v;
294            }
295            if prev != self.header.edge_count {
296                return Err(Error::format("edge index does not cover edges"));
297            }
298            let edges = self.section(bytes, SectionKind::Edges);
299            for i in 0..self.header.edge_count as usize {
300                if get_u32(edges, i * EDGE_LEN) >= self.header.node_count {
301                    return Err(Error::format("edge destination out of range"));
302                }
303            }
304        }
305        // Inbound edges: the exact reverse of the forward CSR (same count),
306        // and attribute runs resolving inside the blob heap.
307        if n > 0 {
308            let check_csr = |kind, total: usize, name: &str| -> Result<()> {
309                let idx = self.section(bytes, kind);
310                if idx.is_empty() && total == 0 {
311                    return Ok(());
312                }
313                if idx.len() != (n + 1) * 4 {
314                    return Err(Error::format(format!("{name} index length mismatch")));
315                }
316                let mut prev = 0u32;
317                for i in 0..=n {
318                    let v = get_u32(idx, i * 4);
319                    if v < prev || v as usize > total {
320                        return Err(Error::format(format!("{name} index not monotone")));
321                    }
322                    prev = v;
323                }
324                if prev as usize == total {
325                    Ok(())
326                } else {
327                    Err(Error::format(format!("{name} index does not cover its records")))
328                }
329            };
330            let in_edges = self.section(bytes, SectionKind::InEdges);
331            if in_edges.len() != self.header.edge_count as usize * EDGE_LEN {
332                return Err(Error::format("inbound edges length mismatch"));
333            }
334            check_csr(SectionKind::InEdgeIndex, self.header.edge_count as usize, "inbound edge")?;
335            for i in 0..self.header.edge_count as usize {
336                if get_u32(in_edges, i * EDGE_LEN) >= self.header.node_count {
337                    return Err(Error::format("inbound edge source out of range"));
338                }
339            }
340            let attrs = self.section(bytes, SectionKind::Attrs);
341            if !attrs.len().is_multiple_of(ATTR_LEN) {
342                return Err(Error::format("attribute section length mismatch"));
343            }
344            check_csr(SectionKind::AttrIndex, attrs.len() / ATTR_LEN, "attribute")?;
345            let blobs = self.section(bytes, SectionKind::Blobs);
346            for rec in attrs.chunks_exact(ATTR_LEN) {
347                let (o, l) = (get_u32(rec, 4) as usize, get_u32(rec, 8) as usize);
348                let v = o.checked_add(l).and_then(|e| blobs.get(o..e));
349                if v.is_none_or(|v| std::str::from_utf8(v).is_err()) || rec[1..4] != [0; 3] {
350                    return Err(Error::format("attribute value out of bounds or not utf-8"));
351                }
352            }
353        }
354        // Lexicon search indices: every id names a lexicon record.
355        let vocab = self.header.token_count;
356        let order = self.section(bytes, SectionKind::LexOrder);
357        if order.len() != vocab as usize * 4
358            || order.chunks_exact(4).any(|c| get_u32(c, 0) >= vocab)
359        {
360            return Err(Error::format("lexicon order out of range"));
361        }
362        let (tri, tri_post) = (
363            self.section(bytes, SectionKind::Trigrams),
364            self.section(bytes, SectionKind::TriPostings),
365        );
366        if !tri.len().is_multiple_of(TRI_LEN) || !tri_post.len().is_multiple_of(4) {
367            return Err(Error::format("trigram section length mismatch"));
368        }
369        for rec in tri.chunks_exact(TRI_LEN) {
370            let (o, l) = (get_u32(rec, 4) as usize, get_u32(rec, 8) as usize);
371            let ids = o.checked_add(l).and_then(|e| tri_post.get(o * 4..e * 4));
372            if ids.is_none_or(|ids| ids.chunks_exact(4).any(|c| get_u32(c, 0) >= vocab)) {
373                return Err(Error::format("trigram postings out of range"));
374            }
375        }
376        // Lexicon postings ranges + sortedness by token hash.
377        let postings = self.section(bytes, SectionKind::Postings);
378        let lex = self.section(bytes, SectionKind::Lexicon);
379        let mut prev_hash = None;
380        for i in 0..self.header.token_count as usize {
381            let r = LexRec(&lex[i * LEX_LEN..(i + 1) * LEX_LEN]);
382            if let Some(p) = prev_hash {
383                if r.token_hash() <= p {
384                    return Err(Error::format("lexicon not sorted"));
385                }
386            }
387            prev_hash = Some(r.token_hash());
388            let (o, l) = (r.postings_off() as usize, r.postings_len() as usize);
389            if o.checked_add(l).is_none_or(|e| e > postings.len()) {
390                return Err(Error::format("postings range out of bounds"));
391            }
392            check_label(r.label_off())?;
393        }
394        // Node table sortedness by url_key (binary-search precondition).
395        let mut prev_key = None;
396        for i in 0..n {
397            let k = NodeRec(&nodes[i * NODE_LEN..(i + 1) * NODE_LEN]).url_key();
398            if let Some(p) = prev_key {
399                if k <= p {
400                    return Err(Error::format("nodes not sorted by url_key"));
401                }
402            }
403            prev_key = Some(k);
404        }
405        Ok(())
406    }
407
408    /// The bytes of section `kind` (empty when absent), O(1).
409    #[must_use]
410    pub fn section<'a>(&self, bytes: &'a [u8], kind: SectionKind) -> &'a [u8] {
411        self.dir.section(bytes, kind as u16)
412    }
413}
414
415/// Validate arbitrary bytes as a base file (fuzz entry point).
416pub fn validate(bytes: &[u8]) -> Result<()> {
417    BaseDir::parse(bytes).map(|_| ())
418}
419
420/// Zero-copy accessor over one 72-byte node record.
421#[derive(Clone, Copy, Debug)]
422pub struct NodeRec<'a>(pub &'a [u8]);
423
424#[allow(missing_docs)]
425impl NodeRec<'_> {
426    #[must_use]
427    pub fn url_key(self) -> UrlKey {
428        UrlKey(get_u64(self.0, 0))
429    }
430    #[must_use]
431    pub fn content_hash(self) -> u64 {
432        get_u64(self.0, 8)
433    }
434    #[must_use]
435    pub fn fetched_at_ms(self) -> u64 {
436        get_u64(self.0, 16)
437    }
438    #[must_use]
439    pub fn last_change_ms(self) -> u64 {
440        get_u64(self.0, 24)
441    }
442    #[must_use]
443    pub fn url_off(self) -> u32 {
444        get_u32(self.0, 32)
445    }
446    #[must_use]
447    pub fn title_off(self) -> u32 {
448        get_u32(self.0, 36)
449    }
450    #[must_use]
451    pub fn snippet_off(self) -> u32 {
452        get_u32(self.0, 40)
453    }
454    #[must_use]
455    pub fn etag_off(self) -> u32 {
456        get_u32(self.0, 44)
457    }
458    #[must_use]
459    pub fn seg_start(self) -> u32 {
460        get_u32(self.0, 48)
461    }
462    #[must_use]
463    pub fn seg_count(self) -> u16 {
464        get_u16(self.0, 52)
465    }
466    #[must_use]
467    pub fn flags(self) -> u16 {
468        get_u16(self.0, 54)
469    }
470    #[must_use]
471    pub fn interval_s(self) -> u32 {
472        get_u32(self.0, 56)
473    }
474    #[must_use]
475    pub fn checks(self) -> u16 {
476        get_u16(self.0, 60)
477    }
478    #[must_use]
479    pub fn changes(self) -> u16 {
480        get_u16(self.0, 62)
481    }
482    #[must_use]
483    pub fn in_degree(self) -> u32 {
484        get_u32(self.0, 64)
485    }
486}
487
488/// Serialize one node record (compaction).
489#[allow(clippy::too_many_arguments)]
490pub(crate) fn write_node_rec(
491    out: &mut Vec<u8>,
492    url_key: UrlKey,
493    content_hash: u64,
494    fetched_at_ms: u64,
495    last_change_ms: u64,
496    label_offs: [u32; 4],
497    seg_start: u32,
498    seg_count: u16,
499    flags: u16,
500    interval_s: u32,
501    checks: u16,
502    changes: u16,
503    in_degree: u32,
504) {
505    put_u64(out, url_key.0);
506    put_u64(out, content_hash);
507    put_u64(out, fetched_at_ms);
508    put_u64(out, last_change_ms);
509    for off in label_offs {
510        put_u32(out, off);
511    }
512    put_u32(out, seg_start);
513    put_u16(out, seg_count);
514    put_u16(out, flags);
515    put_u32(out, interval_s);
516    put_u16(out, checks);
517    put_u16(out, changes);
518    put_u32(out, in_degree);
519    put_u32(out, 0); // reserved
520}
521
522/// Zero-copy accessor over one 24-byte segment record.
523#[derive(Clone, Copy, Debug)]
524pub struct SegRec<'a>(pub &'a [u8]);
525
526#[allow(missing_docs)]
527impl SegRec<'_> {
528    #[must_use]
529    pub fn seg_key(self) -> u64 {
530        get_u64(self.0, 0)
531    }
532    #[must_use]
533    pub fn byte_start(self) -> u32 {
534        get_u32(self.0, 8)
535    }
536    #[must_use]
537    pub fn byte_len(self) -> u32 {
538        get_u32(self.0, 12)
539    }
540    #[must_use]
541    pub fn label_off(self) -> u32 {
542        get_u32(self.0, 16)
543    }
544    #[must_use]
545    pub fn importance(self) -> u16 {
546        get_u16(self.0, 20)
547    }
548    #[must_use]
549    pub fn depth(self) -> u8 {
550        self.0[22]
551    }
552    #[must_use]
553    pub fn flags(self) -> u8 {
554        self.0[23]
555    }
556}
557
558#[allow(clippy::too_many_arguments)]
559pub(crate) fn write_seg_rec(
560    out: &mut Vec<u8>,
561    seg_key: u64,
562    byte_start: u32,
563    byte_len: u32,
564    label_off: u32,
565    importance: u16,
566    depth: u8,
567    flags: u8,
568) {
569    put_u64(out, seg_key);
570    put_u32(out, byte_start);
571    put_u32(out, byte_len);
572    put_u32(out, label_off);
573    put_u16(out, importance);
574    out.push(depth);
575    out.push(flags);
576}
577
578/// Zero-copy accessor over one 24-byte lexicon record.
579#[derive(Clone, Copy, Debug)]
580pub struct LexRec<'a>(pub &'a [u8]);
581
582#[allow(missing_docs)]
583impl LexRec<'_> {
584    #[must_use]
585    pub fn token_hash(self) -> u64 {
586        get_u64(self.0, 0)
587    }
588    #[must_use]
589    pub fn df(self) -> u32 {
590        get_u32(self.0, 8)
591    }
592    #[must_use]
593    pub fn postings_off(self) -> u32 {
594        get_u32(self.0, 12)
595    }
596    #[must_use]
597    pub fn postings_len(self) -> u32 {
598        get_u32(self.0, 16)
599    }
600    #[must_use]
601    pub fn label_off(self) -> u32 {
602        get_u32(self.0, 20)
603    }
604}
605
606pub(crate) fn write_lex_rec(
607    out: &mut Vec<u8>,
608    token_hash: u64,
609    df: u32,
610    postings_off: u32,
611    postings_len: u32,
612    label_off: u32,
613) {
614    put_u64(out, token_hash);
615    put_u32(out, df);
616    put_u32(out, postings_off);
617    put_u32(out, postings_len);
618    put_u32(out, label_off);
619}
620
621/// Read a length-prefixed label at `off` from the labels heap. Offsets have
622/// been validated at parse time, so this is infallible for validated files.
623#[must_use]
624pub fn label_at(labels: &[u8], off: u32) -> Option<&str> {
625    if off == NO_LABEL {
626        return None;
627    }
628    let off = off as usize;
629    let len = usize::from(get_u16(labels, off));
630    std::str::from_utf8(&labels[off + 2..off + 2 + len]).ok()
631}
632
633/// Append a label to the heap and return its offset.
634pub(crate) fn push_label(labels: &mut Vec<u8>, s: &str) -> u32 {
635    let off = labels.len() as u32;
636    bytesio::put_str(labels, s);
637    off
638}
639
640/// Binary-search the sorted node table for `key`; returns the record index.
641#[must_use]
642pub fn find_node(nodes: &[u8], key: UrlKey) -> Option<u32> {
643    let n = nodes.len() / NODE_LEN;
644    let (mut lo, mut hi) = (0usize, n);
645    while lo < hi {
646        let mid = usize::midpoint(lo, hi);
647        let k = get_u64(nodes, mid * NODE_LEN);
648        match k.cmp(&key.0) {
649            std::cmp::Ordering::Less => lo = mid + 1,
650            std::cmp::Ordering::Greater => hi = mid,
651            std::cmp::Ordering::Equal => return Some(mid as u32),
652        }
653    }
654    None
655}
656
657/// Decode a postings run (LEB128 deltas, ascending node ids).
658pub fn decode_postings(bytes: &[u8]) -> Result<Vec<u32>> {
659    let mut r = Reader::new(bytes);
660    let mut out = Vec::with_capacity(bytes.len().min(r.remaining()));
661    let mut prev: u64 = 0;
662    while r.remaining() > 0 {
663        let delta = r.varint()?;
664        let id = if out.is_empty() {
665            delta
666        } else {
667            prev.checked_add(1)
668                .and_then(|v| v.checked_add(delta))
669                .ok_or_else(|| Error::format("posting delta overflow"))?
670        };
671        if id > u64::from(u32::MAX) {
672            return Err(Error::format("posting id overflow"));
673        }
674        out.push(id as u32);
675        prev = id;
676    }
677    Ok(out)
678}
679
680/// Encode a postings run (caller guarantees strictly ascending ids).
681pub(crate) fn encode_postings(ids: &[u32], out: &mut Vec<u8>) {
682    let mut prev: Option<u32> = None;
683    for &id in ids {
684        let delta = match prev {
685            None => u64::from(id),
686            Some(p) => u64::from(id - p - 1),
687        };
688        bytesio::put_varint(out, delta);
689        prev = Some(id);
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    fn empty_header() -> Header {
698        Header {
699            flags: 0,
700            node_count: 0,
701            seg_count: 0,
702            edge_count: 0,
703            token_count: 0,
704            generation: 1,
705            wal_applied_seq: 0,
706            created_at_ms: 0,
707        }
708    }
709
710    #[test]
711    fn empty_file_round_trips() {
712        let bytes = BaseWriter::new(empty_header()).finish();
713        let dir = BaseDir::parse(&bytes).unwrap();
714        assert_eq!(dir.header, empty_header());
715    }
716
717    #[test]
718    fn flipped_byte_anywhere_is_rejected() {
719        let bytes = BaseWriter::new(empty_header()).finish();
720        for i in 0..bytes.len() {
721            let mut bad = bytes.clone();
722            bad[i] ^= 0x01;
723            assert!(BaseDir::parse(&bad).is_err(), "flip at {i} accepted");
724        }
725    }
726
727    #[test]
728    fn truncation_at_every_landmark_is_rejected() {
729        let bytes = BaseWriter::new(empty_header()).finish();
730        for cut in 0..bytes.len() {
731            assert!(BaseDir::parse(&bytes[..cut]).is_err());
732        }
733    }
734
735    #[test]
736    fn postings_round_trip_and_reject_overflow() {
737        let ids = vec![0, 1, 5, 6, 1000, 70_000];
738        let mut enc = Vec::new();
739        encode_postings(&ids, &mut enc);
740        assert_eq!(decode_postings(&enc).unwrap(), ids);
741        assert!(decode_postings(&[0xff; 12]).is_err());
742        // Fuzz-found regression: a second delta of u64::MAX must error, not
743        // overflow the accumulator.
744        let mut hostile = Vec::new();
745        crate::bytesio::put_varint(&mut hostile, 1);
746        crate::bytesio::put_varint(&mut hostile, u64::MAX);
747        assert!(decode_postings(&hostile).is_err());
748    }
749
750    #[test]
751    fn node_binary_search_finds_all() {
752        let mut nodes = Vec::new();
753        for k in [3u64, 9, 12, 400, 500] {
754            write_node_rec(&mut nodes, UrlKey(k), 0, 0, 0, [NO_LABEL; 4], 0, 0, 0, 0, 0, 0, 0);
755        }
756        for k in [3u64, 9, 12, 400, 500] {
757            assert!(find_node(&nodes, UrlKey(k)).is_some());
758        }
759        assert!(find_node(&nodes, UrlKey(10)).is_none());
760        assert!(find_node(&nodes, UrlKey(0)).is_none());
761        assert!(find_node(&nodes, UrlKey(501)).is_none());
762    }
763}