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