Skip to main content

znippy_common/
meta_sink.rs

1//! `ArchiveMetaSink` — abstraction over the archive's metadata layer.
2//!
3//! After the (unchanged) compression pipeline writes all blob bytes to disk, the
4//! metadata layer — one Arrow IPC sub-index per `(pkg_type, repo)` group, a
5//! manifest, and the `MULTI_INDEX_MAGIC` footer — is written through this trait.
6//!
7//! [`ArrowIpcSink`] reproduces the v0.7 on-disk format byte-for-byte. Future
8//! backends (e.g. Iceberg) implement the same trait without touching the blob
9//! pipeline.
10
11use std::fs::File;
12use std::os::unix::fs::FileExt;
13use std::sync::Arc;
14
15use anyhow::{Result, anyhow};
16use arrow::array::{
17    BooleanArray, BooleanBuilder, FixedSizeBinaryArray, FixedSizeBinaryBuilder, StringArray,
18    StringBuilder, UInt32Array, UInt32Builder, UInt64Array, UInt64Builder,
19};
20use arrow::datatypes::Schema;
21use arrow::ipc::writer::StreamWriter;
22use arrow::record_batch::RecordBatch;
23
24use crate::index::{
25    ChunkLoc, LOOKUP_MODULE, META_MODULE, MULTI_INDEX_MAGIC, ManifestEntry, RESERVED_PKG_TYPE,
26    TRIE_MODULE, is_reserved_module, lookup_schema, write_manifest_bytes,
27};
28use crate::meta_index::{MetaTable, build_meta_batch, meta_schema};
29#[cfg(feature = "sign")]
30use crate::index::{SIGN_ARCHIVE_MODULE, SIGN_ARTIFACTS_MODULE};
31
32/// Identifies the logical sub-archive a sub-index belongs to.
33#[derive(Debug, Clone)]
34pub struct GroupKey {
35    pub pkg_type: i8,
36    pub repo: String,
37    pub module_name: String,
38}
39
40/// The bytes of one extra reserved section a writer asks the sink to seal.
41///
42/// `Raw` is a byte blob framed by the manifest entry (what the fst trie and the
43/// git oid index are); `Arrow` is a real Arrow IPC stream (what the commit graph
44/// and the reachability bitmaps are), so DuckDB/Polars can read it straight out
45/// of the archive by its manifest byte range.
46///
47/// `Clone` is cheap on both arms and it is what lets a writer *keep* the
48/// sections it just sealed: [`ReservedSectionBuilder`] consumes what it returns,
49/// so a caller that must both seal and hand the sections back (gunnar's
50/// `GitOps::seal`) would otherwise have to build them twice — two derivations of
51/// one fact, which is the drift LAW 5 forbids. `RecordBatch` clones are Arc
52/// bumps; only a `Raw` payload copies bytes.
53#[derive(Clone)]
54pub enum ReservedPayload {
55    Raw(Vec<u8>),
56    Arrow { schema: Arc<Schema>, batches: Vec<RecordBatch> },
57}
58
59/// One extra reserved section. `module_name` **must** satisfy
60/// [`is_reserved_module`] — the sink refuses anything else, because a section
61/// the manifest readers do not classify as reserved is merged into the data
62/// index and corrupts `list` / `decompress` / the iceberg sink.
63#[derive(Clone)]
64pub struct ReservedSection {
65    pub module_name: String,
66    pub payload: ReservedPayload,
67}
68
69/// Concise on purpose: a `Raw` payload can be megabytes, and the useful facts in
70/// an error message are the module and the size, never the bytes.
71impl std::fmt::Debug for ReservedSection {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match &self.payload {
74            ReservedPayload::Raw(b) => f
75                .debug_struct("ReservedSection")
76                .field("module_name", &self.module_name)
77                .field("raw_bytes", &b.len())
78                .finish(),
79            ReservedPayload::Arrow { batches, .. } => f
80                .debug_struct("ReservedSection")
81                .field("module_name", &self.module_name)
82                .field("rows", &batches.iter().map(|b| b.num_rows()).sum::<usize>())
83                .finish(),
84        }
85    }
86}
87
88impl ReservedSection {
89    pub fn raw(module_name: impl Into<String>, bytes: Vec<u8>) -> Self {
90        Self { module_name: module_name.into(), payload: ReservedPayload::Raw(bytes) }
91    }
92
93    pub fn arrow(
94        module_name: impl Into<String>,
95        schema: Arc<Schema>,
96        batches: Vec<RecordBatch>,
97    ) -> Self {
98        Self {
99            module_name: module_name.into(),
100            payload: ReservedPayload::Arrow { schema, batches },
101        }
102    }
103}
104
105/// Read-only view of the **sorted lookup** exactly as the sink is about to seal
106/// it: row `r` of the lookup sub-index is `(path(r), loc(r))`, ordered by
107/// `(relative_path, chunk_seq)`.
108///
109/// Handed to a [`ReservedSectionBuilder`] so an extra index can point at real
110/// lookup row numbers instead of re-deriving the sort and hoping the two agree
111/// (LAW 5 — fix by construction, do not add a guard watching two copies).
112pub struct LookupView<'a> {
113    paths: &'a [String],
114    locs: &'a [ChunkLoc],
115    order: &'a [usize],
116}
117
118impl<'a> LookupView<'a> {
119    /// Number of lookup rows.
120    pub fn len(&self) -> usize {
121        self.order.len()
122    }
123
124    pub fn is_empty(&self) -> bool {
125        self.order.is_empty()
126    }
127
128    /// `relative_path` of lookup row `row`.
129    pub fn path(&self, row: usize) -> &'a str {
130        &self.paths[self.order[row]]
131    }
132
133    /// Chunk location of lookup row `row`.
134    pub fn loc(&self, row: usize) -> &'a ChunkLoc {
135        &self.locs[self.order[row]]
136    }
137
138    /// Every distinct `relative_path`, in sorted order, with the lookup row its
139    /// contiguous chunk run starts at.
140    pub fn first_rows(&self) -> Vec<(&'a str, u64)> {
141        let mut out: Vec<(&'a str, u64)> = Vec::new();
142        let mut prev: Option<&str> = None;
143        for row in 0..self.order.len() {
144            let p = self.path(row);
145            if prev != Some(p) {
146                out.push((p, row as u64));
147                prev = Some(p);
148            }
149        }
150        out
151    }
152}
153
154/// Builds extra reserved sections once the sink knows the final sorted lookup.
155///
156/// Called from [`ArrowIpcSink::finish`], after the lookup + trie are laid out and
157/// before the manifest is written, so returned sections are recorded as reserved
158/// manifest entries like every other derived structure.
159pub type ReservedSectionBuilder =
160    Box<dyn FnOnce(&LookupView<'_>) -> Result<Vec<ReservedSection>> + Send>;
161
162/// Writes the archive metadata layer (sub-indexes + manifest + footer).
163///
164/// The blob bytes have already been written to the output by the compression
165/// pipeline; implementations only decide how the metadata is materialized.
166pub trait ArchiveMetaSink {
167    /// Serialize one sub-index — an Arrow IPC stream of `batches` (one or more)
168    /// — place it after the previously written region, and record a manifest
169    /// entry for it.
170    fn push_subindex(
171        &mut self,
172        schema: &Schema,
173        batches: &[RecordBatch],
174        key: GroupKey,
175    ) -> Result<()>;
176
177    /// Write the manifest + footer, fsync, and return the total file length.
178    fn finish(self: Box<Self>) -> Result<u64>;
179}
180
181/// Builds the metadata sink once the compression pipeline knows the output file
182/// handle and the byte offset just past the last blob. The factory shape lets a
183/// caller (e.g. the CLI) choose the backend — `ArrowIpcSink` (inline, default)
184/// or a tokio-backed `IcebergSink` (off in a warehouse dir) — **without**
185/// `znippy-compress` taking a dependency on the heavy/async backend: the
186/// `IcebergSink` is constructed by the caller's closure, so its tokio/iceberg
187/// deps stay in the binary that opted in.
188///
189/// `args`: `(output_file, blob_end_offset)`. An `ArrowIpcSink` uses both; an
190/// `IcebergSink` ignores them (it writes its own warehouse, not the `.znippy`).
191pub type MetaSinkFactory = Box<dyn FnOnce(Arc<File>, u64) -> Box<dyn ArchiveMetaSink> + Send>;
192
193/// The default backend: inline Arrow IPC sub-indexes + manifest + 8-byte footer,
194/// i.e. the v0.7 znippy container format. Behaviour is identical to the
195/// previously-inlined writer tail in `slot_packer` / `stream_packer`.
196pub struct ArrowIpcSink {
197    file: Arc<File>,
198    cursor: u64,
199    entries: Vec<ManifestEntry>,
200    /// Accumulated base columns of every data sub-index, used to build the sorted
201    /// lookup sub-index + trie in [`finish`](ArrowIpcSink::finish).
202    lookup_paths: Vec<String>,
203    lookup_locs: Vec<ChunkLoc>,
204    /// Optional searchable metadata (the `META_MODULE` sub-index). `None` — the
205    /// default — emits no section at all, which is what makes the produced
206    /// archive byte-identical to today's AND what a reader later reports as
207    /// `ArchiveMeta::NoMetadata`. `Some(empty table)` is a different thing on
208    /// purpose: a present, empty index.
209    meta: Option<MetaTable>,
210    /// Optional builder for extra *reserved* sections (the `git` package format's
211    /// oid index / commit graph / reachability bitmaps). `None` — the default —
212    /// writes nothing, so an archive sealed without one stays byte-identical.
213    reserved_builder: Option<ReservedSectionBuilder>,
214    /// Optional provenance signer (feature `sign`). When set, [`finish`] emits the
215    /// per-artifact + per-archive detached CMS signatures as two additional
216    /// *reserved* manifest sections — additive and backward-compatible. When
217    /// `None` (the default), the produced archive is byte-identical to today's.
218    #[cfg(feature = "sign")]
219    signer: Option<Box<dyn crate::sign::ArchiveSigner + Send>>,
220}
221
222impl ArrowIpcSink {
223    /// `blob_end_offset` is the byte offset just past the last blob — where the
224    /// first sub-index is placed.
225    pub fn new(file: Arc<File>, blob_end_offset: u64) -> Self {
226        Self {
227            file,
228            cursor: blob_end_offset,
229            entries: Vec::new(),
230            lookup_paths: Vec::new(),
231            lookup_locs: Vec::new(),
232            meta: None,
233            reserved_builder: None,
234            #[cfg(feature = "sign")]
235            signer: None,
236        }
237    }
238
239    /// Seal extra **reserved** sections alongside the built-in derived ones.
240    ///
241    /// The builder is invoked in [`finish`](ArrowIpcSink::finish) with the final
242    /// sorted [`LookupView`], so an index it emits can address real lookup rows.
243    /// This is the injection point the `git` package format uses for
244    /// `__gunnar_oid__` / `__gunnar_graph__` / `__gunnar_reach__` — the same
245    /// shape as [`with_meta`](ArrowIpcSink::with_meta), so `compress_dir`'s
246    /// `MetaSinkFactory` needs no new parameter and there is no second write path.
247    pub fn with_reserved_builder(mut self, builder: ReservedSectionBuilder) -> Self {
248        self.reserved_builder = Some(builder);
249        self
250    }
251
252    /// Seal a searchable metadata sub-index alongside the index.
253    ///
254    /// This is the injection point for the compress path too: `compress_dir`
255    /// already takes a `MetaSinkFactory`, so a caller adds metadata by handing it
256    /// `Box::new(|f, b| Box::new(ArrowIpcSink::new(f, b).with_meta(table)))` —
257    /// no change to the compress signature and no second write path.
258    pub fn with_meta(mut self, meta: MetaTable) -> Self {
259        self.meta = Some(meta);
260        self
261    }
262
263    /// Attach a provenance signer (feature `sign`). On [`finish`], per-artifact and
264    /// per-archive detached CMS signatures are written as reserved sections.
265    #[cfg(feature = "sign")]
266    pub fn with_signer(mut self, signer: Box<dyn crate::sign::ArchiveSigner + Send>) -> Self {
267        self.signer = Some(signer);
268        self
269    }
270
271    /// Pull the base index columns from one batch into the lookup accumulator.
272    /// Every composed schema carries these columns; if any is absent we skip the
273    /// batch (the lookup degrades gracefully — readers fall back to a scan).
274    fn accumulate_lookup(&mut self, batch: &RecordBatch) {
275        let cols = (|| {
276            Some((
277                batch.column_by_name("relative_path")?.as_any().downcast_ref::<StringArray>()?,
278                batch.column_by_name("chunk_seq")?.as_any().downcast_ref::<UInt32Array>()?,
279                batch.column_by_name("fdata_offset")?.as_any().downcast_ref::<UInt64Array>()?,
280                batch.column_by_name("compressed")?.as_any().downcast_ref::<BooleanArray>()?,
281                batch.column_by_name("uncompressed_size")?.as_any().downcast_ref::<UInt64Array>()?,
282                batch.column_by_name("blob_offset")?.as_any().downcast_ref::<UInt64Array>()?,
283                batch.column_by_name("blob_size")?.as_any().downcast_ref::<UInt64Array>()?,
284                batch.column_by_name("checksum")?.as_any().downcast_ref::<FixedSizeBinaryArray>()?,
285            ))
286        })();
287        let Some((paths, chunk_seq, fdata, compressed, usz, blob_off, blob_sz, checksum)) = cols
288        else { return; };
289        for i in 0..batch.num_rows() {
290            let mut ck = [0u8; 32];
291            ck.copy_from_slice(checksum.value(i));
292            self.lookup_paths.push(paths.value(i).to_string());
293            self.lookup_locs.push(ChunkLoc {
294                chunk_seq: chunk_seq.value(i),
295                fdata_offset: fdata.value(i),
296                blob_offset: blob_off.value(i),
297                blob_size: blob_sz.value(i),
298                uncompressed_size: usz.value(i),
299                compressed: compressed.value(i),
300                checksum: ck,
301            });
302        }
303    }
304
305    /// The lookup row order: sorted by (path, chunk_seq) so each file's chunks
306    /// are contiguous and paths are in byte-lexicographic order (fst requirement).
307    ///
308    /// Computed once in `finish` and shared by the lookup/trie writer and the
309    /// extra reserved-section builder, so an index built over it addresses the
310    /// rows the archive actually carries (LAW 5 — one writer, both paths).
311    fn lookup_order(&self) -> Vec<usize> {
312        let n = self.lookup_paths.len();
313        let mut order: Vec<usize> = (0..n).collect();
314        order.sort_by(|&a, &b| {
315            self.lookup_paths[a].cmp(&self.lookup_paths[b])
316                .then(self.lookup_locs[a].chunk_seq.cmp(&self.lookup_locs[b].chunk_seq))
317        });
318        order
319    }
320
321    /// Invoke the caller's reserved-section builder (if any) and seal what it
322    /// returns. A non-reserved `module_name` is a hard error: such a section
323    /// would be merged into the data index by `read_multi_index` and would
324    /// corrupt `list`, `decompress` and the iceberg sink.
325    fn write_reserved_sections(&mut self, order: &[usize]) -> Result<()> {
326        let Some(builder) = self.reserved_builder.take() else {
327            return Ok(());
328        };
329        let sections = {
330            let view = LookupView {
331                paths: &self.lookup_paths,
332                locs: &self.lookup_locs,
333                order,
334            };
335            builder(&view)?
336        };
337        for section in sections {
338            anyhow::ensure!(
339                is_reserved_module(&section.module_name),
340                "module '{}' is not a reserved module name; a non-reserved extra \
341                 section would be merged into the data index and corrupt list/decompress",
342                section.module_name,
343            );
344            let key = GroupKey {
345                pkg_type: RESERVED_PKG_TYPE,
346                repo: String::new(),
347                module_name: section.module_name,
348            };
349            match section.payload {
350                ReservedPayload::Raw(bytes) => self.write_raw_section(&bytes, key)?,
351                ReservedPayload::Arrow { schema, batches } => {
352                    self.push_subindex(schema.as_ref(), &batches, key)?
353                }
354            }
355        }
356        Ok(())
357    }
358
359    /// Write the sorted lookup sub-index + fst trie as two reserved manifest
360    /// entries. Called from `finish` before the manifest is emitted.
361    fn write_lookup_and_trie(&mut self, order: &[usize]) -> Result<()> {
362        let n = self.lookup_paths.len();
363
364        // Build the lookup sub-index batch (base schema, sorted).
365        let mut path_b = StringBuilder::with_capacity(n, n * 16);
366        let mut seq_b = UInt32Builder::with_capacity(n);
367        let mut fdata_b = UInt64Builder::with_capacity(n);
368        let mut comp_b = BooleanBuilder::with_capacity(n);
369        let mut usz_b = UInt64Builder::with_capacity(n);
370        let mut boff_b = UInt64Builder::with_capacity(n);
371        let mut bsz_b = UInt64Builder::with_capacity(n);
372        let mut ck_b = FixedSizeBinaryBuilder::with_capacity(n, 32);
373        for &i in order {
374            let loc = &self.lookup_locs[i];
375            path_b.append_value(&self.lookup_paths[i]);
376            seq_b.append_value(loc.chunk_seq);
377            fdata_b.append_value(loc.fdata_offset);
378            comp_b.append_value(loc.compressed);
379            usz_b.append_value(loc.uncompressed_size);
380            boff_b.append_value(loc.blob_offset);
381            bsz_b.append_value(loc.blob_size);
382            ck_b.append_value(loc.checksum).expect("checksum is 32 bytes");
383        }
384        let schema = lookup_schema();
385        let batch = RecordBatch::try_new(
386            schema.clone(),
387            vec![
388                Arc::new(path_b.finish()),
389                Arc::new(seq_b.finish()),
390                Arc::new(fdata_b.finish()),
391                Arc::new(comp_b.finish()),
392                Arc::new(usz_b.finish()),
393                Arc::new(boff_b.finish()),
394                Arc::new(bsz_b.finish()),
395                Arc::new(ck_b.finish()),
396            ],
397        )?;
398        self.push_subindex(&schema, &[batch], GroupKey {
399            pkg_type: RESERVED_PKG_TYPE,
400            repo: String::new(),
401            module_name: LOOKUP_MODULE.to_string(),
402        })?;
403
404        // Build the fst trie: distinct relative_path → first row index in the
405        // (sorted) lookup. Keys must be inserted in lexicographic order — `order`
406        // already gives that.
407        let mut builder = fst::MapBuilder::memory();
408        let mut prev: Option<&str> = None;
409        for (sorted_idx, &orig) in order.iter().enumerate() {
410            let p = self.lookup_paths[orig].as_str();
411            if prev != Some(p) {
412                builder.insert(p.as_bytes(), sorted_idx as u64)
413                    .map_err(|e| anyhow!("trie insert: {e}"))?;
414                prev = Some(p);
415            }
416        }
417        let trie_bytes = builder.into_inner().map_err(|e| anyhow!("trie finish: {e}"))?;
418        self.write_raw_section(&trie_bytes, GroupKey {
419            pkg_type: RESERVED_PKG_TYPE,
420            repo: String::new(),
421            module_name: TRIE_MODULE.to_string(),
422        })
423    }
424
425    /// Emit the per-artifact + per-archive detached CMS signatures as two reserved
426    /// sections, computed from the already-accumulated chunk hashes (Law 3 order).
427    /// Never re-hashes content. Called from `finish` (before the manifest) only
428    /// when a signer is attached.
429    #[cfg(feature = "sign")]
430    fn write_signatures(&mut self) -> Result<()> {
431        use std::collections::BTreeMap;
432        let Some(signer) = self.signer.take() else {
433            return Ok(());
434        };
435
436        // Group chunk hashes by path (borrows self immutably). Produce owned
437        // outputs so the borrow is released before we write to the file.
438        let (file_digests, artifact_paths, artifact_cms): (
439            Vec<(String, [u8; 32])>,
440            Vec<String>,
441            Vec<Vec<u8>>,
442        ) = {
443            let mut by_path: BTreeMap<&str, Vec<(u32, &[u8; 32])>> = BTreeMap::new();
444            for (p, loc) in self.lookup_paths.iter().zip(self.lookup_locs.iter()) {
445                by_path.entry(p.as_str()).or_default().push((loc.chunk_seq, &loc.checksum));
446            }
447            let mut digs = Vec::with_capacity(by_path.len());
448            let mut paths = Vec::with_capacity(by_path.len());
449            let mut cmss = Vec::with_capacity(by_path.len());
450            for (path, mut chunks) in by_path {
451                chunks.sort_by_key(|(seq, _)| *seq);
452                let n = chunks.len();
453                let digest = crate::sign::file_digest_from_parts(
454                    path,
455                    chunks.iter().map(|(s, c)| (*s, *c)),
456                    n,
457                );
458                let cms = signer.sign_digest(&digest)?;
459                digs.push((path.to_string(), digest));
460                paths.push(path.to_string());
461                cmss.push(cms);
462            }
463            (digs, paths, cmss)
464        };
465
466        // Per-archive root signature. The footer is always Multi (v0.7).
467        let footer = crate::index::IndexFooter::Multi { manifest_offset: 0 };
468        let root = crate::sign::archive_root(&file_digests, &footer);
469        let archive_cms = signer.sign_digest(&root)?;
470
471        let artifacts_bytes = serialize_artifact_signatures(&artifact_paths, &artifact_cms)?;
472        self.write_raw_section(
473            &artifacts_bytes,
474            GroupKey {
475                pkg_type: RESERVED_PKG_TYPE,
476                repo: String::new(),
477                module_name: SIGN_ARTIFACTS_MODULE.to_string(),
478            },
479        )?;
480        self.write_raw_section(
481            &archive_cms,
482            GroupKey {
483                pkg_type: RESERVED_PKG_TYPE,
484                repo: String::new(),
485                module_name: SIGN_ARCHIVE_MODULE.to_string(),
486            },
487        )?;
488        Ok(())
489    }
490
491    /// Emit the searchable metadata sub-index, when there is one to emit.
492    ///
493    /// Reserved module, so the data readers skip it and an older znippy simply
494    /// ignores the entry. `None` writes NOTHING — that absence is exactly what
495    /// `ArchiveMeta::NoMetadata` reports, and it is why an archive sealed without
496    /// metadata stays byte-identical to one sealed before this module existed.
497    fn write_meta_subindex(&mut self) -> Result<()> {
498        let Some(table) = self.meta.take() else {
499            return Ok(());
500        };
501        let batch = build_meta_batch(&table)?;
502        let schema = meta_schema();
503        self.push_subindex(schema.as_ref(), &[batch], GroupKey {
504            pkg_type: RESERVED_PKG_TYPE,
505            repo: String::new(),
506            module_name: META_MODULE.to_string(),
507        })
508    }
509
510    /// Write a raw (non-Arrow) byte section at the cursor and record a manifest
511    /// entry whose `index_offset`/`index_len` frame it.
512    fn write_raw_section(&mut self, bytes: &[u8], key: GroupKey) -> Result<()> {
513        let start = self.cursor;
514        self.file.write_all_at(bytes, start)?;
515        self.cursor += bytes.len() as u64;
516        self.entries.push(ManifestEntry {
517            pkg_type: key.pkg_type,
518            repo: key.repo,
519            module_name: key.module_name,
520            index_offset: start,
521            index_len: bytes.len() as u64,
522            row_count: 0,
523        });
524        Ok(())
525    }
526}
527
528impl ArchiveMetaSink for ArrowIpcSink {
529    fn push_subindex(
530        &mut self,
531        schema: &Schema,
532        batches: &[RecordBatch],
533        key: GroupKey,
534    ) -> Result<()> {
535        let sub_start = self.cursor;
536        let mut sub_bytes: Vec<u8> = Vec::new();
537        let mut sw = StreamWriter::try_new(&mut sub_bytes, schema)
538            .map_err(|e| anyhow!("sub-index writer: {e}"))?;
539        let mut row_count = 0u64;
540        for batch in batches {
541            row_count += batch.num_rows() as u64;
542            sw.write(batch).map_err(|e| anyhow!("sub-index write: {e}"))?;
543        }
544        sw.finish().map_err(|e| anyhow!("sub-index finish: {e}"))?;
545
546        // Accumulate base columns for the lookup layer — but not from the reserved
547        // lookup sub-index itself (that would recurse / double-count).
548        // Accumulate base columns for the lookup layer from DATA sub-indexes only.
549        // Widened from "not lookup, not trie" to "not reserved" when META_MODULE
550        // arrived: the metadata sub-index is Arrow IPC and does come through here,
551        // and its rows are key/value facts, not chunk locations — folding them
552        // into the lookup would corrupt random access. Behaviour for every
553        // pre-existing module is unchanged (sign sections are raw, never pushed).
554        if !is_reserved_module(&key.module_name) {
555            for batch in batches {
556                self.accumulate_lookup(batch);
557            }
558        }
559
560        let sub_len = sub_bytes.len() as u64;
561        self.file.write_all_at(&sub_bytes, sub_start)?;
562        self.cursor += sub_len;
563
564        self.entries.push(ManifestEntry {
565            pkg_type: key.pkg_type,
566            repo: key.repo,
567            module_name: key.module_name,
568            index_offset: sub_start,
569            index_len: sub_len,
570            row_count,
571        });
572        Ok(())
573    }
574
575    fn finish(mut self: Box<Self>) -> Result<u64> {
576        // Emit the sorted lookup sub-index + trie before the manifest so their
577        // byte ranges are recorded as (reserved) manifest entries.
578        let order = self.lookup_order();
579        self.write_lookup_and_trie(&order)?;
580
581        // Emit the caller's extra reserved sections (the `git` package format's
582        // oid index / commit graph / reachability bitmaps). A no-op unless a
583        // caller attached a builder with `with_reserved_builder`.
584        self.write_reserved_sections(&order)?;
585
586        // Emit the searchable metadata sub-index (reserved). A no-op unless a
587        // caller attached one with `with_meta`, so the default archive is
588        // byte-identical to today's.
589        self.write_meta_subindex()?;
590
591        // Emit the detached provenance signatures (feature `sign`) — also reserved
592        // sections, also before the manifest. A no-op when no signer is attached,
593        // so unsigned archives stay byte-identical to today's format.
594        #[cfg(feature = "sign")]
595        self.write_signatures()?;
596
597        let manifest_offset = self.cursor;
598        let manifest_bytes =
599            write_manifest_bytes(&self.entries).map_err(|e| anyhow!("manifest: {e}"))?;
600        self.file.write_all_at(&manifest_bytes, manifest_offset)?;
601
602        let after = manifest_offset + manifest_bytes.len() as u64;
603        self.file.write_all_at(&MULTI_INDEX_MAGIC, after)?;
604        self.file.write_all_at(
605            &manifest_offset.to_le_bytes(),
606            after + MULTI_INDEX_MAGIC.len() as u64,
607        )?;
608        self.file.sync_all()?;
609
610        Ok(after + MULTI_INDEX_MAGIC.len() as u64 + 8)
611    }
612}
613
614/// Serialize the per-artifact detached CMS signatures as an Arrow IPC stream with
615/// columns `(relative_path: Utf8, cms: Binary)` — one row per file. Itself a
616/// valid Arrow IPC file (DuckDB/Polars-queryable), stored as a reserved section.
617#[cfg(feature = "sign")]
618fn serialize_artifact_signatures(paths: &[String], cms: &[Vec<u8>]) -> Result<Vec<u8>> {
619    use arrow::array::BinaryBuilder;
620    use arrow::datatypes::{DataType, Field, Schema};
621
622    let n = paths.len();
623    let schema = Arc::new(Schema::new(vec![
624        Field::new("relative_path", DataType::Utf8, false),
625        Field::new("cms", DataType::Binary, false),
626    ]));
627    let mut path_b = StringBuilder::with_capacity(n, n * 32);
628    let mut cms_b = BinaryBuilder::with_capacity(n, n * 512);
629    for (p, c) in paths.iter().zip(cms.iter()) {
630        path_b.append_value(p);
631        cms_b.append_value(c);
632    }
633    let batch = RecordBatch::try_new(
634        schema.clone(),
635        vec![Arc::new(path_b.finish()), Arc::new(cms_b.finish())],
636    )?;
637    let mut buf = Vec::new();
638    {
639        let mut w = StreamWriter::try_new(&mut buf, &schema)
640            .map_err(|e| anyhow!("artifact-sig writer: {e}"))?;
641        w.write(&batch).map_err(|e| anyhow!("artifact-sig write: {e}"))?;
642        w.finish().map_err(|e| anyhow!("artifact-sig finish: {e}"))?;
643    }
644    Ok(buf)
645}