Skip to main content

rete_core/
recard.rs

1//! Re-carding a finished `.rete` — replacing its Dataset Card and build record
2//! **without rebuilding it**.
3//!
4//! A card used to be fixed at build time: it sits inside the content hash, near
5//! the front of the file, so the only way to a new one was a new build. On the
6//! 2.9 B-statement OpenCitations parts that is three hours each, for a few KB of
7//! JSON. Everything a card is derived from is already in the file — sorted, see
8//! [`crate::card::derive_card_streaming`] — and every other byte of the file is
9//! untouched by a new card, so a re-card is a *rewrite of two sections and a
10//! copy of the rest*:
11//!
12//! * the metadata section (the card) and the build-info section are replaced,
13//!   in place, adjacent as the writers lay them out;
14//! * every other section — dictionary, index, pyramid, named graphs, text
15//!   index, and any section kind this reader does not know — is copied **byte
16//!   for byte**, in its place in the file order; only its offset in the header
17//!   moves, and bytes no section points at (what an in-place re-card leaves,
18//!   below) are not copied;
19//! * the content hash is recomputed, because the card is inside it
20//!   ([`crate::format::content_hash_parts`]); it is folded while the bytes go
21//!   past, so the whole job is one read and one write of the file.
22//!
23//! [`plan_card_rewrite`] is the arithmetic, kept apart (like
24//! [`crate::format::plan_build_info`]) so it is testable without a file;
25//! [`rewrite_card`] is the streaming copy.
26//!
27//! ## The card after the data
28//!
29//! A copy is the price of keeping the card at the front. The header is the
30//! directory, so the card can sit anywhere: [`plan_card_append`],
31//! [`card_append_hash`] and [`commit_card_append`] place a new card and build
32//! record **after every other section**, in the file itself, with no copy.
33//! The memory-bounded build uses them to write its full card once the index
34//! sections it is derived from are on disk, and `rete recard --in-place` to
35//! replace the card of an existing file. The content hash still has to be
36//! folded over every hashed section — the card is hashed first, wherever it
37//! lies — so the job is one read of the file and a few KB of writes.
38//!
39//! In place, the order of the writes is what keeps a crash harmless: the new
40//! card, build record and footer go **past the end the old header records**,
41//! then they are synced, and only then is the 1 KiB header rewritten (and
42//! synced). Until that one write lands, every reader still sees the old file
43//! — its bytes are never touched, only appended to — and once it lands, the
44//! new one. The old card and build record stay where they were, as bytes no
45//! header points at.
46
47use std::io::{Seek, SeekFrom, Write};
48
49use crate::file::{content_hash_parts, FileError};
50use crate::header::{Header, HEADER_LEN, MAGIC};
51use crate::reader::RangeReader;
52
53/// What a card rewrite does to a file's layout.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct CardRewritePlan {
56    /// The new header: every section offset fixed, metadata and build info at
57    /// their new lengths. The content hash is still the **old** one —
58    /// [`rewrite_card`] writes the new one once it has folded it.
59    pub header: Header,
60    /// The sections of the old file copied verbatim, as `(old offset,
61    /// length)`, in file order — every section the header points at except
62    /// the card and the build record. They are written back to back, so bytes
63    /// no section points at (a card an in-place re-card left behind, say) are
64    /// not copied.
65    pub copies: Vec<(u64, u64)>,
66    /// How many of [`copies`](Self::copies) come before the new card: the new
67    /// card and build record take the old ones' place in the file order —
68    /// first on every file the in-RAM writers produce, last on one whose card
69    /// was placed after the data.
70    pub copies_before_card: usize,
71    /// Where the new metadata section starts (build info follows it).
72    pub card_offset: u64,
73    /// The rewritten file's length, footer included.
74    pub new_len: u64,
75}
76
77/// Plan replacing the metadata and build-info sections of a file whose first
78/// [`HEADER_LEN`] bytes are `head` and whose length is `file_len`, with
79/// sections of `metadata_len` and `build_info_len` bytes (`0` = none).
80///
81/// The card occupies one span — the metadata section, with the build-info
82/// section immediately after it (the layout every writer produces, so a card
83/// read is one coalesced range). A file with neither gets them right after the
84/// header. Every other section keeps its place in the file order and is laid
85/// back to back after the header, the new card in the old one's place, then
86/// the footer. A section overlapping another (or the card), or a build-info
87/// section that is not adjacent to the metadata, is a layout this function
88/// will not reshape and is refused rather than guessed at.
89pub fn plan_card_rewrite(
90    head: &[u8],
91    file_len: u64,
92    metadata_len: u64,
93    build_info_len: u64,
94) -> Result<CardRewritePlan, FileError> {
95    /// Which header field a live section is.
96    #[derive(Clone, Copy)]
97    enum Slot {
98        Card,
99        Dictionary,
100        Index,
101        Pyramid,
102        Named,
103        Text,
104        Extra(usize),
105    }
106
107    let mut header = Header::from_bytes(head)?;
108    let (m_off, m_len) = (header.metadata_offset, header.metadata_len);
109    let (b_off, b_len) = (header.build_info_offset, header.build_info_len);
110    let (start, end) = match (m_len > 0, b_len > 0) {
111        (true, true) => {
112            if b_off != m_off.saturating_add(m_len) {
113                return Err(FileError::Container(
114                    "the build-info section is not adjacent to the metadata section",
115                ));
116            }
117            (m_off, b_off.saturating_add(b_len))
118        }
119        (true, false) => (m_off, m_off.saturating_add(m_len)),
120        (false, true) => (b_off, b_off.saturating_add(b_len)),
121        (false, false) => (HEADER_LEN as u64, HEADER_LEN as u64),
122    };
123    if start < HEADER_LEN as u64 || end > file_len {
124        return Err(FileError::Container(
125            "the card section lies outside the file",
126        ));
127    }
128
129    let mut live: Vec<(u64, u64, Slot)> = vec![(start, end - start, Slot::Card)];
130    let named = [
131        (
132            header.dictionary_offset,
133            header.dictionary_len,
134            Slot::Dictionary,
135        ),
136        (header.root_dir_offset, header.root_dir_len, Slot::Index),
137        (
138            header.pyramid_meta_offset,
139            header.pyramid_meta_len,
140            Slot::Pyramid,
141        ),
142        (
143            header.named_graphs_offset,
144            header.named_graphs_len,
145            Slot::Named,
146        ),
147        (header.text_index_offset, header.text_index_len, Slot::Text),
148    ];
149    let extras = header
150        .extra_sections
151        .iter()
152        .enumerate()
153        .map(|(i, s)| (s.offset, s.length, Slot::Extra(i)));
154    for (off, len, slot) in named.into_iter().chain(extras) {
155        if len == 0 {
156            continue;
157        }
158        if off < HEADER_LEN as u64 || off.checked_add(len).is_none_or(|e| e > file_len) {
159            return Err(FileError::Container("a section overruns the file"));
160        }
161        live.push((off, len, slot));
162    }
163    // File order; an empty card span sorts ahead of a section at its offset.
164    live.sort_by_key(|&(off, _, slot)| (off, !matches!(slot, Slot::Card)));
165    if live.windows(2).any(|w| w[0].0 + w[0].1 > w[1].0) {
166        return Err(FileError::Container(
167            "a section overlaps the card or another section; this layout cannot be re-carded",
168        ));
169    }
170
171    let new_span = metadata_len.saturating_add(build_info_len);
172    let mut at = HEADER_LEN as u64;
173    let mut copies = Vec::with_capacity(live.len());
174    let (mut card_offset, mut copies_before_card) = (at, 0);
175    for (off, len, slot) in live {
176        let field = match slot {
177            Slot::Card => {
178                card_offset = at;
179                copies_before_card = copies.len();
180                at += new_span;
181                continue;
182            }
183            Slot::Dictionary => &mut header.dictionary_offset,
184            Slot::Index => &mut header.root_dir_offset,
185            Slot::Pyramid => &mut header.pyramid_meta_offset,
186            Slot::Named => &mut header.named_graphs_offset,
187            Slot::Text => &mut header.text_index_offset,
188            Slot::Extra(i) => &mut header.extra_sections[i].offset,
189        };
190        *field = at;
191        copies.push((off, len));
192        at += len;
193    }
194
195    header.metadata_offset = if metadata_len > 0 { card_offset } else { 0 };
196    header.metadata_len = metadata_len;
197    header.build_info_offset = if build_info_len > 0 {
198        card_offset + metadata_len
199    } else {
200        0
201    };
202    header.build_info_len = build_info_len;
203    Ok(CardRewritePlan {
204        header,
205        copies,
206        copies_before_card,
207        card_offset,
208        new_len: at + MAGIC.len() as u64,
209    })
210}
211
212/// How the rewriter moves bytes: large enough to keep a disk streaming, small
213/// enough that the peak memory of a 26 GB rewrite is this and nothing else.
214pub const REWRITE_CHUNK: u64 = 8 << 20;
215
216/// Rewrite `src` into `out` with `metadata` as its card and `build_info` as its
217/// build record (either may be empty, which removes the section), copying every
218/// other section verbatim and folding the new content hash on the way. Returns
219/// the header written at offset 0.
220///
221/// `out` must start empty; it is written front to back, then the header is
222/// rewritten once the hash is known (hence `Seek`). `progress` is called with
223/// the bytes copied so far after each chunk.
224///
225/// When the hashed sections do not follow each other in the file (no writer
226/// lays them out that way, but a reader cannot assume its writers), the hash
227/// is folded in a second pass over `src` instead — the result is the same.
228pub fn rewrite_card<R: RangeReader + ?Sized, W: Write + Seek>(
229    src: &R,
230    out: &mut W,
231    metadata: &[u8],
232    build_info: &[u8],
233    progress: &mut dyn FnMut(u64),
234) -> Result<Header, FileError> {
235    rewrite_card_impl(src, out, metadata, build_info, progress, false)
236}
237
238/// [`rewrite_card`], optionally forcing the second-pass hash — the path no
239/// writer's layout reaches, which the tests take on purpose.
240fn rewrite_card_impl<R: RangeReader + ?Sized, W: Write + Seek>(
241    src: &R,
242    out: &mut W,
243    metadata: &[u8],
244    build_info: &[u8],
245    progress: &mut dyn FnMut(u64),
246    force_second_pass: bool,
247) -> Result<Header, FileError> {
248    let file_len = src.len();
249    if file_len < HEADER_LEN as u64 {
250        return Err(FileError::Container("file shorter than its header"));
251    }
252    let head = src.read_at(0, HEADER_LEN as u64)?;
253    let plan = plan_card_rewrite(
254        &head,
255        file_len,
256        metadata.len() as u64,
257        build_info.len() as u64,
258    )?;
259    let mut header = plan.header.clone();
260
261    // The hashed ranges in **output** coordinates, in hash order. The metadata
262    // part is the new card; every other part is unchanged input bytes.
263    let parts = content_hash_parts(&header);
264    let streamable = !force_second_pass && parts.windows(2).all(|w| w[0].0 + w[0].1 <= w[1].0);
265    let mut hasher = blake3::Hasher::new();
266    let feed = HashFeed {
267        parts: &parts,
268        next: 0,
269    };
270
271    out.write_all(&header.to_bytes())?;
272    let mut sink = Sink {
273        out: &mut *out,
274        written: HEADER_LEN as u64,
275        streamable,
276        feed,
277        hasher: &mut hasher,
278    };
279    let mut copied = 0u64;
280    for i in 0..=plan.copies.len() {
281        if i == plan.copies_before_card {
282            sink.write(metadata)?;
283            sink.write(build_info)?;
284        }
285        let Some(&(from, len)) = plan.copies.get(i) else {
286            break;
287        };
288        let mut at = from;
289        while at < from + len {
290            let n = (from + len - at).min(REWRITE_CHUNK);
291            let buf = src.read_at(at, n)?;
292            if buf.len() as u64 != n {
293                return Err(FileError::Container("short read while copying"));
294            }
295            sink.write(&buf)?;
296            at += n;
297            copied += n;
298            progress(copied);
299        }
300    }
301    sink.write(&MAGIC)?;
302    let (written, folded) = (sink.written, sink.feed.next);
303    if written != plan.new_len {
304        return Err(FileError::Container(
305            "the rewrite came out the wrong length",
306        ));
307    }
308
309    if !streamable || folded < parts.len() {
310        // Second pass, in hash order: the new card, then every other hashed
311        // section from where it lives in the old file.
312        hasher = blake3::Hasher::new();
313        hasher.update(metadata);
314        let mut old = Header::from_bytes(&head)?;
315        old.metadata_len = 0;
316        for (off, len) in content_hash_parts(&old) {
317            let mut at = off;
318            while at < off + len {
319                let n = (off + len - at).min(REWRITE_CHUNK);
320                hasher.update(&src.read_at(at, n)?);
321                at += n;
322            }
323        }
324    }
325    header
326        .content_hash
327        .copy_from_slice(&hasher.finalize().as_bytes()[..16]);
328    out.seek(SeekFrom::Start(0))?;
329    out.write_all(&header.to_bytes())?;
330    out.flush()?;
331    Ok(header)
332}
333
334/// What placing a new card after a file's data does to its layout — see "The
335/// card after the data" in the module docs.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct CardAppendPlan {
338    /// The new header: the metadata section at [`card_offset`](Self::card_offset),
339    /// no build-info section yet (its length is only known when the record is
340    /// sealed, after the hash — [`commit_card_append`] fills it), every other
341    /// section where it was, and still the **old** content hash.
342    pub header: Header,
343    /// Where the new card starts: the file's end as its header records it
344    /// (in place — past the old footer, so the old file stays whole), or the
345    /// old footer itself (a fresh file nobody has read yet, so no gap).
346    pub card_offset: u64,
347    /// The file's length as its own header records it
348    /// ([`Header::expected_file_len`]).
349    pub old_len: u64,
350    /// The bytes no section points at once the plan is committed: the old card
351    /// and build record, and — in place — the old 4-byte footer.
352    pub dead_bytes: u64,
353    /// The header bytes the plan was made from. [`commit_card_append`]
354    /// refuses a file whose header no longer reads exactly this.
355    pub old_head: Vec<u8>,
356}
357
358/// Plan placing a card of `metadata_len` bytes (`0` = none) after the data of
359/// the file whose first [`HEADER_LEN`] bytes are `head` and whose length on
360/// disk is `file_len`.
361///
362/// `in_place` is for a file readers may already hold: the card goes past the
363/// old footer, which is never overwritten, so an interrupted run leaves the
364/// old file readable by every reader, the remote ones that confirm a length by
365/// its footer included. Without it — a file this process has just written —
366/// the card replaces the footer and the layout has no gap.
367///
368/// Bytes on disk past the end the header records are not part of the file:
369/// they are what an interrupted in-place run leaves, and they are overwritten.
370/// A file **shorter** than its header records is refused.
371pub fn plan_card_append(
372    head: &[u8],
373    file_len: u64,
374    metadata_len: u64,
375    in_place: bool,
376) -> Result<CardAppendPlan, FileError> {
377    let mut header = Header::from_bytes(head)?;
378    let old_len = header.expected_file_len().ok_or(FileError::Container(
379        "the header's section directory overflows",
380    ))?;
381    if old_len > file_len {
382        return Err(FileError::Container(
383            "the file is shorter than its header records (truncated?)",
384        ));
385    }
386    let footer = MAGIC.len() as u64;
387    let card_offset = if in_place { old_len } else { old_len - footer };
388    let dead_bytes = header
389        .metadata_len
390        .saturating_add(header.build_info_len)
391        .saturating_add(if in_place { footer } else { 0 });
392    if metadata_len > 0 {
393        header.metadata_offset = card_offset;
394        header.metadata_len = metadata_len;
395    } else {
396        header.metadata_offset = 0;
397        header.metadata_len = 0;
398    }
399    header.build_info_offset = 0;
400    header.build_info_len = 0;
401    Ok(CardAppendPlan {
402        header,
403        card_offset,
404        old_len,
405        dead_bytes,
406        old_head: head[..HEADER_LEN].to_vec(),
407    })
408}
409
410/// Fold the content hash the file will have once `plan` is committed with
411/// `metadata` as its card: the card from memory, every other hashed section
412/// read from `src` (the file as the plan found it) in [`REWRITE_CHUNK`]
413/// slices. `progress` gets the bytes read so far. The build record does not
414/// take part — it is outside the hash — which is what lets a caller seal it
415/// *after* this, its longest step, and still commit it in the same write.
416///
417/// The file must still end in its footer where its header says it does; a
418/// file that does not is not one this plan describes.
419pub fn card_append_hash<R: RangeReader + ?Sized>(
420    src: &R,
421    plan: &CardAppendPlan,
422    metadata: &[u8],
423    progress: &mut dyn FnMut(u64),
424) -> Result<[u8; 16], FileError> {
425    if metadata.len() as u64 != plan.header.metadata_len {
426        return Err(FileError::Container(
427            "the card is not the length the plan was made for",
428        ));
429    }
430    let footer = MAGIC.len() as u64;
431    if src.len() < plan.old_len || src.read_at(plan.old_len - footer, footer)? != MAGIC {
432        return Err(FileError::Container(
433            "the file does not end in its footer where its header says",
434        ));
435    }
436    let mut hasher = blake3::Hasher::new();
437    let mut read = 0u64;
438    for (i, (off, len)) in content_hash_parts(&plan.header).into_iter().enumerate() {
439        if i == 0 && !metadata.is_empty() {
440            // `content_hash_parts` lists the metadata section first.
441            hasher.update(metadata);
442            continue;
443        }
444        // Every other part is a section of the old file, which ends at its
445        // footer; nothing hashed can reach the new card.
446        if off
447            .checked_add(len)
448            .is_none_or(|end| end > plan.old_len - footer)
449        {
450            return Err(FileError::Container("a hashed section overruns the file"));
451        }
452        let mut at = off;
453        while at < off + len {
454            let n = (off + len - at).min(REWRITE_CHUNK);
455            let buf = src.read_at(at, n)?;
456            if buf.len() as u64 != n {
457                return Err(FileError::Container("short read while hashing"));
458            }
459            hasher.update(&buf);
460            at += n;
461            read += n;
462            progress(read);
463        }
464    }
465    let mut hash = [0u8; 16];
466    hash.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
467    Ok(hash)
468}
469
470/// Commit `plan`: write `metadata`, then `build_info` (outside the hash, so it
471/// may be sealed after [`card_append_hash`]), then a footer at the plan's
472/// card offset, cut the file there, **sync**, and only then rewrite the 1 KiB
473/// header with `hash` and the new section entries, and sync again. Returns the
474/// header written.
475///
476/// Refuses a file whose header is no longer the one the plan was made from
477/// (another writer got there first). In place, nothing the old header points
478/// at is written before the new header lands: a crash before that leaves the
479/// old file, plus bytes past its end that the next run overwrites; a crash
480/// after it, the new file. The header is a single 1 KiB write at offset 0,
481/// inside the file's first page.
482#[cfg(not(target_arch = "wasm32"))]
483pub fn commit_card_append(
484    file: &mut std::fs::File,
485    plan: &CardAppendPlan,
486    hash: [u8; 16],
487    metadata: &[u8],
488    build_info: &[u8],
489) -> Result<Header, FileError> {
490    commit_card_append_impl(file, plan, hash, metadata, build_info, false)
491}
492
493/// [`commit_card_append`], optionally failing just before the header is
494/// written — the crash the tests simulate.
495#[cfg(not(target_arch = "wasm32"))]
496fn commit_card_append_impl(
497    file: &mut std::fs::File,
498    plan: &CardAppendPlan,
499    hash: [u8; 16],
500    metadata: &[u8],
501    build_info: &[u8],
502    fail_before_header: bool,
503) -> Result<Header, FileError> {
504    use std::io::Read;
505    if metadata.len() as u64 != plan.header.metadata_len {
506        return Err(FileError::Container(
507            "the card is not the length the plan was made for",
508        ));
509    }
510    let mut head = vec![0u8; HEADER_LEN];
511    file.seek(SeekFrom::Start(0))?;
512    file.read_exact(&mut head)?;
513    if head != plan.old_head || file.metadata()?.len() < plan.old_len {
514        return Err(FileError::Container(
515            "the file changed since the card was planned; nothing was written",
516        ));
517    }
518    let mut header = plan.header.clone();
519    header.content_hash = hash;
520    let info_offset = plan.card_offset + metadata.len() as u64;
521    if !build_info.is_empty() {
522        header.build_info_offset = info_offset;
523        header.build_info_len = build_info.len() as u64;
524    }
525    let end = info_offset + build_info.len() as u64 + MAGIC.len() as u64;
526    debug_assert_eq!(header.expected_file_len(), Some(end));
527
528    // 1. The new sections and the footer, past (in place) or over (fresh) the
529    //    old footer — never over a byte the old header points at.
530    file.seek(SeekFrom::Start(plan.card_offset))?;
531    let mut tail = Vec::with_capacity((end - plan.card_offset) as usize);
532    tail.extend_from_slice(metadata);
533    tail.extend_from_slice(build_info);
534    tail.extend_from_slice(&MAGIC);
535    file.write_all(&tail)?;
536    file.set_len(end)?;
537    file.sync_all()?;
538    if fail_before_header {
539        return Err(FileError::Container("simulated crash before the header"));
540    }
541    // 2. The header, last.
542    file.seek(SeekFrom::Start(0))?;
543    file.write_all(&header.to_bytes())?;
544    file.sync_all()?;
545    Ok(header)
546}
547
548/// The output side of a rewrite: writes go through here so the hash folds
549/// exactly the bytes that land in the file, at the offsets they land at.
550struct Sink<'a, 'p, W> {
551    out: &'a mut W,
552    written: u64,
553    streamable: bool,
554    feed: HashFeed<'p>,
555    hasher: &'a mut blake3::Hasher,
556}
557
558impl<W: Write> Sink<'_, '_, W> {
559    fn write(&mut self, buf: &[u8]) -> Result<(), FileError> {
560        self.out.write_all(buf)?;
561        if self.streamable {
562            self.feed.fold(self.written, buf, self.hasher);
563        }
564        self.written += buf.len() as u64;
565        Ok(())
566    }
567}
568
569/// Folds the bytes of a sequential write into the hash, for the parts of
570/// `parts` (output coordinates, hash order, ascending) the write overlaps.
571struct HashFeed<'a> {
572    parts: &'a [(u64, u64)],
573    next: usize,
574}
575
576impl HashFeed<'_> {
577    fn fold(&mut self, at: u64, buf: &[u8], hasher: &mut blake3::Hasher) {
578        let end = at + buf.len() as u64;
579        while let Some(&(off, len)) = self.parts.get(self.next) {
580            let part_end = off + len;
581            if off >= end {
582                return;
583            }
584            let lo = off.max(at);
585            let hi = part_end.min(end);
586            if lo < hi {
587                hasher.update(&buf[(lo - at) as usize..(hi - at) as usize]);
588            }
589            if part_end <= end {
590                self.next += 1;
591            } else {
592                return;
593            }
594        }
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::card::{derive_card, CardInput};
602    use crate::reader::SliceReader;
603
604    fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
605        (s.into(), p.into(), o.into(), None)
606    }
607
608    fn quads() -> Vec<(String, String, String, Option<String>)> {
609        let ty = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
610        let mut v = Vec::new();
611        for i in 0..20 {
612            let s = format!("<http://ex/item/{i}>");
613            v.push(q(&s, ty, "<http://ex/Item>"));
614            v.push(q(
615                &s,
616                "<http://ex/next>",
617                &format!("<http://ex/item/{}>", (i + 1) % 20),
618            ));
619            v.push(q(&s, "<http://ex/name>", &format!("\"item {i}\"")));
620        }
621        v.push((
622            "<http://ex/a>".into(),
623            "<http://ex/p>".into(),
624            "<http://ex/b>".into(),
625            Some("<http://ex/g>".into()),
626        ));
627        v
628    }
629
630    /// A carded file, with a build record after the card like every writer
631    /// lays them out.
632    fn carded(text_index: bool) -> Vec<u8> {
633        let quads = quads();
634        let card = derive_card(&quads, 50, 1, CardInput::default()).to_json_bytes();
635        let (bytes, _) = crate::ingest::assemble_dataset_with_opts(
636            quads,
637            true,
638            text_index,
639            None,
640            move |_, _| card.clone(),
641        );
642        crate::format::attach_build_info(&bytes, br#"{"schema":1,"builder":"old"}"#).unwrap()
643    }
644
645    fn rewrite(src: &[u8], meta: &[u8], info: &[u8]) -> Vec<u8> {
646        let mut out = std::io::Cursor::new(Vec::new());
647        rewrite_card(&SliceReader::new(src), &mut out, meta, info, &mut |_| {}).unwrap();
648        out.into_inner()
649    }
650
651    /// Every section other than the card and the build record comes through
652    /// byte for byte, the hash is the one `verify` recomputes, and the result
653    /// equals the in-memory splice the build itself uses.
654    #[test]
655    fn a_rewrite_copies_every_other_section_and_rehashes() {
656        for text_index in [false, true] {
657            let src = carded(text_index);
658            let old = Header::from_bytes(&src).unwrap();
659            let meta = br#"{"title":"new card","triple_count":60,"quad_count":61,"named_graph_count":1,"term_count":50,"format_version":5}"#;
660            let info = br#"{"schema":1,"builder":"recard"}"#;
661            let out = rewrite(&src, meta, info);
662            let new = Header::from_bytes(&out).unwrap();
663            assert!(
664                crate::format::verify(&out).unwrap(),
665                "the new hash verifies"
666            );
667            assert_ne!(
668                new.content_hash, old.content_hash,
669                "the card is inside the hash"
670            );
671            let sec = |b: &[u8], off: u64, len: u64| b[off as usize..(off + len) as usize].to_vec();
672            assert_eq!(
673                sec(&out, new.metadata_offset, new.metadata_len),
674                meta.to_vec()
675            );
676            assert_eq!(
677                sec(&out, new.build_info_offset, new.build_info_len),
678                info.to_vec()
679            );
680            for (o, n) in [
681                (
682                    (old.dictionary_offset, old.dictionary_len),
683                    (new.dictionary_offset, new.dictionary_len),
684                ),
685                (
686                    (old.root_dir_offset, old.root_dir_len),
687                    (new.root_dir_offset, new.root_dir_len),
688                ),
689                (
690                    (old.named_graphs_offset, old.named_graphs_len),
691                    (new.named_graphs_offset, new.named_graphs_len),
692                ),
693                (
694                    (old.text_index_offset, old.text_index_len),
695                    (new.text_index_offset, new.text_index_len),
696                ),
697            ] {
698                assert_eq!(o.1, n.1);
699                assert_eq!(sec(&src, o.0, o.1), sec(&out, n.0, n.1));
700            }
701            // The footer survives too.
702            assert_eq!(&out[out.len() - 4..], &src[src.len() - 4..]);
703            // Same bytes as the build's own splice: replace the metadata, then
704            // the build info.
705            let spliced = crate::format::attach_build_info(
706                &crate::format::replace_metadata(&src, meta).unwrap(),
707                info,
708            )
709            .unwrap();
710            assert_eq!(out, spliced);
711            // And the file still answers queries.
712            let rete = crate::Rete::open(&out).unwrap();
713            assert_eq!(rete.dump(None).len(), 60);
714        }
715    }
716
717    /// The second-pass hash — reading each part back from the source in hash
718    /// order — lands on exactly the bytes and the hash the streamed fold does,
719    /// for a growing and for a shrinking card.
720    #[test]
721    fn the_second_pass_hash_agrees_with_the_streamed_one() {
722        let src = carded(false);
723        for meta in [&b"{}"[..], &[b'x'; 5000][..]] {
724            let streamed = rewrite(&src, meta, b"{}");
725            let mut out = std::io::Cursor::new(Vec::new());
726            rewrite_card_impl(
727                &SliceReader::new(&src),
728                &mut out,
729                meta,
730                b"{}",
731                &mut |_| {},
732                true,
733            )
734            .unwrap();
735            assert_eq!(out.into_inner(), streamed);
736        }
737    }
738
739    /// A smaller card and no build record shrink the file; removing the card
740    /// entirely leaves a valid cardless file.
741    #[test]
742    fn a_rewrite_can_shrink_and_remove() {
743        let src = carded(false);
744        let out = rewrite(&src, b"{}", b"");
745        let h = Header::from_bytes(&out).unwrap();
746        assert_eq!(h.build_info_len, 0);
747        assert!(crate::format::verify(&out).unwrap());
748        let out = rewrite(&src, b"", b"");
749        let h = Header::from_bytes(&out).unwrap();
750        assert_eq!((h.metadata_len, h.build_info_len), (0, 0));
751        assert!(crate::format::verify(&out).unwrap());
752        assert!(crate::Rete::open(&out).is_ok());
753    }
754
755    /// A cardless file gains its card right after the header.
756    #[test]
757    fn a_cardless_file_gains_a_card_after_the_header() {
758        let (src, _) =
759            crate::ingest::assemble_dataset_with_opts(quads(), true, false, None, |_, _| {
760                Vec::new()
761            });
762        let out = rewrite(&src, br#"{"title":"t"}"#, br#"{"schema":1}"#);
763        let h = Header::from_bytes(&out).unwrap();
764        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
765        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + h.metadata_len);
766        assert!(crate::format::verify(&out).unwrap());
767    }
768
769    /// A build record that does not follow the card is a layout the plan
770    /// refuses rather than reshapes.
771    #[test]
772    fn a_detached_build_record_is_refused() {
773        let src = carded(false);
774        let mut h = Header::from_bytes(&src).unwrap();
775        h.build_info_offset += 1;
776        let err = plan_card_rewrite(&h.to_bytes(), src.len() as u64, 10, 10).unwrap_err();
777        assert!(err.to_string().contains("not adjacent"), "{err}");
778    }
779
780    /// A scratch file holding `bytes`, removed on drop.
781    struct Scratch(std::path::PathBuf);
782
783    impl Scratch {
784        fn new(tag: &str, bytes: &[u8]) -> Self {
785            use std::sync::atomic::{AtomicU64, Ordering};
786            static N: AtomicU64 = AtomicU64::new(0);
787            let path = std::env::temp_dir().join(format!(
788                "rete-recard-{tag}-{}-{}.rete",
789                std::process::id(),
790                N.fetch_add(1, Ordering::Relaxed)
791            ));
792            std::fs::write(&path, bytes).unwrap();
793            Scratch(path)
794        }
795
796        fn bytes(&self) -> Vec<u8> {
797            std::fs::read(&self.0).unwrap()
798        }
799
800        fn open(&self) -> std::fs::File {
801            std::fs::OpenOptions::new()
802                .read(true)
803                .write(true)
804                .open(&self.0)
805                .unwrap()
806        }
807    }
808
809    impl Drop for Scratch {
810        fn drop(&mut self) {
811            let _ = std::fs::remove_file(&self.0);
812        }
813    }
814
815    /// Plan, hash and commit a card after the data of `file` — the whole
816    /// in-place sequence, as the CLI runs it.
817    fn append(file: &Scratch, meta: &[u8], info: &[u8], in_place: bool) -> Header {
818        let bytes = file.bytes();
819        let plan =
820            plan_card_append(&bytes, bytes.len() as u64, meta.len() as u64, in_place).unwrap();
821        let hash = card_append_hash(&SliceReader::new(&bytes), &plan, meta, &mut |_| {}).unwrap();
822        commit_card_append(&mut file.open(), &plan, hash, meta, info).unwrap()
823    }
824
825    const META: &[u8] = br#"{"title":"after the data","triple_count":60,"quad_count":61,"named_graph_count":1,"term_count":50,"format_version":5}"#;
826    const INFO: &[u8] = br#"{"schema":1,"builder":"in place"}"#;
827
828    /// In place: every data section stays at its offset, byte for byte; the
829    /// card and the build record land after the old footer, adjacent; the
830    /// hash is the one a copying re-card computes for the same card (the hash
831    /// does not depend on where the card lies), and `verify` agrees.
832    #[test]
833    fn a_card_appended_in_place_moves_nothing_and_verifies() {
834        for text_index in [false, true] {
835            let src = carded(text_index);
836            let old = Header::from_bytes(&src).unwrap();
837            let file = Scratch::new("inplace", &src);
838            let new = append(&file, META, INFO, true);
839            let out = file.bytes();
840
841            assert_eq!(
842                new.metadata_offset,
843                src.len() as u64,
844                "after the old footer"
845            );
846            assert_eq!(
847                new.build_info_offset,
848                new.metadata_offset + META.len() as u64
849            );
850            assert_eq!(new.expected_file_len(), Some(out.len() as u64));
851            assert_eq!(&out[out.len() - 4..], &MAGIC);
852            assert_eq!(
853                &out[..],
854                &{
855                    let mut want = src[..].to_vec();
856                    want[..HEADER_LEN].copy_from_slice(&new.to_bytes());
857                    want.extend_from_slice(META);
858                    want.extend_from_slice(INFO);
859                    want.extend_from_slice(&MAGIC);
860                    want
861                }[..]
862            );
863            for (o, n) in [
864                (old.dictionary_offset, new.dictionary_offset),
865                (old.root_dir_offset, new.root_dir_offset),
866                (old.named_graphs_offset, new.named_graphs_offset),
867                (old.text_index_offset, new.text_index_offset),
868            ] {
869                assert_eq!(o, n, "a data section moved");
870            }
871            assert!(crate::format::verify(&out).unwrap());
872            let copied = rewrite(&src, META, INFO);
873            assert_eq!(
874                new.content_hash,
875                Header::from_bytes(&copied).unwrap().content_hash,
876                "the hash does not depend on where the card lies"
877            );
878            let (m, b) =
879                crate::file::read_card_and_build_info_ranged(&SliceReader::new(&out)).unwrap();
880            assert_eq!((m.unwrap(), b.unwrap()), (META.to_vec(), INFO.to_vec()));
881            let rete = crate::Rete::open(&out).unwrap();
882            assert_eq!(rete.metadata(), Some(META));
883            assert_eq!(rete.dump(None).len(), 60);
884        }
885    }
886
887    /// A crash after the new sections are written but before the header is:
888    /// the file is the old one — same header, same card, same hash, still
889    /// verifying and answering — plus bytes past its recorded end, which the
890    /// next run overwrites and cuts.
891    #[test]
892    fn a_crash_before_the_header_leaves_the_old_file() {
893        let src = carded(false);
894        let file = Scratch::new("crash", &src);
895        let plan = plan_card_append(&src, src.len() as u64, META.len() as u64, true).unwrap();
896        let hash = card_append_hash(&SliceReader::new(&src), &plan, META, &mut |_| {}).unwrap();
897        let err =
898            commit_card_append_impl(&mut file.open(), &plan, hash, META, INFO, true).unwrap_err();
899        assert!(err.to_string().contains("simulated"), "{err}");
900
901        let crashed = file.bytes();
902        assert!(crashed.len() > src.len(), "the new sections were written");
903        assert_eq!(&crashed[..src.len()], &src[..], "the old file is untouched");
904        assert!(crate::format::verify(&crashed).unwrap());
905        let rete = crate::Rete::open(&crashed).unwrap();
906        assert_eq!(
907            rete.metadata(),
908            crate::Rete::open(&src).unwrap().metadata(),
909            "the old card"
910        );
911        assert_eq!(rete.dump(None).len(), 60);
912
913        // The rerun overwrites the leftover and lands on the file a clean run
914        // writes.
915        let rerun = append(&file, META, INFO, true);
916        let clean = Scratch::new("clean", &src);
917        append(&clean, META, INFO, true);
918        assert_eq!(file.bytes(), clean.bytes());
919        assert!(crate::format::verify(&file.bytes()).unwrap());
920        assert_eq!(rerun.expected_file_len(), Some(file.bytes().len() as u64));
921    }
922
923    /// A fresh file — what the memory-bounded build appends to — gets its card
924    /// over its old footer: no gap, no dead bytes, and the same hash a card at
925    /// the front would give it.
926    #[test]
927    fn a_fresh_append_replaces_the_footer() {
928        let (src, _) =
929            crate::ingest::assemble_dataset_with_opts(quads(), true, false, None, |_, _| {
930                Vec::new()
931            });
932        let file = Scratch::new("fresh", &src);
933        let plan = plan_card_append(&src, src.len() as u64, META.len() as u64, false).unwrap();
934        assert_eq!(plan.dead_bytes, 0);
935        let h = append(&file, META, INFO, false);
936        let out = file.bytes();
937        assert_eq!(h.metadata_offset, src.len() as u64 - 4);
938        assert_eq!(out.len(), src.len() + META.len() + INFO.len());
939        assert!(crate::format::verify(&out).unwrap());
940        // The card at the front, through the copying rewrite: same identity.
941        assert_eq!(
942            Header::from_bytes(&rewrite(&src, META, INFO))
943                .unwrap()
944                .content_hash,
945            h.content_hash
946        );
947    }
948
949    /// A card already after the data is replaced in place again (the old one
950    /// becomes dead bytes too), re-carded by copy (it stays at the end), and
951    /// given a new build record by the build-info splice — each keeping the
952    /// file valid.
953    #[test]
954    fn a_card_after_the_data_can_be_replaced_again_every_way() {
955        let src = carded(true);
956        let file = Scratch::new("again", &src);
957        append(&file, META, INFO, true);
958        let once = file.bytes();
959        let plan = plan_card_append(&once, once.len() as u64, 2, true).unwrap();
960        assert_eq!(
961            plan.dead_bytes,
962            (META.len() + INFO.len() + 4) as u64,
963            "the first in-place card, its record and the footer before it"
964        );
965        append(&file, b"{}", b"{\"schema\":1}", true);
966        assert!(crate::format::verify(&file.bytes()).unwrap());
967
968        // A copying re-card keeps the card where it is — after the data — and
969        // drops the bytes the in-place one left behind.
970        let meta2 = br#"{"title":"copied"}"#;
971        let copied = rewrite(&once, meta2, INFO);
972        let h = Header::from_bytes(&copied).unwrap();
973        assert!(h.metadata_offset > h.dictionary_offset);
974        let old = Header::from_bytes(&src).unwrap();
975        assert_eq!(
976            copied.len() as u64,
977            src.len() as u64 - old.metadata_len - old.build_info_len
978                + (meta2.len() + INFO.len()) as u64
979        );
980        assert_eq!(h.expected_file_len(), Some(copied.len() as u64));
981        assert!(crate::format::verify(&copied).unwrap());
982        assert_eq!(crate::Rete::open(&copied).unwrap().dump(None).len(), 60);
983        // Its data sections are the original's, back to back from the header.
984        assert_eq!(h.dictionary_offset, HEADER_LEN as u64);
985        let sec = |b: &[u8], off: u64, len: u64| b[off as usize..(off + len) as usize].to_vec();
986        assert_eq!(
987            sec(&copied, h.root_dir_offset, h.root_dir_len),
988            sec(&src, old.root_dir_offset, old.root_dir_len)
989        );
990
991        // A new build record goes right after the card, wherever it is.
992        let swapped = crate::format::attach_build_info(&once, b"{\"schema\":1,\"x\":2}").unwrap();
993        let h = Header::from_bytes(&swapped).unwrap();
994        assert_eq!(h.build_info_offset, h.metadata_offset + h.metadata_len);
995        assert_eq!(h.expected_file_len(), Some(swapped.len() as u64));
996        assert!(crate::format::verify(&swapped).unwrap());
997    }
998
999    /// Refusals: a truncated file, a file whose footer is not where its header
1000    /// says, and a file that changed between the plan and the commit.
1001    #[test]
1002    fn an_append_refuses_what_it_cannot_vouch_for() {
1003        let src = carded(false);
1004        let err = plan_card_append(&src, src.len() as u64 - 1, 10, true).unwrap_err();
1005        assert!(err.to_string().contains("shorter"), "{err}");
1006
1007        let mut broken = src.clone();
1008        let n = broken.len();
1009        broken[n - 1] = b'X';
1010        let plan = plan_card_append(&broken, n as u64, META.len() as u64, true).unwrap();
1011        let err =
1012            card_append_hash(&SliceReader::new(&broken), &plan, META, &mut |_| {}).unwrap_err();
1013        assert!(err.to_string().contains("footer"), "{err}");
1014
1015        let file = Scratch::new("changed", &src);
1016        let plan = plan_card_append(&src, src.len() as u64, META.len() as u64, true).unwrap();
1017        let hash = card_append_hash(&SliceReader::new(&src), &plan, META, &mut |_| {}).unwrap();
1018        append(&file, b"{}", b"", true); // another writer got there first
1019        let before = file.bytes();
1020        let err = commit_card_append(&mut file.open(), &plan, hash, META, INFO).unwrap_err();
1021        assert!(err.to_string().contains("changed"), "{err}");
1022        assert_eq!(file.bytes(), before, "nothing was written");
1023    }
1024
1025    /// The streaming hash fold is exercised with a tiny chunk boundary by the
1026    /// feed directly: parts split across arbitrary write boundaries.
1027    #[test]
1028    fn the_hash_feed_folds_parts_across_write_boundaries() {
1029        let data: Vec<u8> = (0..200u8).collect();
1030        let parts = [(10u64, 30u64), (40, 5), (100, 50)];
1031        let mut want = blake3::Hasher::new();
1032        for &(o, l) in &parts {
1033            want.update(&data[o as usize..(o + l) as usize]);
1034        }
1035        let mut got = blake3::Hasher::new();
1036        let mut feed = HashFeed {
1037            parts: &parts,
1038            next: 0,
1039        };
1040        for (i, chunk) in data.chunks(7).enumerate() {
1041            feed.fold(i as u64 * 7, chunk, &mut got);
1042        }
1043        assert_eq!(want.finalize(), got.finalize());
1044        assert_eq!(feed.next, parts.len());
1045    }
1046}