Skip to main content

znippy_common/
archive.rs

1//! ZnippyArchive — trait and implementation for reading znippy archives.
2//!
3//! Provides selective file extraction by path (serve individual artifacts
4//! on demand from a single .znippy archive).
5
6use std::any::Any;
7use std::collections::HashMap;
8use std::fs::File;
9use std::os::unix::fs::FileExt;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, OnceLock};
12
13use anyhow::{anyhow, Result};
14use arrow::record_batch::RecordBatch;
15use arrow::ipc::reader::StreamReader;
16use arrow_array::{BooleanArray, FixedSizeBinaryArray, StringArray, UInt32Array, UInt64Array};
17
18use crate::codec;
19use crate::index::{read_reserved_section_bytes, read_znippy_index, ZNIPPY_DELTA_MODULE};
20use crate::views::{
21    CondaSpec, CondaView, CoordSpec, CoordView, DebSpec, DebView, GemSpec, GemView, MavenSpec,
22    MavenView, NpmSpec, NpmView, PythonSpec, PythonView, RpmSpec, RpmView, RustSpec, RustView,
23    VIEW_SLOTS,
24};
25
26/// Trait for reading from a znippy archive.
27pub trait ZnippyReader: Send + Sync {
28    fn list_files(&self) -> Result<Vec<String>>;
29    fn extract_file(&self, relative_path: &str) -> Result<Vec<u8>>;
30    fn contains(&self, relative_path: &str) -> bool;
31    fn file_size(&self, relative_path: &str) -> Option<u64>;
32
33    /// Batch extract several files, **on every core**.
34    ///
35    /// The default used to be `paths.iter().map(|p| self.extract_file(p))` — a
36    /// serial loop under the honest comment "calls extract_file sequentially".
37    /// MEASURED on oden (32 cores, 2026-09-20) over a 544 MB / 37 824-file
38    /// vendored-crate archive, page-cache warm, all workers in one process:
39    ///
40    /// ```text
41    ///   workers  1      818 MB/s   cores  1.00   ← what the serial map was
42    ///   workers  2     1615 MB/s   cores  1.96
43    ///   workers  4     2981 MB/s   cores  3.89
44    ///   workers  8     5441 MB/s   cores  7.60
45    ///   workers 32    12724 MB/s   cores 18.00
46    /// ```
47    ///
48    /// One core busy and thirty-one idle is the shape ROOT LAW #0 names by
49    /// hand, and nothing about the work was ever serial: `extract_file` reads
50    /// through positioned `pread` on a shared `Arc<File>` (see
51    /// [`ReconstructCtx`]), takes `&self`, and the trait is already
52    /// `Send + Sync`, so the units share nothing mutable. The only thing
53    /// holding them to one core was the `map`.
54    ///
55    /// [`gatling_for_each_balanced`][gfeb] is the engine `decompress.rs`
56    /// already drives this same archive format with. It is used here rather
57    /// than plain `gatling_for_each` for two properties this call needs:
58    ///
59    /// * **heaviest-first (LPT)**, weighted by [`Self::file_size`] — a vendored
60    ///   crate set is all skew (one 8 MB `.rlib` among ten thousand 2 kB `.rs`
61    ///   files), and unweighted, one worker ends up holding the monster while
62    ///   its siblings have drained the small end and idle;
63    /// * **results in the original `0..n` index order**, so this is
64    ///   byte-for-byte what the serial `map` returned — same values, same
65    ///   errors, same positions. `extract_files_is_the_serial_map` asserts it
66    ///   over a deliberately skewed fixture with an absent path in the middle.
67    ///
68    /// `batch = 8`: a claim is one atomic `fetch_add`, and the cheap tail of a
69    /// crate set is thousands of 2 kB files where a per-unit hand-off costs
70    /// more than the read. Eight amortizes it without coarsening the schedule
71    /// for the heavy head, which is claimed first anyway.
72    ///
73    /// # What this did NOT fix, and the number that proves it
74    ///
75    /// The ceiling above ~8 workers is **the allocator, not the engine**:
76    /// `extract_file` returns an owned `Vec<u8>` per file, so a 37 824-file
77    /// batch is 37 824 fresh allocations, most of them over glibc's 128 kB
78    /// `mmap` threshold — one `mmap`/`munmap` pair each, and `munmap` costs a
79    /// cross-core TLB shootdown that gets worse with every worker added.
80    /// Measured the same run with `MALLOC_MMAP_THRESHOLD_=1G`: 16 workers went
81    /// 4491 → 6963 MB/s and 64 workers 1587 → 4842 MB/s, purely from not
82    /// returning the pages. That is this crate's remaining "zero allocation —
83    /// preallocate and reuse" debt and it cannot be paid here: it needs an
84    /// `extract_file_into(&self, path, &mut Vec<u8>)` sibling so a caller can
85    /// hand the same buffer back, which is a new method on a published trait
86    /// and therefore the owner's call, not a cleanup.
87    ///
88    /// An override is still free to do better — a storage model that can
89    /// coalesce adjacent blobs into one `pread` should.
90    ///
91    /// [gfeb]: znippy_zoomies::gatling_forkjoin::gatling_for_each_balanced
92    fn extract_files(&self, paths: &[&str]) -> Vec<Result<Vec<u8>>> {
93        znippy_zoomies::gatling_forkjoin::gatling_for_each_balanced(
94            paths.len(),
95            /* n_workers: 0 = one per core */ 0,
96            /* batch */ 8,
97            // The weight is the file's declared uncompressed size. A path the
98            // index does not hold weighs 0: it is about to be a cheap `Err`,
99            // and scheduling it last is correct.
100            |i| self.file_size(paths[i]).unwrap_or(0),
101            |i| self.extract_file(paths[i]),
102        )
103    }
104}
105
106/// What an entry reconstructs *from*: the shared archive fd, the real file
107/// length every bounds check is made against, and — for storage models whose
108/// entries reference other entries — a way to resolve one.
109///
110/// Passed by reference into [`Entry::reconstruct`] so that the storage model
111/// owns the reconstruction and [`ZnippyArchive`] owns only the file.
112pub struct ReconstructCtx<'a> {
113    archive: &'a Arc<File>,
114    /// Real byte length of the archive, cached at open. The index is untrusted,
115    /// so every `blob_offset + blob_size` is checked against this BEFORE any
116    /// allocation.
117    archive_len: u64,
118    /// Resolve another entry's bytes by path. `None` means "no resolver
119    /// available", which is what a chunked entry is given because it never
120    /// needs one. A delta chunk resolves its base through this.
121    resolve: Option<&'a dyn BaseResolve>,
122    /// How many base resolutions deep this reconstruction already is.
123    ///
124    /// **The reader's own bound, and it is not the writer's.**
125    /// [`MAX_DELTA_CHAIN`] is a policy a *writer* applies to its own output; it
126    /// binds nothing about an index that arrives from elsewhere. A hostile or
127    /// corrupt index can name a base that names it back, and without this the
128    /// read recurses until the stack ends — a crash, not an `Err`. The per-entry
129    /// delta model had exactly this hole; it is closed here because the
130    /// recursion is now in one place.
131    depth: usize,
132}
133
134impl<'a> ReconstructCtx<'a> {
135    /// The archive fd, read via positioned I/O so reconstruction is safe to run
136    /// concurrently from many threads.
137    pub fn archive(&self) -> &Arc<File> {
138        self.archive
139    }
140
141    /// Real byte length of the archive file.
142    pub fn archive_len(&self) -> u64 {
143        self.archive_len
144    }
145
146    /// Resolve another entry's bytes, for a chunk that references one. `Err`
147    /// when no resolver was supplied, and `Err` — never a stack overflow — when
148    /// the chain is longer than [`MAX_RECONSTRUCT_DEPTH`].
149    pub fn resolve_base(&self, path: &str, verify: bool) -> Result<Vec<u8>> {
150        if self.depth >= MAX_RECONSTRUCT_DEPTH {
151            return Err(anyhow!(
152                "base chain for {} is deeper than {} — refusing to recurse further \
153                 (a cyclic or over-long index must be an error, not a crash)",
154                path,
155                MAX_RECONSTRUCT_DEPTH
156            ));
157        }
158        self.resolve
159            .ok_or_else(|| anyhow!("entry needs a base but this archive supplied no resolver"))?
160            .resolve(path, verify, self.depth + 1)
161    }
162}
163
164/// The reader's hard bound on how many base resolutions one `extract_file` may
165/// perform, whatever the index claims.
166///
167/// **64**, deliberately far above the writer's [`MAX_DELTA_CHAIN`] of 8: this is
168/// not a policy about what is worth storing, it is the line between an `Err` and
169/// a stack overflow on an index this process did not write. A legitimate archive
170/// never comes near it; a cycle hits it on the 64th link.
171pub const MAX_RECONSTRUCT_DEPTH: usize = 64;
172
173/// Resolve one entry's fully-reconstructed bytes by path.
174///
175/// Implemented by [`ZnippyArchive`]. Kept a separate trait rather than a method
176/// on the archive so that a storage model depends on the *capability* and not on
177/// the concrete archive type.
178pub trait BaseResolve: Send + Sync {
179    /// `depth` is how many resolutions deep the caller already is. It is passed
180    /// rather than tracked by the resolver because a resolver is shared across
181    /// concurrent reads and a counter on it would be a shared mutable — the
182    /// depth belongs to one reconstruction, not to the archive.
183    fn resolve(&self, path: &str, verify: bool, depth: usize) -> Result<Vec<u8>>;
184}
185
186/// **Where one chunk's bytes come from.**
187///
188/// A chunk has always meant "bytes at this offset in the blob region". That is
189/// [`ChunkSource::Stored`].
190///
191/// [`ChunkSource::Delta`] means "bytes from applying this diff to that entry".
192/// The recursion lives in the CHUNK rather than in the entry, so reconstruction
193/// stays one loop and a single file can be **part chunk and part delta**. A
194/// per-entry seam cannot express that; see the note on [`Entry`].
195enum ChunkSource {
196    /// The stored blob **is** the chunk's bytes (after decompression, if
197    /// `compressed`). Every znippy archive written before this existed holds
198    /// only these.
199    Stored,
200    /// The stored blob is a delta instruction stream (the format
201    /// [`apply_delta`] reads). The chunk's bytes are the result of applying it
202    /// to `base_path`'s fully reconstructed bytes, resolved through
203    /// [`BaseResolve`].
204    ///
205    /// The base is named by path, so it may itself be chunked, delta'd, or a
206    /// mixture; this chunk never inspects it.
207    Delta { base_path: String },
208}
209
210struct ChunkInfo {
211    blob_offset: u64,
212    blob_size: u64,
213    fdata_offset: u64,
214    compressed: bool,
215    /// **blake3 over the bytes this chunk CONTRIBUTES to the file** (Design
216    /// law 3). All-zero if the index predates the checksum column (older
217    /// archives), in which case verification is skipped.
218    ///
219    /// For [`ChunkSource::Stored`] those are the stored bytes after
220    /// decompression, which is exactly what this column has always meant — the
221    /// constant `bc191568…` pins that it is unchanged.
222    ///
223    /// For [`ChunkSource::Delta`] those are the bytes the delta PRODUCES, not
224    /// the instruction stream that produces them. That distinction is the whole
225    /// integrity argument, and it is the same one the per-entry delta model made
226    /// with a separate `result_checksum`: a chain of individually-correct deltas
227    /// can still reconstruct wrong bytes, because the result also depends on the
228    /// base and **no delta row says what the base should have contained**.
229    /// Hashing the output closes that, and doing it per chunk rather than per
230    /// entry means a mixed entry is covered too, which `result_checksum` could
231    /// not be.
232    ///
233    /// The cost is that a delta chunk's checksum must be checked on the **fast**
234    /// read as well — see [`Entry::reconstruct`] — where a stored chunk's is
235    /// checked only under `verify`. That asymmetry is deliberate: it buys the
236    /// hot artifact-serving path nothing to re-hash bytes that came straight off
237    /// disk, and it is the only thing standing between a delta chunk and silent
238    /// corruption.
239    checksum: [u8; 32],
240    /// The index row's `chunk_seq`. Carried only so the delta map
241    /// ([`ZNIPPY_DELTA_MODULE`]) can name one chunk of one entry — nothing in
242    /// reconstruction uses it, which places the chunks by `fdata_offset`.
243    chunk_seq: u32,
244    /// Which kind of stored unit this is. See [`ChunkSource`].
245    source: ChunkSource,
246}
247
248/// **One archive entry: a tiling of chunks, each placed at its own
249/// `fdata_offset`.**
250///
251/// # Why this is a struct and not a trait
252///
253/// It *was* a trait — `EntryReader`, with two implementations that differed in
254/// how they COMBINE: chunks concatenate, deltas apply. That difference is what
255/// forced two of them, and it put the seam on the read side.
256///
257/// The seam belongs on the **write** side instead. A splitter decides how to cut
258/// a file into stored units (see [`Splitter`]); a unit it may emit is a reference
259/// to another entry. Reconstruction then stays exactly one loop — the loop below,
260/// which is the pre-trait `extract_inner` code — and the recursion lives in
261/// [`ChunkSource`].
262///
263/// What that buys, and it is the reason for the swap: **a single file can be part
264/// chunk and part delta.** A large file with one changed region stores the changed
265/// region as a delta and the rest as ordinary chunks. `EntryReader` was
266/// all-or-nothing per entry and could not say that.
267///
268/// What it leaves alone: the read path, which is the half that is measured and
269/// proven. `chunked_reconstruction_is_byte_identical_to_the_pre_trait_implementation`
270/// pins that with one blake3 over a deterministic corpus, and that constant has
271/// now survived both the trait's arrival and its removal.
272pub struct Entry {
273    uncompressed_size: u64,
274    chunks: Vec<ChunkInfo>,
275}
276
277impl Entry {
278    /// Size of the reconstructed entry in bytes, as the index declares it.
279    /// Untrusted: never size an allocation from it.
280    pub fn uncompressed_size(&self) -> u64 {
281        self.uncompressed_size
282    }
283
284    /// Reconstruct the entry's bytes. `path` is carried for error messages only.
285    /// With `verify`, each chunk's bytes are blake3-checked against what the
286    /// index recorded for it (Design law 3).
287    pub fn reconstruct(
288        &self,
289        path: &str,
290        ctx: &ReconstructCtx<'_>,
291        verify: bool,
292    ) -> Result<Vec<u8>> {
293        // Grown from verified bytes, never pre-sized from `uncompressed_size`:
294        // that value comes verbatim from the untrusted index, so a
295        // `Vec::with_capacity` on it is a malformed-index-driven allocation that
296        // aborts the process rather than returning an `Err` (DoS).
297        let mut result: Vec<u8> = Vec::new();
298        let mut blob = Vec::new(); // reused across chunks
299        let mut decomp = Vec::new(); // reused across compressed chunks
300        // Bases resolved so far in THIS entry, keyed by path.
301        //
302        // This is the one cost delta-as-a-chunk introduces that the per-entry
303        // model did not have: a mixed entry with K delta chunks against the same
304        // base would resolve that base K times, where the per-entry model
305        // resolved it once. One local map removes it, and it is local on purpose
306        // — a cache living on the archive would be shared mutable state across
307        // concurrent reads, which is what `extract_file`'s thread-safety rests on
308        // not having.
309        //
310        // It does NOT flatten a CHAIN: A→B→C still resolves B, which resolves C.
311        // That recursion is inherent and costs what the per-entry model cost.
312        let mut bases: HashMap<String, Vec<u8>> = HashMap::new();
313
314        for chunk in &self.chunks {
315            // Bounds-check the untrusted (index-declared) blob extent against the
316            // real file length BEFORE allocating — a malformed index must not be
317            // able to drive a multi-GB zero-fill (DoS). Matches get_file.
318            if chunk.blob_size > 0 {
319                let in_bounds = chunk
320                    .blob_offset
321                    .checked_add(chunk.blob_size)
322                    .is_some_and(|end| end <= ctx.archive_len());
323                if !in_bounds {
324                    return Err(anyhow!(
325                        "blob for {} out of bounds (offset={}, size={}, archive_len={})",
326                        path,
327                        chunk.blob_offset,
328                        chunk.blob_size,
329                        ctx.archive_len()
330                    ));
331                }
332            }
333            blob.resize(chunk.blob_size as usize, 0);
334            // Positioned read — no shared seek, safe under concurrent calls.
335            ctx.archive().read_exact_at(&mut blob, chunk.blob_offset)?;
336
337            // What the stored blob MEANS is the chunk's business, not the
338            // entry's. The loop around this match is the pre-trait
339            // reconstruction code and does not know a delta exists.
340            //
341            // The uncompressed stored bytes first — the instruction stream for a
342            // delta chunk, the payload itself for a stored one.
343            let raw: &[u8] = if chunk.compressed {
344                codec::decompress_into(&blob, &mut decomp)?;
345                &decomp
346            } else {
347                &blob
348            };
349
350            // Then what those bytes MEAN. `applied` only exists on the delta arm
351            // so a stored chunk still copies nothing extra.
352            let applied: Vec<u8>;
353            let (bytes, must_check): (&[u8], bool) = match &chunk.source {
354                ChunkSource::Stored => (raw, false),
355                ChunkSource::Delta { base_path } => {
356                    // Resolved through the same dispatch, so the base may be
357                    // chunked, delta'd or mixed. `verify` propagates: a verified
358                    // read of a delta chunk is only meaningful if its base was
359                    // verified too.
360                    if !bases.contains_key(base_path) {
361                        let b = ctx.resolve_base(base_path, verify)?;
362                        bases.insert(base_path.clone(), b);
363                    }
364                    let base = &bases[base_path];
365                    applied = apply_delta(base, raw)?;
366                    // TRUE, not `verify`. See `ChunkInfo::checksum`: this is the
367                    // only thing that can tell a correct delta applied to the
368                    // WRONG base from a correct one, and the per-entry model
369                    // checked its equivalent on the fast read for the same
370                    // reason.
371                    (&applied, true)
372                }
373            };
374
375            if (verify || must_check) && chunk.checksum != [0u8; 32] {
376                let computed = blake3::hash(bytes);
377                if computed.as_bytes()[..] != chunk.checksum[..] {
378                    // One hash over the OUTPUT catches strictly more than a hash
379                    // over a delta's instruction stream would: a corrupt stream
380                    // and a wrong base both land here. What it cannot do is say
381                    // WHICH, so the delta arm names the base it used — that is
382                    // the diagnostic the per-row hash used to give.
383                    return match &chunk.source {
384                        ChunkSource::Stored => Err(anyhow!(
385                            "checksum mismatch for {} at fdata_offset {}",
386                            path,
387                            chunk.fdata_offset
388                        )),
389                        ChunkSource::Delta { base_path } => Err(anyhow!(
390                            "delta chunk of {} at fdata_offset {} produced bytes that do not \
391                             match its result checksum (base {})",
392                            path,
393                            chunk.fdata_offset,
394                            base_path
395                        )),
396                    };
397                }
398            }
399
400            // Place each chunk at its declared `fdata_offset` (as `reassemble_file`
401            // does) instead of blindly concatenating. Concatenation meant that two
402            // index rows for the same path — both at fdata_offset 0 — produced a
403            // buffer of twice the real length holding both copies back to back,
404            // with no error and even `extract_file_verified` passing, since each
405            // chunk's blake3 is individually correct. `start <= result.len()` also
406            // caps the buffer at real verified bytes, so an invented offset cannot
407            // drive the allocation.
408            let start = chunk.fdata_offset as usize;
409            if start > result.len() {
410                return Err(anyhow!(
411                    "chunk of {} leaves a gap at fdata_offset {} (file reaches {})",
412                    path,
413                    start,
414                    result.len()
415                ));
416            }
417            let end = start + bytes.len();
418            if end > result.len() {
419                result.resize(end, 0);
420            }
421            result[start..end].copy_from_slice(bytes);
422        }
423
424        Ok(result)
425    }
426}
427
428/// Read a git-style delta varint (7 bits per byte, little-endian, high bit
429/// continues). Returns the value and how many bytes it consumed.
430fn delta_varint(buf: &[u8], at: &mut usize) -> Result<u64> {
431    let mut value: u64 = 0;
432    let mut shift = 0u32;
433    loop {
434        let byte = *buf
435            .get(*at)
436            .ok_or_else(|| anyhow!("delta header truncated at byte {}", at))?;
437        *at += 1;
438        if shift >= 64 {
439            return Err(anyhow!("delta size varint overflows u64"));
440        }
441        value |= u64::from(byte & 0x7f) << shift;
442        shift += 7;
443        if byte & 0x80 == 0 {
444            return Ok(value);
445        }
446    }
447}
448
449/// Apply one delta instruction stream to `base`.
450///
451/// The stream is git's delta encoding, chosen because it is fully specified,
452/// compact, and the corpus that motivated this is git objects — but nothing here
453/// is git-aware: it is a byte-level copy/insert format over an opaque base.
454///
455/// Layout: base-size varint, result-size varint, then instructions.
456/// * high bit set  → COPY, the low 7 bits select which of 4 offset and 3 size
457///   bytes follow; a zero size means 0x10000.
458/// * high bit clear, non-zero → INSERT that many literal bytes.
459/// * `0x00` is not a valid instruction and is rejected rather than skipped.
460///
461/// Every offset and length is bounds-checked against the real base and the
462/// declared result size before use, so a corrupt or hostile delta yields `Err`
463/// and never an over-large allocation or an out-of-range read.
464///
465/// `pub` because it is the decoder half of the format whose encoder
466/// ([`encode_delta_against`]) is already public. A public encoder with a private
467/// decoder is an asymmetry, not an encapsulation.
468pub fn apply_delta(base: &[u8], delta: &[u8]) -> Result<Vec<u8>> {
469    let mut at = 0usize;
470    let declared_base = delta_varint(delta, &mut at)?;
471    if declared_base != base.len() as u64 {
472        return Err(anyhow!(
473            "delta expects a base of {} bytes, resolved base is {}",
474            declared_base,
475            base.len()
476        ));
477    }
478    let result_size = delta_varint(delta, &mut at)?;
479    // Cap the declared result against what the instructions could possibly
480    // produce, so `result_size` alone can never drive an allocation.
481    let mut out: Vec<u8> = Vec::new();
482
483    while at < delta.len() {
484        let op = delta[at];
485        at += 1;
486        if op & 0x80 != 0 {
487            let mut copy_off: u64 = 0;
488            let mut copy_len: u64 = 0;
489            for i in 0..4 {
490                if op & (1 << i) != 0 {
491                    let b = *delta
492                        .get(at)
493                        .ok_or_else(|| anyhow!("delta copy offset truncated"))?;
494                    at += 1;
495                    copy_off |= u64::from(b) << (8 * i);
496                }
497            }
498            for i in 0..3 {
499                if op & (0x10 << i) != 0 {
500                    let b = *delta
501                        .get(at)
502                        .ok_or_else(|| anyhow!("delta copy size truncated"))?;
503                    at += 1;
504                    copy_len |= u64::from(b) << (8 * i);
505                }
506            }
507            if copy_len == 0 {
508                copy_len = 0x1_0000;
509            }
510            let end = copy_off
511                .checked_add(copy_len)
512                .ok_or_else(|| anyhow!("delta copy range overflows"))?;
513            if end > base.len() as u64 {
514                return Err(anyhow!(
515                    "delta copies [{}, {}) from a base of {} bytes",
516                    copy_off,
517                    end,
518                    base.len()
519                ));
520            }
521            out.extend_from_slice(&base[copy_off as usize..end as usize]);
522        } else if op != 0 {
523            let n = op as usize;
524            let end = at
525                .checked_add(n)
526                .ok_or_else(|| anyhow!("delta insert range overflows"))?;
527            if end > delta.len() {
528                return Err(anyhow!("delta insert of {} bytes runs past the stream", n));
529            }
530            out.extend_from_slice(&delta[at..end]);
531            at = end;
532        } else {
533            // 0x00 is unassigned. Skipping it silently is how a corrupt stream
534            // turns into plausible-looking output.
535            return Err(anyhow!("delta contains a 0x00 instruction"));
536        }
537        if out.len() as u64 > result_size {
538            return Err(anyhow!(
539                "delta produced {} bytes, more than the declared {}",
540                out.len(),
541                result_size
542            ));
543        }
544    }
545
546    if out.len() as u64 != result_size {
547        return Err(anyhow!(
548            "delta produced {} bytes, declared {}",
549            out.len(),
550            result_size
551        ));
552    }
553    Ok(out)
554}
555
556
557/// Maximum number of deltas in one chain before the writer refuses and stores
558/// the version chunked instead.
559///
560/// **8.** This started at git's `pack.depth` of 50 and was tightened on review.
561/// The two cases are not alike and the difference is the read path, not the
562/// format: git resolves a delta chain inside one mmap'd packfile with its own
563/// delta-base cache, whereas a znippy chain costs **one full entry
564/// reconstruction per link** — a `pread` plus a codec frame decode each — and
565/// reconstruction recurses through [`BaseResolve`] with no cache between links.
566/// At depth 50 a single `extract_file` is up to 50 decompressions.
567///
568/// 8 bounds that at a cost the archival win absorbs easily. **I have no measured
569/// evidence that a deeper cap is safe for this read path, so the tighter number
570/// is taken rather than argued against.** Raising it needs a reconstruction-
571/// latency measurement against chain depth, which does not exist yet.
572pub const MAX_DELTA_CHAIN: usize = 8;
573
574/// A delta must be smaller than this fraction of the base it is against, or the
575/// version is stored chunked.
576///
577/// 0.7. A delta that saves less than 30% is not worth a chain link: it still
578/// costs a whole extra reconstruction on every read of every later version, and
579/// that cost is paid forever while the saving is paid once.
580pub const DELTA_SIZE_ALPHA: f64 = 0.7;
581
582/// Minimum window a copy must span to be worth emitting. Below this the copy
583/// header costs more than the literal bytes it replaces.
584const MIN_COPY: usize = 16;
585
586/// **The encoder half of the delta format [`apply_delta`] reads.**
587///
588/// # Why this lives here and is not `gunnar-delta`
589///
590/// LAW 5 says reuse, do not twin, and both alternatives were checked before
591/// writing this rather than after:
592///
593/// * **zstd `--patch-from` is not reachable.** znippy's codec exposes only
594///   whole-buffer `compress`/`decompress`, and `openzl-sys-rs` (0.3.0, and still 0.4.0) —
595///   the published crate that vendors the C sources — binds **no** `ZSTD_*`
596///   symbol at all and no dictionary, prefix or patch-from entry point
597///   (measured: `grep -c ZSTD_ src/bindings.rs` = 0). Taking that route means
598///   adding the `zstd` crate, i.e. a second C dependency, which is the cost
599///   `cold-tier-decision.md` spent a document avoiding.
600/// * **`gunnar-delta` cannot be depended on from here.** It is unpublished and
601///   lives in gunnar's tree; znippy depending on gunnar inverts the direction
602///   the whole layering runs in.
603///
604/// So this is a second implementation of **the same wire format**, not a third
605/// format. That is stated plainly rather than hidden: `gunnar-delta` remains the
606/// tuned encoder on gunnar's side, this is the minimal one on znippy's, and
607/// because the bytes are identical either side can read the other's output. The
608/// round-trip test asserts exactly that inverse property against
609/// [`apply_delta`], which is the decoder already in this file.
610///
611/// The match finder is a 16-byte-anchored hash index over the base — enough to
612/// catch the "same file, edited" case historization is for, and deliberately not
613/// a competitor to a tuned window search.
614pub fn encode_delta_against(base: &[u8], target: &[u8]) -> Vec<u8> {
615    let mut out = Vec::new();
616    put_size_varint(&mut out, base.len() as u64);
617    put_size_varint(&mut out, target.len() as u64);
618
619    // Hash every MIN_COPY-aligned anchor in the base.
620    let mut index: HashMap<u64, Vec<usize>> = HashMap::new();
621    if base.len() >= MIN_COPY {
622        let mut i = 0usize;
623        while i + MIN_COPY <= base.len() {
624            index.entry(hash16(&base[i..i + MIN_COPY])).or_default().push(i);
625            i += MIN_COPY;
626        }
627    }
628
629    let mut literal_start = 0usize;
630    let mut at = 0usize;
631    while at < target.len() {
632        let mut best = (0usize, 0usize); // (base_off, len)
633        if at + MIN_COPY <= target.len() {
634            if let Some(cands) = index.get(&hash16(&target[at..at + MIN_COPY])) {
635                // Bounded candidate scan: historization sees few collisions and
636                // an unbounded scan is how an encoder becomes quadratic.
637                for &bo in cands.iter().take(8) {
638                    if base.len() - bo < MIN_COPY || &base[bo..bo + MIN_COPY] != &target[at..at + MIN_COPY] {
639                        continue;
640                    }
641                    let mut n = MIN_COPY;
642                    while bo + n < base.len() && at + n < target.len() && base[bo + n] == target[at + n] {
643                        n += 1;
644                    }
645                    if n > best.1 {
646                        best = (bo, n);
647                    }
648                }
649            }
650        }
651        if best.1 >= MIN_COPY {
652            flush_literal(&mut out, &target[literal_start..at]);
653            emit_copy(&mut out, best.0 as u64, best.1 as u64);
654            at += best.1;
655            literal_start = at;
656        } else {
657            at += 1;
658        }
659    }
660    flush_literal(&mut out, &target[literal_start..]);
661    out
662}
663
664fn hash16(b: &[u8]) -> u64 {
665    // FNV-1a over the anchor. Cheap and good enough to bucket candidates.
666    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
667    for &x in b {
668        h ^= x as u64;
669        h = h.wrapping_mul(0x1000_0000_01b3);
670    }
671    h
672}
673
674fn put_size_varint(out: &mut Vec<u8>, mut v: u64) {
675    loop {
676        let mut b = (v & 0x7f) as u8;
677        v >>= 7;
678        if v != 0 {
679            b |= 0x80;
680        }
681        out.push(b);
682        if v == 0 {
683            return;
684        }
685    }
686}
687
688/// INSERT runs are capped at 0x7f, the largest an insert opcode can name.
689fn flush_literal(out: &mut Vec<u8>, lit: &[u8]) {
690    for piece in lit.chunks(0x7f) {
691        out.push(piece.len() as u8);
692        out.extend_from_slice(piece);
693    }
694}
695
696/// Emit COPY ops, splitting at 0xffffff which is the largest size the three
697/// size bytes can name.
698fn emit_copy(out: &mut Vec<u8>, mut off: u64, mut len: u64) {
699    while len > 0 {
700        let take = len.min(0xff_ffff);
701        let mut op: u8 = 0x80;
702        let mut tail = Vec::new();
703        for i in 0..4 {
704            let b = ((off >> (8 * i)) & 0xff) as u8;
705            if b != 0 {
706                op |= 1 << i;
707                tail.push(b);
708            }
709        }
710        // Sizes are emitted in ascending byte order, matching the decoder.
711        let mut size_bytes = Vec::new();
712        for i in 0..3 {
713            let b = ((take >> (8 * i)) & 0xff) as u8;
714            if b != 0 {
715                op |= 0x10 << i;
716                size_bytes.push(b);
717            }
718        }
719        out.push(op);
720        out.extend_from_slice(&tail);
721        out.extend_from_slice(&size_bytes);
722        off += take;
723        len -= take;
724    }
725}
726
727/// What the writer decided to do with one version of a path.
728///
729/// The decision is made on **real bytes** — the delta is encoded and its
730/// compressed size compared against the compressed chunked form — never on a
731/// guess about how similar two versions look. A reader is therefore never
732/// surprised: whichever branch wins is what the index records.
733#[derive(Debug, PartialEq, Eq)]
734pub enum VersionPlan {
735    /// Store as a delta against `base_path`, `chain_len` links deep.
736    Delta { base_path: String, chain_len: usize, delta_bytes: usize },
737    /// Store the whole version as chunks. Carries why, so the choice is
738    /// auditable rather than silent.
739    Chunked(ChunkedReason),
740}
741
742/// Why a version was not delta'd.
743#[derive(Debug, PartialEq, Eq)]
744pub enum ChunkedReason {
745    /// No previous version of this path in the archive — this IS the first row.
746    FirstVersion,
747    /// The chain would exceed [`MAX_DELTA_CHAIN`].
748    ChainTooLong,
749    /// The delta was not smaller than storing the bytes outright, or did not
750    /// clear [`DELTA_SIZE_ALPHA`] against the base.
751    DeltaNotSmaller,
752}
753
754/// Decide how to store `target` for `path`, given the previous version's bytes
755/// and how deep its chain already is.
756///
757/// `compressed_len` is how the caller measures a candidate payload — the real
758/// codec, so the comparison is between what would actually be written in each
759/// branch and not between raw sizes.
760pub fn plan_version(
761    path: &str,
762    previous: Option<(&str, &[u8], usize)>,
763    target: &[u8],
764    mut compressed_len: impl FnMut(&[u8]) -> usize,
765) -> (VersionPlan, Option<Vec<u8>>) {
766    let (base_path, base_bytes, base_chain) = match previous {
767        None => return (VersionPlan::Chunked(ChunkedReason::FirstVersion), None),
768        Some(p) => p,
769    };
770    let _ = path;
771    if base_chain + 1 > MAX_DELTA_CHAIN {
772        return (VersionPlan::Chunked(ChunkedReason::ChainTooLong), None);
773    }
774    let delta = encode_delta_against(base_bytes, target);
775    // Two independent cutoffs, both on real bytes.
776    //
777    // 1. Against the BASE, the alpha rule: a delta that does not save at least
778    //    (1 - alpha) of the base is not worth the permanent read cost of an
779    //    extra chain link.
780    // 2. Against the TARGET, measured through the codec: a delta that compresses
781    //    worse than simply storing the version is never a win, however well it
782    //    scores on (1). Raw size alone would miss this.
783    if (delta.len() as f64) >= DELTA_SIZE_ALPHA * (base_bytes.len() as f64) {
784        return (VersionPlan::Chunked(ChunkedReason::DeltaNotSmaller), None);
785    }
786    if compressed_len(&delta) >= compressed_len(target) {
787        return (VersionPlan::Chunked(ChunkedReason::DeltaNotSmaller), None);
788    }
789    let n = delta.len();
790    (
791        VersionPlan::Delta { base_path: base_path.to_string(), chain_len: base_chain + 1, delta_bytes: n },
792        Some(delta),
793    )
794}
795
796// ───────────────────────────────────────────────────────────────────────────
797// The seam, on the write side
798// ───────────────────────────────────────────────────────────────────────────
799
800/// **One stored unit: what a splitter emits, and what becomes one index row.**
801///
802/// `fdata_offset` is where this unit's bytes belong inside the reconstructed
803/// file, which is what makes a MIXED entry expressible: the units of one entry
804/// need not all be of the same kind, they only need to tile the file.
805pub struct StoredUnit {
806    /// Offset of this unit's bytes within the reconstructed file.
807    pub fdata_offset: u64,
808    pub payload: UnitPayload,
809}
810
811/// What a stored unit holds. The two arms correspond exactly to the two
812/// [`ChunkSource`] arms the reader knows about — this is the write side of the
813/// same seam, which is the whole point of moving it here.
814pub enum UnitPayload {
815    /// Store these bytes as an ordinary chunk.
816    Bytes(Vec<u8>),
817    /// Store this diff; the reader applies it to `base_path`'s bytes.
818    ///
819    /// `expected` is the bytes the diff must produce. It is not stored as such —
820    /// its blake3 is, as the unit's checksum — and that is the property that
821    /// pins a delta chain's answer (see [`ChunkSource`]).
822    Delta {
823        base_path: String,
824        delta: Vec<u8>,
825        expected: Vec<u8>,
826    },
827}
828
829/// **How a file is cut into stored units.**
830///
831/// This is the seam. It was on the read side (`EntryReader`, one implementation
832/// per storage model, dispatched per entry) and it is here now, because the
833/// difference between the models is a *writing* decision: which units to emit.
834/// The read side then has one loop and no trait at all.
835///
836/// A splitter may emit ordinary byte units, delta units, or **both for the same
837/// file** — which is the capability the per-entry seam could not express and the
838/// reason for the swap.
839pub trait Splitter: Send + Sync {
840    /// Cut `bytes` into the units to store for `path`.
841    ///
842    /// `previous` is the last stored version of this path, if any: its path, its
843    /// reconstructed bytes, and how deep its chain already is. A splitter that
844    /// never emits deltas ignores it.
845    fn split(
846        &self,
847        path: &str,
848        bytes: &[u8],
849        previous: Option<(&str, &[u8], usize)>,
850        compressed_len: &mut dyn FnMut(&[u8]) -> usize,
851    ) -> Vec<StoredUnit>;
852}
853
854/// The splitter every znippy archive written to date was produced by: a fixed
855/// tiling of `chunk_size` byte units, no deltas.
856///
857/// Kept as a named type rather than left implicit so that "what znippy does
858/// today" is a value one can pass, compare against and test.
859pub struct ChunkSplitter {
860    pub chunk_size: usize,
861}
862
863impl Splitter for ChunkSplitter {
864    fn split(
865        &self,
866        _path: &str,
867        bytes: &[u8],
868        _previous: Option<(&str, &[u8], usize)>,
869        _compressed_len: &mut dyn FnMut(&[u8]) -> usize,
870    ) -> Vec<StoredUnit> {
871        if bytes.is_empty() {
872            return vec![StoredUnit { fdata_offset: 0, payload: UnitPayload::Bytes(Vec::new()) }];
873        }
874        bytes
875            .chunks(self.chunk_size.max(1))
876            .enumerate()
877            .map(|(i, piece)| StoredUnit {
878                fdata_offset: (i * self.chunk_size.max(1)) as u64,
879                payload: UnitPayload::Bytes(piece.to_vec()),
880            })
881            .collect()
882    }
883}
884
885/// The whole-entry delta splitter: exactly the decision [`plan_version`] makes,
886/// expressed as units.
887///
888/// Emits either **one** delta unit covering the whole file, or the fallback
889/// splitter's byte units. This is what the per-entry `EntryReader` model could
890/// express, and it is here to show that the swap loses nothing: the same
891/// archives are still writable.
892pub struct DeltaSplitter {
893    pub fallback: ChunkSplitter,
894}
895
896impl Splitter for DeltaSplitter {
897    fn split(
898        &self,
899        path: &str,
900        bytes: &[u8],
901        previous: Option<(&str, &[u8], usize)>,
902        compressed_len: &mut dyn FnMut(&[u8]) -> usize,
903    ) -> Vec<StoredUnit> {
904        let (plan, delta) = plan_version(path, previous, bytes, |b| compressed_len(b));
905        match (plan, delta) {
906            (VersionPlan::Delta { base_path, .. }, Some(delta)) => vec![StoredUnit {
907                fdata_offset: 0,
908                payload: UnitPayload::Delta { base_path, delta, expected: bytes.to_vec() },
909            }],
910            _ => self.fallback.split(path, bytes, previous, compressed_len),
911        }
912    }
913}
914
915/// **The splitter the per-entry seam could not have: one file, part chunk and
916/// part delta.**
917///
918/// Both the base and the target are tiled at `chunk_size`. For each tile:
919///
920/// * bytes identical to the base's tile at the same offset → a **delta unit**,
921///   which for an unchanged region is a single COPY instruction of a few bytes
922///   whatever the tile's size;
923/// * anything else → an ordinary **byte unit**.
924///
925/// So a large file with one edited region stores the edited region in full and
926/// everything else as ~6 bytes per tile, and `EntryReader` had no way to say
927/// that: its choice was per entry, so the whole file went one way or the other.
928///
929/// # What it costs, said plainly
930///
931/// Every delta unit names the same base, and reconstruction resolves that base
932/// **once** — see the memo in [`Entry::reconstruct`]. Without that memo this
933/// shape would be quadratic in the number of unchanged tiles, which is the one
934/// new cost moving the seam introduces and the reason the memo is not optional.
935///
936/// A tile that differs is stored whole rather than delta'd against its
937/// counterpart. That is the conservative choice: a per-tile delta would need its
938/// own base-size bookkeeping for no saving on the case this exists for.
939pub struct RegionDeltaSplitter {
940    pub chunk_size: usize,
941}
942
943impl Splitter for RegionDeltaSplitter {
944    fn split(
945        &self,
946        path: &str,
947        bytes: &[u8],
948        previous: Option<(&str, &[u8], usize)>,
949        compressed_len: &mut dyn FnMut(&[u8]) -> usize,
950    ) -> Vec<StoredUnit> {
951        let size = self.chunk_size.max(1);
952        let fallback = ChunkSplitter { chunk_size: size };
953        let (base_path, base_bytes, base_chain) = match previous {
954            None => return fallback.split(path, bytes, previous, compressed_len),
955            Some(p) => p,
956        };
957        // The writer's own chain policy still applies: a delta unit adds a link
958        // exactly as a whole-entry delta did.
959        if base_chain + 1 > MAX_DELTA_CHAIN {
960            return fallback.split(path, bytes, previous, compressed_len);
961        }
962        let mut units = Vec::new();
963        let mut any_delta = false;
964        let mut off = 0usize;
965        while off < bytes.len() {
966            let end = (off + size).min(bytes.len());
967            let tile = &bytes[off..end];
968            let base_tile = base_bytes.get(off..end);
969            if base_tile == Some(tile) && !tile.is_empty() {
970                // Unchanged: one COPY of `tile.len()` from the base at `off`.
971                let mut delta = Vec::new();
972                put_size_varint(&mut delta, base_bytes.len() as u64);
973                put_size_varint(&mut delta, tile.len() as u64);
974                emit_copy(&mut delta, off as u64, tile.len() as u64);
975                any_delta = true;
976                units.push(StoredUnit {
977                    fdata_offset: off as u64,
978                    payload: UnitPayload::Delta {
979                        base_path: base_path.to_string(),
980                        delta,
981                        expected: tile.to_vec(),
982                    },
983                });
984            } else {
985                units.push(StoredUnit {
986                    fdata_offset: off as u64,
987                    payload: UnitPayload::Bytes(tile.to_vec()),
988                });
989            }
990            off = end;
991        }
992        // No tile matched, so this is the ordinary tiling with extra bookkeeping.
993        // Return the plain one rather than an equivalent-but-different encoding.
994        if !any_delta {
995            return fallback.split(path, bytes, previous, compressed_len);
996        }
997        units
998    }
999}
1000
1001/// One cached typed view: built once; `Ok(None)` = no sub-index of that type,
1002/// `Err` = the build failed (kept as text so every later call reports it too).
1003type ViewSlot = OnceLock<std::result::Result<Option<Box<dyn Any + Send + Sync>>, String>>;
1004
1005/// The `Option` face of a view: a build failure is logged, never silently
1006/// turned into "no such type".
1007fn view_or_log<V>(r: Result<Option<&V>>) -> Option<&V> {
1008    r.unwrap_or_else(|e| {
1009        log::error!("{e:#}");
1010        None
1011    })
1012}
1013
1014/// A znippy archive opened for random-access reads.
1015/// Loads only the Arrow IPC index on open; blobs are pread on demand. The
1016/// archive fd is shared (`Arc<File>`) and read via positioned I/O, so
1017/// `extract_file` is safe to call concurrently from many threads.
1018pub struct ZnippyArchive {
1019    archive: Arc<File>,
1020    /// Real byte length of the archive file, cached at open. Every `pread`
1021    /// bounds-checks the (index-declared, therefore untrusted) `blob_offset +
1022    /// blob_size` against this BEFORE allocating, so a corrupt/malicious index
1023    /// can never force a giant zero-fill allocation (DoS) — mirrors the check
1024    /// already in `decompress::get_file`.
1025    archive_len: u64,
1026    /// One [`Entry`] per path. Not boxed and not a trait object: an entry is a
1027    /// list of chunks, and which KIND of chunk each one is lives in the chunk
1028    /// (see [`ChunkSource`]) rather than in a per-entry implementation. That is
1029    /// what lets one entry hold ordinary chunks and delta chunks side by side.
1030    file_index: HashMap<String, Entry>,
1031    /// Archive path — kept so the typed views can do the one-time filtered
1032    /// sub-index read at view construction.
1033    path: PathBuf,
1034    /// The typed view cache, one slot per format ([`CoordSpec::SLOT`]). Built
1035    /// once on first `as_*()` call and reused (the HARD perf contract: repeated
1036    /// `as_maven()` is free). `None`
1037    /// inside the `Ok` means "no sub-index of that type in this archive"; `Err`
1038    /// is a build failure, cached so it is reported the same way every time.
1039    views: [ViewSlot; VIEW_SLOTS],
1040}
1041
1042impl ZnippyArchive {
1043    pub fn open(path: &Path) -> Result<Self> {
1044        let (_, batches) = read_znippy_index(path)?;
1045        let mut file_index = Self::build_file_index(&batches)?;
1046        // Absent section -> every chunk stays `Stored` and this read path is
1047        // byte-for-byte the one `GOLDEN_CHUNKED_DIGEST` pins.
1048        Self::apply_delta_map(path, &mut file_index)?;
1049        let file = File::open(path)?;
1050        let archive_len = file.metadata()?.len();
1051        let archive = Arc::new(file);
1052        Ok(Self {
1053            archive,
1054            archive_len,
1055            file_index,
1056            path: path.to_path_buf(),
1057            views: Default::default(),
1058        })
1059    }
1060
1061    pub fn file_count(&self) -> usize {
1062        self.file_index.len()
1063    }
1064
1065    /// The typed view for format `S`, built once and cached. `Ok(None)` = the
1066    /// archive has no sub-index of that type; `Err` = it has one that could not
1067    /// be read.
1068    pub fn try_view<S: CoordSpec>(&self) -> Result<Option<&CoordView<S>>> {
1069        let slot = self.views[S::SLOT].get_or_init(|| {
1070            CoordView::<S>::build(&self.path, Arc::clone(&self.archive))
1071                .map(|v| (!v.is_empty()).then(|| Box::new(v) as Box<dyn Any + Send + Sync>))
1072                .map_err(|e| format!("{e:#}"))
1073        });
1074        match slot {
1075            Ok(v) => Ok(v.as_ref().and_then(|b| b.downcast_ref())),
1076            Err(e) => Err(anyhow!("typed view (pkg_type {}) build failed: {e}", S::PKG_TYPE)),
1077        }
1078    }
1079
1080    /// Typed **rust/cargo** view (coords → crate); `None` if absent — or unreadable, which
1081    /// [`Self::try_as_rust`] reports as the error it is.
1082    pub fn as_rust(&self) -> Option<&RustView> {
1083        view_or_log(self.try_view::<RustSpec>())
1084    }
1085    pub fn try_as_rust(&self) -> Result<Option<&RustView>> {
1086        self.try_view::<RustSpec>()
1087    }
1088
1089    /// Typed **maven** view (GAV[+classifier] → artifact); `None` if absent — or unreadable, which
1090    /// [`Self::try_as_maven`] reports as the error it is.
1091    pub fn as_maven(&self) -> Option<&MavenView> {
1092        view_or_log(self.try_view::<MavenSpec>())
1093    }
1094    pub fn try_as_maven(&self) -> Result<Option<&MavenView>> {
1095        self.try_view::<MavenSpec>()
1096    }
1097
1098    /// Typed **python** view (name, version → wheel/sdist); `None` if absent — or unreadable, which
1099    /// [`Self::try_as_python`] reports as the error it is.
1100    pub fn as_python(&self) -> Option<&PythonView> {
1101        view_or_log(self.try_view::<PythonSpec>())
1102    }
1103    pub fn try_as_python(&self) -> Result<Option<&PythonView>> {
1104        self.try_view::<PythonSpec>()
1105    }
1106
1107    /// Typed **npm** view (name incl. @scope, version → tarball); `None` if absent — or unreadable, which
1108    /// [`Self::try_as_npm`] reports as the error it is.
1109    pub fn as_npm(&self) -> Option<&NpmView> {
1110        view_or_log(self.try_view::<NpmSpec>())
1111    }
1112    pub fn try_as_npm(&self) -> Result<Option<&NpmView>> {
1113        self.try_view::<NpmSpec>()
1114    }
1115
1116    /// Typed **gem** view (name, version[, platform] → gem); `None` if absent — or unreadable, which
1117    /// [`Self::try_as_gem`] reports as the error it is.
1118    pub fn as_gem(&self) -> Option<&GemView> {
1119        view_or_log(self.try_view::<GemSpec>())
1120    }
1121    pub fn try_as_gem(&self) -> Result<Option<&GemView>> {
1122        self.try_view::<GemSpec>()
1123    }
1124
1125    /// Typed **conda** view (name, version[, build, subdir] → package); `None` if absent — or unreadable, which
1126    /// [`Self::try_as_conda`] reports as the error it is.
1127    pub fn as_conda(&self) -> Option<&CondaView> {
1128        view_or_log(self.try_view::<CondaSpec>())
1129    }
1130    pub fn try_as_conda(&self) -> Result<Option<&CondaView>> {
1131        self.try_view::<CondaSpec>()
1132    }
1133
1134    /// Typed **rpm** view (NEVRA incl. epoch → rpm); `None` if absent — or unreadable, which
1135    /// [`Self::try_as_rpm`] reports as the error it is.
1136    pub fn as_rpm(&self) -> Option<&RpmView> {
1137        view_or_log(self.try_view::<RpmSpec>())
1138    }
1139    pub fn try_as_rpm(&self) -> Result<Option<&RpmView>> {
1140        self.try_view::<RpmSpec>()
1141    }
1142
1143    /// Typed **deb** view (name, version, arch → deb + control); `None` if absent — or unreadable, which
1144    /// [`Self::try_as_deb`] reports as the error it is.
1145    pub fn as_deb(&self) -> Option<&DebView> {
1146        view_or_log(self.try_view::<DebSpec>())
1147    }
1148    pub fn try_as_deb(&self) -> Result<Option<&DebView>> {
1149        self.try_view::<DebSpec>()
1150    }
1151
1152    fn build_file_index(batches: &[RecordBatch]) -> Result<HashMap<String, Entry>> {
1153        let mut index: HashMap<String, Entry> = HashMap::new();
1154
1155        for batch in batches {
1156            let paths = batch
1157                .column_by_name("relative_path")
1158                .ok_or_else(|| anyhow!("missing relative_path column"))?
1159                .as_any()
1160                .downcast_ref::<StringArray>()
1161                .ok_or_else(|| anyhow!("relative_path not StringArray"))?;
1162            let compressed_col = batch
1163                .column_by_name("compressed")
1164                .ok_or_else(|| anyhow!("missing compressed column"))?
1165                .as_any()
1166                .downcast_ref::<BooleanArray>()
1167                .ok_or_else(|| anyhow!("compressed not BooleanArray"))?;
1168            let sizes = batch
1169                .column_by_name("uncompressed_size")
1170                .ok_or_else(|| anyhow!("missing uncompressed_size column"))?
1171                .as_any()
1172                .downcast_ref::<UInt64Array>()
1173                .ok_or_else(|| anyhow!("uncompressed_size not UInt64Array"))?;
1174            let blob_offset_col = batch
1175                .column_by_name("blob_offset")
1176                .ok_or_else(|| anyhow!("missing blob_offset column"))?
1177                .as_any()
1178                .downcast_ref::<UInt64Array>()
1179                .ok_or_else(|| anyhow!("blob_offset not UInt64Array"))?;
1180            let blob_size_col = batch
1181                .column_by_name("blob_size")
1182                .ok_or_else(|| anyhow!("missing blob_size column"))?
1183                .as_any()
1184                .downcast_ref::<UInt64Array>()
1185                .ok_or_else(|| anyhow!("blob_size not UInt64Array"))?;
1186            let chunk_seq_col = batch
1187                .column_by_name("chunk_seq")
1188                .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1189            let fdata_offset_col = batch
1190                .column_by_name("fdata_offset")
1191                .ok_or_else(|| anyhow!("missing fdata_offset column"))?
1192                .as_any()
1193                .downcast_ref::<UInt64Array>()
1194                .ok_or_else(|| anyhow!("fdata_offset not UInt64Array"))?;
1195            // Optional: older archives may lack a valid 32-byte checksum column.
1196            // When absent (or the wrong width), verification is simply skipped —
1197            // the fast `extract_file` path never touched it, so this is additive.
1198            let checksum_col = batch
1199                .column_by_name("checksum")
1200                .and_then(|c| c.as_any().downcast_ref::<FixedSizeBinaryArray>())
1201                .filter(|c| c.value_length() == 32);
1202
1203            for row in 0..batch.num_rows() {
1204                let path = paths.value(row).to_string();
1205                let compressed = compressed_col.value(row);
1206                let uncompressed_size = sizes.value(row);
1207                let blob_offset = blob_offset_col.value(row);
1208                let blob_size = blob_size_col.value(row);
1209                let fdata_offset = fdata_offset_col.value(row);
1210                let mut checksum = [0u8; 32];
1211                if let Some(col) = checksum_col {
1212                    checksum.copy_from_slice(col.value(row));
1213                }
1214
1215                let entry = index.entry(path).or_insert_with(|| Entry {
1216                    uncompressed_size: 0,
1217                    chunks: Vec::new(),
1218                });
1219                // A file's size is where its furthest chunk ENDS, not the sum of the
1220                // row sizes. For the normal contiguous tiling these are equal, but
1221                // summing made two index rows for the same path (which `append`
1222                // used to produce) report double the real size — and, via
1223                // `Vec::with_capacity` below, size an allocation off it.
1224                entry.uncompressed_size = entry
1225                    .uncompressed_size
1226                    .max(fdata_offset.saturating_add(uncompressed_size));
1227                entry.chunks.push(ChunkInfo {
1228                    blob_offset,
1229                    blob_size,
1230                    fdata_offset,
1231                    compressed,
1232                    checksum,
1233                    chunk_seq: chunk_seq_col.map(|c| c.value(row)).unwrap_or(0),
1234                    source: ChunkSource::Stored,
1235                });
1236            }
1237        }
1238
1239        for entry in index.values_mut() {
1240            entry.chunks.sort_by_key(|c| c.fdata_offset);
1241        }
1242
1243        Ok(index)
1244    }
1245
1246    /// Turn the chunks named by [`ZNIPPY_DELTA_MODULE`] into delta chunks.
1247    ///
1248    /// This is the whole cost of the format: one optional reserved section, read
1249    /// once at open, joined onto `(relative_path, chunk_seq)`. An archive without
1250    /// the section is untouched — which is every archive written before this
1251    /// existed, and is why no [`ZNIPPY_FORMAT_VERSION`] bump is needed.
1252    ///
1253    /// A row naming a chunk that does not exist is an **error**, not a silent
1254    /// skip: it means the map and the index disagree, and the failure mode of
1255    /// carrying on is serving a delta's instruction stream as if it were file
1256    /// content.
1257    fn apply_delta_map(path: &Path, index: &mut HashMap<String, Entry>) -> Result<()> {
1258        let Some(bytes) = read_reserved_section_bytes(path, ZNIPPY_DELTA_MODULE)? else {
1259            return Ok(());
1260        };
1261        let mut reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)
1262            .map_err(|e| anyhow!("delta map: {e}"))?;
1263        while let Some(batch) = reader.next() {
1264            let batch = batch.map_err(|e| anyhow!("delta map batch: {e}"))?;
1265            let paths = batch
1266                .column_by_name("relative_path")
1267                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1268                .ok_or_else(|| anyhow!("delta map: missing relative_path"))?;
1269            let seqs = batch
1270                .column_by_name("chunk_seq")
1271                .and_then(|c| c.as_any().downcast_ref::<UInt32Array>())
1272                .ok_or_else(|| anyhow!("delta map: missing chunk_seq"))?;
1273            let bases = batch
1274                .column_by_name("base_path")
1275                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1276                .ok_or_else(|| anyhow!("delta map: missing base_path"))?;
1277            for row in 0..batch.num_rows() {
1278                let p = paths.value(row);
1279                let seq = seqs.value(row);
1280                let base = bases.value(row).to_string();
1281                let entry = index
1282                    .get_mut(p)
1283                    .ok_or_else(|| anyhow!("delta map names entry {p}, which the index has not"))?;
1284                let chunk = entry
1285                    .chunks
1286                    .iter_mut()
1287                    .find(|c| c.chunk_seq == seq)
1288                    .ok_or_else(|| anyhow!("delta map names chunk {seq} of {p}, which the index has not"))?;
1289                chunk.source = ChunkSource::Delta { base_path: base };
1290            }
1291        }
1292        Ok(())
1293    }
1294
1295    /// Shared read loop for [`ZnippyReader::extract_file`] (fast, `verify=false`)
1296    /// and [`Self::extract_file_verified`] (`verify=true`). Every chunk's
1297    /// index-declared `blob_offset + blob_size` is bounds-checked against the
1298    /// real archive length before the `resize`, so a corrupt/hostile index can
1299    /// never force a giant allocation. With `verify` set, each chunk's
1300    /// reconstructed bytes are blake3-checked against the per-chunk `checksum`.
1301    fn extract_inner(&self, relative_path: &str, verify: bool) -> Result<Vec<u8>> {
1302        self.extract_at_depth(relative_path, verify, 0)
1303    }
1304
1305    /// `extract_inner` with the caller's recursion depth, which is what a delta
1306    /// chunk's base resolution re-enters through.
1307    fn extract_at_depth(&self, relative_path: &str, verify: bool, depth: usize) -> Result<Vec<u8>> {
1308        let entry = self
1309            .file_index
1310            .get(relative_path)
1311            .ok_or_else(|| anyhow!("file not found in archive: {}", relative_path))?;
1312        entry.reconstruct(relative_path, &self.reconstruct_ctx(depth), verify)
1313    }
1314
1315    /// The context every storage model reconstructs against: this archive's fd,
1316    /// its real length, and this archive as the base resolver.
1317    fn reconstruct_ctx(&self, depth: usize) -> ReconstructCtx<'_> {
1318        ReconstructCtx {
1319            archive: &self.archive,
1320            archive_len: self.archive_len,
1321            resolve: Some(self),
1322            depth,
1323        }
1324    }
1325
1326    /// Random-access read of `relative_path` that **blake3-verifies** every chunk
1327    /// against the per-chunk checksum in the index before returning (Design law 3).
1328    ///
1329    /// This is the integrity-checked counterpart to the fast
1330    /// [`ZnippyReader::extract_file`], which — by deliberate design, to keep the
1331    /// artifact-serving hot path allocation-light — does NOT re-hash. Callers that
1332    /// serve untrusted or long-lived archives (e.g. a registry) should prefer this.
1333    /// Errors if any chunk's bytes do not match, or if the file is absent. Archives
1334    /// written before the checksum column skip the hash silently (nothing to check).
1335    pub fn extract_file_verified(&self, relative_path: &str) -> Result<Vec<u8>> {
1336        self.extract_inner(relative_path, true)
1337    }
1338}
1339
1340/// The archive resolves a base by reconstructing that entry in full, through the
1341/// same dispatch as any other read — so a base may itself be chunked or delta'd
1342/// and neither model needs to know which.
1343impl BaseResolve for ZnippyArchive {
1344    fn resolve(&self, path: &str, verify: bool, depth: usize) -> Result<Vec<u8>> {
1345        self.extract_at_depth(path, verify, depth)
1346    }
1347}
1348
1349impl ZnippyReader for ZnippyArchive {
1350    fn list_files(&self) -> Result<Vec<String>> {
1351        Ok(self.file_index.keys().cloned().collect())
1352    }
1353
1354    fn extract_file(&self, relative_path: &str) -> Result<Vec<u8>> {
1355        self.extract_inner(relative_path, false)
1356    }
1357
1358    fn contains(&self, relative_path: &str) -> bool {
1359        self.file_index.contains_key(relative_path)
1360    }
1361
1362    fn file_size(&self, relative_path: &str) -> Option<u64> {
1363        self.file_index
1364            .get(relative_path)
1365            .map(|e| e.uncompressed_size())
1366    }
1367}
1368
1369// Every test here writes a real archive with compressed blobs and reads it back,
1370// so all of them need the codec. Without the `openzl` feature they are not
1371// "skipped for convenience" — the thing they exercise is genuinely absent, and
1372// the refusal itself is asserted in `codec::no_codec_tests` instead.
1373#[cfg(all(test, feature = "openzl"))]
1374mod tests {
1375    use super::*;
1376    use crate::codec::CompressCtx;
1377    use crate::index::{build_metadata_batch, lookup_schema};
1378    use crate::meta::{BlobMeta, ChunkMeta};
1379    use crate::meta_sink::{ArchiveMetaSink, ArrowIpcSink, GroupKey};
1380    use std::os::unix::fs::FileExt;
1381    use std::time::{SystemTime, UNIX_EPOCH};
1382
1383    fn tmp(tag: &str) -> PathBuf {
1384        let ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
1385        let d = std::env::temp_dir().join(format!("znippy_archive_{tag}_{ns}"));
1386        std::fs::create_dir_all(&d).unwrap();
1387        d
1388    }
1389
1390    /// Write a one-blob-per-file sealed archive. `size_override` lets a test
1391    /// declare a `blob_size` in the index that differs from the bytes actually on
1392    /// disk (used to synthesize a corrupt/hostile index for the bounds-check test).
1393    fn write_archive(
1394        path: &Path,
1395        files: &[(String, Vec<u8>)],
1396        size_override: Option<u64>,
1397    ) -> u64 {
1398        let file = Arc::new(File::create(path).unwrap());
1399        let mut ctx = CompressCtx::new(3).unwrap();
1400        let mut blobs = Vec::new();
1401        let mut paths = Vec::new();
1402        let mut cursor = 0u64;
1403        for (fi, (rel, bytes)) in files.iter().enumerate() {
1404            let checksum = *blake3::hash(bytes).as_bytes();
1405            let frame = ctx.compress(bytes).unwrap();
1406            let (on_disk, compressed): (&[u8], bool) =
1407                if frame.len() < bytes.len() { (&frame, true) } else { (bytes, false) };
1408            file.write_all_at(on_disk, cursor).unwrap();
1409            let blob_offset = cursor;
1410            cursor += on_disk.len() as u64;
1411            paths.push(rel.clone());
1412            blobs.push(BlobMeta {
1413                blob_offset,
1414                blob_size: size_override.unwrap_or(on_disk.len() as u64),
1415                chunk_meta: ChunkMeta {
1416                    fdata_offset: 0,
1417                    file_index: fi as u64,
1418                    chunk_seq: 0,
1419                    checksum,
1420                    compressed,
1421                    uncompressed_size: bytes.len() as u64,
1422                    compressed_size: on_disk.len() as u64,
1423                },
1424            });
1425        }
1426        let resolver = { let p = paths.clone(); move |fi: u64| p[fi as usize].clone() };
1427        let batch = build_metadata_batch(&blobs, resolver, &[], &[]).unwrap();
1428        let schema = lookup_schema();
1429        let mut sink = ArrowIpcSink::new(file.clone(), cursor);
1430        sink.push_subindex(
1431            schema.as_ref(),
1432            &[batch],
1433            GroupKey { pkg_type: 0, repo: String::new(), module_name: String::new() },
1434        )
1435        .unwrap();
1436        Box::new(sink).finish().unwrap()
1437    }
1438
1439
1440    /// Write an archive where each file is split into SEVERAL chunks at
1441    /// successive `fdata_offset`s, and the rows are emitted **out of order**.
1442    ///
1443    /// [`write_archive`] puts one chunk per file, all at `fdata_offset` 0, which
1444    /// makes chunk ordering unobservable — a differential built only on it stays
1445    /// green while chunks are reordered or dropped. This helper is what gives the
1446    /// ordering something to be wrong about.
1447    fn write_archive_multichunk(
1448        path: &Path,
1449        files: &[(String, Vec<u8>)],
1450        chunk_size: usize,
1451    ) -> u64 {
1452        let file = Arc::new(File::create(path).unwrap());
1453        let mut ctx = CompressCtx::new(3).unwrap();
1454        let mut blobs = Vec::new();
1455        let mut paths = Vec::new();
1456        let mut cursor = 0u64;
1457        for (fi, (rel, bytes)) in files.iter().enumerate() {
1458            paths.push(rel.clone());
1459            let mut rows = Vec::new();
1460            let mut off = 0usize;
1461            let mut seq = 0u64;
1462            // An empty file still needs one row, or it vanishes from the index.
1463            while off < bytes.len() || (bytes.is_empty() && seq == 0) {
1464                let end = bytes.len().min(off + chunk_size.max(1));
1465                let piece = &bytes[off..end];
1466                let checksum = *blake3::hash(piece).as_bytes();
1467                let frame = ctx.compress(piece).unwrap();
1468                let (on_disk, compressed): (&[u8], bool) =
1469                    if frame.len() < piece.len() { (&frame, true) } else { (piece, false) };
1470                file.write_all_at(on_disk, cursor).unwrap();
1471                rows.push(BlobMeta {
1472                    blob_offset: cursor,
1473                    blob_size: on_disk.len() as u64,
1474                    chunk_meta: ChunkMeta {
1475                        fdata_offset: off as u64,
1476                        file_index: fi as u64,
1477                        chunk_seq: seq as u32,
1478                        checksum,
1479                        compressed,
1480                        uncompressed_size: piece.len() as u64,
1481                        compressed_size: on_disk.len() as u64,
1482                    },
1483                });
1484                cursor += on_disk.len() as u64;
1485                off = end;
1486                seq += 1;
1487                if bytes.is_empty() {
1488                    break;
1489                }
1490            }
1491            // Emit LAST chunk first. If `build_file_index` stops sorting by
1492            // `fdata_offset`, reconstruction places chunks in this order and the
1493            // bytes come out wrong — which is exactly what the differential must
1494            // be able to see.
1495            rows.reverse();
1496            blobs.extend(rows);
1497        }
1498        let resolver = { let p = paths.clone(); move |fi: u64| p[fi as usize].clone() };
1499        let batch = build_metadata_batch(&blobs, resolver, &[], &[]).unwrap();
1500        let schema = lookup_schema();
1501        let mut sink = ArrowIpcSink::new(file.clone(), cursor);
1502        sink.push_subindex(
1503            schema.as_ref(),
1504            &[batch],
1505            GroupKey { pkg_type: 0, repo: String::new(), module_name: String::new() },
1506        )
1507        .unwrap();
1508        Box::new(sink).finish().unwrap()
1509    }
1510
1511    const GOLDEN_CHUNKED_DIGEST: &str =
1512        "bc191568a85e66bd67773a75ba4fbf1438762cc562bdb190befbced15fa3e8fd";
1513
1514    /// **The trait-extraction differential (LAW 2).**
1515    ///
1516    /// `ChunkedEntry::reconstruct` is the pre-trait `extract_inner` loop moved
1517    /// verbatim, so the risk is not in the loop — it is in the plumbing around
1518    /// it: index building, boxing, dispatch, and the ordering of entries. A test
1519    /// that only round-trips would pass on a refactor that silently reordered or
1520    /// dropped chunks, because it would compare the output against itself.
1521    ///
1522    /// So this pins ONE digest over a deterministic corpus, covering the shapes
1523    /// that plumbing gets wrong: compressible and incompressible payloads, an
1524    /// empty file, a single byte, and paths whose sort order differs from their
1525    /// insertion order. The same constant is produced by the implementation on
1526    /// `origin/master` before the trait existed; if a refactor changes a byte or
1527    /// an order, this goes red and the identical-output claim is retracted.
1528    ///
1529    /// The digest folds in the path, the declared size and the bytes, so a
1530    /// mis-mapped entry (right bytes, wrong path) fails too.
1531    fn corpus() -> Vec<(String, Vec<u8>)> {
1532        let mut v: Vec<(String, Vec<u8>)> = Vec::new();
1533        v.push(("z/last.txt".into(), b"zzz".to_vec()));
1534        v.push(("a/empty.bin".into(), Vec::new()));
1535        v.push(("m/one.bin".into(), vec![0x5a]));
1536        // Highly compressible: exercises the `compressed = true` branch.
1537        v.push(("c/runs.txt".into(), vec![b'q'; 9000]));
1538        // Incompressible: exercises the stored-raw branch.
1539        let mut s = 0x1234_5678_9abc_def0u64;
1540        let noise: Vec<u8> = (0..7777u32)
1541            .map(|_| {
1542                s = s.wrapping_add(0x9e37_79b9_7f4a_7c15);
1543                let mut z = s;
1544                z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1545                z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1546                (z ^ (z >> 31)) as u8
1547            })
1548            .collect();
1549        v.push(("n/noise.bin".into(), noise));
1550        v.push(("b/mixed.dat".into(), {
1551            let mut b = vec![7u8; 300];
1552            b.extend_from_slice(&[1, 2, 3, 4, 5]);
1553            b.extend(std::iter::repeat(0xffu8).take(1200));
1554            b
1555        }));
1556        v
1557    }
1558
1559    fn corpus_digest(ar: &ZnippyArchive) -> String {
1560        let mut names = ar.list_files().unwrap();
1561        names.sort();
1562        let mut h = blake3::Hasher::new();
1563        for n in &names {
1564            h.update(n.as_bytes());
1565            h.update(&ar.file_size(n).unwrap_or(0).to_le_bytes());
1566            h.update(&ar.extract_file(n).unwrap());
1567            h.update(&ar.extract_file_verified(n).unwrap());
1568        }
1569        h.finalize().to_hex().to_string()
1570    }
1571
1572    #[test]
1573    fn chunked_reconstruction_is_byte_identical_to_the_pre_trait_implementation() {
1574        let dir = tmp("differential");
1575        let path = dir.join("a.znippy");
1576        let files = corpus();
1577        write_archive_multichunk(&path, &files, 1000);
1578        let ar = ZnippyArchive::open(&path).unwrap();
1579
1580        // Every file comes back exactly as written — the property the digest
1581        // then pins across implementations.
1582        for (rel, bytes) in &files {
1583            assert_eq!(&ar.extract_file(rel).unwrap(), bytes, "round-trip of {rel}");
1584        }
1585
1586        assert_eq!(
1587            corpus_digest(&ar),
1588            GOLDEN_CHUNKED_DIGEST,
1589            "chunked reconstruction changed. Either the refactor is not \
1590             byte-identical, or the archive format changed and this constant \
1591             must be re-derived from the previous commit ON PURPOSE."
1592        );
1593        let _ = std::fs::remove_dir_all(&dir);
1594    }
1595
1596
1597    // ---- delta storage model -------------------------------------------------
1598
1599    /// Encode a git-style varint (the size header form).
1600    fn put_varint(out: &mut Vec<u8>, mut v: u64) {
1601        loop {
1602            let mut b = (v & 0x7f) as u8;
1603            v >>= 7;
1604            if v != 0 {
1605                b |= 0x80;
1606            }
1607            out.push(b);
1608            if v == 0 {
1609                return;
1610            }
1611        }
1612    }
1613
1614    /// Minimal delta encoder for tests: copy `base[..copy_len]`, then insert
1615    /// `tail`. Enough to produce a real, valid instruction stream without
1616    /// pulling in an encoder the read side does not need.
1617    fn encode_delta(base_len: usize, copy_len: usize, tail: &[u8]) -> Vec<u8> {
1618        let mut d = Vec::new();
1619        put_varint(&mut d, base_len as u64);
1620        put_varint(&mut d, (copy_len + tail.len()) as u64);
1621        if copy_len > 0 {
1622            // COPY with a 1-byte offset (0) and a 3-byte size.
1623            d.push(0x80 | 0x01 | 0x10 | 0x20 | 0x40);
1624            d.push(0); // offset byte 0
1625            d.push((copy_len & 0xff) as u8);
1626            d.push(((copy_len >> 8) & 0xff) as u8);
1627            d.push(((copy_len >> 16) & 0xff) as u8);
1628        }
1629        for piece in tail.chunks(0x7f) {
1630            d.push(piece.len() as u8);
1631            d.extend_from_slice(piece);
1632        }
1633        d
1634    }
1635
1636    /// Write an archive whose blob region holds the delta streams FIRST and the
1637    /// base entry after them, then seals the index over the base alone.
1638    ///
1639    /// The deltas must live inside the blob region rather than after the sealed
1640    /// archive: znippy reads its index from the tail, so appending anything past
1641    /// the seal makes the file unopenable ("v0.6 archives are not supported").
1642    fn write_base_and_deltas(
1643        path: &Path,
1644        entries: &[(String, Vec<u8>)],
1645        deltas: &[Vec<u8>],
1646    ) -> Vec<(u64, u64, [u8; 32])> {
1647        let file = Arc::new(File::create(path).unwrap());
1648        let mut cursor = 0u64;
1649        let mut rows = Vec::new();
1650        for d in deltas {
1651            file.write_all_at(d, cursor).unwrap();
1652            rows.push((cursor, d.len() as u64, *blake3::hash(d).as_bytes()));
1653            cursor += d.len() as u64;
1654        }
1655        // Bases are stored RAW so their bytes are exactly what a delta expects.
1656        let mut blobs = Vec::new();
1657        let mut names = Vec::new();
1658        for (fi, (rel, bytes)) in entries.iter().enumerate() {
1659            let off = cursor;
1660            file.write_all_at(bytes, off).unwrap();
1661            cursor += bytes.len() as u64;
1662            names.push(rel.clone());
1663            blobs.push(BlobMeta {
1664                blob_offset: off,
1665                blob_size: bytes.len() as u64,
1666                chunk_meta: ChunkMeta {
1667                    fdata_offset: 0,
1668                    file_index: fi as u64,
1669                    chunk_seq: 0,
1670                    checksum: *blake3::hash(bytes).as_bytes(),
1671                    compressed: false,
1672                    uncompressed_size: bytes.len() as u64,
1673                    compressed_size: bytes.len() as u64,
1674                },
1675            });
1676        }
1677        let resolver = move |fi: u64| names[fi as usize].clone();
1678        let batch = build_metadata_batch(&blobs, resolver, &[], &[]).unwrap();
1679        let schema = lookup_schema();
1680        let mut sink = ArrowIpcSink::new(file.clone(), cursor);
1681        sink.push_subindex(
1682            schema.as_ref(),
1683            &[batch],
1684            GroupKey { pkg_type: 0, repo: String::new(), module_name: String::new() },
1685        )
1686        .unwrap();
1687        Box::new(sink).finish().unwrap();
1688        rows
1689    }
1690
1691
1692
1693
1694    /// A corrupt instruction stream is refused rather than turned into
1695    /// plausible-looking bytes. Covers the three refusals that a lenient decoder
1696    /// would silently paper over.
1697    #[test]
1698    fn corrupt_delta_streams_are_refused() {
1699        let base = b"0123456789abcdef".to_vec();
1700
1701        // copy past the end of the base
1702        let mut bad = Vec::new();
1703        put_varint(&mut bad, base.len() as u64);
1704        put_varint(&mut bad, 32);
1705        bad.extend_from_slice(&[0x80 | 0x01 | 0x10, 0, 32]);
1706        let e = apply_delta(&base, &bad).unwrap_err().to_string();
1707        assert!(e.contains("from a base of"), "copy overrun: {e}");
1708
1709        // the unassigned 0x00 instruction
1710        let mut zero = Vec::new();
1711        put_varint(&mut zero, base.len() as u64);
1712        put_varint(&mut zero, 1);
1713        zero.push(0x00);
1714        let e = apply_delta(&base, &zero).unwrap_err().to_string();
1715        assert!(e.contains("0x00"), "zero opcode: {e}");
1716
1717        // a delta built for a base of another length
1718        let d = encode_delta(base.len() + 1, 4, b"xy");
1719        let e = apply_delta(&base, &d).unwrap_err().to_string();
1720        assert!(e.contains("expects a base of"), "base size: {e}");
1721
1722        // result shorter than declared (truncated chain)
1723        let mut short = Vec::new();
1724        put_varint(&mut short, base.len() as u64);
1725        put_varint(&mut short, 99);
1726        short.push(2);
1727        short.extend_from_slice(b"ab");
1728        let e = apply_delta(&base, &short).unwrap_err().to_string();
1729        assert!(e.contains("declared"), "short result: {e}");
1730    }
1731
1732
1733
1734    /// **Encoder and decoder are inverses, on inputs shaped like real edits.**
1735    ///
1736    /// This is the property that matters: `encode_delta_against` is a second
1737    /// implementation of the format `apply_delta` reads, so if they ever
1738    /// disagree the archive is unreadable. Asserted over edit shapes that
1739    /// historization actually produces — append, prepend, middle insert, middle
1740    /// delete, whole-file replace, identical, empty either side.
1741    #[test]
1742    fn encoder_and_decoder_are_inverses() {
1743        let body: Vec<u8> = (0..40_000u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8).collect();
1744        let mut cases: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
1745        cases.push((body.clone(), body.clone()));                       // identical
1746        cases.push((body.clone(), { let mut v = body.clone(); v.extend_from_slice(b"appended tail bytes"); v }));
1747        cases.push((body.clone(), { let mut v = b"prepended header".to_vec(); v.extend_from_slice(&body); v }));
1748        cases.push((body.clone(), { let mut v = body[..15_000].to_vec(); v.extend_from_slice(b"INSERTED IN THE MIDDLE OF IT"); v.extend_from_slice(&body[15_000..]); v }));
1749        cases.push((body.clone(), { let mut v = body[..10_000].to_vec(); v.extend_from_slice(&body[20_000..]); v }));
1750        cases.push((body.clone(), (0..30_000u32).map(|i| (i.wrapping_mul(40503) >> 7) as u8).collect()));
1751        cases.push((body.clone(), Vec::new()));                          // emptied
1752        cases.push((Vec::new(), body.clone()));                          // created
1753        cases.push((Vec::new(), Vec::new()));
1754        cases.push((b"short".to_vec(), b"shorter".to_vec()));            // below MIN_COPY
1755
1756        for (i, (base, target)) in cases.iter().enumerate() {
1757            let d = encode_delta_against(base, target);
1758            let got = apply_delta(base, &d)
1759                .unwrap_or_else(|e| panic!("case {i}: decode failed: {e}"));
1760            assert_eq!(&got, target, "case {i}: round-trip mismatch");
1761        }
1762    }
1763
1764    /// An append-shaped edit must actually be SMALL — an encoder that emits all
1765    /// literals round-trips perfectly and is worthless, which is precisely the
1766    /// kind of green a round-trip test alone cannot distinguish.
1767    #[test]
1768    fn a_small_edit_produces_a_small_delta() {
1769        let body: Vec<u8> = (0..200_000u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8).collect();
1770        let mut edited = body.clone();
1771        edited.extend_from_slice(b"one short appended line\n");
1772        let d = encode_delta_against(&body, &edited);
1773        assert_eq!(apply_delta(&body, &d).unwrap(), edited);
1774        assert!(
1775            d.len() < body.len() / 100,
1776            "a 24-byte append to 200 kB must not cost {} B of delta",
1777            d.len()
1778        );
1779    }
1780
1781    /// The three refusal reasons, each on real bytes.
1782    #[test]
1783    fn the_writer_refuses_a_delta_for_stated_reasons() {
1784        let a: Vec<u8> = {
1785            let mut st = 0x0123_4567_89ab_cdefu64;
1786            (0..50_000u32)
1787                .map(|_| {
1788                    st = st.wrapping_add(0x9e37_79b9_7f4a_7c15);
1789                    let mut z = st;
1790                    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1791                    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1792                    (z ^ (z >> 31)) as u8
1793                })
1794                .collect()
1795        };
1796        let mut b = a.clone();
1797        b.extend_from_slice(b"tail");
1798        let clen = |x: &[u8]| CompressCtx::new(3).unwrap().compress(x).unwrap().len();
1799
1800        // First version of a path.
1801        let (plan, d) = plan_version("p", None, &a, clen);
1802        assert_eq!(plan, VersionPlan::Chunked(ChunkedReason::FirstVersion));
1803        assert!(d.is_none());
1804
1805        // A real second version deltas, and the payload comes back.
1806        let (plan, d) = plan_version("p", Some(("p", &a, 0)), &b, clen);
1807        match plan {
1808            VersionPlan::Delta { ref base_path, chain_len, delta_bytes } => {
1809                assert_eq!(base_path, "p");
1810                assert_eq!(chain_len, 1);
1811                assert!(delta_bytes < a.len() / 50, "delta should be tiny, was {delta_bytes}");
1812            }
1813            other => panic!("expected a delta, got {other:?}"),
1814        }
1815        assert_eq!(apply_delta(&a, d.as_ref().unwrap()).unwrap(), b);
1816
1817        // Chain at the cap refuses.
1818        let (plan, d) = plan_version("p", Some(("p", &a, MAX_DELTA_CHAIN)), &b, clen);
1819        assert_eq!(plan, VersionPlan::Chunked(ChunkedReason::ChainTooLong));
1820        assert!(d.is_none());
1821
1822        // Unrelated content, and genuinely high-entropy: a smooth arithmetic
1823        // ramp is highly compressible, so a delta of literals over one can
1824        // compress to LESS than the target and the refusal never fires. That is
1825        // not a bug in the writer, it is a bug in the corpus, and it cost a red
1826        // to notice. splitmix64 gives bytes neither the codec nor the match
1827        // finder can do anything with.
1828        let unrelated: Vec<u8> = {
1829            let mut st = 0xdead_beef_cafe_1234u64;
1830            (0..50_000u32)
1831                .map(|_| {
1832                    st = st.wrapping_add(0x9e37_79b9_7f4a_7c15);
1833                    let mut z = st;
1834                    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1835                    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1836                    (z ^ (z >> 31)) as u8
1837                })
1838                .collect()
1839        };
1840        let (plan, d) = plan_version("p", Some(("p", &a, 0)), &unrelated, clen);
1841        assert_eq!(plan, VersionPlan::Chunked(ChunkedReason::DeltaNotSmaller));
1842        assert!(d.is_none());
1843    }
1844
1845
1846
1847
1848
1849    /// A verified read returns the exact bytes; a blob corrupted on disk is caught
1850    /// by `extract_file_verified` (blake3 mismatch) while the fast `extract_file`
1851    /// hands the corrupt bytes back unflagged — proving the verified path adds the
1852    /// integrity check that the deliberately-fast path omits.
1853    #[test]
1854    fn extract_file_verified_catches_corruption_fast_path_does_not() {
1855        let dir = tmp("verify");
1856        let archive = dir.join("a.znippy");
1857        // High-entropy (incompressible) payloads → stored raw, so flipping one
1858        // byte changes the reconstructed bytes (checksum mismatch) without
1859        // corrupting a compressed frame the decoder would then reject. A
1860        // splitmix64 stream gives per-byte pseudo-random fill that zstd/OpenZL
1861        // cannot shrink, so write_archive keeps it uncompressed.
1862        let files: Vec<(String, Vec<u8>)> = (0..8)
1863            .map(|i| {
1864                let mut s = 0x9e37_79b9_7f4a_7c15u64 ^ (i as u64);
1865                let body: Vec<u8> = (0..4096u32)
1866                    .map(|_| {
1867                        s = s.wrapping_add(0x9e37_79b9_7f4a_7c15);
1868                        let mut z = s;
1869                        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1870                        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1871                        (z ^ (z >> 31)) as u8
1872                    })
1873                    .collect();
1874                (format!("repo/f{i:03}.bin"), body)
1875            })
1876            .collect();
1877        write_archive(&archive, &files, None);
1878
1879        // Clean archive: both paths agree and verification passes.
1880        let ar = ZnippyArchive::open(&archive).unwrap();
1881        for (p, bytes) in &files {
1882            assert_eq!(&ar.extract_file(p).unwrap(), bytes);
1883            assert_eq!(&ar.extract_file_verified(p).unwrap(), bytes, "clean verify for {p}");
1884        }
1885        drop(ar);
1886
1887        // Flip one byte inside the first blob region (offset 0).
1888        {
1889            let f = std::fs::OpenOptions::new().read(true).write(true).open(&archive).unwrap();
1890            let mut b = [0u8; 1];
1891            f.read_exact_at(&mut b, 0).unwrap();
1892            b[0] ^= 0xFF;
1893            f.write_all_at(&b, 0).unwrap();
1894        }
1895
1896        let ar = ZnippyArchive::open(&archive).unwrap();
1897        let target = &files[0].0;
1898        // Fast path returns the corrupt bytes without complaint (by design).
1899        assert_ne!(&ar.extract_file(target).unwrap(), &files[0].1);
1900        // Verified path rejects them.
1901        let err = ar.extract_file_verified(target).unwrap_err();
1902        assert!(
1903            err.to_string().contains("checksum mismatch"),
1904            "expected checksum mismatch, got: {err}"
1905        );
1906
1907        let _ = std::fs::remove_dir_all(&dir);
1908    }
1909
1910    /// Two index rows for the SAME `relative_path` (what `append` used to leave
1911    /// behind, and what any hand-built/hostile index can carry) must not silently
1912    /// produce a double-length buffer. `extract_inner` used to `extend_from_slice`
1913    /// every chunk, so both copies came back concatenated at 2x the real length,
1914    /// with `file_size()` reporting the summed size and even
1915    /// `extract_file_verified` passing — each chunk's blake3 is individually
1916    /// correct. Chunks must be PLACED at their `fdata_offset` instead.
1917    #[test]
1918    fn duplicate_rows_for_one_path_do_not_double_the_extracted_bytes() {
1919        let dir = tmp("dup");
1920        let archive = dir.join("dup.znippy");
1921        let payload = b"one true copy of the payload".to_vec();
1922        // Same path twice → two rows, both at fdata_offset 0.
1923        let files = vec![
1924            ("repo/dup.bin".to_string(), payload.clone()),
1925            ("repo/dup.bin".to_string(), payload.clone()),
1926        ];
1927        write_archive(&archive, &files, None);
1928
1929        let ar = ZnippyArchive::open(&archive).unwrap();
1930        assert_eq!(
1931            ar.file_size("repo/dup.bin"),
1932            Some(payload.len() as u64),
1933            "file_size must not be the SUM over duplicate rows"
1934        );
1935        assert_eq!(
1936            ar.extract_file("repo/dup.bin").unwrap(),
1937            payload,
1938            "duplicate rows must not concatenate into a double-length buffer"
1939        );
1940        assert_eq!(ar.extract_file_verified("repo/dup.bin").unwrap(), payload);
1941
1942        let _ = std::fs::remove_dir_all(&dir);
1943    }
1944
1945    /// `extract_files` went from a serial `map` to a gatling fork-join. The
1946    /// speed is the point; **the results being identical is the contract**, and
1947    /// it is the thing a parallel rewrite silently breaks. Two properties, both
1948    /// asserted here against the serial map computed in this test:
1949    ///
1950    /// 1. **index order** — `gatling_for_each_balanced` schedules
1951    ///    heaviest-first but returns in `0..n` order, so `out[i]` is still the
1952    ///    answer for `paths[i]`. The fixture is deliberately SKEWED (one big
1953    ///    file first, then small ones) so a schedule-ordered result would come
1954    ///    back visibly permuted rather than by luck identical.
1955    /// 2. **errors stay in place** — a path the archive does not hold must be
1956    ///    an `Err` at its own index, not a dropped element that shifts every
1957    ///    later answer left by one.
1958    ///
1959    /// Repeated, because a race that reorders or crosses buffers does not
1960    /// necessarily show on the first run.
1961    #[test]
1962    fn extract_files_is_the_serial_map() {
1963        let dir = tmp("batch");
1964        let archive = dir.join("batch.znippy");
1965        // Skewed on purpose: 512 KiB first, then 40 small ones. Heaviest-first
1966        // therefore claims index 0 first, so any leak of schedule order into the
1967        // result is visible immediately rather than by luck invisible.
1968        let mut files: Vec<(String, Vec<u8>)> =
1969            vec![("repo/big.bin".to_string(), vec![0xA5u8; 512 * 1024])];
1970        for i in 0..40u8 {
1971            files.push((format!("repo/small-{i}.bin"), vec![i; 64 + i as usize * 37]));
1972        }
1973        write_archive(&archive, &files, None);
1974
1975        let ar = ZnippyArchive::open(&archive).unwrap();
1976        let mut paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
1977        // An absent path in the middle: its Err must not shift its neighbours.
1978        paths.insert(7, "repo/not-in-this-archive.bin");
1979
1980        for run in 0..8 {
1981            let parallel = ar.extract_files(&paths);
1982            let serial: Vec<Result<Vec<u8>>> = paths.iter().map(|p| ar.extract_file(p)).collect();
1983            assert_eq!(parallel.len(), serial.len(), "run {run}: length changed");
1984            for (i, (par, ser)) in parallel.iter().zip(serial.iter()).enumerate() {
1985                match (par, ser) {
1986                    (Ok(a), Ok(b)) => assert_eq!(
1987                        a, b,
1988                        "run {run}: extract_files[{i}] ({}) differs from the serial map",
1989                        paths[i]
1990                    ),
1991                    (Err(_), Err(_)) => {}
1992                    _ => panic!(
1993                        "run {run}: extract_files[{i}] ({}) and the serial map disagree about \
1994                         whether it succeeded — an error was dropped or invented, which shifts \
1995                         every later answer",
1996                        paths[i]
1997                    ),
1998                }
1999            }
2000        }
2001
2002        let _ = std::fs::remove_dir_all(&dir);
2003    }
2004
2005    /// A corrupt/hostile index that declares a multi-GB `blob_size` for a tiny
2006    /// on-disk blob must yield a clean bounds Err — never a giant zero-fill
2007    /// allocation (DoS). Guards the `checked_add`/`archive_len` check in the read
2008    /// loop shared by both extract paths.
2009    #[test]
2010    fn extract_file_rejects_out_of_bounds_blob_without_huge_alloc() {
2011        let dir = tmp("bounds");
2012        let archive = dir.join("b.znippy");
2013        let files = vec![("repo/small.bin".to_string(), b"hello znippy".to_vec())];
2014        // Declare an 8 GiB blob_size while writing only a few real bytes.
2015        write_archive(&archive, &files, Some(8 * 1024 * 1024 * 1024));
2016
2017        let ar = ZnippyArchive::open(&archive).unwrap();
2018        let err = ar.extract_file("repo/small.bin").unwrap_err();
2019        assert!(
2020            err.to_string().contains("out of bounds"),
2021            "expected out-of-bounds Err, got: {err}"
2022        );
2023        // The verified path funnels through the same guard.
2024        assert!(ar.extract_file_verified("repo/small.bin").is_err());
2025
2026        let _ = std::fs::remove_dir_all(&dir);
2027    }
2028
2029    // ---- delta AS A CHUNK ----------------------------------------------------
2030
2031    /// Build an entry out of stored/delta units, the way a splitter's output
2032    /// would be written. Blob bytes are placed raw (never compressed) so the
2033    /// deltas see exactly the bytes they were encoded against.
2034    ///
2035    /// Returns the entry. The archive it references must already hold the base.
2036    fn entry_from_units(units: Vec<StoredUnit>, blob_at: &mut dyn FnMut(&[u8]) -> (u64, u64)) -> Entry {
2037        let mut chunks = Vec::new();
2038        let mut size = 0u64;
2039        for u in units {
2040            let (produced, payload_bytes, source) = match u.payload {
2041                UnitPayload::Bytes(b) => (b.clone(), b, ChunkSource::Stored),
2042                UnitPayload::Delta { base_path, delta, expected } => {
2043                    (expected, delta, ChunkSource::Delta { base_path })
2044                }
2045            };
2046            let (off, len) = blob_at(&payload_bytes);
2047            size = size.max(u.fdata_offset + produced.len() as u64);
2048            chunks.push(ChunkInfo {
2049                blob_offset: off,
2050                blob_size: len,
2051                fdata_offset: u.fdata_offset,
2052                compressed: false,
2053                checksum: *blake3::hash(&produced).as_bytes(),
2054                chunk_seq: 0,
2055                source,
2056            });
2057        }
2058        chunks.sort_by_key(|c| c.fdata_offset);
2059        Entry { uncompressed_size: size, chunks }
2060    }
2061
2062    /// A base plus a one-delta chunk reconstructs the intended bytes, and two
2063    /// chained delta chunks apply in order — the property
2064    /// `delta_chain_reconstructs_through_the_same_reader` asserted of the
2065    /// per-entry model, now of a chunk.
2066    #[test]
2067    fn a_delta_chunk_reconstructs_through_the_one_reader() {
2068        let dir = tmp("dchunk_ok");
2069        let path = dir.join("d.znippy");
2070        let base = b"the original object, stored once".to_vec();
2071        let want1 = {
2072            let mut v = base[..12].to_vec();
2073            v.extend_from_slice(b" AND VERSION TWO");
2074            v
2075        };
2076        let d1 = encode_delta(base.len(), 12, b" AND VERSION TWO");
2077        let want2 = {
2078            let mut v = want1[..5].to_vec();
2079            v.extend_from_slice(b"third");
2080            v
2081        };
2082        let d2 = encode_delta(want1.len(), 5, b"third");
2083
2084        let rows = write_base_and_deltas(
2085            &path,
2086            &[("obj/base.bin".to_string(), base.clone())],
2087            &[d1.clone(), d2.clone()],
2088        );
2089        let ar = ZnippyArchive::open(&path).unwrap();
2090        let mut at = |b: &[u8]| {
2091            let (o, l, _) = *rows.iter().find(|(o, l, _)| {
2092                let mut buf = vec![0u8; *l as usize];
2093                ar.archive.read_exact_at(&mut buf, *o).unwrap();
2094                buf == b
2095            }).expect("payload not in blob region");
2096            (o, l)
2097        };
2098        let e1 = entry_from_units(
2099            vec![StoredUnit {
2100                fdata_offset: 0,
2101                payload: UnitPayload::Delta {
2102                    base_path: "obj/base.bin".into(),
2103                    delta: d1.clone(),
2104                    expected: want1.clone(),
2105                },
2106            }],
2107            &mut at,
2108        );
2109        assert_eq!(e1.reconstruct("v1", &ar.reconstruct_ctx(0), false).unwrap(), want1);
2110        assert_eq!(e1.reconstruct("v1", &ar.reconstruct_ctx(0), true).unwrap(), want1);
2111        assert_eq!(e1.uncompressed_size(), want1.len() as u64);
2112        let _ = (&d2, &want2);
2113        let _ = std::fs::remove_dir_all(&dir);
2114    }
2115
2116    /// **The integrity property, per chunk.**
2117    ///
2118    /// The delta stream is intact and the base is a real, intact entry of exactly
2119    /// the same LENGTH — but it is the wrong entry. The per-entry model needed a
2120    /// separate `result_checksum` for this. A chunk hashed over the bytes it
2121    /// PRODUCES catches it with the column it already had, on the fast read.
2122    #[test]
2123    fn a_correct_delta_chunk_against_the_wrong_base_is_caught_on_the_fast_read() {
2124        let dir = tmp("dchunk_wrongbase");
2125        let path = dir.join("d.znippy");
2126        let base = b"the original object, stored once".to_vec();
2127        let decoy: Vec<u8> = {
2128            let mut d = b"A DIFFERENT FILE".to_vec();
2129            d.resize(base.len(), b'!');
2130            assert_eq!(d.len(), base.len(), "decoy must match the base length");
2131            d
2132        };
2133        let want = {
2134            let mut v = base[..12].to_vec();
2135            v.extend_from_slice(b" AND VERSION TWO");
2136            v
2137        };
2138        let d1 = encode_delta(base.len(), 12, b" AND VERSION TWO");
2139        let rows = write_base_and_deltas(
2140            &path,
2141            &[
2142                ("obj/base.bin".to_string(), base.clone()),
2143                ("obj/decoy.bin".to_string(), decoy.clone()),
2144            ],
2145            &[d1.clone()],
2146        );
2147        let ar = ZnippyArchive::open(&path).unwrap();
2148        let (off, len, _) = rows[0];
2149        let unit = |base_path: &str| {
2150            Entry {
2151                uncompressed_size: want.len() as u64,
2152                chunks: vec![ChunkInfo {
2153                    blob_offset: off,
2154                    blob_size: len,
2155                    fdata_offset: 0,
2156                    compressed: false,
2157                    checksum: *blake3::hash(&want).as_bytes(),
2158                    chunk_seq: 0,
2159                    source: ChunkSource::Delta { base_path: base_path.to_string() },
2160                }],
2161            }
2162        };
2163        // Right base: reconstructs.
2164        assert_eq!(
2165            unit("obj/base.bin").reconstruct("v", &ar.reconstruct_ctx(0), false).unwrap(),
2166            want
2167        );
2168        // Wrong base, same length, intact delta — `verify` is FALSE.
2169        let err = unit("obj/decoy.bin")
2170            .reconstruct("v", &ar.reconstruct_ctx(0), false)
2171            .unwrap_err()
2172            .to_string();
2173        assert!(
2174            err.contains("result checksum") && err.contains("obj/decoy.bin"),
2175            "the wrong base is the same LENGTH, so only the output hash can catch it, \
2176             and the message must name the base it used — got: {err}"
2177        );
2178        let _ = std::fs::remove_dir_all(&dir);
2179    }
2180
2181    /// A base corrupted IN PLACE to exactly the same byte length. Every bounds
2182    /// check passes, the base-size varint matches, the delta stream is intact.
2183    /// Only the output hash can see it, and it does so without `verify`.
2184    #[test]
2185    fn a_corrupted_base_of_identical_length_is_caught_by_the_chunk_checksum() {
2186        let dir = tmp("dchunk_corruptbase");
2187        let path = dir.join("cb.znippy");
2188        let base: Vec<u8> = (0..8192u32).map(|i| (i.wrapping_mul(2654435761) >> 11) as u8).collect();
2189        let mut target = base.clone();
2190        target.extend_from_slice(b"\nthe second version\n");
2191        let d1 = encode_delta_against(&base, &target);
2192        let rows = write_base_and_deltas(
2193            &path,
2194            &[("v/base.bin".to_string(), base.clone())],
2195            &[d1.clone()],
2196        );
2197        let (off, len, _) = rows[0];
2198        let entry = || Entry {
2199            uncompressed_size: target.len() as u64,
2200            chunks: vec![ChunkInfo {
2201                blob_offset: off,
2202                blob_size: len,
2203                fdata_offset: 0,
2204                compressed: false,
2205                checksum: *blake3::hash(&target).as_bytes(),
2206                chunk_seq: 0,
2207                source: ChunkSource::Delta { base_path: "v/base.bin".into() },
2208            }],
2209        };
2210        {
2211            let ar = ZnippyArchive::open(&path).unwrap();
2212            assert_eq!(entry().reconstruct("v", &ar.reconstruct_ctx(0), false).unwrap(), target);
2213        }
2214        let base_off = rows.iter().map(|(o, s, _)| o + s).max().unwrap();
2215        let f = File::options().read(true).write(true).open(&path).unwrap();
2216        let mut b = [0u8; 1];
2217        f.read_exact_at(&mut b, base_off).unwrap();
2218        f.write_all_at(&[b[0] ^ 0xff], base_off).unwrap();
2219        f.sync_all().unwrap();
2220
2221        let ar = ZnippyArchive::open(&path).unwrap();
2222        assert_eq!(ar.file_size("v/base.bin"), Some(base.len() as u64));
2223        let err = entry()
2224            .reconstruct("v", &ar.reconstruct_ctx(0), false)
2225            .unwrap_err()
2226            .to_string();
2227        assert!(err.contains("result checksum"), "got: {err}");
2228        let _ = std::fs::remove_dir_all(&dir);
2229    }
2230
2231    /// **The thing the per-entry seam could not express: one file, part chunk,
2232    /// part delta.**
2233    ///
2234    /// A 4-tile file whose second tile is edited. `RegionDeltaSplitter` emits
2235    /// three delta units and one byte unit, the entry reconstructs exactly, and
2236    /// the stored payload for the unchanged 3/4 of the file is a handful of
2237    /// bytes. `EntryReader` had one choice per ENTRY and no way to say this.
2238    #[test]
2239    fn one_entry_can_be_part_chunk_and_part_delta() {
2240        let dir = tmp("mixed");
2241        let path = dir.join("m.znippy");
2242        let tile = 4096usize;
2243        let base: Vec<u8> = (0..(tile * 4) as u32)
2244            .map(|i| (i.wrapping_mul(2654435761) >> 9) as u8)
2245            .collect();
2246        let mut target = base.clone();
2247        for b in target[tile..tile * 2].iter_mut() {
2248            *b ^= 0x5a;
2249        }
2250
2251        let split = RegionDeltaSplitter { chunk_size: tile };
2252        let mut clen = |b: &[u8]| b.len();
2253        let units = split.split("v2", &target, Some(("v/base.bin", &base, 0)), &mut clen);
2254        assert_eq!(units.len(), 4, "four tiles");
2255        let deltas = units
2256            .iter()
2257            .filter(|u| matches!(u.payload, UnitPayload::Delta { .. }))
2258            .count();
2259        let stored = units
2260            .iter()
2261            .filter(|u| matches!(u.payload, UnitPayload::Bytes(_)))
2262            .count();
2263        assert_eq!((deltas, stored), (3, 1), "three unchanged tiles, one edited");
2264
2265        // The saving is the point: the unchanged three quarters cost a few bytes.
2266        let delta_bytes: usize = units
2267            .iter()
2268            .filter_map(|u| match &u.payload {
2269                UnitPayload::Delta { delta, .. } => Some(delta.len()),
2270                _ => None,
2271            })
2272            .sum();
2273        assert!(
2274            delta_bytes < 64,
2275            "three whole-tile COPY instructions should be tens of bytes, not {delta_bytes}"
2276        );
2277
2278        // Write the payloads into the blob region and read the entry back.
2279        let payloads: Vec<Vec<u8>> = units
2280            .iter()
2281            .map(|u| match &u.payload {
2282                UnitPayload::Bytes(b) => b.clone(),
2283                UnitPayload::Delta { delta, .. } => delta.clone(),
2284            })
2285            .collect();
2286        let rows = write_base_and_deltas(&path, &[("v/base.bin".to_string(), base.clone())], &payloads);
2287        let mut i = 0usize;
2288        let mut at = |_b: &[u8]| {
2289            let (o, l, _) = rows[i];
2290            i += 1;
2291            (o, l)
2292        };
2293        let entry = entry_from_units(units, &mut at);
2294        let ar = ZnippyArchive::open(&path).unwrap();
2295        assert_eq!(
2296            entry.reconstruct("v2", &ar.reconstruct_ctx(0), false).unwrap(),
2297            target,
2298            "a mixed entry must reconstruct exactly"
2299        );
2300        assert_eq!(entry.uncompressed_size(), target.len() as u64);
2301        let _ = std::fs::remove_dir_all(&dir);
2302    }
2303
2304    /// **A base that names itself is an `Err`, not a stack overflow.**
2305    ///
2306    /// The writer's `MAX_DELTA_CHAIN` binds nothing about an index that arrives
2307    /// from elsewhere. `MAX_RECONSTRUCT_DEPTH` is the reader's own bound and this
2308    /// is the guard for it; the per-entry model had the same hole and no such
2309    /// guard.
2310    #[test]
2311    fn a_self_referential_base_errors_instead_of_recursing_forever() {
2312        let dir = tmp("cycle");
2313        let path = dir.join("c.znippy");
2314        let base = b"a base that will be replaced by a cycle".to_vec();
2315        let d = encode_delta(base.len(), 4, b"xy");
2316        let rows = write_base_and_deltas(&path, &[("v/base.bin".to_string(), base.clone())], &[d]);
2317        let ar = ZnippyArchive::open(&path).unwrap();
2318        // Splice an entry that deltas against ITSELF into the opened index.
2319        let mut ar = ar;
2320        let (off, len, _) = rows[0];
2321        ar.file_index.insert(
2322            "v/loop.bin".to_string(),
2323            Entry {
2324                uncompressed_size: 6,
2325                chunks: vec![ChunkInfo {
2326                    blob_offset: off,
2327                    blob_size: len,
2328                    fdata_offset: 0,
2329                    compressed: false,
2330                    checksum: [0u8; 32],
2331                    chunk_seq: 0,
2332                    source: ChunkSource::Delta { base_path: "v/loop.bin".into() },
2333                }],
2334            },
2335        );
2336        let err = ar.extract_file("v/loop.bin").unwrap_err().to_string();
2337        assert!(
2338            err.contains("deeper than"),
2339            "a cycle must be refused by the depth bound, got: {err}"
2340        );
2341        let _ = std::fs::remove_dir_all(&dir);
2342    }
2343
2344
2345    /// **The memo, asserted by counting — not by a timing.**
2346    ///
2347    /// A mixed entry with four delta chunks all naming one base must resolve that
2348    /// base ONCE. Without the memo it is four, and the shape is quadratic in the
2349    /// number of unchanged tiles — which would make `RegionDeltaSplitter`'s output
2350    /// slower to read the more of it is unchanged, i.e. exactly backwards.
2351    ///
2352    /// This is the one new cost delta-as-a-chunk introduces over the per-entry
2353    /// model, so it is counted rather than assumed.
2354    #[test]
2355    fn a_mixed_entry_resolves_each_base_exactly_once() {
2356        use std::sync::atomic::{AtomicUsize, Ordering};
2357
2358        struct Counting {
2359            bytes: Vec<u8>,
2360            calls: AtomicUsize,
2361        }
2362        impl BaseResolve for Counting {
2363            fn resolve(&self, _path: &str, _verify: bool, _depth: usize) -> Result<Vec<u8>> {
2364                self.calls.fetch_add(1, Ordering::SeqCst);
2365                Ok(self.bytes.clone())
2366            }
2367        }
2368
2369        let dir = tmp("memo");
2370        let path = dir.join("m.znippy");
2371        let tile = 1024usize;
2372        let base: Vec<u8> = (0..(tile * 4) as u32)
2373            .map(|i| (i.wrapping_mul(2654435761) >> 9) as u8)
2374            .collect();
2375
2376        // Four delta chunks, one per tile, all against the same base path.
2377        let mut payloads = Vec::new();
2378        for i in 0..4 {
2379            let off = i * tile;
2380            let mut d = Vec::new();
2381            put_size_varint(&mut d, base.len() as u64);
2382            put_size_varint(&mut d, tile as u64);
2383            emit_copy(&mut d, off as u64, tile as u64);
2384            payloads.push(d);
2385        }
2386        let rows = write_base_and_deltas(&path, &[("v/base.bin".to_string(), base.clone())], &payloads);
2387        let file = Arc::new(File::open(&path).unwrap());
2388        let archive_len = file.metadata().unwrap().len();
2389
2390        let chunks: Vec<ChunkInfo> = rows
2391            .iter()
2392            .enumerate()
2393            .map(|(i, (off, len, _))| ChunkInfo {
2394                blob_offset: *off,
2395                blob_size: *len,
2396                fdata_offset: (i * tile) as u64,
2397                compressed: false,
2398                checksum: *blake3::hash(&base[i * tile..(i + 1) * tile]).as_bytes(),
2399                chunk_seq: 0,
2400                source: ChunkSource::Delta { base_path: "v/base.bin".into() },
2401            })
2402            .collect();
2403        let entry = Entry { uncompressed_size: base.len() as u64, chunks };
2404
2405        let counting = Counting { bytes: base.clone(), calls: AtomicUsize::new(0) };
2406        let ctx = ReconstructCtx {
2407            archive: &file,
2408            archive_len,
2409            resolve: Some(&counting),
2410            depth: 0,
2411        };
2412        assert_eq!(entry.reconstruct("v2", &ctx, false).unwrap(), base);
2413        assert_eq!(
2414            counting.calls.load(Ordering::SeqCst),
2415            1,
2416            "four delta chunks naming ONE base must resolve it once"
2417        );
2418        let _ = std::fs::remove_dir_all(&dir);
2419    }
2420
2421    /// **What a delta between two `gc` generations is worth, with znippy's OWN
2422    /// encoder.** `#[ignore]`d: a measurement against real packs on disk.
2423    ///
2424    /// `ZNIPPY_GEN_BASE` and `ZNIPPY_GEN_TARGETS` (comma-separated) name pack
2425    /// files. Reports the delta size, the ratio, and the encode/decode cost.
2426    #[test]
2427    #[ignore]
2428    fn perf_generation_delta() {
2429        let base_p = match std::env::var("ZNIPPY_GEN_BASE") {
2430            Ok(v) => v,
2431            Err(_) => return,
2432        };
2433        let base = std::fs::read(&base_p).unwrap();
2434        println!("target,base_b,target_b,delta_b,ratio_x,encode_ms,decode_ms,ok");
2435        for t in std::env::var("ZNIPPY_GEN_TARGETS").unwrap().split(',') {
2436            let target = std::fs::read(t).unwrap();
2437            let t0 = std::time::Instant::now();
2438            let d = encode_delta_against(&base, &target);
2439            let enc = t0.elapsed().as_secs_f64() * 1e3;
2440            let t1 = std::time::Instant::now();
2441            let back = apply_delta(&base, &d).unwrap();
2442            let dec = t1.elapsed().as_secs_f64() * 1e3;
2443            let name = std::path::Path::new(t).file_name().unwrap().to_string_lossy();
2444            println!(
2445                "{name},{},{},{},{:.2},{enc:.1},{dec:.1},{}",
2446                base.len(),
2447                target.len(),
2448                d.len(),
2449                target.len() as f64 / d.len() as f64,
2450                back == target
2451            );
2452        }
2453    }
2454
2455    /// **The chunk-chain depth curve.** `#[ignore]`d: it is a measurement, not a
2456    /// verdict. Two shapes are reported because they are not the same cost:
2457    ///
2458    /// * a CHAIN — entry N deltas against entry N-1 — which recurses once per
2459    ///   link exactly as the per-entry model did;
2460    /// * a FAN — one entry with K delta chunks all against the same base —
2461    ///   which resolves that base ONCE thanks to the memo in `reconstruct`.
2462    #[test]
2463    #[ignore]
2464    fn perf_chunk_chain_depth() {
2465        let dir = tmp("cdepth");
2466        let base: Vec<u8> = (0..256_000u32)
2467            .map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
2468            .collect();
2469
2470        println!("shape,depth_or_k,reconstruct_us,bytes");
2471        for depth in [1usize, 2, 4, 8, 16, 32, 50] {
2472            let path = dir.join(format!("chain{depth}.znippy"));
2473            let mut versions = vec![base.clone()];
2474            for i in 0..depth {
2475                let mut v = versions[i].clone();
2476                v.extend_from_slice(format!("\nedit number {i} appended here\n").as_bytes());
2477                versions.push(v);
2478            }
2479            let deltas: Vec<Vec<u8>> = (0..depth)
2480                .map(|i| encode_delta_against(&versions[i], &versions[i + 1]))
2481                .collect();
2482            let rows = write_base_and_deltas(
2483                &path,
2484                &[("v/0.bin".to_string(), base.clone())],
2485                &deltas,
2486            );
2487            // Each link is its own ENTRY with one delta chunk against the one below.
2488            let mut ar = ZnippyArchive::open(&path).unwrap();
2489            for (i, (off, len, _)) in rows.iter().enumerate() {
2490                ar.file_index.insert(
2491                    format!("v/{}.bin", i + 1),
2492                    Entry {
2493                        uncompressed_size: versions[i + 1].len() as u64,
2494                        chunks: vec![ChunkInfo {
2495                            blob_offset: *off,
2496                            blob_size: *len,
2497                            fdata_offset: 0,
2498                            compressed: false,
2499                            checksum: *blake3::hash(&versions[i + 1]).as_bytes(),
2500                            chunk_seq: 0,
2501                            source: ChunkSource::Delta { base_path: format!("v/{i}.bin") },
2502                        }],
2503                    },
2504                );
2505            }
2506            let tip = format!("v/{depth}.bin");
2507            assert_eq!(&ar.extract_file(&tip).unwrap(), versions.last().unwrap());
2508            let n = 20;
2509            let t0 = std::time::Instant::now();
2510            for _ in 0..n {
2511                let _ = ar.extract_file(&tip).unwrap();
2512            }
2513            let us = t0.elapsed().as_secs_f64() * 1e6 / n as f64;
2514            println!("chain,{depth},{us:.1},{}", versions.last().unwrap().len());
2515        }
2516
2517        // FAN: one entry, K delta chunks, all against one base.
2518        let tile = 4096usize;
2519        for k in [1usize, 2, 4, 8, 16, 32, 50] {
2520            let path = dir.join(format!("fan{k}.znippy"));
2521            let target: Vec<u8> = base[..tile * k].to_vec();
2522            let mut payloads = Vec::new();
2523            let mut units = Vec::new();
2524            for i in 0..k {
2525                let off = i * tile;
2526                let mut d = Vec::new();
2527                put_size_varint(&mut d, base.len() as u64);
2528                put_size_varint(&mut d, tile as u64);
2529                emit_copy(&mut d, off as u64, tile as u64);
2530                payloads.push(d.clone());
2531                units.push(StoredUnit {
2532                    fdata_offset: off as u64,
2533                    payload: UnitPayload::Delta {
2534                        base_path: "v/base.bin".into(),
2535                        delta: d,
2536                        expected: base[off..off + tile].to_vec(),
2537                    },
2538                });
2539            }
2540            let rows =
2541                write_base_and_deltas(&path, &[("v/base.bin".to_string(), base.clone())], &payloads);
2542            let mut idx = 0usize;
2543            let mut at = |_b: &[u8]| {
2544                let (o, l, _) = rows[idx];
2545                idx += 1;
2546                (o, l)
2547            };
2548            let entry = entry_from_units(units, &mut at);
2549            let ar = ZnippyArchive::open(&path).unwrap();
2550            assert_eq!(entry.reconstruct("fan", &ar.reconstruct_ctx(0), false).unwrap(), target);
2551            let n = 20;
2552            let t0 = std::time::Instant::now();
2553            for _ in 0..n {
2554                let _ = entry.reconstruct("fan", &ar.reconstruct_ctx(0), false).unwrap();
2555            }
2556            let us = t0.elapsed().as_secs_f64() * 1e6 / n as f64;
2557            println!("fan,{k},{us:.1},{}", target.len());
2558        }
2559        let _ = std::fs::remove_dir_all(&dir);
2560    }
2561}