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