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}