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 (`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 `ArchiveReader` semantics.
33
34use crate::arrow_tile::{TemplateCollector, TemplateRegistry};
35use crate::compression;
36use crate::directory::TileEntry;
37use crate::curve::BlobOrdering;
38use crate::error::{Error, Result};
39use crate::metadata::Metadata;
40use crate::tile::TileId;
41use crate::types::Compression;
42use memmap2::Mmap;
43use rayon::prelude::*;
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46use std::fs::{self, File, OpenOptions};
47use std::io::Write;
48use std::path::{Path, PathBuf};
49use std::sync::Arc;
50
51/// `format` discriminator written into every packed manifest.
52pub const PACKED_FORMAT: &str = "stt-packed";
53
54/// The 0.3.x packed format: no object magic, v1 layer frames, no manifest
55/// `schemas` table. Frozen — [`PackWriter::with_format_version`]`(1)` MUST
56/// keep reproducing it byte-identically (pinned by `tests/v1_golden.rs`).
57pub const PACKED_FORMAT_VERSION_V1: u32 = 1;
58/// The 2026-07 coordinated byte break
59/// (`docs/roadmap/stt-packed-v2-design-2026-07.md`): `STTP`/`STTD` object
60/// magic, object-absolute blob offsets, and v2 sectioned layer frames
61/// referencing manifest-embedded schema templates (`manifest.schemas`).
62pub const PACKED_FORMAT_VERSION_V2: u32 = 2;
63/// Packed-format version (the manifest schema, distinct from the directory
64/// codec's [`crate::directory::DIRECTORY_VERSION`]) this writer emits by
65/// default. `stt-build --format-version 1` is the transitional kill switch.
66pub const PACKED_FORMAT_VERSION: u32 = PACKED_FORMAT_VERSION_V2;
67/// Every `manifest.formatVersion` this toolchain's readers accept.
68pub const SUPPORTED_PACKED_FORMAT_VERSIONS: [u32; 2] =
69    [PACKED_FORMAT_VERSION_V1, PACKED_FORMAT_VERSION_V2];
70
71// ----------------------------------------------------------------------------
72// v2 object magic (`.sttp` / `.sttd` self-identification, spec §9.2)
73// ----------------------------------------------------------------------------
74
75/// First 4 bytes of a formatVersion-2 pack object.
76pub const PACK_MAGIC: [u8; 4] = *b"STTP";
77/// First 4 bytes of a formatVersion-2 directory object.
78pub const DIRECTORY_MAGIC: [u8; 4] = *b"STTD";
79/// Length of the v2 object magic prelude: 4-byte tag + u8 version(2) + 3 zero
80/// bytes. Blob offsets in a v2 directory are object-absolute, so a pack's
81/// first blob sits at offset 8; the manifest `length` fields and every blake3
82/// content address cover the ENTIRE object including this prelude (★F5).
83/// v1 objects carry no magic (byte-frozen).
84pub const OBJECT_MAGIC_LEN: usize = 8;
85/// Version byte inside the v2 object magic prelude.
86const OBJECT_MAGIC_VERSION: u8 = 2;
87
88/// The full 8-byte magic prelude for a v2 object kind.
89fn object_magic(kind: [u8; 4]) -> [u8; OBJECT_MAGIC_LEN] {
90    [kind[0], kind[1], kind[2], kind[3], OBJECT_MAGIC_VERSION, 0, 0, 0]
91}
92
93/// Validate a v2 object's 8-byte magic prelude and return the bytes after it.
94fn strip_object_magic<'a>(bytes: &'a [u8], kind: [u8; 4], what: &str) -> Result<&'a [u8]> {
95    let tag = std::str::from_utf8(&kind).expect("magic tags are ASCII");
96    if bytes.len() < OBJECT_MAGIC_LEN || bytes[0..4] != kind {
97        return Err(Error::InvalidArchive(format!(
98            "{what}: missing {tag:?} magic (formatVersion-2 objects start with an \
99             8-byte magic prelude)"
100        )));
101    }
102    if bytes[4] != OBJECT_MAGIC_VERSION {
103        return Err(Error::InvalidArchive(format!(
104            "{what}: unsupported {tag} object version {} (this reader supports \
105             {OBJECT_MAGIC_VERSION})",
106            bytes[4]
107        )));
108    }
109    if bytes[5..OBJECT_MAGIC_LEN] != [0, 0, 0] {
110        return Err(Error::InvalidArchive(format!(
111            "{what}: reserved {tag} magic bytes must be zero"
112        )));
113    }
114    Ok(&bytes[OBJECT_MAGIC_LEN..])
115}
116
117/// A directory object's codec bytes: the whole object under v1, the
118/// post-magic slice under v2. (`rootLength` keeps meaning the root frame's
119/// at-rest length — a paged root fetch is `bytes=0..(8+rootLength-1)` — so
120/// all downstream paged math is unchanged once the magic is stripped.)
121/// Public so out-of-band directory consumers (e.g. `stt-validate`'s direct
122/// paged-structure re-check) apply the same version-aware unwrap.
123pub fn directory_codec_bytes(bytes: &[u8], format_version: u32) -> Result<&[u8]> {
124    if format_version == PACKED_FORMAT_VERSION_V2 {
125        strip_object_magic(bytes, DIRECTORY_MAGIC, "directory object")
126    } else {
127        Ok(bytes)
128    }
129}
130
131/// `manifest.capabilities` entry: coordinate quantization (`stt:quant`
132/// re-types the existing `geometry` column to fixed-point `Int32`).
133pub const CAPABILITY_COORD_QUANT: &str = "coord-quant";
134/// `manifest.capabilities` entry: numeric-attribute quantization (`stt:qa`
135/// re-types existing property columns to fixed-point integers).
136pub const CAPABILITY_ATTR_QUANT: &str = "attr-quant";
137/// `manifest.capabilities` entry: point-elevation fold (a property folded into
138/// POINT geometry z — the point leaf becomes 3 components instead of 2).
139pub const CAPABILITY_ELEVATION_FOLD: &str = "elevation-fold";
140/// Every `manifest.capabilities` value this toolchain implements — the
141/// required-to-understand feature registry (`docs/spec/stt-packed-format.md`
142/// §3.1). Each capability RE-TYPES existing columns, so a reader that lacks it
143/// wouldn't error downstream — it would silently misdecode (e.g. `Int32` grid
144/// indices read as microscopic lon/lat degrees). A reader MUST therefore
145/// refuse, at open, any dataset declaring a capability outside its own set.
146pub const KNOWN_CAPABILITIES: &[&str] = &[
147    CAPABILITY_COORD_QUANT,
148    CAPABILITY_ATTR_QUANT,
149    CAPABILITY_ELEVATION_FOLD,
150];
151
152/// Default pack target size — 64 MiB. Well under the 512 MB CDN per-object cap,
153/// with enough granularity for fine cache + parallel range reads.
154pub const DEFAULT_PACK_TARGET_BYTES: u64 = 64 * 1024 * 1024;
155
156/// CRC32C integrity tag for a compressed blob (mirrors the archive writer).
157fn crc32c_tag(bytes: &[u8]) -> u32 {
158    crc32c::crc32c(bytes)
159}
160
161/// blake3 content address, 128-bit → 32 lowercase hex chars.
162fn blake3_128_hex(bytes: &[u8]) -> String {
163    let hash = blake3::hash(bytes);
164    // Take the first 16 bytes (128 bits) of the 256-bit digest.
165    hash.as_bytes()[..16]
166        .iter()
167        .map(|b| format!("{b:02x}"))
168        .collect()
169}
170
171// ----------------------------------------------------------------------------
172// Manifest
173// ----------------------------------------------------------------------------
174
175/// At-rest encoding value for a zstd-compressed directory object.
176pub const DIRECTORY_ENCODING_ZSTD: &str = "zstd";
177
178/// `directory.layout` value for the paged container (root page + leaf pages,
179/// each independently framed). Absent or `"single"` = the whole-load v5 object.
180pub const DIRECTORY_LAYOUT_PAGED: &str = "paged";
181/// `directory.layout` value for the single whole-load object (the default).
182pub const DIRECTORY_LAYOUT_SINGLE: &str = "single";
183
184/// Pointer to the encoded directory object.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct DirectoryRef {
187    /// Object key, relative to the dataset root (e.g. `index/<hash>.sttd`).
188    pub key: String,
189    /// Directory object length in bytes (the at-rest object, i.e. the
190    /// compressed length when `encoding` is set).
191    pub length: u64,
192    /// Directory codec version (`5` for the packed format). The leaf pages of a
193    /// paged directory are this same v5 codec — `layout` (below), not this
194    /// version, discriminates the container shape.
195    #[serde(rename = "directoryVersion")]
196    pub directory_version: u8,
197    /// At-rest encoding of the directory object. `Some("zstd")` means the
198    /// object bytes are a zstd frame wrapping the codec bytes; absent (the
199    /// shape every pre-encoding manifest has) means raw codec bytes. For a
200    /// paged directory it describes the framing of **each page** (root + every
201    /// leaf), not one frame over the whole object. The content address and
202    /// `length` always describe the at-rest bytes.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub encoding: Option<String>,
205    /// Container layout. `Some("paged")` = a root page + leaf pages (Wave 2);
206    /// absent or `Some("single")` = the single whole-load object. Readers that
207    /// don't know `"paged"` fail loudly (the root's first byte isn't a valid v5
208    /// directory version), which is why readers ship before any paged dataset.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub layout: Option<String>,
211    /// At-rest byte length of the root page (a prefix of the object). Present
212    /// iff `layout == "paged"`; the reader range-GETs `bytes=0-(rootLength-1)`
213    /// for the root under formatVersion 1, or `bytes=0-(8+rootLength-1)` under
214    /// formatVersion 2 (the 8-byte object magic shifts the root), then leaf
215    /// ranges on demand.
216    #[serde(default, rename = "rootLength", skip_serializing_if = "Option::is_none")]
217    pub root_length: Option<u64>,
218    /// Number of leaf pages (informational / validation). Paged only.
219    #[serde(default, rename = "pageCount", skip_serializing_if = "Option::is_none")]
220    pub page_count: Option<u64>,
221    /// Nominal entries-per-page used at build (informational). Paged only.
222    #[serde(default, rename = "pageEntries", skip_serializing_if = "Option::is_none")]
223    pub page_entries: Option<u64>,
224}
225
226impl DirectoryRef {
227    /// Is this a paged-container directory?
228    pub fn is_paged(&self) -> bool {
229        self.layout.as_deref() == Some(DIRECTORY_LAYOUT_PAGED)
230    }
231}
232
233/// Pointer to one pack object. The position in `Manifest::packs` **is** the
234/// `pack_id` the directory references.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct PackRef {
237    /// Object key, relative to the dataset root (e.g. `packs/<hash>.sttp`).
238    pub key: String,
239    /// Pack object length in bytes.
240    pub length: u64,
241}
242
243/// One schema-template entry of a formatVersion-2 manifest's `schemas` table:
244/// the blake3-128 content hash of the raw template bytes (32 lowercase hex
245/// chars — the hex form of the 16-byte reference v2 layer frames embed) and
246/// the raw template bytes, base64-encoded (standard alphabet, padded).
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct SchemaTemplateRef {
249    /// blake3-128 of the RAW (decoded) template bytes, lowercase hex.
250    pub hash: String,
251    /// The raw template bytes, base64.
252    pub data: String,
253}
254
255/// Decode + hash-validate a manifest's `schemas` table into the decode-side
256/// [`TemplateRegistry`]. Every entry must base64-decode and blake3-hash to
257/// its declared `hash` — the loud, dataset-level failure mode for corrupt
258/// manifests (design §2.2).
259fn build_template_registry(schemas: &[SchemaTemplateRef]) -> Result<TemplateRegistry> {
260    use base64::Engine as _;
261    let mut registry = TemplateRegistry::new();
262    for (i, s) in schemas.iter().enumerate() {
263        let data = base64::engine::general_purpose::STANDARD
264            .decode(&s.data)
265            .map_err(|e| {
266                Error::InvalidArchive(format!(
267                    "manifest schemas[{i}] ({}): base64 decode failed: {e}",
268                    s.hash
269                ))
270            })?;
271        let actual = blake3_128_hex(&data);
272        if actual != s.hash {
273            return Err(Error::InvalidArchive(format!(
274                "manifest schemas[{i}]: template bytes hash to {actual}, declared {}",
275                s.hash
276            )));
277        }
278        registry.insert(data);
279    }
280    Ok(registry)
281}
282
283/// The packed-format `manifest.json` — the only mutable object per dataset.
284///
285/// Folds the metadata, directory pointer and pack table into one tiny JSON so a
286/// cold reader needs exactly one manifest + one directory + N pack-range fetches
287/// (no separate header or metadata object).
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct Manifest {
290    /// Always [`PACKED_FORMAT`] (`"stt-packed"`).
291    pub format: String,
292    /// Manifest schema version.
293    #[serde(rename = "formatVersion")]
294    pub format_version: u32,
295    /// Required-to-understand feature declarations (spec §3.1). Each entry
296    /// names a feature the writer used that RE-TYPES existing tile columns
297    /// (registry: [`KNOWN_CAPABILITIES`]); a reader MUST refuse a dataset
298    /// declaring a capability it does not implement. Empty when no such
299    /// feature was used — omitted from the JSON so pre-capabilities builds
300    /// stay byte-identical. Additive columns never need a capability.
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub capabilities: Vec<String>,
303    /// formatVersion ≥ 2: the dataset's Arrow schema **templates**, embedded
304    /// directly in the manifest (no extra object class — every session
305    /// already fetches the manifest). Each entry is a layer schema's IPC
306    /// prefix, referenced from v2 layer frames by blake3-128 hash. Sorted by
307    /// `hash` and deduped (byte-reproducible manifests); a reader validates
308    /// `blake3(data) == hash` for every entry at open, so a corrupt manifest
309    /// fails loudly, dataset-level, before any tile fetch. Absent/empty on
310    /// formatVersion-1 manifests (the key is omitted).
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub schemas: Vec<SchemaTemplateRef>,
313    /// Blob compression codec (always `"zstd"`, per-blob, no shared dict).
314    pub compression: String,
315    /// The concrete blob byte-ordering the writer resolved and laid down
316    /// (`spatial` | `time-major` | `hilbert3` | `morton3`) — see
317    /// [`crate::curve::BlobOrdering`]. Additive/optional: pre-2026-07 archives
318    /// omit it (a reader infers the order from the `(pack_id, offset)` layout).
319    /// Never `auto`/`measured` — those resolve to a concrete order at build.
320    /// Omitted from the JSON when `None` so pre-field builds stay byte-identical.
321    #[serde(default, rename = "blobOrdering", skip_serializing_if = "Option::is_none")]
322    pub blob_ordering: Option<String>,
323    /// Pointer to the encoded directory object.
324    pub directory: DirectoryRef,
325    /// Pack table. Index == `pack_id`.
326    pub packs: Vec<PackRef>,
327    /// The full `crate::metadata::Metadata` JSON, verbatim.
328    pub metadata: Metadata,
329}
330
331impl Manifest {
332    /// Parse a manifest from its JSON bytes.
333    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
334        serde_json::from_slice(bytes)
335            .map_err(|e| Error::InvalidArchive(format!("manifest JSON decode failed: {e}")))
336    }
337
338    /// Serialise the manifest to pretty JSON bytes.
339    pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
340        serde_json::to_vec_pretty(self)
341            .map_err(|e| Error::Other(format!("manifest JSON encode failed: {e}")))
342    }
343}
344
345// ----------------------------------------------------------------------------
346// Writer
347// ----------------------------------------------------------------------------
348
349/// A tile buffered for deferred ordering + per-blob compression + pack-cutting.
350struct PendingTile {
351    z: u8,
352    x: u32,
353    y: u32,
354    hilbert: u64,
355    time_start: i64,
356    time_end: i64,
357    cover_t_min: Option<i64>,
358    feature_count: u32,
359    temporal_bucket_ms: Option<u64>,
360    payload: PendingPayload,
361}
362
363/// Where a pending tile's uncompressed payload lives: in RAM (the legacy
364/// behaviour, and always the first `memory_budget` bytes), or appended to the
365/// writer's temp spill file as an `(offset, len)` record once the in-memory
366/// budget is exhausted. Either way the ~100 B of per-tile metadata above stays
367/// in RAM — only the payload bytes change medium, so the finalize sort/dedup/
368/// cut logic (and therefore every output byte) is identical in both modes.
369enum PendingPayload {
370    Mem(Vec<u8>),
371    /// `len` is u64 to match [`SpillFile`]'s u64 offsets — a narrower field
372    /// here would silently truncate what the spill file faithfully stored.
373    /// (Payloads over u32::MAX never get this far: [`check_payload_len`]
374    /// rejects them at [`PackWriter::add_tile_full`].)
375    Spilled { offset: u64, len: u64 },
376}
377
378impl PendingPayload {
379    /// Uncompressed payload length in bytes.
380    fn len(&self) -> usize {
381        match self {
382            PendingPayload::Mem(v) => v.len(),
383            PendingPayload::Spilled { len, .. } => *len as usize,
384        }
385    }
386}
387
388/// Reject payloads the packed format cannot represent: the directory's
389/// `uncompressed_size` is a u32 field, so a tile payload of 4 GiB or more
390/// must be a loud, descriptive error on BOTH storage paths (in-RAM and
391/// spilled) — never a silent length truncation that surfaces as corruption
392/// at read time. Extracted so the bound is unit-testable without a 4 GiB
393/// allocation.
394fn check_payload_len(id: &TileId, len: u64) -> Result<()> {
395    if len > u32::MAX as u64 {
396        return Err(Error::Other(format!(
397            "tile {id:?}: payload is {len} bytes, exceeding the directory's u32 \
398             uncompressed_size field (the packed format cannot represent tiles of \
399             4 GiB or larger) — split the tile"
400        )));
401    }
402    Ok(())
403}
404
405/// Temp file holding spilled tile payloads, created lazily in the OUTPUT
406/// directory (same filesystem as the final objects — no /tmp capacity or
407/// cross-device concerns). Append-only during the add phase; random-access
408/// reads during finalize's chunked compression pass. Removed on drop, which
409/// covers success, error and abandoned-writer paths alike.
410struct SpillFile {
411    file: File,
412    path: PathBuf,
413    /// Bytes appended so far == the next record's offset.
414    len: u64,
415}
416
417impl SpillFile {
418    fn create(dir: &Path) -> Result<Self> {
419        fs::create_dir_all(dir)?;
420        let nanos = std::time::SystemTime::now()
421            .duration_since(std::time::UNIX_EPOCH)
422            .map(|d| d.as_nanos())
423            .unwrap_or(0);
424        let path = dir.join(format!(".spill-{}-{nanos:x}", std::process::id()));
425        let file = OpenOptions::new()
426            .read(true)
427            .write(true)
428            .create_new(true)
429            .open(&path)?;
430        Ok(Self { file, path, len: 0 })
431    }
432
433    /// Append one payload, returning its record offset.
434    fn append(&mut self, payload: &[u8]) -> Result<u64> {
435        use std::io::{Seek, SeekFrom};
436        let offset = self.len;
437        let mut f = &self.file;
438        f.seek(SeekFrom::Start(offset))?;
439        f.write_all(payload)?;
440        self.len += payload.len() as u64;
441        Ok(offset)
442    }
443
444    /// Read one payload record back. Only called from finalize's sequential
445    /// materialisation pass (after all appends), so sharing the file cursor
446    /// is safe.
447    fn read(&self, offset: u64, len: usize) -> Result<Vec<u8>> {
448        use std::io::{Read, Seek, SeekFrom};
449        let mut f = &self.file;
450        f.seek(SeekFrom::Start(offset))?;
451        let mut buf = vec![0u8; len];
452        f.read_exact(&mut buf)?;
453        Ok(buf)
454    }
455}
456
457impl Drop for SpillFile {
458    fn drop(&mut self) {
459        let _ = fs::remove_file(&self.path);
460    }
461}
462
463/// Writer for the multi-object packed format.
464///
465/// Consumes an `(id, payload)` stream like `ArchiveWriter`,
466/// buffering every tile until [`finalize`](Self::finalize). At finalize it
467/// reuses the buffered-writer pipeline — resolve [`BlobOrdering::Auto`], sort by
468/// the space-time curve key, per-blob zstd (NO shared dictionary, so the fzstd
469/// TS reader can decode), byte-identical dedup via blake3 — then cuts the
470/// ordered/deduped blob stream into packs of `≤ pack_target_bytes`, never
471/// splitting a blob, and writes content-addressed `packs/*.sttp` +
472/// `index/*.sttd` + `manifest.json`.
473pub struct PackWriter {
474    out_dir: PathBuf,
475    ordering: BlobOrdering,
476    pack_target_bytes: u64,
477    pending: Vec<PendingTile>,
478    /// `Some(k)` → emit a **paged** directory (root + leaf pages of ≤ `k`
479    /// entries; see [`crate::directory_page`]); `None` → the single whole-load
480    /// v5 directory (the default). Set via [`with_paging`](Self::with_paging).
481    page_entries: Option<usize>,
482    /// zstd level for per-blob + directory compression. Defaults to
483    /// [`compression::ZSTD_LEVEL`]; a publish build raises it via
484    /// [`with_zstd_level`](Self::with_zstd_level). Decode is level-independent,
485    /// so this only trades build CPU for smaller on-the-wire bytes.
486    zstd_level: i32,
487    /// `manifest.capabilities` — required-to-understand feature declarations.
488    /// Set via [`with_capabilities`](Self::with_capabilities); empty (the
489    /// default) omits the key from the manifest.
490    capabilities: Vec<String>,
491    /// Opt into the **measured** ordering picker: resolve the concrete on-disk
492    /// order by simulating per-ordering range-read cost ([`crate::ordering_sim`])
493    /// rather than the `auto` cardinality heuristic. Set via
494    /// [`with_measured_ordering`](Self::with_measured_ordering); `false` (the
495    /// default) keeps the `ordering` argument's behaviour.
496    measured_ordering: bool,
497    /// Packed format version to emit ([`PACKED_FORMAT_VERSION_V1`] |
498    /// [`PACKED_FORMAT_VERSION_V2`]). Defaults to v2; v1 is the transitional
499    /// kill switch and MUST reproduce the 0.3.x bytes exactly (no object
500    /// magic, `formatVersion: 1`, no `schemas` — pinned by
501    /// `tests/v1_golden.rs`). Set via
502    /// [`with_format_version`](Self::with_format_version).
503    format_version: u32,
504    /// v2 schema-template sink: the encoder records each layer's stripped
505    /// schema prefix here (wire it via [`template_collector`]
506    /// (Self::template_collector) → [`crate::arrow_tile::EncoderConfig::
507    /// template_collector`] / [`crate::arrow_tile::set_template_collector`]);
508    /// finalize publishes the snapshot as `manifest.schemas`.
509    templates: Arc<TemplateCollector>,
510    /// In-memory budget (bytes) for buffered UNCOMPRESSED tile payloads.
511    /// `0` (the default) = unlimited — every payload stays in RAM until
512    /// finalize, the legacy behaviour. Non-zero: once buffered payload bytes
513    /// would exceed the budget, further payloads are appended to a temp spill
514    /// file in `out_dir` and read back record-by-record during finalize.
515    /// A pure memory-behaviour lever: output bytes are identical either way.
516    /// Set via [`with_memory_budget`](Self::with_memory_budget).
517    memory_budget: u64,
518    /// Total bytes of payloads currently held in RAM (`PendingPayload::Mem`).
519    pending_payload_bytes: u64,
520    /// Lazily created once the budget is exceeded; `None` = nothing spilled.
521    spill: Option<SpillFile>,
522}
523
524impl PackWriter {
525    /// Create a packed-format writer targeting `out_dir`.
526    ///
527    /// `out_dir` (and its `index/` + `packs/` subdirs) are created on
528    /// [`finalize`](Self::finalize). `ordering` controls the on-disk blob byte
529    /// order ([`BlobOrdering::Auto`] resolves per-dataset at finalize);
530    /// `pack_target_bytes` is the soft per-pack size cap — a single blob larger
531    /// than the target gets its own pack rather than being split.
532    pub fn create<P: AsRef<Path>>(
533        out_dir: P,
534        ordering: BlobOrdering,
535        pack_target_bytes: u64,
536    ) -> Result<Self> {
537        Ok(Self {
538            out_dir: out_dir.as_ref().to_path_buf(),
539            ordering,
540            pack_target_bytes: pack_target_bytes.max(1),
541            pending: Vec::new(),
542            page_entries: None,
543            zstd_level: compression::ZSTD_LEVEL,
544            capabilities: Vec::new(),
545            measured_ordering: false,
546            format_version: PACKED_FORMAT_VERSION,
547            templates: Arc::new(TemplateCollector::new()),
548            memory_budget: 0,
549            pending_payload_bytes: 0,
550            spill: None,
551        })
552    }
553
554    /// Cap the UNCOMPRESSED tile-payload bytes buffered in RAM between
555    /// [`add_tile_full`](Self::add_tile_full) and [`finalize`](Self::finalize).
556    /// Once the budget is reached, further payloads are appended to a temp
557    /// spill file inside `out_dir` (same rename-safe filesystem as the final
558    /// objects; removed on success, error and drop alike) while the ~100 B of
559    /// per-tile directory metadata stays in RAM. Finalize reads spilled
560    /// payloads back record-by-record inside its existing chunked
561    /// parallel-compression pass, so the sort keys, dedup and pack-cut logic —
562    /// and therefore every output byte — are identical to an unbounded build.
563    /// `0` (the default) = unlimited / legacy all-in-RAM behaviour.
564    pub fn with_memory_budget(mut self, bytes: u64) -> Self {
565        self.memory_budget = bytes;
566        self
567    }
568
569    /// The configured in-RAM payload budget in bytes (`0` = unlimited).
570    /// Encode drivers (the stt-build tiler) read this so the ENCODED-but-not-
571    /// yet-added payloads of a parallel encode batch honor the same cap as
572    /// the writer's own buffered payloads.
573    pub fn memory_budget(&self) -> u64 {
574        self.memory_budget
575    }
576
577    /// Seed the writer's schema-template collector from an existing dataset's
578    /// registry — the VERBATIM-repack path (pack-cover, repair tools). Copied
579    /// v2 payloads reference templates by 16-byte hash but are never
580    /// re-encoded, so nothing records their templates; without seeding, the
581    /// repacked `manifest.schemas` comes out empty and every tile read fails
582    /// template resolution. Recording is content-addressed (sorted + deduped
583    /// at finalize), so seeding order is irrelevant and templates the subset
584    /// no longer references ride along harmlessly.
585    pub fn with_seeded_templates(self, templates: &TemplateRegistry) -> Self {
586        for (_, bytes) in templates.iter() {
587            self.templates.record(bytes);
588        }
589        self
590    }
591
592    /// Select the packed format version to emit (`1` | `2`; default 2 =
593    /// [`PACKED_FORMAT_VERSION`]). v1 is the one-release transitional kill
594    /// switch (`stt-build --format-version 1`) and reproduces the 0.3.x
595    /// output byte-for-byte. Anything else errors at
596    /// [`finalize`](Self::finalize). The caller is responsible for feeding
597    /// payloads of the MATCHING frame version — readers hard-reject
598    /// mixed-version datasets (design §1 ★F6).
599    pub fn with_format_version(mut self, format_version: u32) -> Self {
600        self.format_version = format_version;
601        self
602    }
603
604    /// The packed format version this writer will emit (`1` | `2`). Encode
605    /// callers derive their frame version from this so a dataset can never
606    /// mix frame and manifest versions (design §1 ★F6).
607    pub fn format_version(&self) -> u32 {
608        self.format_version
609    }
610
611    /// The writer's schema-template sink for a v2 build. Install it on the
612    /// encoder ([`crate::arrow_tile::EncoderConfig::template_collector`], or
613    /// process-wide via [`crate::arrow_tile::set_template_collector`]) so
614    /// every template a v2 frame references ends up in `manifest.schemas` at
615    /// [`finalize`](Self::finalize). Unused (and empty) under v1.
616    pub fn template_collector(&self) -> Arc<TemplateCollector> {
617        Arc::clone(&self.templates)
618    }
619
620    /// Opt into a **paged** directory: the `.sttd` becomes a root page + leaf
621    /// pages of ≤ `page_entries` entries each, so a cold reader fetches only the
622    /// leaves its viewport/time-window touches (Wave 2). `None` (the default)
623    /// emits the single whole-load v5 directory — byte-identical output to a
624    /// pre-paging build. `Some(0)` is clamped to 1.
625    pub fn with_paging(mut self, page_entries: Option<usize>) -> Self {
626        self.page_entries = page_entries.map(|k| k.max(1));
627        self
628    }
629
630    /// Set the zstd compression level for tile blobs and the directory.
631    ///
632    /// The packed format is write-once / serve-many, so the higher build CPU of
633    /// a level like 19 is paid once while the smaller bytes are paid on every
634    /// fetch (measured −10..19% vs the level-3 default; decode is unaffected).
635    /// Clamped to zstd's valid 1..=22 range. The default
636    /// ([`compression::ZSTD_LEVEL`]) reproduces byte-identical pre-existing builds.
637    pub fn with_zstd_level(mut self, level: i32) -> Self {
638        self.zstd_level = level.clamp(1, compression::ZSTD_LEVEL_MAX);
639        self
640    }
641
642    /// Declare the required-to-understand features this build used
643    /// (`manifest.capabilities`, spec §3.1) — e.g. [`CAPABILITY_COORD_QUANT`]
644    /// when coordinate quantization re-types the geometry column. Canonicalized
645    /// (sorted + deduped) so the manifest bytes never depend on call order.
646    /// Empty (the default) omits the key entirely, keeping the manifest
647    /// byte-identical to a pre-capabilities build.
648    pub fn with_capabilities(mut self, mut capabilities: Vec<String>) -> Self {
649        capabilities.sort_unstable();
650        capabilities.dedup();
651        self.capabilities = capabilities;
652        self
653    }
654
655    /// Opt into the **measured** ordering picker (`--blob-ordering measured`):
656    /// at finalize, resolve the concrete on-disk blob order by simulating
657    /// per-ordering range-read cost over the native-tier tiles
658    /// ([`crate::ordering_sim`]) instead of the `auto` cardinality heuristic.
659    /// When `on`, the `ordering` passed to [`create`](Self::create) is ignored.
660    /// `false` (the default) reproduces byte-identical pre-existing builds.
661    pub fn with_measured_ordering(mut self, on: bool) -> Self {
662        self.measured_ordering = on;
663        self
664    }
665
666    /// Add a tile carrying the full directory metadata. Same shape as
667    /// `ArchiveWriter::add_tile_full`: `cover_t_min` is the
668    /// tight lower covering bound (`None` to omit), `temporal_bucket_ms` tags the
669    /// directory entry with the temporal bucket size the tile represents. The
670    /// `payload` is the uncompressed tile frame.
671    #[allow(clippy::too_many_arguments)]
672    pub fn add_tile_full(
673        &mut self,
674        id: &TileId,
675        time_start: i64,
676        time_end: i64,
677        cover_t_min: Option<i64>,
678        feature_count: u32,
679        temporal_bucket_ms: Option<u64>,
680        payload: &[u8],
681    ) -> Result<()> {
682        check_payload_len(id, payload.len() as u64)?;
683        // Frame/manifest version coherence (design §1 ★F6): readers hard-
684        // reject mixed-version datasets, so a frame of the OTHER version
685        // would brick the output on first read. Refuse it at add time,
686        // naming the tile — both directions.
687        let v2_frame = crate::arrow_tile::is_frame_v2(payload);
688        if v2_frame != (self.format_version == PACKED_FORMAT_VERSION_V2) {
689            return Err(Error::Other(format!(
690                "tile {id:?}: {} layer frame fed to a formatVersion-{} writer \
691                 (mixed-version datasets are unreadable; encode payloads with \
692                 the writer's PackWriter::format_version)",
693                if v2_frame { "v2" } else { "v1" },
694                self.format_version,
695            )));
696        }
697        // Payload storage medium: RAM until the (optional) memory budget is
698        // exhausted, then the temp spill file. Metadata always stays in RAM.
699        let payload = if self.memory_budget > 0
700            && self.pending_payload_bytes + payload.len() as u64 > self.memory_budget
701        {
702            let spill = match &mut self.spill {
703                Some(s) => s,
704                None => self.spill.insert(SpillFile::create(&self.out_dir)?),
705            };
706            let offset = spill.append(payload)?;
707            PendingPayload::Spilled {
708                offset,
709                len: payload.len() as u64,
710            }
711        } else {
712            self.pending_payload_bytes += payload.len() as u64;
713            PendingPayload::Mem(payload.to_vec())
714        };
715        self.pending.push(PendingTile {
716            z: id.z,
717            x: id.x,
718            y: id.y,
719            hilbert: id.hilbert_index(),
720            time_start,
721            time_end,
722            cover_t_min,
723            feature_count,
724            temporal_bucket_ms,
725            payload,
726        });
727        Ok(())
728    }
729
730    /// Number of tiles buffered so far.
731    pub fn tile_count(&self) -> usize {
732        self.pending.len()
733    }
734
735    /// Finalise: order + dedup + per-blob zstd, cut packs, write
736    /// `packs/*.sttp` + `index/*.sttd` + `manifest.json` into `out_dir`.
737    ///
738    /// Mirrors `ArchiveWriter::finalize_buffered`: the same time-bucket calc,
739    /// [`BlobOrdering::Auto`] → [`BlobOrdering::choose`] resolution, space-time
740    /// sort key, per-blob `compress_zstd_with_dict(_, None)` (no shared dict),
741    /// and blake3 byte-identical dedup. THEN the ordered, deduped blob stream is
742    /// cut into packs.
743    pub fn finalize(self, metadata: &Metadata) -> Result<Manifest> {
744        let PackWriter {
745            out_dir,
746            ordering,
747            pack_target_bytes,
748            mut pending,
749            page_entries,
750            zstd_level,
751            capabilities,
752            measured_ordering,
753            format_version,
754            templates,
755            memory_budget,
756            pending_payload_bytes: _,
757            spill,
758        } = self;
759        if !SUPPORTED_PACKED_FORMAT_VERSIONS.contains(&format_version) {
760            return Err(Error::Other(format!(
761                "unsupported packed formatVersion {format_version} (this writer emits 1 or 2)"
762            )));
763        }
764        // (v2 objects carry an 8-byte magic prelude and blob offsets are
765        // object-absolute — the first blob of a pack sits at offset 8 (★F5);
766        // `PackStreamWriter` below owns that math.)
767
768        // --- Blob ordering (identical to finalize_buffered) ---------------
769        let base_bucket = metadata.temporal_bucket_ms.max(1) as i64;
770        let tb = |p: &PendingTile| {
771            let b = p
772                .temporal_bucket_ms
773                .map(|v| v as i64)
774                .unwrap_or(base_bucket)
775                .max(1);
776            p.time_start.div_euclid(b)
777        };
778        let (tb_min, tb_max) = pending.iter().fold((i64::MAX, i64::MIN), |(lo, hi), p| {
779            let t = tb(p);
780            (lo.min(t), hi.max(t))
781        });
782        let tb_span = if pending.is_empty() { 0 } else { tb_max - tb_min };
783        // Occupied spatial extent over the native (max-zoom) tier only — coarse
784        // LOD tiers have artificially small x/y ranges. Measuring the OCCUPIED
785        // bbox (not the raw 2^zoom grid) makes the space axis symmetric with the
786        // time axis, so `choose` no longer overstates space for sparse data (F1).
787        let native_z = pending.iter().map(|p| p.z).max().unwrap_or(0);
788        let (mut x_min, mut x_max, mut y_min, mut y_max) = (u32::MAX, 0u32, u32::MAX, 0u32);
789        for p in pending.iter().filter(|p| p.z == native_z) {
790            x_min = x_min.min(p.x);
791            x_max = x_max.max(p.x);
792            y_min = y_min.min(p.y);
793            y_max = y_max.max(p.y);
794        }
795        let x_span = x_max.saturating_sub(x_min) as u64;
796        let y_span = y_max.saturating_sub(y_min) as u64;
797        // Resolve the concrete on-disk ordering. `measured` simulates per-ordering
798        // range-read cost across the native tiles; otherwise `auto` uses the
799        // (occupied-extent) cardinality heuristic and an explicit order passes
800        // through.
801        let ordering = if measured_ordering {
802            let samples: Vec<crate::ordering_sim::TileSample> = pending
803                .iter()
804                .filter(|p| p.z == native_z)
805                .map(|p| crate::ordering_sim::TileSample {
806                    z: p.z,
807                    x: p.x,
808                    y: p.y,
809                    hilbert: p.hilbert,
810                    time_start: p.time_start,
811                    tb: tb(p),
812                    len: p.payload.len() as u64,
813                })
814                .collect();
815            crate::ordering_sim::measured_ordering(
816                &samples,
817                crate::ordering_sim::SimOptions::default(),
818            )
819        } else {
820            match ordering {
821                BlobOrdering::Auto => {
822                    let space_bits = crate::curve::bits_for(x_span.max(y_span) + 1);
823                    let time_bits = crate::curve::bits_for((tb_span.max(0) + 1) as u64);
824                    BlobOrdering::choose(space_bits, time_bits)
825                }
826                other => other,
827            }
828        };
829        // The curve key alone is not total (base and temporal-LOD tiles of one
830        // cell tie; the 21-bit cube cap can collide), and `pending` arrives in
831        // whatever order the (possibly parallel) tiler produced — so a total
832        // tiebreak makes the blob byte order, and therefore every content
833        // address, reproducible across identical rebuilds. Immutable-pack CDN
834        // caching depends on that: a rebuild of unchanged data must re-derive
835        // the same pack names.
836        pending.sort_by_key(|p| {
837            (
838                crate::curve::space_time_key(
839                    ordering,
840                    p.z,
841                    p.x,
842                    p.y,
843                    p.hilbert,
844                    p.time_start,
845                    tb(p),
846                    tb_min,
847                    tb_span,
848                ),
849                p.z,
850                p.x,
851                p.y,
852                p.time_start,
853                p.temporal_bucket_ms,
854            )
855        });
856
857        // Output directories exist BEFORE the compress/dedup pass: the pack
858        // phase below streams each pack to a temp file in `packs/` as blobs
859        // are deduped (write_atomic gives the same rename discipline for the
860        // directory + manifest afterwards).
861        fs::create_dir_all(&out_dir)?;
862        let index_dir = out_dir.join("index");
863        let packs_dir = out_dir.join("packs");
864        fs::create_dir_all(&index_dir)?;
865        fs::create_dir_all(&packs_dir)?;
866
867        // --- Per-blob zstd + byte-identical dedup + streaming pack cut ----
868        // Each pending tile is compressed (NO shared dictionary, so the fzstd TS
869        // reader can decode). Byte-identical compressed blobs collapse to a
870        // single physical blob. Packs are cut greedily in first-seen (curve)
871        // order, so each NEW unique blob streams straight into the current
872        // pack's temp file via `PackStreamWriter` — the archive's compressed
873        // bytes are never all resident, regardless of `memory_budget`. Only
874        // ~24 B of metadata per unique blob stays in RAM; placement is
875        // assigned at first sight, so a tile shared across time buckets still
876        // resolves to one (pack, offset).
877        struct Blob {
878            /// Compressed length (the directory's u32 `length` field).
879            length: u32,
880            uncompressed_size: u32,
881            crc: u32,
882        }
883        /// Where a unique blob landed: `(pack_id, object-absolute offset)`.
884        struct Placement {
885            pack_id: u32,
886            offset: u64,
887        }
888        let mut stream =
889            PackStreamWriter::new(&out_dir, &packs_dir, format_version, pack_target_bytes);
890        let mut blobs: Vec<Blob> = Vec::new();
891        let mut placements: Vec<Placement> = Vec::new();
892        // blake3(compressed) → blob index in `blobs`.
893        let mut blob_dedup: HashMap<[u8; 32], usize> = HashMap::new();
894        // Per pending tile (in sorted order): which blob it references.
895        let mut tile_blob: Vec<usize> = Vec::with_capacity(pending.len());
896        // Compress in parallel, dedup sequentially. zstd at a high level
897        // (`--zstd-level`) is the build-time bottleneck and is embarrassingly
898        // parallel per blob; the dedup/index assignment that follows stays
899        // strictly sequential over the sorted order, so the output is
900        // byte-identical to a single-threaded build. Chunking caps peak memory
901        // at ~CHUNK compressed blobs (a whole-dataset parallel pass would hold
902        // every pre-dedup blob at once — pathological on dedup-heavy datasets).
903        //
904        // Chunk BOUNDARIES can never change output bytes (compression is
905        // per-blob; the dedup/index pass runs strictly sequentially across
906        // boundaries) — they only bound how many payloads are resident at
907        // once. Without a spill file the chunks are the legacy fixed 8192;
908        // with one, a chunk is additionally capped at ~memory_budget bytes of
909        // payload so the spilled read-back honours the same budget that
910        // triggered spilling.
911        const COMPRESS_CHUNK: usize = 8192;
912        let chunk_byte_cap: u64 = if spill.is_some() && memory_budget > 0 {
913            memory_budget
914        } else {
915            u64::MAX
916        };
917        let mut chunk_ranges: Vec<(usize, usize)> = Vec::new();
918        {
919            let mut start = 0usize;
920            let mut bytes = 0u64;
921            for (i, p) in pending.iter().enumerate() {
922                let len = p.payload.len() as u64;
923                if i > start && (i - start >= COMPRESS_CHUNK || bytes + len > chunk_byte_cap) {
924                    chunk_ranges.push((start, i));
925                    start = i;
926                    bytes = 0;
927                }
928                bytes += len;
929            }
930            if start < pending.len() {
931                chunk_ranges.push((start, pending.len()));
932            }
933        }
934        for (chunk_start, chunk_end) in chunk_ranges {
935            let chunk = &pending[chunk_start..chunk_end];
936            // Materialise this chunk's spilled payloads (sequential reads
937            // by (offset, len) record; in-memory payloads borrow in place).
938            let materialized: Vec<Option<Vec<u8>>> = chunk
939                .iter()
940                .map(|p| match &p.payload {
941                    PendingPayload::Mem(_) => Ok(None),
942                    PendingPayload::Spilled { offset, len } => {
943                        let s = spill
944                            .as_ref()
945                            .expect("spilled payload without a spill file");
946                        s.read(*offset, *len as usize).map(Some)
947                    }
948                })
949                .collect::<Result<_>>()?;
950            let compressed_chunk: Vec<Vec<u8>> = chunk
951                .par_iter()
952                .zip(materialized.par_iter())
953                .map(|(p, m)| {
954                    let payload: &[u8] = match (&p.payload, m) {
955                        (PendingPayload::Mem(v), _) => v,
956                        (PendingPayload::Spilled { .. }, Some(v)) => v,
957                        (PendingPayload::Spilled { .. }, None) => {
958                            unreachable!("spilled payload was not materialised")
959                        }
960                    };
961                    compression::compress_zstd_with_dict_level(payload, None, zstd_level)
962                })
963                .collect::<Result<Vec<_>>>()?;
964            for (p, compressed) in chunk.iter().zip(compressed_chunk) {
965                let key = *blake3::hash(&compressed).as_bytes();
966                let idx = if let Some(&i) = blob_dedup.get(&key) {
967                    i
968                } else {
969                    let i = blobs.len();
970                    let crc = crc32c_tag(&compressed);
971                    // v1: offsets are pack-relative from 0. v2: object-
972                    // absolute — the magic prelude occupies [0, 8), so blobs
973                    // start at 8 and readers slice `[offset..offset+length]`
974                    // off the whole object unchanged.
975                    let (pack_id, offset) = stream.append(&compressed)?;
976                    blobs.push(Blob {
977                        length: compressed.len() as u32,
978                        uncompressed_size: p.payload.len() as u32,
979                        crc,
980                    });
981                    placements.push(Placement { pack_id, offset });
982                    blob_dedup.insert(key, i);
983                    i
984                };
985                tile_blob.push(idx);
986            }
987        }
988        // Every payload has been compressed; delete the spill file now (via
989        // Drop) so peak disk usage isn't spill + packs longer than necessary.
990        drop(spill);
991
992        // Seal the trailing pack: the pack table is complete (index ==
993        // pack_id, matching every placement handed out above).
994        let pack_refs: Vec<PackRef> = stream.finish()?;
995
996        // --- Build directory entries ------------------------------------
997        let mut entries: Vec<TileEntry> = Vec::with_capacity(pending.len());
998        for (p, &bi) in pending.iter().zip(tile_blob.iter()) {
999            let blob = &blobs[bi];
1000            let pl = &placements[bi];
1001            entries.push(TileEntry {
1002                zoom: p.z,
1003                x: p.x,
1004                y: p.y,
1005                time_start: p.time_start,
1006                time_end: p.time_end,
1007                pack_id: pl.pack_id,
1008                offset: pl.offset,
1009                length: blob.length,
1010                uncompressed_size: blob.uncompressed_size,
1011                feature_count: p.feature_count,
1012                hilbert: p.hilbert,
1013                crc32c: blob.crc,
1014                temporal_bucket_ms: p.temporal_bucket_ms,
1015                cover_t_min: p.cover_t_min,
1016            });
1017        }
1018        // Directory codec order is (zoom, hilbert, time_start); the extra
1019        // bucket key totalizes ties between a cell's base and temporal-LOD
1020        // entries so the encoded directory is byte-reproducible too.
1021        entries.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
1022
1023        // The manifest's totals are derived from the directory itself, not
1024        // taken from the caller: `tile_count` = directory entries,
1025        // `feature_count` = sum of per-entry counts (the same total
1026        // stt-validate reports as `feature_count_index`). Deriving here keeps
1027        // manifest and directory consistent by construction for every writer
1028        // path — historically no caller set these and manifests shipped 0s.
1029        let mut metadata = metadata.clone();
1030        metadata.tile_count = entries.len() as u64;
1031        metadata.feature_count = entries.iter().map(|e| u64::from(e.feature_count)).sum();
1032
1033        // --- Write the remaining objects ----------------------------------
1034        // (Packs already streamed to disk above — content-addressed via the
1035        // incremental hash, atomically renamed at each seal.)
1036
1037        // Encode the directory. Both shapes are zstd-at-rest (declared via
1038        // `directory.encoding`) — directories compress ~2x and sit on the
1039        // cold-start critical path with no CDN content-encoding rescue:
1040        //   - single (default): one zstd frame over the whole v5 codec buffer.
1041        //   - paged (opt-in):   a root page + leaf pages, each its own zstd
1042        //     frame, so a cold reader fetches only the leaves it touches. The
1043        //     leaf codec is the same v5 directory.
1044        // The object is content-addressed over its at-rest bytes either way.
1045        let (index_bytes, directory_ref_fields): (Vec<u8>, _) = if let Some(k) = page_entries {
1046            let paged =
1047                crate::directory_page::encode_paged_directory_level(&entries, k, true, zstd_level)?;
1048            (
1049                paged.bytes,
1050                (
1051                    Some(DIRECTORY_LAYOUT_PAGED.to_string()),
1052                    Some(paged.root_length),
1053                    Some(paged.page_count as u64),
1054                    Some(paged.page_entries as u64),
1055                ),
1056            )
1057        } else {
1058            let index_plain = crate::directory::encode_directory(&entries);
1059            let bytes = compression::compress_zstd_with_dict_level(&index_plain, None, zstd_level)?;
1060            (bytes, (None, None, None, None))
1061        };
1062        // v2: the object is magic + frames; `rootLength` keeps meaning the
1063        // root frame's at-rest length (a paged reader fetches
1064        // `bytes=0..(8+rootLength-1)`), so the DirectoryRef fields above need
1065        // no adjustment — only the object bytes gain the prelude.
1066        let index_bytes: Vec<u8> = if format_version == PACKED_FORMAT_VERSION_V2 {
1067            let mut with_magic = Vec::with_capacity(OBJECT_MAGIC_LEN + index_bytes.len());
1068            with_magic.extend_from_slice(&object_magic(DIRECTORY_MAGIC));
1069            with_magic.extend_from_slice(&index_bytes);
1070            with_magic
1071        } else {
1072            index_bytes
1073        };
1074        let index_hex = blake3_128_hex(&index_bytes);
1075        let index_rel = format!("index/{index_hex}.sttd");
1076        let index_path = out_dir.join(&index_rel);
1077        write_atomic(&index_dir, &index_path, &index_bytes)?;
1078
1079        let (layout, root_length, page_count, page_entries_field) = directory_ref_fields;
1080
1081        // v2: publish the collected schema templates. The collector snapshot
1082        // is sorted by hash and deduped by construction, so the manifest
1083        // bytes are independent of encode order/parallelism (★F1).
1084        let schemas: Vec<SchemaTemplateRef> = if format_version == PACKED_FORMAT_VERSION_V2 {
1085            use base64::Engine as _;
1086            templates
1087                .snapshot()
1088                .into_iter()
1089                .map(|(hash, data)| SchemaTemplateRef {
1090                    hash: hash.iter().map(|b| format!("{b:02x}")).collect(),
1091                    data: base64::engine::general_purpose::STANDARD.encode(&data),
1092                })
1093                .collect()
1094        } else {
1095            if !templates.is_empty() {
1096                return Err(Error::Other(
1097                    "template collector is non-empty on a formatVersion-1 build: v2-encoded \
1098                     payloads were fed to a v1 writer (mixed-version dataset)"
1099                        .into(),
1100                ));
1101            }
1102            Vec::new()
1103        };
1104
1105        // Build + write the manifest.
1106        let manifest = Manifest {
1107            format: PACKED_FORMAT.to_string(),
1108            format_version,
1109            capabilities,
1110            schemas,
1111            compression: "zstd".to_string(),
1112            // Record the concrete order actually laid down (never auto/measured).
1113            blob_ordering: Some(ordering.as_str().to_string()),
1114            directory: DirectoryRef {
1115                key: index_rel,
1116                length: index_bytes.len() as u64,
1117                directory_version: crate::directory::DIRECTORY_VERSION,
1118                encoding: Some(DIRECTORY_ENCODING_ZSTD.to_string()),
1119                layout,
1120                root_length,
1121                page_count,
1122                page_entries: page_entries_field,
1123            },
1124            packs: pack_refs,
1125            metadata,
1126        };
1127        let manifest_bytes = manifest.to_json_bytes()?;
1128        let manifest_path = out_dir.join("manifest.json");
1129        let mut f = File::create(&manifest_path)?;
1130        f.write_all(&manifest_bytes)?;
1131        f.flush()?;
1132
1133        Ok(manifest)
1134    }
1135}
1136
1137/// Unwrap a directory object's at-rest encoding into raw codec bytes.
1138/// `encoding` is the manifest's `directory.encoding`: absent = raw (every
1139/// pre-encoding manifest), `"zstd"` = one zstd frame around the codec bytes.
1140fn decode_directory_object(bytes: &[u8], encoding: Option<&str>) -> Result<Vec<u8>> {
1141    match encoding {
1142        None => Ok(bytes.to_vec()),
1143        Some(DIRECTORY_ENCODING_ZSTD) => compression::decompress_zstd_with_dict(bytes, None),
1144        Some(other) => Err(Error::InvalidArchive(format!(
1145            "unknown directory encoding {other:?} (this reader supports absent or \"zstd\")"
1146        ))),
1147    }
1148}
1149
1150/// Decode the full tile-entry list from a directory object's at-rest bytes,
1151/// branching on the container layout. The single (whole-load) shape unwraps the
1152/// at-rest encoding then runs the v5 codec; the paged shape decodes the root +
1153/// every leaf (the local load-all path — a mmap'd file has no cold-start cost,
1154/// so the paging *query* win lives in the TS HTTP reader). Used by the local
1155/// `PackedReader` and `verify_packed_objects`.
1156fn decode_directory_entries(bytes: &[u8], dref: &DirectoryRef) -> Result<Vec<TileEntry>> {
1157    if dref.is_paged() {
1158        let root_length = dref.root_length.ok_or_else(|| {
1159            Error::InvalidArchive("paged directory: manifest missing rootLength".into())
1160        })?;
1161        let zstd = dref.encoding.as_deref() == Some(DIRECTORY_ENCODING_ZSTD);
1162        crate::directory_page::decode_paged_directory(bytes, root_length, zstd)
1163    } else {
1164        let raw = decode_directory_object(bytes, dref.encoding.as_deref())?;
1165        crate::directory::decode_directory(&raw)
1166    }
1167}
1168
1169/// Write `bytes` to a temp file inside `dir` then atomically rename to
1170/// `final_path` (content-addressed, so an existing identical object is a no-op
1171/// overwrite of the same bytes).
1172fn write_atomic(dir: &Path, final_path: &Path, bytes: &[u8]) -> Result<()> {
1173    // Unique temp name within the same directory so the rename is atomic.
1174    let tmp = dir.join(format!(
1175        ".tmp-{}-{}",
1176        std::process::id(),
1177        blake3_128_hex(bytes)
1178    ));
1179    {
1180        let mut f = OpenOptions::new()
1181            .write(true)
1182            .create(true)
1183            .truncate(true)
1184            .open(&tmp)?;
1185        f.write_all(bytes)?;
1186        f.flush()?;
1187    }
1188    fs::rename(&tmp, final_path)?;
1189    Ok(())
1190}
1191
1192/// Streaming pack emitter for [`PackWriter::finalize`]: blobs are assigned to
1193/// packs greedily in first-seen (curve) order, so each new unique blob's
1194/// compressed bytes are appended straight to the current pack's TEMP file
1195/// while an INCREMENTAL blake3 hasher tracks the object's content address;
1196/// exceeding `pack_target_bytes` seals the pack — finalize the hash, rename
1197/// the temp to `packs/<hex>.sttp`. Only the current pack's file handle (not
1198/// the archive's compressed bytes) stays resident, so finalize's memory is
1199/// independent of dataset size. Same bytes, same order, same hashing as the
1200/// old buffer-everything path — output is byte-identical (pinned by the
1201/// golden/reproducibility tests). The open temp file is removed on drop
1202/// (error paths); sealed packs are content-addressed, so re-writes after a
1203/// failed run are idempotent.
1204struct PackStreamWriter {
1205    out_dir: PathBuf,
1206    packs_dir: PathBuf,
1207    format_version: u32,
1208    pack_target_bytes: u64,
1209    /// Object-absolute offset of the first blob (`OBJECT_MAGIC_LEN` under
1210    /// v2, 0 under v1) — the "empty pack" length.
1211    data_start: u64,
1212    current: Option<OpenPack>,
1213    next_pack_id: u32,
1214    refs: Vec<PackRef>,
1215}
1216
1217/// The in-progress pack object: temp file + incremental hash + length so far.
1218struct OpenPack {
1219    file: File,
1220    tmp_path: PathBuf,
1221    hasher: blake3::Hasher,
1222    len: u64,
1223}
1224
1225impl Drop for OpenPack {
1226    fn drop(&mut self) {
1227        // Sealing renames the temp away first, so this only fires for an
1228        // ABANDONED pack (finalize errored) — never a published object.
1229        let _ = fs::remove_file(&self.tmp_path);
1230    }
1231}
1232
1233impl PackStreamWriter {
1234    fn new(
1235        out_dir: &Path,
1236        packs_dir: &Path,
1237        format_version: u32,
1238        pack_target_bytes: u64,
1239    ) -> Self {
1240        Self {
1241            out_dir: out_dir.to_path_buf(),
1242            packs_dir: packs_dir.to_path_buf(),
1243            format_version,
1244            pack_target_bytes,
1245            data_start: if format_version == PACKED_FORMAT_VERSION_V2 {
1246                OBJECT_MAGIC_LEN as u64
1247            } else {
1248                0
1249            },
1250            current: None,
1251            next_pack_id: 0,
1252            refs: Vec::new(),
1253        }
1254    }
1255
1256    /// Append one blob's compressed bytes, returning its `(pack_id,
1257    /// object-absolute offset)` placement. Cuts BEFORE the blob if adding it
1258    /// would exceed the target and the current pack is non-empty, so a lone
1259    /// oversized blob still fits — it just owns a pack (the unsplittable-blob
1260    /// rule, unchanged).
1261    fn append(&mut self, compressed: &[u8]) -> Result<(u32, u64)> {
1262        let blen = compressed.len() as u64;
1263        if let Some(cur) = &self.current {
1264            if cur.len > self.data_start && cur.len + blen > self.pack_target_bytes {
1265                self.seal()?;
1266            }
1267        }
1268        if self.current.is_none() {
1269            self.open()?;
1270        }
1271        let cur = self.current.as_mut().expect("just opened");
1272        let offset = cur.len;
1273        cur.file.write_all(compressed)?;
1274        cur.hasher.update(compressed);
1275        cur.len += blen;
1276        Ok((self.next_pack_id, offset))
1277    }
1278
1279    /// Start a new pack: unique temp file (pack ids are unique per run) with
1280    /// the v2 magic prelude already written and hashed.
1281    fn open(&mut self) -> Result<()> {
1282        let tmp_path = self.packs_dir.join(format!(
1283            ".tmp-{}-pack-{}",
1284            std::process::id(),
1285            self.next_pack_id
1286        ));
1287        let mut file = OpenOptions::new()
1288            .write(true)
1289            .create(true)
1290            .truncate(true)
1291            .open(&tmp_path)?;
1292        let mut hasher = blake3::Hasher::new();
1293        let mut len = 0u64;
1294        if self.format_version == PACKED_FORMAT_VERSION_V2 {
1295            let magic = object_magic(PACK_MAGIC);
1296            file.write_all(&magic)?;
1297            hasher.update(&magic);
1298            len = OBJECT_MAGIC_LEN as u64;
1299        }
1300        self.current = Some(OpenPack {
1301            file,
1302            tmp_path,
1303            hasher,
1304            len,
1305        });
1306        Ok(())
1307    }
1308
1309    /// Seal the current pack (if any): flush, finalize the incremental hash
1310    /// into the content address, atomically rename temp → final, record the
1311    /// [`PackRef`]. No-op when nothing is open.
1312    fn seal(&mut self) -> Result<()> {
1313        let Some(mut cur) = self.current.take() else {
1314            return Ok(());
1315        };
1316        cur.file.flush()?;
1317        let hash = cur.hasher.finalize();
1318        let hex: String = hash.as_bytes()[..16]
1319            .iter()
1320            .map(|b| format!("{b:02x}"))
1321            .collect();
1322        let rel = format!("packs/{hex}.sttp");
1323        fs::rename(&cur.tmp_path, self.out_dir.join(&rel))?;
1324        self.refs.push(PackRef {
1325            key: rel,
1326            length: cur.len,
1327        });
1328        self.next_pack_id += 1;
1329        Ok(())
1330    }
1331
1332    /// Seal any trailing open pack and return the complete pack table
1333    /// (index == `pack_id`).
1334    fn finish(mut self) -> Result<Vec<PackRef>> {
1335        self.seal()?;
1336        Ok(std::mem::take(&mut self.refs))
1337    }
1338}
1339
1340// ----------------------------------------------------------------------------
1341// Integrity
1342// ----------------------------------------------------------------------------
1343
1344/// Verify the on-disk integrity of a packed dataset against its manifest.
1345///
1346/// Because packs and the directory are **content-addressed**, integrity is a
1347/// property anyone can check with no trusted side-channel: each object's bytes
1348/// must blake3-hash to the name the manifest gave it, and its on-disk length
1349/// must match the declared length. Additionally the directory must decode and
1350/// reference no `pack_id` outside the manifest's pack table.
1351///
1352/// Returns the list of violations (empty ⇒ clean). Returns `Err` only when the
1353/// manifest itself cannot be read or parsed (a missing referenced object is a
1354/// reported violation, not an `Err`, so a full report is produced in one pass).
1355pub fn verify_packed_objects<P: AsRef<Path>>(manifest_path: P) -> Result<Vec<String>> {
1356    let manifest_path = manifest_path.as_ref();
1357    let root = manifest_path
1358        .parent()
1359        .ok_or_else(|| Error::InvalidArchive("manifest path has no parent dir".into()))?;
1360    let manifest = Manifest::from_json_bytes(&fs::read(manifest_path)?)?;
1361
1362    let mut issues = Vec::new();
1363
1364    if manifest.format != PACKED_FORMAT {
1365        issues.push(format!(
1366            "manifest format is {:?}, expected {PACKED_FORMAT:?}",
1367            manifest.format
1368        ));
1369    }
1370    if !SUPPORTED_PACKED_FORMAT_VERSIONS.contains(&manifest.format_version) {
1371        issues.push(format!(
1372            "manifest formatVersion is {}, expected one of {SUPPORTED_PACKED_FORMAT_VERSIONS:?}",
1373            manifest.format_version
1374        ));
1375    }
1376    if manifest.directory.directory_version != crate::directory::DIRECTORY_VERSION {
1377        issues.push(format!(
1378            "directoryVersion is {}, expected {}",
1379            manifest.directory.directory_version,
1380            crate::directory::DIRECTORY_VERSION
1381        ));
1382    }
1383    verify_manifest_schemas(&manifest, &mut issues);
1384
1385    // Each content-addressed object: name must equal blake3-128 of its bytes,
1386    // and on-disk length must equal the declared length. Content addresses
1387    // cover the ENTIRE object — magic prelude included on v2 — so this check
1388    // is version-independent; the magic itself is validated separately below.
1389    fn check_object(
1390        root: &Path,
1391        key: &str,
1392        declared_len: u64,
1393        prefix: &str,
1394        ext: &str,
1395        issues: &mut Vec<String>,
1396    ) {
1397        match fs::read(root.join(key)) {
1398            Ok(bytes) => {
1399                if bytes.len() as u64 != declared_len {
1400                    issues.push(format!(
1401                        "{key}: on-disk length {} != manifest-declared {declared_len}",
1402                        bytes.len()
1403                    ));
1404                }
1405                let expected = format!("{prefix}/{}.{ext}", blake3_128_hex(&bytes));
1406                if key != expected {
1407                    issues.push(format!(
1408                        "{key}: content-address mismatch (bytes hash to {expected})"
1409                    ));
1410                }
1411            }
1412            Err(e) => issues.push(format!("{key}: cannot read object ({e})")),
1413        }
1414    }
1415
1416    check_object(
1417        root,
1418        &manifest.directory.key,
1419        manifest.directory.length,
1420        "index",
1421        "sttd",
1422        &mut issues,
1423    );
1424    for p in &manifest.packs {
1425        check_object(root, &p.key, p.length, "packs", "sttp", &mut issues);
1426    }
1427
1428    // v2 objects must self-identify: validate each object's magic prelude.
1429    if manifest.format_version == PACKED_FORMAT_VERSION_V2 {
1430        for (key, kind) in std::iter::once((&manifest.directory.key, DIRECTORY_MAGIC))
1431            .chain(manifest.packs.iter().map(|p| (&p.key, PACK_MAGIC)))
1432        {
1433            if let Ok(bytes) = fs::read(root.join(key)) {
1434                if let Err(e) = strip_object_magic(&bytes, kind, key) {
1435                    issues.push(e.to_string());
1436                }
1437            }
1438        }
1439    }
1440
1441    // Directory must decode (through its v2 magic prelude, at-rest encoding +
1442    // container layout) and reference only packs that exist. Under v2, the
1443    // decoded entries additionally drive the frame → manifest.schemas
1444    // reference check (a dataset whose frames reference a missing template
1445    // is undecodable and must not verify clean).
1446    match fs::read(root.join(&manifest.directory.key)) {
1447        Ok(dir_bytes) => {
1448            match directory_codec_bytes(&dir_bytes, manifest.format_version)
1449                .and_then(|codec| decode_directory_entries(codec, &manifest.directory))
1450            {
1451                Ok(entries) => {
1452                    if let Some(max_pid) = entries.iter().map(|e| e.pack_id).max() {
1453                        if max_pid as usize >= manifest.packs.len() {
1454                            issues.push(format!(
1455                                "directory references pack_id {max_pid} but the manifest lists only {} pack(s)",
1456                                manifest.packs.len()
1457                            ));
1458                        }
1459                    }
1460                    verify_v2_frame_template_refs(
1461                        &manifest,
1462                        &entries,
1463                        |pid| {
1464                            manifest
1465                                .packs
1466                                .get(pid)
1467                                .and_then(|p| fs::read(root.join(&p.key)).ok())
1468                        },
1469                        &mut issues,
1470                    );
1471                }
1472                Err(e) => issues.push(format!("directory failed to decode: {e}")),
1473            }
1474        }
1475        // A read failure here is already reported by check_object above.
1476        Err(_) => {}
1477    }
1478
1479    // Paged directories: validate the container structure beyond plain decode —
1480    // page descriptor bounds cover their leaf's entries (so a reader's prune
1481    // never drops a matching tile) and cross-page key order is monotonic.
1482    if manifest.directory.is_paged() {
1483        match manifest.directory.root_length {
1484            Some(rl) => {
1485                if let Ok(dir_bytes) = fs::read(root.join(&manifest.directory.key)) {
1486                    if let Ok(codec) = directory_codec_bytes(&dir_bytes, manifest.format_version) {
1487                        let zstd = manifest.directory.encoding.as_deref()
1488                            == Some(DIRECTORY_ENCODING_ZSTD);
1489                        match crate::directory_page::verify_paged_structure(codec, rl, zstd) {
1490                            Ok(mut more) => issues.append(&mut more),
1491                            Err(e) => issues.push(format!("paged structure check failed: {e}")),
1492                        }
1493                    }
1494                }
1495            }
1496            None => issues.push("paged directory: manifest missing rootLength".into()),
1497        }
1498    }
1499
1500    Ok(issues)
1501}
1502
1503/// Shared `manifest.schemas` checks for both verifiers: v1 must not carry the
1504/// table; v2 entries must base64-decode, hash to their declared address, and
1505/// arrive sorted-by-hash + deduped (the byte-reproducibility contract).
1506fn verify_manifest_schemas(manifest: &Manifest, issues: &mut Vec<String>) {
1507    if manifest.format_version == PACKED_FORMAT_VERSION_V1 {
1508        if !manifest.schemas.is_empty() {
1509            issues.push("formatVersion-1 manifest carries a schemas table (v2-only)".into());
1510        }
1511        return;
1512    }
1513    if let Err(e) = build_template_registry(&manifest.schemas) {
1514        issues.push(e.to_string());
1515    }
1516    let hashes: Vec<&str> = manifest.schemas.iter().map(|s| s.hash.as_str()).collect();
1517    if hashes.windows(2).any(|w| w[0] >= w[1]) {
1518        issues.push("manifest schemas are not sorted by hash (or contain duplicates)".into());
1519    }
1520}
1521
1522/// Parse a 32-lower-hex schema hash into its raw 16 bytes.
1523fn parse_schema_hash_hex(s: &str) -> Option<[u8; 16]> {
1524    if s.len() != 32 || !s.is_ascii() {
1525        return None;
1526    }
1527    let mut out = [0u8; 16];
1528    for (i, byte) in out.iter_mut().enumerate() {
1529        *byte = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
1530    }
1531    Some(out)
1532}
1533
1534/// v2 deep check shared by both verifiers: every template hash any tile frame
1535/// references must resolve in `manifest.schemas` — otherwise the dataset
1536/// "verifies clean" while no tile can decode. Walks each UNIQUE blob (dedup by
1537/// `(pack_id, offset)`), decompresses it, parses ONLY the v2 frame header
1538/// (escape/version/count, per-layer ref_kinds + 16-byte hashes — no Arrow
1539/// decode; [`crate::arrow_tile::frame_v2_template_refs`]) and reports each
1540/// missing hash once, with an example tile. `pack_object` returns a pack's
1541/// full object bytes by `pack_id` (`None` when the object is unreadable —
1542/// already reported by the content-address checks).
1543fn verify_v2_frame_template_refs<F>(
1544    manifest: &Manifest,
1545    entries: &[TileEntry],
1546    mut pack_object: F,
1547    issues: &mut Vec<String>,
1548) where
1549    F: FnMut(usize) -> Option<Vec<u8>>,
1550{
1551    if manifest.format_version != PACKED_FORMAT_VERSION_V2 {
1552        return;
1553    }
1554    // Unparseable hex entries simply can't match any frame reference; their
1555    // own malformation is reported by `verify_manifest_schemas`.
1556    let known: std::collections::HashSet<[u8; 16]> = manifest
1557        .schemas
1558        .iter()
1559        .filter_map(|s| parse_schema_hash_hex(&s.hash))
1560        .collect();
1561
1562    let mut seen_blobs: std::collections::HashSet<(u32, u64)> = std::collections::HashSet::new();
1563    let mut pack_cache: HashMap<u32, Option<Vec<u8>>> = HashMap::new();
1564    // hex hash → example tile (BTreeMap for deterministic issue order).
1565    let mut missing: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
1566    for e in entries {
1567        if !seen_blobs.insert((e.pack_id, e.offset)) {
1568            continue;
1569        }
1570        let Some(pack) = pack_cache
1571            .entry(e.pack_id)
1572            .or_insert_with(|| pack_object(e.pack_id as usize))
1573            .as_deref()
1574        else {
1575            continue;
1576        };
1577        let (start, end) = (e.offset as usize, e.offset as usize + e.length as usize);
1578        if end > pack.len() {
1579            issues.push(format!(
1580                "tile {:?} blob range {start}..{end} exceeds pack {} size {}",
1581                e.tile_id(),
1582                e.pack_id,
1583                pack.len()
1584            ));
1585            continue;
1586        }
1587        let payload = match manifest.compression.as_str() {
1588            "zstd" => match compression::decompress_zstd_with_dict(&pack[start..end], None) {
1589                Ok(p) => p,
1590                Err(err) => {
1591                    issues.push(format!(
1592                        "tile {:?}: blob failed to decompress during the schema-reference \
1593                         check: {err}",
1594                        e.tile_id()
1595                    ));
1596                    continue;
1597                }
1598            },
1599            _ => pack[start..end].to_vec(),
1600        };
1601        if !crate::arrow_tile::is_frame_v2(&payload) {
1602            issues.push(format!(
1603                "tile {:?}: v1 layer frame inside a formatVersion-2 dataset",
1604                e.tile_id()
1605            ));
1606            continue;
1607        }
1608        match crate::arrow_tile::frame_v2_template_refs(&payload) {
1609            Ok(refs) => {
1610                for hash in refs {
1611                    if !known.contains(&hash) {
1612                        let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
1613                        missing
1614                            .entry(hex)
1615                            .or_insert_with(|| format!("{:?}", e.tile_id()));
1616                    }
1617                }
1618            }
1619            Err(err) => issues.push(format!(
1620                "tile {:?}: v2 frame header parse failed: {err}",
1621                e.tile_id()
1622            )),
1623        }
1624    }
1625    for (hex, tile) in missing {
1626        issues.push(format!(
1627            "tile frames reference schema template {hex}, which is absent from \
1628             manifest.schemas (e.g. tile {tile}) — no such tile can decode"
1629        ));
1630    }
1631}
1632
1633// ----------------------------------------------------------------------------
1634// Reader
1635// ----------------------------------------------------------------------------
1636
1637/// One mapped pack, lazily loaded on first access.
1638struct LoadedPack {
1639    /// `None` until first read of a tile in this pack.
1640    mmap: Option<Mmap>,
1641    /// Absolute path to the pack object.
1642    path: PathBuf,
1643    /// Declared length from the manifest (for a bounds sanity check).
1644    length: u64,
1645}
1646
1647/// Reader for a **local** packed dataset. Remote/HTTP reads are the TS reader's
1648/// job; this opens objects from the filesystem.
1649///
1650/// Mirrors `ArchiveReader`: [`entries`](Self::entries) returns
1651/// the decoded v5 directory, [`metadata`](Self::metadata) the folded metadata,
1652/// and [`read_payload`](Self::read_payload) selects the entry's pack, slices
1653/// `[offset..offset+length]`, verifies CRC32C and decompresses the per-blob
1654/// zstd. Packs are mmap'd lazily by `pack_id`.
1655pub struct PackedReader {
1656    entries: Vec<TileEntry>,
1657    metadata: Metadata,
1658    compression: Compression,
1659    packs: Vec<std::cell::RefCell<LoadedPack>>,
1660    capabilities: Vec<String>,
1661    /// The manifest's authoritative `formatVersion` (1 | 2). Decides object
1662    /// magic expectations and which layer-frame shape
1663    /// [`read_layers`](Self::read_layers) accepts — mixed-version datasets
1664    /// are hard errors both ways (design §1 ★F6).
1665    format_version: u32,
1666    /// v2 only: the hash-validated schema-template registry decoded from
1667    /// `manifest.schemas` at open. `None` on v1 datasets.
1668    templates: Option<TemplateRegistry>,
1669    /// `Some` iff this reader was opened via
1670    /// [`open_bundle`](Self::open_bundle): the whole `.sttb` is one mapping
1671    /// and each pack is an (offset, length) window into it. `None` for the
1672    /// exploded-directory [`open`](Self::open) path, which keeps using
1673    /// `packs` above.
1674    bundle: Option<BundleBacking>,
1675}
1676
1677/// Bundle backing for a [`PackedReader`] opened from a single-file `.sttb`:
1678/// one mmap of the whole bundle plus each pack's window into it.
1679struct BundleBacking {
1680    mmap: Mmap,
1681    /// Per-pack `(absolute_offset, length)` window into `mmap`. Index ==
1682    /// `pack_id` (manifest pack-table order). Bounds-checked at open.
1683    windows: Vec<(u64, u64)>,
1684}
1685
1686/// Open-time manifest checks shared by [`PackedReader::open`] and
1687/// [`PackedReader::open_bundle`]: format tag, `formatVersion`, the
1688/// required-to-understand capability registry, and the compression codec.
1689/// Returns the decoded compression so both open paths stay in lockstep.
1690fn manifest_open_checks(manifest: &Manifest) -> Result<Compression> {
1691    if manifest.format != PACKED_FORMAT {
1692        return Err(Error::InvalidArchive(format!(
1693            "not a packed manifest: format={:?} (expected {PACKED_FORMAT:?})",
1694            manifest.format
1695        )));
1696    }
1697    // A future breaking manifest revision must fail loudly at open, not
1698    // misdecode downstream (the directory codec has its own version byte;
1699    // this guards the manifest schema itself). `formatVersion` is the
1700    // AUTHORITATIVE v1/v2 discriminator (★F6) — the frame escape is only
1701    // defense-in-depth.
1702    if !SUPPORTED_PACKED_FORMAT_VERSIONS.contains(&manifest.format_version) {
1703        return Err(Error::InvalidArchive(format!(
1704            "unsupported packed formatVersion {} (this reader supports 1 and 2)",
1705            manifest.format_version
1706        )));
1707    }
1708    // A schemas table on a v1 manifest is a mixed-version artifact — refuse
1709    // rather than guess which half to believe.
1710    if manifest.format_version == PACKED_FORMAT_VERSION_V1 && !manifest.schemas.is_empty() {
1711        return Err(Error::InvalidArchive(
1712            "formatVersion-1 manifest carries a schemas table (v2-only)".into(),
1713        ));
1714    }
1715    // Required-to-understand capabilities (spec §3.1): each one re-types
1716    // EXISTING columns, so a reader that lacks it wouldn't fail later — it
1717    // would silently misdecode, per tile. Refuse loudly at open instead.
1718    let unknown: Vec<&str> = manifest
1719        .capabilities
1720        .iter()
1721        .map(String::as_str)
1722        .filter(|c| !KNOWN_CAPABILITIES.contains(c))
1723        .collect();
1724    if !unknown.is_empty() {
1725        return Err(Error::InvalidArchive(format!(
1726            "dataset requires capabilities this reader does not implement: {} \
1727             (implemented: {})",
1728            unknown.join(", "),
1729            KNOWN_CAPABILITIES.join(", ")
1730        )));
1731    }
1732    match manifest.compression.as_str() {
1733        "zstd" => Ok(Compression::Zstd),
1734        "none" => Ok(Compression::None),
1735        other => Err(Error::InvalidArchive(format!(
1736            "unknown packed compression {other:?}"
1737        ))),
1738    }
1739}
1740
1741impl PackedReader {
1742    /// Open a packed dataset by its `manifest.json` path. The directory and pack
1743    /// objects are resolved relative to the manifest's parent directory.
1744    pub fn open<P: AsRef<Path>>(manifest_path: P) -> Result<Self> {
1745        let manifest_path = manifest_path.as_ref();
1746        let root = manifest_path
1747            .parent()
1748            .ok_or_else(|| Error::InvalidArchive("manifest path has no parent dir".into()))?
1749            .to_path_buf();
1750
1751        let manifest_bytes = fs::read(manifest_path)?;
1752        let manifest = Manifest::from_json_bytes(&manifest_bytes)?;
1753        let compression = manifest_open_checks(&manifest)?;
1754        // v2: decode + hash-validate the manifest-embedded schema templates
1755        // into the registry every tile decode resolves against. Fails the
1756        // whole open (dataset-level) on any corrupt entry.
1757        let templates = (manifest.format_version == PACKED_FORMAT_VERSION_V2)
1758            .then(|| build_template_registry(&manifest.schemas))
1759            .transpose()?;
1760
1761        // Load + decode the directory object (validating + stripping the v2
1762        // magic prelude first). The single (whole-load) shape unwraps the
1763        // at-rest encoding then runs the v5 codec; the paged shape decodes the
1764        // root + every leaf (local load-all — a mmap'd file has no cold-start
1765        // cost). Both branches return the same full entry list.
1766        let dir_path = root.join(&manifest.directory.key);
1767        let dir_bytes = fs::read(&dir_path)?;
1768        let codec_bytes = directory_codec_bytes(&dir_bytes, manifest.format_version)?;
1769        let entries = decode_directory_entries(codec_bytes, &manifest.directory)?;
1770
1771        // Prepare (lazy) pack handles in pack_id order.
1772        let packs = manifest
1773            .packs
1774            .iter()
1775            .map(|p| {
1776                std::cell::RefCell::new(LoadedPack {
1777                    mmap: None,
1778                    path: root.join(&p.key),
1779                    length: p.length,
1780                })
1781            })
1782            .collect();
1783
1784        Ok(Self {
1785            entries,
1786            metadata: manifest.metadata,
1787            compression,
1788            packs,
1789            capabilities: manifest.capabilities,
1790            format_version: manifest.format_version,
1791            templates,
1792            bundle: None,
1793        })
1794    }
1795
1796    /// Open a single-file `.sttb` **bundle** (spec §13, the interchange
1797    /// profile). The bundle is mmap'd once; the manifest comes verbatim from
1798    /// the header, each pack becomes an `(offset, length)` window into the
1799    /// mapping, and everything below [`read_payload`](Self::read_payload) is
1800    /// shared with the exploded-directory [`open`](Self::open) path —
1801    /// including the manifest format/version/capability refusals.
1802    pub fn open_bundle<P: AsRef<Path>>(path: P) -> Result<Self> {
1803        let file = File::open(path.as_ref())?;
1804        // SAFETY: read-only mapping of a file we never write through; the
1805        // mapping is owned by the reader for its lifetime.
1806        let mmap =
1807            unsafe { Mmap::map(&file) }.map_err(|e| Error::Other(format!("mmap failed: {e}")))?;
1808        let (header, header_end) = parse_bundle_header(&mmap)?;
1809        let manifest = Manifest::from_json_bytes(header.manifest.get().as_bytes())?;
1810        let compression = manifest_open_checks(&manifest)?;
1811
1812        // Object table: key → window, every window bounds-checked against the
1813        // file before anything dereferences it.
1814        let mut by_key: HashMap<&str, (u64, u64)> = HashMap::with_capacity(header.objects.len());
1815        for o in &header.objects {
1816            let end = o.offset.checked_add(o.length).ok_or_else(|| {
1817                Error::InvalidArchive(format!("bundle object {:?}: offset+length overflows", o.key))
1818            })?;
1819            if o.offset < header_end || end > mmap.len() as u64 {
1820                return Err(Error::InvalidArchive(format!(
1821                    "bundle object {:?} window {}..{end} outside the data region ({header_end}..{})",
1822                    o.key,
1823                    o.offset,
1824                    mmap.len()
1825                )));
1826            }
1827            if by_key.insert(o.key.as_str(), (o.offset, o.length)).is_some() {
1828                return Err(Error::InvalidArchive(format!(
1829                    "bundle header lists object key {:?} twice",
1830                    o.key
1831                )));
1832            }
1833        }
1834        let lookup = |key: &str, declared: u64, what: &str| -> Result<(u64, u64)> {
1835            let &(offset, length) = by_key.get(key).ok_or_else(|| {
1836                Error::InvalidArchive(format!("bundle header lists no object for {what} {key:?}"))
1837            })?;
1838            if length != declared {
1839                return Err(Error::InvalidArchive(format!(
1840                    "{what} {key:?} is {length} bytes in the bundle, manifest declared {declared}"
1841                )));
1842            }
1843            Ok((offset, length))
1844        };
1845
1846        // v2: registry from the embedded manifest, then per-object magic
1847        // validation on every window before anything decodes through it.
1848        let templates = (manifest.format_version == PACKED_FORMAT_VERSION_V2)
1849            .then(|| build_template_registry(&manifest.schemas))
1850            .transpose()?;
1851
1852        let (dir_off, dir_len) =
1853            lookup(&manifest.directory.key, manifest.directory.length, "directory")?;
1854        let dir_object = &mmap[dir_off as usize..(dir_off + dir_len) as usize];
1855        let codec_bytes = directory_codec_bytes(dir_object, manifest.format_version)?;
1856        let entries = decode_directory_entries(codec_bytes, &manifest.directory)?;
1857        let windows = manifest
1858            .packs
1859            .iter()
1860            .map(|p| lookup(&p.key, p.length, "pack"))
1861            .collect::<Result<Vec<_>>>()?;
1862        if manifest.format_version == PACKED_FORMAT_VERSION_V2 {
1863            for (&(off, len), p) in windows.iter().zip(&manifest.packs) {
1864                strip_object_magic(
1865                    &mmap[off as usize..(off + len) as usize],
1866                    PACK_MAGIC,
1867                    &format!("bundle pack {:?}", p.key),
1868                )?;
1869            }
1870        }
1871
1872        Ok(Self {
1873            entries,
1874            metadata: manifest.metadata,
1875            compression,
1876            packs: Vec::new(),
1877            capabilities: manifest.capabilities,
1878            format_version: manifest.format_version,
1879            templates,
1880            bundle: Some(BundleBacking { mmap, windows }),
1881        })
1882    }
1883
1884    /// All directory entries (sorted by zoom then Hilbert index).
1885    pub fn entries(&self) -> &[TileEntry] {
1886        &self.entries
1887    }
1888
1889    /// Dataset metadata.
1890    pub fn metadata(&self) -> &Metadata {
1891        &self.metadata
1892    }
1893
1894    /// The manifest's `capabilities` declarations (spec §3.1). A tool that
1895    /// repacks this dataset's tiles verbatim MUST carry these forward via
1896    /// [`PackWriter::with_capabilities`] — dropping them re-arms the
1897    /// silent-misdecode hazard the declaration exists to prevent.
1898    pub fn capabilities(&self) -> &[String] {
1899        &self.capabilities
1900    }
1901
1902    /// Read and decompress a tile's raw payload bytes, verifying its CRC32C.
1903    ///
1904    /// Selects the pack by `entry.pack_id`, slices `[offset..offset+length]`
1905    /// within it, checks the CRC, then decompresses the per-blob zstd. Mirrors
1906    /// `ArchiveReader::read_payload`.
1907    pub fn read_payload(&self, entry: &TileEntry) -> Result<Vec<u8>> {
1908        // Bundle-backed reader: the pack is an (offset, length) window into
1909        // the bundle mapping — slice it, then the shared CRC/decompress tail.
1910        if let Some(bundle) = &self.bundle {
1911            let &(pack_offset, pack_length) =
1912                bundle.windows.get(entry.pack_id as usize).ok_or_else(|| {
1913                    Error::InvalidArchive(format!(
1914                        "tile {:?} references pack {} but the bundle holds {} pack(s)",
1915                        entry.tile_id(),
1916                        entry.pack_id,
1917                        bundle.windows.len()
1918                    ))
1919                })?;
1920            let end_in_pack = entry.offset.checked_add(entry.length as u64).ok_or_else(|| {
1921                Error::InvalidArchive(format!(
1922                    "tile {:?}: blob offset+length overflows",
1923                    entry.tile_id()
1924                ))
1925            })?;
1926            if end_in_pack > pack_length {
1927                return Err(Error::InvalidArchive(format!(
1928                    "tile {:?} blob range {}..{end_in_pack} exceeds pack size {pack_length}",
1929                    entry.tile_id(),
1930                    entry.offset
1931                )));
1932            }
1933            // Windows were bounds-checked against the mapping at open.
1934            let start = (pack_offset + entry.offset) as usize;
1935            let end = start + entry.length as usize;
1936            return self.decode_blob(entry, &bundle.mmap[start..end]);
1937        }
1938
1939        let cell = self.packs.get(entry.pack_id as usize).ok_or_else(|| {
1940            Error::InvalidArchive(format!(
1941                "tile {:?} references pack {} but only {} packs exist",
1942                entry.tile_id(),
1943                entry.pack_id,
1944                self.packs.len()
1945            ))
1946        })?;
1947
1948        let mut pack = cell.borrow_mut();
1949        if pack.mmap.is_none() {
1950            let file = File::open(&pack.path)?;
1951            // SAFETY: read-only mapping of a file we never write through; the
1952            // mapping is owned by the reader for its lifetime.
1953            let mmap = unsafe { Mmap::map(&file) }
1954                .map_err(|e| Error::Other(format!("mmap failed: {e}")))?;
1955            if mmap.len() as u64 != pack.length {
1956                return Err(Error::InvalidArchive(format!(
1957                    "pack {} is {} bytes, manifest declared {}",
1958                    pack.path.display(),
1959                    mmap.len(),
1960                    pack.length
1961                )));
1962            }
1963            // v2 objects self-identify; validate once per mapping. (Blob
1964            // offsets are object-absolute under v2, so the slice math below
1965            // is version-independent.)
1966            if self.format_version == PACKED_FORMAT_VERSION_V2 {
1967                strip_object_magic(
1968                    &mmap,
1969                    PACK_MAGIC,
1970                    &format!("pack {}", pack.path.display()),
1971                )?;
1972            }
1973            pack.mmap = Some(mmap);
1974        }
1975        let mmap = pack.mmap.as_ref().expect("just loaded");
1976
1977        let start = entry.offset as usize;
1978        let end = start + entry.length as usize;
1979        if end > mmap.len() {
1980            return Err(Error::InvalidArchive(format!(
1981                "tile {:?} blob range {start}..{end} exceeds pack size {}",
1982                entry.tile_id(),
1983                mmap.len()
1984            )));
1985        }
1986        self.decode_blob(entry, &mmap[start..end])
1987    }
1988
1989    /// Shared read tail: CRC32C-check, decompress and size-check one tile's
1990    /// compressed blob bytes (whichever backing they were sliced from).
1991    fn decode_blob(&self, entry: &TileEntry, compressed: &[u8]) -> Result<Vec<u8>> {
1992        if crc32c_tag(compressed) != entry.crc32c {
1993            return Err(Error::InvalidArchive(format!(
1994                "tile {:?} failed integrity check (corrupt pack)",
1995                entry.tile_id()
1996            )));
1997        }
1998
1999        let payload = if self.compression == Compression::Zstd {
2000            compression::decompress_zstd_with_dict(compressed, None)?
2001        } else {
2002            compression::decompress(compressed, self.compression)?
2003        };
2004
2005        if payload.len() != entry.uncompressed_size as usize {
2006            return Err(Error::InvalidArchive(format!(
2007                "tile {:?} decompressed to {} bytes, expected {}",
2008                entry.tile_id(),
2009                payload.len(),
2010                entry.uncompressed_size
2011            )));
2012        }
2013        Ok(payload)
2014    }
2015
2016    /// Read and decode a tile into its Arrow layers.
2017    pub fn read_layers(&self, entry: &TileEntry) -> Result<Vec<crate::arrow_tile::DecodedLayer>> {
2018        let payload = self.read_payload(entry)?;
2019        self.decode_payload(&payload)
2020    }
2021
2022    /// Decode a tile payload under this dataset's declared `formatVersion`.
2023    ///
2024    /// The manifest is authoritative (design §1 ★F6): a v2 frame inside a
2025    /// v1-declared dataset is a hard error, and vice versa — never a silent
2026    /// best-effort decode of a mixed-version dataset. v2 frames resolve their
2027    /// template references through the registry built at open.
2028    pub fn decode_payload(&self, payload: &[u8]) -> Result<Vec<crate::arrow_tile::DecodedLayer>> {
2029        let v2_frame = crate::arrow_tile::is_frame_v2(payload);
2030        if self.format_version == PACKED_FORMAT_VERSION_V2 {
2031            if !v2_frame {
2032                return Err(Error::InvalidArchive(
2033                    "v1 layer frame inside a formatVersion-2 dataset (the manifest is \
2034                     authoritative; mixed-version datasets are invalid)"
2035                        .into(),
2036                ));
2037            }
2038            let templates = self
2039                .templates
2040                .as_ref()
2041                .expect("v2 open always builds the template registry");
2042            crate::arrow_tile::decode_tile_with_templates(payload, templates)
2043        } else {
2044            if v2_frame {
2045                return Err(Error::InvalidArchive(
2046                    "v2 layer frame inside a formatVersion-1 dataset (the manifest is \
2047                     authoritative; mixed-version datasets are invalid)"
2048                        .into(),
2049                ));
2050            }
2051            crate::arrow_tile::decode_tile(payload)
2052        }
2053    }
2054
2055    /// The manifest's authoritative `formatVersion` (1 | 2). A tool that
2056    /// repacks this dataset's payloads verbatim MUST write the same version
2057    /// (`PackWriter::with_format_version`) — frames and manifest declarations
2058    /// may not mix.
2059    pub fn format_version(&self) -> u32 {
2060        self.format_version
2061    }
2062
2063    /// The v2 schema-template registry decoded from `manifest.schemas`
2064    /// (`None` on v1 datasets). Exposed so tools that fetch payloads through
2065    /// [`read_payload`](Self::read_payload) can decode them out-of-band via
2066    /// [`crate::arrow_tile::decode_tile_with_templates`].
2067    pub fn templates(&self) -> Option<&TemplateRegistry> {
2068        self.templates.as_ref()
2069    }
2070}
2071
2072// ----------------------------------------------------------------------------
2073// Bundle profile (`.sttb`) — single-file interchange (spec §13, DRAFT)
2074// ----------------------------------------------------------------------------
2075//
2076// A bundle is the packed dataset as ONE file, restoring the "download one
2077// file" usability property the exploded layout gave up. Strictly an
2078// interchange profile: the CDN story remains the exploded layout — nothing
2079// serves bundles over HTTP ranges. Layout:
2080//
2081// ```text
2082// "STTB"  u8 version(1)  [3 × 0x00]   # 8-byte magic prelude
2083// u32     header_len                  # little-endian
2084// [header JSON]                       # { manifest: <verbatim>, objects: [..] }
2085// [zero pad to 8]
2086// [objects back-to-back at 8-byte-aligned offsets]
2087// ```
2088//
2089// The container is object-agnostic (keys + bytes are opaque), so it works
2090// for `formatVersion` 1 today and future manifest revisions unchanged.
2091
2092/// First 4 bytes of a `.sttb` bundle file.
2093pub const BUNDLE_MAGIC: [u8; 4] = *b"STTB";
2094/// Bundle container version this toolchain writes and reads.
2095pub const BUNDLE_VERSION: u8 = 1;
2096/// Fixed bundle prelude: 8-byte magic (`"STTB"`, version, 3 × 0x00) + the
2097/// little-endian `u32` header length.
2098const BUNDLE_PRELUDE_LEN: usize = 12;
2099
2100/// One object entry in the bundle header's `objects` table.
2101///
2102/// `offset`/`length` are byte positions within the bundle file, emitted as
2103/// JSON numbers — exact for u64 in Rust, and fine to 2^53 for JS readers
2104/// (a 9-PB bundle).
2105#[derive(Debug, Clone, Serialize, Deserialize)]
2106pub struct BundleObject {
2107    /// Object key, verbatim from the manifest (e.g. `packs/<hash>.sttp`).
2108    pub key: String,
2109    /// Absolute byte offset of the object within the bundle (8-aligned).
2110    pub offset: u64,
2111    /// Object length in bytes (at-rest bytes; padding excluded).
2112    pub length: u64,
2113}
2114
2115/// The bundle header JSON: the dataset's `manifest.json` VERBATIM (so unpack
2116/// reproduces it byte-identically) plus the object table.
2117#[derive(Serialize, Deserialize)]
2118struct BundleHeader {
2119    manifest: Box<serde_json::value::RawValue>,
2120    objects: Vec<BundleObject>,
2121}
2122
2123/// Summary of a bundle pack/unpack for CLI reporting.
2124#[derive(Debug, Clone, Copy)]
2125pub struct BundleSummary {
2126    /// Objects carried (directory + packs + any future manifest-listed
2127    /// kinds); `manifest.json` rides in the header and is not counted.
2128    pub objects: usize,
2129    /// Pack: the final bundle file size. Unpack: total bytes written
2130    /// (objects + `manifest.json`).
2131    pub bytes: u64,
2132}
2133
2134/// Round `n` up to the next 8-byte boundary (bundle object alignment —
2135/// mirrors the layer-frame alignment rule).
2136fn align8_u64(n: u64) -> u64 {
2137    n.div_ceil(8) * 8
2138}
2139
2140/// Reject bundle/manifest object keys that could escape the dataset root
2141/// when joined to a filesystem path — spec §11's path-traversal guard,
2142/// applied to the bundle header exactly as readers apply it to the manifest.
2143fn validate_bundle_key(key: &str) -> Result<()> {
2144    let bad = key.is_empty()
2145        || key.starts_with('/')
2146        || key.contains('\\')
2147        || key.contains(':')
2148        || key.split('/').any(|seg| seg.is_empty() || seg == "." || seg == "..");
2149    if bad {
2150        return Err(Error::InvalidArchive(format!(
2151            "unsafe bundle object key {key:?} (keys must be relative paths \
2152             with no empty/'.'/'..' segments)"
2153        )));
2154    }
2155    Ok(())
2156}
2157
2158/// If `key` names a content-addressed object (`<dir>/<32-lower-hex>.<ext>`),
2159/// return the hex stem so callers can verify blake3(bytes) against it.
2160/// Unknown/future key shapes return `None` and skip the hash check.
2161fn content_address_stem(key: &str) -> Option<&str> {
2162    let name = key.rsplit('/').next()?;
2163    let stem = name.split('.').next()?;
2164    (stem.len() == 32 && stem.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')))
2165        .then_some(stem)
2166}
2167
2168/// One manifest-referenced object in canonical bundle order.
2169struct ManifestObjectKey {
2170    key: String,
2171    /// Manifest-declared at-rest length, when the manifest carries one.
2172    declared_length: Option<u64>,
2173}
2174
2175/// Enumerate a packed manifest's object keys in the **canonical bundle
2176/// order**: the directory first, then packs in `pack_id` order, then any
2177/// future manifest object tables (a `formatVersion: 2` `schemas` array) in
2178/// listed order. Operates on the raw JSON value so bundling stays
2179/// object-agnostic across manifest revisions. Keys are traversal-validated
2180/// and must be unique.
2181fn manifest_object_keys(manifest: &serde_json::Value) -> Result<Vec<ManifestObjectKey>> {
2182    let obj = manifest
2183        .as_object()
2184        .ok_or_else(|| Error::InvalidArchive("manifest is not a JSON object".into()))?;
2185    if obj.get("format").and_then(|v| v.as_str()) != Some(PACKED_FORMAT) {
2186        return Err(Error::InvalidArchive(format!(
2187            "not a packed manifest: format={:?} (expected {PACKED_FORMAT:?})",
2188            obj.get("format")
2189        )));
2190    }
2191    let entry = |v: &serde_json::Value, what: &str| -> Result<ManifestObjectKey> {
2192        let o = v
2193            .as_object()
2194            .ok_or_else(|| Error::InvalidArchive(format!("manifest {what} is not an object")))?;
2195        let key = o
2196            .get("key")
2197            .and_then(|k| k.as_str())
2198            .ok_or_else(|| Error::InvalidArchive(format!("manifest {what} has no string `key`")))?;
2199        Ok(ManifestObjectKey {
2200            key: key.to_string(),
2201            declared_length: o.get("length").and_then(|l| l.as_u64()),
2202        })
2203    };
2204
2205    let mut out = Vec::new();
2206    out.push(entry(
2207        obj.get("directory")
2208            .ok_or_else(|| Error::InvalidArchive("manifest has no `directory`".into()))?,
2209        "directory",
2210    )?);
2211    let packs = obj
2212        .get("packs")
2213        .and_then(|v| v.as_array())
2214        .ok_or_else(|| Error::InvalidArchive("manifest has no `packs` array".into()))?;
2215    for (i, p) in packs.iter().enumerate() {
2216        out.push(entry(p, &format!("packs[{i}]"))?);
2217    }
2218    // formatVersion 2 embeds its schema templates INSIDE the manifest
2219    // (`schemas: [{hash, data}]` — base64, no object keys), so they ride the
2220    // header's verbatim manifest and the bundle carries no schema objects.
2221    // (An earlier draft planned external `schemas/<hash>.sttt` objects; the
2222    // frozen v2 design dissolved that object class.)
2223
2224    let mut seen = std::collections::HashSet::with_capacity(out.len());
2225    for k in &out {
2226        validate_bundle_key(&k.key)?;
2227        if !seen.insert(k.key.as_str()) {
2228            return Err(Error::InvalidArchive(format!(
2229                "manifest lists object key {:?} twice",
2230                k.key
2231            )));
2232        }
2233    }
2234    Ok(out)
2235}
2236
2237/// Parse and validate a bundle's fixed prelude + JSON header. Returns the
2238/// header and the byte offset where the header region ends (object windows
2239/// must start at or after it). Errors — never panics — on truncation, bad
2240/// magic, unknown version, nonzero reserved bytes, or malformed header JSON.
2241fn parse_bundle_header(bytes: &[u8]) -> Result<(BundleHeader, u64)> {
2242    if bytes.len() < BUNDLE_PRELUDE_LEN {
2243        return Err(Error::InvalidArchive(format!(
2244            "not an STT bundle: {} bytes is shorter than the {BUNDLE_PRELUDE_LEN}-byte prelude",
2245            bytes.len()
2246        )));
2247    }
2248    if bytes[0..4] != BUNDLE_MAGIC {
2249        return Err(Error::InvalidArchive(
2250            "not an STT bundle (bad magic; expected \"STTB\")".into(),
2251        ));
2252    }
2253    if bytes[4] != BUNDLE_VERSION {
2254        return Err(Error::InvalidArchive(format!(
2255            "unsupported bundle version {} (this reader supports {BUNDLE_VERSION})",
2256            bytes[4]
2257        )));
2258    }
2259    if bytes[5..8] != [0, 0, 0] {
2260        return Err(Error::InvalidArchive(
2261            "malformed bundle: reserved prelude bytes must be zero".into(),
2262        ));
2263    }
2264    let header_len = u32::from_le_bytes(bytes[8..12].try_into().expect("4 bytes")) as u64;
2265    let header_end = BUNDLE_PRELUDE_LEN as u64 + header_len;
2266    if header_end > bytes.len() as u64 {
2267        return Err(Error::InvalidArchive(format!(
2268            "truncated bundle: header claims {header_len} bytes but only {} remain",
2269            bytes.len() - BUNDLE_PRELUDE_LEN
2270        )));
2271    }
2272    let header: BundleHeader =
2273        serde_json::from_slice(&bytes[BUNDLE_PRELUDE_LEN..header_end as usize])
2274            .map_err(|e| Error::InvalidArchive(format!("bundle header JSON decode failed: {e}")))?;
2275    Ok((header, header_end))
2276}
2277
2278/// Pack an exploded packed dataset into a single `.sttb` bundle file.
2279///
2280/// The manifest JSON is embedded **verbatim** in the header (so
2281/// [`unpack_bundle`] reproduces `manifest.json` byte-identically) and the
2282/// objects are laid out back-to-back at 8-byte-aligned offsets in canonical
2283/// manifest order — deterministic: the same dataset packs to byte-identical
2284/// bundle bytes. Every content-addressed object is re-hashed on the way in;
2285/// a mismatch aborts the pack (never emit a bundle that cannot verify).
2286pub fn write_bundle<P: AsRef<Path>, Q: AsRef<Path>>(
2287    manifest_path: P,
2288    out_path: Q,
2289) -> Result<BundleSummary> {
2290    let manifest_path = manifest_path.as_ref();
2291    let out_path = out_path.as_ref();
2292    let root = manifest_path
2293        .parent()
2294        .ok_or_else(|| Error::InvalidArchive("manifest path has no parent dir".into()))?;
2295
2296    let manifest_raw = fs::read(manifest_path)?;
2297    let manifest_text = std::str::from_utf8(&manifest_raw)
2298        .map_err(|_| Error::InvalidArchive("manifest.json is not UTF-8".into()))?
2299        .trim()
2300        .to_string();
2301    let manifest_value: serde_json::Value = serde_json::from_str(&manifest_text)
2302        .map_err(|e| Error::InvalidArchive(format!("manifest JSON decode failed: {e}")))?;
2303    let keys = manifest_object_keys(&manifest_value)?;
2304    let manifest_rv = serde_json::value::RawValue::from_string(manifest_text)
2305        .map_err(|e| Error::InvalidArchive(format!("manifest is not a JSON value: {e}")))?;
2306
2307    // Plan the layout from on-disk lengths (verified again at copy time, and
2308    // against the manifest-declared lengths where the manifest carries one).
2309    let mut lengths: Vec<u64> = Vec::with_capacity(keys.len());
2310    for k in &keys {
2311        let len = fs::metadata(root.join(&k.key))
2312            .map_err(|e| Error::InvalidArchive(format!("{}: cannot stat object ({e})", k.key)))?
2313            .len();
2314        if let Some(declared) = k.declared_length {
2315            if len != declared {
2316                return Err(Error::InvalidArchive(format!(
2317                    "{}: on-disk length {len} != manifest-declared {declared}",
2318                    k.key
2319                )));
2320            }
2321        }
2322        lengths.push(len);
2323    }
2324    let mut rel_offsets: Vec<u64> = Vec::with_capacity(lengths.len());
2325    let mut data_len = 0u64;
2326    for len in &lengths {
2327        rel_offsets.push(data_len);
2328        data_len = align8_u64(data_len + len);
2329    }
2330
2331    // Fixed point on the header length: absolute offsets appear as decimal
2332    // digits inside the header JSON, so the data start depends on the header
2333    // length and vice versa. `data_start` only ever grows across iterations
2334    // (offsets grow → digits grow → header grows), so this converges —
2335    // typically on the second pass.
2336    let mut data_start = align8_u64(BUNDLE_PRELUDE_LEN as u64);
2337    let header_bytes = loop {
2338        let objects: Vec<BundleObject> = keys
2339            .iter()
2340            .zip(&rel_offsets)
2341            .zip(&lengths)
2342            .map(|((k, rel), len)| BundleObject {
2343                key: k.key.clone(),
2344                offset: data_start + rel,
2345                length: *len,
2346            })
2347            .collect();
2348        let header = BundleHeader {
2349            manifest: manifest_rv.clone(),
2350            objects,
2351        };
2352        let bytes = serde_json::to_vec(&header)
2353            .map_err(|e| Error::Other(format!("bundle header encode failed: {e}")))?;
2354        if bytes.len() as u64 > u32::MAX as u64 {
2355            return Err(Error::InvalidArchive(format!(
2356                "bundle header is {} bytes, exceeding the u32 header_len cap",
2357                bytes.len()
2358            )));
2359        }
2360        let next = align8_u64(BUNDLE_PRELUDE_LEN as u64 + bytes.len() as u64);
2361        if next == data_start {
2362            break bytes;
2363        }
2364        data_start = next;
2365    };
2366
2367    // Stream out via a same-directory temp file + atomic rename (the
2368    // content-addressed-object write discipline, applied to the bundle).
2369    let out_dir = match out_path.parent() {
2370        Some(p) if p != Path::new("") => p.to_path_buf(),
2371        _ => PathBuf::from("."),
2372    };
2373    fs::create_dir_all(&out_dir)?;
2374    let tmp = out_dir.join(format!(".tmp-sttb-{}", std::process::id()));
2375    let total = {
2376        use std::io::BufWriter;
2377        let file = OpenOptions::new()
2378            .write(true)
2379            .create(true)
2380            .truncate(true)
2381            .open(&tmp)?;
2382        let mut w = BufWriter::new(file);
2383        const ZEROS: [u8; 8] = [0u8; 8];
2384
2385        w.write_all(&BUNDLE_MAGIC)?;
2386        w.write_all(&[BUNDLE_VERSION, 0, 0, 0])?;
2387        w.write_all(&(header_bytes.len() as u32).to_le_bytes())?;
2388        w.write_all(&header_bytes)?;
2389        let mut pos = BUNDLE_PRELUDE_LEN as u64 + header_bytes.len() as u64;
2390        w.write_all(&ZEROS[..(data_start - pos) as usize])?;
2391        pos = data_start;
2392
2393        for ((k, rel), len) in keys.iter().zip(&rel_offsets).zip(&lengths) {
2394            debug_assert_eq!(pos, data_start + rel);
2395            let bytes = fs::read(root.join(&k.key))?;
2396            if bytes.len() as u64 != *len {
2397                return Err(Error::InvalidArchive(format!(
2398                    "{}: object changed size during bundling ({} != planned {len})",
2399                    k.key,
2400                    bytes.len()
2401                )));
2402            }
2403            // Content-addressed keys must hash to their own name — never
2404            // emit a bundle that cannot verify.
2405            if let Some(stem) = content_address_stem(&k.key) {
2406                let hex = blake3_128_hex(&bytes);
2407                if hex != stem {
2408                    return Err(Error::InvalidArchive(format!(
2409                        "{}: content-address mismatch (bytes hash to {hex})",
2410                        k.key
2411                    )));
2412                }
2413            }
2414            w.write_all(&bytes)?;
2415            pos += *len;
2416            let padded = align8_u64(pos);
2417            w.write_all(&ZEROS[..(padded - pos) as usize])?;
2418            pos = padded;
2419        }
2420        w.flush()?;
2421        pos
2422    };
2423    fs::rename(&tmp, out_path)?;
2424
2425    Ok(BundleSummary {
2426        objects: keys.len(),
2427        bytes: total,
2428    })
2429}
2430
2431/// Explode a `.sttb` bundle back into a packed dataset directory:
2432/// `manifest.json` (the verbatim header bytes) plus every object at its key
2433/// path. Keys are traversal-validated and windows bounds-checked **before**
2434/// anything is written. Objects are content-addressed, so callers can (and
2435/// `stt-bundle unpack` does) run [`verify_packed_objects`] on the result to
2436/// prove the round-trip byte-identical.
2437pub fn unpack_bundle<P: AsRef<Path>, Q: AsRef<Path>>(
2438    bundle_path: P,
2439    out_dir: Q,
2440) -> Result<BundleSummary> {
2441    let out_dir = out_dir.as_ref();
2442    let file = File::open(bundle_path.as_ref())?;
2443    // SAFETY: read-only mapping of a file we never write through, held only
2444    // for the duration of this call.
2445    let mmap =
2446        unsafe { Mmap::map(&file) }.map_err(|e| Error::Other(format!("mmap failed: {e}")))?;
2447    let (header, header_end) = parse_bundle_header(&mmap)?;
2448
2449    // Validate everything before the first write.
2450    let mut seen = std::collections::HashSet::with_capacity(header.objects.len());
2451    for o in &header.objects {
2452        validate_bundle_key(&o.key)?;
2453        if !seen.insert(o.key.as_str()) {
2454            return Err(Error::InvalidArchive(format!(
2455                "bundle header lists object key {:?} twice",
2456                o.key
2457            )));
2458        }
2459        let end = o.offset.checked_add(o.length).ok_or_else(|| {
2460            Error::InvalidArchive(format!("bundle object {:?}: offset+length overflows", o.key))
2461        })?;
2462        if o.offset < header_end || end > mmap.len() as u64 {
2463            return Err(Error::InvalidArchive(format!(
2464                "bundle object {:?} window {}..{end} outside the data region ({header_end}..{})",
2465                o.key,
2466                o.offset,
2467                mmap.len()
2468            )));
2469        }
2470    }
2471    // Every manifest-referenced object must be present — fail before writing
2472    // a dataset that could never verify.
2473    let manifest_value: serde_json::Value = serde_json::from_str(header.manifest.get())
2474        .map_err(|e| Error::InvalidArchive(format!("bundle manifest JSON decode failed: {e}")))?;
2475    for k in manifest_object_keys(&manifest_value)? {
2476        if !seen.contains(k.key.as_str()) {
2477            return Err(Error::InvalidArchive(format!(
2478                "bundle header carries no object for manifest key {:?}",
2479                k.key
2480            )));
2481        }
2482    }
2483
2484    fs::create_dir_all(out_dir)?;
2485    let mut bytes_written = 0u64;
2486    for o in &header.objects {
2487        let path = out_dir.join(&o.key);
2488        let parent = path.parent().map(Path::to_path_buf).unwrap_or_else(|| out_dir.to_path_buf());
2489        fs::create_dir_all(&parent)?;
2490        write_atomic(
2491            &parent,
2492            &path,
2493            &mmap[o.offset as usize..(o.offset + o.length) as usize],
2494        )?;
2495        bytes_written += o.length;
2496    }
2497    // Manifest last: its presence marks the exploded dataset complete.
2498    let manifest_bytes = header.manifest.get().as_bytes();
2499    write_atomic(out_dir, &out_dir.join("manifest.json"), manifest_bytes)?;
2500    bytes_written += manifest_bytes.len() as u64;
2501
2502    Ok(BundleSummary {
2503        objects: header.objects.len(),
2504        bytes: bytes_written,
2505    })
2506}
2507
2508/// Read just the (typed) manifest out of a `.sttb` bundle header.
2509pub fn read_bundle_manifest<P: AsRef<Path>>(bundle_path: P) -> Result<Manifest> {
2510    let file = File::open(bundle_path.as_ref())?;
2511    // SAFETY: read-only mapping, held only for the duration of this call.
2512    let mmap =
2513        unsafe { Mmap::map(&file) }.map_err(|e| Error::Other(format!("mmap failed: {e}")))?;
2514    let (header, _) = parse_bundle_header(&mmap)?;
2515    Manifest::from_json_bytes(header.manifest.get().as_bytes())
2516}
2517
2518/// Bundle analog of [`verify_packed_objects`]: verify a `.sttb`'s contents
2519/// against its embedded manifest with no trusted side-channel — each
2520/// content-addressed object's bytes must blake3-hash to its key, in-bundle
2521/// lengths must match the manifest-declared lengths, the directory must
2522/// decode (paged structure included), and no `pack_id` may fall outside the
2523/// pack table.
2524///
2525/// Returns the list of violations (empty ⇒ clean). Returns `Err` only when
2526/// the bundle container itself cannot be read or parsed (mirroring the
2527/// manifest-unreadable case of the exploded verifier).
2528pub fn verify_bundle_objects<P: AsRef<Path>>(bundle_path: P) -> Result<Vec<String>> {
2529    let file = File::open(bundle_path.as_ref())?;
2530    // SAFETY: read-only mapping, held only for the duration of this call.
2531    let mmap =
2532        unsafe { Mmap::map(&file) }.map_err(|e| Error::Other(format!("mmap failed: {e}")))?;
2533    let (header, header_end) = parse_bundle_header(&mmap)?;
2534    let manifest = Manifest::from_json_bytes(header.manifest.get().as_bytes())?;
2535
2536    let mut issues = Vec::new();
2537
2538    if manifest.format != PACKED_FORMAT {
2539        issues.push(format!(
2540            "manifest format is {:?}, expected {PACKED_FORMAT:?}",
2541            manifest.format
2542        ));
2543    }
2544    if !SUPPORTED_PACKED_FORMAT_VERSIONS.contains(&manifest.format_version) {
2545        issues.push(format!(
2546            "manifest formatVersion is {}, expected one of {SUPPORTED_PACKED_FORMAT_VERSIONS:?}",
2547            manifest.format_version
2548        ));
2549    }
2550    if manifest.directory.directory_version != crate::directory::DIRECTORY_VERSION {
2551        issues.push(format!(
2552            "directoryVersion is {}, expected {}",
2553            manifest.directory.directory_version,
2554            crate::directory::DIRECTORY_VERSION
2555        ));
2556    }
2557    verify_manifest_schemas(&manifest, &mut issues);
2558
2559    // Object table: key/window sanity, then key → slice for the checks below.
2560    let mut by_key: HashMap<&str, &[u8]> = HashMap::with_capacity(header.objects.len());
2561    for o in &header.objects {
2562        if let Err(e) = validate_bundle_key(&o.key) {
2563            issues.push(e.to_string());
2564            continue;
2565        }
2566        let end = match o.offset.checked_add(o.length) {
2567            Some(end) if o.offset >= header_end && end <= mmap.len() as u64 => end,
2568            _ => {
2569                issues.push(format!(
2570                    "bundle object {:?} window {}+{} outside the data region ({header_end}..{})",
2571                    o.key,
2572                    o.offset,
2573                    o.length,
2574                    mmap.len()
2575                ));
2576                continue;
2577            }
2578        };
2579        if by_key
2580            .insert(o.key.as_str(), &mmap[o.offset as usize..end as usize])
2581            .is_some()
2582        {
2583            issues.push(format!("bundle header lists object key {:?} twice", o.key));
2584        }
2585    }
2586
2587    // Each content-addressed object: bytes must hash to the key's name and
2588    // match the manifest-declared length — exactly the exploded-directory
2589    // checks, sourced from bundle windows instead of files.
2590    fn check_object(
2591        by_key: &HashMap<&str, &[u8]>,
2592        key: &str,
2593        declared_len: u64,
2594        prefix: &str,
2595        ext: &str,
2596        issues: &mut Vec<String>,
2597    ) {
2598        match by_key.get(key) {
2599            Some(bytes) => {
2600                if bytes.len() as u64 != declared_len {
2601                    issues.push(format!(
2602                        "{key}: in-bundle length {} != manifest-declared {declared_len}",
2603                        bytes.len()
2604                    ));
2605                }
2606                let expected = format!("{prefix}/{}.{ext}", blake3_128_hex(bytes));
2607                if key != expected {
2608                    issues.push(format!(
2609                        "{key}: content-address mismatch (bytes hash to {expected})"
2610                    ));
2611                }
2612            }
2613            None => issues.push(format!("{key}: bundle header carries no such object")),
2614        }
2615    }
2616
2617    check_object(
2618        &by_key,
2619        &manifest.directory.key,
2620        manifest.directory.length,
2621        "index",
2622        "sttd",
2623        &mut issues,
2624    );
2625    for p in &manifest.packs {
2626        check_object(&by_key, &p.key, p.length, "packs", "sttp", &mut issues);
2627    }
2628
2629    // v2 objects must self-identify: validate each window's magic prelude.
2630    if manifest.format_version == PACKED_FORMAT_VERSION_V2 {
2631        for (key, kind) in std::iter::once((&manifest.directory.key, DIRECTORY_MAGIC))
2632            .chain(manifest.packs.iter().map(|p| (&p.key, PACK_MAGIC)))
2633        {
2634            if let Some(bytes) = by_key.get(key.as_str()) {
2635                if let Err(e) = strip_object_magic(bytes, kind, key) {
2636                    issues.push(e.to_string());
2637                }
2638            }
2639        }
2640    }
2641
2642    // Directory must decode (through its v2 magic prelude, at-rest encoding +
2643    // container layout) and reference only packs the manifest lists; a paged
2644    // directory additionally passes the structural covering/order checks.
2645    if let Some(dir_bytes) = by_key.get(manifest.directory.key.as_str()) {
2646        match directory_codec_bytes(dir_bytes, manifest.format_version) {
2647            Ok(codec) => {
2648                match decode_directory_entries(codec, &manifest.directory) {
2649                    Ok(entries) => {
2650                        if let Some(max_pid) = entries.iter().map(|e| e.pack_id).max() {
2651                            if max_pid as usize >= manifest.packs.len() {
2652                                issues.push(format!(
2653                                    "directory references pack_id {max_pid} but the manifest lists only {} pack(s)",
2654                                    manifest.packs.len()
2655                                ));
2656                            }
2657                        }
2658                        verify_v2_frame_template_refs(
2659                            &manifest,
2660                            &entries,
2661                            |pid| {
2662                                manifest
2663                                    .packs
2664                                    .get(pid)
2665                                    .and_then(|p| by_key.get(p.key.as_str()))
2666                                    .map(|b| b.to_vec())
2667                            },
2668                            &mut issues,
2669                        );
2670                    }
2671                    Err(e) => issues.push(format!("directory failed to decode: {e}")),
2672                }
2673                if manifest.directory.is_paged() {
2674                    match manifest.directory.root_length {
2675                        Some(rl) => {
2676                            let zstd = manifest.directory.encoding.as_deref()
2677                                == Some(DIRECTORY_ENCODING_ZSTD);
2678                            match crate::directory_page::verify_paged_structure(codec, rl, zstd) {
2679                                Ok(mut more) => issues.append(&mut more),
2680                                Err(e) => {
2681                                    issues.push(format!("paged structure check failed: {e}"))
2682                                }
2683                            }
2684                        }
2685                        None => {
2686                            issues.push("paged directory: manifest missing rootLength".into())
2687                        }
2688                    }
2689                }
2690            }
2691            // The magic check above already reported the malformed prelude.
2692            Err(_) => {}
2693        }
2694    }
2695
2696    Ok(issues)
2697}
2698
2699#[cfg(test)]
2700mod tests {
2701    use super::*;
2702    use crate::arrow_tile::{
2703        encode_tile, encode_tile_with, ColumnarLayer, EncoderConfig, GeometryColumn,
2704        PropertyColumn, FORMAT_VERSION_V2,
2705    };
2706
2707    fn point_layer(name: &str, ids: Vec<u64>, t0: i64) -> ColumnarLayer {
2708        let n = ids.len();
2709        ColumnarLayer {
2710            name: name.to_string(),
2711            feature_ids: ids,
2712            start_times: vec![t0; n],
2713            end_times: vec![t0 + 100; n],
2714            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]; n]),
2715            vertex_times: None,
2716            vertex_values: None,
2717            triangles: None,
2718            vertex_value_matrix: None,
2719            properties: vec![],
2720        }
2721    }
2722
2723    /// Distinct-payload tile so each blob is unique (forces many packs at a tiny
2724    /// target). `seed` perturbs the feature ids so compressed bytes differ.
2725    fn distinct_tile(seed: u64) -> Vec<u8> {
2726        let ids: Vec<u64> = (0..6).map(|i| seed * 100 + i).collect();
2727        encode_tile(&[point_layer("default", ids, (seed as i64) * 7)]).unwrap()
2728    }
2729
2730    /// v1-pinned writer for the tests whose payloads are bare `encode_tile`
2731    /// v1 frames (or opaque bytes): `add_tile_full` enforces frame/manifest
2732    /// version coherence, so these fixtures must not inherit the v2 default.
2733    fn v1_writer(out: &Path, ordering: BlobOrdering, pack_target_bytes: u64) -> PackWriter {
2734        PackWriter::create(out, ordering, pack_target_bytes)
2735            .unwrap()
2736            .with_format_version(PACKED_FORMAT_VERSION_V1)
2737    }
2738
2739    #[test]
2740    fn blake3_128_hex_is_32_chars() {
2741        let h = blake3_128_hex(b"hello");
2742        assert_eq!(h.len(), 32);
2743        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
2744    }
2745
2746    #[test]
2747    fn manifest_json_roundtrips() {
2748        let m = Manifest {
2749            format: PACKED_FORMAT.to_string(),
2750            format_version: PACKED_FORMAT_VERSION,
2751            capabilities: Vec::new(),
2752            schemas: Vec::new(),
2753            compression: "zstd".to_string(),
2754            blob_ordering: None,
2755            directory: DirectoryRef {
2756                key: "index/abc.sttd".to_string(),
2757                length: 42,
2758                directory_version: 5,
2759                encoding: Some(DIRECTORY_ENCODING_ZSTD.to_string()),
2760                layout: None,
2761                root_length: None,
2762                page_count: None,
2763                page_entries: None,
2764            },
2765            packs: vec![
2766                PackRef { key: "packs/a.sttp".to_string(), length: 100 },
2767                PackRef { key: "packs/b.sttp".to_string(), length: 200 },
2768            ],
2769            metadata: Metadata::new("manifest-test"),
2770        };
2771        let bytes = m.to_json_bytes().unwrap();
2772        // The spec keys must be camelCase where renamed.
2773        let s = String::from_utf8(bytes.clone()).unwrap();
2774        assert!(s.contains("\"formatVersion\""), "{s}");
2775        assert!(s.contains("\"directoryVersion\""), "{s}");
2776        assert!(s.contains("\"stt-packed\""), "{s}");
2777        // Empty capabilities are OMITTED — every pre-capabilities manifest
2778        // (and every non-quantized build) stays byte-identical.
2779        assert!(!s.contains("\"capabilities\""), "{s}");
2780        // blob_ordering None is OMITTED too, so pre-field builds stay byte-identical.
2781        assert!(!s.contains("\"blobOrdering\""), "{s}");
2782        let back = Manifest::from_json_bytes(&bytes).unwrap();
2783        assert_eq!(back.format, m.format);
2784        assert_eq!(back.format_version, m.format_version);
2785        assert_eq!(back.packs.len(), 2);
2786        assert_eq!(back.directory.directory_version, 5);
2787        assert_eq!(back.directory.encoding.as_deref(), Some("zstd"));
2788        assert_eq!(back.metadata.name, "manifest-test");
2789
2790        // Backward compat: a pre-encoding manifest (no `encoding` key — the
2791        // shape of every deployed dataset) must parse with `encoding: None`,
2792        // and a None encoding must serialize without the key.
2793        let mut legacy_json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2794        legacy_json["directory"]
2795            .as_object_mut()
2796            .unwrap()
2797            .remove("encoding");
2798        let legacy_back =
2799            Manifest::from_json_bytes(&serde_json::to_vec(&legacy_json).unwrap()).unwrap();
2800        assert_eq!(legacy_back.directory.encoding, None);
2801        // A pre-capabilities manifest (no `capabilities` key — the shape of
2802        // every deployed dataset) parses to an empty list.
2803        assert!(legacy_back.capabilities.is_empty());
2804        let legacy_out =
2805            String::from_utf8(legacy_back.to_json_bytes().unwrap()).unwrap();
2806        assert!(!legacy_out.contains("\"encoding\""), "{legacy_out}");
2807    }
2808
2809    /// Full round-trip: 30 synthetic tiles (a couple byte-identical to exercise
2810    /// dedup) across 2 zooms + several time buckets, written with a tiny pack
2811    /// target to force multiple packs. Every payload must decode byte-identical.
2812    #[test]
2813    fn packwriter_roundtrips_through_multiple_packs() {
2814        let dir = tempfile::tempdir().unwrap();
2815        let out = dir.path().join("dataset");
2816
2817        let mut w = v1_writer(&out, BlobOrdering::Auto, 8 * 1024);
2818
2819        // Build 30 tiles. Tiles 0..28 distinct; the static one is reused so two
2820        // entries point at one byte-identical blob (dedup).
2821        let static_payload = encode_tile(&[point_layer("default", vec![7, 8, 9], 0)]).unwrap();
2822        let mut expected: Vec<(TileId, i64, i64, Vec<u8>)> = Vec::new();
2823        let bucket = 3_600_000i64;
2824        for k in 0..30u64 {
2825            let zoom = if k % 2 == 0 { 9u8 } else { 10u8 };
2826            let b = (k % 5) as i64; // several time buckets
2827            let t = b * bucket;
2828            let payload = if k == 13 || k == 27 {
2829                static_payload.clone() // two byte-identical → dedup
2830            } else {
2831                distinct_tile(k)
2832            };
2833            let id = TileId::new(zoom, (k % 7) as u32, (k / 7) as u32, t as u64);
2834            w.add_tile_full(&id, t, t + bucket - 1, Some(t), 6, Some(bucket as u64), &payload)
2835                .unwrap();
2836            expected.push((id, t, t + bucket - 1, payload));
2837        }
2838        assert_eq!(w.tile_count(), 30);
2839
2840        let meta = Metadata::new("packed-roundtrip").with_temporal_bucket_ms(bucket as u64);
2841        let manifest = w.finalize(&meta).unwrap();
2842
2843        // >1 pack produced at the tiny target.
2844        assert!(manifest.packs.len() > 1, "expected multiple packs, got {}", manifest.packs.len());
2845
2846        // Every pack file ≤ target, except a lone oversized blob owning a pack.
2847        for p in &manifest.packs {
2848            let bytes = fs::read(out.join(&p.key)).unwrap();
2849            assert_eq!(bytes.len() as u64, p.length);
2850            // A pack over target must contain exactly one blob (oversized loner).
2851            // We can't see blob boundaries here, but the cut rule guarantees a
2852            // pack only exceeds the target when it holds a single blob; the
2853            // round-trip below proves correctness regardless.
2854        }
2855
2856        // Pack/dir filenames are blake3-128 hex of their bytes.
2857        for p in &manifest.packs {
2858            let bytes = fs::read(out.join(&p.key)).unwrap();
2859            let hex = blake3_128_hex(&bytes);
2860            assert_eq!(p.key, format!("packs/{hex}.sttp"));
2861        }
2862        let dir_bytes = fs::read(out.join(&manifest.directory.key)).unwrap();
2863        assert_eq!(
2864            manifest.directory.key,
2865            format!("index/{}.sttd", blake3_128_hex(&dir_bytes))
2866        );
2867
2868        // pack_ids are contiguous from 0 across all entries.
2869        let reader = PackedReader::open(out.join("manifest.json")).unwrap();
2870        let max_pack = reader.entries().iter().map(|e| e.pack_id).max().unwrap();
2871        assert_eq!(max_pack as usize, manifest.packs.len() - 1);
2872        let observed: std::collections::BTreeSet<u32> =
2873            reader.entries().iter().map(|e| e.pack_id).collect();
2874        for pid in 0..manifest.packs.len() as u32 {
2875            assert!(observed.contains(&pid), "pack_id {pid} unused");
2876        }
2877
2878        // Metadata round-trips.
2879        assert_eq!(reader.metadata().name, "packed-roundtrip");
2880        assert_eq!(reader.metadata().temporal_bucket_ms, bucket as u64);
2881        assert_eq!(reader.entries().len(), 30);
2882
2883        // The manifest totals are derived from the directory at finalize —
2884        // the caller's Metadata left them 0 and finalize must overwrite them.
2885        assert_eq!(manifest.metadata.tile_count, 30);
2886        assert_eq!(manifest.metadata.feature_count, 30 * 6);
2887        assert_eq!(reader.metadata().tile_count, 30);
2888        assert_eq!(reader.metadata().feature_count, 30 * 6);
2889
2890        // Every tile's decompressed payload is byte-identical.
2891        for (id, ts, _te, payload) in &expected {
2892            let e = reader
2893                .entries()
2894                .iter()
2895                .find(|e| {
2896                    e.zoom == id.z && e.x == id.x && e.y == id.y && e.time_start == *ts
2897                })
2898                .expect("entry present");
2899            let got = reader.read_payload(e).unwrap();
2900            assert_eq!(&got, payload, "payload mismatch for {id:?}");
2901        }
2902
2903        // Dedup: the two static tiles share one (pack, offset).
2904        let static_entries: Vec<&TileEntry> = reader
2905            .entries()
2906            .iter()
2907            .filter(|e| {
2908                let g = reader.read_payload(e).unwrap();
2909                g == static_payload
2910            })
2911            .collect();
2912        assert_eq!(static_entries.len(), 2);
2913        assert_eq!(static_entries[0].pack_id, static_entries[1].pack_id);
2914        assert_eq!(static_entries[0].offset, static_entries[1].offset);
2915    }
2916
2917    /// F1: `auto` measures the OCCUPIED spatial extent, not the raw max zoom. A
2918    /// dataset at a high zoom but a tiny spatial bbox over a deep-ish timeline
2919    /// resolves to spatial-major — which only holds when choose() sees the
2920    /// occupied bbox (~2 bits) rather than the 14-bit zoom (which would pick
2921    /// hilbert3). Also records the resolved order in the manifest (F4a).
2922    #[test]
2923    fn auto_resolves_from_occupied_extent_not_max_zoom() {
2924        let dir = tempfile::tempdir().unwrap();
2925        let out = dir.path().join("f1");
2926        let mut w = v1_writer(&out, BlobOrdering::Auto, 1 << 20);
2927        let bucket = 3_600_000i64;
2928        for x in 0..4u32 {
2929            for b in 0..64i64 {
2930                let t = b * bucket;
2931                let id = TileId::new(14, 10_000 + x, 20_000, t as u64);
2932                let payload = format!("f1-{x}-{b}").into_bytes();
2933                w.add_tile_full(&id, t, t + bucket - 1, Some(t), 1, None, &payload).unwrap();
2934            }
2935        }
2936        let meta = Metadata::new("f1").with_temporal_bucket_ms(bucket as u64);
2937        let manifest = w.finalize(&meta).unwrap();
2938        // occupied space bits = bits_for(4) = 2; time bits = bits_for(64) = 6;
2939        // 6 > 2 + 3 → spatial. (Old raw-max-zoom rule: choose(14, 6) → hilbert3.)
2940        assert_eq!(manifest.blob_ordering.as_deref(), Some("spatial"));
2941    }
2942
2943    /// The opt-in measured picker resolves to a concrete, deterministic order
2944    /// and records it in the manifest (never `auto`/`measured`).
2945    #[test]
2946    fn measured_ordering_records_concrete_order() {
2947        let build = || {
2948            let dir = tempfile::tempdir().unwrap();
2949            let out = dir.path().join("m");
2950            let mut w = v1_writer(&out, BlobOrdering::Auto, 1 << 20)
2951                .with_measured_ordering(true);
2952            let bucket = 3_600_000i64;
2953            for x in 0..2u32 {
2954                for b in 0..24i64 {
2955                    let t = b * bucket;
2956                    let id = TileId::new(10, 5_000 + x, 6_000, t as u64);
2957                    let payload = format!("m-{x}-{b}").into_bytes();
2958                    w.add_tile_full(&id, t, t + bucket - 1, Some(t), 1, None, &payload).unwrap();
2959                }
2960            }
2961            let meta = Metadata::new("m").with_temporal_bucket_ms(bucket as u64);
2962            w.finalize(&meta).unwrap().blob_ordering
2963        };
2964        let a = build();
2965        let b = build();
2966        assert_eq!(a, b, "measured resolution must be deterministic");
2967        let o = a.expect("measured records a concrete order");
2968        assert!(matches!(o.as_str(), "spatial" | "time-major" | "hilbert3" | "morton3"));
2969    }
2970
2971    /// A **paged** directory build round-trips end-to-end through
2972    /// `PackedReader`: every input tile's payload decodes byte-identically, the
2973    /// manifest carries the container fields, and content-address verification
2974    /// is clean. Separately, a paged and a single build of the same input must
2975    /// agree byte-for-byte on everything except the directory object's
2976    /// container shape: identical pack content addresses and identical decoded
2977    /// entries. (Cross-finalize blob bytes ARE reproducible — the encoder feeds
2978    /// sorted metadata to Arrow ≥59's sorted-order IPC writer, guarded by
2979    /// `reproducible_build.rs` — so the old arrow-54 "compare only address
2980    /// keys" softening no longer applies.)
2981    #[test]
2982    fn paged_directory_writer_roundtrips_and_matches_single() {
2983        let bucket = 3_600_000i64;
2984        // Deterministic input tiles: (id, time_start, time_end, payload).
2985        let mut input: Vec<(TileId, i64, i64, Vec<u8>)> = Vec::new();
2986        for k in 0..120u64 {
2987            let zoom = [6u8, 10, 13][(k % 3) as usize];
2988            let b = (k % 4) as i64;
2989            let t = b * bucket;
2990            let id = TileId::new(zoom, (k % 11) as u32, (k / 11) as u32, t as u64);
2991            input.push((id, t, t + bucket - 1, distinct_tile(k)));
2992        }
2993        let build = |out: &Path, page_entries: Option<usize>| -> Manifest {
2994            let mut w = v1_writer(out, BlobOrdering::Auto, 16 * 1024)
2995                .with_paging(page_entries);
2996            for (id, ts, te, payload) in &input {
2997                w.add_tile_full(id, *ts, *te, Some(*ts), 6, Some(bucket as u64), payload)
2998                    .unwrap();
2999            }
3000            let meta = Metadata::new("paged-roundtrip").with_temporal_bucket_ms(bucket as u64);
3001            w.finalize(&meta).unwrap()
3002        };
3003
3004        let dir = tempfile::tempdir().unwrap();
3005        let single_out = dir.path().join("single");
3006        let paged_out = dir.path().join("paged");
3007        let single = build(&single_out, None);
3008        // Small page size to force several leaf pages over 120 entries.
3009        let paged = build(&paged_out, Some(16));
3010
3011        // Paged manifest carries the container fields; single does not.
3012        assert!(single.directory.layout.is_none());
3013        assert_eq!(paged.directory.layout.as_deref(), Some(DIRECTORY_LAYOUT_PAGED));
3014        assert!(paged.directory.root_length.unwrap() > 0);
3015        assert!(paged.directory.page_count.unwrap() >= 2, "expected multiple leaf pages");
3016        assert_eq!(paged.directory.page_entries, Some(16));
3017        assert_eq!(paged.directory.directory_version, crate::directory::DIRECTORY_VERSION);
3018        assert_eq!(paged.directory.encoding.as_deref(), Some(DIRECTORY_ENCODING_ZSTD));
3019
3020        let r_single = PackedReader::open(single_out.join("manifest.json")).unwrap();
3021        let r_paged = PackedReader::open(paged_out.join("manifest.json")).unwrap();
3022        assert_eq!(r_paged.entries().len(), 120);
3023        assert_eq!(r_single.entries().len(), 120);
3024
3025        // Cross-build agreement is byte-level: identical pack content addresses
3026        // (blob bytes are reproducible across finalize runs) and identical
3027        // decoded directory entries — the paged build differs from the single
3028        // build ONLY in the directory object's container shape.
3029        assert_eq!(
3030            single.packs.iter().map(|p| (&p.key, p.length)).collect::<Vec<_>>(),
3031            paged.packs.iter().map(|p| (&p.key, p.length)).collect::<Vec<_>>(),
3032            "pack content addresses must match across single vs paged builds"
3033        );
3034        assert_eq!(r_single.entries(), r_paged.entries());
3035
3036        // Every INPUT tile decodes byte-identically through the paged reader.
3037        for (id, ts, _te, payload) in &input {
3038            let e = r_paged
3039                .entries()
3040                .iter()
3041                .find(|x| x.zoom == id.z && x.x == id.x && x.y == id.y && x.time_start == *ts)
3042                .expect("paged entry present");
3043            assert_eq!(&r_paged.read_payload(e).unwrap(), payload, "payload mismatch {id:?}");
3044        }
3045
3046        // Content-address integrity verifies clean on the paged dataset.
3047        let issues = verify_packed_objects(paged_out.join("manifest.json")).unwrap();
3048        assert!(issues.is_empty(), "paged verify issues: {issues:?}");
3049    }
3050
3051    /// A single blob larger than the pack target gets its own pack (never split).
3052    #[test]
3053    fn oversized_blob_gets_its_own_pack() {
3054        let dir = tempfile::tempdir().unwrap();
3055        let out = dir.path().join("dataset");
3056
3057        // One big tile (lots of distinct points → big compressed blob) plus a
3058        // few small ones, with a target smaller than the big blob.
3059        let big_ids: Vec<u64> = (0..4000).collect();
3060        let big = encode_tile(&[ColumnarLayer {
3061            name: "default".to_string(),
3062            feature_ids: big_ids.clone(),
3063            start_times: vec![0; big_ids.len()],
3064            end_times: vec![100; big_ids.len()],
3065            geometry: GeometryColumn::Point(
3066                (0..big_ids.len()).map(|i| [i as f64 * 0.01, i as f64 * 0.013]).collect(),
3067            ),
3068            vertex_times: None,
3069            vertex_values: None,
3070            triangles: None,
3071            vertex_value_matrix: None,
3072            properties: vec![],
3073        }])
3074        .unwrap();
3075        let big_compressed_len = compression::compress_zstd_with_dict(&big, None).unwrap().len();
3076
3077        let mut w = v1_writer(&out, BlobOrdering::SpatialMajor, 4 * 1024);
3078        // target 4 KiB < big blob.
3079        assert!(big_compressed_len as u64 > 4 * 1024);
3080        w.add_tile_full(&TileId::new(10, 0, 0, 0), 0, 100, None, 4000, None, &big).unwrap();
3081        for k in 1..4u64 {
3082            let p = distinct_tile(k);
3083            w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p).unwrap();
3084        }
3085        let _manifest = w.finalize(&Metadata::new("big")).unwrap();
3086
3087        // At least one pack exceeds the target (the loner). Reading proves it.
3088        let reader = PackedReader::open(out.join("manifest.json")).unwrap();
3089        let big_entry = reader.entries().iter().find(|e| e.x == 0).unwrap();
3090        assert_eq!(reader.read_payload(big_entry).unwrap(), big);
3091    }
3092
3093    /// Read-path integrity: flipping a byte inside a pack object makes
3094    /// [`PackedReader::read_payload`] fail the per-blob CRC32C check instead of
3095    /// returning silently-wrong bytes. Guards the integrity check in
3096    /// `read_payload` (replaces the archive-era `corrupt_blob_is_detected`).
3097    #[test]
3098    fn corrupt_pack_blob_is_detected_on_read() {
3099        let dir = tempfile::tempdir().unwrap();
3100        let out = dir.path().join("dataset");
3101
3102        let mut w = v1_writer(&out, BlobOrdering::Auto, 64 * 1024 * 1024);
3103        for k in 0..4u64 {
3104            let p = distinct_tile(k);
3105            w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p).unwrap();
3106        }
3107        let manifest = w.finalize(&Metadata::new("crc")).unwrap();
3108
3109        // Clean read before corruption.
3110        let entry = PackedReader::open(out.join("manifest.json")).unwrap().entries()[0].clone();
3111        assert!(PackedReader::open(out.join("manifest.json"))
3112            .unwrap()
3113            .read_payload(&entry)
3114            .is_ok());
3115
3116        // Flip the first byte of that tile's compressed blob inside its pack.
3117        let pack_path = out.join(&manifest.packs[entry.pack_id as usize].key);
3118        let mut bytes = fs::read(&pack_path).unwrap();
3119        bytes[entry.offset as usize] ^= 0xff;
3120        fs::write(&pack_path, &bytes).unwrap();
3121
3122        // A fresh reader must now reject that tile's payload (CRC32C mismatch).
3123        let reader = PackedReader::open(out.join("manifest.json")).unwrap();
3124        assert!(
3125            reader.read_payload(&entry).is_err(),
3126            "corrupt pack blob must fail the read-path CRC32C check"
3127        );
3128    }
3129
3130    /// Two builds of the same input — added in different orders, including
3131    /// curve-key ties (a base + temporal-LOD entry on one cell) — must produce
3132    /// byte-identical objects: same pack hashes, same directory hash. This is
3133    /// the immutable-pack CDN contract (a rebuild of unchanged data must not
3134    /// invalidate the edge cache).
3135    #[test]
3136    fn rebuilds_are_byte_reproducible() {
3137        // Tiles including a tie pair: same (z, x, y, time_start), one base
3138        // (bucket None) and one LOD (bucket Some) — the curve key alone can't
3139        // order them, the tiebreak must.
3140        let bucket = 3_600_000i64;
3141        let mut tiles: Vec<(TileId, i64, Option<u64>, Vec<u8>)> = Vec::new();
3142        for k in 0..10u64 {
3143            let t = (k % 4) as i64 * bucket;
3144            tiles.push((
3145                TileId::new(9, (k % 5) as u32, (k / 5) as u32, t as u64),
3146                t,
3147                None,
3148                distinct_tile(k),
3149            ));
3150        }
3151        // The tie pair on cell (1, 0): base + LOD aggregate at one time_start.
3152        tiles.push((TileId::new(9, 1, 0, 0), 0, None, distinct_tile(100)));
3153        tiles.push((
3154            TileId::new(9, 1, 0, 0),
3155            0,
3156            Some(24 * bucket as u64),
3157            distinct_tile(101),
3158        ));
3159
3160        let meta = Metadata::new("repro").with_temporal_bucket_ms(bucket as u64);
3161        let build = |order: &[usize]| {
3162            let dir = tempfile::tempdir().unwrap();
3163            let out = dir.path().join("dataset");
3164            let mut w = v1_writer(&out, BlobOrdering::Auto, 8 * 1024);
3165            for &i in order {
3166                let (id, t, b, payload) = &tiles[i];
3167                w.add_tile_full(id, *t, t + bucket - 1, Some(*t), 6, *b, payload)
3168                    .unwrap();
3169            }
3170            let manifest = w.finalize(&meta).unwrap();
3171            (dir, manifest)
3172        };
3173
3174        let forward: Vec<usize> = (0..tiles.len()).collect();
3175        let reverse: Vec<usize> = (0..tiles.len()).rev().collect();
3176        let (_d1, m1) = build(&forward);
3177        let (_d2, m2) = build(&reverse);
3178
3179        assert_eq!(m1.directory.key, m2.directory.key, "directory hash must be stable");
3180        assert_eq!(
3181            m1.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
3182            m2.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
3183            "pack hashes must be stable across rebuilds"
3184        );
3185        assert_eq!(m1.to_json_bytes().unwrap(), m2.to_json_bytes().unwrap());
3186    }
3187
3188    /// Spilling is a MEMORY-behaviour lever only: a build whose payloads spill
3189    /// to disk (tiny budget) must produce byte-identical objects — same pack
3190    /// hashes, same directory hash, same manifest — as the unlimited all-in-RAM
3191    /// build, and the temp spill file must be gone afterwards (success path)
3192    /// as well as when the writer is dropped without finalize (abandon path).
3193    #[test]
3194    fn spilled_build_is_byte_identical_to_in_memory() {
3195        let bucket = 3_600_000i64;
3196        let mut tiles: Vec<(TileId, i64, Option<u64>, Vec<u8>)> = Vec::new();
3197        for k in 0..40u64 {
3198            let t = (k % 4) as i64 * bucket;
3199            tiles.push((
3200                TileId::new(9, (k % 8) as u32, (k / 8) as u32, t as u64),
3201                t,
3202                None,
3203                distinct_tile(k),
3204            ));
3205        }
3206        // Duplicate payload pair so dedup is exercised across the spill.
3207        tiles.push((TileId::new(9, 7, 7, 0), 0, None, distinct_tile(3)));
3208
3209        let meta = Metadata::new("spill").with_temporal_bucket_ms(bucket as u64);
3210        let build = |budget: u64| {
3211            let dir = tempfile::tempdir().unwrap();
3212            let out = dir.path().join("dataset");
3213            let mut w = v1_writer(&out, BlobOrdering::Auto, 8 * 1024).with_memory_budget(budget);
3214            for (id, t, b, payload) in &tiles {
3215                w.add_tile_full(id, *t, t + bucket - 1, Some(*t), 6, *b, payload)
3216                    .unwrap();
3217            }
3218            let manifest = w.finalize(&meta).unwrap();
3219            // No `.spill-*` residue in the output dir on success.
3220            let residue: Vec<_> = fs::read_dir(&out)
3221                .unwrap()
3222                .filter_map(|e| e.ok())
3223                .filter(|e| e.file_name().to_string_lossy().starts_with(".spill-"))
3224                .collect();
3225            assert!(residue.is_empty(), "spill file left behind: {residue:?}");
3226            (dir, manifest)
3227        };
3228
3229        // 1 KiB budget: virtually every payload spills. 0 = legacy unlimited.
3230        let (d1, spilled) = build(1024);
3231        let (_d2, in_mem) = build(0);
3232        assert_eq!(
3233            spilled.directory.key, in_mem.directory.key,
3234            "directory hash must not depend on the payload storage medium"
3235        );
3236        assert_eq!(
3237            spilled.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
3238            in_mem.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
3239            "pack hashes must not depend on the payload storage medium"
3240        );
3241        assert_eq!(spilled.to_json_bytes().unwrap(), in_mem.to_json_bytes().unwrap());
3242
3243        // Multi-pack at the tiny target, so the STREAMING pack phase
3244        // (incremental blake3, per-seal temp→content-address renames) crossed
3245        // pack cuts under both budgets and still matched byte-for-byte; its
3246        // sealed objects verify clean and leave no `.tmp-*` residue.
3247        assert!(spilled.packs.len() > 1, "fixture must span multiple packs");
3248        let spilled_out = d1.path().join("dataset");
3249        assert!(verify_packed_objects(spilled_out.join("manifest.json")).unwrap().is_empty());
3250        let tmp_residue: Vec<_> = fs::read_dir(spilled_out.join("packs"))
3251            .unwrap()
3252            .filter_map(|e| e.ok())
3253            .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp-"))
3254            .collect();
3255        assert!(tmp_residue.is_empty(), "streaming pack temp left behind: {tmp_residue:?}");
3256
3257        // Abandon path: dropping a writer that spilled (no finalize) must
3258        // remove the spill file too.
3259        let dir = tempfile::tempdir().unwrap();
3260        let out = dir.path().join("dataset");
3261        {
3262            let mut w = v1_writer(&out, BlobOrdering::Auto, 8 * 1024).with_memory_budget(1);
3263            for (id, t, b, payload) in tiles.iter().take(4) {
3264                w.add_tile_full(id, *t, t + bucket - 1, Some(*t), 6, *b, payload)
3265                    .unwrap();
3266            }
3267            // Spill file exists while the writer is live.
3268            assert!(fs::read_dir(&out)
3269                .unwrap()
3270                .filter_map(|e| e.ok())
3271                .any(|e| e.file_name().to_string_lossy().starts_with(".spill-")));
3272        }
3273        assert!(!fs::read_dir(&out)
3274            .unwrap()
3275            .filter_map(|e| e.ok())
3276            .any(|e| e.file_name().to_string_lossy().starts_with(".spill-")));
3277    }
3278
3279    /// The directory's `uncompressed_size` is a u32 field, so a ≥ 4 GiB
3280    /// payload must be a loud error at `add_tile_full` — never a silent
3281    /// length truncation (in-RAM path) or a spill-length mismatch. The bound
3282    /// is tested on the extracted check (no 4 GiB allocation).
3283    #[test]
3284    fn payload_len_guard_rejects_4gib_payloads() {
3285        let id = TileId::new(5, 1, 2, 0);
3286        assert!(check_payload_len(&id, 0).is_ok());
3287        assert!(check_payload_len(&id, u32::MAX as u64).is_ok(), "exact u32::MAX still fits");
3288        let err = check_payload_len(&id, u32::MAX as u64 + 1).unwrap_err();
3289        assert!(
3290            err.to_string().contains("uncompressed_size") && err.to_string().contains("4 GiB"),
3291            "got: {err}"
3292        );
3293        assert!(check_payload_len(&id, u64::MAX).is_err());
3294    }
3295
3296    /// ★F6 at WRITE time: `add_tile_full` refuses a layer frame whose version
3297    /// disagrees with the writer's declared formatVersion — in BOTH
3298    /// directions — so a mixed-version dataset can never be produced (it
3299    /// would brick on first read; readers hard-reject).
3300    #[test]
3301    fn add_tile_full_rejects_mixed_frame_and_writer_versions() {
3302        let dir = tempfile::tempdir().unwrap();
3303        let id = TileId::new(10, 0, 0, 0);
3304        let v1_payload = encode_tile_with(
3305            &[point_layer("default", vec![1, 2], 0)],
3306            &EncoderConfig::default(),
3307        )
3308        .unwrap();
3309        let v2_payload = encode_tile_with(
3310            &[point_layer("default", vec![1, 2], 0)],
3311            &EncoderConfig {
3312                format_version: FORMAT_VERSION_V2,
3313                ..EncoderConfig::default()
3314            },
3315        )
3316        .unwrap();
3317
3318        // v1 frame → v2 (default) writer.
3319        let mut w2 = PackWriter::create(dir.path().join("v2"), BlobOrdering::Auto, 8 * 1024)
3320            .unwrap();
3321        let err = w2
3322            .add_tile_full(&id, 0, 100, None, 2, None, &v1_payload)
3323            .unwrap_err();
3324        assert!(
3325            err.to_string().contains("v1 layer frame")
3326                && err.to_string().contains("formatVersion-2"),
3327            "got: {err}"
3328        );
3329
3330        // v2 frame → v1 writer.
3331        let mut w1 = v1_writer(&dir.path().join("v1"), BlobOrdering::Auto, 8 * 1024);
3332        let err = w1
3333            .add_tile_full(&id, 0, 100, None, 2, None, &v2_payload)
3334            .unwrap_err();
3335        assert!(
3336            err.to_string().contains("v2 layer frame")
3337                && err.to_string().contains("formatVersion-1"),
3338            "got: {err}"
3339        );
3340
3341        // Matching versions pass (both directions).
3342        w2.add_tile_full(&id, 0, 100, None, 2, None, &v2_payload).unwrap();
3343        w1.add_tile_full(&id, 0, 100, None, 2, None, &v1_payload).unwrap();
3344    }
3345
3346    /// The directory ships zstd-compressed at rest (declared via
3347    /// `directory.encoding`), and a legacy manifest with a RAW directory and
3348    /// no `encoding` key — the shape of every deployed dataset — must still
3349    /// open and verify. formatVersion 1 throughout: the raw-directory legacy
3350    /// shape predates v2 (whose objects always carry the magic prelude).
3351    #[test]
3352    fn directory_encoding_compressed_and_raw_both_read() {
3353        let dir = tempfile::tempdir().unwrap();
3354        let out = dir.path().join("dataset");
3355        let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024)
3356            .unwrap()
3357            .with_format_version(PACKED_FORMAT_VERSION_V1);
3358        for k in 0..8u64 {
3359            let p = distinct_tile(k);
3360            w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p)
3361                .unwrap();
3362        }
3363        let manifest = w.finalize(&Metadata::new("dir-enc")).unwrap();
3364        let manifest_path = out.join("manifest.json");
3365
3366        // Fresh output declares the encoding and the at-rest bytes are a
3367        // valid zstd frame that inflates to the codec bytes.
3368        assert_eq!(manifest.directory.encoding.as_deref(), Some("zstd"));
3369        let at_rest = fs::read(out.join(&manifest.directory.key)).unwrap();
3370        assert_eq!(at_rest.len() as u64, manifest.directory.length);
3371        let raw = compression::decompress_zstd_with_dict(&at_rest, None).unwrap();
3372        assert!(crate::directory::decode_directory(&raw).is_ok());
3373        assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
3374        let entries_compressed = PackedReader::open(&manifest_path).unwrap().entries().to_vec();
3375
3376        // Rewrite the dataset as a legacy (raw-directory) manifest: store the
3377        // raw codec bytes under their own content address and drop `encoding`.
3378        let raw_hex = blake3_128_hex(&raw);
3379        let raw_rel = format!("index/{raw_hex}.sttd");
3380        fs::write(out.join(&raw_rel), &raw).unwrap();
3381        let mut legacy = manifest.clone();
3382        legacy.directory = DirectoryRef {
3383            key: raw_rel,
3384            length: raw.len() as u64,
3385            directory_version: crate::directory::DIRECTORY_VERSION,
3386            encoding: None,
3387            layout: None,
3388            root_length: None,
3389            page_count: None,
3390            page_entries: None,
3391        };
3392        fs::write(&manifest_path, legacy.to_json_bytes().unwrap()).unwrap();
3393
3394        assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
3395        let entries_raw = PackedReader::open(&manifest_path).unwrap().entries().to_vec();
3396        assert_eq!(entries_raw, entries_compressed);
3397
3398        // An unknown encoding must fail loudly, not decode garbage.
3399        legacy.directory.encoding = Some("br".to_string());
3400        fs::write(&manifest_path, legacy.to_json_bytes().unwrap()).unwrap();
3401        assert!(PackedReader::open(&manifest_path).is_err());
3402    }
3403
3404    /// `PackedReader::open` must reject a manifest whose `formatVersion` is
3405    /// not the one this reader implements — a future breaking revision has to
3406    /// fail loudly at open instead of silently misdecoding.
3407    #[test]
3408    fn unknown_format_version_is_rejected_at_open() {
3409        let dir = tempfile::tempdir().unwrap();
3410        let out = dir.path().join("dataset");
3411
3412        let mut w = v1_writer(&out, BlobOrdering::Auto, 8 * 1024);
3413        w.add_tile_full(&TileId::new(10, 0, 0, 0), 0, 100, None, 6, None, &distinct_tile(1))
3414            .unwrap();
3415        w.finalize(&Metadata::new("ver")).unwrap();
3416        let manifest_path = out.join("manifest.json");
3417        assert!(PackedReader::open(&manifest_path).is_ok());
3418
3419        // Doctor the manifest to claim a future formatVersion.
3420        let mut v: serde_json::Value =
3421            serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
3422        v["formatVersion"] = serde_json::json!(3);
3423        fs::write(&manifest_path, serde_json::to_vec(&v).unwrap()).unwrap();
3424
3425        let err = match PackedReader::open(&manifest_path) {
3426            Ok(_) => panic!("formatVersion 3 must be rejected at open"),
3427            Err(e) => e,
3428        };
3429        assert!(err.to_string().contains("formatVersion"), "unexpected error: {err}");
3430    }
3431
3432    /// The manifest's `formatVersion` is AUTHORITATIVE (design §1 ★F6):
3433    /// doctoring a v1 dataset's manifest to claim v2 must fail loudly at open
3434    /// (the v1 objects carry no magic prelude), never best-effort decode.
3435    #[test]
3436    fn v1_dataset_doctored_to_claim_v2_is_rejected_at_open() {
3437        let dir = tempfile::tempdir().unwrap();
3438        let out = dir.path().join("dataset");
3439
3440        let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024)
3441            .unwrap()
3442            .with_format_version(PACKED_FORMAT_VERSION_V1);
3443        w.add_tile_full(&TileId::new(10, 0, 0, 0), 0, 100, None, 6, None, &distinct_tile(1))
3444            .unwrap();
3445        w.finalize(&Metadata::new("ver")).unwrap();
3446        let manifest_path = out.join("manifest.json");
3447
3448        let mut v: serde_json::Value =
3449            serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
3450        v["formatVersion"] = serde_json::json!(2);
3451        fs::write(&manifest_path, serde_json::to_vec(&v).unwrap()).unwrap();
3452
3453        let err = match PackedReader::open(&manifest_path) {
3454            Ok(_) => panic!("v2-declared manifest over magic-less v1 objects must be rejected"),
3455            Err(e) => e,
3456        };
3457        assert!(err.to_string().contains("STTD"), "unexpected error: {err}");
3458    }
3459
3460    /// `with_capabilities` declares required-to-understand features in the
3461    /// manifest: canonicalized (sorted + deduped) for byte-reproducibility,
3462    /// present in the JSON, and accepted by a reader that implements them.
3463    #[test]
3464    fn writer_declares_capabilities_canonically() {
3465        let dir = tempfile::tempdir().unwrap();
3466        let out = dir.path().join("dataset");
3467
3468        let mut w = v1_writer(&out, BlobOrdering::Auto, 8 * 1024)
3469            // Unsorted + duplicated on purpose: the manifest must not depend
3470            // on the caller's flag order.
3471            .with_capabilities(vec![
3472                CAPABILITY_ELEVATION_FOLD.to_string(),
3473                CAPABILITY_COORD_QUANT.to_string(),
3474                CAPABILITY_COORD_QUANT.to_string(),
3475            ]);
3476        w.add_tile_full(&TileId::new(10, 0, 0, 0), 0, 100, None, 6, None, &distinct_tile(1))
3477            .unwrap();
3478        let manifest = w.finalize(&Metadata::new("caps")).unwrap();
3479        assert_eq!(
3480            manifest.capabilities,
3481            vec![CAPABILITY_COORD_QUANT.to_string(), CAPABILITY_ELEVATION_FOLD.to_string()]
3482        );
3483
3484        let manifest_path = out.join("manifest.json");
3485        let s = fs::read_to_string(&manifest_path).unwrap();
3486        assert!(s.contains("\"capabilities\""), "{s}");
3487        // This reader implements the whole registry, so open succeeds.
3488        assert!(PackedReader::open(&manifest_path).is_ok());
3489    }
3490
3491    /// `PackedReader::open` must reject a dataset declaring a capability this
3492    /// reader does not implement, naming the unknown entries — a capability
3493    /// re-types existing columns, so proceeding would silently misdecode.
3494    #[test]
3495    fn unknown_capability_is_rejected_at_open() {
3496        let dir = tempfile::tempdir().unwrap();
3497        let out = dir.path().join("dataset");
3498
3499        let mut w = v1_writer(&out, BlobOrdering::Auto, 8 * 1024);
3500        w.add_tile_full(&TileId::new(10, 0, 0, 0), 0, 100, None, 6, None, &distinct_tile(1))
3501            .unwrap();
3502        w.finalize(&Metadata::new("caps")).unwrap();
3503        let manifest_path = out.join("manifest.json");
3504
3505        // Doctor the manifest to declare a capability from the future,
3506        // alongside one this reader DOES implement (only the unknown one may
3507        // be named in the error).
3508        let mut v: serde_json::Value =
3509            serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
3510        v["capabilities"] = serde_json::json!([CAPABILITY_COORD_QUANT, "from-the-future"]);
3511        fs::write(&manifest_path, serde_json::to_vec(&v).unwrap()).unwrap();
3512
3513        let err = match PackedReader::open(&manifest_path) {
3514            Ok(_) => panic!("unknown capability must be rejected at open"),
3515            Err(e) => e,
3516        };
3517        let msg = err.to_string();
3518        // Exactly the unknown entry is named (coord-quant is implemented).
3519        assert!(
3520            msg.contains("does not implement: from-the-future ("),
3521            "unexpected error: {msg}"
3522        );
3523    }
3524
3525    /// A clean packed dataset verifies with no issues; corrupting a pack's
3526    /// bytes (without changing its length) breaks the content address and is
3527    /// reported.
3528    #[test]
3529    fn verify_packed_objects_clean_then_detects_corruption() {
3530        let dir = tempfile::tempdir().unwrap();
3531        let out = dir.path().join("dataset");
3532
3533        let mut w = v1_writer(&out, BlobOrdering::Auto, 8 * 1024);
3534        for k in 0..12u64 {
3535            let p = distinct_tile(k);
3536            w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p)
3537                .unwrap();
3538        }
3539        let manifest = w.finalize(&Metadata::new("verify")).unwrap();
3540        let manifest_path = out.join("manifest.json");
3541
3542        // Clean dataset → no integrity violations.
3543        assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
3544
3545        // Flip a byte in pack 0: same length, but blake3 no longer matches the
3546        // filename it was addressed by.
3547        let pack0 = out.join(&manifest.packs[0].key);
3548        let mut bytes = fs::read(&pack0).unwrap();
3549        bytes[0] ^= 0xff;
3550        fs::write(&pack0, &bytes).unwrap();
3551
3552        let issues = verify_packed_objects(&manifest_path).unwrap();
3553        assert!(
3554            issues.iter().any(|s| s.contains("content-address mismatch")),
3555            "expected a content-address mismatch, got {issues:?}"
3556        );
3557    }
3558
3559    // ---- Bundle profile (`.sttb`) ------------------------------------------
3560
3561    /// Build a small multi-pack dataset (with a dedup pair) into `out` and
3562    /// return its manifest — shared fixture for the bundle tests.
3563    fn build_bundle_fixture(out: &Path) -> Manifest {
3564        // v1: the fixture's payloads are v1 frames (bare `encode_tile`), and
3565        // the bundle container is deliberately format-version-agnostic — v2
3566        // bundle coverage lives in `v2_bundle_roundtrips_and_verifies`.
3567        let mut w = PackWriter::create(out, BlobOrdering::Auto, 8 * 1024)
3568            .unwrap()
3569            .with_format_version(PACKED_FORMAT_VERSION_V1);
3570        let bucket = 3_600_000i64;
3571        let static_payload = encode_tile(&[point_layer("default", vec![7, 8, 9], 0)]).unwrap();
3572        for k in 0..20u64 {
3573            let t = (k % 4) as i64 * bucket;
3574            let payload = if k == 3 || k == 17 {
3575                static_payload.clone() // byte-identical pair → dedup
3576            } else {
3577                distinct_tile(k)
3578            };
3579            let id = TileId::new(10, (k % 5) as u32, (k / 5) as u32, t as u64);
3580            w.add_tile_full(&id, t, t + bucket - 1, Some(t), 6, Some(bucket as u64), &payload)
3581                .unwrap();
3582        }
3583        let meta = Metadata::new("bundle-fixture").with_temporal_bucket_ms(bucket as u64);
3584        w.finalize(&meta).unwrap()
3585    }
3586
3587    /// pack → unpack round-trips every object byte-identically (blake3 names
3588    /// re-verify via `verify_packed_objects`), `manifest.json` comes back
3589    /// verbatim, and packing is deterministic (same dataset ⇒ same bytes).
3590    #[test]
3591    fn bundle_pack_unpack_roundtrips_byte_identical() {
3592        let dir = tempfile::tempdir().unwrap();
3593        let src = dir.path().join("dataset");
3594        let manifest = build_bundle_fixture(&src);
3595        assert!(manifest.packs.len() > 1, "fixture should span multiple packs");
3596        let manifest_path = src.join("manifest.json");
3597
3598        // Deterministic pack: two runs produce byte-identical bundles.
3599        let bundle_a = dir.path().join("a.sttb");
3600        let bundle_b = dir.path().join("b.sttb");
3601        let summary = write_bundle(&manifest_path, &bundle_a).unwrap();
3602        write_bundle(&manifest_path, &bundle_b).unwrap();
3603        let bytes_a = fs::read(&bundle_a).unwrap();
3604        assert_eq!(bytes_a, fs::read(&bundle_b).unwrap(), "bundling must be deterministic");
3605        assert_eq!(bytes_a.len() as u64, summary.bytes);
3606        assert_eq!(summary.objects, 1 + manifest.packs.len());
3607
3608        // Magic prelude + 8-aligned object offsets in canonical order.
3609        assert_eq!(&bytes_a[0..4], b"STTB");
3610        assert_eq!(bytes_a[4], BUNDLE_VERSION);
3611        assert_eq!(&bytes_a[5..8], &[0, 0, 0]);
3612        let (header, header_end) = parse_bundle_header(&bytes_a).unwrap();
3613        assert_eq!(header.objects.len(), summary.objects);
3614        assert_eq!(header.objects[0].key, manifest.directory.key, "directory first");
3615        for (o, p) in header.objects[1..].iter().zip(&manifest.packs) {
3616            assert_eq!(o.key, p.key, "packs in pack_id order");
3617        }
3618        for o in &header.objects {
3619            assert_eq!(o.offset % 8, 0, "object {} not 8-aligned", o.key);
3620            assert!(o.offset >= header_end);
3621        }
3622        // The embedded manifest is VERBATIM.
3623        assert_eq!(
3624            header.manifest.get().as_bytes(),
3625            fs::read(&manifest_path).unwrap(),
3626            "header manifest must be the manifest.json bytes verbatim"
3627        );
3628
3629        // Unpack: byte-identical objects + manifest, clean verification.
3630        let out = dir.path().join("unpacked");
3631        let back = unpack_bundle(&bundle_a, &out).unwrap();
3632        assert_eq!(back.objects, summary.objects);
3633        assert!(
3634            verify_packed_objects(out.join("manifest.json")).unwrap().is_empty(),
3635            "unpacked dataset must re-verify its content addresses"
3636        );
3637        assert_eq!(
3638            fs::read(out.join("manifest.json")).unwrap(),
3639            fs::read(&manifest_path).unwrap()
3640        );
3641        for key in std::iter::once(&manifest.directory.key)
3642            .chain(manifest.packs.iter().map(|p| &p.key))
3643        {
3644            assert_eq!(
3645                fs::read(out.join(key)).unwrap(),
3646                fs::read(src.join(key)).unwrap(),
3647                "object {key} must round-trip byte-identical"
3648            );
3649        }
3650
3651        // And the bundle's own verifier is clean.
3652        assert!(verify_bundle_objects(&bundle_a).unwrap().is_empty());
3653    }
3654
3655    /// `open_bundle` reads every tile byte-identical to the exploded-dir
3656    /// reader, over both directory container shapes (single and paged).
3657    #[test]
3658    fn open_bundle_reads_tiles_byte_identical_to_exploded_dir() {
3659        for paged in [false, true] {
3660            let dir = tempfile::tempdir().unwrap();
3661            let src = dir.path().join("dataset");
3662            let mut w = v1_writer(&src, BlobOrdering::Auto, 8 * 1024)
3663                .with_paging(paged.then_some(4));
3664            for k in 0..20u64 {
3665                w.add_tile_full(
3666                    &TileId::new(10, (k % 5) as u32, (k / 5) as u32, 0),
3667                    0,
3668                    100,
3669                    None,
3670                    6,
3671                    None,
3672                    &distinct_tile(k),
3673                )
3674                .unwrap();
3675            }
3676            w.finalize(&Metadata::new("bundle-read")).unwrap();
3677
3678            let bundle = dir.path().join("dataset.sttb");
3679            write_bundle(src.join("manifest.json"), &bundle).unwrap();
3680
3681            let exploded = PackedReader::open(src.join("manifest.json")).unwrap();
3682            let bundled = PackedReader::open_bundle(&bundle).unwrap();
3683            assert_eq!(exploded.entries(), bundled.entries(), "paged={paged}");
3684            assert_eq!(exploded.metadata().name, bundled.metadata().name);
3685            for (e_dir, e_bun) in exploded.entries().iter().zip(bundled.entries()) {
3686                assert_eq!(
3687                    exploded.read_payload(e_dir).unwrap(),
3688                    bundled.read_payload(e_bun).unwrap(),
3689                    "payload mismatch (paged={paged}) for {:?}",
3690                    e_dir.tile_id()
3691                );
3692            }
3693        }
3694    }
3695
3696    /// Corrupting a blob inside the bundle is caught twice: by
3697    /// `verify_bundle_objects` (content-address mismatch) and by the
3698    /// bundle reader's per-blob CRC on read.
3699    #[test]
3700    fn corrupt_bundle_blob_is_detected() {
3701        let dir = tempfile::tempdir().unwrap();
3702        let src = dir.path().join("dataset");
3703        build_bundle_fixture(&src);
3704        let bundle = dir.path().join("dataset.sttb");
3705        write_bundle(src.join("manifest.json"), &bundle).unwrap();
3706        assert!(verify_bundle_objects(&bundle).unwrap().is_empty());
3707
3708        // Flip the first byte of the first pack's window.
3709        let bytes = fs::read(&bundle).unwrap();
3710        let (header, _) = parse_bundle_header(&bytes).unwrap();
3711        let pack0 = header
3712            .objects
3713            .iter()
3714            .find(|o| o.key.starts_with("packs/"))
3715            .expect("a pack object");
3716        let mut corrupt = bytes.clone();
3717        corrupt[pack0.offset as usize] ^= 0xff;
3718        fs::write(&bundle, &corrupt).unwrap();
3719
3720        let issues = verify_bundle_objects(&bundle).unwrap();
3721        assert!(
3722            issues.iter().any(|s| s.contains("content-address mismatch")),
3723            "expected a content-address mismatch, got {issues:?}"
3724        );
3725        let reader = PackedReader::open_bundle(&bundle).unwrap();
3726        let bad = reader
3727            .entries()
3728            .iter()
3729            .find(|e| reader.read_payload(e).is_err());
3730        assert!(bad.is_some(), "some tile in the corrupted pack must fail its CRC");
3731    }
3732
3733    /// Truncated / doctored bundles error loudly — never panic, never
3734    /// silently succeed. Mirrors the adversarial-decode style.
3735    #[test]
3736    fn truncated_and_doctored_bundles_error_loudly() {
3737        let dir = tempfile::tempdir().unwrap();
3738        let src = dir.path().join("dataset");
3739        build_bundle_fixture(&src);
3740        let bundle = dir.path().join("dataset.sttb");
3741        write_bundle(src.join("manifest.json"), &bundle).unwrap();
3742        let good = fs::read(&bundle).unwrap();
3743
3744        let open_err = |bytes: &[u8]| {
3745            let p = dir.path().join("bad.sttb");
3746            fs::write(&p, bytes).unwrap();
3747            assert!(PackedReader::open_bundle(&p).is_err(), "must reject {} bytes", bytes.len());
3748            assert!(unpack_bundle(&p, dir.path().join("bad-out")).is_err());
3749        };
3750
3751        // Shorter than the prelude.
3752        open_err(&good[..5]);
3753        // Bad magic.
3754        let mut bad = good.clone();
3755        bad[0] = b'X';
3756        open_err(&bad);
3757        // Unknown bundle version.
3758        let mut bad = good.clone();
3759        bad[4] = 9;
3760        open_err(&bad);
3761        // Nonzero reserved bytes.
3762        let mut bad = good.clone();
3763        bad[5] = 1;
3764        open_err(&bad);
3765        // header_len pointing past EOF.
3766        let mut bad = good.clone();
3767        bad[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
3768        open_err(&bad);
3769        // Truncated mid-pack: an object window now falls outside the file.
3770        open_err(&good[..good.len() - 32]);
3771        // Garbage header JSON.
3772        let mut bad = good.clone();
3773        let hl = u32::from_le_bytes(bad[8..12].try_into().unwrap()) as usize;
3774        bad[12..12 + hl].fill(b'!');
3775        open_err(&bad);
3776
3777        // A handcrafted header with a path-traversal key must be refused
3778        // before anything is written.
3779        let evil_header = r#"{"manifest":{"format":"stt-packed"},"objects":[{"key":"../evil","offset":128,"length":0}]}"#;
3780        let mut evil = Vec::new();
3781        evil.extend_from_slice(&BUNDLE_MAGIC);
3782        evil.extend_from_slice(&[BUNDLE_VERSION, 0, 0, 0]);
3783        evil.extend_from_slice(&(evil_header.len() as u32).to_le_bytes());
3784        evil.extend_from_slice(evil_header.as_bytes());
3785        evil.resize(256, 0);
3786        let p = dir.path().join("evil.sttb");
3787        fs::write(&p, &evil).unwrap();
3788        let out = dir.path().join("evil-out");
3789        let err = unpack_bundle(&p, &out).unwrap_err();
3790        assert!(err.to_string().contains("unsafe bundle object key"), "got: {err}");
3791        assert!(!dir.path().join("evil-out").exists(), "nothing may be written");
3792        assert!(!dir.path().join("evil").exists(), "traversal target must not exist");
3793    }
3794
3795    // ---- formatVersion 2 (template-referencing frames + object magic) ------
3796
3797    /// A quantized point layer with numeric + categorical properties — the
3798    /// shape whose per-tile `stt:qa`/`stt:time_offset_ms` variance v2 hoists
3799    /// into TILE_META. `seed` shifts values so every tile's affines differ.
3800    fn v2_point_layer(seed: u64, n: usize) -> ColumnarLayer {
3801        let base = 1_700_000_000_000i64 + seed as i64 * 60_000;
3802        ColumnarLayer {
3803            name: "default".to_string(),
3804            feature_ids: (0..n as u64).map(|i| seed * 100 + i).collect(),
3805            start_times: (0..n as i64).map(|i| base + i * 1000).collect(),
3806            end_times: (0..n as i64).map(|i| base + i * 1000 + 500).collect(),
3807            geometry: GeometryColumn::Point(
3808                (0..n).map(|i| [-122.4 + seed as f64 * 0.01, 37.7 + i as f64 * 1e-4]).collect(),
3809            ),
3810            vertex_times: None,
3811            vertex_values: None,
3812            triangles: None,
3813            vertex_value_matrix: None,
3814            properties: vec![
3815                (
3816                    "speed".to_string(),
3817                    PropertyColumn::Numeric(
3818                        (0..n).map(|i| Some(seed as f64 * 3.0 + i as f64)).collect(),
3819                    ),
3820                ),
3821                (
3822                    "kind".to_string(),
3823                    PropertyColumn::Categorical(
3824                        (0..n).map(|i| Some(["car", "bus"][i % 2].to_string())).collect(),
3825                    ),
3826                ),
3827            ],
3828        }
3829    }
3830
3831    /// The layer set the v2 fixture builds: quantized point tiles with
3832    /// per-tile-varying affines plus an EMPTY tile (0 rows, dictionary
3833    /// column intact).
3834    fn v2_fixture_layers() -> Vec<ColumnarLayer> {
3835        let mut layers: Vec<ColumnarLayer> = (0..5).map(|k| v2_point_layer(k, 4)).collect();
3836        layers.push(v2_point_layer(99, 0)); // empty-bucket tile
3837        layers
3838    }
3839
3840    fn v2_encoder_cfg(w: &PackWriter) -> EncoderConfig {
3841        EncoderConfig {
3842            quantize_coords_m: Some(1.0),
3843            quantize_attrs_auto: true,
3844            format_version: FORMAT_VERSION_V2,
3845            template_collector: Some(w.template_collector()),
3846            ..EncoderConfig::default()
3847        }
3848    }
3849
3850    /// Build a small v2 dataset (encoder wired to the writer's template
3851    /// collector) into `out`, returning the manifest and the input layers in
3852    /// tile order.
3853    fn build_v2_dataset(out: &Path, paging: Option<usize>) -> (Manifest, Vec<ColumnarLayer>) {
3854        let mut w = PackWriter::create(out, BlobOrdering::Auto, 8 * 1024)
3855            .unwrap()
3856            .with_paging(paging)
3857            .with_capabilities(vec![
3858                CAPABILITY_COORD_QUANT.to_string(),
3859                CAPABILITY_ATTR_QUANT.to_string(),
3860            ]);
3861        let cfg = v2_encoder_cfg(&w);
3862        let layers = v2_fixture_layers();
3863        for (k, layer) in layers.iter().enumerate() {
3864            let payload = encode_tile_with(std::slice::from_ref(layer), &cfg).unwrap();
3865            w.add_tile_full(
3866                &TileId::new(10, k as u32, 0, 0),
3867                0,
3868                100,
3869                None,
3870                layer.feature_count() as u32,
3871                None,
3872                &payload,
3873            )
3874            .unwrap();
3875        }
3876        let manifest = w.finalize(&Metadata::new("v2-fixture")).unwrap();
3877        (manifest, layers)
3878    }
3879
3880    /// End-to-end v2: magic on every object, schemas embedded (sorted,
3881    /// hash-valid), verify clean, and every tile decodes through the registry
3882    /// to EXACTLY what the v1 frame of the same layer decodes to (metadata
3883    /// re-injection contract).
3884    #[test]
3885    fn v2_dataset_roundtrips_with_templates_magic_and_v1_equivalence() {
3886        let dir = tempfile::tempdir().unwrap();
3887        let out = dir.path().join("dataset");
3888        let (manifest, layers) = build_v2_dataset(&out, None);
3889
3890        assert_eq!(manifest.format_version, PACKED_FORMAT_VERSION_V2);
3891        // Templates collected: one CORE + one PROPS schema for the shared
3892        // layer shape (constancy: five differing tiles, ONE template pair).
3893        assert_eq!(manifest.schemas.len(), 2, "expected core+props templates");
3894        let hashes: Vec<&str> = manifest.schemas.iter().map(|s| s.hash.as_str()).collect();
3895        assert!(hashes.windows(2).all(|w| w[0] < w[1]), "schemas sorted by hash");
3896
3897        // Object magic on every object; content addresses cover it.
3898        let dir_bytes = fs::read(out.join(&manifest.directory.key)).unwrap();
3899        assert_eq!(&dir_bytes[0..8], &object_magic(DIRECTORY_MAGIC));
3900        assert_eq!(
3901            manifest.directory.key,
3902            format!("index/{}.sttd", blake3_128_hex(&dir_bytes))
3903        );
3904        for p in &manifest.packs {
3905            let bytes = fs::read(out.join(&p.key)).unwrap();
3906            assert_eq!(&bytes[0..8], &object_magic(PACK_MAGIC));
3907            assert_eq!(p.key, format!("packs/{}.sttp", blake3_128_hex(&bytes)));
3908        }
3909
3910        // Blob offsets are object-absolute: nothing points into the magic.
3911        let reader = PackedReader::open(out.join("manifest.json")).unwrap();
3912        assert_eq!(reader.format_version(), PACKED_FORMAT_VERSION_V2);
3913        assert!(reader.templates().is_some());
3914        for e in reader.entries() {
3915            assert!(e.offset >= OBJECT_MAGIC_LEN as u64, "offset {} inside magic", e.offset);
3916        }
3917
3918        assert!(verify_packed_objects(out.join("manifest.json")).unwrap().is_empty());
3919
3920        // Decode parity: each tile equals the v1 decode of the same layer.
3921        for (k, layer) in layers.iter().enumerate() {
3922            let e = reader
3923                .entries()
3924                .iter()
3925                .find(|e| e.x == k as u32)
3926                .expect("entry present");
3927            let got = reader.read_layers(e).unwrap();
3928            let v1 = crate::arrow_tile::decode_tile(
3929                &encode_tile_with(
3930                    std::slice::from_ref(layer),
3931                    &EncoderConfig {
3932                        quantize_coords_m: Some(1.0),
3933                        quantize_attrs_auto: true,
3934                        ..EncoderConfig::default()
3935                    },
3936                )
3937                .unwrap(),
3938            )
3939            .unwrap();
3940            assert_eq!(got.len(), v1.len());
3941            assert_eq!(got[0].name, v1[0].name);
3942            assert_eq!(
3943                got[0].batch, v1[0].batch,
3944                "tile {k}: v2 decode must equal the v1 decode after re-injection"
3945            );
3946        }
3947    }
3948
3949    /// ★F6: the manifest's formatVersion is authoritative — a frame of the
3950    /// OTHER version inside a dataset is a hard error in both directions.
3951    #[test]
3952    fn mixed_frame_and_manifest_versions_hard_error() {
3953        let dir = tempfile::tempdir().unwrap();
3954
3955        // v2 dataset refuses a v1 frame…
3956        let v2_out = dir.path().join("v2");
3957        build_v2_dataset(&v2_out, None);
3958        let v2_reader = PackedReader::open(v2_out.join("manifest.json")).unwrap();
3959        let v1_payload = encode_tile(&[point_layer("default", vec![1, 2], 0)]).unwrap();
3960        let err = v2_reader.decode_payload(&v1_payload).unwrap_err();
3961        assert!(err.to_string().contains("v1 layer frame"), "got: {err}");
3962
3963        // …and a v1 dataset refuses a v2 frame.
3964        let v1_out = dir.path().join("v1");
3965        let mut w = PackWriter::create(&v1_out, BlobOrdering::Auto, 8 * 1024)
3966            .unwrap()
3967            .with_format_version(PACKED_FORMAT_VERSION_V1);
3968        w.add_tile_full(&TileId::new(10, 0, 0, 0), 0, 100, None, 6, None, &distinct_tile(1))
3969            .unwrap();
3970        w.finalize(&Metadata::new("v1")).unwrap();
3971        let v1_reader = PackedReader::open(v1_out.join("manifest.json")).unwrap();
3972        let v2_payload = encode_tile_with(
3973            &[v2_point_layer(0, 3)],
3974            &EncoderConfig {
3975                format_version: FORMAT_VERSION_V2,
3976                ..EncoderConfig::default()
3977            },
3978        )
3979        .unwrap();
3980        let err = v1_reader.decode_payload(&v2_payload).unwrap_err();
3981        assert!(err.to_string().contains("v2 layer frame"), "got: {err}");
3982    }
3983
3984    /// A doctored `schemas` entry (bytes not matching the declared hash) is a
3985    /// loud, dataset-level failure at open — before any tile fetch.
3986    #[test]
3987    fn corrupt_manifest_schema_template_fails_open() {
3988        use base64::Engine as _;
3989        let dir = tempfile::tempdir().unwrap();
3990        let out = dir.path().join("dataset");
3991        build_v2_dataset(&out, None);
3992        let manifest_path = out.join("manifest.json");
3993
3994        let mut v: serde_json::Value =
3995            serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
3996        let tampered = base64::engine::general_purpose::STANDARD.encode(b"not a template");
3997        v["schemas"][0]["data"] = serde_json::json!(tampered);
3998        fs::write(&manifest_path, serde_json::to_vec(&v).unwrap()).unwrap();
3999
4000        let err = match PackedReader::open(&manifest_path) {
4001            Ok(_) => panic!("corrupt template must fail the open"),
4002            Err(e) => e,
4003        };
4004        assert!(err.to_string().contains("hash"), "got: {err}");
4005        let issues = verify_packed_objects(&manifest_path).unwrap();
4006        assert!(
4007            issues.iter().any(|i| i.contains("hash")),
4008            "verify must report the schema corruption: {issues:?}"
4009        );
4010    }
4011
4012    /// Corrupting a v2 object's magic prelude is caught at open/read AND by
4013    /// the verifier (which also still reports the content-address mismatch).
4014    #[test]
4015    fn corrupt_v2_object_magic_is_detected() {
4016        let dir = tempfile::tempdir().unwrap();
4017        let out = dir.path().join("dataset");
4018        let (manifest, _) = build_v2_dataset(&out, None);
4019        let manifest_path = out.join("manifest.json");
4020
4021        // Pack magic: flipping byte 0 breaks STTP → read path + verifier.
4022        let pack0 = out.join(&manifest.packs[0].key);
4023        let mut bytes = fs::read(&pack0).unwrap();
4024        bytes[0] ^= 0xff;
4025        fs::write(&pack0, &bytes).unwrap();
4026        let reader = PackedReader::open(&manifest_path).unwrap();
4027        let entry = reader.entries().iter().find(|e| e.pack_id == 0).unwrap();
4028        let err = reader.read_payload(entry).unwrap_err();
4029        assert!(err.to_string().contains("STTP"), "got: {err}");
4030        let issues = verify_packed_objects(&manifest_path).unwrap();
4031        assert!(issues.iter().any(|i| i.contains("STTP")), "{issues:?}");
4032        assert!(
4033            issues.iter().any(|i| i.contains("content-address mismatch")),
4034            "{issues:?}"
4035        );
4036        fs::write(&pack0, {
4037            let mut b = fs::read(&pack0).unwrap();
4038            b[0] ^= 0xff;
4039            b
4040        })
4041        .unwrap();
4042
4043        // Directory magic: same treatment, caught at open.
4044        let dir_path = out.join(&manifest.directory.key);
4045        let mut bytes = fs::read(&dir_path).unwrap();
4046        bytes[0] ^= 0xff;
4047        fs::write(&dir_path, &bytes).unwrap();
4048        let err = match PackedReader::open(&manifest_path) {
4049            Ok(_) => panic!("corrupt directory magic must fail the open"),
4050            Err(e) => e,
4051        };
4052        assert!(err.to_string().contains("STTD"), "got: {err}");
4053    }
4054
4055    /// The paged directory container works identically under v2 (magic
4056    /// stripped before the root/leaf math; `rootLength` still means the root
4057    /// frame's at-rest length).
4058    #[test]
4059    fn v2_paged_directory_roundtrips() {
4060        let dir = tempfile::tempdir().unwrap();
4061        let out = dir.path().join("dataset");
4062        let (manifest, layers) = build_v2_dataset(&out, Some(2));
4063        assert_eq!(manifest.directory.layout.as_deref(), Some(DIRECTORY_LAYOUT_PAGED));
4064        assert!(verify_packed_objects(out.join("manifest.json")).unwrap().is_empty());
4065        let reader = PackedReader::open(out.join("manifest.json")).unwrap();
4066        assert_eq!(reader.entries().len(), layers.len());
4067        for e in reader.entries() {
4068            assert!(reader.read_layers(e).is_ok());
4069        }
4070    }
4071
4072    /// v2 bundles: pack → verify → open → unpack → re-verify, all through
4073    /// the embedded v2 manifest (design §6's open item).
4074    #[test]
4075    fn v2_bundle_roundtrips_and_verifies() {
4076        let dir = tempfile::tempdir().unwrap();
4077        let src = dir.path().join("dataset");
4078        let (_, layers) = build_v2_dataset(&src, None);
4079
4080        let bundle = dir.path().join("dataset.sttb");
4081        write_bundle(src.join("manifest.json"), &bundle).unwrap();
4082        assert!(verify_bundle_objects(&bundle).unwrap().is_empty());
4083
4084        let exploded = PackedReader::open(src.join("manifest.json")).unwrap();
4085        let bundled = PackedReader::open_bundle(&bundle).unwrap();
4086        assert_eq!(bundled.format_version(), PACKED_FORMAT_VERSION_V2);
4087        assert_eq!(exploded.entries(), bundled.entries());
4088        for (e_dir, e_bun) in exploded.entries().iter().zip(bundled.entries()) {
4089            let a = exploded.read_layers(e_dir).unwrap();
4090            let b = bundled.read_layers(e_bun).unwrap();
4091            assert_eq!(a.len(), b.len());
4092            for (x, y) in a.iter().zip(&b) {
4093                assert_eq!(x.batch, y.batch);
4094            }
4095        }
4096        assert_eq!(bundled.entries().len(), layers.len());
4097
4098        let unpacked = dir.path().join("unpacked");
4099        unpack_bundle(&bundle, &unpacked).unwrap();
4100        assert!(
4101            verify_packed_objects(unpacked.join("manifest.json")).unwrap().is_empty(),
4102            "unpacked v2 dataset must re-verify"
4103        );
4104    }
4105
4106    /// The verbatim-repack path (pack-cover / repair tools): payloads copied
4107    /// from a v2 source MUST carry the source's formatVersion forward and
4108    /// seed the new writer's template collector from the source registry —
4109    /// the repacked dataset round-trips readable, tile for tile.
4110    #[test]
4111    fn v2_verbatim_repack_roundtrips_readable() {
4112        let dir = tempfile::tempdir().unwrap();
4113        let src = dir.path().join("src");
4114        let (src_manifest, layers) = build_v2_dataset(&src, None);
4115        let reader = PackedReader::open(src.join("manifest.json")).unwrap();
4116
4117        let out = dir.path().join("repacked");
4118        let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024)
4119            .unwrap()
4120            .with_format_version(reader.format_version())
4121            .with_capabilities(reader.capabilities().to_vec())
4122            .with_seeded_templates(reader.templates().expect("v2 source has a registry"));
4123        for e in reader.entries() {
4124            let payload = reader.read_payload(e).unwrap();
4125            w.add_tile_full(
4126                &TileId::new(e.zoom, e.x, e.y, e.time_start.max(0) as u64),
4127                e.time_start,
4128                e.time_end,
4129                e.cover_t_min,
4130                e.feature_count,
4131                e.temporal_bucket_ms,
4132                &payload,
4133            )
4134            .unwrap();
4135        }
4136        let manifest = w.finalize(reader.metadata()).unwrap();
4137        assert_eq!(manifest.format_version, PACKED_FORMAT_VERSION_V2);
4138        // The seeded templates published (sorted + deduped by construction).
4139        assert_eq!(
4140            manifest.schemas.iter().map(|s| &s.hash).collect::<Vec<_>>(),
4141            src_manifest.schemas.iter().map(|s| &s.hash).collect::<Vec<_>>(),
4142            "seeded schemas must match the source's table"
4143        );
4144
4145        assert!(verify_packed_objects(out.join("manifest.json")).unwrap().is_empty());
4146        let re = PackedReader::open(out.join("manifest.json")).unwrap();
4147        assert_eq!(re.entries().len(), layers.len());
4148        for e in re.entries() {
4149            re.read_layers(e)
4150                .unwrap_or_else(|err| panic!("repacked tile {:?} must decode: {err}", e.tile_id()));
4151        }
4152    }
4153
4154    /// F4: a v2 dataset whose frames reference a template MISSING from
4155    /// `manifest.schemas` is undecodable — both validators must flag the
4156    /// absent hash instead of verifying clean (every object still hashes to
4157    /// its content address and the remaining schemas table is valid).
4158    #[test]
4159    fn verify_flags_missing_frame_referenced_schema_template() {
4160        let dir = tempfile::tempdir().unwrap();
4161        let out = dir.path().join("dataset");
4162        let (manifest, _) = build_v2_dataset(&out, None);
4163        assert_eq!(manifest.schemas.len(), 2, "core + props templates");
4164        let manifest_path = out.join("manifest.json");
4165        assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
4166
4167        // Remove ONE schemas entry: the manifest still parses, the remaining
4168        // table is hash-valid + sorted, every object address still matches —
4169        // pre-fix this dataset "verified clean" while no tile could decode.
4170        let mut v: serde_json::Value =
4171            serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
4172        let removed = v["schemas"].as_array_mut().unwrap().remove(0);
4173        let removed_hash = removed["hash"].as_str().unwrap().to_string();
4174        fs::write(&manifest_path, serde_json::to_vec(&v).unwrap()).unwrap();
4175
4176        let issues = verify_packed_objects(&manifest_path).unwrap();
4177        assert!(
4178            issues
4179                .iter()
4180                .any(|i| i.contains(&removed_hash) && i.contains("manifest.schemas")),
4181            "expected the missing template hash {removed_hash} to be reported: {issues:?}"
4182        );
4183
4184        // The bundle validator shares the check (the doctored manifest rides
4185        // the bundle header verbatim).
4186        let bundle = dir.path().join("dataset.sttb");
4187        write_bundle(&manifest_path, &bundle).unwrap();
4188        let issues = verify_bundle_objects(&bundle).unwrap();
4189        assert!(
4190            issues.iter().any(|i| i.contains(&removed_hash)),
4191            "bundle verify must report the missing template: {issues:?}"
4192        );
4193    }
4194}