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