Skip to main content

stt_core/
pack.rs

1//! STT **packed format** — a multi-object, edge-cacheable container.
2//!
3//! Replaces the single-file v4 archive ([`crate::archive::ArchiveWriter`]) as
4//! the canonical write path. A single-file archive cannot be edge-cached once it
5//! exceeds the CDN per-object limit (Cloudflare = 512 MB): every range request
6//! hits origin forever. The packed format makes the *cacheable unit a small
7//! object, not the whole dataset* — tile blobs are split into many
8//! content-addressed **pack** objects (each `≤ pack_target_bytes`) plus a tiny
9//! mutable manifest. A dumb CDN caches each immutable pack natively; no Worker,
10//! no vendor lock-in.
11//!
12//! ## On-disk layout (per dataset)
13//!
14//! ```text
15//! <out_dir>/
16//!   manifest.json            tiny, MUTABLE   → short TTL
17//!   index/<blake3>.sttd      directory blob  → IMMUTABLE (content-addressed)
18//!   packs/<blake3>.sttp      tile blob data  → IMMUTABLE (content-addressed)
19//!   packs/<blake3>.sttp
20//!   ...
21//! ```
22//!
23//! Packs and the directory are content-addressed (blake3, 128-bit → 32 hex
24//! chars) so their bytes never change without their name changing. The directory
25//! is the v5 codec ([`crate::directory`]): per-run `pack_id` column +
26//! pack-relative offsets. See `docs/spec/stt-packed-format.md` for the full
27//! contract.
28//!
29//! [`PackWriter`] reuses the dedup / per-blob-zstd / curve-ordering logic of
30//! `ArchiveWriter::finalize_buffered`, then cuts the ordered, deduped blob stream
31//! into packs. [`PackedReader`] is the local-file reader (the TS reader handles
32//! remote/HTTP); it mirrors [`crate::archive::ArchiveReader`] semantics.
33
34use crate::archive::{ArchiveReader, TileEntry};
35use crate::compression;
36use crate::curve::BlobOrdering;
37use crate::error::{Error, Result};
38use crate::metadata::Metadata;
39use crate::tile::TileId;
40use crate::types::Compression;
41use memmap2::Mmap;
42use rayon::prelude::*;
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45use std::fs::{self, File, OpenOptions};
46use std::io::Write;
47use std::path::{Path, PathBuf};
48
49/// `format` discriminator written into every packed manifest.
50pub const PACKED_FORMAT: &str = "stt-packed";
51
52/// Packed-format version (the manifest schema, distinct from the directory
53/// codec's [`crate::directory::DIRECTORY_VERSION`]).
54pub const PACKED_FORMAT_VERSION: u32 = 1;
55
56/// Default pack target size — 64 MiB. Well under the 512 MB CDN per-object cap,
57/// with enough granularity for fine cache + parallel range reads.
58pub const DEFAULT_PACK_TARGET_BYTES: u64 = 64 * 1024 * 1024;
59
60/// CRC32C integrity tag for a compressed blob (mirrors the archive writer).
61fn crc32c_tag(bytes: &[u8]) -> u32 {
62    crc32c::crc32c(bytes)
63}
64
65/// blake3 content address, 128-bit → 32 lowercase hex chars.
66fn blake3_128_hex(bytes: &[u8]) -> String {
67    let hash = blake3::hash(bytes);
68    // Take the first 16 bytes (128 bits) of the 256-bit digest.
69    hash.as_bytes()[..16]
70        .iter()
71        .map(|b| format!("{b:02x}"))
72        .collect()
73}
74
75// ----------------------------------------------------------------------------
76// Manifest
77// ----------------------------------------------------------------------------
78
79/// At-rest encoding value for a zstd-compressed directory object.
80pub const DIRECTORY_ENCODING_ZSTD: &str = "zstd";
81
82/// `directory.layout` value for the paged container (root page + leaf pages,
83/// each independently framed). Absent or `"single"` = the whole-load v5 object.
84pub const DIRECTORY_LAYOUT_PAGED: &str = "paged";
85/// `directory.layout` value for the single whole-load object (the default).
86pub const DIRECTORY_LAYOUT_SINGLE: &str = "single";
87
88/// Pointer to the encoded directory object.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct DirectoryRef {
91    /// Object key, relative to the dataset root (e.g. `index/<hash>.sttd`).
92    pub key: String,
93    /// Directory object length in bytes (the at-rest object, i.e. the
94    /// compressed length when `encoding` is set).
95    pub length: u64,
96    /// Directory codec version (`5` for the packed format). The leaf pages of a
97    /// paged directory are this same v5 codec — `layout` (below), not this
98    /// version, discriminates the container shape.
99    #[serde(rename = "directoryVersion")]
100    pub directory_version: u8,
101    /// At-rest encoding of the directory object. `Some("zstd")` means the
102    /// object bytes are a zstd frame wrapping the codec bytes; absent (the
103    /// shape every pre-encoding manifest has) means raw codec bytes. For a
104    /// paged directory it describes the framing of **each page** (root + every
105    /// leaf), not one frame over the whole object. The content address and
106    /// `length` always describe the at-rest bytes.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub encoding: Option<String>,
109    /// Container layout. `Some("paged")` = a root page + leaf pages (Wave 2);
110    /// absent or `Some("single")` = the single whole-load object. Readers that
111    /// don't know `"paged"` fail loudly (the root's first byte isn't a valid v5
112    /// directory version), which is why readers ship before any paged dataset.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub layout: Option<String>,
115    /// At-rest byte length of the root page (a prefix of the object). Present
116    /// iff `layout == "paged"`; the reader range-GETs `bytes=0-(rootLength-1)`
117    /// for the root, then leaf ranges on demand.
118    #[serde(default, rename = "rootLength", skip_serializing_if = "Option::is_none")]
119    pub root_length: Option<u64>,
120    /// Number of leaf pages (informational / validation). Paged only.
121    #[serde(default, rename = "pageCount", skip_serializing_if = "Option::is_none")]
122    pub page_count: Option<u64>,
123    /// Nominal entries-per-page used at build (informational). Paged only.
124    #[serde(default, rename = "pageEntries", skip_serializing_if = "Option::is_none")]
125    pub page_entries: Option<u64>,
126}
127
128impl DirectoryRef {
129    /// Is this a paged-container directory?
130    pub fn is_paged(&self) -> bool {
131        self.layout.as_deref() == Some(DIRECTORY_LAYOUT_PAGED)
132    }
133}
134
135/// Pointer to one pack object. The position in `Manifest::packs` **is** the
136/// `pack_id` the directory references.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct PackRef {
139    /// Object key, relative to the dataset root (e.g. `packs/<hash>.sttp`).
140    pub key: String,
141    /// Pack object length in bytes.
142    pub length: u64,
143}
144
145/// The packed-format `manifest.json` — the only mutable object per dataset.
146///
147/// Folds the metadata, directory pointer and pack table into one tiny JSON so a
148/// cold reader needs exactly one manifest + one directory + N pack-range fetches
149/// (no separate header or metadata object).
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Manifest {
152    /// Always [`PACKED_FORMAT`] (`"stt-packed"`).
153    pub format: String,
154    /// Manifest schema version.
155    #[serde(rename = "formatVersion")]
156    pub format_version: u32,
157    /// Blob compression codec (always `"zstd"`, per-blob, no shared dict).
158    pub compression: String,
159    /// Pointer to the encoded directory object.
160    pub directory: DirectoryRef,
161    /// Pack table. Index == `pack_id`.
162    pub packs: Vec<PackRef>,
163    /// The full `crate::metadata::Metadata` JSON, verbatim.
164    pub metadata: Metadata,
165}
166
167impl Manifest {
168    /// Parse a manifest from its JSON bytes.
169    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
170        serde_json::from_slice(bytes)
171            .map_err(|e| Error::InvalidArchive(format!("manifest JSON decode failed: {e}")))
172    }
173
174    /// Serialise the manifest to pretty JSON bytes.
175    pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
176        serde_json::to_vec_pretty(self)
177            .map_err(|e| Error::Other(format!("manifest JSON encode failed: {e}")))
178    }
179}
180
181// ----------------------------------------------------------------------------
182// Writer
183// ----------------------------------------------------------------------------
184
185/// A tile buffered for deferred ordering + per-blob compression + pack-cutting.
186struct PendingTile {
187    z: u8,
188    x: u32,
189    y: u32,
190    hilbert: u64,
191    time_start: i64,
192    time_end: i64,
193    cover_t_min: Option<i64>,
194    feature_count: u32,
195    temporal_bucket_ms: Option<u64>,
196    payload: Vec<u8>,
197}
198
199/// Writer for the multi-object packed format.
200///
201/// Consumes an `(id, payload)` stream like [`crate::archive::ArchiveWriter`],
202/// buffering every tile until [`finalize`](Self::finalize). At finalize it
203/// reuses the buffered-writer pipeline — resolve [`BlobOrdering::Auto`], sort by
204/// the space-time curve key, per-blob zstd (NO shared dictionary, so the fzstd
205/// TS reader can decode), byte-identical dedup via blake3 — then cuts the
206/// ordered/deduped blob stream into packs of `≤ pack_target_bytes`, never
207/// splitting a blob, and writes content-addressed `packs/*.sttp` +
208/// `index/*.sttd` + `manifest.json`.
209pub struct PackWriter {
210    out_dir: PathBuf,
211    ordering: BlobOrdering,
212    pack_target_bytes: u64,
213    pending: Vec<PendingTile>,
214    /// `Some(k)` → emit a **paged** directory (root + leaf pages of ≤ `k`
215    /// entries; see [`crate::directory_page`]); `None` → the single whole-load
216    /// v5 directory (the default). Set via [`with_paging`](Self::with_paging).
217    page_entries: Option<usize>,
218    /// zstd level for per-blob + directory compression. Defaults to
219    /// [`compression::ZSTD_LEVEL`]; a publish build raises it via
220    /// [`with_zstd_level`](Self::with_zstd_level). Decode is level-independent,
221    /// so this only trades build CPU for smaller on-the-wire bytes.
222    zstd_level: i32,
223}
224
225impl PackWriter {
226    /// Create a packed-format writer targeting `out_dir`.
227    ///
228    /// `out_dir` (and its `index/` + `packs/` subdirs) are created on
229    /// [`finalize`](Self::finalize). `ordering` controls the on-disk blob byte
230    /// order ([`BlobOrdering::Auto`] resolves per-dataset at finalize);
231    /// `pack_target_bytes` is the soft per-pack size cap — a single blob larger
232    /// than the target gets its own pack rather than being split.
233    pub fn create<P: AsRef<Path>>(
234        out_dir: P,
235        ordering: BlobOrdering,
236        pack_target_bytes: u64,
237    ) -> Result<Self> {
238        Ok(Self {
239            out_dir: out_dir.as_ref().to_path_buf(),
240            ordering,
241            pack_target_bytes: pack_target_bytes.max(1),
242            pending: Vec::new(),
243            page_entries: None,
244            zstd_level: compression::ZSTD_LEVEL,
245        })
246    }
247
248    /// Opt into a **paged** directory: the `.sttd` becomes a root page + leaf
249    /// pages of ≤ `page_entries` entries each, so a cold reader fetches only the
250    /// leaves its viewport/time-window touches (Wave 2). `None` (the default)
251    /// emits the single whole-load v5 directory — byte-identical output to a
252    /// pre-paging build. `Some(0)` is clamped to 1.
253    pub fn with_paging(mut self, page_entries: Option<usize>) -> Self {
254        self.page_entries = page_entries.map(|k| k.max(1));
255        self
256    }
257
258    /// Set the zstd compression level for tile blobs and the directory.
259    ///
260    /// The packed format is write-once / serve-many, so the higher build CPU of
261    /// a level like 19 is paid once while the smaller bytes are paid on every
262    /// fetch (measured −10..19% vs the level-3 default; decode is unaffected).
263    /// Clamped to zstd's valid 1..=22 range. The default
264    /// ([`compression::ZSTD_LEVEL`]) reproduces byte-identical pre-existing builds.
265    pub fn with_zstd_level(mut self, level: i32) -> Self {
266        self.zstd_level = level.clamp(1, compression::ZSTD_LEVEL_MAX);
267        self
268    }
269
270    /// Add a tile carrying the full directory metadata. Same shape as
271    /// [`crate::archive::ArchiveWriter::add_tile_full`]: `cover_t_min` is the
272    /// tight lower covering bound (`None` to omit), `temporal_bucket_ms` tags the
273    /// directory entry with the temporal bucket size the tile represents. The
274    /// `payload` is the uncompressed tile frame.
275    #[allow(clippy::too_many_arguments)]
276    pub fn add_tile_full(
277        &mut self,
278        id: &TileId,
279        time_start: i64,
280        time_end: i64,
281        cover_t_min: Option<i64>,
282        feature_count: u32,
283        temporal_bucket_ms: Option<u64>,
284        payload: &[u8],
285    ) -> Result<()> {
286        self.pending.push(PendingTile {
287            z: id.z,
288            x: id.x,
289            y: id.y,
290            hilbert: id.hilbert_index(),
291            time_start,
292            time_end,
293            cover_t_min,
294            feature_count,
295            temporal_bucket_ms,
296            payload: payload.to_vec(),
297        });
298        Ok(())
299    }
300
301    /// Number of tiles buffered so far.
302    pub fn tile_count(&self) -> usize {
303        self.pending.len()
304    }
305
306    /// Finalise: order + dedup + per-blob zstd, cut packs, write
307    /// `packs/*.sttp` + `index/*.sttd` + `manifest.json` into `out_dir`.
308    ///
309    /// Mirrors `ArchiveWriter::finalize_buffered`: the same time-bucket calc,
310    /// [`BlobOrdering::Auto`] → [`BlobOrdering::choose`] resolution, space-time
311    /// sort key, per-blob `compress_zstd_with_dict(_, None)` (no shared dict),
312    /// and blake3 byte-identical dedup. THEN the ordered, deduped blob stream is
313    /// cut into packs.
314    pub fn finalize(self, metadata: &Metadata) -> Result<Manifest> {
315        let PackWriter {
316            out_dir,
317            ordering,
318            pack_target_bytes,
319            mut pending,
320            page_entries,
321            zstd_level,
322        } = self;
323
324        // --- Blob ordering (identical to finalize_buffered) ---------------
325        let base_bucket = metadata.temporal_bucket_ms.max(1) as i64;
326        let tb = |p: &PendingTile| {
327            let b = p
328                .temporal_bucket_ms
329                .map(|v| v as i64)
330                .unwrap_or(base_bucket)
331                .max(1);
332            p.time_start.div_euclid(b)
333        };
334        let (tb_min, tb_max) = pending.iter().fold((i64::MAX, i64::MIN), |(lo, hi), p| {
335            let t = tb(p);
336            (lo.min(t), hi.max(t))
337        });
338        let tb_span = if pending.is_empty() { 0 } else { tb_max - tb_min };
339        // Resolve Auto from the dataset's space-vs-time cardinality.
340        let ordering = match ordering {
341            BlobOrdering::Auto => {
342                let max_z = pending.iter().map(|p| p.z).max().unwrap_or(0) as u32;
343                let time_bits = crate::curve::bits_for((tb_span.max(0) + 1) as u64);
344                BlobOrdering::choose(max_z, time_bits)
345            }
346            other => other,
347        };
348        // The curve key alone is not total (base and temporal-LOD tiles of one
349        // cell tie; the 21-bit cube cap can collide), and `pending` arrives in
350        // whatever order the (possibly parallel) tiler produced — so a total
351        // tiebreak makes the blob byte order, and therefore every content
352        // address, reproducible across identical rebuilds. Immutable-pack CDN
353        // caching depends on that: a rebuild of unchanged data must re-derive
354        // the same pack names.
355        pending.sort_by_key(|p| {
356            (
357                crate::curve::space_time_key(
358                    ordering,
359                    p.z,
360                    p.x,
361                    p.y,
362                    p.hilbert,
363                    p.time_start,
364                    tb(p),
365                    tb_min,
366                    tb_span,
367                ),
368                p.z,
369                p.x,
370                p.y,
371                p.time_start,
372                p.temporal_bucket_ms,
373            )
374        });
375
376        // --- Per-blob zstd + byte-identical dedup ------------------------
377        // Each pending tile is compressed (NO shared dictionary, so the fzstd TS
378        // reader can decode). Byte-identical compressed blobs collapse to a
379        // single physical blob — but, unlike the single-file writer, we DON'T
380        // assign byte offsets yet: pack assignment happens after dedup so a
381        // shared blob lands in exactly one pack.
382        struct Blob {
383            compressed: Vec<u8>,
384            uncompressed_size: u32,
385            crc: u32,
386        }
387        let mut blobs: Vec<Blob> = Vec::new();
388        // blake3(compressed) → blob index in `blobs`.
389        let mut blob_dedup: HashMap<[u8; 32], usize> = HashMap::new();
390        // Per pending tile (in sorted order): which blob it references.
391        let mut tile_blob: Vec<usize> = Vec::with_capacity(pending.len());
392        // Compress in parallel, dedup sequentially. zstd at a high level
393        // (`--zstd-level`) is the build-time bottleneck and is embarrassingly
394        // parallel per blob; the dedup/index assignment that follows stays
395        // strictly sequential over the sorted order, so the output is
396        // byte-identical to a single-threaded build. Chunking caps peak memory
397        // at ~CHUNK compressed blobs (a whole-dataset parallel pass would hold
398        // every pre-dedup blob at once — pathological on dedup-heavy datasets).
399        const COMPRESS_CHUNK: usize = 8192;
400        for chunk in pending.chunks(COMPRESS_CHUNK) {
401            let compressed_chunk: Vec<Vec<u8>> = chunk
402                .par_iter()
403                .map(|p| compression::compress_zstd_with_dict_level(&p.payload, None, zstd_level))
404                .collect::<Result<Vec<_>>>()?;
405            for (p, compressed) in chunk.iter().zip(compressed_chunk) {
406                let key = *blake3::hash(&compressed).as_bytes();
407                let idx = if let Some(&i) = blob_dedup.get(&key) {
408                    i
409                } else {
410                    let i = blobs.len();
411                    let crc = crc32c_tag(&compressed);
412                    let uncompressed_size = p.payload.len() as u32;
413                    blobs.push(Blob {
414                        compressed,
415                        uncompressed_size,
416                        crc,
417                    });
418                    blob_dedup.insert(key, i);
419                    i
420                };
421                tile_blob.push(idx);
422            }
423        }
424
425        // --- Pack cutting -----------------------------------------------
426        // Walk the deduped blobs in their (first-seen, i.e. curve) order and cut
427        // into packs of ≤ pack_target_bytes. Never split a blob; a single blob
428        // larger than the target gets its own pack. pack_id is assigned in cut
429        // order; offset_in_pack resets to 0 per pack. Each blob is placed once,
430        // so a tile shared across time buckets resolves to one (pack, offset).
431        struct Placement {
432            pack_id: u32,
433            offset: u64,
434        }
435        let mut placements: Vec<Placement> = Vec::with_capacity(blobs.len());
436        // Pack contents: each pack is a contiguous slice of `blobs` indices.
437        let mut packs_blob_ranges: Vec<(usize, usize)> = Vec::new(); // (start, end) into blobs
438        if !blobs.is_empty() {
439            let mut pack_start = 0usize;
440            let mut pack_id = 0u32;
441            let mut cur_offset = 0u64;
442            for (i, blob) in blobs.iter().enumerate() {
443                let blen = blob.compressed.len() as u64;
444                // Cut BEFORE this blob if adding it would exceed the target and
445                // the current pack is non-empty (so a lone oversized blob still
446                // fits — it just owns a pack).
447                if i > pack_start && cur_offset + blen > pack_target_bytes {
448                    packs_blob_ranges.push((pack_start, i));
449                    pack_start = i;
450                    pack_id += 1;
451                    cur_offset = 0;
452                }
453                placements.push(Placement {
454                    pack_id,
455                    offset: cur_offset,
456                });
457                cur_offset += blen;
458            }
459            packs_blob_ranges.push((pack_start, blobs.len()));
460        }
461
462        // --- Build directory entries ------------------------------------
463        let mut entries: Vec<TileEntry> = Vec::with_capacity(pending.len());
464        for (p, &bi) in pending.iter().zip(tile_blob.iter()) {
465            let blob = &blobs[bi];
466            let pl = &placements[bi];
467            entries.push(TileEntry {
468                zoom: p.z,
469                x: p.x,
470                y: p.y,
471                time_start: p.time_start,
472                time_end: p.time_end,
473                pack_id: pl.pack_id,
474                offset: pl.offset,
475                length: blob.compressed.len() as u32,
476                uncompressed_size: blob.uncompressed_size,
477                feature_count: p.feature_count,
478                hilbert: p.hilbert,
479                crc32c: blob.crc,
480                temporal_bucket_ms: p.temporal_bucket_ms,
481                cover_t_min: p.cover_t_min,
482            });
483        }
484        // Directory codec order is (zoom, hilbert, time_start); the extra
485        // bucket key totalizes ties between a cell's base and temporal-LOD
486        // entries so the encoded directory is byte-reproducible too.
487        entries.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
488
489        // The manifest's totals are derived from the directory itself, not
490        // taken from the caller: `tile_count` = directory entries,
491        // `feature_count` = sum of per-entry counts (the same total
492        // stt-validate reports as `feature_count_index`). Deriving here keeps
493        // manifest and directory consistent by construction for every writer
494        // path — historically no caller set these and manifests shipped 0s.
495        let mut metadata = metadata.clone();
496        metadata.tile_count = entries.len() as u64;
497        metadata.feature_count = entries.iter().map(|e| u64::from(e.feature_count)).sum();
498
499        // --- Write objects ----------------------------------------------
500        fs::create_dir_all(&out_dir)?;
501        let index_dir = out_dir.join("index");
502        let packs_dir = out_dir.join("packs");
503        fs::create_dir_all(&index_dir)?;
504        fs::create_dir_all(&packs_dir)?;
505
506        // Write each pack to a temp file then atomically rename to its content
507        // address. Packs are immutable, so a re-sync skips an unchanged pack.
508        let mut pack_refs: Vec<PackRef> = Vec::with_capacity(packs_blob_ranges.len());
509        for (start, end) in &packs_blob_ranges {
510            let mut bytes: Vec<u8> = Vec::new();
511            for blob in &blobs[*start..*end] {
512                bytes.extend_from_slice(&blob.compressed);
513            }
514            let hex = blake3_128_hex(&bytes);
515            let rel = format!("packs/{hex}.sttp");
516            let final_path = out_dir.join(&rel);
517            write_atomic(&packs_dir, &final_path, &bytes)?;
518            pack_refs.push(PackRef {
519                key: rel,
520                length: bytes.len() as u64,
521            });
522        }
523
524        // Encode the directory. Both shapes are zstd-at-rest (declared via
525        // `directory.encoding`) — directories compress ~2x and sit on the
526        // cold-start critical path with no CDN content-encoding rescue:
527        //   - single (default): one zstd frame over the whole v5 codec buffer.
528        //   - paged (opt-in):   a root page + leaf pages, each its own zstd
529        //     frame, so a cold reader fetches only the leaves it touches. The
530        //     leaf codec is the same v5 directory.
531        // The object is content-addressed over its at-rest bytes either way.
532        let (index_bytes, directory_ref_fields): (Vec<u8>, _) = if let Some(k) = page_entries {
533            let paged =
534                crate::directory_page::encode_paged_directory_level(&entries, k, true, zstd_level)?;
535            (
536                paged.bytes,
537                (
538                    Some(DIRECTORY_LAYOUT_PAGED.to_string()),
539                    Some(paged.root_length),
540                    Some(paged.page_count as u64),
541                    Some(paged.page_entries as u64),
542                ),
543            )
544        } else {
545            let index_plain = crate::directory::encode_directory(&entries);
546            let bytes = compression::compress_zstd_with_dict_level(&index_plain, None, zstd_level)?;
547            (bytes, (None, None, None, None))
548        };
549        let index_hex = blake3_128_hex(&index_bytes);
550        let index_rel = format!("index/{index_hex}.sttd");
551        let index_path = out_dir.join(&index_rel);
552        write_atomic(&index_dir, &index_path, &index_bytes)?;
553
554        let (layout, root_length, page_count, page_entries_field) = directory_ref_fields;
555
556        // Build + write the manifest.
557        let manifest = Manifest {
558            format: PACKED_FORMAT.to_string(),
559            format_version: PACKED_FORMAT_VERSION,
560            compression: "zstd".to_string(),
561            directory: DirectoryRef {
562                key: index_rel,
563                length: index_bytes.len() as u64,
564                directory_version: crate::directory::DIRECTORY_VERSION,
565                encoding: Some(DIRECTORY_ENCODING_ZSTD.to_string()),
566                layout,
567                root_length,
568                page_count,
569                page_entries: page_entries_field,
570            },
571            packs: pack_refs,
572            metadata,
573        };
574        let manifest_bytes = manifest.to_json_bytes()?;
575        let manifest_path = out_dir.join("manifest.json");
576        let mut f = File::create(&manifest_path)?;
577        f.write_all(&manifest_bytes)?;
578        f.flush()?;
579
580        Ok(manifest)
581    }
582}
583
584/// Unwrap a directory object's at-rest encoding into raw codec bytes.
585/// `encoding` is the manifest's `directory.encoding`: absent = raw (every
586/// pre-encoding manifest), `"zstd"` = one zstd frame around the codec bytes.
587fn decode_directory_object(bytes: &[u8], encoding: Option<&str>) -> Result<Vec<u8>> {
588    match encoding {
589        None => Ok(bytes.to_vec()),
590        Some(DIRECTORY_ENCODING_ZSTD) => compression::decompress_zstd_with_dict(bytes, None),
591        Some(other) => Err(Error::InvalidArchive(format!(
592            "unknown directory encoding {other:?} (this reader supports absent or \"zstd\")"
593        ))),
594    }
595}
596
597/// Decode the full tile-entry list from a directory object's at-rest bytes,
598/// branching on the container layout. The single (whole-load) shape unwraps the
599/// at-rest encoding then runs the v5 codec; the paged shape decodes the root +
600/// every leaf (the local load-all path — a mmap'd file has no cold-start cost,
601/// so the paging *query* win lives in the TS HTTP reader). Used by the local
602/// `PackedReader` and `verify_packed_objects`.
603fn decode_directory_entries(bytes: &[u8], dref: &DirectoryRef) -> Result<Vec<TileEntry>> {
604    if dref.is_paged() {
605        let root_length = dref.root_length.ok_or_else(|| {
606            Error::InvalidArchive("paged directory: manifest missing rootLength".into())
607        })?;
608        let zstd = dref.encoding.as_deref() == Some(DIRECTORY_ENCODING_ZSTD);
609        crate::directory_page::decode_paged_directory(bytes, root_length, zstd)
610    } else {
611        let raw = decode_directory_object(bytes, dref.encoding.as_deref())?;
612        crate::directory::decode_directory(&raw)
613    }
614}
615
616/// Write `bytes` to a temp file inside `dir` then atomically rename to
617/// `final_path` (content-addressed, so an existing identical object is a no-op
618/// overwrite of the same bytes).
619fn write_atomic(dir: &Path, final_path: &Path, bytes: &[u8]) -> Result<()> {
620    // Unique temp name within the same directory so the rename is atomic.
621    let tmp = dir.join(format!(
622        ".tmp-{}-{}",
623        std::process::id(),
624        blake3_128_hex(bytes)
625    ));
626    {
627        let mut f = OpenOptions::new()
628            .write(true)
629            .create(true)
630            .truncate(true)
631            .open(&tmp)?;
632        f.write_all(bytes)?;
633        f.flush()?;
634    }
635    fs::rename(&tmp, final_path)?;
636    Ok(())
637}
638
639// ----------------------------------------------------------------------------
640// Transcode (single-file v4 archive -> packed dir)
641// ----------------------------------------------------------------------------
642
643/// Losslessly re-wrap a single-file v4 `.stt` archive into the packed format.
644///
645/// Reads every tile payload via [`ArchiveReader`] and streams it into a fresh
646/// [`PackWriter`], preserving the tight covering bound (`cover_t_min`) and the
647/// per-tile `temporal_bucket_ms` exactly (no thinning), then finalizes with the
648/// source archive's metadata. Writes `manifest.json`, `index/<hash>.sttd`, and
649/// one or more `packs/<hash>.sttp` objects under `out_dir`.
650///
651/// Shared by the `pack-transcode` example and `stt-build`'s `--streaming-arrow`
652/// path (which builds a temp single-file archive under bounded RAM, then
653/// transcodes it to packs).
654pub fn transcode_archive_to_packs<I: AsRef<Path>, O: AsRef<Path>>(
655    in_archive: I,
656    out_dir: O,
657    ordering: BlobOrdering,
658    pack_target_bytes: u64,
659) -> Result<Manifest> {
660    transcode_archive_to_packs_paged(in_archive, out_dir, ordering, pack_target_bytes, None)
661}
662
663/// [`transcode_archive_to_packs`] with an explicit paging choice.
664///
665/// `page_entries`: `Some(k)` emits a paged directory (root + leaf pages of ≤ `k`
666/// entries — Wave 2); `None` emits the single whole-load v5 directory. The tile
667/// payloads, packs, dedup and ordering are identical either way — only the
668/// `.sttd` directory shape changes — so a paged transcode of an existing dataset
669/// re-ships only `index/*.sttd` + `manifest.json` (the packs are unchanged
670/// content addresses).
671pub fn transcode_archive_to_packs_paged<I: AsRef<Path>, O: AsRef<Path>>(
672    in_archive: I,
673    out_dir: O,
674    ordering: BlobOrdering,
675    pack_target_bytes: u64,
676    page_entries: Option<usize>,
677) -> Result<Manifest> {
678    transcode_archive_to_packs_paged_level(
679        in_archive,
680        out_dir,
681        ordering,
682        pack_target_bytes,
683        page_entries,
684        compression::ZSTD_LEVEL,
685    )
686}
687
688/// [`transcode_archive_to_packs_paged`] at an explicit zstd `level` (see
689/// [`PackWriter::with_zstd_level`]). Re-compresses every blob at `level`, so —
690/// unlike [`repack_directory`] — it rewrites the packs, not just the directory.
691pub fn transcode_archive_to_packs_paged_level<I: AsRef<Path>, O: AsRef<Path>>(
692    in_archive: I,
693    out_dir: O,
694    ordering: BlobOrdering,
695    pack_target_bytes: u64,
696    page_entries: Option<usize>,
697    zstd_level: i32,
698) -> Result<Manifest> {
699    let reader = ArchiveReader::open(in_archive)?;
700    let meta = reader.metadata().clone();
701    let entries = reader.entries().to_vec();
702
703    let mut writer = PackWriter::create(out_dir, ordering, pack_target_bytes)?
704        .with_paging(page_entries)
705        .with_zstd_level(zstd_level);
706    for e in &entries {
707        let payload = reader.read_payload(e)?;
708        writer.add_tile_full(
709            &TileId::new(e.zoom, e.x, e.y, e.time_start.max(0) as u64),
710            e.time_start,
711            e.time_end,
712            e.cover_t_min,
713            e.feature_count,
714            e.temporal_bucket_ms,
715            &payload,
716        )?;
717    }
718    writer.finalize(&meta)
719}
720
721/// Re-transcode an existing **packed** dataset into a fresh packed dataset at an
722/// explicit zstd `level` and (optionally) a paged directory — the publish-build
723/// migration. Unlike [`repack_directory`] (directory-only) this re-reads and
724/// **re-compresses every tile payload**, so it picks up a higher `--zstd-level`
725/// (−10..19% on the wire) and re-pages the directory in one pass.
726///
727/// The input packed dir is the source of truth — important because several
728/// showcase datasets were rebuilt later than their legacy single-file `.stt`,
729/// so transcoding from the packs (not the stale `.stt`) preserves those fixes.
730/// Tile content is byte-for-byte preserved (lossless); only the compression
731/// level and directory shape change. `out_dir` MUST differ from the input dir.
732pub fn transcode_packs_to_packs_paged_level<I: AsRef<Path>, O: AsRef<Path>>(
733    in_manifest: I,
734    out_dir: O,
735    ordering: BlobOrdering,
736    pack_target_bytes: u64,
737    page_entries: Option<usize>,
738    zstd_level: i32,
739) -> Result<Manifest> {
740    let reader = PackedReader::open(in_manifest)?;
741    let meta = reader.metadata().clone();
742    let entries = reader.entries().to_vec();
743
744    let mut writer = PackWriter::create(out_dir, ordering, pack_target_bytes)?
745        .with_paging(page_entries)
746        .with_zstd_level(zstd_level);
747    for e in &entries {
748        let payload = reader.read_payload(e)?;
749        writer.add_tile_full(
750            &TileId::new(e.zoom, e.x, e.y, e.time_start.max(0) as u64),
751            e.time_start,
752            e.time_end,
753            e.cover_t_min,
754            e.feature_count,
755            e.temporal_bucket_ms,
756            &payload,
757        )?;
758    }
759    writer.finalize(&meta)
760}
761
762/// Re-encode **only the directory** of an existing packed dataset, optionally
763/// switching it to (or from) the paged container — without re-reading or
764/// re-compressing a single tile payload.
765///
766/// This is the cheap Wave-2 re-transcode: the directory entries already carry
767/// every blob's `(pack_id, offset, length, crc32c)`, and the packs are
768/// content-addressed and immutable, so a directory shape change re-ships only
769/// `index/<hash>.sttd` + `manifest.json` — the packs keep their exact bytes and
770/// names. `page_entries`: `Some(k)` → paged directory (leaf pages of ≤ `k`),
771/// `None` → single whole-load directory.
772///
773/// When `out_dir` differs from the input dataset's directory, the (unchanged)
774/// pack objects are copied across so `out_dir` is a complete dataset; an
775/// in-place re-pack (`out_dir` == the input dir) leaves the packs untouched and
776/// just adds the new index object + overwrites the manifest.
777pub fn repack_directory<P: AsRef<Path>, Q: AsRef<Path>>(
778    in_manifest: P,
779    out_dir: Q,
780    page_entries: Option<usize>,
781) -> Result<Manifest> {
782    let in_manifest = in_manifest.as_ref();
783    let in_dir = in_manifest
784        .parent()
785        .ok_or_else(|| Error::InvalidArchive("manifest path has no parent dir".into()))?;
786    let out_dir = out_dir.as_ref();
787
788    let reader = PackedReader::open(in_manifest)?;
789    let entries = reader.entries().to_vec();
790    let metadata = reader.metadata().clone();
791    let src = Manifest::from_json_bytes(&fs::read(in_manifest)?)?;
792
793    fs::create_dir_all(out_dir.join("index"))?;
794    fs::create_dir_all(out_dir.join("packs"))?;
795
796    // Copy the (unchanged, content-addressed) packs only when writing elsewhere.
797    let same_dir = fs::canonicalize(in_dir).ok() == fs::canonicalize(out_dir).ok();
798    if !same_dir {
799        for p in &src.packs {
800            let dst = out_dir.join(&p.key);
801            if !dst.exists() {
802                fs::copy(in_dir.join(&p.key), &dst)?;
803            }
804        }
805    }
806
807    // Re-encode the directory from the existing entries (no payload re-read).
808    let (index_bytes, layout, root_length, page_count, page_entries_field): (
809        Vec<u8>,
810        Option<String>,
811        Option<u64>,
812        Option<u64>,
813        Option<u64>,
814    ) = if let Some(k) = page_entries {
815        let paged = crate::directory_page::encode_paged_directory(&entries, k, true)?;
816        (
817            paged.bytes,
818            Some(DIRECTORY_LAYOUT_PAGED.to_string()),
819            Some(paged.root_length),
820            Some(paged.page_count as u64),
821            Some(paged.page_entries as u64),
822        )
823    } else {
824        let plain = crate::directory::encode_directory(&entries);
825        (
826            compression::compress_zstd_with_dict(&plain, None)?,
827            None,
828            None,
829            None,
830            None,
831        )
832    };
833
834    let index_hex = blake3_128_hex(&index_bytes);
835    let index_rel = format!("index/{index_hex}.sttd");
836    write_atomic(&out_dir.join("index"), &out_dir.join(&index_rel), &index_bytes)?;
837
838    let manifest = Manifest {
839        format: PACKED_FORMAT.to_string(),
840        format_version: PACKED_FORMAT_VERSION,
841        compression: src.compression.clone(),
842        directory: DirectoryRef {
843            key: index_rel,
844            length: index_bytes.len() as u64,
845            directory_version: crate::directory::DIRECTORY_VERSION,
846            encoding: Some(DIRECTORY_ENCODING_ZSTD.to_string()),
847            layout,
848            root_length,
849            page_count,
850            page_entries: page_entries_field,
851        },
852        packs: src.packs.clone(),
853        metadata,
854    };
855    let manifest_bytes = manifest.to_json_bytes()?;
856    let mut f = File::create(out_dir.join("manifest.json"))?;
857    f.write_all(&manifest_bytes)?;
858    f.flush()?;
859    Ok(manifest)
860}
861
862// ----------------------------------------------------------------------------
863// Integrity
864// ----------------------------------------------------------------------------
865
866/// Verify the on-disk integrity of a packed dataset against its manifest.
867///
868/// Because packs and the directory are **content-addressed**, integrity is a
869/// property anyone can check with no trusted side-channel: each object's bytes
870/// must blake3-hash to the name the manifest gave it, and its on-disk length
871/// must match the declared length. Additionally the directory must decode and
872/// reference no `pack_id` outside the manifest's pack table.
873///
874/// Returns the list of violations (empty ⇒ clean). Returns `Err` only when the
875/// manifest itself cannot be read or parsed (a missing referenced object is a
876/// reported violation, not an `Err`, so a full report is produced in one pass).
877pub fn verify_packed_objects<P: AsRef<Path>>(manifest_path: P) -> Result<Vec<String>> {
878    let manifest_path = manifest_path.as_ref();
879    let root = manifest_path
880        .parent()
881        .ok_or_else(|| Error::InvalidArchive("manifest path has no parent dir".into()))?;
882    let manifest = Manifest::from_json_bytes(&fs::read(manifest_path)?)?;
883
884    let mut issues = Vec::new();
885
886    if manifest.format != PACKED_FORMAT {
887        issues.push(format!(
888            "manifest format is {:?}, expected {PACKED_FORMAT:?}",
889            manifest.format
890        ));
891    }
892    if manifest.format_version != PACKED_FORMAT_VERSION {
893        issues.push(format!(
894            "manifest formatVersion is {}, expected {PACKED_FORMAT_VERSION}",
895            manifest.format_version
896        ));
897    }
898    if manifest.directory.directory_version != crate::directory::DIRECTORY_VERSION {
899        issues.push(format!(
900            "directoryVersion is {}, expected {}",
901            manifest.directory.directory_version,
902            crate::directory::DIRECTORY_VERSION
903        ));
904    }
905
906    // Each content-addressed object: name must equal blake3-128 of its bytes,
907    // and on-disk length must equal the declared length.
908    fn check_object(
909        root: &Path,
910        key: &str,
911        declared_len: u64,
912        prefix: &str,
913        ext: &str,
914        issues: &mut Vec<String>,
915    ) {
916        match fs::read(root.join(key)) {
917            Ok(bytes) => {
918                if bytes.len() as u64 != declared_len {
919                    issues.push(format!(
920                        "{key}: on-disk length {} != manifest-declared {declared_len}",
921                        bytes.len()
922                    ));
923                }
924                let expected = format!("{prefix}/{}.{ext}", blake3_128_hex(&bytes));
925                if key != expected {
926                    issues.push(format!(
927                        "{key}: content-address mismatch (bytes hash to {expected})"
928                    ));
929                }
930            }
931            Err(e) => issues.push(format!("{key}: cannot read object ({e})")),
932        }
933    }
934
935    check_object(
936        root,
937        &manifest.directory.key,
938        manifest.directory.length,
939        "index",
940        "sttd",
941        &mut issues,
942    );
943    for p in &manifest.packs {
944        check_object(root, &p.key, p.length, "packs", "sttp", &mut issues);
945    }
946
947    // Directory must decode (through its at-rest encoding + container layout)
948    // and reference only packs that exist.
949    match fs::read(root.join(&manifest.directory.key)) {
950        Ok(dir_bytes) => match decode_directory_entries(&dir_bytes, &manifest.directory) {
951            Ok(entries) => {
952                if let Some(max_pid) = entries.iter().map(|e| e.pack_id).max() {
953                    if max_pid as usize >= manifest.packs.len() {
954                        issues.push(format!(
955                            "directory references pack_id {max_pid} but the manifest lists only {} pack(s)",
956                            manifest.packs.len()
957                        ));
958                    }
959                }
960            }
961            Err(e) => issues.push(format!("directory failed to decode: {e}")),
962        },
963        // A read failure here is already reported by check_object above.
964        Err(_) => {}
965    }
966
967    // Paged directories: validate the container structure beyond plain decode —
968    // page descriptor bounds cover their leaf's entries (so a reader's prune
969    // never drops a matching tile) and cross-page key order is monotonic.
970    if manifest.directory.is_paged() {
971        match manifest.directory.root_length {
972            Some(rl) => {
973                if let Ok(dir_bytes) = fs::read(root.join(&manifest.directory.key)) {
974                    let zstd =
975                        manifest.directory.encoding.as_deref() == Some(DIRECTORY_ENCODING_ZSTD);
976                    match crate::directory_page::verify_paged_structure(&dir_bytes, rl, zstd) {
977                        Ok(mut more) => issues.append(&mut more),
978                        Err(e) => issues.push(format!("paged structure check failed: {e}")),
979                    }
980                }
981            }
982            None => issues.push("paged directory: manifest missing rootLength".into()),
983        }
984    }
985
986    Ok(issues)
987}
988
989// ----------------------------------------------------------------------------
990// Reader
991// ----------------------------------------------------------------------------
992
993/// One mapped pack, lazily loaded on first access.
994struct LoadedPack {
995    /// `None` until first read of a tile in this pack.
996    mmap: Option<Mmap>,
997    /// Absolute path to the pack object.
998    path: PathBuf,
999    /// Declared length from the manifest (for a bounds sanity check).
1000    length: u64,
1001}
1002
1003/// Reader for a **local** packed dataset. Remote/HTTP reads are the TS reader's
1004/// job; this opens objects from the filesystem.
1005///
1006/// Mirrors [`crate::archive::ArchiveReader`]: [`entries`](Self::entries) returns
1007/// the decoded v5 directory, [`metadata`](Self::metadata) the folded metadata,
1008/// and [`read_payload`](Self::read_payload) selects the entry's pack, slices
1009/// `[offset..offset+length]`, verifies CRC32C and decompresses the per-blob
1010/// zstd. Packs are mmap'd lazily by `pack_id`.
1011pub struct PackedReader {
1012    entries: Vec<TileEntry>,
1013    metadata: Metadata,
1014    compression: Compression,
1015    packs: Vec<std::cell::RefCell<LoadedPack>>,
1016}
1017
1018impl PackedReader {
1019    /// Open a packed dataset by its `manifest.json` path. The directory and pack
1020    /// objects are resolved relative to the manifest's parent directory.
1021    pub fn open<P: AsRef<Path>>(manifest_path: P) -> Result<Self> {
1022        let manifest_path = manifest_path.as_ref();
1023        let root = manifest_path
1024            .parent()
1025            .ok_or_else(|| Error::InvalidArchive("manifest path has no parent dir".into()))?
1026            .to_path_buf();
1027
1028        let manifest_bytes = fs::read(manifest_path)?;
1029        let manifest = Manifest::from_json_bytes(&manifest_bytes)?;
1030
1031        if manifest.format != PACKED_FORMAT {
1032            return Err(Error::InvalidArchive(format!(
1033                "not a packed manifest: format={:?} (expected {PACKED_FORMAT:?})",
1034                manifest.format
1035            )));
1036        }
1037        let compression = match manifest.compression.as_str() {
1038            "zstd" => Compression::Zstd,
1039            "none" => Compression::None,
1040            other => {
1041                return Err(Error::InvalidArchive(format!(
1042                    "unknown packed compression {other:?}"
1043                )))
1044            }
1045        };
1046
1047        // Load + decode the directory object. The single (whole-load) shape
1048        // unwraps the at-rest encoding then runs the v5 codec; the paged shape
1049        // decodes the root + every leaf (local load-all — a mmap'd file has no
1050        // cold-start cost). Both branches return the same full entry list.
1051        let dir_path = root.join(&manifest.directory.key);
1052        let dir_bytes = fs::read(&dir_path)?;
1053        let entries = decode_directory_entries(&dir_bytes, &manifest.directory)?;
1054
1055        // Prepare (lazy) pack handles in pack_id order.
1056        let packs = manifest
1057            .packs
1058            .iter()
1059            .map(|p| {
1060                std::cell::RefCell::new(LoadedPack {
1061                    mmap: None,
1062                    path: root.join(&p.key),
1063                    length: p.length,
1064                })
1065            })
1066            .collect();
1067
1068        Ok(Self {
1069            entries,
1070            metadata: manifest.metadata,
1071            compression,
1072            packs,
1073        })
1074    }
1075
1076    /// All directory entries (sorted by zoom then Hilbert index).
1077    pub fn entries(&self) -> &[TileEntry] {
1078        &self.entries
1079    }
1080
1081    /// Dataset metadata.
1082    pub fn metadata(&self) -> &Metadata {
1083        &self.metadata
1084    }
1085
1086    /// Read and decompress a tile's raw payload bytes, verifying its CRC32C.
1087    ///
1088    /// Selects the pack by `entry.pack_id`, slices `[offset..offset+length]`
1089    /// within it, checks the CRC, then decompresses the per-blob zstd. Mirrors
1090    /// [`crate::archive::ArchiveReader::read_payload`].
1091    pub fn read_payload(&self, entry: &TileEntry) -> Result<Vec<u8>> {
1092        let cell = self.packs.get(entry.pack_id as usize).ok_or_else(|| {
1093            Error::InvalidArchive(format!(
1094                "tile {:?} references pack {} but only {} packs exist",
1095                entry.tile_id(),
1096                entry.pack_id,
1097                self.packs.len()
1098            ))
1099        })?;
1100
1101        let payload = {
1102            let mut pack = cell.borrow_mut();
1103            if pack.mmap.is_none() {
1104                let file = File::open(&pack.path)?;
1105                // SAFETY: read-only mapping of a file we never write through; the
1106                // mapping is owned by the reader for its lifetime.
1107                let mmap = unsafe { Mmap::map(&file) }
1108                    .map_err(|e| Error::Other(format!("mmap failed: {e}")))?;
1109                if mmap.len() as u64 != pack.length {
1110                    return Err(Error::InvalidArchive(format!(
1111                        "pack {} is {} bytes, manifest declared {}",
1112                        pack.path.display(),
1113                        mmap.len(),
1114                        pack.length
1115                    )));
1116                }
1117                pack.mmap = Some(mmap);
1118            }
1119            let mmap = pack.mmap.as_ref().expect("just loaded");
1120
1121            let start = entry.offset as usize;
1122            let end = start + entry.length as usize;
1123            if end > mmap.len() {
1124                return Err(Error::InvalidArchive(format!(
1125                    "tile {:?} blob range {start}..{end} exceeds pack size {}",
1126                    entry.tile_id(),
1127                    mmap.len()
1128                )));
1129            }
1130            let compressed = &mmap[start..end];
1131
1132            if crc32c_tag(compressed) != entry.crc32c {
1133                return Err(Error::InvalidArchive(format!(
1134                    "tile {:?} failed integrity check (corrupt pack)",
1135                    entry.tile_id()
1136                )));
1137            }
1138
1139            if self.compression == Compression::Zstd {
1140                compression::decompress_zstd_with_dict(compressed, None)?
1141            } else {
1142                compression::decompress(compressed, self.compression)?
1143            }
1144        };
1145
1146        if payload.len() != entry.uncompressed_size as usize {
1147            return Err(Error::InvalidArchive(format!(
1148                "tile {:?} decompressed to {} bytes, expected {}",
1149                entry.tile_id(),
1150                payload.len(),
1151                entry.uncompressed_size
1152            )));
1153        }
1154        Ok(payload)
1155    }
1156
1157    /// Read and decode a tile into its Arrow layers.
1158    pub fn read_layers(&self, entry: &TileEntry) -> Result<Vec<crate::arrow_tile::DecodedLayer>> {
1159        let payload = self.read_payload(entry)?;
1160        crate::arrow_tile::decode_tile(&payload)
1161    }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167    use crate::arrow_tile::{encode_tile, ColumnarLayer, GeometryColumn};
1168
1169    fn point_layer(name: &str, ids: Vec<u64>, t0: i64) -> ColumnarLayer {
1170        let n = ids.len();
1171        ColumnarLayer {
1172            name: name.to_string(),
1173            feature_ids: ids,
1174            start_times: vec![t0; n],
1175            end_times: vec![t0 + 100; n],
1176            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]; n]),
1177            vertex_times: None,
1178            vertex_values: None,
1179            triangles: None,
1180            vertex_value_matrix: None,
1181            properties: vec![],
1182        }
1183    }
1184
1185    /// Distinct-payload tile so each blob is unique (forces many packs at a tiny
1186    /// target). `seed` perturbs the feature ids so compressed bytes differ.
1187    fn distinct_tile(seed: u64) -> Vec<u8> {
1188        let ids: Vec<u64> = (0..6).map(|i| seed * 100 + i).collect();
1189        encode_tile(&[point_layer("default", ids, (seed as i64) * 7)]).unwrap()
1190    }
1191
1192    #[test]
1193    fn blake3_128_hex_is_32_chars() {
1194        let h = blake3_128_hex(b"hello");
1195        assert_eq!(h.len(), 32);
1196        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1197    }
1198
1199    #[test]
1200    fn manifest_json_roundtrips() {
1201        let m = Manifest {
1202            format: PACKED_FORMAT.to_string(),
1203            format_version: PACKED_FORMAT_VERSION,
1204            compression: "zstd".to_string(),
1205            directory: DirectoryRef {
1206                key: "index/abc.sttd".to_string(),
1207                length: 42,
1208                directory_version: 5,
1209                encoding: Some(DIRECTORY_ENCODING_ZSTD.to_string()),
1210                layout: None,
1211                root_length: None,
1212                page_count: None,
1213                page_entries: None,
1214            },
1215            packs: vec![
1216                PackRef { key: "packs/a.sttp".to_string(), length: 100 },
1217                PackRef { key: "packs/b.sttp".to_string(), length: 200 },
1218            ],
1219            metadata: Metadata::new("manifest-test"),
1220        };
1221        let bytes = m.to_json_bytes().unwrap();
1222        // The spec keys must be camelCase where renamed.
1223        let s = String::from_utf8(bytes.clone()).unwrap();
1224        assert!(s.contains("\"formatVersion\""), "{s}");
1225        assert!(s.contains("\"directoryVersion\""), "{s}");
1226        assert!(s.contains("\"stt-packed\""), "{s}");
1227        let back = Manifest::from_json_bytes(&bytes).unwrap();
1228        assert_eq!(back.format, m.format);
1229        assert_eq!(back.format_version, m.format_version);
1230        assert_eq!(back.packs.len(), 2);
1231        assert_eq!(back.directory.directory_version, 5);
1232        assert_eq!(back.directory.encoding.as_deref(), Some("zstd"));
1233        assert_eq!(back.metadata.name, "manifest-test");
1234
1235        // Backward compat: a pre-encoding manifest (no `encoding` key — the
1236        // shape of every deployed dataset) must parse with `encoding: None`,
1237        // and a None encoding must serialize without the key.
1238        let mut legacy_json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1239        legacy_json["directory"]
1240            .as_object_mut()
1241            .unwrap()
1242            .remove("encoding");
1243        let legacy_back =
1244            Manifest::from_json_bytes(&serde_json::to_vec(&legacy_json).unwrap()).unwrap();
1245        assert_eq!(legacy_back.directory.encoding, None);
1246        let legacy_out =
1247            String::from_utf8(legacy_back.to_json_bytes().unwrap()).unwrap();
1248        assert!(!legacy_out.contains("\"encoding\""), "{legacy_out}");
1249    }
1250
1251    /// Full round-trip: 30 synthetic tiles (a couple byte-identical to exercise
1252    /// dedup) across 2 zooms + several time buckets, written with a tiny pack
1253    /// target to force multiple packs. Every payload must decode byte-identical.
1254    #[test]
1255    fn packwriter_roundtrips_through_multiple_packs() {
1256        let dir = tempfile::tempdir().unwrap();
1257        let out = dir.path().join("dataset");
1258
1259        let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024).unwrap();
1260
1261        // Build 30 tiles. Tiles 0..28 distinct; the static one is reused so two
1262        // entries point at one byte-identical blob (dedup).
1263        let static_payload = encode_tile(&[point_layer("default", vec![7, 8, 9], 0)]).unwrap();
1264        let mut expected: Vec<(TileId, i64, i64, Vec<u8>)> = Vec::new();
1265        let bucket = 3_600_000i64;
1266        for k in 0..30u64 {
1267            let zoom = if k % 2 == 0 { 9u8 } else { 10u8 };
1268            let b = (k % 5) as i64; // several time buckets
1269            let t = b * bucket;
1270            let payload = if k == 13 || k == 27 {
1271                static_payload.clone() // two byte-identical → dedup
1272            } else {
1273                distinct_tile(k)
1274            };
1275            let id = TileId::new(zoom, (k % 7) as u32, (k / 7) as u32, t as u64);
1276            w.add_tile_full(&id, t, t + bucket - 1, Some(t), 6, Some(bucket as u64), &payload)
1277                .unwrap();
1278            expected.push((id, t, t + bucket - 1, payload));
1279        }
1280        assert_eq!(w.tile_count(), 30);
1281
1282        let meta = Metadata::new("packed-roundtrip").with_temporal_bucket_ms(bucket as u64);
1283        let manifest = w.finalize(&meta).unwrap();
1284
1285        // >1 pack produced at the tiny target.
1286        assert!(manifest.packs.len() > 1, "expected multiple packs, got {}", manifest.packs.len());
1287
1288        // Every pack file ≤ target, except a lone oversized blob owning a pack.
1289        for p in &manifest.packs {
1290            let bytes = fs::read(out.join(&p.key)).unwrap();
1291            assert_eq!(bytes.len() as u64, p.length);
1292            // A pack over target must contain exactly one blob (oversized loner).
1293            // We can't see blob boundaries here, but the cut rule guarantees a
1294            // pack only exceeds the target when it holds a single blob; the
1295            // round-trip below proves correctness regardless.
1296        }
1297
1298        // Pack/dir filenames are blake3-128 hex of their bytes.
1299        for p in &manifest.packs {
1300            let bytes = fs::read(out.join(&p.key)).unwrap();
1301            let hex = blake3_128_hex(&bytes);
1302            assert_eq!(p.key, format!("packs/{hex}.sttp"));
1303        }
1304        let dir_bytes = fs::read(out.join(&manifest.directory.key)).unwrap();
1305        assert_eq!(
1306            manifest.directory.key,
1307            format!("index/{}.sttd", blake3_128_hex(&dir_bytes))
1308        );
1309
1310        // pack_ids are contiguous from 0 across all entries.
1311        let reader = PackedReader::open(out.join("manifest.json")).unwrap();
1312        let max_pack = reader.entries().iter().map(|e| e.pack_id).max().unwrap();
1313        assert_eq!(max_pack as usize, manifest.packs.len() - 1);
1314        let observed: std::collections::BTreeSet<u32> =
1315            reader.entries().iter().map(|e| e.pack_id).collect();
1316        for pid in 0..manifest.packs.len() as u32 {
1317            assert!(observed.contains(&pid), "pack_id {pid} unused");
1318        }
1319
1320        // Metadata round-trips.
1321        assert_eq!(reader.metadata().name, "packed-roundtrip");
1322        assert_eq!(reader.metadata().temporal_bucket_ms, bucket as u64);
1323        assert_eq!(reader.entries().len(), 30);
1324
1325        // The manifest totals are derived from the directory at finalize —
1326        // the caller's Metadata left them 0 and finalize must overwrite them.
1327        assert_eq!(manifest.metadata.tile_count, 30);
1328        assert_eq!(manifest.metadata.feature_count, 30 * 6);
1329        assert_eq!(reader.metadata().tile_count, 30);
1330        assert_eq!(reader.metadata().feature_count, 30 * 6);
1331
1332        // Every tile's decompressed payload is byte-identical.
1333        for (id, ts, _te, payload) in &expected {
1334            let e = reader
1335                .entries()
1336                .iter()
1337                .find(|e| {
1338                    e.zoom == id.z && e.x == id.x && e.y == id.y && e.time_start == *ts
1339                })
1340                .expect("entry present");
1341            let got = reader.read_payload(e).unwrap();
1342            assert_eq!(&got, payload, "payload mismatch for {id:?}");
1343        }
1344
1345        // Dedup: the two static tiles share one (pack, offset).
1346        let static_entries: Vec<&TileEntry> = reader
1347            .entries()
1348            .iter()
1349            .filter(|e| {
1350                let g = reader.read_payload(e).unwrap();
1351                g == static_payload
1352            })
1353            .collect();
1354        assert_eq!(static_entries.len(), 2);
1355        assert_eq!(static_entries[0].pack_id, static_entries[1].pack_id);
1356        assert_eq!(static_entries[0].offset, static_entries[1].offset);
1357    }
1358
1359    /// A **paged** directory build round-trips end-to-end through
1360    /// `PackedReader`: every input tile's payload decodes byte-identically, the
1361    /// manifest carries the container fields, and content-address verification
1362    /// is clean. Separately, a paged and a single build of the same input agree
1363    /// on the payload-independent address keys (the cross-build *blob* bytes are
1364    /// not comparable — Arrow IPC schema-metadata ordering isn't reproducible
1365    /// across `finalize` runs, the documented D6 caveat; the codec-level
1366    /// "paged == whole-load" equality is proven in `directory_page` tests).
1367    #[test]
1368    fn paged_directory_writer_roundtrips_and_matches_single() {
1369        let bucket = 3_600_000i64;
1370        // Deterministic input tiles: (id, time_start, time_end, payload).
1371        let mut input: Vec<(TileId, i64, i64, Vec<u8>)> = Vec::new();
1372        for k in 0..120u64 {
1373            let zoom = [6u8, 10, 13][(k % 3) as usize];
1374            let b = (k % 4) as i64;
1375            let t = b * bucket;
1376            let id = TileId::new(zoom, (k % 11) as u32, (k / 11) as u32, t as u64);
1377            input.push((id, t, t + bucket - 1, distinct_tile(k)));
1378        }
1379        let build = |out: &Path, page_entries: Option<usize>| -> Manifest {
1380            let mut w = PackWriter::create(out, BlobOrdering::Auto, 16 * 1024)
1381                .unwrap()
1382                .with_paging(page_entries);
1383            for (id, ts, te, payload) in &input {
1384                w.add_tile_full(id, *ts, *te, Some(*ts), 6, Some(bucket as u64), payload)
1385                    .unwrap();
1386            }
1387            let meta = Metadata::new("paged-roundtrip").with_temporal_bucket_ms(bucket as u64);
1388            w.finalize(&meta).unwrap()
1389        };
1390
1391        let dir = tempfile::tempdir().unwrap();
1392        let single_out = dir.path().join("single");
1393        let paged_out = dir.path().join("paged");
1394        let single = build(&single_out, None);
1395        // Small page size to force several leaf pages over 120 entries.
1396        let paged = build(&paged_out, Some(16));
1397
1398        // Paged manifest carries the container fields; single does not.
1399        assert!(single.directory.layout.is_none());
1400        assert_eq!(paged.directory.layout.as_deref(), Some(DIRECTORY_LAYOUT_PAGED));
1401        assert!(paged.directory.root_length.unwrap() > 0);
1402        assert!(paged.directory.page_count.unwrap() >= 2, "expected multiple leaf pages");
1403        assert_eq!(paged.directory.page_entries, Some(16));
1404        assert_eq!(paged.directory.directory_version, crate::directory::DIRECTORY_VERSION);
1405        assert_eq!(paged.directory.encoding.as_deref(), Some(DIRECTORY_ENCODING_ZSTD));
1406
1407        let r_single = PackedReader::open(single_out.join("manifest.json")).unwrap();
1408        let r_paged = PackedReader::open(paged_out.join("manifest.json")).unwrap();
1409        assert_eq!(r_paged.entries().len(), 120);
1410        assert_eq!(r_single.entries().len(), 120);
1411
1412        // Cross-build agreement on payload-INDEPENDENT address keys (sorted, so
1413        // the comparison ignores blob-byte non-reproducibility).
1414        let keys = |r: &PackedReader| -> Vec<(u8, u32, u32, i64, i64, u32, Option<u64>, Option<i64>)> {
1415            let mut v: Vec<_> = r
1416                .entries()
1417                .iter()
1418                .map(|e| {
1419                    (
1420                        e.zoom, e.x, e.y, e.time_start, e.time_end, e.feature_count,
1421                        e.temporal_bucket_ms, e.cover_t_min,
1422                    )
1423                })
1424                .collect();
1425            v.sort();
1426            v
1427        };
1428        assert_eq!(keys(&r_single), keys(&r_paged));
1429
1430        // Every INPUT tile decodes byte-identically through the paged reader.
1431        for (id, ts, _te, payload) in &input {
1432            let e = r_paged
1433                .entries()
1434                .iter()
1435                .find(|x| x.zoom == id.z && x.x == id.x && x.y == id.y && x.time_start == *ts)
1436                .expect("paged entry present");
1437            assert_eq!(&r_paged.read_payload(e).unwrap(), payload, "payload mismatch {id:?}");
1438        }
1439
1440        // Content-address integrity verifies clean on the paged dataset.
1441        let issues = verify_packed_objects(paged_out.join("manifest.json")).unwrap();
1442        assert!(issues.is_empty(), "paged verify issues: {issues:?}");
1443    }
1444
1445    /// `repack_directory` switches a built dataset's directory to paged WITHOUT
1446    /// re-reading payloads: the packs keep their exact content addresses, the
1447    /// re-opened entries are identical, and the result verifies clean.
1448    #[test]
1449    fn repack_directory_to_paged_preserves_packs_and_entries() {
1450        let dir = tempfile::tempdir().unwrap();
1451        let src = dir.path().join("src");
1452        let out = dir.path().join("out");
1453
1454        // A small single-directory dataset.
1455        let mut w = PackWriter::create(&src, BlobOrdering::Auto, 16 * 1024).unwrap();
1456        for k in 0..80u64 {
1457            let zoom = [8u8, 11][(k % 2) as usize];
1458            let t = (k % 3) as i64 * 3_600_000;
1459            let id = TileId::new(zoom, (k % 9) as u32, (k / 9) as u32, t as u64);
1460            w.add_tile_full(&id, t, t + 3_599_999, Some(t), 4, Some(3_600_000), &distinct_tile(k))
1461                .unwrap();
1462        }
1463        let src_manifest = w.finalize(&Metadata::new("repack-src")).unwrap();
1464        assert!(src_manifest.directory.layout.is_none());
1465
1466        // Directory-only re-pack into a fresh dir, paged.
1467        let out_manifest = repack_directory(src.join("manifest.json"), &out, Some(8)).unwrap();
1468        assert_eq!(out_manifest.directory.layout.as_deref(), Some(DIRECTORY_LAYOUT_PAGED));
1469        assert!(out_manifest.directory.page_count.unwrap() >= 2);
1470
1471        // Packs are byte-identical content addresses (only the directory changed).
1472        let src_keys: std::collections::BTreeSet<&str> =
1473            src_manifest.packs.iter().map(|p| p.key.as_str()).collect();
1474        let out_keys: std::collections::BTreeSet<&str> =
1475            out_manifest.packs.iter().map(|p| p.key.as_str()).collect();
1476        assert_eq!(src_keys, out_keys);
1477
1478        // Re-opened entries are identical, and integrity verifies clean.
1479        let r_src = PackedReader::open(src.join("manifest.json")).unwrap();
1480        let r_out = PackedReader::open(out.join("manifest.json")).unwrap();
1481        assert_eq!(r_src.entries(), r_out.entries());
1482        // A payload reads back identically through the re-packed (paged) dataset.
1483        let e = &r_out.entries()[0];
1484        assert_eq!(r_out.read_payload(e).unwrap(), r_src.read_payload(e).unwrap());
1485        assert!(verify_packed_objects(out.join("manifest.json")).unwrap().is_empty());
1486    }
1487
1488    /// A single blob larger than the pack target gets its own pack (never split).
1489    #[test]
1490    fn oversized_blob_gets_its_own_pack() {
1491        let dir = tempfile::tempdir().unwrap();
1492        let out = dir.path().join("dataset");
1493
1494        // One big tile (lots of distinct points → big compressed blob) plus a
1495        // few small ones, with a target smaller than the big blob.
1496        let big_ids: Vec<u64> = (0..4000).collect();
1497        let big = encode_tile(&[ColumnarLayer {
1498            name: "default".to_string(),
1499            feature_ids: big_ids.clone(),
1500            start_times: vec![0; big_ids.len()],
1501            end_times: vec![100; big_ids.len()],
1502            geometry: GeometryColumn::Point(
1503                (0..big_ids.len()).map(|i| [i as f64 * 0.01, i as f64 * 0.013]).collect(),
1504            ),
1505            vertex_times: None,
1506            vertex_values: None,
1507            triangles: None,
1508            vertex_value_matrix: None,
1509            properties: vec![],
1510        }])
1511        .unwrap();
1512        let big_compressed_len = compression::compress_zstd_with_dict(&big, None).unwrap().len();
1513
1514        let mut w = PackWriter::create(&out, BlobOrdering::SpatialMajor, 4 * 1024).unwrap();
1515        // target 4 KiB < big blob.
1516        assert!(big_compressed_len as u64 > 4 * 1024);
1517        w.add_tile_full(&TileId::new(10, 0, 0, 0), 0, 100, None, 4000, None, &big).unwrap();
1518        for k in 1..4u64 {
1519            let p = distinct_tile(k);
1520            w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p).unwrap();
1521        }
1522        let _manifest = w.finalize(&Metadata::new("big")).unwrap();
1523
1524        // At least one pack exceeds the target (the loner). Reading proves it.
1525        let reader = PackedReader::open(out.join("manifest.json")).unwrap();
1526        let big_entry = reader.entries().iter().find(|e| e.x == 0).unwrap();
1527        assert_eq!(reader.read_payload(big_entry).unwrap(), big);
1528    }
1529
1530    /// Two builds of the same input — added in different orders, including
1531    /// curve-key ties (a base + temporal-LOD entry on one cell) — must produce
1532    /// byte-identical objects: same pack hashes, same directory hash. This is
1533    /// the immutable-pack CDN contract (a rebuild of unchanged data must not
1534    /// invalidate the edge cache).
1535    #[test]
1536    fn rebuilds_are_byte_reproducible() {
1537        // Tiles including a tie pair: same (z, x, y, time_start), one base
1538        // (bucket None) and one LOD (bucket Some) — the curve key alone can't
1539        // order them, the tiebreak must.
1540        let bucket = 3_600_000i64;
1541        let mut tiles: Vec<(TileId, i64, Option<u64>, Vec<u8>)> = Vec::new();
1542        for k in 0..10u64 {
1543            let t = (k % 4) as i64 * bucket;
1544            tiles.push((
1545                TileId::new(9, (k % 5) as u32, (k / 5) as u32, t as u64),
1546                t,
1547                None,
1548                distinct_tile(k),
1549            ));
1550        }
1551        // The tie pair on cell (1, 0): base + LOD aggregate at one time_start.
1552        tiles.push((TileId::new(9, 1, 0, 0), 0, None, distinct_tile(100)));
1553        tiles.push((
1554            TileId::new(9, 1, 0, 0),
1555            0,
1556            Some(24 * bucket as u64),
1557            distinct_tile(101),
1558        ));
1559
1560        let meta = Metadata::new("repro").with_temporal_bucket_ms(bucket as u64);
1561        let build = |order: &[usize]| {
1562            let dir = tempfile::tempdir().unwrap();
1563            let out = dir.path().join("dataset");
1564            let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024).unwrap();
1565            for &i in order {
1566                let (id, t, b, payload) = &tiles[i];
1567                w.add_tile_full(id, *t, t + bucket - 1, Some(*t), 6, *b, payload)
1568                    .unwrap();
1569            }
1570            let manifest = w.finalize(&meta).unwrap();
1571            (dir, manifest)
1572        };
1573
1574        let forward: Vec<usize> = (0..tiles.len()).collect();
1575        let reverse: Vec<usize> = (0..tiles.len()).rev().collect();
1576        let (_d1, m1) = build(&forward);
1577        let (_d2, m2) = build(&reverse);
1578
1579        assert_eq!(m1.directory.key, m2.directory.key, "directory hash must be stable");
1580        assert_eq!(
1581            m1.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
1582            m2.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
1583            "pack hashes must be stable across rebuilds"
1584        );
1585        assert_eq!(m1.to_json_bytes().unwrap(), m2.to_json_bytes().unwrap());
1586    }
1587
1588    /// The directory ships zstd-compressed at rest (declared via
1589    /// `directory.encoding`), and a legacy manifest with a RAW directory and
1590    /// no `encoding` key — the shape of every deployed dataset — must still
1591    /// open and verify.
1592    #[test]
1593    fn directory_encoding_compressed_and_raw_both_read() {
1594        let dir = tempfile::tempdir().unwrap();
1595        let out = dir.path().join("dataset");
1596        let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024).unwrap();
1597        for k in 0..8u64 {
1598            let p = distinct_tile(k);
1599            w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p)
1600                .unwrap();
1601        }
1602        let manifest = w.finalize(&Metadata::new("dir-enc")).unwrap();
1603        let manifest_path = out.join("manifest.json");
1604
1605        // Fresh output declares the encoding and the at-rest bytes are a
1606        // valid zstd frame that inflates to the codec bytes.
1607        assert_eq!(manifest.directory.encoding.as_deref(), Some("zstd"));
1608        let at_rest = fs::read(out.join(&manifest.directory.key)).unwrap();
1609        assert_eq!(at_rest.len() as u64, manifest.directory.length);
1610        let raw = compression::decompress_zstd_with_dict(&at_rest, None).unwrap();
1611        assert!(crate::directory::decode_directory(&raw).is_ok());
1612        assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
1613        let entries_compressed = PackedReader::open(&manifest_path).unwrap().entries().to_vec();
1614
1615        // Rewrite the dataset as a legacy (raw-directory) manifest: store the
1616        // raw codec bytes under their own content address and drop `encoding`.
1617        let raw_hex = blake3_128_hex(&raw);
1618        let raw_rel = format!("index/{raw_hex}.sttd");
1619        fs::write(out.join(&raw_rel), &raw).unwrap();
1620        let mut legacy = manifest.clone();
1621        legacy.directory = DirectoryRef {
1622            key: raw_rel,
1623            length: raw.len() as u64,
1624            directory_version: crate::directory::DIRECTORY_VERSION,
1625            encoding: None,
1626            layout: None,
1627            root_length: None,
1628            page_count: None,
1629            page_entries: None,
1630        };
1631        fs::write(&manifest_path, legacy.to_json_bytes().unwrap()).unwrap();
1632
1633        assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
1634        let entries_raw = PackedReader::open(&manifest_path).unwrap().entries().to_vec();
1635        assert_eq!(entries_raw, entries_compressed);
1636
1637        // An unknown encoding must fail loudly, not decode garbage.
1638        legacy.directory.encoding = Some("br".to_string());
1639        fs::write(&manifest_path, legacy.to_json_bytes().unwrap()).unwrap();
1640        assert!(PackedReader::open(&manifest_path).is_err());
1641    }
1642
1643    /// A clean packed dataset verifies with no issues; corrupting a pack's
1644    /// bytes (without changing its length) breaks the content address and is
1645    /// reported.
1646    #[test]
1647    fn verify_packed_objects_clean_then_detects_corruption() {
1648        let dir = tempfile::tempdir().unwrap();
1649        let out = dir.path().join("dataset");
1650
1651        let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024).unwrap();
1652        for k in 0..12u64 {
1653            let p = distinct_tile(k);
1654            w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p)
1655                .unwrap();
1656        }
1657        let manifest = w.finalize(&Metadata::new("verify")).unwrap();
1658        let manifest_path = out.join("manifest.json");
1659
1660        // Clean dataset → no integrity violations.
1661        assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
1662
1663        // Flip a byte in pack 0: same length, but blake3 no longer matches the
1664        // filename it was addressed by.
1665        let pack0 = out.join(&manifest.packs[0].key);
1666        let mut bytes = fs::read(&pack0).unwrap();
1667        bytes[0] ^= 0xff;
1668        fs::write(&pack0, &bytes).unwrap();
1669
1670        let issues = verify_packed_objects(&manifest_path).unwrap();
1671        assert!(
1672            issues.iter().any(|s| s.contains("content-address mismatch")),
1673            "expected a content-address mismatch, got {issues:?}"
1674        );
1675    }
1676}