Skip to main content

heddle_pack/store/pack/
streaming_builder.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Streaming pack builder for bounded-memory imports.
3//!
4//! `PackBuilder` accumulates every `(id, type, data)` tuple in memory
5//! before producing a pack. That's fine for sync-protocol packets and
6//! small batches, but the import path can produce millions of objects
7//! and would OOM on large repos.
8//!
9//! `StreamingPackBuilder` removes the in-memory buffering by:
10//!
11//! 1. **Streaming pack data to disk** as objects are added. Compression
12//!    runs per-object (the existing zstd path is non-streaming, so the
13//!    one compressed payload is held briefly in a `Vec<u8>` before
14//!    being written), but the writer never holds more than one
15//!    object's worth of data plus its `BufWriter` capacity.
16//!
17//! 2. **External sorting the index** via 512 hash-prefix bucket files
18//!    on disk (256 for `Hash` ids, 256 for `StateId` ids). Each
19//!    `add()` appends one fixed-shape `(id, offset)` record to the
20//!    bucket whose first byte matches the id's first inner byte. At
21//!    finalize, each bucket is small enough to sort in memory; the
22//!    concatenation of `Hash` buckets followed by `StateId` buckets
23//!    in byte order produces the exact same global sort `PackBuilder`
24//!    would have via `entries.sort_by_key(|e| e.id)`.
25//!
26//! ## Memory bound
27//!
28//! - Pack data on disk: streamed; only one compressed object held in
29//!   memory at a time.
30//! - Index entries in bucket buffers: at most 32 bucket files are held
31//!   open at once, each behind a default-capacity `BufWriter` (~8 KB),
32//!   so peak buffering is ~256 KB.
33//! - Sort scratch at finalize: O(largest bucket). For uniformly-
34//!   distributed BLAKE3 hashes / ULID change-ids and N total objects,
35//!   the largest bucket is ~N/256 entries ≈ 40 bytes each. Even at
36//!   100 M objects that's ~16 MB peak.
37//!
38//! Net peak memory: ~20 MB regardless of repo size, modulo the size
39//! of the largest single object (which is unavoidable while the zstd
40//! API is non-streaming).
41//!
42//! ## Trade-offs vs `PackBuilder`
43//!
44//! - **No delta encoding.** Streaming and sliding-window deltas are
45//!   incompatible — delta search needs random access to recently-
46//!   written objects. The import path runs with deltas disabled
47//!   anyway (the cost-benefit is bad on real Heddle history), so this
48//!   is a non-issue for the call site that motivated this builder.
49//! - **No path-grouped reordering.** Entries land in the order added.
50//! - **Output is a pack file at a path** rather than `(Vec<u8>, Vec<u8>)`.
51//!   Callers pair this with `objects::store::ObjectStore::install_pack_from_path`
52//!   which moves/installs the pack without copying it through RAM.
53//! - **Re-reads the pack at finalize** to compute the BLAKE3 trailer
54//!   checksum (the pack format hashes header+body, and the count goes
55//!   in the header — we patch it on finalize via seek-back, then
56//!   re-stream the body to the hasher). 2× sequential disk I/O on the
57//!   pack data is the cost of sticking with the current format. A
58//!   future format change could put the count in the footer to avoid
59//!   the second pass.
60
61use std::{
62    collections::HashSet,
63    fs::{File, OpenOptions},
64    io::{self, BufWriter, Cursor, Read, Seek, SeekFrom, Write},
65    path::PathBuf,
66};
67
68use heddle_format::compression::CompressionConfig;
69
70use super::{ObjectType, PackObjectId, PackStats, pack_container_spec, write_container_header};
71
72/// How many bytes to reserve for the compressed-size varint in the
73/// streaming path. 10 is enough to encode any `u64` (max 9 7-bit
74/// continuation bytes plus 1 terminator). After streaming we patch
75/// the placeholder with a non-canonical varint that pads to exactly
76/// this length. Only the zstd-enabled compress path uses it.
77#[cfg(feature = "zstd")]
78const CSIZE_PLACEHOLDER_LEN: usize = 10;
79use crate::{
80    object::ContentHash,
81    store::{Result, StoreError},
82};
83
84/// Number of buckets per id variant. 256 = one bucket per first byte
85/// of the inner id. We want the bucket boundaries to align with the
86/// `PackObjectId`'s `Ord` derivation (variant tag major, inner bytes
87/// minor) so the concatenated bucket output matches what
88/// `PackIndex::sort()` would have produced.
89const BUCKETS_PER_VARIANT: usize = 256;
90/// 256 each for Hash, StateId, and AnnotatedTag ids.
91const TOTAL_BUCKETS: usize = BUCKETS_PER_VARIANT * 3;
92/// Cap concurrently-open index-bucket files. macOS GUI-launched
93/// processes commonly inherit a 256-fd soft limit; imports also need
94/// room for Git pack/index files, sqlite maps, the output pack, etc.
95const MAX_OPEN_BUCKET_WRITERS: usize = 32;
96
97/// Variant indices into the `bucket_*` arrays. `Hash` ids fill the
98/// lower half (matches the variant order in `PackObjectId` which makes
99/// `Hash(_) < StateId(_)`).
100const HASH_VARIANT: usize = 0;
101const CHANGEID_VARIANT: usize = 1;
102const ANNOTATED_TAG_VARIANT: usize = 2;
103
104/// Fsync staged pack bytes after finalize flush (Wave 5 L7).
105///
106/// Production writers are [`File`]; in-memory [`Cursor`] tests no-op.
107/// Publish still re-fsyncs at `publish_file_durable` install; this closes
108/// the pre-publish window if a caller inspects staged files after finalize.
109pub trait SyncData {
110    fn sync_data_for_durability(&mut self) -> io::Result<()>;
111}
112
113impl SyncData for File {
114    fn sync_data_for_durability(&mut self) -> io::Result<()> {
115        self.sync_all()
116    }
117}
118
119impl SyncData for Cursor<Vec<u8>> {
120    fn sync_data_for_durability(&mut self) -> io::Result<()> {
121        Ok(())
122    }
123}
124
125/// Streaming pack builder. Held generic over the pack writer (`File`
126/// in production, `Cursor<Vec<u8>>` in tests).
127pub struct StreamingPackBuilder<W: Write + Read + Seek> {
128    /// Writer for the pack's `[header][body]` content. The trailer
129    /// checksum is appended to the same writer at `finalize`.
130    /// Wrapped in `Option` so `finalize` can `.take()` it out without
131    /// running afoul of the `Drop` impl's restriction on moving fields.
132    /// `None` after `finalize` succeeds.
133    pack_writer: Option<BufWriter<W>>,
134    /// Position in the pack writer where the header was written, so
135    /// we can seek back at finalize and patch the real `object_count`
136    /// into bytes 8..16.
137    header_offset: u64,
138    /// Logical append position. Avoids flushing the buffered writer before
139    /// every object just to ask the file for its current offset.
140    pack_position: u64,
141    record_count: u64,
142    object_count: u64,
143    declared_object_count: Option<u64>,
144    total_uncompressed: u64,
145    total_compressed: u64,
146    /// Compression knobs. Only consulted when the `zstd` feature is on
147    /// (`enabled` and `min_size` decide whether each entry compresses;
148    /// `level` parameterizes the encoder). Without `zstd` every entry
149    /// takes the raw branch and this field is just along for the ride.
150    #[cfg_attr(not(feature = "zstd"), allow(dead_code))]
151    compression: CompressionConfig,
152    /// Directory holding the 512 bucket files. Owned by the builder
153    /// so we can clean up on `Drop` if `finalize` is never called.
154    bucket_dir: PathBuf,
155    /// Buckets `[variant][prefix_byte]` → optional buffered file.
156    /// Lazily opened on first write and capped with LRU eviction so a
157    /// large import cannot exhaust the process fd limit.
158    bucket_writers: Vec<Option<BucketWriter>>,
159    open_bucket_writers: usize,
160    bucket_access_tick: u64,
161    bucket_paths: Vec<PathBuf>,
162    /// File path where the pack index is materialized at `finalize`.
163    /// Bytes are written incrementally as buckets are sorted, so the
164    /// index never sits in memory in its entirety.
165    index_path: PathBuf,
166    /// Set true on `finalize` so `Drop` knows the bucket dir was
167    /// already cleaned and shouldn't be removed again.
168    finalized: bool,
169    durable: bool,
170}
171
172struct BucketWriter {
173    writer: BufWriter<File>,
174    last_used: u64,
175}
176
177#[cfg(feature = "zstd")]
178struct CountingWriter<'a, W: Write> {
179    inner: &'a mut W,
180    written: u64,
181}
182
183#[cfg(feature = "zstd")]
184impl<'a, W: Write> CountingWriter<'a, W> {
185    fn new(inner: &'a mut W) -> Self {
186        Self { inner, written: 0 }
187    }
188}
189
190#[cfg(feature = "zstd")]
191impl<W: Write> Write for CountingWriter<'_, W> {
192    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
193        let written = self.inner.write(buf)?;
194        self.written = self.written.saturating_add(written as u64);
195        Ok(written)
196    }
197
198    fn flush(&mut self) -> std::io::Result<()> {
199        self.inner.flush()
200    }
201}
202
203impl<W: Write + Read + Seek + SyncData> StreamingPackBuilder<W> {
204    /// Open a streaming builder against `pack_writer`, using
205    /// `bucket_dir` for transient index buckets and writing the
206    /// finalized index to `index_path`. The bucket dir is created if
207    /// it doesn't exist; on a successful `finalize` it's removed
208    /// (along with any bucket files left in it).
209    ///
210    /// `index_path` is *not* created by `new` — opening happens at
211    /// finalize so a misconfigured caller doesn't leave an empty index
212    /// file behind on early failure. It's still recorded here so
213    /// `finalize` can write to a known location and the caller can
214    /// install the file by path.
215    ///
216    /// The `pack_writer` must support `Read` because finalize re-streams
217    /// the body to compute the trailer checksum — see the module-level
218    /// note on the format.
219    #[cfg(feature = "fs")]
220    pub fn new(
221        pack_writer: W,
222        index_path: PathBuf,
223        compression: CompressionConfig,
224        bucket_dir: PathBuf,
225    ) -> Result<Self> {
226        Self::new_inner(pack_writer, index_path, compression, bucket_dir, None, true)
227    }
228
229    /// Open a streaming builder whose final object count is known up front.
230    ///
231    /// This writes the real count into the pack header immediately, so callers
232    /// may safely stream already-flushed pack bytes before `finalize()` appends
233    /// the trailer checksum. `finalize()` still verifies that exactly this many
234    /// objects were added before producing the index.
235    #[cfg(feature = "fs")]
236    pub fn new_with_object_count(
237        pack_writer: W,
238        index_path: PathBuf,
239        compression: CompressionConfig,
240        bucket_dir: PathBuf,
241        object_count: u64,
242    ) -> Result<Self> {
243        Self::new_inner(
244            pack_writer,
245            index_path,
246            compression,
247            bucket_dir,
248            Some(object_count),
249            true,
250        )
251    }
252
253    /// Open a known-count builder for a transient transfer spool.
254    ///
255    /// The source object store remains authoritative, so these files only need
256    /// to be flushed for same-process readers; crash durability would add an
257    /// fsync to the pack, index, and directory for every unary push without
258    /// making the transfer any safer.
259    pub fn new_with_object_count_ephemeral(
260        pack_writer: W,
261        index_path: PathBuf,
262        compression: CompressionConfig,
263        bucket_dir: PathBuf,
264        object_count: u64,
265    ) -> Result<Self> {
266        Self::new_inner(
267            pack_writer,
268            index_path,
269            compression,
270            bucket_dir,
271            Some(object_count),
272            false,
273        )
274    }
275
276    fn new_inner(
277        mut pack_writer: W,
278        index_path: PathBuf,
279        compression: CompressionConfig,
280        bucket_dir: PathBuf,
281        declared_object_count: Option<u64>,
282        durable: bool,
283    ) -> Result<Self> {
284        #[cfg(feature = "fs")]
285        if durable {
286            heddle_fs_prims::fs_atomic::create_dir_all_durable(&bucket_dir)
287                .map_err(StoreError::from)?;
288        } else {
289            std::fs::create_dir_all(&bucket_dir).map_err(StoreError::from)?;
290        }
291        #[cfg(not(feature = "fs"))]
292        {
293            debug_assert!(!durable);
294            std::fs::create_dir_all(&bucket_dir).map_err(StoreError::from)?;
295        }
296        let header_offset = pack_writer.stream_position().map_err(StoreError::from)?;
297
298        // Write a placeholder header with `count = 0` unless the caller knows
299        // the count up front. The known-count path lets network senders tail
300        // flushed pack bytes before finalize without later mutating bytes they
301        // already sent.
302        let mut header_bytes = Vec::with_capacity(16);
303        write_container_header(
304            &mut header_bytes,
305            pack_container_spec(),
306            declared_object_count.unwrap_or(0),
307        );
308        pack_writer
309            .write_all(&header_bytes)
310            .map_err(StoreError::from)?;
311
312        let bucket_paths: Vec<PathBuf> = (0..TOTAL_BUCKETS)
313            .map(|i| {
314                let variant = match i / BUCKETS_PER_VARIANT {
315                    HASH_VARIANT => 'h',
316                    CHANGEID_VARIANT => 's',
317                    ANNOTATED_TAG_VARIANT => 't',
318                    _ => unreachable!("bucket variant is bounded by TOTAL_BUCKETS"),
319                };
320                let prefix = i % BUCKETS_PER_VARIANT;
321                bucket_dir.join(format!("bucket-{variant}-{prefix:02x}"))
322            })
323            .collect();
324        for path in &bucket_paths {
325            let _ = std::fs::remove_file(path);
326        }
327
328        Ok(Self {
329            pack_writer: Some(BufWriter::new(pack_writer)),
330            header_offset,
331            pack_position: header_offset + header_bytes.len() as u64,
332            record_count: 0,
333            object_count: 0,
334            declared_object_count,
335            total_uncompressed: 0,
336            total_compressed: 0,
337            compression,
338            bucket_dir,
339            bucket_writers: (0..TOTAL_BUCKETS).map(|_| None).collect(),
340            open_bucket_writers: 0,
341            bucket_access_tick: 0,
342            bucket_paths,
343            index_path,
344            finalized: false,
345            durable,
346        })
347    }
348
349    /// Flush pack bytes written so far to the underlying writer.
350    ///
351    /// Used by hosted sync's interleaved build/send path: after each complete
352    /// entry is added, the sender flushes and drains full chunks from the file.
353    pub fn flush_pack(&mut self) -> Result<()> {
354        if let Some(writer) = self.pack_writer.as_mut() {
355            writer.flush().map_err(StoreError::from)?;
356        }
357        Ok(())
358    }
359
360    /// Add an object with a content-hash id.
361    pub fn add(&mut self, hash: ContentHash, obj_type: ObjectType, data: Vec<u8>) -> Result<()> {
362        self.add_id(PackObjectId::Hash(hash), obj_type, data)
363    }
364
365    /// Add an object with an explicit id. Mirrors [`super::PackBuilder::add_id`].
366    ///
367    /// # Memory shape
368    ///
369    /// Per-entry, the only allocations are:
370    ///
371    /// - `data: Vec<u8>` (the input, owned by the caller — comes from
372    ///   gix' `find_object` and isn't ours to stream further).
373    /// - A ~40-byte stack scratch for the entry header.
374    /// - zstd's internal compression context (~128 KB constant).
375    /// - One 50-byte index-bucket entry buffered into the bucket's
376    ///   `BufWriter`.
377    ///
378    /// The compressed payload is **never materialized** as a `Vec<u8>` —
379    /// it streams directly through `zstd::stream::write::Encoder` into
380    /// the pack writer. The pack format requires a `compressed_size`
381    /// varint *before* the compressed bytes, which we don't know yet
382    /// when we write the header; we reserve a 10-byte placeholder and
383    /// seek-back to patch it after the encoder finishes. Heddle's
384    /// varint decoder accepts non-canonical encodings (it walks
385    /// continuation bits without enforcing minimum-byte form), so the
386    /// padded write decodes back to the same value any reader expects.
387    pub fn add_id(
388        &mut self,
389        id: PackObjectId,
390        obj_type: ObjectType,
391        data: impl AsRef<[u8]>,
392    ) -> Result<()> {
393        let data = data.as_ref();
394        // Compute the entry's offset relative to the header from our logical
395        // append cursor. Asking the underlying file for its position would
396        // flush the BufWriter on every object, which defeats the streaming
397        // sender's chunk-sized drain cadence.
398        let pw = self
399            .pack_writer
400            .as_mut()
401            .ok_or_else(|| StoreError::InvalidObject("pack builder is finalized".into()))?;
402        let entry_start = self.pack_position;
403        let offset = entry_start
404            .checked_sub(self.header_offset)
405            .ok_or_else(|| StoreError::InvalidObject("pack position precedes its header".into()))?;
406
407        self.total_uncompressed = self
408            .total_uncompressed
409            .checked_add(data.len() as u64)
410            .ok_or_else(|| StoreError::InvalidObject("pack decoded size overflow".into()))?;
411
412        // Phase 1: write the entry header up to (but not including) the
413        // compressed-size varint. Always small, fits in `entry_header_buf`.
414        let mut header_buf = Vec::with_capacity(40);
415        id.encode_tagged(&mut header_buf);
416        let encoded_type = if obj_type == ObjectType::AnnotatedTag {
417            ObjectType::Blob
418        } else {
419            obj_type
420        };
421        super::varint::encode_type_and_size(encoded_type, data.len() as u64, &mut header_buf);
422        pw.write_all(&header_buf).map_err(StoreError::from)?;
423        self.pack_position = self
424            .pack_position
425            .checked_add(header_buf.len() as u64)
426            .ok_or_else(|| {
427                StoreError::InvalidObject("streaming pack position overflow".to_string())
428            })?;
429        // Only consumed by the zstd-enabled streaming branch below, but
430        // we compute it here while we already have `header_buf`'s length
431        // in scope.
432        #[cfg(feature = "zstd")]
433        let csize_pos = entry_start + header_buf.len() as u64;
434
435        // Phase 2: stream the compressed payload. We branch here on
436        // whether to compress at all — for tiny objects (`< min_size`)
437        // the bulk path traditionally wrote raw bytes to skip zstd
438        // overhead, and the reader's existing `compressed_size ==
439        // uncompressed_size` heuristic in `pack_reader.rs:128` reads
440        // raw entries back unchanged. We preserve that policy.
441        // `want_compress` gates the zstd path. Even with the feature
442        // enabled we fall through to raw for tiny entries (where
443        // zstd's frame overhead dominates) or when the caller
444        // explicitly disabled compression in `CompressionConfig`.
445        // Without the `zstd` Cargo feature, every entry takes the raw
446        // branch — same fallback shape as `compress_pack_payload`.
447        let want_compress: bool;
448        #[cfg(feature = "zstd")]
449        {
450            want_compress = self.compression.enabled && data.len() >= self.compression.min_size;
451        }
452        #[cfg(not(feature = "zstd"))]
453        {
454            want_compress = false;
455        }
456        if !want_compress {
457            // Raw entry: known compressed_size = data.len(). One canonical
458            // varint + the data itself. No seek-back needed.
459            let mut csize_buf = Vec::with_capacity(10);
460            super::varint::encode_varint(data.len() as u64, &mut csize_buf);
461            pw.write_all(&csize_buf).map_err(StoreError::from)?;
462            self.pack_position = self
463                .pack_position
464                .checked_add(csize_buf.len() as u64)
465                .ok_or_else(|| {
466                    StoreError::InvalidObject("streaming pack position overflow".to_string())
467                })?;
468            pw.write_all(data).map_err(StoreError::from)?;
469            self.pack_position = self
470                .pack_position
471                .checked_add(data.len() as u64)
472                .ok_or_else(|| {
473                    StoreError::InvalidObject("streaming pack position overflow".to_string())
474                })?;
475            self.total_compressed += data.len() as u64;
476        } else {
477            #[cfg(feature = "zstd")]
478            {
479                // Streaming entry: reserve 10 bytes for compressed_size,
480                // stream-compress the payload, then seek back to patch.
481                pw.write_all(&[0u8; CSIZE_PLACEHOLDER_LEN])
482                    .map_err(StoreError::from)?;
483                self.pack_position = self
484                    .pack_position
485                    .checked_add(CSIZE_PLACEHOLDER_LEN as u64)
486                    .ok_or_else(|| {
487                        StoreError::InvalidObject("streaming pack position overflow".to_string())
488                    })?;
489                let body_start = self.pack_position;
490                let compressed_size;
491                {
492                    let mut counting = CountingWriter::new(&mut *pw);
493                    let mut enc =
494                        zstd::stream::write::Encoder::new(&mut counting, self.compression.level)
495                            .map_err(StoreError::from)?;
496                    // Pass the source size so the zstd frame's optional
497                    // Frame Content Size field is set — lets decoders
498                    // preallocate output buffers and validates that we
499                    // wrote exactly what we promised at finish().
500                    enc.set_pledged_src_size(Some(data.len() as u64))
501                        .map_err(StoreError::from)?;
502                    enc.write_all(data).map_err(StoreError::from)?;
503                    enc.finish().map_err(StoreError::from)?;
504                    compressed_size = counting.written;
505                }
506                self.pack_position =
507                    self.pack_position
508                        .checked_add(compressed_size)
509                        .ok_or_else(|| {
510                            StoreError::InvalidObject(
511                                "streaming pack position overflow".to_string(),
512                            )
513                        })?;
514                let body_end = body_start.checked_add(compressed_size).ok_or_else(|| {
515                    StoreError::InvalidObject("streaming pack position overflow".to_string())
516                })?;
517                self.total_compressed += compressed_size;
518
519                // Seek back over the placeholder, write a 10-byte
520                // non-canonical varint encoding the actual compressed_size,
521                // then seek forward to where we left off so subsequent
522                // adds append correctly.
523                let mut csize_bytes = [0u8; CSIZE_PLACEHOLDER_LEN];
524                encode_varint_padded_to_10(compressed_size, &mut csize_bytes);
525                pw.flush().map_err(StoreError::from)?;
526                let inner = pw.get_mut();
527                inner
528                    .seek(SeekFrom::Start(csize_pos))
529                    .map_err(StoreError::from)?;
530                inner.write_all(&csize_bytes).map_err(StoreError::from)?;
531                // Pack entries have no explicit compression bit: readers treat
532                // equal stored/logical lengths as raw bytes. A zstd frame can
533                // occasionally be exactly as long as its input, so replace
534                // that ambiguous frame in place with the original payload.
535                if compressed_size == data.len() as u64 {
536                    inner
537                        .seek(SeekFrom::Start(body_start))
538                        .map_err(StoreError::from)?;
539                    inner.write_all(data).map_err(StoreError::from)?;
540                }
541                inner
542                    .seek(SeekFrom::Start(body_end))
543                    .map_err(StoreError::from)?;
544            }
545            #[cfg(not(feature = "zstd"))]
546            {
547                // Unreachable: `want_compress` is forced to `false`
548                // when the `zstd` feature is off.
549                unreachable!("compression branch reached without `zstd` feature");
550            }
551        }
552
553        self.add_index_entry(id, offset)?;
554        self.record_count += 1;
555        self.object_count += 1;
556        Ok(())
557    }
558
559    /// Add one compact frame shared by several logical blob, tree, or state ids.
560    ///
561    /// `stored_data` is either the raw frame or a zstd frame whose decoded
562    /// length is `uncompressed_size`. Every id is indexed at the same physical
563    /// record; [`super::PackReader`] verifies and decodes the complete frame
564    /// before selecting the requested logical object.
565    pub fn add_shared_frame(
566        &mut self,
567        ids: &[PackObjectId],
568        obj_type: ObjectType,
569        uncompressed_size: usize,
570        stored_data: &[u8],
571    ) -> Result<()> {
572        if ids.is_empty() {
573            return Err(StoreError::InvalidObject(
574                "compact frame must contain at least one object".to_string(),
575            ));
576        }
577        if !matches!(
578            obj_type,
579            ObjectType::Blob | ObjectType::Tree | ObjectType::State
580        ) {
581            return Err(StoreError::InvalidObject(
582                "shared compact frames may contain only blobs, trees, or states".to_string(),
583            ));
584        }
585        let unique = ids.iter().copied().collect::<HashSet<_>>();
586        if unique.len() != ids.len() {
587            return Err(StoreError::InvalidObject(
588                "compact frame contains duplicate object ids".to_string(),
589            ));
590        }
591        if ids.iter().any(|id| {
592            !matches!(
593                (obj_type, id),
594                (ObjectType::Blob | ObjectType::Tree, PackObjectId::Hash(_))
595                    | (ObjectType::State, PackObjectId::StateId(_))
596            )
597        }) {
598            return Err(StoreError::InvalidObject(
599                "compact frame id kind does not match its object type".to_string(),
600            ));
601        }
602
603        let entry_start = self.pack_position;
604        let offset = entry_start
605            .checked_sub(self.header_offset)
606            .expect("header offset should precede compact frame");
607        let mut header = Vec::with_capacity(48);
608        ids[0].encode_tagged(&mut header);
609        super::varint::encode_type_and_size(obj_type, uncompressed_size as u64, &mut header);
610        super::varint::encode_varint(stored_data.len() as u64, &mut header);
611        let writer = self
612            .pack_writer
613            .as_mut()
614            .expect("add_shared_frame called after finalize");
615        writer.write_all(&header).map_err(StoreError::from)?;
616        writer.write_all(stored_data).map_err(StoreError::from)?;
617        self.pack_position = self
618            .pack_position
619            .checked_add((header.len() + stored_data.len()) as u64)
620            .ok_or_else(|| {
621                StoreError::InvalidObject("streaming pack position overflow".to_string())
622            })?;
623        self.total_uncompressed = self
624            .total_uncompressed
625            .saturating_add(uncompressed_size as u64);
626        self.total_compressed = self
627            .total_compressed
628            .saturating_add(stored_data.len() as u64);
629        self.record_count = self
630            .record_count
631            .checked_add(1)
632            .ok_or_else(|| StoreError::InvalidObject("pack record count overflow".to_string()))?;
633        for id in ids {
634            self.add_index_entry(*id, offset)?;
635        }
636        self.object_count = self
637            .object_count
638            .checked_add(ids.len() as u64)
639            .ok_or_else(|| StoreError::InvalidObject("pack object count overflow".to_string()))?;
640        Ok(())
641    }
642
643    fn add_index_entry(&mut self, id: PackObjectId, offset: u64) -> Result<()> {
644        let bucket_idx = bucket_index_for(&id);
645        let bucket = self.get_or_open_bucket(bucket_idx)?;
646        let mut entry = Vec::with_capacity(33 + 8);
647        id.encode_tagged(&mut entry);
648        entry.extend_from_slice(&offset.to_be_bytes());
649        bucket.write_all(&entry).map_err(StoreError::from)
650    }
651
652    fn get_or_open_bucket(&mut self, idx: usize) -> Result<&mut BufWriter<File>> {
653        self.bucket_access_tick = self.bucket_access_tick.wrapping_add(1);
654        let last_used = self.bucket_access_tick;
655        if self.bucket_writers[idx].is_none() {
656            if self.open_bucket_writers >= MAX_OPEN_BUCKET_WRITERS {
657                self.evict_lru_bucket()?;
658            }
659            let path = &self.bucket_paths[idx];
660            let f = OpenOptions::new()
661                .create(true)
662                .append(true)
663                .open(path)
664                .map_err(StoreError::from)?;
665            self.bucket_writers[idx] = Some(BucketWriter {
666                writer: BufWriter::new(f),
667                last_used,
668            });
669            self.open_bucket_writers += 1;
670        } else if let Some(bucket) = self.bucket_writers[idx].as_mut() {
671            bucket.last_used = last_used;
672        }
673        Ok(&mut self.bucket_writers[idx]
674            .as_mut()
675            .expect("just inserted above")
676            .writer)
677    }
678
679    fn evict_lru_bucket(&mut self) -> Result<()> {
680        let Some((idx, _)) = self
681            .bucket_writers
682            .iter()
683            .enumerate()
684            .filter_map(|(idx, bucket)| bucket.as_ref().map(|bucket| (idx, bucket.last_used)))
685            .min_by_key(|(_, last_used)| *last_used)
686        else {
687            return Ok(());
688        };
689
690        if let Some(mut bucket) = self.bucket_writers[idx].take() {
691            bucket.writer.flush().map_err(StoreError::from)?;
692            self.open_bucket_writers -= 1;
693        }
694        Ok(())
695    }
696
697    /// Close the pack: patch the header count, append the BLAKE3
698    /// trailer, build the sorted index from bucket files, and clean up
699    /// the bucket directory. Returns `(pack_writer, index_bytes,
700    /// stats)` so the caller can install the pack into its store.
701    ///
702    /// On any failure the bucket dir is left in place; rerunning the
703    /// import will overwrite stale bucket files (they're keyed by
704    /// fixed name, not content) so this isn't a correctness issue —
705    /// just a small amount of disk churn until the next clean
706    /// finalize.
707    pub fn finalize(mut self) -> Result<(W, PackStats)> {
708        // 1. Flush every bucket so reads in the next phase see all
709        //    queued entries. `flatten()` skips the never-opened slots.
710        for bucket in self.bucket_writers.iter_mut().flatten() {
711            bucket.writer.flush().map_err(StoreError::from)?;
712        }
713        // Drop the writers so the OS file handles close before we
714        // re-open the same paths for reading.
715        for slot in self.bucket_writers.iter_mut() {
716            *slot = None;
717        }
718        self.open_bucket_writers = 0;
719
720        // 2. Patch the pack header with the real object count unless the
721        //    caller declared it up front, then re-stream the [header][body]
722        //    bytes to compute the trailer checksum.
723        let bw = self
724            .pack_writer
725            .take()
726            .expect("finalize called twice — pack_writer already consumed");
727        let mut writer = bw
728            .into_inner()
729            .map_err(|e| StoreError::from(std::io::Error::other(e.to_string())))?;
730        if let Some(expected) = self.declared_object_count {
731            if expected != self.record_count {
732                return Err(StoreError::InvalidObject(format!(
733                    "streaming pack declared {expected} record(s) but added {}",
734                    self.record_count
735                )));
736            }
737        } else {
738            writer
739                .seek(SeekFrom::Start(self.header_offset))
740                .map_err(StoreError::from)?;
741            let mut header_bytes = Vec::with_capacity(16);
742            write_container_header(&mut header_bytes, pack_container_spec(), self.record_count);
743            writer.write_all(&header_bytes).map_err(StoreError::from)?;
744        }
745
746        // 3. Hash the on-disk content from header_offset to current
747        //    position (which is just past the body). One sequential
748        //    pass; the BufWriter we drained is gone so this read is
749        //    on the raw writer.
750        writer
751            .seek(SeekFrom::Start(self.header_offset))
752            .map_err(StoreError::from)?;
753        let mut hasher = blake3::Hasher::new();
754        let mut buf = vec![0u8; 64 * 1024];
755        loop {
756            let n = writer.read(&mut buf).map_err(StoreError::from)?;
757            if n == 0 {
758                break;
759            }
760            hasher.update(&buf[..n]);
761        }
762        let checksum = hasher.finalize();
763
764        // 4. Append the trailer checksum.
765        writer.seek(SeekFrom::End(0)).map_err(StoreError::from)?;
766        writer
767            .write_all(checksum.as_bytes())
768            .map_err(StoreError::from)?;
769        writer.flush().map_err(StoreError::from)?;
770        // L7: durable staged pack before return (File fsync; Cursor no-op).
771        if self.durable {
772            writer
773                .sync_data_for_durability()
774                .map_err(StoreError::from)?;
775        }
776
777        // 5. Stream the final sorted index directly to disk. We open
778        //    a `BufWriter` against `index_path`, write the index
779        //    container header (magic + version + count — count is
780        //    already known from the per-add bookkeeping), then walk
781        //    the 512 buckets in `(variant, prefix)` order, sorting
782        //    each in memory and writing entries to the file as they
783        //    come off the sort. The intermediate `PackIndex` Vec —
784        //    O(K) in the previous implementation — is gone; the
785        //    largest in-memory state is one bucket's worth of entries.
786        //    Bucket distribution is uniform via BLAKE3 so each bucket
787        //    is ~K/256 entries × ~50 bytes; even at 100M objects that's
788        //    a ~16 MB sort scratch.
789        let idx_file = File::create(&self.index_path).map_err(StoreError::from)?;
790        let mut idx_writer = BufWriter::new(idx_file);
791        write_index_header(&mut idx_writer, self.object_count)?;
792        let mut entries_written: u64 = 0;
793        for path in self.bucket_paths.iter() {
794            if !path.exists() {
795                continue;
796            }
797            let bucket_bytes = std::fs::read(path).map_err(StoreError::from)?;
798            let mut entries = decode_bucket_file(&bucket_bytes)?;
799            // Local sort by `PackObjectId` matches the global sort
800            // because all entries in a bucket share the same variant
801            // tag *and* the same first inner byte; only the remaining
802            // bytes differ between them.
803            entries.sort_by_key(|(id, _)| *id);
804            for (id, offset) in entries {
805                write_index_entry(&mut idx_writer, id, offset)?;
806                entries_written += 1;
807            }
808        }
809        idx_writer.flush().map_err(StoreError::from)?;
810        // L7: durable staged index file + parent dirent for rename/read.
811        let _idx_file = idx_writer
812            .into_inner()
813            .map_err(|e| StoreError::from(std::io::Error::other(e.to_string())))?;
814        #[cfg(feature = "fs")]
815        if self.durable {
816            _idx_file.sync_all().map_err(StoreError::from)?;
817            if let Some(parent) = self.index_path.parent() {
818                heddle_fs_prims::fs_atomic::sync_directory(parent).map_err(StoreError::from)?;
819            }
820        }
821        debug_assert_eq!(
822            entries_written, self.object_count,
823            "streaming index entry count drifted from add() count"
824        );
825
826        // 6. Clean up the bucket dir so the heddle store doesn't carry
827        //    transient artifacts. Deletion failures are non-fatal —
828        //    the dir is uniquely named per import so leftovers are at
829        //    worst stale, not corrupting.
830        for path in self.bucket_paths.iter() {
831            let _ = std::fs::remove_file(path);
832        }
833        let _ = std::fs::remove_dir(&self.bucket_dir);
834        self.finalized = true;
835
836        let stats = PackStats {
837            object_count: self.object_count,
838            total_uncompressed: self.total_uncompressed,
839            total_compressed: self.total_compressed,
840            delta_count: 0,
841            compression_ratio: if self.total_uncompressed == 0 {
842                0.0
843            } else {
844                self.total_compressed as f64 / self.total_uncompressed as f64
845            },
846        };
847
848        Ok((writer, stats))
849    }
850}
851
852/// Write the index container header to `out`. Mirrors
853/// [`PackIndex::to_bytes`]'s prefix exactly (4-byte magic, 4-byte
854/// big-endian version, 8-byte big-endian count) so the streaming and
855/// in-memory builders produce the same index container.
856fn write_index_header<W: Write>(out: &mut W, count: u64) -> Result<()> {
857    super::pack_index::index_header().write_to(out, count)
858}
859
860/// Append one `(id, offset)` index entry to `out` using the compact
861/// [`PackIndex::to_bytes`] encoding.
862fn write_index_entry<W: Write>(out: &mut W, id: PackObjectId, offset: u64) -> Result<()> {
863    let buf = super::pack_index::encode_index_entry(id, offset);
864    out.write_all(&buf).map_err(StoreError::from)
865}
866
867/// Encode a `u64` as a non-canonical 10-byte LEB128 varint. The first
868/// 9 bytes always set the continuation bit (`0x80`), the 10th never
869/// does — so the decoder reads exactly 10 bytes regardless of the
870/// value. Used by the streaming path to reserve a fixed-width
871/// placeholder for `compressed_size` before stream-compressing the
872/// payload, then patch the placeholder with the actual size after.
873///
874/// `decode_varint` ignores the canonicalness of the encoding (it
875/// walks continuation bits without checking minimum-byte form), so
876/// the value round-trips exactly. Cost is up to 9 wasted bytes per
877/// entry, ~115 KB on a 13 K-entry import — negligible relative to
878/// the pack body.
879#[cfg(feature = "zstd")]
880fn encode_varint_padded_to_10(value: u64, out: &mut [u8; 10]) {
881    let mut v = value;
882    for slot in out.iter_mut().take(9) {
883        *slot = 0x80 | ((v & 0x7F) as u8);
884        v >>= 7;
885    }
886    out[9] = (v & 0x7F) as u8;
887}
888
889impl<W: Write + Read + Seek> Drop for StreamingPackBuilder<W> {
890    fn drop(&mut self) {
891        if self.finalized {
892            return;
893        }
894        // Best-effort cleanup of bucket dir on abort. Errors here are
895        // suppressed because Drop can't propagate them.
896        for path in self.bucket_paths.iter() {
897            let _ = std::fs::remove_file(path);
898        }
899        let _ = std::fs::remove_dir(&self.bucket_dir);
900    }
901}
902
903/// Map a `PackObjectId` to one of `TOTAL_BUCKETS` buckets. The variant
904/// (Hash vs StateId) picks the upper half; the first byte of the
905/// inner id picks the slot within the half.
906fn bucket_index_for(id: &PackObjectId) -> usize {
907    match id {
908        PackObjectId::Hash(h) => HASH_VARIANT * BUCKETS_PER_VARIANT + h.as_bytes()[0] as usize,
909        PackObjectId::StateId(c) => {
910            CHANGEID_VARIANT * BUCKETS_PER_VARIANT + c.as_bytes()[0] as usize
911        }
912        PackObjectId::AnnotatedTag(hash) => {
913            ANNOTATED_TAG_VARIANT * BUCKETS_PER_VARIANT + hash.as_bytes()[0] as usize
914        }
915    }
916}
917
918/// Decode `(id, offset)` records from a bucket file. The format
919/// matches `PackObjectId::encode_tagged` followed by a u64 BE offset,
920/// repeated. Unrecognized tags or truncated trailers fail loudly —
921/// we wrote the bytes, so any corruption is a bug, not user input.
922fn decode_bucket_file(bytes: &[u8]) -> Result<Vec<(PackObjectId, u64)>> {
923    let mut out = Vec::new();
924    let mut pos = 0;
925    while pos < bytes.len() {
926        let (id, id_len) = PackObjectId::decode_tagged(&bytes[pos..])?;
927        pos += id_len;
928        if pos + 8 > bytes.len() {
929            return Err(StoreError::InvalidObject(
930                "streaming bucket entry truncated at offset".to_string(),
931            ));
932        }
933        let offset = u64::from_be_bytes(bytes[pos..pos + 8].try_into().map_err(|_| {
934            StoreError::InvalidObject("streaming bucket bad offset slice".to_string())
935        })?);
936        pos += 8;
937        out.push((id, offset));
938    }
939    Ok(out)
940}
941
942// ---------------------- Tests ----------------------
943
944#[cfg(test)]
945mod tests {
946    use std::io::Cursor;
947
948    use super::*;
949    use crate::{
950        object::StateId,
951        store::pack::{PackReader, PackStats},
952    };
953
954    fn deterministic_hash(seed: u8) -> ContentHash {
955        // Spread `seed` across the high byte so different seeds end up
956        // in different hash-prefix buckets. We don't actually want
957        // collisions in the tests that check distribution.
958        let mut bytes = [0u8; 32];
959        bytes[0] = seed;
960        for (i, b) in bytes.iter_mut().enumerate().skip(1) {
961            *b = seed.wrapping_mul(31).wrapping_add(i as u8);
962        }
963        ContentHash::from_bytes(bytes)
964    }
965
966    fn deterministic_state_id(seed: u8) -> StateId {
967        let mut bytes = [0u8; 32];
968        bytes[0] = seed;
969        for (i, b) in bytes.iter_mut().enumerate().skip(1) {
970            *b = seed.wrapping_add(i as u8 * 7);
971        }
972        StateId::from_bytes(bytes)
973    }
974
975    /// Test rig: returns the builder, the bucket dir (for cleanup
976    /// inspection), and the index path the builder will write at
977    /// finalize. The index path lives in the temp dir so it gets
978    /// auto-cleaned with `tmp`.
979    fn fresh_builder(
980        tmp: &tempfile::TempDir,
981    ) -> (StreamingPackBuilder<Cursor<Vec<u8>>>, PathBuf, PathBuf) {
982        let bucket_dir = tmp.path().join("buckets");
983        let index_path = tmp.path().join("test.idx");
984        let cursor = Cursor::new(Vec::<u8>::new());
985        let b = StreamingPackBuilder::new(
986            cursor,
987            index_path.clone(),
988            CompressionConfig::default(),
989            bucket_dir.clone(),
990        )
991        .unwrap();
992        (b, bucket_dir, index_path)
993    }
994
995    /// Finalize the builder and return `(pack_bytes, index_bytes, stats)`.
996    /// The index bytes are read back from the file the builder wrote
997    /// to — verifying that the streaming index path actually produced
998    /// readable bytes.
999    fn finalize_cursor(
1000        b: StreamingPackBuilder<Cursor<Vec<u8>>>,
1001        index_path: &std::path::Path,
1002    ) -> (Vec<u8>, Vec<u8>, PackStats) {
1003        let (cursor, stats) = b.finalize().unwrap();
1004        let index_bytes = std::fs::read(index_path).unwrap();
1005        (cursor.into_inner(), index_bytes, stats)
1006    }
1007
1008    #[test]
1009    fn empty_pack_finalizes_to_valid_zero_count_pack() {
1010        let tmp = tempfile::TempDir::new().unwrap();
1011        let (b, bucket_dir, idx_path) = fresh_builder(&tmp);
1012        let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1013
1014        assert_eq!(stats.object_count, 0);
1015        // PackReader can parse the empty pack and reports zero objects.
1016        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1017        assert!(reader.list_ids().unwrap().is_empty());
1018        // Bucket dir was removed.
1019        assert!(
1020            !bucket_dir.exists(),
1021            "bucket dir should be cleaned on successful finalize"
1022        );
1023    }
1024
1025    #[test]
1026    fn single_blob_with_hash_id_round_trips() {
1027        let tmp = tempfile::TempDir::new().unwrap();
1028        let (mut b, _, idx_path) = fresh_builder(&tmp);
1029        let hash = deterministic_hash(0x42);
1030        let payload = b"hello, streaming pack".to_vec();
1031        b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1032        let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1033
1034        assert_eq!(stats.object_count, 1);
1035        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1036        let id = PackObjectId::Hash(hash);
1037        assert!(reader.has_object(&id).unwrap());
1038        let (got_type, got_data) = reader.get_object(&id).unwrap().unwrap();
1039        assert_eq!(got_type, ObjectType::Blob);
1040        assert_eq!(got_data, payload);
1041    }
1042
1043    #[test]
1044    fn single_state_with_change_id_round_trips() {
1045        let tmp = tempfile::TempDir::new().unwrap();
1046        let (mut b, _, idx_path) = fresh_builder(&tmp);
1047        let cid = deterministic_state_id(0xa5);
1048        let payload = b"serialized-state-bytes".to_vec();
1049        b.add_id(
1050            PackObjectId::StateId(cid),
1051            ObjectType::State,
1052            payload.clone(),
1053        )
1054        .unwrap();
1055        let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1056
1057        assert_eq!(stats.object_count, 1);
1058        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1059        let id = PackObjectId::StateId(cid);
1060        let (ty, data) = reader.get_object(&id).unwrap().unwrap();
1061        assert_eq!(ty, ObjectType::State);
1062        assert_eq!(data, payload);
1063    }
1064
1065    #[test]
1066    fn shared_compact_tree_frame_reconstructs_each_indexed_object() {
1067        use crate::object::{Tree, TreeEntry};
1068
1069        let tmp = tempfile::TempDir::new().unwrap();
1070        let (mut builder, _, index_path) = fresh_builder(&tmp);
1071        let blob = deterministic_hash(0x33);
1072        let trees = vec![
1073            Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1074            Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1075        ];
1076        let ids = trees
1077            .iter()
1078            .map(|tree| PackObjectId::Hash(tree.hash()))
1079            .collect::<Vec<_>>();
1080        let frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1081        builder
1082            .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1083            .unwrap();
1084        let (pack, index, stats) = finalize_cursor(builder, &index_path);
1085        let reader = PackReader::from_bytes(pack, index).unwrap();
1086
1087        assert_eq!(stats.object_count, 2);
1088        assert_eq!(
1089            reader.encoded_payload_bytes(ObjectType::Tree).unwrap(),
1090            frame.len() as u64
1091        );
1092        let hosted = ids
1093            .iter()
1094            .zip(&trees)
1095            .map(|(id, tree)| {
1096                (
1097                    *id,
1098                    ObjectType::Tree,
1099                    tree.encode_canonical().unwrap().len() as u64,
1100                )
1101            })
1102            .collect::<Vec<_>>();
1103        assert!(
1104            reader
1105                .copy_hosted_encoded_subset(&hosted)
1106                .unwrap()
1107                .is_none(),
1108            "repository-local compact frames must use the hosted fallback"
1109        );
1110        for (id, tree) in ids.iter().zip(&trees) {
1111            let (object_type, bytes) = reader.get_object(id).unwrap().unwrap();
1112            assert_eq!(object_type, ObjectType::Tree);
1113            assert_eq!(bytes, tree.encode_canonical().unwrap());
1114        }
1115    }
1116
1117    #[test]
1118    fn compact_tree_extraction_rejects_an_index_alias_with_the_wrong_typed_hash() {
1119        use crate::object::{Tree, TreeEntry};
1120
1121        let tmp = tempfile::TempDir::new().unwrap();
1122        let (mut builder, _, index_path) = fresh_builder(&tmp);
1123        let blob = deterministic_hash(0x34);
1124        let trees = vec![
1125            Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1126            Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1127        ];
1128        let wrong_hash = ContentHash::compute_typed("tree", b"not the second tree");
1129        assert_ne!(wrong_hash, trees[1].hash());
1130        let ids = vec![
1131            PackObjectId::Hash(trees[0].hash()),
1132            PackObjectId::Hash(wrong_hash),
1133        ];
1134        let frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1135        builder
1136            .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1137            .unwrap();
1138        let (pack, index, _) = finalize_cursor(builder, &index_path);
1139        let reader = PackReader::from_bytes(pack, index).unwrap();
1140
1141        let error = reader.get_object(&ids[1]).unwrap_err();
1142        assert!(
1143            error
1144                .to_string()
1145                .contains("does not contain indexed object"),
1146            "extraction must derive and verify the tree's typed hash: {error}"
1147        );
1148    }
1149
1150    #[test]
1151    fn shared_lineage_blob_frame_reconstructs_each_indexed_object() {
1152        let tmp = tempfile::TempDir::new().unwrap();
1153        let (mut builder, _, index_path) = fresh_builder(&tmp);
1154        let bodies = [b"newest version".as_slice(), b"older version".as_slice()];
1155        let ids = bodies
1156            .iter()
1157            .map(|body| PackObjectId::Hash(ContentHash::compute_typed("blob", body)))
1158            .collect::<Vec<_>>();
1159        let frame = heddle_object_model::compact::encode_blob_frame(&bodies).unwrap();
1160        builder
1161            .add_shared_frame(&ids, ObjectType::Blob, frame.len(), &frame)
1162            .unwrap();
1163        let (pack, index, stats) = finalize_cursor(builder, &index_path);
1164        let reader = PackReader::from_bytes(pack, index).unwrap();
1165
1166        assert_eq!(stats.object_count, bodies.len() as u64);
1167        assert_eq!(
1168            reader.encoded_payload_bytes(ObjectType::Blob).unwrap(),
1169            frame.len() as u64
1170        );
1171        for (id, expected) in ids.iter().zip(bodies) {
1172            let (object_type, actual) = reader.get_object(id).unwrap().unwrap();
1173            assert_eq!(object_type, ObjectType::Blob);
1174            assert_eq!(actual, expected);
1175            let PackObjectId::Hash(hash) = id else {
1176                unreachable!("blob ids are hashes")
1177            };
1178            assert_eq!(
1179                reader.get_hashed_object_type(hash).unwrap(),
1180                Some(ObjectType::Blob)
1181            );
1182            assert_eq!(
1183                reader.get_hashed_object_size(hash).unwrap(),
1184                Some(expected.len() as u64)
1185            );
1186        }
1187    }
1188
1189    #[test]
1190    fn ordinary_blob_starting_with_frame_magic_remains_ordinary() {
1191        let tmp = tempfile::TempDir::new().unwrap();
1192        let (mut builder, _, index_path) = fresh_builder(&tmp);
1193        let body = b"HCB2 arbitrary user content".to_vec();
1194        let hash = ContentHash::compute_typed("blob", &body);
1195        builder.add(hash, ObjectType::Blob, body.clone()).unwrap();
1196        let (pack, index, _) = finalize_cursor(builder, &index_path);
1197        let reader = PackReader::from_bytes(pack, index).unwrap();
1198
1199        assert_eq!(
1200            reader.get_object(&PackObjectId::Hash(hash)).unwrap(),
1201            Some((ObjectType::Blob, body))
1202        );
1203    }
1204
1205    #[test]
1206    fn corrupt_compact_frame_byte_invalidates_every_contained_object() {
1207        use crate::object::{Tree, TreeEntry};
1208
1209        let tmp = tempfile::TempDir::new().unwrap();
1210        let (mut builder, _, index_path) = fresh_builder(&tmp);
1211        let blob = deterministic_hash(0x44);
1212        let trees = vec![
1213            Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1214            Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1215        ];
1216        let ids = trees
1217            .iter()
1218            .map(|tree| PackObjectId::Hash(tree.hash()))
1219            .collect::<Vec<_>>();
1220        let mut frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1221        let corrupt_at = frame.len() / 2;
1222        frame[corrupt_at] ^= 0x01;
1223        builder
1224            .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1225            .unwrap();
1226        let (pack, index, _) = finalize_cursor(builder, &index_path);
1227        let reader = PackReader::from_bytes(pack, index).unwrap();
1228
1229        for id in ids {
1230            let error = reader.get_object(&id).unwrap_err();
1231            assert!(
1232                error
1233                    .to_string()
1234                    .contains("compact frame checksum mismatch"),
1235                "unexpected error for {id:?}: {error}"
1236            );
1237        }
1238    }
1239
1240    #[test]
1241    fn corrupt_blob_frame_byte_invalidates_every_contained_object() {
1242        let tmp = tempfile::TempDir::new().unwrap();
1243        let (mut builder, _, index_path) = fresh_builder(&tmp);
1244        let bodies = [b"newest version".as_slice(), b"older version".as_slice()];
1245        let ids = bodies
1246            .iter()
1247            .map(|body| PackObjectId::Hash(ContentHash::compute_typed("blob", body)))
1248            .collect::<Vec<_>>();
1249        let mut frame = heddle_object_model::compact::encode_blob_frame(&bodies).unwrap();
1250        let corrupt_at = frame.len() / 2;
1251        frame[corrupt_at] ^= 0x01;
1252        builder
1253            .add_shared_frame(&ids, ObjectType::Blob, frame.len(), &frame)
1254            .unwrap();
1255        let (pack, index, _) = finalize_cursor(builder, &index_path);
1256        let reader = PackReader::from_bytes(pack, index).unwrap();
1257
1258        for id in ids {
1259            let error = reader.get_object(&id).unwrap_err();
1260            assert!(
1261                error
1262                    .to_string()
1263                    .contains("compact frame checksum mismatch"),
1264                "unexpected error for {id:?}: {error}"
1265            );
1266        }
1267    }
1268
1269    #[test]
1270    fn mixed_hash_and_changeid_ids_all_retrievable() {
1271        let tmp = tempfile::TempDir::new().unwrap();
1272        let (mut b, _, idx_path) = fresh_builder(&tmp);
1273        let blob_hash = deterministic_hash(0x10);
1274        let tree_hash = deterministic_hash(0x20);
1275        let state_cid = deterministic_state_id(0x80);
1276
1277        b.add(blob_hash, ObjectType::Blob, b"blob-bytes".to_vec())
1278            .unwrap();
1279        b.add(tree_hash, ObjectType::Tree, b"serialized-tree".to_vec())
1280            .unwrap();
1281        b.add_id(
1282            PackObjectId::StateId(state_cid),
1283            ObjectType::State,
1284            b"serialized-state",
1285        )
1286        .unwrap();
1287
1288        let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1289        assert_eq!(stats.object_count, 3);
1290        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1291        assert_eq!(
1292            reader
1293                .get_object(&PackObjectId::Hash(blob_hash))
1294                .unwrap()
1295                .unwrap()
1296                .1,
1297            b"blob-bytes".to_vec()
1298        );
1299        assert_eq!(
1300            reader
1301                .get_object(&PackObjectId::Hash(tree_hash))
1302                .unwrap()
1303                .unwrap()
1304                .1,
1305            b"serialized-tree".to_vec()
1306        );
1307        assert_eq!(
1308            reader
1309                .get_object(&PackObjectId::StateId(state_cid))
1310                .unwrap()
1311                .unwrap()
1312                .1,
1313            b"serialized-state".to_vec()
1314        );
1315    }
1316
1317    #[test]
1318    fn ten_thousand_objects_round_trip_correctly() {
1319        // Stresses the bucket sort: 10K objects spread across
1320        // 256 hash buckets averages 40 entries per bucket — well
1321        // within in-memory sort capacity but covers every bucket.
1322        let tmp = tempfile::TempDir::new().unwrap();
1323        let (mut b, _, idx_path) = fresh_builder(&tmp);
1324        let mut hashes = Vec::with_capacity(10_000);
1325        for i in 0..10_000u32 {
1326            // Use BLAKE3 over the index so first-byte distribution is
1327            // pseudo-uniform across the 256 hash buckets.
1328            let h = blake3::hash(&i.to_le_bytes());
1329            let hash = ContentHash::from_bytes(*h.as_bytes());
1330            hashes.push(hash);
1331            b.add(hash, ObjectType::Blob, format!("payload-{i}").into_bytes())
1332                .unwrap();
1333        }
1334        let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1335        assert_eq!(stats.object_count, 10_000);
1336
1337        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1338        assert_eq!(reader.list_ids().unwrap().len(), 10_000);
1339        // Spot-check ten across the range.
1340        for i in [0, 1, 99, 1234, 5_000, 9_999] {
1341            let id = PackObjectId::Hash(hashes[i]);
1342            let (_ty, data) = reader.get_object(&id).unwrap().unwrap();
1343            assert_eq!(data, format!("payload-{i}").into_bytes());
1344        }
1345    }
1346
1347    #[test]
1348    fn bucket_writers_are_lru_capped_below_fd_limit() {
1349        let tmp = tempfile::TempDir::new().unwrap();
1350        let (mut b, _bucket_dir, idx_path) = fresh_builder(&tmp);
1351        let mut ids = Vec::new();
1352
1353        for i in 0..BUCKETS_PER_VARIANT {
1354            let hash = deterministic_hash(i as u8);
1355            ids.push(PackObjectId::Hash(hash));
1356            b.add(hash, ObjectType::Blob, format!("hash-{i}").into_bytes())
1357                .unwrap();
1358            assert!(
1359                b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1360                "open bucket writers should stay capped"
1361            );
1362        }
1363
1364        for i in 0..BUCKETS_PER_VARIANT {
1365            let cid = deterministic_state_id(i as u8);
1366            ids.push(PackObjectId::StateId(cid));
1367            b.add_id(
1368                PackObjectId::StateId(cid),
1369                ObjectType::State,
1370                format!("state-{i}").into_bytes(),
1371            )
1372            .unwrap();
1373            assert!(
1374                b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1375                "open bucket writers should stay capped"
1376            );
1377        }
1378
1379        for i in 0..BUCKETS_PER_VARIANT {
1380            let hash = deterministic_hash(i as u8);
1381            let id = PackObjectId::AnnotatedTag(hash);
1382            ids.push(id);
1383            b.add_id(
1384                id,
1385                ObjectType::AnnotatedTag,
1386                format!("tag-{i}").into_bytes(),
1387            )
1388            .unwrap();
1389            assert!(
1390                b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1391                "open bucket writers should stay capped"
1392            );
1393        }
1394
1395        let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1396        assert_eq!(stats.object_count, TOTAL_BUCKETS as u64);
1397        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1398        for id in ids {
1399            assert!(reader.has_object(&id).unwrap(), "missing id {id:?}");
1400        }
1401    }
1402
1403    #[test]
1404    fn index_id_sort_order_matches_packbuilder_output() {
1405        // PackBuilder groups objects by `ObjectType` before encoding,
1406        // which changes the byte offsets relative to a streaming builder
1407        // that writes in added-order. The bytes of the two indices
1408        // therefore can't match exactly. What MUST match is the
1409        // **sort order of ids** — both builders ultimately call
1410        // `PackIndex::sort()` (or the bucket-equivalent), and any
1411        // reader binary-searches against that order.
1412        use crate::store::pack::PackBuilder;
1413        let payloads: Vec<(PackObjectId, ObjectType, Vec<u8>)> = (0..200u32)
1414            .map(|i| {
1415                let h = blake3::hash(&i.to_le_bytes());
1416                (
1417                    PackObjectId::Hash(ContentHash::from_bytes(*h.as_bytes())),
1418                    if i % 3 == 0 {
1419                        ObjectType::Tree
1420                    } else {
1421                        ObjectType::Blob
1422                    },
1423                    format!("body-{i}").into_bytes(),
1424                )
1425            })
1426            .collect();
1427
1428        // Disable delta encoding so the classic builder produces a pack
1429        // shape comparable to the streaming one (which never deltas).
1430        let compression = CompressionConfig {
1431            max_delta_size: 0,
1432            ..CompressionConfig::default()
1433        };
1434        let mut classic = PackBuilder::new(compression);
1435        for (id, ty, data) in payloads.iter() {
1436            classic.add_id(*id, *ty, data.clone());
1437        }
1438        let (classic_pack, classic_index, _) = classic.build().unwrap();
1439        let classic_reader = PackReader::from_bytes(classic_pack, classic_index).unwrap();
1440
1441        let tmp = tempfile::TempDir::new().unwrap();
1442        let bucket_dir = tmp.path().join("buckets");
1443        let idx_path = tmp.path().join("test.idx");
1444        let cursor = Cursor::new(Vec::<u8>::new());
1445        let mut streaming =
1446            StreamingPackBuilder::new(cursor, idx_path.clone(), compression, bucket_dir).unwrap();
1447        for (id, ty, data) in payloads.iter() {
1448            streaming.add_id(*id, *ty, data.clone()).unwrap();
1449        }
1450        let (streaming_pack, streaming_index, _) = finalize_cursor(streaming, &idx_path);
1451        let streaming_reader = PackReader::from_bytes(streaming_pack, streaming_index).unwrap();
1452
1453        // Same set of ids in the same sorted order — that's the
1454        // contract for binary search to work.
1455        assert_eq!(
1456            streaming_reader.list_ids().unwrap(),
1457            classic_reader.list_ids().unwrap(),
1458            "streaming and classic indices should report the same id sequence"
1459        );
1460        // Spot-check that each id resolves to a payload that matches
1461        // the classic builder's output (equal bytes after decompression).
1462        for (id, _ty, want) in payloads.iter().take(10).chain(payloads.iter().skip(190)) {
1463            let (_, got) = streaming_reader.get_object(id).unwrap().unwrap();
1464            assert_eq!(&got, want);
1465            let (_, classic_got) = classic_reader.get_object(id).unwrap().unwrap();
1466            assert_eq!(got, classic_got);
1467        }
1468    }
1469
1470    #[test]
1471    fn corrupted_pack_fails_checksum_verification() {
1472        let tmp = tempfile::TempDir::new().unwrap();
1473        let (mut b, _, idx_path) = fresh_builder(&tmp);
1474        b.add(
1475            deterministic_hash(0x01),
1476            ObjectType::Blob,
1477            b"some bytes".to_vec(),
1478        )
1479        .unwrap();
1480        let (mut pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1481        // Flip one byte in the body. The trailer checksum must reject.
1482        let body_byte = 18; // past the 16-byte header
1483        pack_data[body_byte] ^= 0xff;
1484        let result = PackReader::from_bytes(pack_data, index_data);
1485        assert!(
1486            result.is_err(),
1487            "PackReader should reject pack with mutated body"
1488        );
1489    }
1490
1491    #[test]
1492    fn pack_count_in_header_matches_index_entry_count() {
1493        let tmp = tempfile::TempDir::new().unwrap();
1494        let (mut b, _, idx_path) = fresh_builder(&tmp);
1495        for i in 0..7u8 {
1496            b.add(
1497                deterministic_hash(i),
1498                ObjectType::Blob,
1499                format!("p{i}").into_bytes(),
1500            )
1501            .unwrap();
1502        }
1503        let (pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1504        // Header count is bytes 8..16 (big-endian).
1505        let count = u64::from_be_bytes(pack_data[8..16].try_into().unwrap());
1506        assert_eq!(count, 7);
1507        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1508        assert_eq!(reader.list_ids().unwrap().len(), 7);
1509    }
1510
1511    #[test]
1512    fn declared_pack_count_is_written_before_finalize() {
1513        let tmp = tempfile::TempDir::new().unwrap();
1514        let bucket_dir = tmp.path().join("buckets");
1515        let idx_path = tmp.path().join("test.idx");
1516        let cursor = Cursor::new(Vec::<u8>::new());
1517        let mut b = StreamingPackBuilder::new_with_object_count(
1518            cursor,
1519            idx_path.clone(),
1520            CompressionConfig::default(),
1521            bucket_dir,
1522            2,
1523        )
1524        .unwrap();
1525
1526        b.flush_pack().unwrap();
1527        let initial = b.pack_writer.as_ref().unwrap().get_ref().get_ref().clone();
1528        assert_eq!(u64::from_be_bytes(initial[8..16].try_into().unwrap()), 2);
1529
1530        let hash = deterministic_hash(0x40);
1531        b.add(hash, ObjectType::Blob, b"known-count-entry".to_vec())
1532            .unwrap();
1533        b.flush_pack().unwrap();
1534        let after_add = b.pack_writer.as_ref().unwrap().get_ref().get_ref().clone();
1535        assert_eq!(u64::from_be_bytes(after_add[8..16].try_into().unwrap()), 2);
1536
1537        let second_hash = deterministic_hash(0x41);
1538        b.add(second_hash, ObjectType::Blob, b"second-entry".to_vec())
1539            .unwrap();
1540        let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1541
1542        assert_eq!(stats.object_count, 2);
1543        assert_eq!(u64::from_be_bytes(pack_data[8..16].try_into().unwrap()), 2);
1544        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1545        assert!(reader.has_object(&PackObjectId::Hash(hash)).unwrap());
1546        assert!(reader.has_object(&PackObjectId::Hash(second_hash)).unwrap());
1547    }
1548
1549    #[test]
1550    fn declared_pack_count_mismatch_fails_finalize() {
1551        let tmp = tempfile::TempDir::new().unwrap();
1552        let bucket_dir = tmp.path().join("buckets");
1553        let idx_path = tmp.path().join("test.idx");
1554        let cursor = Cursor::new(Vec::<u8>::new());
1555        let mut b = StreamingPackBuilder::new_with_object_count(
1556            cursor,
1557            idx_path,
1558            CompressionConfig::default(),
1559            bucket_dir,
1560            2,
1561        )
1562        .unwrap();
1563
1564        b.add(
1565            deterministic_hash(0x50),
1566            ObjectType::Blob,
1567            b"only-entry".to_vec(),
1568        )
1569        .unwrap();
1570        let error = b.finalize().unwrap_err();
1571
1572        assert!(
1573            error
1574                .to_string()
1575                .contains("streaming pack declared 2 record(s) but added 1")
1576        );
1577    }
1578
1579    #[test]
1580    fn bucket_files_are_cleaned_on_successful_finalize() {
1581        let tmp = tempfile::TempDir::new().unwrap();
1582        let bucket_dir = tmp.path().join("buckets");
1583        let idx_path = tmp.path().join("test.idx");
1584        let cursor = Cursor::new(Vec::<u8>::new());
1585        let mut b = StreamingPackBuilder::new(
1586            cursor,
1587            idx_path.clone(),
1588            CompressionConfig::default(),
1589            bucket_dir.clone(),
1590        )
1591        .unwrap();
1592        for i in 0..50u8 {
1593            b.add(deterministic_hash(i), ObjectType::Blob, vec![i; 32])
1594                .unwrap();
1595        }
1596        // Buckets exist and contain data.
1597        assert!(bucket_dir.exists());
1598        let bucket_count = std::fs::read_dir(&bucket_dir).unwrap().count();
1599        assert!(bucket_count > 0, "bucket dir should hold some files");
1600        let _ = finalize_cursor(b, &idx_path);
1601        assert!(
1602            !bucket_dir.exists(),
1603            "bucket dir should be removed on finalize"
1604        );
1605    }
1606
1607    #[test]
1608    fn bucket_files_are_cleaned_on_drop_without_finalize() {
1609        let tmp = tempfile::TempDir::new().unwrap();
1610        let bucket_dir = tmp.path().join("buckets");
1611        let idx_path = tmp.path().join("test.idx");
1612        {
1613            let cursor = Cursor::new(Vec::<u8>::new());
1614            let mut b = StreamingPackBuilder::new(
1615                cursor,
1616                idx_path.clone(),
1617                CompressionConfig::default(),
1618                bucket_dir.clone(),
1619            )
1620            .unwrap();
1621            for i in 0..10u8 {
1622                b.add(deterministic_hash(i), ObjectType::Blob, vec![0; 32])
1623                    .unwrap();
1624            }
1625            assert!(bucket_dir.exists());
1626            // Drop without finalize — Drop impl should clean up.
1627        }
1628        assert!(
1629            !idx_path.exists(),
1630            "no index file should have been created without finalize"
1631        );
1632        assert!(
1633            !bucket_dir.exists(),
1634            "bucket dir should be removed on Drop when finalize never ran"
1635        );
1636    }
1637
1638    #[test]
1639    fn large_blob_streams_to_disk_without_double_buffering() {
1640        // 4 MiB blob — well under the actual streaming target but big
1641        // enough to confirm we're not buffering the entire pack body in
1642        // RAM. The pack data on disk should be at least 4 MiB; the
1643        // builder's in-memory state is per-object only.
1644        let tmp = tempfile::TempDir::new().unwrap();
1645        let bucket_dir = tmp.path().join("buckets");
1646        let pack_path = tmp.path().join("pack.dat");
1647        let idx_path = tmp.path().join("pack.idx");
1648        let file = std::fs::OpenOptions::new()
1649            .read(true)
1650            .write(true)
1651            .create(true)
1652            .truncate(true)
1653            .open(&pack_path)
1654            .unwrap();
1655        let mut b = StreamingPackBuilder::new(
1656            file,
1657            idx_path.clone(),
1658            CompressionConfig::default(),
1659            bucket_dir,
1660        )
1661        .unwrap();
1662        let payload: Vec<u8> = (0..4 * 1024 * 1024u32).map(|i| (i & 0xff) as u8).collect();
1663        let hash = deterministic_hash(0xff);
1664        b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1665        let (_, stats) = b.finalize().unwrap();
1666        let index_data = std::fs::read(&idx_path).unwrap();
1667        assert_eq!(stats.object_count, 1);
1668        let pack_bytes = std::fs::read(&pack_path).unwrap();
1669        // Pack on disk holds the whole compressed payload + headers
1670        // + trailer. Confirm it round-trips.
1671        let reader = PackReader::from_bytes(pack_bytes, index_data).unwrap();
1672        let (_ty, got) = reader
1673            .get_object(&PackObjectId::Hash(hash))
1674            .unwrap()
1675            .unwrap();
1676        assert_eq!(got, payload);
1677    }
1678
1679    #[test]
1680    fn bucket_distribution_for_random_hashes_is_roughly_uniform() {
1681        // Confirms our sort-time peak memory bound. We accumulate
1682        // 1024 random hashes through a builder and check that no
1683        // single bucket holds more than ~3× the average. (BLAKE3 hash
1684        // first-byte distribution is uniform; this is mostly a
1685        // sanity check that we route to the right bucket and aren't
1686        // accidentally collapsing.)
1687        let tmp = tempfile::TempDir::new().unwrap();
1688        let bucket_dir = tmp.path().join("buckets");
1689        let idx_path = tmp.path().join("test.idx");
1690        let cursor = Cursor::new(Vec::<u8>::new());
1691        let mut b = StreamingPackBuilder::new(
1692            cursor,
1693            idx_path.clone(),
1694            CompressionConfig::default(),
1695            bucket_dir.clone(),
1696        )
1697        .unwrap();
1698        for i in 0..1024u32 {
1699            let h = blake3::hash(&i.to_le_bytes());
1700            let hash = ContentHash::from_bytes(*h.as_bytes());
1701            b.add(hash, ObjectType::Blob, b"x".to_vec()).unwrap();
1702        }
1703        // Inspect bucket file sizes BEFORE finalize (which deletes them).
1704        b.pack_writer.as_mut().unwrap().flush().unwrap();
1705        let mut max_entries = 0usize;
1706        // Bucket files use the temporary tagged ID encoding; only the
1707        // finalized index folds the tag into the offset.
1708        let entry_size = 33 + 8;
1709        for path in b.bucket_paths.iter() {
1710            if path.exists() {
1711                let size = std::fs::metadata(path).unwrap().len() as usize;
1712                let entries = size / entry_size;
1713                if entries > max_entries {
1714                    max_entries = entries;
1715                }
1716            }
1717        }
1718        // Average is 1024 / 256 = 4 entries per bucket. Allow up to 16
1719        // (4× average) — uniformity isn't perfect on small samples.
1720        assert!(
1721            max_entries <= 16,
1722            "max bucket has {max_entries} entries; uniform expected ~4"
1723        );
1724        let _ = finalize_cursor(b, &idx_path);
1725    }
1726
1727    #[test]
1728    fn finalize_returns_correct_stats() {
1729        let tmp = tempfile::TempDir::new().unwrap();
1730        let (mut b, _, idx_path) = fresh_builder(&tmp);
1731        let payload = vec![0xabu8; 1024];
1732        for i in 0..5u8 {
1733            b.add(deterministic_hash(i), ObjectType::Blob, payload.clone())
1734                .unwrap();
1735        }
1736        let (_, _, stats) = finalize_cursor(b, &idx_path);
1737        assert_eq!(stats.object_count, 5);
1738        assert_eq!(stats.total_uncompressed, 5 * 1024);
1739        assert!(stats.total_compressed > 0);
1740        assert!(stats.compression_ratio > 0.0);
1741        assert_eq!(stats.delta_count, 0, "streaming builder never deltas");
1742    }
1743
1744    #[cfg(feature = "zstd")]
1745    #[test]
1746    fn streaming_compression_roundtrips_through_zstd_frame() {
1747        // Force the streaming path with a payload that compresses
1748        // well (long runs of identical bytes). Verifies:
1749        //  1. Streaming output decodes back to the original bytes.
1750        //  2. The compressed body is genuinely smaller than the
1751        //     uncompressed input (proving zstd ran), and
1752        //  3. The non-canonical 10-byte varint patched into the
1753        //     compressed_size slot decodes to the right value.
1754        let tmp = tempfile::TempDir::new().unwrap();
1755        let (mut b, _, idx_path) = fresh_builder(&tmp);
1756        // 64 KiB of zeros — compresses to a tiny zstd frame, well
1757        // above the default `min_size` so we hit the streaming branch.
1758        let payload = vec![0u8; 64 * 1024];
1759        let hash = deterministic_hash(0x77);
1760        b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1761        let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1762        assert!(
1763            stats.total_compressed < stats.total_uncompressed,
1764            "expected compression ratio < 1.0, got {}/{}",
1765            stats.total_compressed,
1766            stats.total_uncompressed
1767        );
1768        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1769        let (_ty, got) = reader
1770            .get_object(&PackObjectId::Hash(hash))
1771            .unwrap()
1772            .unwrap();
1773        assert_eq!(got, payload);
1774    }
1775
1776    #[cfg(feature = "zstd")]
1777    #[test]
1778    fn equal_length_zstd_frame_is_stored_as_raw_payload() {
1779        // The pack format infers compression from stored length != logical
1780        // length. Find a stable boundary payload whose zstd frame is exactly
1781        // as long as its input, then pin the streaming writer's raw fallback.
1782        fn encode_like_streaming_writer(data: &[u8]) -> Vec<u8> {
1783            let mut compressed = Vec::new();
1784            let mut encoder = zstd::stream::write::Encoder::new(&mut compressed, 3).unwrap();
1785            encoder
1786                .set_pledged_src_size(Some(data.len() as u64))
1787                .unwrap();
1788            std::io::Write::write_all(&mut encoder, data).unwrap();
1789            encoder.finish().unwrap();
1790            compressed
1791        }
1792
1793        let mut random = Vec::with_capacity(1024);
1794        for seed in 0u64..32 {
1795            random.extend_from_slice(blake3::hash(&seed.to_le_bytes()).as_bytes());
1796        }
1797        let payload = (256..=random.len())
1798            .find_map(|logical_len| {
1799                (0..=logical_len).find_map(|zero_prefix| {
1800                    let mut candidate = random[..logical_len].to_vec();
1801                    candidate[..zero_prefix].fill(0);
1802                    let compressed = encode_like_streaming_writer(&candidate);
1803                    (compressed.len() == candidate.len()).then_some(candidate)
1804                })
1805            })
1806            .expect("fixture search must find an equal-length zstd frame");
1807        let compressed = encode_like_streaming_writer(&payload);
1808        assert_eq!(compressed.len(), payload.len());
1809        assert_eq!(compressed.first(), Some(&0x28), "fixture must be zstd");
1810
1811        let tmp = tempfile::TempDir::new().unwrap();
1812        let (mut builder, _, index_path) = fresh_builder(&tmp);
1813        let id = PackObjectId::Hash(deterministic_hash(0x78));
1814        builder
1815            .add_id(id, ObjectType::StateAttachment, payload.clone())
1816            .unwrap();
1817        let (pack, index, stats) = finalize_cursor(builder, &index_path);
1818
1819        assert_eq!(stats.total_compressed, stats.total_uncompressed);
1820        let reader = PackReader::from_bytes(pack, index).unwrap();
1821        assert_eq!(
1822            reader.get_object(&id).unwrap(),
1823            Some((ObjectType::StateAttachment, payload))
1824        );
1825    }
1826
1827    #[cfg(feature = "zstd")]
1828    #[test]
1829    fn padded_varint_decodes_to_original_value_for_canonical_decoder() {
1830        // Sanity for the seek-back scheme: for every value we'd want
1831        // to encode (small, mid, large), confirm the existing
1832        // `decode_varint` returns the same `value` from a 10-byte
1833        // padded encoding. If this ever fails the streaming path's
1834        // patched compressed_size would be misread by readers.
1835        let cases: &[u64] = &[0, 1, 127, 128, 4096, 1_000_000, 1_000_000_000_000, u64::MAX];
1836        for &value in cases {
1837            let mut buf = [0u8; 10];
1838            super::encode_varint_padded_to_10(value, &mut buf);
1839            let (decoded, consumed) = super::super::varint::decode_varint(&buf)
1840                .expect("padded varint should always decode");
1841            assert_eq!(decoded, value, "varint roundtrip failed for {value}");
1842            assert_eq!(
1843                consumed, 10,
1844                "padded encoding should consume all 10 bytes for {value}"
1845            );
1846        }
1847    }
1848
1849    #[cfg(feature = "zstd")]
1850    #[test]
1851    fn streaming_path_does_not_buffer_compressed_payload_in_memory() {
1852        // Smoke check: write a single 8 MiB payload, observe the
1853        // pack file size on disk during/after the add. The pack file
1854        // grows incrementally during the streaming compression — if
1855        // we were buffering an intermediate compressed `Vec<u8>` the
1856        // on-disk size would jump by ~8 MiB at finalize, not stay
1857        // bounded as the encoder pumps bytes through.
1858        //
1859        // We can't easily measure peak heap from inside Rust without
1860        // a custom allocator. What we *can* verify is that calling
1861        // `add` returns control with the pack file already at its
1862        // final body size, demonstrating the encoder wrote through
1863        // and didn't accumulate.
1864        let tmp = tempfile::TempDir::new().unwrap();
1865        let bucket_dir = tmp.path().join("buckets");
1866        let pack_path = tmp.path().join("pack.dat");
1867        let idx_path = tmp.path().join("pack.idx");
1868        let file = std::fs::OpenOptions::new()
1869            .read(true)
1870            .write(true)
1871            .create(true)
1872            .truncate(true)
1873            .open(&pack_path)
1874            .unwrap();
1875        let mut b = StreamingPackBuilder::new(
1876            file,
1877            idx_path.clone(),
1878            CompressionConfig::default(),
1879            bucket_dir,
1880        )
1881        .unwrap();
1882        let payload = vec![0xa5u8; 8 * 1024 * 1024];
1883        let hash = deterministic_hash(0x66);
1884        b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1885        // Pack file already on disk holds at least the entry header +
1886        // compressed payload (excluding the 32-byte trailer the builder
1887        // appends at finalize).
1888        let mid_size = std::fs::metadata(&pack_path).unwrap().len();
1889        assert!(
1890            mid_size > 16 + 40,
1891            "pack file should hold real entry data after add; size={mid_size}"
1892        );
1893        let (_, _) = b.finalize().unwrap();
1894        let pack_bytes = std::fs::read(&pack_path).unwrap();
1895        let index_bytes = std::fs::read(&idx_path).unwrap();
1896        let reader = PackReader::from_bytes(pack_bytes, index_bytes).unwrap();
1897        let (_ty, got) = reader
1898            .get_object(&PackObjectId::Hash(hash))
1899            .unwrap()
1900            .unwrap();
1901        assert_eq!(got, payload);
1902    }
1903
1904    #[test]
1905    fn list_ids_returns_all_added_ids_sorted() {
1906        let tmp = tempfile::TempDir::new().unwrap();
1907        let (mut b, _, idx_path) = fresh_builder(&tmp);
1908        let mut added: Vec<PackObjectId> = Vec::new();
1909        // Mix of Hash and StateId in a non-sorted order on input.
1910        for seed in [0x05u8, 0xa0, 0x12, 0x9f, 0x33] {
1911            let id = PackObjectId::Hash(deterministic_hash(seed));
1912            b.add_id(id, ObjectType::Blob, vec![seed; 4]).unwrap();
1913            added.push(id);
1914        }
1915        for seed in [0x80u8, 0x10, 0xff] {
1916            let id = PackObjectId::StateId(deterministic_state_id(seed));
1917            b.add_id(id, ObjectType::State, vec![seed; 4]).unwrap();
1918            added.push(id);
1919        }
1920        let (pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1921        let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1922        let mut got = reader.list_ids().unwrap();
1923        // PackReader's list_ids returns index order — should already be
1924        // sorted because we sort on finalize.
1925        let mut sorted = got.clone();
1926        sorted.sort();
1927        assert_eq!(got, sorted, "list_ids must come back sorted");
1928        // And every added id should appear.
1929        added.sort();
1930        got.sort();
1931        assert_eq!(got, added);
1932    }
1933}