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