Skip to main content

mkit_core/
pack.rs

1//! Packfile writer / reader — conformant to `docs/specs/SPEC-PACKFILE.md`.
2//!
3//! Layout (SPEC-PACKFILE §1, §2, §3, §8):
4//!
5//! ```text
6//! [4B  magic            "MKIT"]                       offset 0
7//! [4B  version u32 LE  == 1 or 2]
8//! [4B  entry_count u32 LE     ]
9//!   for each entry:
10//!     [u8  entry_type]           0x00 raw | 0x02 delta | 0x03 zstd-raw | 0x04 zstd-delta
11//!     [u32 LE payload_len]                            length of payload only
12//!     [payload_len bytes payload]
13//! [32B trailer = BLAKE3 of all preceding bytes]
14//! ```
15//!
16//! Entry types (SPEC-PACKFILE §3):
17//!
18//! * `0x00` raw        — payload is a fully serialised mkit object.
19//! * `0x01`             — RESERVED, MUST be rejected.
20//! * `0x02` delta       — payload is `[32B base_hash][SPEC-DELTA stream]`.
21//! * `0x03` zstd-raw    — v2 only. payload is `[4B uncompressed_len LE][zstd frame]`;
22//!   the frame decompresses to exactly what a `0x00` payload would be.
23//! * `0x04` zstd-delta  — v2 only. payload is `[32B base_hash][4B uncompressed_len LE][zstd frame]`;
24//!   `base_hash` stays uncompressed, the frame decompresses to exactly
25//!   what a `0x02` entry's post-base-hash bytes would be.
26//!
27//! **Version selection is writer policy, not caller policy**
28//! (SPEC-PACKFILE §1): [`PackWriter`] emits `version = 1` when the
29//! finished pack contains no `0x03`/`0x04` entries, and `version = 2`
30//! the moment it contains at least one — even in an otherwise-mixed
31//! pack. `0x03`/`0x04` are illegal inside a `version = 1` pack; a
32//! reader seeing one there rejects with `InvalidEntryType` exactly as
33//! it would for any other unrecognized type (SPEC-PACKFILE §3).
34//!
35//! **Compression is per-entry**, not a whole-pack stream: every
36//! `0x03`/`0x04` entry carries its own independent zstd frame, so
37//! existing framing/caps/trailer semantics (§2, §5, §8) are unchanged
38//! and decompression memory is bounded to one entry at a time.
39//! Decoding a `0x03`/`0x04` entry is bomb-guarded: the claimed
40//! `uncompressed_len` is checked against [`MAX_RAW_OBJECT_SIZE`]
41//! *before* any decompression allocation, decompression itself is
42//! capacity-bounded to that claim, and the actual decompressed length
43//! is re-checked against the claim afterward (`DecompressedSizeMismatch`
44//! / `DecompressedSizeOverCap`).
45//!
46//! Caps (SPEC-PACKFILE §5, unchanged by v2 — measured on the *wire*
47//! size; the decompressed-side cap above is separate and new):
48//!
49//! * `entry_count <= 10_000_000`
50//! * total `payload_len` sum `<= 4 GiB`
51//!
52//! Delta-base ordering rule (SPEC-PACKFILE §4): every delta entry's
53//! (`0x02` or `0x04`) `base_hash` MUST appear earlier in the same pack
54//! as a raw entry, OR already exist in the destination object store.
55//! `0x04`'s `base_hash` is never compressed, so this never requires
56//! decompression to evaluate.
57//!
58//! The pack key (SPEC-PACKFILE §7) is `packs/<lower-hex BLAKE3 of entire
59//! pack>`. The trailer is then redundant w.r.t. that key, but it lets a
60//! streaming reader detect bit-rot before the whole pack has been
61//! hashed end-to-end.
62
63use crate::delta;
64use crate::hash::{self, Hash};
65use crate::object::{MkitError, Object};
66use crate::store::{MAX_RAW_OBJECT_SIZE, ObjectStore};
67use std::borrow::Cow;
68use std::sync::atomic::{AtomicU64, Ordering};
69
70/// ASCII magic ("MKIT") at the start of every pack, v1 or v2.
71pub const MAGIC: &[u8; 4] = b"MKIT";
72/// Packfile version emitted when a pack contains no compressed
73/// (`0x03`/`0x04`) entries. Also the minimum version any reader
74/// accepts.
75pub const VERSION: u32 = 1;
76/// Packfile version emitted the moment a pack contains at least one
77/// compressed (`0x03`/`0x04`) entry (SPEC-PACKFILE §1, §9). Readers
78/// accept both `VERSION` and `VERSION_V2`; only `VERSION_V2` packs may
79/// contain `0x03`/`0x04` entries.
80pub const VERSION_V2: u32 = 2;
81
82/// Hard cap on entries (SPEC-PACKFILE §5).
83pub const MAX_ENTRIES: u32 = 10_000_000;
84/// Hard cap on the sum of payload bytes across all entries.
85pub const MAX_TOTAL_PAYLOAD: u64 = 4 * 1024 * 1024 * 1024;
86/// Trailer is a 32-byte raw BLAKE3 digest.
87pub const TRAILER_LEN: usize = 32;
88
89/// Header is `[4B magic][4B version][4B entry_count]`.
90pub const HEADER_LEN: usize = 4 + 4 + 4;
91/// Per-entry framing overhead is `[1B type][4B payload_len]`.
92pub const ENTRY_FRAME_LEN: usize = 1 + 4;
93/// Byte offset of the 4-byte `version` field within the header —
94/// right after the 4-byte magic. `PackWriter::finish` patches this
95/// once the final v1-vs-v2 decision is known (mirrors
96/// `ENTRY_COUNT_OFFSET` below).
97pub const VERSION_OFFSET: usize = 4;
98/// Byte offset of the 4-byte `entry_count` field within the header —
99/// after the 4-byte magic and 4-byte version fields. Found hardcoded
100/// as the literal range `8..12` at four call sites during the
101/// epic-#634 code review; named here instead, consistent with this
102/// file's existing `HEADER_LEN`/`TRAILER_LEN` convention.
103pub const ENTRY_COUNT_OFFSET: usize = 8;
104
105/// Compression candidates shorter than this are never compressed
106/// (SPEC-PACKFILE §3.3) — per-entry zstd framing overhead and CPU
107/// cost isn't worth it for tiny payloads. Only meaningful when the
108/// `pack-zstd` feature is compiled in (see `maybe_compress`).
109#[cfg(feature = "pack-zstd")]
110const MIN_COMPRESS_LEN: usize = 64;
111/// zstd compression level `PackWriter` uses for `0x03`/`0x04` entries.
112/// The library default (`ZSTD_CLEVEL_DEFAULT`); no benchmark evidence
113/// in issue #646 justified deviating from it.
114#[cfg(feature = "pack-zstd")]
115const ZSTD_LEVEL: i32 = 3;
116/// Byte length of a `0x03`/`0x04` entry's `uncompressed_len` length
117/// prefix. Part of the wire format regardless of whether this build
118/// can itself produce/consume `0x03`/`0x04` entries.
119const ZSTD_LEN_PREFIX: usize = 4;
120
121/// Packfile errors. Distinct from [`MkitError`] so callers can match on
122/// pack-specific failures (trailer mismatch, base-missing) without
123/// catching every object decode error.
124#[derive(Debug, thiserror::Error)]
125pub enum PackError {
126    #[error("packfile is shorter than the {HEADER_LEN}-byte header + {TRAILER_LEN}-byte trailer")]
127    PackfileTooShort,
128    #[error("first 4 bytes are not ASCII \"MKIT\"")]
129    InvalidMagic,
130    #[error("version {0} is not supported (v1 or v2 only)")]
131    UnsupportedVersion(u32),
132    #[error(
133        "entry_type {0:#04x} is not 0x00 (raw), 0x02 (delta), 0x03 (zstd-raw), or 0x04 \
134         (zstd-delta) — or is a v2-only entry type inside a version-1 pack"
135    )]
136    InvalidEntryType(u8),
137    #[error("entry_count {0} exceeds the {MAX_ENTRIES} cap")]
138    TooManyObjects(u32),
139    #[error("sum of payload_len exceeds {MAX_TOTAL_PAYLOAD} bytes")]
140    PackfileTooLarge,
141    #[error("entry payload extends past the trailer offset")]
142    UnexpectedEof,
143    #[error("trailer BLAKE3 mismatch — packfile is corrupt or truncated")]
144    PackfileCorrupted,
145    #[error("delta entry references base hash {0} which is not in this pack or the store")]
146    DeltaBaseMissing(String),
147    #[error("delta entry payload is shorter than the 32-byte base hash prefix")]
148    DeltaEntryTruncated,
149    #[error("delta reconstruction failed: {0}")]
150    DeltaApply(#[from] MkitError),
151    #[error("pack entry is not a canonical storable object: {0}")]
152    InvalidObject(MkitError),
153    #[error("pack entry resolves to pack-only delta object")]
154    NonStorableObject,
155    #[error("pack contains trailing bytes after declared entries")]
156    TrailingData,
157    #[error("store I/O failure: {0}")]
158    Store(#[from] crate::store::StoreError),
159    /// `0x03`/`0x04` payload shorter than its `[uncompressed_len]`
160    /// length-prefix header (SPEC-PACKFILE §3.3, §3.4) — distinct from
161    /// `DeltaEntryTruncated`, which covers the 32-byte `base_hash`
162    /// prefix a `0x04` entry has in front of this.
163    #[error("zstd entry payload is shorter than its length-prefix header")]
164    ZstdEntryTruncated,
165    /// Claimed `uncompressed_len` exceeds [`MAX_RAW_OBJECT_SIZE`] —
166    /// rejected before any decompression allocation is attempted
167    /// (SPEC-PACKFILE §3.3 bomb-guarding).
168    #[error(
169        "zstd entry's claimed decompressed size {0} exceeds the {MAX_RAW_OBJECT_SIZE}-byte cap"
170    )]
171    DecompressedSizeOverCap(usize),
172    /// The zstd frame decompressed successfully but produced a
173    /// different byte count than the entry's claimed
174    /// `uncompressed_len` (SPEC-PACKFILE §3.3 bomb-guarding).
175    #[error("zstd entry claims {0} decompressed bytes but produced {1}")]
176    DecompressedSizeMismatch(usize, usize),
177    /// The zstd frame itself is corrupt / not a valid zstd stream.
178    #[error("zstd decompression failed: {0}")]
179    ZstdDecompress(String),
180}
181
182/// Result of an unpack: which entries were stored, plus a count of
183/// delta resolutions vs raw writes. Useful for transport/CLI summaries.
184#[derive(Debug, Clone, Default, PartialEq, Eq)]
185pub struct UnpackReport {
186    pub raw_count: u32,
187    pub delta_count: u32,
188    /// Hashes inserted into the store this unpack call.
189    pub stored: Vec<Hash>,
190}
191
192/// Builds a packfile, enforcing entry/payload caps and streaming each
193/// pushed entry's frame directly into the final output buffer as it
194/// arrives. [`Self::finish`] only patches the header's entry count
195/// (unknown up front from a streaming writer) and appends the trailer —
196/// it never re-copies the pushed entries into a second, same-sized
197/// buffer (issue #647).
198#[derive(Debug)]
199pub struct PackWriter {
200    // The final packfile bytes, built incrementally: `new` writes the
201    // header with a zero entry-count placeholder (patched by `finish`
202    // once the final count is known); `push_raw`/`push_delta` append
203    // each entry's `[type][len][payload]` frame directly here. There is
204    // no separate per-entry collection copied a second time at
205    // `finish`.
206    buf: Vec<u8>,
207    entry_count: u32,
208    total_payload: u64,
209    // Set the first time `push_raw`/`push_delta` emits a `0x03`/`0x04`
210    // entry. `finish` reads this to decide the header's `version`
211    // field (SPEC-PACKFILE §1's writer version-selection rule) — v2
212    // the moment ANY entry ended up compressed, v1 otherwise.
213    has_compressed_entry: bool,
214}
215
216impl Default for PackWriter {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222impl PackWriter {
223    /// Create an empty writer.
224    #[must_use]
225    pub fn new() -> Self {
226        let mut buf = Vec::with_capacity(HEADER_LEN);
227        buf.extend_from_slice(MAGIC);
228        buf.extend_from_slice(&VERSION.to_le_bytes());
229        buf.extend_from_slice(&0u32.to_le_bytes()); // entry_count placeholder; `finish` patches it in.
230        Self {
231            buf,
232            entry_count: 0,
233            total_payload: 0,
234            has_compressed_entry: false,
235        }
236    }
237
238    /// Append a raw object entry. `bytes` is the fully serialised object
239    /// payload; `hash_of_bytes` is the BLAKE3 of those same bytes —
240    /// callers usually have it on hand from the object store, so we take
241    /// it explicitly to avoid an extra BLAKE3 pass over the same buffer.
242    /// Takes `bytes` by reference (not by value): the streaming writer
243    /// copies it straight into the output buffer as it's pushed, so it
244    /// never needs to own the caller's copy (issue #647). Returns the
245    /// same hash for chaining.
246    ///
247    /// Applies the SPEC-PACKFILE §3.3 compression policy transparently:
248    /// if `bytes` compresses under zstd strictly smaller on the wire
249    /// (and is long enough to bother, see `MIN_COMPRESS_LEN`), this
250    /// emits a `0x03` zstd-raw entry instead of `0x00` raw — callers
251    /// never need to opt in. Either way the returned/stored identity
252    /// (`hash_of_bytes`) is unchanged; only the wire encoding differs.
253    pub fn push_raw(&mut self, hash_of_bytes: Hash, bytes: &[u8]) -> Result<Hash, PackError> {
254        if let Some(frame) = maybe_compress(bytes) {
255            let uncompressed_len: u32 = bytes
256                .len()
257                .try_into()
258                .map_err(|_| PackError::PackfileTooLarge)?;
259            let payload_len = ZSTD_LEN_PREFIX + frame.len();
260            self.check_caps_for(payload_len)?;
261            self.total_payload += payload_len as u64;
262            self.append_entry(0x03, &[&uncompressed_len.to_le_bytes(), &frame])?;
263            self.has_compressed_entry = true;
264        } else {
265            self.check_caps_for(bytes.len())?;
266            self.total_payload += bytes.len() as u64;
267            self.append_entry(0x00, &[bytes])?;
268        }
269        self.entry_count += 1;
270        Ok(hash_of_bytes)
271    }
272
273    /// Append a delta entry. `base_hash` MUST refer to an earlier raw
274    /// entry in this pack OR an object already in the destination store.
275    /// `delta_stream` MUST be a valid SPEC-DELTA stream — we don't
276    /// re-validate here (the writer is trusted), but the reader will.
277    ///
278    /// Applies the same §3.3 compression policy as [`Self::push_raw`],
279    /// but ONLY to `delta_stream` — `base_hash` is always written
280    /// uncompressed (SPEC-PACKFILE §3.4), so ordering/base-discovery
281    /// logic never needs to decompress anything. Emits `0x04`
282    /// zstd-delta when the stream compresses strictly smaller on the
283    /// wire and is long enough to bother; `0x02` delta otherwise.
284    pub fn push_delta(&mut self, base_hash: &Hash, delta_stream: &[u8]) -> Result<(), PackError> {
285        if let Some(frame) = maybe_compress(delta_stream) {
286            let uncompressed_len: u32 = delta_stream
287                .len()
288                .try_into()
289                .map_err(|_| PackError::PackfileTooLarge)?;
290            let payload_len = hash::HASH_LEN + ZSTD_LEN_PREFIX + frame.len();
291            self.check_caps_for(payload_len)?;
292            self.total_payload += payload_len as u64;
293            self.append_entry(
294                0x04,
295                &[
296                    base_hash.as_slice(),
297                    &uncompressed_len.to_le_bytes(),
298                    &frame,
299                ],
300            )?;
301            self.has_compressed_entry = true;
302        } else {
303            let payload_len = hash::HASH_LEN + delta_stream.len();
304            self.check_caps_for(payload_len)?;
305            self.total_payload += payload_len as u64;
306            self.append_entry(0x02, &[base_hash.as_slice(), delta_stream])?;
307        }
308        self.entry_count += 1;
309        Ok(())
310    }
311
312    /// Append one entry's frame — `[1B type][4B payload_len][payload]`
313    /// — straight onto the output buffer. `parts` is the payload split
314    /// into its logical pieces (a delta entry is `[base_hash][stream]`)
315    /// so no intermediate concatenated buffer is ever built just to
316    /// hand a single contiguous slice to `finish`.
317    fn append_entry(&mut self, etype: u8, parts: &[&[u8]]) -> Result<(), PackError> {
318        let payload_len: usize = parts.iter().map(|p| p.len()).sum();
319        let plen: u32 = payload_len
320            .try_into()
321            .map_err(|_| PackError::PackfileTooLarge)?;
322        self.buf.push(etype);
323        self.buf.extend_from_slice(&plen.to_le_bytes());
324        for p in parts {
325            self.buf.extend_from_slice(p);
326        }
327        Ok(())
328    }
329
330    fn check_caps_for(&self, add_len: usize) -> Result<(), PackError> {
331        let next_count = u64::from(self.entry_count) + 1;
332        if next_count > u64::from(MAX_ENTRIES) {
333            return Err(PackError::TooManyObjects(MAX_ENTRIES + 1));
334        }
335        let next_total = self.total_payload.saturating_add(add_len as u64);
336        if next_total > MAX_TOTAL_PAYLOAD {
337            return Err(PackError::PackfileTooLarge);
338        }
339        Ok(())
340    }
341
342    /// Number of entries pushed so far. Useful for sizing diagnostics.
343    #[must_use]
344    pub fn entry_count(&self) -> usize {
345        self.entry_count as usize
346    }
347
348    /// Sum of wire payload bytes pushed so far — the quantity the
349    /// writer's own internal cap check compares against
350    /// [`MAX_TOTAL_PAYLOAD`]. Measured post-compression (SPEC-PACKFILE
351    /// §5): each `push_raw`/`push_delta` call adds the *wire* payload
352    /// length, not the caller's uncompressed input length. Callers
353    /// deciding whether to seal a pack before pushing another entry can
354    /// use a conservative uncompressed-length estimate against this
355    /// value — the actual wire cost is never more than that estimate,
356    /// since compression is only ever applied when it's strictly
357    /// smaller (see `maybe_compress`).
358    #[must_use]
359    pub fn total_payload(&self) -> u64 {
360        self.total_payload
361    }
362
363    /// Serialise the pack: header + entries + trailer. Entries are
364    /// already in `self.buf` (streamed in by `push_raw`/`push_delta`);
365    /// `finish` patches the header's `version` (SPEC-PACKFILE §1: v2
366    /// iff at least one entry ended up compressed, v1 otherwise) and
367    /// `entry_count`, then appends the trailer,
368    /// `BLAKE3(everything_before_trailer)`. The whole pack's BLAKE3 is
369    /// the on-disk pack key — see [`pack_key`].
370    pub fn finish(self) -> Result<Vec<u8>, PackError> {
371        self.finish_inner(None)
372    }
373
374    /// Test-only variant of [`Self::finish`] that also reports, via
375    /// `bytes_copied`, how many payload bytes it copies WHILE finishing
376    /// (as opposed to while entries were pushed). Proves `finish`
377    /// streams rather than double-buffers (issue #647): the unpatched
378    /// writer re-copied every pushed entry's payload into a fresh
379    /// same-size buffer inside `finish`, so this counter would track
380    /// the whole pack; the streaming writer only ever appends the
381    /// 32-byte trailer here.
382    #[cfg(test)]
383    pub(crate) fn finish_tracking_bytes_copied(
384        self,
385        bytes_copied: &AtomicU64,
386    ) -> Result<Vec<u8>, PackError> {
387        self.finish_inner(Some(bytes_copied))
388    }
389
390    fn finish_inner(mut self, bytes_copied: Option<&AtomicU64>) -> Result<Vec<u8>, PackError> {
391        if self.entry_count > MAX_ENTRIES {
392            return Err(PackError::TooManyObjects(self.entry_count));
393        }
394        let version = if self.has_compressed_entry {
395            VERSION_V2
396        } else {
397            VERSION
398        };
399        self.buf[VERSION_OFFSET..VERSION_OFFSET + 4].copy_from_slice(&version.to_le_bytes());
400        self.buf[ENTRY_COUNT_OFFSET..ENTRY_COUNT_OFFSET + 4]
401            .copy_from_slice(&self.entry_count.to_le_bytes());
402        let trailer = hash::hash(&self.buf);
403        if let Some(c) = bytes_copied {
404            c.fetch_add(trailer.len() as u64, Ordering::Relaxed);
405        }
406        self.buf.extend_from_slice(&trailer);
407        Ok(self.buf)
408    }
409}
410
411/// Compute the on-disk pack key: BLAKE3 of the entire packfile bytes
412/// (including the trailer). SPEC-PACKFILE §7. Returns the bare digest;
413/// callers prepend `packs/` and lower-hex-encode for the storage path.
414#[must_use]
415pub fn pack_key(pack_bytes: &[u8]) -> Hash {
416    hash::hash(pack_bytes)
417}
418
419/// SPEC-PACKFILE §3.3 writer compression policy: compress `data` with
420/// zstd and return the frame ONLY if doing so is worth it — `data` is
421/// at least [`MIN_COMPRESS_LEN`] bytes AND the compressed frame plus
422/// its `ZSTD_LEN_PREFIX`-byte length prefix is strictly smaller than
423/// `data` itself. Mirrors `transfer.rs`'s `try_delta` gate's
424/// "strictly smaller or don't bother" posture. Returns `None` (never
425/// an error) on any compression failure or when compression isn't
426/// worth it — compression is a pure wire-size optimization, so a
427/// writer always has a correct fallback (emit the entry uncompressed)
428/// rather than a new failure mode to propagate.
429#[cfg(feature = "pack-zstd")]
430fn maybe_compress(data: &[u8]) -> Option<Vec<u8>> {
431    if data.len() < MIN_COMPRESS_LEN {
432        return None;
433    }
434    let compressed = zstd::bulk::compress(data, ZSTD_LEVEL).ok()?;
435    if ZSTD_LEN_PREFIX + compressed.len() < data.len() {
436        Some(compressed)
437    } else {
438        None
439    }
440}
441
442#[cfg(not(feature = "pack-zstd"))]
443fn maybe_compress(_data: &[u8]) -> Option<Vec<u8>> {
444    // No compression backend compiled in (e.g. mkit-wasm, which opts
445    // out of `pack-zstd` for wasm32-buildability — see mkit-core's
446    // Cargo.toml). Every pack this build writes is a valid v1 pack;
447    // it just never uses the v2-only entry types.
448    None
449}
450
451/// Parse and decompress a `0x03`/`0x04`-style `[uncompressed_len][zstd
452/// frame]` payload, enforcing SPEC-PACKFILE §3.3's bomb-guarding
453/// before any decompression allocation: the claimed length is checked
454/// against [`MAX_RAW_OBJECT_SIZE`] first, decompression is bounded to
455/// that claim, and the actual decompressed length is re-checked
456/// against the claim afterward.
457fn decompress_zstd_entry(payload: &[u8]) -> Result<Vec<u8>, PackError> {
458    if payload.len() < ZSTD_LEN_PREFIX {
459        return Err(PackError::ZstdEntryTruncated);
460    }
461    let uncompressed_len =
462        u32::from_le_bytes(payload[..ZSTD_LEN_PREFIX].try_into().expect("4 bytes")) as usize;
463    if uncompressed_len > MAX_RAW_OBJECT_SIZE {
464        return Err(PackError::DecompressedSizeOverCap(uncompressed_len));
465    }
466    let frame = &payload[ZSTD_LEN_PREFIX..];
467    let decompressed = zstd_decompress_capped(frame, uncompressed_len)?;
468    if decompressed.len() != uncompressed_len {
469        return Err(PackError::DecompressedSizeMismatch(
470            uncompressed_len,
471            decompressed.len(),
472        ));
473    }
474    Ok(decompressed)
475}
476
477/// Decompress `frame`, bounding the allocation to `capacity` bytes
478/// (already checked against [`MAX_RAW_OBJECT_SIZE`] by the caller) so
479/// a corrupt or hostile frame can't force an over-large allocation.
480#[cfg(feature = "pack-zstd")]
481fn zstd_decompress_capped(frame: &[u8], capacity: usize) -> Result<Vec<u8>, PackError> {
482    zstd::bulk::decompress(frame, capacity).map_err(|e| PackError::ZstdDecompress(e.to_string()))
483}
484
485#[cfg(not(feature = "pack-zstd"))]
486fn zstd_decompress_capped(_frame: &[u8], _capacity: usize) -> Result<Vec<u8>, PackError> {
487    Err(PackError::ZstdDecompress(
488        "this build was compiled without the `pack-zstd` feature".to_string(),
489    ))
490}
491
492/// Collect the `base_hash` of every `0x02` delta entry in `pack_bytes`,
493/// without resolving or storing anything.
494///
495/// This lets a caller pre-fetch bases that may live OUTSIDE the pack (e.g.
496/// objects a legacy per-object remote stored individually) before calling
497/// [`PackReader::read`], so delta resolution never fails part-way through a
498/// pack. Raw entries are skipped; duplicates are de-duplicated.
499///
500/// Only the header (magic/version) and entry framing are validated — the
501/// trailer is intentionally NOT verified here, because [`PackReader::read`]
502/// re-verifies the whole pack (trailer included) before storing anything.
503///
504/// # Errors
505///
506/// Returns the same framing [`PackError`] variants as [`PackReader::read`]
507/// for a malformed header or out-of-bounds entry.
508///
509/// # Panics
510///
511/// The `try_into` calls on fixed 4-byte slices are statically guaranteed by
512/// the preceding bounds checks; they `expect`-panic only if slice-bounds
513/// elision is wrong.
514pub fn delta_base_hashes(pack_bytes: &[u8]) -> Result<Vec<Hash>, PackError> {
515    if pack_bytes.len() < HEADER_LEN + TRAILER_LEN {
516        return Err(PackError::PackfileTooShort);
517    }
518    if &pack_bytes[..4] != MAGIC.as_slice() {
519        return Err(PackError::InvalidMagic);
520    }
521    let version = u32::from_le_bytes(pack_bytes[4..8].try_into().expect("4 bytes"));
522    if version != VERSION && version != VERSION_V2 {
523        return Err(PackError::UnsupportedVersion(version));
524    }
525    let count = u32::from_le_bytes(
526        pack_bytes[ENTRY_COUNT_OFFSET..ENTRY_COUNT_OFFSET + 4]
527            .try_into()
528            .expect("4 bytes"),
529    );
530    if count > MAX_ENTRIES {
531        return Err(PackError::TooManyObjects(count));
532    }
533    // Entries live between the header and the 32-byte trailer.
534    let split = pack_bytes.len() - TRAILER_LEN;
535
536    let mut bases = Vec::new();
537    let mut seen = std::collections::HashSet::new();
538    let mut pos = HEADER_LEN;
539    for _ in 0..count {
540        if pos + ENTRY_FRAME_LEN > split {
541            return Err(PackError::UnexpectedEof);
542        }
543        let etype = pack_bytes[pos];
544        pos += 1;
545        let payload_len =
546            u32::from_le_bytes(pack_bytes[pos..pos + 4].try_into().expect("4 bytes")) as usize;
547        pos += 4;
548        if pos + payload_len > split {
549            return Err(PackError::UnexpectedEof);
550        }
551        // `0x04`'s base_hash sits at the same offset (byte 0 of the
552        // payload) as `0x02`'s and is always uncompressed
553        // (SPEC-PACKFILE §3.4), so both entry types are scanned the
554        // same way here — no decompression needed to pre-fetch bases.
555        if etype == 0x02 || etype == 0x04 {
556            if payload_len < TRAILER_LEN {
557                return Err(PackError::DeltaEntryTruncated);
558            }
559            let mut base = [0u8; 32];
560            base.copy_from_slice(&pack_bytes[pos..pos + TRAILER_LEN]);
561            if seen.insert(base) {
562                bases.push(base);
563            }
564        }
565        pos += payload_len;
566    }
567    Ok(bases)
568}
569
570/// Streaming-style packfile reader. Verifies header, trailer, entry
571/// framing, and the base-before-delta ordering rule. Reconstructs delta
572/// targets and writes every resolved object to `store`.
573#[derive(Debug)]
574pub struct PackReader;
575
576impl PackReader {
577    /// Verify and unpack `pack_bytes` into `store`. Returns counts of
578    /// raw vs. delta entries plus the list of stored hashes (in pack
579    /// order, deduped within this call).
580    ///
581    /// # Errors
582    ///
583    /// Returns the matching [`PackError`] variant on any malformed
584    /// input or trailer mismatch. The store is not modified if the
585    /// trailer fails verification.
586    ///
587    /// # Panics
588    ///
589    /// The internal `try_into` calls on fixed-size byte slices are
590    /// statically guaranteed to succeed (we slice exactly 4 bytes for
591    /// every `u32::from_le_bytes`). They `expect`-panic only if the
592    /// compiler's slice-bounds elision is wrong.
593    pub fn read(pack_bytes: &[u8], store: &ObjectStore) -> Result<UnpackReport, PackError> {
594        Self::read_with_payload_cap(pack_bytes, store, MAX_TOTAL_PAYLOAD)
595    }
596
597    /// Same as [`Pack::read`], but with a caller-supplied running-total
598    /// payload cap instead of the hardcoded [`MAX_TOTAL_PAYLOAD`] (4
599    /// GiB). `pub(crate)`, not part of the public API — test-only
600    /// injection point so `PackfileTooLarge` can be exercised without
601    /// constructing a multi-gigabyte pack. Real callers MUST use
602    /// [`Pack::read`] instead.
603    pub(crate) fn read_with_payload_cap(
604        pack_bytes: &[u8],
605        store: &ObjectStore,
606        payload_cap: u64,
607    ) -> Result<UnpackReport, PackError> {
608        Self::read_inner(pack_bytes, store, payload_cap, None)
609    }
610
611    /// Test-only variant of [`Self::read`] that also reports, via
612    /// `owned_bytes`, the total number of payload bytes it allocates
613    /// freshly (as opposed to borrowing straight from the
614    /// already-resident `pack_bytes`). Proves the streaming reader
615    /// (issue #647) never re-copies a raw entry's bytes: only delta
616    /// targets — genuinely new bytes produced by `delta::decode`, which
617    /// cannot alias `pack_bytes` — increment this counter.
618    #[cfg(test)]
619    pub(crate) fn read_tracking_owned_bytes(
620        pack_bytes: &[u8],
621        store: &ObjectStore,
622        owned_bytes: &AtomicU64,
623    ) -> Result<UnpackReport, PackError> {
624        Self::read_inner(pack_bytes, store, MAX_TOTAL_PAYLOAD, Some(owned_bytes))
625    }
626
627    fn read_inner(
628        pack_bytes: &[u8],
629        store: &ObjectStore,
630        payload_cap: u64,
631        owned_bytes: Option<&AtomicU64>,
632    ) -> Result<UnpackReport, PackError> {
633        // Steps 1-5 (length/magic/version/trailer/entry-count) live in
634        // `validate_pack_header` — pulled out purely to keep this
635        // function's entry-parsing loop under clippy's line-count cap;
636        // no behavior moves, just where it's written.
637        let (version, split, count) = validate_pack_header(pack_bytes)?;
638
639        let mut report = UnpackReport::default();
640        // Track entries resolved in *this* pack so subsequent delta
641        // entries can resolve their base from memory before falling
642        // back to the on-disk store: `WriteBatch::write_prehashed`
643        // stages bytes durably-pending but NOT visible until
644        // `commit()`, so a not-yet-committed entry can only be found
645        // here, never via `store`. Raw entries borrow straight out of
646        // `pack_bytes` — it's already resident for the whole call, so
647        // keeping a second owned copy alongside it would just double
648        // the memory a large pack needs (issue #647). Only
649        // delta-resolved entries need an owned buffer, since
650        // `delta::decode` produces bytes that don't alias `pack_bytes`.
651        let mut in_pack: std::collections::HashMap<Hash, Cow<'_, [u8]>> =
652            std::collections::HashMap::new();
653        let mut total_payload: u64 = 0;
654        let mut pos = HEADER_LEN;
655
656        // Stage each entry into the batch as soon as it's parsed and
657        // validated, rather than collecting every entry's bytes into a
658        // second list first and writing them all out after the loop.
659        // `commit()` still runs exactly once, after the loop below, so
660        // the "durable and visible together" contract (see the
661        // `WriteBatch` module docs) is unaffected — only the point at
662        // which each entry's bytes are handed to the batch moves
663        // earlier, from "after every entry is parsed" to "as each
664        // entry is parsed".
665        let batch = store.batch();
666
667        for _ in 0..count {
668            // Frame: [type][payload_len].
669            if pos + ENTRY_FRAME_LEN > split {
670                return Err(PackError::UnexpectedEof);
671            }
672            let etype = pack_bytes[pos];
673            pos += 1;
674            let payload_len =
675                u32::from_le_bytes(pack_bytes[pos..pos + 4].try_into().expect("4 bytes")) as usize;
676            pos += 4;
677
678            total_payload = total_payload.saturating_add(payload_len as u64);
679            if total_payload > payload_cap {
680                return Err(PackError::PackfileTooLarge);
681            }
682            if pos + payload_len > split {
683                return Err(PackError::UnexpectedEof);
684            }
685            let payload = &pack_bytes[pos..pos + payload_len];
686            pos += payload_len;
687
688            match etype {
689                0x00 => {
690                    // raw — validate, then stage into the batch immediately.
691                    stage_raw_object(
692                        &batch,
693                        &mut in_pack,
694                        &mut report,
695                        owned_bytes,
696                        Cow::Borrowed(payload),
697                    )?;
698                }
699                0x02 => {
700                    // delta — payload is [32B base_hash][stream].
701                    if payload.len() < hash::HASH_LEN {
702                        return Err(PackError::DeltaEntryTruncated);
703                    }
704                    let mut base_hash = [0u8; hash::HASH_LEN];
705                    base_hash.copy_from_slice(&payload[..hash::HASH_LEN]);
706                    let stream = &payload[hash::HASH_LEN..];
707                    stage_delta_target(
708                        store,
709                        &batch,
710                        &mut in_pack,
711                        &mut report,
712                        owned_bytes,
713                        base_hash,
714                        stream,
715                    )?;
716                }
717                0x03 if version == VERSION_V2 => {
718                    // zstd-raw — payload is [4B uncompressed_len][zstd frame]
719                    // (SPEC-PACKFILE §3.3). Decompress, then treat exactly
720                    // like a 0x00 raw entry.
721                    let obj_bytes = decompress_zstd_entry(payload)?;
722                    stage_raw_object(
723                        &batch,
724                        &mut in_pack,
725                        &mut report,
726                        owned_bytes,
727                        Cow::Owned(obj_bytes),
728                    )?;
729                }
730                0x04 if version == VERSION_V2 => {
731                    // zstd-delta — payload is [32B base_hash (uncompressed)]
732                    // [4B uncompressed_len][zstd frame] (SPEC-PACKFILE §3.4).
733                    if payload.len() < hash::HASH_LEN {
734                        return Err(PackError::DeltaEntryTruncated);
735                    }
736                    let mut base_hash = [0u8; hash::HASH_LEN];
737                    base_hash.copy_from_slice(&payload[..hash::HASH_LEN]);
738                    let stream = decompress_zstd_entry(&payload[hash::HASH_LEN..])?;
739                    stage_delta_target(
740                        store,
741                        &batch,
742                        &mut in_pack,
743                        &mut report,
744                        owned_bytes,
745                        base_hash,
746                        &stream,
747                    )?;
748                }
749                0x01 => return Err(PackError::InvalidEntryType(0x01)),
750                other => return Err(PackError::InvalidEntryType(other)),
751            }
752        }
753
754        if pos != split {
755            return Err(PackError::TrailingData);
756        }
757
758        // Batched durability: one full flush for the whole pack instead
759        // of one per object. The caller's ref update happens after
760        // `read` returns, so the commit-before-reference ordering holds.
761        batch.commit()?;
762
763        Ok(report)
764    }
765}
766
767/// SPEC-PACKFILE §1/§5/§8 steps 1-5: length sanity, magic, version,
768/// trailer verification (BEFORE anything touches the store), and
769/// entry-count cap. Returns `(version, split, count)` — `split` is the
770/// byte offset where the trailer begins (entries live in
771/// `pack_bytes[HEADER_LEN..split]`). Split out of
772/// [`PackReader::read_inner`] purely to keep that function's
773/// entry-parsing loop under clippy's line-count cap; no check moves,
774/// reorders, or changes behavior.
775fn validate_pack_header(pack_bytes: &[u8]) -> Result<(u32, usize, u32), PackError> {
776    // 1. Length sanity: must fit header + trailer at minimum.
777    if pack_bytes.len() < HEADER_LEN + TRAILER_LEN {
778        return Err(PackError::PackfileTooShort);
779    }
780    // 2. Magic.
781    if &pack_bytes[..4] != MAGIC.as_slice() {
782        return Err(PackError::InvalidMagic);
783    }
784    // 3. Version. v1 and v2 both decode here; only v2 packs may
785    // contain `0x03`/`0x04` entries (enforced per-entry by the caller).
786    let version = u32::from_le_bytes(pack_bytes[4..8].try_into().expect("4 bytes"));
787    if version != VERSION && version != VERSION_V2 {
788        return Err(PackError::UnsupportedVersion(version));
789    }
790    // 4. Trailer must match BEFORE we touch the store. SPEC-PACKFILE §8.
791    // Every entry parsed by the caller is staged into its batch as
792    // it's seen (not buffered up front), but that's only safe to do
793    // BECAUSE the pack's own integrity is already established here,
794    // first — a corrupt/truncated pack is rejected before a single
795    // byte is staged, so the "abort leaves the store untouched"
796    // guarantee does not depend on holding the whole pack's staged
797    // output in memory at once (see `WriteBatch`'s module docs: a
798    // dropped, uncommitted batch unlinks its temp files for free).
799    let split = pack_bytes.len() - TRAILER_LEN;
800    let body = &pack_bytes[..split];
801    let trailer = &pack_bytes[split..];
802    let computed = hash::hash(body);
803    if computed.as_slice() != trailer {
804        return Err(PackError::PackfileCorrupted);
805    }
806    // 5. Entry count + cap.
807    let count = u32::from_le_bytes(
808        pack_bytes[ENTRY_COUNT_OFFSET..ENTRY_COUNT_OFFSET + 4]
809            .try_into()
810            .expect("4 bytes"),
811    );
812    if count > MAX_ENTRIES {
813        return Err(PackError::TooManyObjects(count));
814    }
815    // Quick lower bound sanity: each entry is at least ENTRY_FRAME_LEN bytes.
816    let body_after_header = body.len() - HEADER_LEN;
817    if u64::from(count) * ENTRY_FRAME_LEN as u64 > body_after_header as u64 {
818        return Err(PackError::TooManyObjects(count));
819    }
820    Ok((version, split, count))
821}
822
823/// Validate `payload` as a canonical storable object and stage it into
824/// `batch`/`in_pack`/`report`. Shared by the `0x00` and `0x03` branches
825/// of [`PackReader::read_inner`] — they differ only in whether
826/// `payload` is a zero-copy borrow straight out of `pack_bytes` (`0x00`)
827/// or an owned buffer produced by decompression (`0x03`); `owned_bytes`
828/// is credited only for the latter (`Cow::Owned`), preserving the
829/// zero-copy accounting issue #647 established for raw entries.
830fn stage_raw_object<'p>(
831    batch: &crate::batch::WriteBatch<'_>,
832    in_pack: &mut std::collections::HashMap<Hash, Cow<'p, [u8]>>,
833    report: &mut UnpackReport,
834    owned_bytes: Option<&AtomicU64>,
835    payload: Cow<'p, [u8]>,
836) -> Result<(), PackError> {
837    let obj = validate_storable_object(&payload)?;
838    // Address by the dispatched id (merkle root for Tree/ChunkedBlob,
839    // BLAKE3 otherwise) from the object we just decoded, so the
840    // unpacked object lands under the same key every sink uses without
841    // a second decode.
842    let stored_hash = crate::object::id_from_object(&obj, &payload);
843    batch.write_prehashed(stored_hash, &[payload.as_ref()])?;
844    if let (Cow::Owned(_), Some(c)) = (&payload, owned_bytes) {
845        c.fetch_add(payload.len() as u64, Ordering::Relaxed);
846    }
847    in_pack.insert(stored_hash, payload);
848    report.raw_count += 1;
849    report.stored.push(stored_hash);
850    Ok(())
851}
852
853/// Resolve a delta's base, decode `stream` against it, and stage the
854/// reconstructed target into `batch`/`in_pack`/`report`. Shared by the
855/// `0x02` and `0x04` branches of [`PackReader::read_inner`] — `0x04`
856/// differs only in how `stream` was sourced (decompressed vs. borrowed
857/// straight from `pack_bytes`), not in how base resolution, delta
858/// decoding, or staging work.
859#[allow(clippy::too_many_arguments)]
860fn stage_delta_target(
861    store: &ObjectStore,
862    batch: &crate::batch::WriteBatch<'_>,
863    in_pack: &mut std::collections::HashMap<Hash, Cow<'_, [u8]>>,
864    report: &mut UnpackReport,
865    owned_bytes: Option<&AtomicU64>,
866    base_hash: Hash,
867    stream: &[u8],
868) -> Result<(), PackError> {
869    let resolved = resolve_delta_target(store, in_pack, base_hash, stream)?;
870    let obj = validate_storable_object(&resolved)?;
871    let stored_hash = crate::object::id_from_object(&obj, &resolved);
872    batch.write_prehashed(stored_hash, &[&resolved])?;
873    if let Some(c) = owned_bytes {
874        c.fetch_add(resolved.len() as u64, Ordering::Relaxed);
875    }
876    in_pack.insert(stored_hash, Cow::Owned(resolved));
877    report.delta_count += 1;
878    report.stored.push(stored_hash);
879    Ok(())
880}
881
882/// Resolve a delta's base (in-pack first, then on-disk store) and decode
883/// `stream` against it. Shared by the `0x02` and `0x04` branches of
884/// [`PackReader::read_inner`] — `0x04` differs only in how `stream` was
885/// sourced (decompressed vs. borrowed straight from `pack_bytes`), not
886/// in how base resolution or delta decoding work.
887fn resolve_delta_target(
888    store: &ObjectStore,
889    in_pack: &mut std::collections::HashMap<Hash, Cow<'_, [u8]>>,
890    base_hash: Hash,
891    stream: &[u8],
892) -> Result<Vec<u8>, PackError> {
893    // Resolve base: in-pack first, then on-disk. A store-resolved base
894    // is cached into `in_pack` under its own hash so a later delta
895    // entry referencing the same out-of-pack base hits the cache-hit
896    // branch above instead of paying another full read + verify +
897    // decode (#643). This is safe because `store.read` already
898    // hash-verified the bytes against `base_hash`. Cloning once here
899    // (vs. #643's original Arc::clone) is the cost of composing with
900    // #647's Cow-based `in_pack`, which trades that one-time clone for
901    // zero-copy borrows on the far more common raw-entry path — a net
902    // win, and this clone only happens once per unique out-of-pack
903    // base, not per delta entry.
904    let base_bytes: Cow<'_, [u8]> = if let Some(b) = in_pack.get(&base_hash) {
905        Cow::Borrowed(b.as_ref())
906    } else if store.contains(&base_hash) {
907        let bytes = store.read(&base_hash)?;
908        validate_storable_object(&bytes)?;
909        in_pack.insert(base_hash, Cow::Owned(bytes.clone()));
910        Cow::Owned(bytes)
911    } else {
912        return Err(PackError::DeltaBaseMissing(hash::to_hex(&base_hash)));
913    };
914    validate_delta_result_size(stream)?;
915    let resolved = delta::decode(base_bytes.as_ref(), stream)?;
916    Ok(resolved)
917}
918
919/// Decode `bytes`, enforce the size and storability invariants, and hand back
920/// the decoded [`Object`] so callers can address it without decoding twice.
921fn validate_storable_object(bytes: &[u8]) -> Result<Object, PackError> {
922    if bytes.len() > MAX_RAW_OBJECT_SIZE {
923        return Err(PackError::Store(crate::store::StoreError::ObjectTooLarge));
924    }
925    match crate::serialize::deserialize(bytes).map_err(PackError::InvalidObject)? {
926        Object::Delta(_) => Err(PackError::NonStorableObject),
927        obj @ (Object::Blob(_)
928        | Object::Tree(_)
929        | Object::Commit(_)
930        | Object::Remix(_)
931        | Object::ChunkedBlob(_)
932        | Object::Tag(_)) => Ok(obj),
933    }
934}
935
936fn validate_delta_result_size(stream: &[u8]) -> Result<(), PackError> {
937    if stream.len() < delta::HEADER_LEN {
938        return Err(PackError::DeltaApply(MkitError::UnexpectedEof));
939    }
940    let result_len = u32::from_le_bytes(stream[5..9].try_into().expect("4 bytes")) as usize;
941    if result_len > MAX_RAW_OBJECT_SIZE {
942        return Err(PackError::Store(crate::store::StoreError::ObjectTooLarge));
943    }
944    Ok(())
945}
946
947// =========================================================================
948// Tests
949// =========================================================================
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954    use tempfile::TempDir;
955
956    fn fresh_store() -> (TempDir, ObjectStore) {
957        let dir = TempDir::new().unwrap();
958        let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
959        (dir, store)
960    }
961
962    fn write_blob_via_serialize(payload: &[u8]) -> Vec<u8> {
963        // Use the serialize/object stack so the bytes are a real mkit
964        // object — important because `store.write` accepts any bytes
965        // but unpack-time delta apply produces what serialize would.
966        let blob = crate::object::Object::Blob(crate::object::Blob {
967            data: payload.to_vec(),
968        });
969        crate::serialize::serialize(&blob).expect("serialize blob")
970    }
971
972    fn finish_pack_body(mut body: Vec<u8>) -> Vec<u8> {
973        let trailer = hash::hash(&body);
974        body.extend_from_slice(&trailer);
975        body
976    }
977
978    /// Deterministic, high-entropy filler for tests that specifically
979    /// exercise UNCOMPRESSED-entry behavior (zero-copy borrows, exact
980    /// on-wire cap arithmetic) and therefore need payloads the §3.3
981    /// writer policy will decline to compress. A simple LCG byte
982    /// stream is enough: zstd's LZ+entropy stages find no exploitable
983    /// redundancy in it, unlike a repeated-byte or short-period
984    /// pattern, so `4 + compressed_len < raw_len` never holds and
985    /// these payloads stay `0x00`/`0x02` exactly as before this
986    /// change. (Payloads elsewhere in this file that use a short
987    /// repeating pattern are fine to leave as-is — those tests assert
988    /// only functional round-trip correctness, not wire-format byte
989    /// counts, so whether they end up compressed doesn't affect them.)
990    fn incompressible_bytes(seed: u64, len: usize) -> Vec<u8> {
991        let mut buf = vec![0u8; len];
992        let mut state = seed | 1; // odd seed keeps the LCG full-period
993        for chunk in buf.chunks_mut(8) {
994            state = state
995                .wrapping_mul(6_364_136_223_846_793_005)
996                .wrapping_add(1_442_695_040_888_963_407);
997            let bytes = state.to_le_bytes();
998            chunk.copy_from_slice(&bytes[..chunk.len()]);
999        }
1000        buf
1001    }
1002
1003    #[test]
1004    fn empty_pack_is_44_bytes() {
1005        let pack = PackWriter::new().finish().unwrap();
1006        assert_eq!(pack.len(), HEADER_LEN + TRAILER_LEN);
1007        assert_eq!(&pack[..4], MAGIC);
1008        assert_eq!(u32::from_le_bytes(pack[4..8].try_into().unwrap()), VERSION);
1009        assert_eq!(
1010            u32::from_le_bytes(
1011                pack[ENTRY_COUNT_OFFSET..ENTRY_COUNT_OFFSET + 4]
1012                    .try_into()
1013                    .unwrap()
1014            ),
1015            0
1016        );
1017
1018        let (_dir, store) = fresh_store();
1019        let report = PackReader::read(&pack, &store).unwrap();
1020        assert_eq!(report.raw_count, 0);
1021        assert_eq!(report.delta_count, 0);
1022        assert!(report.stored.is_empty());
1023    }
1024
1025    #[test]
1026    fn unpack_writes_objects_via_single_batch_flush() {
1027        // clone/fetch receive N objects per pack; durability must cost
1028        // O(1) full flushes per pack, not O(N).
1029        use crate::batch::testing::{Ev, RecordingSyncer};
1030        use std::sync::Arc;
1031
1032        let mut w = PackWriter::new();
1033        let mut blobs = Vec::new();
1034        for i in 0u32..30 {
1035            let blob = write_blob_via_serialize(format!("pack object {i}").as_bytes());
1036            w.push_raw(hash::hash(&blob), &blob).unwrap();
1037            blobs.push(blob);
1038        }
1039        let pack = w.finish().unwrap();
1040
1041        let (_dir, mut store) = fresh_store();
1042        let rec = Arc::new(RecordingSyncer::default());
1043        store.set_syncer(rec.clone());
1044
1045        let report = PackReader::read(&pack, &store).unwrap();
1046        assert_eq!(report.raw_count, 30);
1047
1048        let fulls = rec
1049            .events()
1050            .iter()
1051            .filter(|e| matches!(e, Ev::Full(_)))
1052            .count();
1053        assert_eq!(
1054            fulls, 2,
1055            "unpack flush cost must be constant, not O(objects)"
1056        );
1057        for blob in &blobs {
1058            assert_eq!(store.read(&hash::hash(blob)).unwrap(), *blob);
1059        }
1060    }
1061
1062    #[test]
1063    fn single_raw_roundtrip() {
1064        let blob = write_blob_via_serialize(b"hello packfile");
1065        let h = hash::hash(&blob);
1066
1067        let mut w = PackWriter::new();
1068        w.push_raw(h, &blob).unwrap();
1069        let pack = w.finish().unwrap();
1070
1071        let (_dir, store) = fresh_store();
1072        let report = PackReader::read(&pack, &store).unwrap();
1073        assert_eq!(report.raw_count, 1);
1074        assert_eq!(report.delta_count, 0);
1075        assert_eq!(report.stored, vec![h]);
1076        assert_eq!(store.read(&h).unwrap(), blob);
1077    }
1078
1079    #[test]
1080    fn total_payload_tracks_wire_sum_for_mixed_raw_and_delta() {
1081        // issue #831: push-side pack-splitting decisions read
1082        // `total_payload()` to decide when to seal a pack, so it must
1083        // track the writer's real running wire-payload sum — checked
1084        // here against both a plain raw entry and a delta entry, and
1085        // bounded by the uncompressed input sizes (compression only
1086        // ever makes the wire payload smaller, never larger).
1087        let mut w = PackWriter::new();
1088        assert_eq!(w.total_payload(), 0);
1089
1090        // Incompressible (random-ish) bytes so `maybe_compress` doesn't
1091        // shrink them — keeps the assertion exact rather than "at most".
1092        let raw = write_blob_via_serialize(&incompressible_bytes(0xA11C_E000, 2048));
1093        let raw_hash = hash::hash(&raw);
1094        w.push_raw(raw_hash, &raw).unwrap();
1095        assert_eq!(w.total_payload(), raw.len() as u64);
1096
1097        let base = write_blob_via_serialize(&incompressible_bytes(0xB0BA_1000, 2048));
1098        let base_hash = hash::hash(&base);
1099        let target = write_blob_via_serialize(&incompressible_bytes(0xC0FF_EE00, 2048));
1100        let stream = delta::encode(&base, &target).unwrap();
1101        let before_delta = w.total_payload();
1102        w.push_delta(&base_hash, &stream).unwrap();
1103        let delta_wire_len = w.total_payload() - before_delta;
1104
1105        // The writer never emits more wire bytes than the caller handed
1106        // it (delta payload = base_hash + stream, uncompressed worst
1107        // case), and `total_payload` must equal the sum of what was
1108        // actually appended so far.
1109        assert!(delta_wire_len <= (hash::HASH_LEN + stream.len()) as u64);
1110        assert_eq!(w.total_payload(), before_delta + delta_wire_len);
1111        assert!(w.total_payload() <= raw.len() as u64 + (hash::HASH_LEN + stream.len()) as u64);
1112    }
1113
1114    #[test]
1115    fn raw_then_delta_resolves_in_pack() {
1116        // Two near-identical blobs. Delta should reconstruct the second.
1117        let mut content_base = vec![0u8; 1024];
1118        for (i, b) in content_base.iter_mut().enumerate() {
1119            *b = u8::try_from(i % 251).expect("modulo < 256");
1120        }
1121        let mut content_target = content_base.clone();
1122        content_target[500] = 0xFF;
1123        content_target[501] = 0xFE;
1124
1125        let base_obj = write_blob_via_serialize(&content_base);
1126        let target_obj = write_blob_via_serialize(&content_target);
1127        let base_hash = hash::hash(&base_obj);
1128        let target_hash = hash::hash(&target_obj);
1129
1130        let stream = delta::encode(&base_obj, &target_obj).unwrap();
1131
1132        let mut w = PackWriter::new();
1133        w.push_raw(base_hash, &base_obj).unwrap();
1134        w.push_delta(&base_hash, &stream).unwrap();
1135        let pack = w.finish().unwrap();
1136
1137        let (_dir, store) = fresh_store();
1138        let report = PackReader::read(&pack, &store).unwrap();
1139        assert_eq!(report.raw_count, 1);
1140        assert_eq!(report.delta_count, 1);
1141        assert_eq!(report.stored, vec![base_hash, target_hash]);
1142        assert_eq!(store.read(&target_hash).unwrap(), target_obj);
1143    }
1144
1145    #[test]
1146    fn delta_base_hashes_lists_delta_bases_only() {
1147        // One raw blob + two deltas against two different bases. The scan
1148        // must return exactly the two (deduped) base hashes, ignoring raw.
1149        let base_a = write_blob_via_serialize(b"base alpha content here padding");
1150        let base_b = write_blob_via_serialize(b"base bravo content here padding");
1151        let ha = hash::hash(&base_a);
1152        let hb = hash::hash(&base_b);
1153        let target_a = write_blob_via_serialize(b"base alpha content here PADDED!");
1154        let target_b = write_blob_via_serialize(b"base bravo content here PADDED!");
1155        let stream_a = delta::encode(&base_a, &target_a).unwrap();
1156        let stream_b = delta::encode(&base_b, &target_b).unwrap();
1157
1158        let mut w = PackWriter::new();
1159        w.push_raw(ha, &base_a).unwrap(); // a raw entry — must be ignored
1160        w.push_delta(&ha, &stream_a).unwrap();
1161        w.push_delta(&hb, &stream_b).unwrap();
1162        w.push_delta(&ha, &stream_a).unwrap(); // duplicate base — deduped
1163        let pack = w.finish().unwrap();
1164
1165        let mut bases = delta_base_hashes(&pack).unwrap();
1166        bases.sort_unstable();
1167        let mut expected = vec![ha, hb];
1168        expected.sort_unstable();
1169        assert_eq!(bases, expected);
1170    }
1171
1172    #[test]
1173    fn delta_base_hashes_rejects_bad_magic() {
1174        let mut pack = PackWriter::new().finish().unwrap();
1175        pack[0] = b'X';
1176        assert!(matches!(
1177            delta_base_hashes(&pack),
1178            Err(PackError::InvalidMagic)
1179        ));
1180    }
1181
1182    #[test]
1183    fn rejects_raw_payload_that_is_not_canonical_object_without_store_write() {
1184        let payload = b"not a serialized mkit object".to_vec();
1185        let payload_hash = hash::hash(&payload);
1186        let mut body = Vec::new();
1187        body.extend_from_slice(MAGIC);
1188        body.extend_from_slice(&VERSION.to_le_bytes());
1189        body.extend_from_slice(&1u32.to_le_bytes());
1190        body.push(0x00);
1191        let payload_len = u32::try_from(payload.len()).unwrap();
1192        body.extend_from_slice(&payload_len.to_le_bytes());
1193        body.extend_from_slice(&payload);
1194        let pack = finish_pack_body(body);
1195
1196        let (_dir, store) = fresh_store();
1197        let err = PackReader::read(&pack, &store).unwrap_err();
1198        assert!(matches!(err, PackError::InvalidObject(_)), "got {err:?}");
1199        assert!(!store.contains(&payload_hash));
1200    }
1201
1202    #[test]
1203    fn rejects_raw_delta_object_without_store_write() {
1204        let delta = crate::object::Object::Delta(crate::object::Delta {
1205            base_hash: [0xAB; 32],
1206            result_size: 0,
1207            instructions: Vec::new(),
1208        });
1209        let payload = crate::serialize::serialize(&delta).unwrap();
1210        let payload_hash = hash::hash(&payload);
1211        let mut w = PackWriter::new();
1212        w.push_raw(payload_hash, &payload).unwrap();
1213        let pack = w.finish().unwrap();
1214
1215        let (_dir, store) = fresh_store();
1216        let err = PackReader::read(&pack, &store).unwrap_err();
1217        assert!(matches!(err, PackError::NonStorableObject), "got {err:?}");
1218        assert!(!store.contains(&payload_hash));
1219    }
1220
1221    #[test]
1222    fn rejects_delta_resolving_to_non_object_without_partial_store_write() {
1223        let base_obj = write_blob_via_serialize(b"base bytes");
1224        let base_hash = hash::hash(&base_obj);
1225        let invalid_target = b"not a serialized object".to_vec();
1226        let invalid_hash = hash::hash(&invalid_target);
1227        let stream = delta::encode(&base_obj, &invalid_target).unwrap();
1228
1229        let mut w = PackWriter::new();
1230        w.push_raw(base_hash, &base_obj).unwrap();
1231        w.push_delta(&base_hash, &stream).unwrap();
1232        let pack = w.finish().unwrap();
1233
1234        let (_dir, store) = fresh_store();
1235        let err = PackReader::read(&pack, &store).unwrap_err();
1236        assert!(matches!(err, PackError::InvalidObject(_)), "got {err:?}");
1237        assert!(!store.contains(&base_hash));
1238        assert!(!store.contains(&invalid_hash));
1239    }
1240
1241    #[test]
1242    fn rejects_delta_result_over_object_cap_without_partial_store_write() {
1243        let base_obj = write_blob_via_serialize(b"base bytes");
1244        let base_hash = hash::hash(&base_obj);
1245        let mut stream = Vec::new();
1246        stream.push(delta::STREAM_VERSION);
1247        stream.extend_from_slice(&u32::try_from(base_obj.len()).unwrap().to_le_bytes());
1248        stream.extend_from_slice(
1249            &u32::try_from(MAX_RAW_OBJECT_SIZE + 1)
1250                .unwrap()
1251                .to_le_bytes(),
1252        );
1253
1254        let mut w = PackWriter::new();
1255        w.push_raw(base_hash, &base_obj).unwrap();
1256        w.push_delta(&base_hash, &stream).unwrap();
1257        let pack = w.finish().unwrap();
1258
1259        let (_dir, store) = fresh_store();
1260        let err = PackReader::read(&pack, &store).unwrap_err();
1261        assert!(
1262            matches!(
1263                err,
1264                PackError::Store(crate::store::StoreError::ObjectTooLarge)
1265            ),
1266            "got {err:?}"
1267        );
1268        assert!(!store.contains(&base_hash));
1269    }
1270
1271    #[test]
1272    fn rejects_trailing_bytes_after_declared_entries_without_store_write() {
1273        let blob = write_blob_via_serialize(b"trailing bytes test");
1274        let blob_hash = hash::hash(&blob);
1275        let mut body = Vec::new();
1276        body.extend_from_slice(MAGIC);
1277        body.extend_from_slice(&VERSION.to_le_bytes());
1278        body.extend_from_slice(&1u32.to_le_bytes());
1279        body.push(0x00);
1280        let blob_len = u32::try_from(blob.len()).unwrap();
1281        body.extend_from_slice(&blob_len.to_le_bytes());
1282        body.extend_from_slice(&blob);
1283        body.extend_from_slice(b"junk");
1284        let pack = finish_pack_body(body);
1285
1286        let (_dir, store) = fresh_store();
1287        let err = PackReader::read(&pack, &store).unwrap_err();
1288        assert!(matches!(err, PackError::TrailingData), "got {err:?}");
1289        assert!(!store.contains(&blob_hash));
1290    }
1291
1292    #[test]
1293    fn rejects_invalid_magic() {
1294        // Use an arbitrary invalid 4-byte sequence; the rename gate
1295        // forbids spelling out the upstream pre-rename magic literally.
1296        let mut pack = PackWriter::new().finish().unwrap();
1297        pack[0] = b'X';
1298        pack[1] = b'X';
1299        pack[2] = b'X';
1300        pack[3] = b'X';
1301        let (_dir, store) = fresh_store();
1302        let err = PackReader::read(&pack, &store).unwrap_err();
1303        assert!(matches!(err, PackError::InvalidMagic));
1304    }
1305
1306    #[test]
1307    fn rejects_unknown_version() {
1308        let mut pack = PackWriter::new().finish().unwrap();
1309        // version is u32 LE at offset 4
1310        pack[4] = 99;
1311        // Corrupt trailer so the version check fires first — but
1312        // SPEC-PACKFILE §8 says trailer is checked before entries,
1313        // and we want UnsupportedVersion. Trailer check happens after
1314        // version check in our impl (see read()), so just leave the
1315        // trailer; it will fail UnsupportedVersion on byte 4.
1316        let (_dir, store) = fresh_store();
1317        let err = PackReader::read(&pack, &store).unwrap_err();
1318        assert!(matches!(err, PackError::UnsupportedVersion(99)));
1319    }
1320
1321    #[test]
1322    fn rejects_truncated_pack() {
1323        let pack = vec![b'M', b'K']; // only 2 bytes
1324        let (_dir, store) = fresh_store();
1325        let err = PackReader::read(&pack, &store).unwrap_err();
1326        assert!(matches!(err, PackError::PackfileTooShort));
1327    }
1328
1329    #[test]
1330    fn rejects_bit_flipped_trailer() {
1331        let blob = write_blob_via_serialize(b"trailer test");
1332        let h = hash::hash(&blob);
1333        let mut w = PackWriter::new();
1334        w.push_raw(h, &blob).unwrap();
1335        let mut pack = w.finish().unwrap();
1336        let last = pack.len() - 1;
1337        pack[last] ^= 0x01; // flip one bit
1338        let (_dir, store) = fresh_store();
1339        let err = PackReader::read(&pack, &store).unwrap_err();
1340        assert!(matches!(err, PackError::PackfileCorrupted));
1341    }
1342
1343    #[test]
1344    fn rejects_reserved_entry_type_0x01() {
1345        // Hand-build a pack with one entry of type 0x01.
1346        let mut buf = Vec::new();
1347        buf.extend_from_slice(MAGIC);
1348        buf.extend_from_slice(&VERSION.to_le_bytes());
1349        buf.extend_from_slice(&1u32.to_le_bytes());
1350        buf.push(0x01); // RESERVED type
1351        buf.extend_from_slice(&0u32.to_le_bytes()); // payload_len = 0
1352        let trailer = hash::hash(&buf);
1353        buf.extend_from_slice(&trailer);
1354
1355        let (_dir, store) = fresh_store();
1356        let err = PackReader::read(&buf, &store).unwrap_err();
1357        assert!(matches!(err, PackError::InvalidEntryType(0x01)));
1358    }
1359
1360    #[test]
1361    fn rejects_unknown_entry_type() {
1362        let mut buf = Vec::new();
1363        buf.extend_from_slice(MAGIC);
1364        buf.extend_from_slice(&VERSION.to_le_bytes());
1365        buf.extend_from_slice(&1u32.to_le_bytes());
1366        buf.push(0x77); // unknown
1367        buf.extend_from_slice(&0u32.to_le_bytes());
1368        let trailer = hash::hash(&buf);
1369        buf.extend_from_slice(&trailer);
1370
1371        let (_dir, store) = fresh_store();
1372        let err = PackReader::read(&buf, &store).unwrap_err();
1373        assert!(matches!(err, PackError::InvalidEntryType(0x77)));
1374    }
1375
1376    #[test]
1377    fn delta_base_missing_is_loud() {
1378        let mut fake_base = [0u8; 32];
1379        fake_base[0] = 0xAB;
1380        // Build a minimal SPEC-DELTA stream that targets a nonexistent base.
1381        let mut stream = Vec::new();
1382        stream.push(0x01); // version
1383        stream.extend_from_slice(&0u32.to_le_bytes()); // base_len
1384        stream.extend_from_slice(&0u32.to_le_bytes()); // result_len
1385        let mut w = PackWriter::new();
1386        w.push_delta(&fake_base, &stream).unwrap();
1387        let pack = w.finish().unwrap();
1388
1389        let (_dir, store) = fresh_store();
1390        let err = PackReader::read(&pack, &store).unwrap_err();
1391        assert!(matches!(err, PackError::DeltaBaseMissing(_)), "got {err:?}");
1392    }
1393
1394    #[test]
1395    fn entry_payload_past_trailer_rejected() {
1396        let mut buf = Vec::new();
1397        buf.extend_from_slice(MAGIC);
1398        buf.extend_from_slice(&VERSION.to_le_bytes());
1399        buf.extend_from_slice(&1u32.to_le_bytes());
1400        buf.push(0x00);
1401        buf.extend_from_slice(&1_000_000u32.to_le_bytes());
1402        // No payload bytes follow.
1403        let trailer = hash::hash(&buf);
1404        buf.extend_from_slice(&trailer);
1405
1406        let (_dir, store) = fresh_store();
1407        let err = PackReader::read(&buf, &store).unwrap_err();
1408        assert!(matches!(err, PackError::UnexpectedEof));
1409    }
1410
1411    #[test]
1412    fn entry_count_over_cap_rejected() {
1413        let mut buf = Vec::new();
1414        buf.extend_from_slice(MAGIC);
1415        buf.extend_from_slice(&VERSION.to_le_bytes());
1416        buf.extend_from_slice(&u32::MAX.to_le_bytes());
1417        // Add a fake trailer so trailer-check passes — wait, it can't
1418        // pass since the body is bogus. Compute it correctly so the
1419        // trailer is the not-the-failure path; then the count cap must
1420        // fire first per read() ordering.
1421        let trailer = hash::hash(&buf);
1422        buf.extend_from_slice(&trailer);
1423
1424        let (_dir, store) = fresh_store();
1425        let err = PackReader::read(&buf, &store).unwrap_err();
1426        // count cap fires after trailer verify in our impl. Either is
1427        // acceptable; assert one of them.
1428        assert!(
1429            matches!(err, PackError::TooManyObjects(_)),
1430            "expected TooManyObjects, got {err:?}"
1431        );
1432    }
1433
1434    #[test]
1435    fn payload_sum_over_cap_is_rejected_before_bounds_or_decode() {
1436        // `PackfileTooLarge` on the reader's running-payload-total is
1437        // enforced against MAX_TOTAL_PAYLOAD (4 GiB) in production —
1438        // impractical to trip directly in a unit test without
1439        // allocating gigabytes. `read_with_payload_cap` is the
1440        // test-only injection point: same check, caller-supplied cap.
1441        // Incompressible filler (not `[0xAA; 64]`/`[0xBB; 64]`, which
1442        // the §3.3 writer policy would shrink to a handful of
1443        // compressed bytes): this test's cap arithmetic below assumes
1444        // `blob_a.len()`/`blob_b.len()` ARE the on-wire sizes.
1445        let blob_a = write_blob_via_serialize(&incompressible_bytes(0xA5A5, 64));
1446        let blob_b = write_blob_via_serialize(&incompressible_bytes(0xB6B6, 64));
1447        let mut w = PackWriter::new();
1448        w.push_raw(hash::hash(&blob_a), &blob_a).unwrap();
1449        w.push_raw(hash::hash(&blob_b), &blob_b).unwrap();
1450        let pack = w.finish().unwrap();
1451
1452        let (_dir, store) = fresh_store();
1453
1454        // Cap smaller than the combined payload but big enough that
1455        // the first entry alone fits — the SECOND entry's running
1456        // total must trip the cap, not an entry-count or bounds check.
1457        let cap = (blob_a.len() as u64) + 10;
1458        let err = PackReader::read_with_payload_cap(&pack, &store, cap).unwrap_err();
1459        assert!(
1460            matches!(err, PackError::PackfileTooLarge),
1461            "expected PackfileTooLarge, got {err:?}"
1462        );
1463
1464        // Sanity: the same pack with a generous cap (the real
1465        // MAX_TOTAL_PAYLOAD) unpacks normally.
1466        let report = PackReader::read(&pack, &store).unwrap();
1467        assert_eq!(report.raw_count, 2);
1468    }
1469
1470    #[test]
1471    fn pack_key_is_blake3_of_pack_bytes() {
1472        let blob = write_blob_via_serialize(b"key test");
1473        let h = hash::hash(&blob);
1474        let mut w = PackWriter::new();
1475        w.push_raw(h, &blob).unwrap();
1476        let pack = w.finish().unwrap();
1477        assert_eq!(pack_key(&pack), hash::hash(&pack));
1478    }
1479
1480    #[test]
1481    fn unpack_does_not_recopy_raw_payloads_into_a_second_buffer() {
1482        // Issue #647: `PackReader::read` used to copy EVERY raw entry's
1483        // payload into a fresh `Arc<[u8]>` retained in `in_pack` for the
1484        // whole call — redundant given `pack_bytes` (the pack's own
1485        // bytes) is already resident in the caller's memory the whole
1486        // time. A streaming reader only needs a BORROW into
1487        // `pack_bytes` for raw entries; nothing about a raw entry's
1488        // bytes should ever be copied a second time. `owned_bytes`
1489        // tracks the exact production code path that would otherwise
1490        // do that copy (see `read_tracking_owned_bytes`), so this is a
1491        // precise, allocator-free proof rather than a fuzzy proxy.
1492        // Incompressible filler: a repeated-byte 16 KiB payload would
1493        // trip the §3.3 writer policy into emitting `0x03` zstd-raw
1494        // instead of `0x00` raw, which genuinely DOES need an owned
1495        // decompression buffer — that's not what this test is about.
1496        let mut w = PackWriter::new();
1497        for i in 0u32..64 {
1498            let payload = incompressible_bytes(0x1000_0000 + u64::from(i), 16 * 1024);
1499            let blob = write_blob_via_serialize(&payload);
1500            w.push_raw(hash::hash(&blob), &blob).unwrap();
1501        }
1502        let pack = w.finish().unwrap();
1503        assert!(
1504            pack.len() > 512 * 1024,
1505            "sanity: synthetic pack should be substantial, got {}",
1506            pack.len()
1507        );
1508        assert_eq!(
1509            u32::from_le_bytes(pack[VERSION_OFFSET..VERSION_OFFSET + 4].try_into().unwrap()),
1510            VERSION,
1511            "sanity: incompressible filler must stay an uncompressed v1 pack"
1512        );
1513
1514        let (_dir, store) = fresh_store();
1515        let owned_bytes = AtomicU64::new(0);
1516        let report = PackReader::read_tracking_owned_bytes(&pack, &store, &owned_bytes).unwrap();
1517        assert_eq!(report.raw_count, 64);
1518
1519        assert_eq!(
1520            owned_bytes.load(Ordering::Relaxed),
1521            0,
1522            "an all-raw pack must not allocate a second copy of any entry's payload"
1523        );
1524    }
1525
1526    #[test]
1527    fn unpack_owned_bytes_for_deltas_is_exactly_the_delta_targets_not_the_whole_pack() {
1528        // Complements the all-raw test above: the raw base must still
1529        // be a zero-copy borrow, and each delta's "owned" cost must be
1530        // exactly its reconstructed target size — never the base's size
1531        // too, and never the whole pack's. Incompressible base content,
1532        // same rationale as that test: a compressible base would
1533        // legitimately need an owned decompression buffer, which is
1534        // not what this test measures.
1535        let content_base = incompressible_bytes(0x2BAD_2BAD, 4096);
1536        let base_obj = write_blob_via_serialize(&content_base);
1537        let base_hash = hash::hash(&base_obj);
1538
1539        let mut w = PackWriter::new();
1540        w.push_raw(base_hash, &base_obj).unwrap();
1541        let mut expected_owned = 0u64;
1542        for i in 0u32..10 {
1543            let mut target = content_base.clone();
1544            target[i as usize] ^= 0xFF;
1545            let target_obj = write_blob_via_serialize(&target);
1546            let stream = delta::encode(&base_obj, &target_obj).unwrap();
1547            w.push_delta(&base_hash, &stream).unwrap();
1548            expected_owned += target_obj.len() as u64;
1549        }
1550        let pack = w.finish().unwrap();
1551
1552        let (_dir, store) = fresh_store();
1553        let owned_bytes = AtomicU64::new(0);
1554        let report = PackReader::read_tracking_owned_bytes(&pack, &store, &owned_bytes).unwrap();
1555        assert_eq!(report.raw_count, 1);
1556        assert_eq!(report.delta_count, 10);
1557
1558        assert_eq!(
1559            owned_bytes.load(Ordering::Relaxed),
1560            expected_owned,
1561            "owned bytes must equal exactly the sum of delta target sizes — \
1562             no extra copy of the raw base"
1563        );
1564    }
1565
1566    #[test]
1567    fn pack_writer_finish_does_not_recopy_pushed_payloads() {
1568        // Issue #647: `PackWriter::finish()` used to hold every pushed
1569        // entry in a separate `entries` list and then copy ALL of them
1570        // a second time into a freshly `Vec::with_capacity`'d output
1571        // buffer. A streaming writer appends each entry's frame
1572        // directly into the one output buffer as it's pushed, so
1573        // `finish()` itself should only ever append the 32-byte
1574        // trailer — `bytes_copied` tracks exactly that production code
1575        // path (see `finish_tracking_bytes_copied`).
1576        // Incompressible filler (see the comment in
1577        // `unpack_does_not_recopy_raw_payloads_into_a_second_buffer`):
1578        // a repeated-byte payload would shrink dramatically under the
1579        // §3.3 writer policy, invalidating the `pack.len() > 512 KiB`
1580        // sanity check below, which has nothing to do with what this
1581        // test is proving.
1582        let mut w = PackWriter::new();
1583        for i in 0u32..64 {
1584            let payload = incompressible_bytes(0x2000_0000 + u64::from(i), 16 * 1024);
1585            let blob = write_blob_via_serialize(&payload);
1586            w.push_raw(hash::hash(&blob), &blob).unwrap();
1587        }
1588        let bytes_copied = AtomicU64::new(0);
1589        let pack = w.finish_tracking_bytes_copied(&bytes_copied).unwrap();
1590        assert!(pack.len() > 512 * 1024);
1591
1592        assert_eq!(
1593            bytes_copied.load(Ordering::Relaxed),
1594            TRAILER_LEN as u64,
1595            "finish() must only append the trailer, not re-copy every pushed entry"
1596        );
1597    }
1598
1599    #[test]
1600    fn delta_resolves_against_pre_existing_store_object() {
1601        let (_dir, store) = fresh_store();
1602        // Plant the base in the store first.
1603        let mut content_base = vec![0u8; 256];
1604        for (i, b) in content_base.iter_mut().enumerate() {
1605            *b = u8::try_from(i % 251).expect("modulo < 256");
1606        }
1607        let base_obj = write_blob_via_serialize(&content_base);
1608        let base_hash = store.write(&base_obj).unwrap();
1609
1610        // Pack contains ONLY a delta; the base must be resolved from disk.
1611        let mut content_target = content_base.clone();
1612        content_target[100] = 0xAA;
1613        let target_obj = write_blob_via_serialize(&content_target);
1614        let target_hash = hash::hash(&target_obj);
1615        let stream = delta::encode(&base_obj, &target_obj).unwrap();
1616
1617        let mut w = PackWriter::new();
1618        w.push_delta(&base_hash, &stream).unwrap();
1619        let pack = w.finish().unwrap();
1620
1621        let report = PackReader::read(&pack, &store).unwrap();
1622        assert_eq!(report.delta_count, 1);
1623        assert_eq!(report.raw_count, 0);
1624        assert_eq!(store.read(&target_hash).unwrap(), target_obj);
1625    }
1626
1627    #[test]
1628    fn multiple_deltas_against_shared_external_base_read_store_once() {
1629        // Regression for #643: N deltas in one pack all referencing the
1630        // SAME out-of-pack (already-in-store) base object must resolve
1631        // that base with exactly one physical store read, not N — the
1632        // first store-resolved base should be cached into `in_pack` for
1633        // subsequent deltas to hit in memory.
1634        const N: usize = 5;
1635
1636        let (_dir, store) = fresh_store();
1637
1638        let mut content_base = vec![0u8; 512];
1639        for (i, b) in content_base.iter_mut().enumerate() {
1640            *b = u8::try_from(i % 251).expect("modulo < 256");
1641        }
1642        let base_obj = write_blob_via_serialize(&content_base);
1643        let base_hash = store.write(&base_obj).unwrap();
1644
1645        // Five distinct deltas against the one shared external base.
1646        let mut w = PackWriter::new();
1647        let mut expected_targets = Vec::new();
1648        for i in 0..N {
1649            let mut content_target = content_base.clone();
1650            content_target[100] = u8::try_from(i).unwrap();
1651            let target_obj = write_blob_via_serialize(&content_target);
1652            let target_hash = hash::hash(&target_obj);
1653            let stream = delta::encode(&base_obj, &target_obj).unwrap();
1654            w.push_delta(&base_hash, &stream).unwrap();
1655            expected_targets.push((target_hash, target_obj));
1656        }
1657        let pack = w.finish().unwrap();
1658
1659        let reads_before = store.read_call_count();
1660        let report = PackReader::read(&pack, &store).unwrap();
1661        let reads_after_for_base = store.read_call_count() - reads_before;
1662
1663        assert_eq!(report.delta_count, u32::try_from(N).unwrap());
1664        assert_eq!(
1665            reads_after_for_base, 1,
1666            "base object must be read from the store exactly once for {N} deltas sharing it, got {reads_after_for_base}"
1667        );
1668
1669        // Correctness: caching the store-resolved base must not change
1670        // the decoded result for any of the N deltas — every target
1671        // still comes out byte-identical to the uncached decode.
1672        for (target_hash, target_obj) in expected_targets {
1673            assert_eq!(store.read(&target_hash).unwrap(), target_obj);
1674        }
1675    }
1676
1677    // =====================================================================
1678    // SPEC-PACKFILE v2: zstd-compressed entries (issue #646)
1679    // =====================================================================
1680
1681    /// Highly-compressible synthetic payload: `MIN_COMPRESS_LEN` (64) is
1682    /// the writer's floor, so use something well past it — a single
1683    /// repeated byte is the easiest thing for zstd to shrink hard.
1684    fn compressible_bytes(len: usize) -> Vec<u8> {
1685        vec![0x42u8; len]
1686    }
1687
1688    #[test]
1689    #[cfg(feature = "pack-zstd")]
1690    fn compressed_raw_entry_roundtrips() {
1691        let payload = compressible_bytes(4096);
1692        let blob = write_blob_via_serialize(&payload);
1693        let h = hash::hash(&blob);
1694
1695        let mut w = PackWriter::new();
1696        w.push_raw(h, &blob).unwrap();
1697        let pack = w.finish().unwrap();
1698
1699        assert_eq!(
1700            u32::from_le_bytes(pack[VERSION_OFFSET..VERSION_OFFSET + 4].try_into().unwrap()),
1701            VERSION_V2,
1702            "a pack containing a compressed entry must be emitted as version 2"
1703        );
1704        assert_eq!(
1705            pack[HEADER_LEN], 0x03,
1706            "a highly-compressible raw payload must be emitted as 0x03 zstd-raw"
1707        );
1708
1709        let (_dir, store) = fresh_store();
1710        let report = PackReader::read(&pack, &store).unwrap();
1711        assert_eq!(report.raw_count, 1);
1712        assert_eq!(report.delta_count, 0);
1713        assert_eq!(report.stored, vec![h]);
1714        assert_eq!(
1715            store.read(&h).unwrap(),
1716            blob,
1717            "recovered object must be byte-identical to the pre-compression original"
1718        );
1719    }
1720
1721    #[test]
1722    #[cfg(feature = "pack-zstd")]
1723    fn compressed_delta_entry_roundtrips() {
1724        // Base and target share (almost) nothing, so `delta::encode`
1725        // emits a stream dominated by one big INSERT of the target's
1726        // highly-compressible content — long and repetitive enough for
1727        // the §3.3 writer policy to compress it into a 0x04 entry.
1728        let base_obj =
1729            write_blob_via_serialize(b"delta base filler bytes, not compressible-target-shaped");
1730        let base_hash = hash::hash(&base_obj);
1731        let target_content = compressible_bytes(4096);
1732        let target_obj = write_blob_via_serialize(&target_content);
1733        let target_hash = hash::hash(&target_obj);
1734        let stream = delta::encode(&base_obj, &target_obj).unwrap();
1735        assert!(
1736            stream.len() >= 64,
1737            "sanity: delta stream must clear the writer's compression-candidate floor, got {}",
1738            stream.len()
1739        );
1740
1741        let mut w = PackWriter::new();
1742        w.push_raw(base_hash, &base_obj).unwrap();
1743        w.push_delta(&base_hash, &stream).unwrap();
1744        let pack = w.finish().unwrap();
1745
1746        assert_eq!(
1747            u32::from_le_bytes(pack[VERSION_OFFSET..VERSION_OFFSET + 4].try_into().unwrap()),
1748            VERSION_V2,
1749            "a pack containing a compressed entry must be emitted as version 2"
1750        );
1751        // Walk past the first (raw base) entry's frame to find the
1752        // second entry's type byte.
1753        let base_payload_len =
1754            u32::from_le_bytes(pack[HEADER_LEN + 1..HEADER_LEN + 5].try_into().unwrap()) as usize;
1755        let second_entry_type_offset = HEADER_LEN + ENTRY_FRAME_LEN + base_payload_len;
1756        assert_eq!(
1757            pack[second_entry_type_offset], 0x04,
1758            "a highly-compressible delta stream must be emitted as 0x04 zstd-delta"
1759        );
1760
1761        let (_dir, store) = fresh_store();
1762        let report = PackReader::read(&pack, &store).unwrap();
1763        assert_eq!(report.raw_count, 1);
1764        assert_eq!(report.delta_count, 1);
1765        assert_eq!(report.stored, vec![base_hash, target_hash]);
1766        assert_eq!(
1767            store.read(&target_hash).unwrap(),
1768            target_obj,
1769            "recovered delta target must be byte-identical to the pre-compression original"
1770        );
1771    }
1772
1773    #[test]
1774    fn rejects_v2_entry_type_in_v1_pack() {
1775        // Hand-build a version-1-declared pack with one 0x03 entry —
1776        // even though 0x03's byte layout is otherwise well-formed, it
1777        // MUST be rejected because the header says version 1
1778        // (SPEC-PACKFILE §3).
1779        let mut buf = Vec::new();
1780        buf.extend_from_slice(MAGIC);
1781        buf.extend_from_slice(&VERSION.to_le_bytes()); // version = 1
1782        buf.extend_from_slice(&1u32.to_le_bytes()); // entry_count = 1
1783        buf.push(0x03);
1784        let inner_payload = 0u32.to_le_bytes(); // uncompressed_len = 0, no frame bytes
1785        buf.extend_from_slice(&u32::try_from(inner_payload.len()).unwrap().to_le_bytes());
1786        buf.extend_from_slice(&inner_payload);
1787        let pack = finish_pack_body(buf);
1788
1789        let (_dir, store) = fresh_store();
1790        let err = PackReader::read(&pack, &store).unwrap_err();
1791        assert!(
1792            matches!(err, PackError::InvalidEntryType(0x03)),
1793            "got {err:?}"
1794        );
1795    }
1796
1797    #[test]
1798    #[cfg(feature = "pack-zstd")]
1799    fn rejects_decompressed_len_mismatch() {
1800        // Build a real compressed pack, then tamper the payload so the
1801        // claimed `uncompressed_len` no longer matches what the frame
1802        // actually decompresses to.
1803        let payload = compressible_bytes(4096);
1804        let blob = write_blob_via_serialize(&payload);
1805        let h = hash::hash(&blob);
1806        let mut w = PackWriter::new();
1807        w.push_raw(h, &blob).unwrap();
1808        let mut pack = w.finish().unwrap();
1809
1810        assert_eq!(pack[HEADER_LEN], 0x03, "sanity: must be a zstd-raw entry");
1811        let len_prefix_offset = HEADER_LEN + ENTRY_FRAME_LEN;
1812        let claimed_len = u32::from_le_bytes(
1813            pack[len_prefix_offset..len_prefix_offset + 4]
1814                .try_into()
1815                .unwrap(),
1816        );
1817        // Lie about the length: claim one byte more than the frame
1818        // actually decompresses to. The trailer no longer matches the
1819        // tampered body, so recompute it (this test targets the
1820        // length-mismatch check specifically, not trailer verification,
1821        // which is already covered by `rejects_bit_flipped_trailer`).
1822        pack[len_prefix_offset..len_prefix_offset + 4]
1823            .copy_from_slice(&(claimed_len + 1).to_le_bytes());
1824        let split = pack.len() - TRAILER_LEN;
1825        let new_trailer = hash::hash(&pack[..split]);
1826        pack[split..].copy_from_slice(&new_trailer);
1827
1828        let (_dir, store) = fresh_store();
1829        let err = PackReader::read(&pack, &store).unwrap_err();
1830        assert!(
1831            matches!(err, PackError::DecompressedSizeMismatch(_, _)),
1832            "got {err:?}"
1833        );
1834        assert!(!store.contains(&h));
1835    }
1836
1837    #[test]
1838    #[cfg(feature = "pack-zstd")]
1839    fn rejects_decompressed_len_over_object_cap() {
1840        // A `0x03` entry claiming a decompressed size over
1841        // MAX_RAW_OBJECT_SIZE must be rejected before any decompression
1842        // is attempted — hand-build the entry rather than actually
1843        // producing a >1 GiB frame.
1844        let claimed_len = u32::try_from(MAX_RAW_OBJECT_SIZE + 1).unwrap();
1845        let mut buf = Vec::new();
1846        buf.extend_from_slice(MAGIC);
1847        buf.extend_from_slice(&VERSION_V2.to_le_bytes());
1848        buf.extend_from_slice(&1u32.to_le_bytes());
1849        buf.push(0x03);
1850        // Payload: [4B claimed uncompressed_len][tiny bogus "frame"].
1851        // The over-cap check must fire before the (bogus) frame is
1852        // ever touched, so its content doesn't need to be valid zstd.
1853        let mut inner = Vec::new();
1854        inner.extend_from_slice(&claimed_len.to_le_bytes());
1855        inner.extend_from_slice(&[0u8; 8]);
1856        buf.extend_from_slice(&u32::try_from(inner.len()).unwrap().to_le_bytes());
1857        buf.extend_from_slice(&inner);
1858        let pack = finish_pack_body(buf);
1859
1860        let (_dir, store) = fresh_store();
1861        let err = PackReader::read(&pack, &store).unwrap_err();
1862        assert!(
1863            matches!(err, PackError::DecompressedSizeOverCap(n) if n == claimed_len as usize),
1864            "got {err:?}"
1865        );
1866    }
1867}