Skip to main content

rete_core/
header.rs

1//! The fixed-size 1024-byte file header (SPEC.md §4.1).
2//!
3//! A client's very first request is `bytes=0-1023`; this struct is what those
4//! bytes decode to, and it points at every other section of the file via a
5//! **typed section directory**, so new top-level sections (e.g. a future text
6//! index) are added as a new directory entry without reshaping the header.
7//!
8//! Layout: a fixed 64-byte **core** (magic, version, flags, content hash, counts,
9//! codecs, `section_count`, `schema_meta_len`) followed by up to
10//! [`MAX_SECTIONS`] **directory entries** of 24 bytes each `(kind, flags, offset,
11//! length)`, zero-padded to 1024. The known section kinds populate the named
12//! convenience fields below; unknown kinds (written by a newer build) are
13//! preserved verbatim in [`Header::extra_sections`].
14
15use std::convert::TryInto;
16
17/// Magic bytes at offset 0: ASCII `RETE`.
18pub const MAGIC: [u8; 4] = *b"RETE";
19
20/// Current format generation written by this crate.
21///
22/// `0x05` is stable format generation 1, frozen on 2026-07-14 and first
23/// released in Rete 0.3.0. It retains the six-wide index-permutation addressing
24/// and the 1 KiB section-directory layout finalized in the last experimental
25/// generation; *which* of those six a given file stores is [`Header::perms`]
26/// (byte 50), not the generation.
27///
28/// The generation number is not a release version — there is no Rete 1.0.0, and
29/// it is the Rust/CLI/WASM APIs, not the format, that are waiting on one. Nor
30/// does it pin reader capability: writer semantics changed inside `0x05` nine
31/// days after the freeze (#68 split oversized index groups across tiles), so a
32/// `0x05` file can need a reader newer than the one that froze `0x05`.
33pub const CURRENT_FORMAT_VERSION: u8 = 0x06;
34
35/// Format generation `0x05` (stable generation 1, the 1 KiB section-directory
36/// layout this module parses). Every reader still reads it while the catalog is
37/// converted; the memory-bounded build, `merge` and `recard` still write it
38/// until they move to `0x06`.
39pub const LEGACY_FORMAT_VERSION: u8 = 0x05;
40
41/// Oldest stable format generation accepted by this reader.
42///
43/// Files written before the generation-1 freeze used experimental generations
44/// `0x01` through `0x04` and must be rebuilt from their RDF source. `0x05` has
45/// not moved since it froze on 2026-07-14, but that is a track record, not a
46/// promise: no backwards compatibility is guaranteed before 1.0.0, so this
47/// constant may yet be raised and a `0x05` file may yet need a rebuild. See
48/// `docs/compatibility.md`.
49pub const MIN_STABLE_READ_VERSION: u8 = 0x05;
50
51/// Fixed header size in bytes.
52pub const HEADER_LEN: usize = 1024;
53
54/// Byte offset of the first section-directory entry (i.e. the core size).
55const SECTION_DIR_OFFSET: usize = 64;
56/// Size of one section-directory entry.
57const SECTION_ENTRY_LEN: usize = 24;
58/// How many directory entries fit in the 1 KB frame.
59pub const MAX_SECTIONS: usize = (HEADER_LEN - SECTION_DIR_OFFSET) / SECTION_ENTRY_LEN;
60
61/// Flag bit: the file contains named graphs (quads) rather than triples only.
62pub const FLAG_HAS_QUADS: u8 = 0b0000_0001;
63
64/// Flag bit: each tiled permutation section carries a **tile-synopsis trailer**
65/// — per-tile min/max of the two non-leading columns, appended after the tile
66/// payloads. It lets a range reader prune a routed tile by a bound secondary
67/// component *before* fetching it. See `file.rs::encode_tiled_section`.
68pub const FLAG_TILE_SYNOPSIS: u8 = 0b0000_0010;
69
70/// Flag bit: the file contains **RDF-star quoted triples** (`<< s p o >>`) as
71/// dictionary terms. Purely informational for compatibility — a plain-RDF
72/// consumer can detect from the header alone that some terms are quoted triples
73/// (which it may not understand) without scanning the dictionary. The file is
74/// otherwise a normal `.rete`: quoted triples are stored like any other term, so
75/// this needs no format-version bump and old readers stay forward-compatible.
76pub const FLAG_HAS_QUOTED_TRIPLES: u8 = 0b0000_0100;
77
78/// Flag bit: the named graphs live in the **quad index** — the QUAD_INDEX
79/// section ([`SectionKind::QuadIndex`], kind 10) holds every named-graph quad as
80/// the fourth column of graph-leading permutations, the NAMED_GRAPHS section
81/// (kind 5) is absent, and the index container carries one trailing **seal**
82/// section (see `quad.rs`). Readers before this flag do not check it; they
83/// refuse the file anyway, on the seal (the container's section count), so a
84/// quad-index file never opens as one without named graphs
85/// (`docs/compatibility.md`).
86pub const FLAG_QUAD_INDEX: u8 = 0b0000_1000;
87
88/// Flag bit: the full-text index also covers the **named graphs'** literals
89/// (`rete build --text-index-graphs all`, Rete 0.4.0). It lets a query inside
90/// `GRAPH`, under `FROM` or over the union default graph decide from the
91/// header alone whether the index can prune its `FILTER(CONTAINS(…))`: without
92/// the bit it scans and never faults the index's token table. Bit 3
93/// (`0b0000_1000`) belongs to the quad index. Older readers ignore unknown bits.
94pub const FLAG_TEXT_INDEX_NAMED_GRAPHS: u8 = 0b0001_0000;
95
96/// Length of the BLAKE3 self-digest that heads a predicate-restricted text
97/// index section ([`SectionKind::TextIndexPartial`], kind 11):
98/// `digest = blake3(section[16..])[..16]`, the same rule as the LABEL_INDEX and
99/// SCHEMA sections.
100pub const TEXT_INDEX_PARTIAL_DIGEST_LEN: u64 = 16;
101
102/// A top-level file section, addressed by [`SectionKind`] in the header directory.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum SectionKind {
105    /// Dataset-card metadata.
106    Metadata,
107    /// Dictionary container (front-coded term sections).
108    Dictionary,
109    /// Permutation index container (SPO/POS/OSP).
110    Index,
111    /// Community + schema pyramid metadata.
112    PyramidMeta,
113    /// Named-graphs section.
114    NamedGraphs,
115    /// Full-text (word) index over literals — `token → subjects`.
116    TextIndex,
117    /// A full-text index that covers **only some predicates**
118    /// (`rete build --text-index-include/--text-index-exclude`): the same
119    /// layout as [`SectionKind::TextIndex`], under its own kind so that a
120    /// reader which predates text-index scopes does not see it at all. Such a
121    /// reader prunes `FILTER(CONTAINS(…))` with any text index it finds, as if
122    /// the index covered every literal; with this one it would silently drop
123    /// the rows of the predicates left out. Not seeing it, it scans instead.
124    /// The header maps it onto the same `text_index_*` fields, with
125    /// [`Header::text_index_partial`] set. Like kinds 8 and 9 it is **outside
126    /// the content hash** — an older `verify`, which cannot see it, must not
127    /// call the file corrupt — and starts with its own 16-byte digest
128    /// ([`TEXT_INDEX_PARTIAL_DIGEST_LEN`]) instead; the kind-6 layout follows
129    /// it ([`Header::text_index_payload`]).
130    TextIndexPartial,
131    /// Build-conditions record (timestamp, builder version, build parameters,
132    /// measured starter-query costs), stored as an opaque JSON blob owned by the
133    /// application layer (the CLI). **Deliberately excluded from the content
134    /// hash**: two builds of identical data must hash identically, and this
135    /// section is exactly the per-build facts (when, by which binary, how fast)
136    /// that differ between them. `verify` therefore does not cover it.
137    BuildInfo,
138    /// The range-readable **label index** (`rete build --label-index`): every
139    /// labelled subject's display label, sorted for prefix search, as a
140    /// multi-level table a reader walks with a few small range reads. See
141    /// [`crate::label_index`]. Optional; outside the content hash and
142    /// self-digested (SPEC §6.4).
143    LabelIndex,
144    /// The **schema** section: the class hierarchy, the per-level type rollups
145    /// and lateral class links, the coherence axioms, the planner's
146    /// `query_stats` and the characteristic sets — computed without the
147    /// community dendrogram. See [`crate::schema_section`]. Optional; outside
148    /// the content hash and self-digested (SPEC §7.5).
149    Schema,
150    /// The quad index (kind 10): every named-graph quad in graph-leading
151    /// permutations behind a sorted graph directory. Present exactly when the
152    /// header has [`FLAG_QUAD_INDEX`]. Kept in [`Header::extra_sections`] like
153    /// an unknown kind (it has no named convenience fields).
154    QuadIndex,
155    /// A section kind this build doesn't know — preserved verbatim on round-trip
156    /// so a newer writer's sections survive an older reader.
157    Unknown(u16),
158}
159
160impl SectionKind {
161    fn to_u16(self) -> u16 {
162        match self {
163            SectionKind::Metadata => 1,
164            SectionKind::Dictionary => 2,
165            SectionKind::Index => 3,
166            SectionKind::PyramidMeta => 4,
167            SectionKind::NamedGraphs => 5,
168            SectionKind::TextIndex => 6,
169            SectionKind::BuildInfo => 7,
170            SectionKind::LabelIndex => 8,
171            SectionKind::Schema => 9,
172            SectionKind::QuadIndex => 10,
173            SectionKind::TextIndexPartial => 11,
174            SectionKind::Unknown(k) => k,
175        }
176    }
177
178    fn from_u16(k: u16) -> Self {
179        match k {
180            1 => SectionKind::Metadata,
181            2 => SectionKind::Dictionary,
182            3 => SectionKind::Index,
183            4 => SectionKind::PyramidMeta,
184            5 => SectionKind::NamedGraphs,
185            6 => SectionKind::TextIndex,
186            7 => SectionKind::BuildInfo,
187            8 => SectionKind::LabelIndex,
188            9 => SectionKind::Schema,
189            10 => SectionKind::QuadIndex,
190            11 => SectionKind::TextIndexPartial,
191            other => SectionKind::Unknown(other),
192        }
193    }
194}
195
196/// One parsed/encoded section-directory entry: a typed `(offset, length)` into
197/// the file, plus 16 bits of per-section flags (reserved).
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct Section {
200    pub kind: SectionKind,
201    pub flags: u16,
202    pub offset: u64,
203    pub length: u64,
204}
205
206#[derive(Debug, thiserror::Error)]
207#[non_exhaustive]
208pub enum HeaderError {
209    #[error("buffer too small: need {HEADER_LEN} bytes, got {0}")]
210    TooSmall(usize),
211    #[error("bad magic: expected RETE")]
212    BadMagic,
213    #[error(
214        "unsupported .rete format {found:#04x}; this Rete build reads {min:#04x}..={max:#04x}. Pre-1.0 files must be rebuilt from RDF source with `rete build`"
215    )]
216    UnsupportedVersion { found: u8, min: u8, max: u8 },
217    #[error("section count {0} overruns the header frame")]
218    BadSectionCount(usize),
219    #[error("unusable index permutation mask {mask:#04x}: {why}")]
220    BadPermMask { mask: u8, why: &'static str },
221    #[error("malformed 0x06 header: {0}")]
222    Malformed06(&'static str),
223}
224
225/// Decoded file header. All multi-byte fields are little-endian on disk. The
226/// `*_offset` / `*_len` fields are a convenience view over the section directory
227/// (populated from it on parse, emitted back to it on serialize).
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Header {
230    /// Format version of the parsed file (currently
231    /// [`MIN_STABLE_READ_VERSION`] through [`CURRENT_FORMAT_VERSION`]).
232    pub version: u8,
233    pub flags: u8,
234    pub metadata_offset: u64,
235    pub metadata_len: u64,
236    pub dictionary_offset: u64,
237    pub dictionary_len: u64,
238    pub root_dir_offset: u64,
239    pub root_dir_len: u64,
240    pub pyramid_meta_offset: u64,
241    pub pyramid_meta_len: u64,
242    pub dict_codec: u8,
243    pub block_codec: u8,
244    pub pyramid_levels: u16,
245    /// 0x06 only: per-position term-id widths (header byte 77); `0` = all u32.
246    pub id_widths: u8,
247    pub quad_count: u64,
248    pub term_count: u64,
249    /// First 16 bytes of the blake3 content hash — an immutable validator.
250    pub content_hash: [u8; 16],
251    /// Named-graphs section (0 if the file has only the default graph).
252    pub named_graphs_offset: u64,
253    pub named_graphs_len: u64,
254    /// Byte length of the trailing **schema-pyramid block** within the pyramid-meta
255    /// section (0 if none). A reader fetches *only* that block — at
256    /// `pyramid_meta_offset + pyramid_meta_len - schema_meta_len` — for an
257    /// index/dictionary/summary-free Tier-0 coherence check.
258    pub schema_meta_len: u32,
259    /// Full-text index section (0 if the file has none; built with
260    /// `rete build --text-index`). See [`SectionKind::TextIndex`].
261    pub text_index_offset: u64,
262    pub text_index_len: u64,
263    /// The full-text index covers only some predicates: it is written (and was
264    /// read) as [`SectionKind::TextIndexPartial`] rather than
265    /// [`SectionKind::TextIndex`]. `false` for every file without one.
266    pub text_index_partial: bool,
267    /// Build-conditions section (0 if absent). See [`SectionKind::BuildInfo`]:
268    /// an opaque per-build record that is **not** covered by the content hash.
269    pub build_info_offset: u64,
270    pub build_info_len: u64,
271    /// Which index permutations the file's index containers carry
272    /// ([`crate::index::PermSet`]).
273    ///
274    /// On disk this is one byte at `[50]`, inside the reserved core span, and
275    /// **`0` means all six** — so every file written before the mask existed
276    /// decodes as [`PermSet::ALL`] and a full six-permutation build stays
277    /// byte-identical to what it always was. A lean file writes its mask
278    /// (`0b000_0111` for SPO+POS+OSP), and its index container then holds three
279    /// sections rather than six, which is what an older reader trips over.
280    ///
281    /// [`PermSet::ALL`]: crate::index::PermSet::ALL
282    pub perms: crate::index::PermSet,
283    /// Directory entries of the kinds added after the generation-1 freeze —
284    /// [`SectionKind::LabelIndex`] (8), [`SectionKind::Schema`] (9) — and of
285    /// any kind this build doesn't recognize, preserved verbatim in directory
286    /// order. None of them is covered by the header content hash.
287    pub extra_sections: Vec<Section>,
288}
289
290impl Header {
291    /// Serialize into a fixed 1024-byte array.
292    pub fn to_bytes(&self) -> [u8; HEADER_LEN] {
293        let mut b = [0u8; HEADER_LEN];
294        // --- core (64 bytes) ---
295        b[0..4].copy_from_slice(&MAGIC);
296        b[4] = self.version;
297        b[5] = self.flags;
298        b[6..8].copy_from_slice(&(HEADER_LEN as u16).to_le_bytes());
299        b[8..24].copy_from_slice(&self.content_hash);
300        b[24..32].copy_from_slice(&self.quad_count.to_le_bytes());
301        b[32..40].copy_from_slice(&self.term_count.to_le_bytes());
302        b[40..42].copy_from_slice(&self.pyramid_levels.to_le_bytes());
303        b[42] = self.dict_codec;
304        b[43] = self.block_codec;
305        // [44..46) section_count written below.
306        b[46..50].copy_from_slice(&self.schema_meta_len.to_le_bytes());
307        // [50] permutation mask; 0 = all six, so a full build is byte-identical
308        // to every file written before the field existed.
309        b[50] = if self.perms == crate::index::PermSet::ALL {
310            0
311        } else {
312            self.perms.bits()
313        };
314        // [51..64) reserved.
315
316        // --- section directory ---
317        // The five always-present sections (verbatim, so the named offsets
318        // round-trip exactly), then the optional text index (only when present, so
319        // a file without one stays byte-identical), then preserved unknown kinds.
320        let entry = |kind, offset, length| Section {
321            kind,
322            flags: 0,
323            offset,
324            length,
325        };
326        let mut entries: Vec<Section> = vec![
327            entry(
328                SectionKind::Metadata,
329                self.metadata_offset,
330                self.metadata_len,
331            ),
332            entry(
333                SectionKind::Dictionary,
334                self.dictionary_offset,
335                self.dictionary_len,
336            ),
337            entry(SectionKind::Index, self.root_dir_offset, self.root_dir_len),
338            entry(
339                SectionKind::PyramidMeta,
340                self.pyramid_meta_offset,
341                self.pyramid_meta_len,
342            ),
343            entry(
344                SectionKind::NamedGraphs,
345                self.named_graphs_offset,
346                self.named_graphs_len,
347            ),
348        ];
349        if self.text_index_len > 0 {
350            entries.push(entry(
351                if self.text_index_partial {
352                    SectionKind::TextIndexPartial
353                } else {
354                    SectionKind::TextIndex
355                },
356                self.text_index_offset,
357                self.text_index_len,
358            ));
359        }
360        if self.build_info_len > 0 {
361            entries.push(entry(
362                SectionKind::BuildInfo,
363                self.build_info_offset,
364                self.build_info_len,
365            ));
366        }
367        entries.extend(self.extra_sections.iter().copied());
368        debug_assert!(
369            entries.len() <= MAX_SECTIONS,
370            "too many sections for a 1 KB header"
371        );
372        let n = entries.len().min(MAX_SECTIONS);
373        b[44..46].copy_from_slice(&(n as u16).to_le_bytes());
374        for (i, s) in entries.iter().take(n).enumerate() {
375            let p = SECTION_DIR_OFFSET + i * SECTION_ENTRY_LEN;
376            b[p..p + 2].copy_from_slice(&s.kind.to_u16().to_le_bytes());
377            b[p + 2..p + 4].copy_from_slice(&s.flags.to_le_bytes());
378            // [p+4..p+8) reserved.
379            b[p + 8..p + 16].copy_from_slice(&s.offset.to_le_bytes());
380            b[p + 16..p + 24].copy_from_slice(&s.length.to_le_bytes());
381        }
382        b
383    }
384
385    /// Parse a header from the first 1024 bytes of a file.
386    pub fn from_bytes(b: &[u8]) -> Result<Self, HeaderError> {
387        if b.len() < HEADER_LEN {
388            return Err(HeaderError::TooSmall(b.len()));
389        }
390        if b[0..4] != MAGIC {
391            return Err(HeaderError::BadMagic);
392        }
393        if b[4] == crate::v06::VERSION {
394            // A 0x06 file: its 128-byte header, seen as a logical header (no
395            // physical offsets — see `v06::logical_header`).
396            return crate::v06::Header06::from_bytes(b)
397                .map(|h| crate::v06::logical_header(&h))
398                .map_err(|e| match e {
399                    crate::file::FileError::Container(m) => HeaderError::Malformed06(m),
400                    _ => HeaderError::Malformed06("unreadable"),
401                });
402        }
403        if !(MIN_STABLE_READ_VERSION..=CURRENT_FORMAT_VERSION).contains(&b[4]) {
404            return Err(HeaderError::UnsupportedVersion {
405                found: b[4],
406                min: MIN_STABLE_READ_VERSION,
407                max: CURRENT_FORMAT_VERSION,
408            });
409        }
410        let u16_at = |o: usize| u16::from_le_bytes(b[o..o + 2].try_into().unwrap());
411        let u32_at = |o: usize| u32::from_le_bytes(b[o..o + 4].try_into().unwrap());
412        let u64_at = |o: usize| u64::from_le_bytes(b[o..o + 8].try_into().unwrap());
413
414        let section_count = u16_at(44) as usize;
415        if SECTION_DIR_OFFSET + section_count * SECTION_ENTRY_LEN > HEADER_LEN {
416            return Err(HeaderError::BadSectionCount(section_count));
417        }
418
419        let perms = if b[50] == 0 {
420            crate::index::PermSet::ALL
421        } else {
422            crate::index::PermSet::from_bits(b[50])
423                .map_err(|why| HeaderError::BadPermMask { mask: b[50], why })?
424        };
425
426        let mut h = Header {
427            version: b[4],
428            flags: b[5],
429            metadata_offset: 0,
430            metadata_len: 0,
431            dictionary_offset: 0,
432            dictionary_len: 0,
433            root_dir_offset: 0,
434            root_dir_len: 0,
435            pyramid_meta_offset: 0,
436            pyramid_meta_len: 0,
437            dict_codec: b[42],
438            block_codec: b[43],
439            pyramid_levels: u16_at(40),
440            id_widths: 0,
441            quad_count: u64_at(24),
442            term_count: u64_at(32),
443            content_hash: b[8..24].try_into().unwrap(),
444            named_graphs_offset: 0,
445            named_graphs_len: 0,
446            schema_meta_len: u32_at(46),
447            perms,
448            text_index_offset: 0,
449            text_index_len: 0,
450            text_index_partial: false,
451            build_info_offset: 0,
452            build_info_len: 0,
453            extra_sections: Vec::new(),
454        };
455        for i in 0..section_count {
456            let p = SECTION_DIR_OFFSET + i * SECTION_ENTRY_LEN;
457            let kind = SectionKind::from_u16(u16_at(p));
458            let offset = u64_at(p + 8);
459            let length = u64_at(p + 16);
460            match kind {
461                SectionKind::Metadata => {
462                    h.metadata_offset = offset;
463                    h.metadata_len = length;
464                }
465                SectionKind::Dictionary => {
466                    h.dictionary_offset = offset;
467                    h.dictionary_len = length;
468                }
469                SectionKind::Index => {
470                    h.root_dir_offset = offset;
471                    h.root_dir_len = length;
472                }
473                SectionKind::PyramidMeta => {
474                    h.pyramid_meta_offset = offset;
475                    h.pyramid_meta_len = length;
476                }
477                SectionKind::NamedGraphs => {
478                    h.named_graphs_offset = offset;
479                    h.named_graphs_len = length;
480                }
481                SectionKind::TextIndex => {
482                    h.text_index_offset = offset;
483                    h.text_index_len = length;
484                    h.text_index_partial = false;
485                }
486                SectionKind::TextIndexPartial => {
487                    h.text_index_offset = offset;
488                    h.text_index_len = length;
489                    h.text_index_partial = true;
490                }
491                SectionKind::BuildInfo => {
492                    h.build_info_offset = offset;
493                    h.build_info_len = length;
494                }
495                SectionKind::LabelIndex
496                | SectionKind::Schema
497                | SectionKind::QuadIndex
498                | SectionKind::Unknown(_) => h.extra_sections.push(Section {
499                    kind,
500                    flags: u16_at(p + 2),
501                    offset,
502                    length,
503                }),
504            }
505        }
506        Ok(h)
507    }
508
509    /// The LABEL_INDEX section (kind 8), if the file carries a non-empty one.
510    pub fn label_index_section(&self) -> Option<Section> {
511        self.section(SectionKind::LabelIndex)
512            .filter(|s| s.length > 0)
513    }
514
515    /// The SCHEMA section (kind 9), if the file carries a non-empty one.
516    pub fn schema_section(&self) -> Option<Section> {
517        self.section(SectionKind::Schema).filter(|s| s.length > 0)
518    }
519
520    pub fn has_quads(&self) -> bool {
521        self.flags & FLAG_HAS_QUADS != 0
522    }
523
524    /// The total file length implied by this header alone: the writer lays the
525    /// sections out back-to-back after the 1 KiB header and ends the file with
526    /// the 4-byte `RETE` footer, so the furthest `offset + length` in the
527    /// section directory plus 4 **is** the file length.
528    ///
529    /// This is the one length signal a *remote* reader can always trust: an
530    /// HTTP host may advertise the size of a compressed representation in
531    /// `Content-Length` (GitHub Pages gzips — a 71 MB file HEADs as 58 MB)
532    /// while serving ranges over the identity bytes, and may hide
533    /// `Content-Range` from cross-origin JS by omitting
534    /// `Access-Control-Expose-Headers`. The header travels *in band* — in the
535    /// first 1 KiB range read — so it cannot be skewed by transport encoding.
536    ///
537    /// `None` if any directory entry overflows `u64` (a crafted or corrupt
538    /// header must yield "unknown", never a panic or a wrapped length — the
539    /// weekly fuzz caught `verify()` on exactly this class of input).
540    pub fn expected_file_len(&self) -> Option<u64> {
541        if self.version == crate::v06::VERSION {
542            // A 0x06 header states its length; its logical view parks every
543            // offset at that length (see `v06::logical_header`).
544            return Some(self.metadata_offset);
545        }
546        let mut end = HEADER_LEN as u64;
547        let named = [
548            (self.metadata_offset, self.metadata_len),
549            (self.dictionary_offset, self.dictionary_len),
550            (self.root_dir_offset, self.root_dir_len),
551            (self.pyramid_meta_offset, self.pyramid_meta_len),
552            (self.named_graphs_offset, self.named_graphs_len),
553            (self.text_index_offset, self.text_index_len),
554            (self.build_info_offset, self.build_info_len),
555        ];
556        let extras = self.extra_sections.iter().map(|s| (s.offset, s.length));
557        for (offset, length) in named.into_iter().chain(extras) {
558            if length == 0 {
559                continue; // absent section — its (0, 0) entry says nothing
560            }
561            end = end.max(offset.checked_add(length)?);
562        }
563        end.checked_add(MAGIC.len() as u64)
564    }
565
566    /// Does the file contain RDF-star quoted triples ([`FLAG_HAS_QUOTED_TRIPLES`])?
567    pub fn has_quoted_triples(&self) -> bool {
568        self.flags & FLAG_HAS_QUOTED_TRIPLES != 0
569    }
570
571    /// Do the tiled index sections carry a [`FLAG_TILE_SYNOPSIS`] trailer?
572    pub fn has_tile_synopsis(&self) -> bool {
573        self.flags & FLAG_TILE_SYNOPSIS != 0
574    }
575
576    /// Does the full-text index cover the named graphs' literals too
577    /// ([`FLAG_TEXT_INDEX_NAMED_GRAPHS`])? Known from the header alone.
578    pub fn text_index_covers_named_graphs(&self) -> bool {
579        self.flags & FLAG_TEXT_INDEX_NAMED_GRAPHS != 0
580    }
581
582    /// The `(offset, length)` of the text index's payload — the token-table
583    /// varint, the token table and the postings. That is the whole section for
584    /// kind 6; a kind-11 section starts with its 16-byte self-digest, which this
585    /// skips. `(offset, 0)` past a kind-11 section too short for its digest.
586    pub fn text_index_payload(&self) -> (u64, u64) {
587        if self.text_index_partial {
588            (
589                self.text_index_offset
590                    .saturating_add(TEXT_INDEX_PARTIAL_DIGEST_LEN),
591                self.text_index_len
592                    .saturating_sub(TEXT_INDEX_PARTIAL_DIGEST_LEN),
593            )
594        } else {
595            (self.text_index_offset, self.text_index_len)
596        }
597    }
598
599    /// The directory entry for a section kind, or `None` if absent. Known kinds
600    /// read from the named convenience fields; unknown kinds from
601    /// [`extra_sections`](Self::extra_sections).
602    pub fn section(&self, kind: SectionKind) -> Option<Section> {
603        let (offset, length) = match kind {
604            SectionKind::Metadata => (self.metadata_offset, self.metadata_len),
605            SectionKind::Dictionary => (self.dictionary_offset, self.dictionary_len),
606            SectionKind::Index => (self.root_dir_offset, self.root_dir_len),
607            SectionKind::PyramidMeta => (self.pyramid_meta_offset, self.pyramid_meta_len),
608            SectionKind::NamedGraphs => (self.named_graphs_offset, self.named_graphs_len),
609            SectionKind::TextIndex if !self.text_index_partial => {
610                (self.text_index_offset, self.text_index_len)
611            }
612            SectionKind::TextIndexPartial if self.text_index_partial => {
613                (self.text_index_offset, self.text_index_len)
614            }
615            SectionKind::TextIndex | SectionKind::TextIndexPartial => (0, 0),
616            SectionKind::BuildInfo => (self.build_info_offset, self.build_info_len),
617            SectionKind::LabelIndex
618            | SectionKind::Schema
619            | SectionKind::QuadIndex
620            | SectionKind::Unknown(_) => {
621                return self.extra_sections.iter().find(|s| s.kind == kind).copied()
622            }
623        };
624        Some(Section {
625            kind,
626            flags: 0,
627            offset,
628            length,
629        })
630    }
631
632    /// Attach (or overwrite) a section's `(offset, length)` — the extension point
633    /// for new top-level sections. Known kinds set the named fields; an unknown
634    /// kind is appended to [`extra_sections`](Self::extra_sections).
635    pub fn with_section(mut self, kind: SectionKind, offset: u64, length: u64) -> Self {
636        match kind {
637            SectionKind::Metadata => {
638                self.metadata_offset = offset;
639                self.metadata_len = length;
640            }
641            SectionKind::Dictionary => {
642                self.dictionary_offset = offset;
643                self.dictionary_len = length;
644            }
645            SectionKind::Index => {
646                self.root_dir_offset = offset;
647                self.root_dir_len = length;
648            }
649            SectionKind::PyramidMeta => {
650                self.pyramid_meta_offset = offset;
651                self.pyramid_meta_len = length;
652            }
653            SectionKind::NamedGraphs => {
654                self.named_graphs_offset = offset;
655                self.named_graphs_len = length;
656            }
657            SectionKind::TextIndex => {
658                self.text_index_offset = offset;
659                self.text_index_len = length;
660                self.text_index_partial = false;
661            }
662            SectionKind::TextIndexPartial => {
663                self.text_index_offset = offset;
664                self.text_index_len = length;
665                self.text_index_partial = true;
666            }
667            SectionKind::BuildInfo => {
668                self.build_info_offset = offset;
669                self.build_info_len = length;
670            }
671            SectionKind::LabelIndex | SectionKind::Schema | SectionKind::QuadIndex => {
672                // A known post-freeze kind appears at most once: overwrite.
673                match self.extra_sections.iter_mut().find(|s| s.kind == kind) {
674                    Some(s) => {
675                        s.offset = offset;
676                        s.length = length;
677                    }
678                    None => self.extra_sections.push(Section {
679                        kind,
680                        flags: 0,
681                        offset,
682                        length,
683                    }),
684                }
685            }
686            SectionKind::Unknown(_) => self.extra_sections.push(Section {
687                kind,
688                flags: 0,
689                offset,
690                length,
691            }),
692        }
693        self
694    }
695
696    /// Are the named graphs stored in the quad index ([`FLAG_QUAD_INDEX`])?
697    pub fn has_quad_index(&self) -> bool {
698        self.flags & FLAG_QUAD_INDEX != 0
699    }
700
701    /// The QUAD_INDEX section, when the directory lists a non-empty one.
702    pub fn quad_index_section(&self) -> Option<Section> {
703        self.section(SectionKind::QuadIndex)
704            .filter(|s| s.length > 0)
705    }
706
707    /// How many sections the index container (kind 3) holds: one per stored
708    /// permutation, plus the quad-index seal when [`FLAG_QUAD_INDEX`] is set.
709    pub fn index_container_sections(&self) -> usize {
710        self.perms.len() + usize::from(self.has_quad_index())
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    fn sample() -> Header {
719        Header {
720            version: LEGACY_FORMAT_VERSION,
721            flags: FLAG_HAS_QUADS,
722            metadata_offset: 1024,
723            metadata_len: 42,
724            dictionary_offset: 1066,
725            dictionary_len: 2048,
726            root_dir_offset: 3114,
727            root_dir_len: 256,
728            pyramid_meta_offset: 3370,
729            pyramid_meta_len: 64,
730            dict_codec: 1,
731            block_codec: 2,
732            pyramid_levels: 3,
733            id_widths: 0,
734            perms: crate::index::PermSet::ALL,
735            quad_count: 5,
736            term_count: 9,
737            content_hash: [7u8; 16],
738            named_graphs_offset: 3434,
739            named_graphs_len: 48,
740            schema_meta_len: 99,
741            text_index_offset: 0,
742            text_index_len: 0,
743            text_index_partial: false,
744            build_info_offset: 0,
745            build_info_len: 0,
746            extra_sections: Vec::new(),
747        }
748    }
749
750    #[test]
751    fn round_trip() {
752        let h = sample();
753        let bytes = h.to_bytes();
754        assert_eq!(bytes.len(), HEADER_LEN);
755        assert_eq!(&bytes[0..4], b"RETE");
756        let back = Header::from_bytes(&bytes).unwrap();
757        assert_eq!(h, back);
758        assert!(back.has_quads());
759    }
760
761    #[test]
762    fn byte_layout_matches_spec() {
763        // Pins the core fields and the first directory entry to exact offsets.
764        let h = Header {
765            content_hash: [0xCC; 16],
766            quad_count: 0x99,
767            term_count: 0xAA,
768            pyramid_levels: 0xABCD,
769            id_widths: 0,
770            dict_codec: 0xA1,
771            block_codec: 0xA2,
772            schema_meta_len: 0xD00D,
773            metadata_offset: 0x11,
774            metadata_len: 0x22,
775            dictionary_offset: 0x33,
776            dictionary_len: 0x44,
777            ..sample()
778        };
779        let b = h.to_bytes();
780        let u16_at = |o: usize| u16::from_le_bytes(b[o..o + 2].try_into().unwrap());
781        let u64_at = |o: usize| u64::from_le_bytes(b[o..o + 8].try_into().unwrap());
782
783        // core
784        assert_eq!(&b[0..4], b"RETE");
785        assert_eq!(b[4], LEGACY_FORMAT_VERSION);
786        assert_eq!(b[5], FLAG_HAS_QUADS);
787        assert_eq!(u16_at(6), HEADER_LEN as u16);
788        assert_eq!(&b[8..24], &[0xCC; 16]); // content hash
789        assert_eq!(u64_at(24), 0x99); // quad count
790        assert_eq!(u64_at(32), 0xAA); // term count
791        assert_eq!(u16_at(40), 0xABCD); // pyramid levels
792        assert_eq!(b[42], 0xA1); // dict codec
793        assert_eq!(b[43], 0xA2); // block codec
794        assert_eq!(u16_at(44), 5); // section_count: the 5 known sections
795        assert_eq!(u32::from_le_bytes(b[46..50].try_into().unwrap()), 0xD00D); // schema-meta len
796                                                                               // first directory entry = Metadata, at offset 64
797        assert_eq!(u16_at(64), 1); // kind = Metadata
798        assert_eq!(u64_at(72), 0x11); // metadata offset
799        assert_eq!(u64_at(80), 0x22); // metadata length
800                                      // second entry = Dictionary, at 88
801        assert_eq!(u16_at(88), 2);
802        assert_eq!(u64_at(96), 0x33);
803        assert_eq!(u64_at(104), 0x44);
804        assert_eq!(b.len(), HEADER_LEN);
805    }
806
807    #[test]
808    fn expected_file_len_is_last_section_end_plus_footer() {
809        // sample(): named graphs end furthest, at 3434 + 48 = 3482.
810        assert_eq!(sample().expected_file_len(), Some(3482 + 4));
811
812        // A zero-length section's offset must not count (absent sections are
813        // written as (0, 0), and a stale offset with len 0 addresses nothing).
814        let mut h = sample();
815        h.text_index_offset = 1 << 40;
816        h.text_index_len = 0;
817        assert_eq!(h.expected_file_len(), Some(3482 + 4));
818
819        // An empty file (header + footer only) is 1028 bytes.
820        let mut e = sample();
821        e.metadata_len = 0;
822        e.dictionary_len = 0;
823        e.root_dir_len = 0;
824        e.pyramid_meta_len = 0;
825        e.named_graphs_len = 0;
826        assert_eq!(e.expected_file_len(), Some(1024 + 4));
827
828        // A crafted header whose entry overflows u64 yields None, not a panic
829        // or a wrapped (tiny) length.
830        let mut c = sample();
831        c.named_graphs_offset = u64::MAX - 8;
832        c.named_graphs_len = 64;
833        assert_eq!(c.expected_file_len(), None);
834
835        // Unknown (future) sections extend the file too.
836        let f = sample().with_section(SectionKind::Unknown(99), 1 << 20, 512);
837        assert_eq!(f.expected_file_len(), Some((1 << 20) + 512 + 4));
838    }
839
840    #[test]
841    fn rejects_bad_magic() {
842        let mut bytes = [0u8; HEADER_LEN];
843        bytes[4] = CURRENT_FORMAT_VERSION;
844        assert!(matches!(
845            Header::from_bytes(&bytes),
846            Err(HeaderError::BadMagic)
847        ));
848    }
849
850    #[test]
851    fn stable_reader_accepts_v1_baseline_and_rejects_pre_v1() {
852        let current = sample().to_bytes();
853        assert_eq!(current[4], 0x05);
854        assert_eq!(Header::from_bytes(&current).unwrap().version, 0x05);
855
856        for old in 0x01..=0x04 {
857            let mut bytes = current;
858            bytes[4] = old;
859            let error = Header::from_bytes(&bytes).unwrap_err();
860            assert!(matches!(
861                &error,
862                HeaderError::UnsupportedVersion {
863                    found,
864                    min: 0x05,
865                    max: 0x06
866                } if *found == old
867            ));
868            assert!(error
869                .to_string()
870                .contains("Pre-1.0 files must be rebuilt from RDF source with `rete build`"));
871        }
872
873        for unsupported in [0x00, 0x07, 0xff] {
874            let mut bytes = current;
875            bytes[4] = unsupported;
876            assert!(matches!(
877                Header::from_bytes(&bytes),
878                Err(HeaderError::UnsupportedVersion {
879                    found,
880                    min: 0x05,
881                    max: 0x06
882                }) if found == unsupported
883            ));
884        }
885    }
886
887    #[test]
888    fn rejects_overrunning_section_count() {
889        let mut bad = sample().to_bytes();
890        bad[44..46].copy_from_slice(&9999u16.to_le_bytes());
891        assert!(matches!(
892            Header::from_bytes(&bad),
893            Err(HeaderError::BadSectionCount(9999))
894        ));
895    }
896
897    #[test]
898    fn unknown_section_survives_round_trip() {
899        // A section a future build added (kind 99) must be preserved verbatim by a
900        // reader that doesn't know it, and readable via `section()`.
901        let h = sample().with_section(SectionKind::Unknown(99), 4096, 512);
902        let back = Header::from_bytes(&h.to_bytes()).unwrap();
903        assert_eq!(back.extra_sections.len(), 1);
904        let s = back.section(SectionKind::Unknown(99)).unwrap();
905        assert_eq!((s.offset, s.length), (4096, 512));
906        // Known sections still resolve.
907        let dict = back.section(SectionKind::Dictionary).unwrap();
908        assert_eq!(dict.offset, h.dictionary_offset);
909        assert_eq!(h, back);
910    }
911
912    #[test]
913    fn build_info_section_round_trips_and_is_optional() {
914        // Absent (len 0): only the 5 always-present sections — a file without a
915        // build-info section is byte-identical to one built before it existed.
916        let plain = sample().to_bytes();
917        assert_eq!(u16::from_le_bytes(plain[44..46].try_into().unwrap()), 5);
918        assert_eq!(sample().section(SectionKind::BuildInfo).unwrap().length, 0);
919
920        // Present: a directory entry that round-trips and resolves as kind 7.
921        let h = sample().with_section(SectionKind::BuildInfo, 1066, 300);
922        let bytes = h.to_bytes();
923        assert_eq!(u16::from_le_bytes(bytes[44..46].try_into().unwrap()), 6);
924        let back = Header::from_bytes(&bytes).unwrap();
925        assert_eq!(h, back);
926        let s = back.section(SectionKind::BuildInfo).unwrap();
927        assert_eq!((s.offset, s.length), (1066, 300));
928        assert!(back.extra_sections.is_empty(), "BuildInfo is a known kind");
929        // And it extends the expected file length like any other section.
930        let far = sample().with_section(SectionKind::BuildInfo, 1 << 21, 128);
931        assert_eq!(far.expected_file_len(), Some((1 << 21) + 128 + 4));
932    }
933
934    #[test]
935    fn label_index_and_schema_sections_are_known_extras() {
936        // Absent: the directory is unchanged (5 entries), so a file without
937        // them is byte-identical to one built before they existed.
938        assert!(sample().label_index_section().is_none());
939        assert!(sample().schema_section().is_none());
940
941        let h = sample()
942            .with_section(SectionKind::LabelIndex, 8000, 300)
943            .with_section(SectionKind::Schema, 8300, 40);
944        let bytes = h.to_bytes();
945        assert_eq!(u16::from_le_bytes(bytes[44..46].try_into().unwrap()), 7);
946        // entries 5 and 6 carry kinds 8 and 9 at the directory's byte layout
947        assert_eq!(
948            u16::from_le_bytes(bytes[64 + 5 * 24..][..2].try_into().unwrap()),
949            8
950        );
951        assert_eq!(
952            u16::from_le_bytes(bytes[64 + 6 * 24..][..2].try_into().unwrap()),
953            9
954        );
955        let back = Header::from_bytes(&bytes).unwrap();
956        assert_eq!(h, back);
957        assert_eq!(back.extra_sections.len(), 2, "stored with the extras");
958        let l = back.label_index_section().unwrap();
959        assert_eq!((l.offset, l.length), (8000, 300));
960        let s = back.schema_section().unwrap();
961        assert_eq!((s.offset, s.length), (8300, 40));
962        assert_eq!(back.expected_file_len(), Some(8340 + 4));
963
964        // A known post-freeze kind is overwritten, never duplicated.
965        let again = back.with_section(SectionKind::Schema, 9000, 10);
966        assert_eq!(again.extra_sections.len(), 2);
967        assert_eq!(again.schema_section().unwrap().offset, 9000);
968
969        // A zero-length entry reads as absent.
970        let empty = sample().with_section(SectionKind::LabelIndex, 0, 0);
971        assert!(empty.label_index_section().is_none());
972    }
973
974    #[test]
975    fn quad_index_flag_and_section_round_trip() {
976        // Absent: no flag, no kind-10 entry, the container is the permutations.
977        let plain = sample();
978        assert!(!plain.has_quad_index());
979        assert!(plain.quad_index_section().is_none());
980        assert_eq!(plain.index_container_sections(), 6);
981
982        // Present: flag bit 3, a kind-10 entry kept with the extra sections,
983        // and one more container section (the seal).
984        let mut h = sample().with_section(SectionKind::QuadIndex, 7000, 900);
985        h.flags |= FLAG_QUAD_INDEX;
986        h.named_graphs_offset = 0;
987        h.named_graphs_len = 0;
988        let bytes = h.to_bytes();
989        assert_eq!(bytes[5] & 0b1000, 0b1000, "flag bit 3");
990        let n = u16::from_le_bytes(bytes[44..46].try_into().unwrap()) as usize;
991        let kinds: Vec<u16> = (0..n)
992            .map(|i| {
993                let p = SECTION_DIR_OFFSET + i * SECTION_ENTRY_LEN;
994                u16::from_le_bytes(bytes[p..p + 2].try_into().unwrap())
995            })
996            .collect();
997        assert!(kinds.contains(&10), "a kind-10 directory entry: {kinds:?}");
998        let back = Header::from_bytes(&bytes).unwrap();
999        assert_eq!(back, h);
1000        assert!(back.has_quad_index());
1001        let s = back.quad_index_section().unwrap();
1002        assert_eq!(
1003            (s.kind, s.offset, s.length),
1004            (SectionKind::QuadIndex, 7000, 900)
1005        );
1006        assert_eq!(back.index_container_sections(), 7);
1007        assert_eq!(back.expected_file_len(), Some(7000 + 900 + 4));
1008        // Three permutations + seal = four: never the 3 or 6 an old reader wants.
1009        let mut lean = back.clone();
1010        lean.perms = crate::index::PermSet::CORE;
1011        assert_eq!(lean.index_container_sections(), 4);
1012        // Re-attaching overwrites rather than duplicating.
1013        let again = back.with_section(SectionKind::QuadIndex, 8000, 10);
1014        assert_eq!(again.extra_sections.len(), 1);
1015        assert_eq!(again.quad_index_section().unwrap().offset, 8000);
1016    }
1017
1018    #[test]
1019    fn text_index_section_round_trips_and_is_optional() {
1020        // Absent (len 0): only the 5 always-present sections, so a file without a
1021        // text index is byte-identical to one built before this section existed.
1022        assert_eq!(
1023            u16::from_le_bytes(sample().to_bytes()[44..46].try_into().unwrap()),
1024            5
1025        );
1026        assert!(sample().section(SectionKind::TextIndex).unwrap().length == 0);
1027
1028        // Present: a 6th directory entry that round-trips and resolves.
1029        let h = sample().with_section(SectionKind::TextIndex, 5000, 4096);
1030        let bytes = h.to_bytes();
1031        assert_eq!(u16::from_le_bytes(bytes[44..46].try_into().unwrap()), 6);
1032        let back = Header::from_bytes(&bytes).unwrap();
1033        assert_eq!(h, back);
1034        let s = back.section(SectionKind::TextIndex).unwrap();
1035        assert_eq!((s.offset, s.length), (5000, 4096));
1036        assert!(back.extra_sections.is_empty(), "TextIndex is a known kind");
1037        assert!(!back.text_index_partial);
1038    }
1039
1040    /// A predicate-restricted text index is written as kind 11, maps onto the
1041    /// same `text_index_*` fields, and a reader that only knows kind 6 sees an
1042    /// unknown section — no text index at all — rather than a full one.
1043    #[test]
1044    fn partial_text_index_is_kind_11_and_invisible_as_kind_6() {
1045        let h = sample().with_section(SectionKind::TextIndexPartial, 5000, 4096);
1046        assert!(h.text_index_partial);
1047        let bytes = h.to_bytes();
1048        assert_eq!(u16::from_le_bytes(bytes[44..46].try_into().unwrap()), 6);
1049        // The 6th entry, at 64 + 5 × 24, carries kind 11.
1050        let p = SECTION_DIR_OFFSET + 5 * SECTION_ENTRY_LEN;
1051        assert_eq!(u16::from_le_bytes(bytes[p..p + 2].try_into().unwrap()), 11);
1052        let back = Header::from_bytes(&bytes).unwrap();
1053        assert_eq!(h, back);
1054        assert!(back.text_index_partial);
1055        assert_eq!((back.text_index_offset, back.text_index_len), (5000, 4096));
1056        let s = back.section(SectionKind::TextIndexPartial).unwrap();
1057        assert_eq!((s.offset, s.length), (5000, 4096));
1058        assert_eq!(back.section(SectionKind::TextIndex).unwrap().length, 0);
1059        assert!(back.extra_sections.is_empty(), "kind 11 is known here");
1060
1061        // What a reader that predates kind 11 decodes: an unknown kind, which
1062        // it preserves, and empty text-index fields — so it neither searches
1063        // nor prunes with the partial index.
1064        let mut old_view = bytes;
1065        old_view[p..p + 2].copy_from_slice(&0x7777u16.to_le_bytes());
1066        let old = Header::from_bytes(&old_view).unwrap();
1067        assert_eq!(old.text_index_len, 0);
1068        assert_eq!(old.extra_sections.len(), 1);
1069    }
1070}