Skip to main content

znippy_common/
index.rs

1// index.rs — v0.6 format: blobs stored inline, Arrow IPC is a pure metadata index.
2//
3// File layout:
4//   [blob_0][blob_1]...[blob_N]  — compressed/raw chunk bytes, written as produced
5//   [Arrow IPC stream]           — metadata index, written after all blobs
6//   [8 bytes LE u64]             — byte offset where Arrow IPC starts (footer)
7//
8// Arrow schema columns:
9//   relative_path, chunk_seq, fdata_offset, checksum_group,
10//   compressed, uncompressed_size, blob_offset, blob_size, checksum
11
12use std::collections::HashMap;
13use std::fs::File;
14use std::io::{Read, Seek, SeekFrom};
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use crate::common_config::StrategicConfig;
19use crate::meta::BlobMeta;
20use crate::plugin::ExtensionRow;
21use crate::{decompress_archive};
22use anyhow::Result;
23use arrow::array::{
24    Array, ArrayRef, BooleanBuilder, FixedSizeBinaryBuilder, Int8Builder, StringBuilder,
25    UInt32Builder, UInt64Builder,
26};
27use arrow::datatypes::{DataType, Field, Schema};
28use arrow::record_batch::RecordBatch;
29use once_cell::sync::Lazy;
30
31/// Per-file extension metadata carried into the Arrow index.
32/// (plugin_type_id, extracted fields) — None for files with no matching plugin.
33pub type FileExtMeta = Option<(i8, ExtensionRow)>;
34
35/// v0.6 schema: Arrow IPC is a pure metadata index; blobs are stored inline before it.
36/// Base index columns — present in every archive, type-agnostic.
37/// Package-type modules contribute their own columns on top via `schema_fields()`;
38/// the writer composes the on-disk schema with [`compose_index_schema`].
39pub static ZNIPPY_INDEX_SCHEMA: Lazy<Arc<Schema>> = Lazy::new(|| {
40    Arc::new(Schema::new(base_index_fields()))
41});
42
43fn base_index_fields() -> Vec<Field> {
44    vec![
45        Field::new("relative_path", DataType::Utf8, false),
46        Field::new("chunk_seq", DataType::UInt32, false),
47        Field::new("fdata_offset", DataType::UInt64, false),
48        Field::new("compressed", DataType::Boolean, false),
49        Field::new("uncompressed_size", DataType::UInt64, false),
50        Field::new("blob_offset", DataType::UInt64, false),
51        Field::new("blob_size", DataType::UInt64, false),
52        Field::new("checksum", DataType::FixedSizeBinary(32), false),
53    ]
54}
55
56pub fn znippy_index_schema() -> &'static Arc<Schema> {
57    &ZNIPPY_INDEX_SCHEMA
58}
59
60/// Compose the on-disk index schema: base columns, plus — when a module contributes columns —
61/// a `pkg_type` discriminator followed by the module's own `ext_fields`.
62/// With no module fields, this is exactly the base schema (v0.6 layout, directly DuckDB-queryable).
63pub fn compose_index_schema(ext_fields: &[Field]) -> Arc<Schema> {
64    let mut fields = base_index_fields();
65    if !ext_fields.is_empty() {
66        fields.push(Field::new("pkg_type", DataType::Int8, true));
67        fields.extend(ext_fields.iter().cloned());
68    }
69    Arc::new(Schema::new(fields))
70}
71
72/// On-disk archive format version, recorded in the Arrow index schema metadata
73/// under `znippy_format_version`. The reader refuses any archive whose recorded
74/// version is greater than this — a newer format it cannot safely parse — with a
75/// clear error instead of panicking or mis-parsing. Bump only on a real on-disk
76/// format change.
77pub const ZNIPPY_FORMAT_VERSION: u32 = 3;
78
79/// Schema-metadata key holding the on-disk [`ZNIPPY_FORMAT_VERSION`].
80pub const FORMAT_VERSION_KEY: &str = "znippy_format_version";
81
82/// Enforce that an archive's recorded format version is one this reader supports.
83/// `metadata` is an Arrow schema's key/value metadata. A *newer* version yields a
84/// clear error instead of a panic or silent mis-parse; equal/older versions — and
85/// archives with no recorded version — read exactly as before.
86pub fn check_format_version(metadata: &HashMap<String, String>) -> Result<()> {
87    if let Some(raw) = metadata.get(FORMAT_VERSION_KEY) {
88        let version: u32 = raw
89            .parse()
90            .map_err(|_| anyhow::anyhow!("invalid znippy archive format version {raw:?}"))?;
91        anyhow::ensure!(
92            version <= ZNIPPY_FORMAT_VERSION,
93            "znippy archive format v{version} is newer than this reader supports \
94             (max v{ZNIPPY_FORMAT_VERSION}) — upgrade znippy",
95        );
96    }
97    Ok(())
98}
99
100/// Build Arrow schema metadata containing config (no checksum entries — those live in column).
101pub fn build_arrow_metadata_for_config(config: &StrategicConfig) -> HashMap<String, String> {
102    let mut m = HashMap::new();
103    m.insert(FORMAT_VERSION_KEY.into(), ZNIPPY_FORMAT_VERSION.to_string());
104    m.insert("max_core_in_flight".into(), config.max_core_in_flight.to_string());
105    m.insert("max_core_in_compress".into(), config.max_core_in_compress.to_string());
106    m.insert("max_mem_allowed".into(), config.max_mem_allowed.to_string());
107    m.insert("min_free_memory_ratio".into(), config.min_free_memory_ratio.to_string());
108    m.insert("file_split_block_size".into(), config.file_split_block_size.to_string());
109    m.insert("max_chunks".into(), config.max_chunks.to_string());
110    m.insert("compression_level".into(), config.compression_level.to_string());
111    m.insert("zstd_output_buffer_size".into(), config.zstd_output_buffer_size.to_string());
112    m
113}
114
115pub fn extract_config_from_arrow_metadata(
116    metadata: &HashMap<String, String>,
117) -> anyhow::Result<StrategicConfig> {
118    Ok(StrategicConfig {
119        max_core_allowed: 0,
120        max_core_in_flight: metadata
121            .get("max_core_in_flight")
122            .ok_or_else(|| anyhow::anyhow!("Missing 'max_core_in_flight'"))?
123            .parse()?,
124        max_core_in_compress: metadata
125            .get("max_core_in_compress")
126            .ok_or_else(|| anyhow::anyhow!("Missing 'max_core_in_compress'"))?
127            .parse()?,
128        max_mem_allowed: metadata
129            .get("max_mem_allowed")
130            .ok_or_else(|| anyhow::anyhow!("Missing 'max_mem_allowed'"))?
131            .parse()?,
132        min_free_memory_ratio: metadata
133            .get("min_free_memory_ratio")
134            .ok_or_else(|| anyhow::anyhow!("Missing 'min_free_memory_ratio'"))?
135            .parse()?,
136        file_split_block_size: metadata
137            .get("file_split_block_size")
138            .ok_or_else(|| anyhow::anyhow!("Missing 'file_split_block_size'"))?
139            .parse()?,
140        max_chunks: metadata
141            .get("max_chunks")
142            .ok_or_else(|| anyhow::anyhow!("Missing 'max_chunks'"))?
143            .parse()?,
144        compression_level: metadata
145            .get("compression_level")
146            .ok_or_else(|| anyhow::anyhow!("Missing 'compression_level'"))?
147            .parse()?,
148        zstd_output_buffer_size: metadata
149            .get("zstd_output_buffer_size")
150            .ok_or_else(|| anyhow::anyhow!("Missing 'zstd_output_buffer_size'"))?
151            .parse()?,
152    })
153}
154
155/// Build the Arrow metadata index batch from blob positions.
156///
157/// Every row carries its own per-slice BLAKE3 in the `checksum` column
158/// (over the chunk's uncompressed bytes).
159pub fn build_metadata_batch<F>(
160    blobs: &[BlobMeta],
161    path_resolver: F,
162    ext_meta: &[FileExtMeta],
163    ext_fields: &[Field],
164) -> arrow::error::Result<RecordBatch>
165where
166    F: Fn(u64) -> String,
167{
168    let len = blobs.len();
169
170    let mut path_builder = StringBuilder::with_capacity(len, len * 64);
171    let mut seq_builder = UInt32Builder::with_capacity(len);
172    let mut fdata_builder = UInt64Builder::with_capacity(len);
173    let mut compressed_builder = BooleanBuilder::with_capacity(len);
174    let mut size_builder = UInt64Builder::with_capacity(len);
175    let mut blob_offset_builder = UInt64Builder::with_capacity(len);
176    let mut blob_size_builder = UInt64Builder::with_capacity(len);
177    let mut checksum_builder = FixedSizeBinaryBuilder::with_capacity(len, 32);
178
179    for blob in blobs {
180        let m = &blob.chunk_meta;
181        path_builder.append_value(path_resolver(m.file_index));
182        seq_builder.append_value(m.chunk_seq);
183        fdata_builder.append_value(m.fdata_offset);
184        compressed_builder.append_value(m.compressed);
185        size_builder.append_value(m.uncompressed_size);
186        blob_offset_builder.append_value(blob.blob_offset);
187        blob_size_builder.append_value(blob.blob_size);
188        checksum_builder.append_value(m.checksum)?;
189    }
190
191    let mut columns: Vec<ArrayRef> = vec![
192        Arc::new(path_builder.finish()),
193        Arc::new(seq_builder.finish()),
194        Arc::new(fdata_builder.finish()),
195        Arc::new(compressed_builder.finish()),
196        Arc::new(size_builder.finish()),
197        Arc::new(blob_offset_builder.finish()),
198        Arc::new(blob_size_builder.finish()),
199        Arc::new(checksum_builder.finish()),
200    ];
201
202    // Module-contributed columns: a pkg_type discriminator + one column per ext field.
203    if !ext_fields.is_empty() {
204        let mut pkg_type_builder = Int8Builder::with_capacity(len);
205        for blob in blobs {
206            match ext_meta.get(blob.chunk_meta.file_index as usize).and_then(|x| x.as_ref()) {
207                Some((type_id, _)) => pkg_type_builder.append_value(*type_id),
208                None => pkg_type_builder.append_null(),
209            }
210        }
211        columns.push(Arc::new(pkg_type_builder.finish()));
212
213        for field in ext_fields {
214            columns.push(build_ext_column(field, blobs, ext_meta));
215        }
216    }
217
218    RecordBatch::try_new(compose_index_schema(ext_fields), columns)
219}
220
221/// Build one extension column from the per-file `ExtensionRow`, keyed by the field name.
222/// Supports the Arrow types modules currently declare (Utf8, UInt32); other types yield nulls.
223fn build_ext_column(field: &Field, blobs: &[BlobMeta], ext_meta: &[FileExtMeta]) -> ArrayRef {
224    use crate::plugin::ExtensionValue;
225    let len = blobs.len();
226    let value_for = |blob: &BlobMeta| -> Option<&ExtensionValue> {
227        ext_meta
228            .get(blob.chunk_meta.file_index as usize)
229            .and_then(|x| x.as_ref())
230            .and_then(|(_, row)| row.fields.get(field.name()))
231    };
232
233    match field.data_type() {
234        DataType::UInt32 => {
235            let mut b = UInt32Builder::with_capacity(len);
236            for blob in blobs {
237                match value_for(blob) {
238                    Some(ExtensionValue::U32(n)) => b.append_value(*n),
239                    _ => b.append_null(),
240                }
241            }
242            Arc::new(b.finish())
243        }
244        // Default to Utf8 for string-like fields (Str / OptStr).
245        _ => {
246            let mut b = StringBuilder::with_capacity(len, len * 16);
247            for blob in blobs {
248                match value_for(blob) {
249                    Some(ExtensionValue::Str(s)) => b.append_value(s),
250                    Some(ExtensionValue::OptStr(Some(s))) => b.append_value(s),
251                    _ => b.append_null(),
252                }
253            }
254            Arc::new(b.finish())
255        }
256    }
257}
258
259// ─── Multi-index container codec (planned v0.7, see design.md §6) ──────────────
260//
261// A multi-type archive holds several Arrow IPC index streams (one per (pkg_type, repo)
262// sub-znippy, each with its own narrow schema), followed by a manifest stream that points
263// at them, and a footer. The footer layout still *distinguishes* the legacy v0.6 trailer
264// from the v0.7 one, but v0.6 archives are no longer readable — they are detected only to
265// emit a clear "unsupported, re-compress with v0.7" error (see `read_znippy_index_filtered`):
266//
267//   v0.6 single index:  [...index...] [8-byte LE u64 index_offset]
268//   v0.7 multi index:   [...sub-indexes...][manifest] [8-byte MAGIC] [8-byte LE u64 manifest_offset]
269//
270// A reader peeks the 8 bytes preceding the trailing offset: if they equal MAGIC it's a
271// multi-index (v0.7) archive; otherwise it's a legacy v0.6 single index, which the reader
272// rejects rather than parses.
273
274/// Magic preceding the trailing offset that marks a multi-index (v0.7) archive.
275pub const MULTI_INDEX_MAGIC: [u8; 8] = *b"ZNPYMIDX";
276
277/// One entry in the multi-index manifest: a sub-znippy's identity + byte range.
278#[derive(Debug, Clone, PartialEq)]
279pub struct ManifestEntry {
280    pub pkg_type: i8,
281    pub repo: String,
282    pub module_name: String,
283    pub index_offset: u64,
284    pub index_len: u64,
285    pub row_count: u64,
286}
287
288/// Reserved `module_name` for the sorted random-access lookup sub-index.
289/// Its rows are the base index columns re-sorted by `(relative_path, chunk_seq)`,
290/// so external tools can `SELECT … WHERE relative_path = …` in O(log n) and the
291/// native reader can binary-search it. Manifest readers filter this entry out of
292/// the data sub-index set; [`read_znippy_lookup`] reads it explicitly.
293pub const LOOKUP_MODULE: &str = "__znippy_lookup__";
294
295/// Reserved `module_name` for the fst trie blob: an `fst::Map` of
296/// `relative_path → first row index in the lookup sub-index`. Not Arrow IPC —
297/// raw fst bytes — so it must never be parsed as a sub-index.
298pub const TRIE_MODULE: &str = "__znippy_trie__";
299
300/// Reserved `module_name` for the per-artifact detached CMS signatures
301/// (feature `sign`). An Arrow IPC sub-index of `(relative_path, cms)` rows — one
302/// detached CMS `SignedData` per file, over that file's merkle-folded chunk
303/// hashes. Additive: readers that don't know it simply skip it (it is reserved),
304/// so unsigned archives are byte-identical and old readers ignore signed ones.
305pub const SIGN_ARTIFACTS_MODULE: &str = "__znippy_sign_artifacts__";
306
307/// Reserved `module_name` for the per-archive detached CMS signature (feature
308/// `sign`): raw CMS `SignedData` (DER) over the archive root digest.
309pub const SIGN_ARCHIVE_MODULE: &str = "__znippy_sign_archive__";
310
311/// Reserved `module_name` for the **searchable metadata sub-index**: typed
312/// key/value rows, per entry and per archive, sorted by `(key, relative_path)`.
313/// An Arrow IPC sub-index like the lookup — so "which entries carry key `X`" is
314/// answered by one seek to the footer plus one read of this section, at a cost
315/// set by the number of metadata rows and **not** by the payload size.
316///
317/// Reserved, therefore additive: a reader that predates it skips the entry, and
318/// an archive that predates it simply has no such entry — which
319/// [`crate::meta_index::read_archive_meta`] reports as
320/// [`ArchiveMeta::NoMetadata`](crate::meta_index::ArchiveMeta::NoMetadata),
321/// a state distinct from a present-but-empty index.
322pub const META_MODULE: &str = "__znippy_meta__";
323
324/// Reserved `module_name` for the **git oid index** written by the `git` package
325/// format (`znippy-plugin-git`): a raw (non-Arrow) `stree` payload over the first
326/// eight bytes of every object id, mapping to that object's first lookup row.
327/// Raw bytes, never an Arrow IPC stream — it must never be parsed as a sub-index.
328pub const GUNNAR_OID_MODULE: &str = "__gunnar_oid__";
329
330/// Reserved `module_name` for the **git commit graph** written by the `git`
331/// package format: an Arrow IPC sub-index of
332/// `(oid, parents[], tree, committer_time, generation)`. Reserved so ordinary
333/// `list`/`decompress`/iceberg readers skip it; queryable from DuckDB by slicing
334/// its manifest byte range.
335pub const GUNNAR_GRAPH_MODULE: &str = "__gunnar_graph__";
336
337/// Reserved `module_name` for the **git reachability bitmaps** written by the
338/// `git` package format: an Arrow IPC sub-index of `(commit_oid, bitmap)` where
339/// `bitmap` is a serialized roaring bitmap over object ordinals.
340pub const GUNNAR_REACH_MODULE: &str = "__gunnar_reach__";
341
342/// Reserved `module_name` for the **git ref log** written by the `git` package
343/// format: an Arrow IPC sub-index carrying **one RecordBatch per push**.
344///
345/// The batch boundary *is* the transaction boundary. There is no database here
346/// (D18: redb was a database wedged between two archive formats, and it is
347/// gone). A push is durable exactly when its IPC frame is complete on disk; a
348/// frame torn by a crash is not half a push, it is a trailing byte run that no
349/// reader will accept. Recovery is therefore "read forward while frames parse",
350/// never a journal replay.
351pub const GUNNAR_REFS_MODULE: &str = "__gunnar_refs__";
352
353/// Reserved `module_name` for the **git secrets log** written by the `git`
354/// package format: the same one-RecordBatch-per-push shape as
355/// [`GUNNAR_REFS_MODULE`], carrying already-encrypted material only. znippy
356/// never sees plaintext, and ciphertext is never compressed.
357pub const GUNNAR_SECRETS_MODULE: &str = "__gunnar_secrets__";
358
359/// Reserved `module_name` for the **delta map**: which stored chunks are delta
360/// chunks, and what each one's base entry is.
361///
362/// # Why a reserved section and not a column
363///
364/// The obvious place is a `delta_base` column on the data sub-index. It is the
365/// wrong place, and the reason is worth recording because the column is the
366/// first thing anyone reaches for. `base_index_fields()` is the schema of EVERY
367/// archive znippy has ever written; adding a column to it means a
368/// [`ZNIPPY_FORMAT_VERSION`] bump, a new column every existing writer must
369/// supply, and a changed on-disk shape for the iceberg sink and for anyone
370/// pointing DuckDB at an archive — all to describe a property that almost no row
371/// has.
372///
373/// A reserved section is additive by construction: the manifest readers already
374/// skip reserved modules, so an archive without this one is exactly what it is
375/// today and needs no version bump. `ZnippyArchive::open` joins it onto the
376/// chunks it names; absent, every chunk is [`Stored`](crate::archive) and the
377/// read path is byte-for-byte the one the golden digest pins.
378///
379/// The payload is an Arrow IPC stream of `(relative_path, chunk_seq, base_path)`.
380/// It is independent of the objects, so an append must carry it — but it travels
381/// as **decoded rows** rather than raw bytes (like `__meta__`, and unlike
382/// `__gunnar_refs__`), because an append may add rows to it and two sections with
383/// one module name is not a thing the manifest can express. That is why it is
384/// NOT in [`CARRIED_RESERVED_MODULES`].
385pub const ZNIPPY_DELTA_MODULE: &str = "__znippy_delta__";
386
387/// Schema of the [`ZNIPPY_DELTA_MODULE`] section.
388pub fn delta_map_schema() -> Arc<Schema> {
389    Arc::new(Schema::new(vec![
390        Field::new("relative_path", DataType::Utf8, false),
391        Field::new("chunk_seq", DataType::UInt32, false),
392        Field::new("base_path", DataType::Utf8, false),
393    ]))
394}
395
396/// `pkg_type` discriminant carried by reserved (non-data) manifest entries.
397pub const RESERVED_PKG_TYPE: i8 = i8::MIN;
398
399/// Every reserved `module_name` this reader knows, in one place.
400///
401/// Kept as a slice rather than a hand-written `||` chain so that adding a
402/// reserved module is a single edit that both [`is_reserved_module`] and the
403/// guard test see — the failure mode being avoided is a new module name that is
404/// written as reserved by the sink but classified as *data* by the manifest
405/// readers, which silently corrupts `list`, `decompress`, the iceberg sink and
406/// the manifest-count assertions.
407pub const RESERVED_MODULES: &[&str] = &[
408    LOOKUP_MODULE,
409    TRIE_MODULE,
410    SIGN_ARTIFACTS_MODULE,
411    SIGN_ARCHIVE_MODULE,
412    META_MODULE,
413    GUNNAR_OID_MODULE,
414    GUNNAR_GRAPH_MODULE,
415    GUNNAR_REACH_MODULE,
416    GUNNAR_REFS_MODULE,
417    GUNNAR_SECRETS_MODULE,
418    ZNIPPY_DELTA_MODULE,
419];
420
421/// A reserved manifest entry holds a derived structure (lookup / trie / detached
422/// signatures / the git oid-index, commit-graph and reachability sections), not
423/// file rows. The merge + manifest readers skip these so data consumers are
424/// unaffected — which is exactly what makes those sections additive and
425/// backward-compatible.
426pub fn is_reserved_module(module_name: &str) -> bool {
427    RESERVED_MODULES.contains(&module_name)
428}
429
430/// The reserved sections an **append must carry forward**, because they are
431/// independent logs rather than derivations of the blobs beside them.
432///
433/// MEASURED 2026-08-04, gunnar: an object-carrying push removed
434/// `__gunnar_refs__` from the archive (144 928 -> 146 396 bytes, section gone),
435/// because [`crate::ArrowIpcSinkAppend::open_existing`] truncates the whole
436/// metadata tail and re-supplies no reserved section it was not handed. All
437/// reserved sections were treated alike, and they are not alike:
438///
439/// * **derived** — `__gunnar_oid__`, `__gunnar_graph__`, `__gunnar_reach__`,
440///   plus `__lookup__` / `__trie__` — are functions of the objects in the
441///   archive. When objects change they are *wrong*, and dropping them is
442///   correct; the writer rebuilds them.
443/// * **independent** — `__gunnar_refs__` and `__gunnar_secrets__` — are logs,
444///   one RecordBatch per push. Nothing in a new push's objects reproduces the
445///   ref history that came before it, so dropping them destroys data.
446///
447/// `__meta__` is carried by its own typed path (`open_existing` decodes it into
448/// a `MetaTable`) and so is deliberately not listed here: it would then be
449/// written twice.
450///
451/// This distinction is what the append path could not previously express, and it
452/// blocks serving refs from the archive for most of a repository's life.
453pub const CARRIED_RESERVED_MODULES: &[&str] = &[GUNNAR_REFS_MODULE, GUNNAR_SECRETS_MODULE];
454
455/// Whether an append must carry this reserved section forward verbatim.
456/// See [`CARRIED_RESERVED_MODULES`].
457pub fn is_carried_reserved_module(module_name: &str) -> bool {
458    CARRIED_RESERVED_MODULES.contains(&module_name)
459}
460
461/// Read the raw bytes of one reserved sub-section by module name, if present.
462/// Public entry point for the signature layer (feature `sign`) and any other
463/// reader that needs a reserved section's bytes without re-implementing the
464/// manifest walk.
465pub fn read_reserved_section_bytes(path: &Path, module_name: &str) -> Result<Option<Vec<u8>>> {
466    read_reserved_section(path, module_name)
467}
468
469/// Schema of the lookup sub-index — identical to the base index columns. The
470/// lookup is the same per-chunk rows, re-sorted by `(relative_path, chunk_seq)`
471/// and stripped of any plugin columns, so one path's chunks are contiguous.
472///
473/// **Carries no schema metadata**, and that is correct for the *lookup*: the
474/// lookup is a RESERVED sub-index, which every reader skips. It is NOT correct
475/// for a **data** sub-index — use [`data_subindex_schema`] there, or the archive
476/// records no format version and [`check_format_version`] has nothing to check.
477pub fn lookup_schema() -> Arc<Schema> {
478    Arc::new(Schema::new(base_index_fields()))
479}
480
481/// Schema for a base-column **data** sub-index: the same columns as
482/// [`lookup_schema`], stamped with the archive metadata that
483/// [`build_arrow_metadata_for_config`] produces — including
484/// [`FORMAT_VERSION_KEY`].
485///
486/// The reader (and holger's independent reader-side pin) determines an archive's
487/// on-disk format version from the Arrow schema metadata of the **first
488/// non-reserved sub-index**. A writer that seals its data sub-index with the bare
489/// [`lookup_schema`] therefore produces an archive that records no version at
490/// all, and the version pin degrades to "undetermined → read as before" for
491/// exactly those archives. Every data sub-index must be sealed with this.
492pub fn data_subindex_schema() -> Arc<Schema> {
493    Arc::new(Schema::new_with_metadata(
494        base_index_fields(),
495        build_arrow_metadata_for_config(&crate::common_config::CONFIG),
496    ))
497}
498
499/// One chunk's location for single-file random access.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct ChunkLoc {
502    pub chunk_seq: u32,
503    pub fdata_offset: u64,
504    pub blob_offset: u64,
505    pub blob_size: u64,
506    pub uncompressed_size: u64,
507    pub compressed: bool,
508    pub checksum: [u8; 32],
509}
510
511/// What the trailing footer of an archive points at.
512#[derive(Debug, Clone, PartialEq)]
513pub enum IndexFooter {
514    /// Legacy v0.6: a single Arrow IPC index began at this offset. Still detected
515    /// so the reader can reject v0.6 archives with a clear "re-compress with v0.7"
516    /// error — it is no longer a live read path.
517    Single { index_offset: u64 },
518    /// v0.7: the manifest stream begins at this offset.
519    Multi { manifest_offset: u64 },
520}
521
522/// Interpret an archive's trailing bytes. `tail` must be the last 16 bytes of the file
523/// (or last 8 for tiny v0.6 files — then it's always Single).
524pub fn interpret_footer(tail: &[u8]) -> IndexFooter {
525    let n = tail.len();
526    let offset = u64::from_le_bytes(tail[n - 8..].try_into().unwrap());
527    if n >= 16 && tail[n - 16..n - 8] == MULTI_INDEX_MAGIC {
528        IndexFooter::Multi { manifest_offset: offset }
529    } else {
530        IndexFooter::Single { index_offset: offset }
531    }
532}
533
534fn manifest_schema() -> Arc<Schema> {
535    Arc::new(Schema::new(vec![
536        Field::new("pkg_type", DataType::Int8, false),
537        Field::new("repo", DataType::Utf8, false),
538        Field::new("module_name", DataType::Utf8, false),
539        Field::new("index_offset", DataType::UInt64, false),
540        Field::new("index_len", DataType::UInt64, false),
541        Field::new("row_count", DataType::UInt64, false),
542    ]))
543}
544
545/// Serialize manifest entries to an Arrow IPC stream (itself DuckDB-readable).
546pub fn write_manifest_bytes(entries: &[ManifestEntry]) -> Result<Vec<u8>> {
547    use arrow::ipc::writer::StreamWriter;
548
549    let len = entries.len();
550    let mut pkg_type = Int8Builder::with_capacity(len);
551    let mut repo = StringBuilder::with_capacity(len, len * 16);
552    let mut module_name = StringBuilder::with_capacity(len, len * 16);
553    let mut index_offset = UInt64Builder::with_capacity(len);
554    let mut index_len = UInt64Builder::with_capacity(len);
555    let mut row_count = UInt64Builder::with_capacity(len);
556    for e in entries {
557        pkg_type.append_value(e.pkg_type);
558        repo.append_value(&e.repo);
559        module_name.append_value(&e.module_name);
560        index_offset.append_value(e.index_offset);
561        index_len.append_value(e.index_len);
562        row_count.append_value(e.row_count);
563    }
564
565    let schema = manifest_schema();
566    let batch = RecordBatch::try_new(
567        schema.clone(),
568        vec![
569            Arc::new(pkg_type.finish()),
570            Arc::new(repo.finish()),
571            Arc::new(module_name.finish()),
572            Arc::new(index_offset.finish()),
573            Arc::new(index_len.finish()),
574            Arc::new(row_count.finish()),
575        ],
576    )?;
577
578    let mut buf = Vec::new();
579    {
580        let mut w = StreamWriter::try_new(&mut buf, &schema)?;
581        w.write(&batch)?;
582        w.finish()?;
583    }
584    Ok(buf)
585}
586
587/// Parse a manifest Arrow IPC stream back into entries.
588pub fn read_manifest_bytes(bytes: &[u8]) -> Result<Vec<ManifestEntry>> {
589    use arrow::array::{Int8Array, StringArray, UInt64Array};
590    use arrow::ipc::reader::StreamReader;
591
592    let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)?;
593    let mut out = Vec::new();
594    for batch in reader {
595        let batch = batch?;
596        let col = |name: &str| batch.column_by_name(name)
597            .ok_or_else(|| anyhow::anyhow!("manifest missing column {name}"));
598        let pkg_type = col("pkg_type")?.as_any().downcast_ref::<Int8Array>()
599            .ok_or_else(|| anyhow::anyhow!("pkg_type type"))?;
600        let repo = col("repo")?.as_any().downcast_ref::<StringArray>()
601            .ok_or_else(|| anyhow::anyhow!("repo type"))?;
602        let module_name = col("module_name")?.as_any().downcast_ref::<StringArray>()
603            .ok_or_else(|| anyhow::anyhow!("module_name type"))?;
604        let index_offset = col("index_offset")?.as_any().downcast_ref::<UInt64Array>()
605            .ok_or_else(|| anyhow::anyhow!("index_offset type"))?;
606        let index_len = col("index_len")?.as_any().downcast_ref::<UInt64Array>()
607            .ok_or_else(|| anyhow::anyhow!("index_len type"))?;
608        let row_count = col("row_count")?.as_any().downcast_ref::<UInt64Array>()
609            .ok_or_else(|| anyhow::anyhow!("row_count type"))?;
610        for i in 0..batch.num_rows() {
611            out.push(ManifestEntry {
612                pkg_type: pkg_type.value(i),
613                repo: repo.value(i).to_string(),
614                module_name: module_name.value(i).to_string(),
615                index_offset: index_offset.value(i),
616                index_len: index_len.value(i),
617                row_count: row_count.value(i),
618            });
619        }
620    }
621    Ok(out)
622}
623
624/// Read the Arrow IPC index from a v0.7 .znippy file.
625///
626/// Reads the 16-byte footer (8-byte `ZNPYMIDX` magic + 8-byte LE u64 manifest_offset),
627/// parses the manifest, reads every sub-index, and merges all batches into one so callers
628/// need no format-version awareness.
629pub fn read_znippy_index(path: &Path) -> Result<(Arc<Schema>, Vec<RecordBatch>)> {
630    read_znippy_index_filtered(path, &IndexFilter::default())
631}
632
633/// Selective sub-index filter: keep only sub-indexes whose manifest entry
634/// matches. A `None` field matches anything, so `IndexFilter::default()` reads
635/// the whole archive (what [`read_znippy_index`] does). Filtering happens at the
636/// `(pkg_type, repo)` sub-index granularity — whole Arrow IPC streams are
637/// skipped, never read — so a selective extract touches only the matching rows.
638#[derive(Debug, Clone, Default)]
639pub struct IndexFilter {
640    /// Keep only this package-type discriminant (`ManifestEntry::pkg_type`).
641    pub pkg_type: Option<i8>,
642    /// Keep only this repo (`ManifestEntry::repo`).
643    pub repo: Option<String>,
644}
645
646impl IndexFilter {
647    pub fn is_empty(&self) -> bool {
648        self.pkg_type.is_none() && self.repo.is_none()
649    }
650    fn matches(&self, e: &ManifestEntry) -> bool {
651        self.pkg_type.is_none_or(|t| e.pkg_type == t)
652            && self.repo.as_deref().is_none_or(|r| e.repo == r)
653    }
654}
655
656/// Like [`read_znippy_index`] but keeps only sub-indexes matching `filter`.
657pub fn read_znippy_index_filtered(
658    path: &Path,
659    filter: &IndexFilter,
660) -> Result<(Arc<Schema>, Vec<RecordBatch>)> {
661    let mut file = File::open(path)?;
662    let file_len = file.metadata()?.len();
663    anyhow::ensure!(file_len >= 16, "file too small to be a v0.7 znippy archive");
664
665    file.seek(SeekFrom::End(-16))?;
666    let mut tail = [0u8; 16];
667    file.read_exact(&mut tail)?;
668
669    match interpret_footer(&tail) {
670        IndexFooter::Multi { manifest_offset } => {
671            read_multi_index(&mut file, file_len, manifest_offset, filter)
672        }
673        IndexFooter::Single { .. } => {
674            anyhow::bail!("v0.6 archives are not supported; re-compress with v0.7")
675        }
676    }
677}
678
679/// Read all sub-indexes from a v0.7 multi-index archive and concatenate them.
680fn read_multi_index(
681    file: &mut File,
682    file_len: u64,
683    manifest_offset: u64,
684    filter: &IndexFilter,
685) -> Result<(Arc<Schema>, Vec<RecordBatch>)> {
686    use arrow::ipc::reader::StreamReader;
687
688    // manifest lives between manifest_offset and (file_len − 16): 8-byte magic + 8-byte offset
689    let manifest_end = file_len.checked_sub(16)
690        .ok_or_else(|| anyhow::anyhow!("v0.7 archive too small"))?;
691    anyhow::ensure!(manifest_offset <= manifest_end, "corrupt v0.7 manifest_offset");
692    let manifest_len = (manifest_end - manifest_offset) as usize;
693
694    file.seek(SeekFrom::Start(manifest_offset))?;
695    let mut manifest_bytes = vec![0u8; manifest_len];
696    file.read_exact(&mut manifest_bytes)?;
697    let entries = read_manifest_bytes(&manifest_bytes)?;
698
699    let mut all_batches: Vec<RecordBatch> = Vec::new();
700    let mut schema: Option<Arc<Schema>> = None;
701
702    for entry in &entries {
703        // Skip derived structures (lookup sub-index, trie blob) — they are not
704        // file-row data and must not be merged into the index.
705        if is_reserved_module(&entry.module_name) {
706            continue;
707        }
708        // Selective read: skip whole sub-indexes that don't match the filter.
709        if !filter.matches(entry) {
710            continue;
711        }
712        // Bound the attacker-controlled declared length against the real file
713        // size before allocating, so a corrupt manifest can't force a huge alloc.
714        anyhow::ensure!(
715            entry.index_offset.checked_add(entry.index_len)
716                .is_some_and(|end| end <= file_len),
717            "sub-index for module {} out of bounds (offset={}, len={}, file_len={})",
718            entry.module_name, entry.index_offset, entry.index_len, file_len
719        );
720        file.seek(SeekFrom::Start(entry.index_offset))?;
721        let mut sub_bytes = vec![0u8; entry.index_len as usize];
722        file.read_exact(&mut sub_bytes)?;
723        let cursor = std::io::Cursor::new(sub_bytes);
724        let reader = StreamReader::try_new(cursor, None)?;
725        if schema.is_none() {
726            let sub_schema = reader.schema();
727            // Refuse archives written by a newer znippy than this reader supports,
728            // before parsing any sub-index batches we might mis-interpret.
729            check_format_version(sub_schema.metadata())?;
730            schema = Some(sub_schema);
731        }
732        for batch in reader {
733            all_batches.push(batch.map_err(|e| anyhow::anyhow!("sub-index read error: {}", e))?);
734        }
735    }
736
737    let schema = schema.unwrap_or_else(|| Arc::new(Schema::new(base_index_fields())));
738
739    // Merge all sub-index batches into one so callers stay format-agnostic.
740    let merged = if all_batches.len() <= 1 {
741        all_batches
742    } else {
743        let batch = arrow_select::concat::concat_batches(&schema, all_batches.iter())
744            .map_err(|e| anyhow::anyhow!("concat sub-indexes: {}", e))?;
745        vec![batch]
746    };
747
748    Ok((schema, merged))
749}
750
751/// Read the manifest from a v0.7 multi-index archive.
752/// Returns an error if the file is a plain v0.6 single-index archive.
753pub fn read_znippy_manifest(path: &Path) -> Result<Vec<ManifestEntry>> {
754    let mut file = File::open(path)?;
755    let file_len = file.metadata()?.len();
756    anyhow::ensure!(file_len >= 16, "file too small to be a v0.7 archive");
757
758    file.seek(SeekFrom::End(-16))?;
759    let mut tail = [0u8; 16];
760    file.read_exact(&mut tail)?;
761
762    match interpret_footer(&tail) {
763        IndexFooter::Single { .. } => {
764            anyhow::bail!("not a v0.7 multi-index archive (no MULTI_INDEX_MAGIC)")
765        }
766        IndexFooter::Multi { manifest_offset } => {
767            let manifest_end = file_len - 16;
768            anyhow::ensure!(manifest_offset <= manifest_end, "corrupt manifest_offset");
769            let manifest_len = (manifest_end - manifest_offset) as usize;
770            file.seek(SeekFrom::Start(manifest_offset))?;
771            let mut manifest_bytes = vec![0u8; manifest_len];
772            file.read_exact(&mut manifest_bytes)?;
773            let mut entries = read_manifest_bytes(&manifest_bytes)?;
774            // Hide reserved (lookup/trie) entries from data-manifest consumers.
775            entries.retain(|e| !is_reserved_module(&e.module_name));
776            Ok(entries)
777        }
778    }
779}
780
781/// Read **all** manifest entries of a sealed v0.7 archive, *including* the
782/// reserved (lookup/trie) ones, plus the manifest's byte offset.
783///
784/// Public entrypoint used by the append/resume sink
785/// ([`ArrowIpcSinkAppend::open_existing`](crate::ArrowIpcSinkAppend::open_existing)):
786/// it needs the reserved sections (to find the blob-region end and to recover
787/// the sorted lookup) which `read_znippy_manifest` hides. Read-only — does not
788/// touch the original write path.
789pub fn read_znippy_full_manifest(path: &Path) -> Result<(Vec<ManifestEntry>, u64)> {
790    let mut file = File::open(path)?;
791    let file_len = file.metadata()?.len();
792    read_full_manifest(&mut file, file_len)
793}
794
795/// Read all manifest entries, *including* reserved (lookup/trie) ones.
796fn read_full_manifest(file: &mut File, file_len: u64) -> Result<(Vec<ManifestEntry>, u64)> {
797    anyhow::ensure!(file_len >= 16, "file too small to be a v0.7 archive");
798    file.seek(SeekFrom::End(-16))?;
799    let mut tail = [0u8; 16];
800    file.read_exact(&mut tail)?;
801    let manifest_offset = match interpret_footer(&tail) {
802        IndexFooter::Multi { manifest_offset } => manifest_offset,
803        IndexFooter::Single { .. } => anyhow::bail!("not a v0.7 multi-index archive"),
804    };
805    let manifest_end = file_len.checked_sub(16)
806        .ok_or_else(|| anyhow::anyhow!("v0.7 archive too small"))?;
807    anyhow::ensure!(manifest_offset <= manifest_end, "corrupt manifest_offset");
808    let manifest_len = (manifest_end - manifest_offset) as usize;
809    file.seek(SeekFrom::Start(manifest_offset))?;
810    let mut manifest_bytes = vec![0u8; manifest_len];
811    file.read_exact(&mut manifest_bytes)?;
812    Ok((read_manifest_bytes(&manifest_bytes)?, manifest_offset))
813}
814
815/// Read the raw bytes of one reserved sub-section (lookup or trie), if present.
816fn read_reserved_section(path: &Path, module_name: &str) -> Result<Option<Vec<u8>>> {
817    let mut file = File::open(path)?;
818    let file_len = file.metadata()?.len();
819    let (entries, _) = read_full_manifest(&mut file, file_len)?;
820    let Some(entry) = entries.iter().find(|e| e.module_name == module_name) else {
821        return Ok(None);
822    };
823    // Bound the attacker-controlled declared length against the real file size
824    // before allocating, so a corrupt manifest can't force a huge allocation.
825    anyhow::ensure!(
826        entry.index_offset.checked_add(entry.index_len)
827            .is_some_and(|end| end <= file_len),
828        "reserved section {} out of bounds (offset={}, len={}, file_len={})",
829        entry.module_name, entry.index_offset, entry.index_len, file_len
830    );
831    file.seek(SeekFrom::Start(entry.index_offset))?;
832    let mut bytes = vec![0u8; entry.index_len as usize];
833    file.read_exact(&mut bytes)?;
834    Ok(Some(bytes))
835}
836
837/// Decode the lookup sub-index bytes into parallel column vectors (already sorted
838/// by `(relative_path, chunk_seq)` on disk).
839/// Read row `i` of an index/lookup `checksum` column as a 32-byte blake3 digest.
840///
841/// `FixedSizeBinaryArray` is one concrete Arrow type for *every* width, so a
842/// `downcast_ref::<FixedSizeBinaryArray>()` succeeds just as happily for
843/// `FixedSizeBinary(16)` as for `(32)`. Copying such a row straight into a
844/// `[u8; 32]` panics ("source slice length (16) does not match destination slice
845/// length (32)") — a process abort driven by an attacker-supplied schema, which
846/// is exactly what the downcast-or-error style everywhere else in this file
847/// exists to prevent. Check the declared width and return a clean `Err` instead.
848/// (`ZnippyArchive::build_file_index` already guards this with the same test.)
849fn checksum32(col: &arrow::array::FixedSizeBinaryArray, i: usize) -> Result<[u8; 32]> {
850    if col.value_length() != 32 {
851        return Err(anyhow::anyhow!(
852            "checksum column has width {}, expected 32",
853            col.value_length()
854        ));
855    }
856    let mut ck = [0u8; 32];
857    ck.copy_from_slice(col.value(i));
858    Ok(ck)
859}
860
861fn decode_lookup(bytes: &[u8]) -> Result<LookupColumns> {
862    use arrow::array::{BooleanArray, FixedSizeBinaryArray, StringArray, UInt32Array, UInt64Array};
863    use arrow::ipc::reader::StreamReader;
864
865    let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)?;
866    let mut cols = LookupColumns::default();
867    for batch in reader {
868        let batch = batch?;
869        let get = |n: &str| batch.column_by_name(n)
870            .ok_or_else(|| anyhow::anyhow!("lookup missing column {n}"));
871        let paths = get("relative_path")?.as_any().downcast_ref::<StringArray>()
872            .ok_or_else(|| anyhow::anyhow!("relative_path type"))?;
873        let chunk_seq = get("chunk_seq")?.as_any().downcast_ref::<UInt32Array>()
874            .ok_or_else(|| anyhow::anyhow!("chunk_seq type"))?;
875        let fdata = get("fdata_offset")?.as_any().downcast_ref::<UInt64Array>()
876            .ok_or_else(|| anyhow::anyhow!("fdata_offset type"))?;
877        let compressed = get("compressed")?.as_any().downcast_ref::<BooleanArray>()
878            .ok_or_else(|| anyhow::anyhow!("compressed type"))?;
879        let usize_col = get("uncompressed_size")?.as_any().downcast_ref::<UInt64Array>()
880            .ok_or_else(|| anyhow::anyhow!("uncompressed_size type"))?;
881        let blob_offset = get("blob_offset")?.as_any().downcast_ref::<UInt64Array>()
882            .ok_or_else(|| anyhow::anyhow!("blob_offset type"))?;
883        let blob_size = get("blob_size")?.as_any().downcast_ref::<UInt64Array>()
884            .ok_or_else(|| anyhow::anyhow!("blob_size type"))?;
885        let checksum = get("checksum")?.as_any().downcast_ref::<FixedSizeBinaryArray>()
886            .ok_or_else(|| anyhow::anyhow!("checksum type"))?;
887        for i in 0..batch.num_rows() {
888            cols.paths.push(paths.value(i).to_string());
889            let ck = checksum32(checksum, i)?;
890            cols.locs.push(ChunkLoc {
891                chunk_seq: chunk_seq.value(i),
892                fdata_offset: fdata.value(i),
893                blob_offset: blob_offset.value(i),
894                blob_size: blob_size.value(i),
895                uncompressed_size: usize_col.value(i),
896                compressed: compressed.value(i),
897                checksum: ck,
898            });
899        }
900    }
901    Ok(cols)
902}
903
904#[derive(Default)]
905struct LookupColumns {
906    paths: Vec<String>,
907    locs: Vec<ChunkLoc>,
908}
909
910/// Locate every chunk of `target` for single-file random access.
911///
912/// Fast paths, in order: the fst trie (O(key length)) → binary search of the
913/// sorted lookup sub-index (O(log n)) → linear scan of the merged main index
914/// (O(n), for archives written before the lookup layer existed). Returns the
915/// chunks sorted by `chunk_seq`, or an empty vec if `target` is not in the archive.
916pub fn locate_file(path: &Path, target: &str) -> Result<Vec<ChunkLoc>> {
917    if let Some(lookup_bytes) = read_reserved_section(path, LOOKUP_MODULE)? {
918        let cols = decode_lookup(&lookup_bytes)?;
919        let n = cols.paths.len();
920
921        // Find any row whose path == target.
922        let hit = if let Some(trie_bytes) = read_reserved_section(path, TRIE_MODULE)? {
923            let map = fst::Map::new(trie_bytes)
924                .map_err(|e| anyhow::anyhow!("trie open: {e}"))?;
925            map.get(target.as_bytes()).map(|v| v as usize)
926        } else {
927            // Lookup is sorted by path; binary-search the path column.
928            match cols.paths.binary_search_by(|p| p.as_str().cmp(target)) {
929                Ok(i) => Some(i),
930                Err(_) => None,
931            }
932        };
933
934        let Some(hit) = hit else { return Ok(Vec::new()); };
935
936        // The trie value is attacker-controlled: a corrupt/malicious archive can
937        // map `target` to a row index past the (smaller) lookup table. Treat an
938        // out-of-range hit as "not found" instead of indexing OOB below — this is
939        // a remote-DoS guard on the random-access read path.
940        if hit >= n { return Ok(Vec::new()); }
941
942        // Expand to the contiguous run of rows sharing this path.
943        let mut start = hit;
944        while start > 0 && cols.paths[start - 1] == target { start -= 1; }
945        let mut end = hit + 1;
946        while end < n && cols.paths[end] == target { end += 1; }
947
948        let mut out: Vec<ChunkLoc> = cols.locs[start..end].to_vec();
949        out.sort_by_key(|c| c.chunk_seq);
950        return Ok(out);
951    }
952
953    // Fallback: no lookup layer — scan the merged main index.
954    locate_file_via_index(path, target)
955}
956
957/// Slim per-file (per-artifact) metadata for browsing an archive without
958/// reading file bytes. One row per file, aggregated across its chunks.
959#[derive(Debug, Clone, PartialEq)]
960pub struct ArtifactMeta {
961    pub relative_path: String,
962    /// Total uncompressed size across all of the file's chunks.
963    pub uncompressed_size: u64,
964    pub chunk_count: u32,
965    /// Whether the file's data was compressed (false on the stored-raw skip path).
966    pub compressed: bool,
967}
968
969/// Metadata for **every** file in the archive, sorted by `relative_path`.
970///
971/// Reads only the lookup sub-index (not the file bytes); falls back to the main
972/// index for archives written before the lookup layer.
973pub fn get_all_files_meta(path: &Path) -> Result<Vec<ArtifactMeta>> {
974    files_meta_impl(path, None)
975}
976
977/// Metadata for the files whose `relative_path` starts with `prefix` — for when
978/// you don't want all files (e.g. one `group/artifact/` subtree).
979///
980/// When the trie is present this jumps straight to the first matching key via the
981/// fst's ordered range (O(prefix) to seek, then O(matches)); otherwise it binary
982/// -searches the sorted lookup; otherwise it scans the legacy main index.
983pub fn get_files_meta_with_prefix(path: &Path, prefix: &str) -> Result<Vec<ArtifactMeta>> {
984    files_meta_impl(path, Some(prefix))
985}
986
987fn files_meta_impl(path: &Path, prefix: Option<&str>) -> Result<Vec<ArtifactMeta>> {
988    use fst::{IntoStreamer, Streamer};
989
990    if let Some(lookup_bytes) = read_reserved_section(path, LOOKUP_MODULE)? {
991        let cols = decode_lookup(&lookup_bytes)?; // sorted by (relative_path, chunk_seq)
992        let n = cols.paths.len();
993
994        // Resolve the contiguous row window [lo, hi) to aggregate.
995        let (lo, hi) = match prefix {
996            None | Some("") => (0, n),
997            Some(pre) => {
998                // Seek the first key >= prefix. Use the trie's ordered range when
999                // present (the "trie search"); else binary-search the sorted paths.
1000                let lo = if let Some(trie_bytes) = read_reserved_section(path, TRIE_MODULE)? {
1001                    let map = fst::Map::new(trie_bytes)
1002                        .map_err(|e| anyhow::anyhow!("trie open: {e}"))?;
1003                    let mut stream = map.range().ge(pre.as_bytes()).into_stream();
1004                    match stream.next() {
1005                        Some((k, v)) if k.starts_with(pre.as_bytes()) => v as usize,
1006                        _ => return Ok(Vec::new()),
1007                    }
1008                } else {
1009                    cols.paths.partition_point(|p| p.as_str() < pre)
1010                };
1011                if lo >= n || !cols.paths[lo].starts_with(pre) {
1012                    return Ok(Vec::new());
1013                }
1014                let mut hi = lo;
1015                while hi < n && cols.paths[hi].starts_with(pre) {
1016                    hi += 1;
1017                }
1018                (lo, hi)
1019            }
1020        };
1021
1022        // Aggregate contiguous chunk rows per path.
1023        let mut out = Vec::new();
1024        let mut i = lo;
1025        while i < hi {
1026            let p = &cols.paths[i];
1027            let mut total = 0u64;
1028            let mut count = 0u32;
1029            let mut compressed = false;
1030            while i < hi && &cols.paths[i] == p {
1031                total += cols.locs[i].uncompressed_size;
1032                count += 1;
1033                compressed |= cols.locs[i].compressed;
1034                i += 1;
1035            }
1036            out.push(ArtifactMeta {
1037                relative_path: p.clone(),
1038                uncompressed_size: total,
1039                chunk_count: count,
1040                compressed,
1041            });
1042        }
1043        return Ok(out);
1044    }
1045
1046    files_meta_via_index(path, prefix)
1047}
1048
1049/// A cached, open-once random-access reader over a sealed v0.7 archive.
1050///
1051/// The free functions [`locate_file`] and [`get_files_meta_with_prefix`] re-open
1052/// the file and re-read the footer + manifest + both reserved sections (the
1053/// lookup sub-index and the trie) on **every** call, and re-`decode_lookup` every
1054/// row each time — three manifest reads per lookup (analysis §1.5). For a
1055/// long-lived reader (a browsing UI, holger's dynamic side, selective restore)
1056/// that is pure repeated I/O.
1057///
1058/// `ArchiveReader::open` reads the footer, manifest, lookup sub-index and trie
1059/// **once**, decodes the lookup columns once, and builds the fst map once. Every
1060/// subsequent [`locate`](ArchiveReader::locate),
1061/// [`files_meta`](ArchiveReader::files_meta) and
1062/// [`files_meta_with_prefix`](ArchiveReader::files_meta_with_prefix) query is then
1063/// served from memory — an fst `get` (O(key)) or a binary search (O(log n)) over
1064/// the already-decoded columns — with **zero** further I/O and **zero**
1065/// per-call manifest reads. The on-disk format is untouched; results are
1066/// identical to the equivalent free function.
1067pub struct ArchiveReader {
1068    /// Relative paths, sorted by `(relative_path, chunk_seq)` (lookup order).
1069    paths: Vec<String>,
1070    /// Chunk locators, index-aligned with `paths`.
1071    locs: Vec<ChunkLoc>,
1072    /// `path -> first row index` fst over the sorted paths, when the archive
1073    /// carries a trie section (it always does for v0.7 seals). Absent ⇒ fall back
1074    /// to binary search over `paths`.
1075    trie: Option<fst::Map<Vec<u8>>>,
1076    /// The archive handle kept open across calls, so [`read_file`](Self::read_file)
1077    /// serves selective restore with **one** open + cached locate (positioned
1078    /// `pread`s only — never seeks a shared cursor).
1079    file: File,
1080    /// Real archive length, for the same pre-alloc bounds check the free
1081    /// `get_file` does against attacker-controlled blob offsets/sizes.
1082    file_len: u64,
1083}
1084
1085impl ArchiveReader {
1086    /// Open a sealed v0.7 archive and cache its manifest + lookup + trie. Reads
1087    /// the manifest exactly once (vs three re-reads per free-function call).
1088    pub fn open(path: &Path) -> Result<Self> {
1089        let mut file = File::open(path)?;
1090        let file_len = file.metadata()?.len();
1091        let (entries, _) = read_full_manifest(&mut file, file_len)?;
1092
1093        // Read one reserved section by module name from the already-open handle,
1094        // bounding the attacker-controlled declared length against the real file
1095        // size before allocating (same guard as `read_reserved_section`).
1096        let mut read_section = |module: &str| -> Result<Option<Vec<u8>>> {
1097            let Some(entry) = entries.iter().find(|e| e.module_name == module) else {
1098                return Ok(None);
1099            };
1100            anyhow::ensure!(
1101                entry.index_offset.checked_add(entry.index_len)
1102                    .is_some_and(|end| end <= file_len),
1103                "reserved section {} out of bounds (offset={}, len={}, file_len={})",
1104                entry.module_name, entry.index_offset, entry.index_len, file_len
1105            );
1106            file.seek(SeekFrom::Start(entry.index_offset))?;
1107            let mut bytes = vec![0u8; entry.index_len as usize];
1108            file.read_exact(&mut bytes)?;
1109            Ok(Some(bytes))
1110        };
1111
1112        let lookup_bytes = read_section(LOOKUP_MODULE)?.ok_or_else(|| {
1113            anyhow::anyhow!("archive has no lookup sub-index (not a v0.7 sealed archive)")
1114        })?;
1115        let cols = decode_lookup(&lookup_bytes)?;
1116        let trie = match read_section(TRIE_MODULE)? {
1117            Some(tb) => Some(fst::Map::new(tb).map_err(|e| anyhow::anyhow!("trie open: {e}"))?),
1118            None => None,
1119        };
1120
1121        Ok(Self { paths: cols.paths, locs: cols.locs, trie, file, file_len })
1122    }
1123
1124    /// Total number of chunk rows cached (across all files).
1125    pub fn row_count(&self) -> usize {
1126        self.paths.len()
1127    }
1128
1129    /// Read a single file's bytes by relative path — the cached, held-open
1130    /// equivalent of [`get_file`](crate::get_file). Uses the in-memory locate
1131    /// (no manifest re-read, no per-call `decode_lookup`) and the archive handle
1132    /// opened once in [`open`](Self::open), then `pread`s + decompresses +
1133    /// blake3-verifies each chunk. This is the selective-restore entry point:
1134    /// hold one reader and call `read_file` per artifact instead of paying a full
1135    /// index re-parse on every `get_file`.
1136    ///
1137    /// Returns an error if `target` is not present in the archive.
1138    pub fn read_file(&self, target: &str) -> Result<Vec<u8>> {
1139        let chunks = self.locate(target);
1140        anyhow::ensure!(!chunks.is_empty(), "file not found in archive: {target}");
1141        crate::decompress::reassemble_file(&self.file, self.file_len, target, &chunks)
1142    }
1143
1144    /// Locate every chunk of `target`, sorted by `chunk_seq` — the cached
1145    /// equivalent of [`locate_file`], but with no I/O after `open`.
1146    pub fn locate(&self, target: &str) -> Vec<ChunkLoc> {
1147        let n = self.paths.len();
1148        let hit = match &self.trie {
1149            Some(map) => map.get(target.as_bytes()).map(|v| v as usize),
1150            None => self.paths.binary_search_by(|p| p.as_str().cmp(target)).ok(),
1151        };
1152        let Some(hit) = hit else { return Vec::new(); };
1153
1154        // The trie value is attacker-controlled (see `locate_file`): a malicious
1155        // archive can map `target` to a row index past the cached lookup table.
1156        // Treat an out-of-range hit as "not found" instead of panicking below.
1157        if hit >= n { return Vec::new(); }
1158
1159        // Expand to the contiguous run of rows sharing this path.
1160        let mut start = hit;
1161        while start > 0 && self.paths[start - 1] == target { start -= 1; }
1162        let mut end = hit + 1;
1163        while end < n && self.paths[end] == target { end += 1; }
1164
1165        let mut out: Vec<ChunkLoc> = self.locs[start..end].to_vec();
1166        out.sort_by_key(|c| c.chunk_seq);
1167        out
1168    }
1169
1170    /// Per-file metadata for **every** file, sorted by `relative_path` — the
1171    /// cached equivalent of [`get_all_files_meta`].
1172    pub fn files_meta(&self) -> Vec<ArtifactMeta> {
1173        self.aggregate(0, self.paths.len())
1174    }
1175
1176    /// Per-file metadata for files whose path starts with `prefix` — the cached
1177    /// equivalent of [`get_files_meta_with_prefix`].
1178    pub fn files_meta_with_prefix(&self, prefix: &str) -> Vec<ArtifactMeta> {
1179        let (lo, hi) = self.window(prefix);
1180        self.aggregate(lo, hi)
1181    }
1182
1183    /// Resolve the contiguous `[lo, hi)` row window matching `prefix` — trie
1184    /// range-seek when present, else binary search over the sorted paths. Empty
1185    /// prefix selects all rows; a non-matching prefix returns `(0, 0)`.
1186    fn window(&self, prefix: &str) -> (usize, usize) {
1187        use fst::{IntoStreamer, Streamer};
1188        let n = self.paths.len();
1189        if prefix.is_empty() {
1190            return (0, n);
1191        }
1192        let lo = match &self.trie {
1193            Some(map) => {
1194                let mut stream = map.range().ge(prefix.as_bytes()).into_stream();
1195                match stream.next() {
1196                    Some((k, v)) if k.starts_with(prefix.as_bytes()) => v as usize,
1197                    _ => return (0, 0),
1198                }
1199            }
1200            None => self.paths.partition_point(|p| p.as_str() < prefix),
1201        };
1202        if lo >= n || !self.paths[lo].starts_with(prefix) {
1203            return (0, 0);
1204        }
1205        let mut hi = lo;
1206        while hi < n && self.paths[hi].starts_with(prefix) {
1207            hi += 1;
1208        }
1209        (lo, hi)
1210    }
1211
1212    /// Aggregate the contiguous chunk rows in `[lo, hi)` into one `ArtifactMeta`
1213    /// per file (rows are already grouped by path in lookup order).
1214    fn aggregate(&self, lo: usize, hi: usize) -> Vec<ArtifactMeta> {
1215        let mut out = Vec::new();
1216        let mut i = lo;
1217        while i < hi {
1218            let p = &self.paths[i];
1219            let mut total = 0u64;
1220            let mut count = 0u32;
1221            let mut compressed = false;
1222            while i < hi && &self.paths[i] == p {
1223                total += self.locs[i].uncompressed_size;
1224                count += 1;
1225                compressed |= self.locs[i].compressed;
1226                i += 1;
1227            }
1228            out.push(ArtifactMeta {
1229                relative_path: p.clone(),
1230                uncompressed_size: total,
1231                chunk_count: count,
1232                compressed,
1233            });
1234        }
1235        out
1236    }
1237}
1238
1239/// Legacy fallback: aggregate per-file metadata from the merged main index.
1240fn files_meta_via_index(path: &Path, prefix: Option<&str>) -> Result<Vec<ArtifactMeta>> {
1241    use arrow::array::{BooleanArray, StringArray, UInt64Array};
1242
1243    let (schema, batches) = read_znippy_index(path)?;
1244    let batch = match batches.len() {
1245        0 => return Ok(Vec::new()),
1246        1 => batches.into_iter().next().unwrap(),
1247        _ => arrow_select::concat::concat_batches(&schema, batches.iter())?,
1248    };
1249    // Downcast-or-error: the schema is attacker-controlled, so a wrong column
1250    // type must surface as an `Err`, never an `unwrap` panic (DoS on `get`/meta).
1251    // Downcast-or-error: the schema is attacker-controlled, so a wrong column
1252    // type must surface as an `Err`, never an `unwrap` panic (DoS on `get`/meta).
1253    let col = |n: &str| batch.column_by_name(n)
1254        .ok_or_else(|| anyhow::anyhow!("index missing column {n}"));
1255    let paths = col("relative_path")?.as_any().downcast_ref::<StringArray>()
1256        .ok_or_else(|| anyhow::anyhow!("index column relative_path has unexpected type"))?;
1257    let usize_col = col("uncompressed_size")?.as_any().downcast_ref::<UInt64Array>()
1258        .ok_or_else(|| anyhow::anyhow!("index column uncompressed_size has unexpected type"))?;
1259    let compressed = col("compressed")?.as_any().downcast_ref::<BooleanArray>()
1260        .ok_or_else(|| anyhow::anyhow!("index column compressed has unexpected type"))?;
1261
1262    // Main index rows are not path-sorted, so aggregate via a map.
1263    let mut agg: HashMap<&str, (u64, u32, bool)> = HashMap::new();
1264    for i in 0..batch.num_rows() {
1265        let p = paths.value(i);
1266        if let Some(pre) = prefix {
1267            if !p.starts_with(pre) { continue; }
1268        }
1269        let e = agg.entry(p).or_insert((0, 0, false));
1270        e.0 += usize_col.value(i);
1271        e.1 += 1;
1272        e.2 |= compressed.value(i);
1273    }
1274    let mut out: Vec<ArtifactMeta> = agg.into_iter().map(|(p, (sz, c, comp))| ArtifactMeta {
1275        relative_path: p.to_string(),
1276        uncompressed_size: sz,
1277        chunk_count: c,
1278        compressed: comp,
1279    }).collect();
1280    out.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1281    Ok(out)
1282}
1283
1284/// O(n) fallback for archives written before the lookup sub-index existed.
1285fn locate_file_via_index(path: &Path, target: &str) -> Result<Vec<ChunkLoc>> {
1286    use arrow::array::{BooleanArray, FixedSizeBinaryArray, StringArray, UInt32Array, UInt64Array};
1287
1288    let (schema, batches) = read_znippy_index(path)?;
1289    let batch = match batches.len() {
1290        0 => return Ok(Vec::new()),
1291        1 => batches.into_iter().next().unwrap(),
1292        _ => arrow_select::concat::concat_batches(&schema, batches.iter())?,
1293    };
1294    // Downcast-or-error: the schema is attacker-controlled, so a wrong column
1295    // type must surface as an `Err`, never an `unwrap` panic (DoS on `get`).
1296    let col = |n: &str| batch.column_by_name(n)
1297        .ok_or_else(|| anyhow::anyhow!("index missing column {n}"));
1298    let paths = col("relative_path")?.as_any().downcast_ref::<StringArray>()
1299        .ok_or_else(|| anyhow::anyhow!("index column relative_path has unexpected type"))?;
1300    let chunk_seq = col("chunk_seq")?.as_any().downcast_ref::<UInt32Array>()
1301        .ok_or_else(|| anyhow::anyhow!("index column chunk_seq has unexpected type"))?;
1302    let fdata = col("fdata_offset")?.as_any().downcast_ref::<UInt64Array>()
1303        .ok_or_else(|| anyhow::anyhow!("index column fdata_offset has unexpected type"))?;
1304    let compressed = col("compressed")?.as_any().downcast_ref::<BooleanArray>()
1305        .ok_or_else(|| anyhow::anyhow!("index column compressed has unexpected type"))?;
1306    let usize_col = col("uncompressed_size")?.as_any().downcast_ref::<UInt64Array>()
1307        .ok_or_else(|| anyhow::anyhow!("index column uncompressed_size has unexpected type"))?;
1308    let blob_offset = col("blob_offset")?.as_any().downcast_ref::<UInt64Array>()
1309        .ok_or_else(|| anyhow::anyhow!("index column blob_offset has unexpected type"))?;
1310    let blob_size = col("blob_size")?.as_any().downcast_ref::<UInt64Array>()
1311        .ok_or_else(|| anyhow::anyhow!("index column blob_size has unexpected type"))?;
1312    let checksum = col("checksum")?.as_any().downcast_ref::<FixedSizeBinaryArray>()
1313        .ok_or_else(|| anyhow::anyhow!("index column checksum has unexpected type"))?;
1314
1315    let mut out = Vec::new();
1316    for i in 0..batch.num_rows() {
1317        if paths.value(i) == target {
1318            let ck = checksum32(checksum, i)?;
1319            out.push(ChunkLoc {
1320                chunk_seq: chunk_seq.value(i),
1321                fdata_offset: fdata.value(i),
1322                blob_offset: blob_offset.value(i),
1323                blob_size: blob_size.value(i),
1324                uncompressed_size: usize_col.value(i),
1325                compressed: compressed.value(i),
1326                checksum: ck,
1327            });
1328        }
1329    }
1330    out.sort_by_key(|c| c.chunk_seq);
1331    Ok(out)
1332}
1333
1334pub fn is_probably_compressed(path: &Path) -> bool {
1335    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1336        let ext = ext.to_ascii_lowercase();
1337        matches!(
1338            ext.as_str(),
1339            "zip" | "gz" | "bz2" | "xz" | "lz" | "lzma" | "7z" | "rar" | "cab"
1340                | "jar" | "war" | "ear" | "zst" | "sz" | "lz4" | "tgz" | "txz"
1341                | "tbz" | "apk" | "dmg" | "deb" | "rpm" | "arrow" | "mpeg" | "mpg"
1342                | "jpeg" | "jpg" | "gif" | "bmp" | "png" | "crate" | "znippy"
1343                | "zdata" | "parquet" | "webp" | "webm"
1344                // already-compressed / high-entropy payloads (Skidbladnir bundles
1345                // carry these): age = encrypted (never shrinks), iso = squashfs
1346                // (already xz/zstd), pdf = Flate streams. NB: pg_dump plain-SQL
1347                // (`.dump`/`.sql`) is deliberately NOT here — it compresses well.
1348                | "age" | "iso" | "pdf"
1349                // git's pack directory — the bulk of any real `.git`, and of
1350                // gunnar's cold tier. `pack` holds deflated objects; `idx`,
1351                // `midx` and `bitmap` hold object ids (hash output) and EWAH
1352                // bitmaps, all incompressible. NB: `.rev` is deliberately NOT
1353                // here — it is a permutation of 0..N as big-endian u32s, whose
1354                // high bytes are mostly zero, so it genuinely does compress.
1355                | "pack" | "idx" | "midx" | "bitmap"
1356        )
1357    } else {
1358        false
1359    }
1360}
1361
1362/// The name-only decision, kept as the fast path it has always been.
1363///
1364/// This is step 2 of the policy in [`crate::precompressed`] — it sees a path and
1365/// nothing else, so it cannot reach an entry that carries no extension (a git
1366/// loose object is named for its oid) and it believes a name that lies. Callers
1367/// holding real bytes should go through
1368/// [`SkipPolicy`](crate::precompressed::SkipPolicy), which runs this first and
1369/// then refines it with a magic-byte probe.
1370pub fn should_skip_compression(path: &Path) -> bool {
1371    is_probably_compressed(path)
1372}
1373
1374#[cfg(test)]
1375mod skip_compression_tests {
1376    use super::*;
1377
1378    #[test]
1379    fn skips_already_compressed_and_encrypted() {
1380        for p in ["secrets/secrets.age", "infra/talos/talos-1.13.iso", "s3/doc.pdf", "x.PDF"] {
1381            assert!(should_skip_compression(Path::new(p)), "should skip {p}");
1382        }
1383    }
1384
1385    #[test]
1386    fn compresses_plain_dump_and_config() {
1387        // pg_dump plain-SQL + declarative config must still be compressed.
1388        for p in ["dbdump/njord.dump", "njord.sql", "lakespec.json", "njordconf/serverspec.json"] {
1389            assert!(!should_skip_compression(Path::new(p)), "should compress {p}");
1390        }
1391    }
1392
1393    /// A packfile and its index are the two largest things in any real `.git`,
1394    /// and neither was on the extension table.
1395    #[test]
1396    fn skips_git_pack_directory_artefacts() {
1397        for p in [
1398            ".git/objects/pack/pack-9f2c.pack",
1399            ".git/objects/pack/pack-9f2c.idx",
1400            ".git/objects/pack/multi-pack-index.midx",
1401            ".git/objects/pack/pack-9f2c.bitmap",
1402        ] {
1403            assert!(should_skip_compression(Path::new(p)), "should skip {p}");
1404        }
1405    }
1406
1407    /// The pack directory is not uniformly incompressible, and the table must
1408    /// not be widened to the whole directory. `.rev` is a permutation of
1409    /// `0..N` as big-endian u32s — its high bytes are mostly zero.
1410    #[test]
1411    fn still_compresses_the_compressible_git_artefacts() {
1412        for p in [
1413            ".git/objects/pack/pack-9f2c.rev",
1414            ".git/objects/pack/pack-9f2c.promisor",
1415            ".git/COMMIT_EDITMSG",
1416            ".git/config",
1417        ] {
1418            assert!(!should_skip_compression(Path::new(p)), "should compress {p}");
1419        }
1420    }
1421}
1422
1423#[derive(Debug, Default)]
1424pub struct VerifyReport {
1425    pub total_files: usize,
1426    pub verified_files: usize,
1427    pub corrupt_files: usize,
1428    pub total_bytes: u64,
1429    pub verified_bytes: u64,
1430    pub corrupt_bytes: u64,
1431    pub chunks: u64,
1432}
1433
1434pub fn list_archive_contents(path: &Path) -> Result<()> {
1435    let (_schema, batches) = read_znippy_index(path)?;
1436    for batch in &batches {
1437        let paths = batch
1438            .column_by_name("relative_path")
1439            .and_then(|c| c.as_any().downcast_ref::<arrow::array::StringArray>())
1440            .ok_or_else(|| anyhow::anyhow!("missing relative_path column"))?;
1441        let sizes = batch
1442            .column_by_name("uncompressed_size")
1443            .and_then(|c| c.as_any().downcast_ref::<arrow::array::UInt64Array>())
1444            .ok_or_else(|| anyhow::anyhow!("missing uncompressed_size column"))?;
1445        let chunk_seqs = batch
1446            .column_by_name("chunk_seq")
1447            .and_then(|c| c.as_any().downcast_ref::<arrow::array::UInt32Array>());
1448        let group_ids = batch
1449            .column_by_name("group_id")
1450            .and_then(|c| c.as_any().downcast_ref::<arrow::array::StringArray>());
1451        let artifact_ids = batch
1452            .column_by_name("artifact_id")
1453            .and_then(|c| c.as_any().downcast_ref::<arrow::array::StringArray>());
1454        let versions = batch
1455            .column_by_name("version")
1456            .and_then(|c| c.as_any().downcast_ref::<arrow::array::StringArray>());
1457        for i in 0..batch.num_rows() {
1458            // Only print once per file (chunk_seq == 0)
1459            if let Some(seqs) = chunk_seqs {
1460                if seqs.value(i) != 0 {
1461                    continue;
1462                }
1463            }
1464            if let (Some(g), Some(a), Some(v)) = (group_ids, artifact_ids, versions) {
1465                if !g.is_null(i) {
1466                    println!(
1467                        "{}\t{}\t{}:{}:{}",
1468                        paths.value(i),
1469                        sizes.value(i),
1470                        g.value(i),
1471                        a.value(i),
1472                        v.value(i)
1473                    );
1474                    continue;
1475                }
1476            }
1477            println!("{}\t{}", paths.value(i), sizes.value(i));
1478        }
1479    }
1480    Ok(())
1481}
1482
1483pub fn verify_archive_integrity(path: &Path) -> Result<VerifyReport> {
1484    let out_dir = PathBuf::from("/dev/null");
1485    decompress_archive(path, false, &out_dir)
1486}
1487
1488#[cfg(test)]
1489mod version_tests {
1490    use super::*;
1491
1492    #[test]
1493    fn current_and_older_versions_are_accepted() {
1494        let mut m = HashMap::new();
1495        // No version recorded (legacy / pre-version archives): read as before.
1496        check_format_version(&m).unwrap();
1497        // Exactly the supported version.
1498        m.insert(FORMAT_VERSION_KEY.into(), ZNIPPY_FORMAT_VERSION.to_string());
1499        check_format_version(&m).unwrap();
1500        // An older version.
1501        m.insert(FORMAT_VERSION_KEY.into(), (ZNIPPY_FORMAT_VERSION - 1).to_string());
1502        check_format_version(&m).unwrap();
1503    }
1504
1505    #[test]
1506    fn newer_version_is_rejected_clearly() {
1507        let mut m = HashMap::new();
1508        m.insert(FORMAT_VERSION_KEY.into(), (ZNIPPY_FORMAT_VERSION + 1).to_string());
1509        let err = check_format_version(&m).unwrap_err().to_string();
1510        assert!(err.contains("newer than this reader supports"), "got: {err}");
1511        assert!(err.contains("upgrade znippy"), "got: {err}");
1512    }
1513
1514    #[test]
1515    fn writer_records_the_current_version() {
1516        let meta = build_arrow_metadata_for_config(&crate::common_config::CONFIG);
1517        assert_eq!(
1518            meta.get(FORMAT_VERSION_KEY).map(String::as_str),
1519            Some(ZNIPPY_FORMAT_VERSION.to_string().as_str())
1520        );
1521    }
1522}
1523
1524#[cfg(test)]
1525mod checksum_width_tests {
1526    use super::*;
1527    use arrow::array::FixedSizeBinaryArray;
1528
1529    /// `FixedSizeBinaryArray` is ONE concrete Arrow type for every width, so the
1530    /// `downcast_ref::<FixedSizeBinaryArray>()` in `decode_lookup` /
1531    /// `locate_file_via_index` succeeds just as happily for a hostile archive
1532    /// declaring `FixedSizeBinary(16)`. The old code then did
1533    /// `[0u8; 32].copy_from_slice(col.value(i))`, which panics — a process abort
1534    /// driven purely by an attacker-supplied schema, on the `znippy get` path.
1535    #[test]
1536    fn narrow_checksum_column_errors_instead_of_panicking() {
1537        let narrow = FixedSizeBinaryArray::try_from_iter([[0u8; 16]].into_iter()).unwrap();
1538        assert_eq!(narrow.value_length(), 16);
1539        let err = checksum32(&narrow, 0).expect_err("a 16-byte checksum column must be an Err");
1540        assert!(
1541            err.to_string().contains("width 16"),
1542            "error must name the bad width, got: {err}"
1543        );
1544    }
1545
1546    #[test]
1547    fn wide_checksum_column_errors_instead_of_truncating() {
1548        let wide = FixedSizeBinaryArray::try_from_iter([[7u8; 64]].into_iter()).unwrap();
1549        assert!(checksum32(&wide, 0).is_err(), "a 64-byte checksum column must be an Err");
1550    }
1551
1552    #[test]
1553    fn correct_width_checksum_is_read_verbatim() {
1554        let mut digest = [0u8; 32];
1555        for (i, b) in digest.iter_mut().enumerate() {
1556            *b = i as u8;
1557        }
1558        let col = FixedSizeBinaryArray::try_from_iter([digest].into_iter()).unwrap();
1559        assert_eq!(checksum32(&col, 0).unwrap(), digest);
1560    }
1561}
1562
1563#[cfg(test)]
1564mod reserved_module_tests {
1565    use super::*;
1566
1567    /// The regression this guards is specific and it has teeth: a module name
1568    /// that the sink writes with `RESERVED_PKG_TYPE` but that
1569    /// [`is_reserved_module`] does not recognise is classified as a **data**
1570    /// sub-index. `read_multi_index` then merges its rows into the file index and
1571    /// `read_znippy_manifest` reports it as a data section — corrupting `list`,
1572    /// `decompress`, the iceberg sink and every manifest-count assertion.
1573    ///
1574    /// So the assertion is not "the constant exists"; it is "every reserved
1575    /// module name in the catalog is classified reserved, and nothing else is".
1576    #[test]
1577    fn every_reserved_module_is_classified_reserved() {
1578        for m in RESERVED_MODULES {
1579            assert!(
1580                is_reserved_module(m),
1581                "'{m}' is in RESERVED_MODULES but is_reserved_module says it is DATA — \
1582                 its rows would be merged into the file index"
1583            );
1584        }
1585    }
1586
1587    /// The three `git` package-format modules, named literally. If someone
1588    /// removes one from [`RESERVED_MODULES`], the loop above still passes
1589    /// (it iterates whatever is left) — this is the test that goes red.
1590    #[test]
1591    fn the_git_format_modules_are_reserved() {
1592        for m in [
1593            GUNNAR_OID_MODULE,
1594            GUNNAR_GRAPH_MODULE,
1595            GUNNAR_REACH_MODULE,
1596            GUNNAR_REFS_MODULE,
1597            GUNNAR_SECRETS_MODULE,
1598        ] {
1599            assert!(is_reserved_module(m), "git package-format module '{m}' must be reserved");
1600            assert!(
1601                RESERVED_MODULES.contains(&m),
1602                "git package-format module '{m}' must be in the catalog"
1603            );
1604        }
1605        assert_eq!(GUNNAR_OID_MODULE, "__gunnar_oid__");
1606        assert_eq!(GUNNAR_GRAPH_MODULE, "__gunnar_graph__");
1607        assert_eq!(GUNNAR_REACH_MODULE, "__gunnar_reach__");
1608        assert_eq!(GUNNAR_REFS_MODULE, "__gunnar_refs__");
1609        assert_eq!(GUNNAR_SECRETS_MODULE, "__gunnar_secrets__");
1610    }
1611
1612    /// The other direction: a data module must NOT be swept up as reserved, or
1613    /// its file rows would silently vanish from `list` and `decompress`.
1614    #[test]
1615    fn ordinary_module_names_stay_data() {
1616        for m in ["maven", "git", "rust", "", "__gunnar__", "gunnar_oid", "__gunnar_oid", "objects"] {
1617            assert!(!is_reserved_module(m), "'{m}' must be treated as a DATA sub-index");
1618        }
1619    }
1620
1621    /// No duplicates and no accidental empty entry in the catalog.
1622    #[test]
1623    fn the_catalog_is_well_formed() {
1624        let mut seen = std::collections::HashSet::new();
1625        for m in RESERVED_MODULES {
1626            assert!(!m.is_empty(), "empty reserved module name");
1627            assert!(seen.insert(*m), "duplicate reserved module name '{m}'");
1628        }
1629        assert_eq!(seen.len(), RESERVED_MODULES.len());
1630    }
1631}