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