1use 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
51pub const PACKED_FORMAT: &str = "stt-packed";
53
54pub const PACKED_FORMAT_VERSION_V1: u32 = 1;
58pub const PACKED_FORMAT_VERSION_V2: u32 = 2;
63pub const PACKED_FORMAT_VERSION: u32 = PACKED_FORMAT_VERSION_V2;
67pub const SUPPORTED_PACKED_FORMAT_VERSIONS: [u32; 2] =
69 [PACKED_FORMAT_VERSION_V1, PACKED_FORMAT_VERSION_V2];
70
71pub const PACK_MAGIC: [u8; 4] = *b"STTP";
77pub const DIRECTORY_MAGIC: [u8; 4] = *b"STTD";
79pub const OBJECT_MAGIC_LEN: usize = 8;
85const OBJECT_MAGIC_VERSION: u8 = 2;
87
88fn 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
93fn 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
117pub 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
131pub const CAPABILITY_COORD_QUANT: &str = "coord-quant";
134pub const CAPABILITY_ATTR_QUANT: &str = "attr-quant";
137pub const CAPABILITY_ELEVATION_FOLD: &str = "elevation-fold";
140pub const KNOWN_CAPABILITIES: &[&str] = &[
147 CAPABILITY_COORD_QUANT,
148 CAPABILITY_ATTR_QUANT,
149 CAPABILITY_ELEVATION_FOLD,
150];
151
152pub const DEFAULT_PACK_TARGET_BYTES: u64 = 64 * 1024 * 1024;
155
156fn crc32c_tag(bytes: &[u8]) -> u32 {
158 crc32c::crc32c(bytes)
159}
160
161fn blake3_128_hex(bytes: &[u8]) -> String {
163 let hash = blake3::hash(bytes);
164 hash.as_bytes()[..16]
166 .iter()
167 .map(|b| format!("{b:02x}"))
168 .collect()
169}
170
171pub const DIRECTORY_ENCODING_ZSTD: &str = "zstd";
177
178pub const DIRECTORY_LAYOUT_PAGED: &str = "paged";
181pub const DIRECTORY_LAYOUT_SINGLE: &str = "single";
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct DirectoryRef {
187 pub key: String,
189 pub length: u64,
192 #[serde(rename = "directoryVersion")]
196 pub directory_version: u8,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub encoding: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub layout: Option<String>,
211 #[serde(default, rename = "rootLength", skip_serializing_if = "Option::is_none")]
217 pub root_length: Option<u64>,
218 #[serde(default, rename = "pageCount", skip_serializing_if = "Option::is_none")]
220 pub page_count: Option<u64>,
221 #[serde(default, rename = "pageEntries", skip_serializing_if = "Option::is_none")]
223 pub page_entries: Option<u64>,
224}
225
226impl DirectoryRef {
227 pub fn is_paged(&self) -> bool {
229 self.layout.as_deref() == Some(DIRECTORY_LAYOUT_PAGED)
230 }
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct PackRef {
237 pub key: String,
239 pub length: u64,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct SchemaTemplateRef {
249 pub hash: String,
251 pub data: String,
253}
254
255fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct Manifest {
290 pub format: String,
292 #[serde(rename = "formatVersion")]
294 pub format_version: u32,
295 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub capabilities: Vec<String>,
303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
312 pub schemas: Vec<SchemaTemplateRef>,
313 pub compression: String,
315 #[serde(default, rename = "blobOrdering", skip_serializing_if = "Option::is_none")]
322 pub blob_ordering: Option<String>,
323 pub directory: DirectoryRef,
325 pub packs: Vec<PackRef>,
327 pub metadata: Metadata,
329}
330
331impl Manifest {
332 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 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
345struct 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
363enum PendingPayload {
370 Mem(Vec<u8>),
371 Spilled { offset: u64, len: u64 },
376}
377
378impl PendingPayload {
379 fn len(&self) -> usize {
381 match self {
382 PendingPayload::Mem(v) => v.len(),
383 PendingPayload::Spilled { len, .. } => *len as usize,
384 }
385 }
386}
387
388fn 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
405struct SpillFile {
411 file: File,
412 path: PathBuf,
413 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 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 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
463pub struct PackWriter {
474 out_dir: PathBuf,
475 ordering: BlobOrdering,
476 pack_target_bytes: u64,
477 pending: Vec<PendingTile>,
478 page_entries: Option<usize>,
482 zstd_level: i32,
487 capabilities: Vec<String>,
491 measured_ordering: bool,
497 format_version: u32,
504 templates: Arc<TemplateCollector>,
510 memory_budget: u64,
518 pending_payload_bytes: u64,
520 spill: Option<SpillFile>,
522}
523
524impl PackWriter {
525 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 pub fn with_memory_budget(mut self, bytes: u64) -> Self {
565 self.memory_budget = bytes;
566 self
567 }
568
569 pub fn memory_budget(&self) -> u64 {
574 self.memory_budget
575 }
576
577 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 pub fn with_format_version(mut self, format_version: u32) -> Self {
600 self.format_version = format_version;
601 self
602 }
603
604 pub fn format_version(&self) -> u32 {
608 self.format_version
609 }
610
611 pub fn template_collector(&self) -> Arc<TemplateCollector> {
617 Arc::clone(&self.templates)
618 }
619
620 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 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 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 pub fn with_measured_ordering(mut self, on: bool) -> Self {
662 self.measured_ordering = on;
663 self
664 }
665
666 #[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 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 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 pub fn tile_count(&self) -> usize {
732 self.pending.len()
733 }
734
735 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 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 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 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 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 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 struct Blob {
878 length: u32,
880 uncompressed_size: u32,
881 crc: u32,
882 }
883 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 let mut blob_dedup: HashMap<[u8; 32], usize> = HashMap::new();
894 let mut tile_blob: Vec<usize> = Vec::with_capacity(pending.len());
896 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 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 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 drop(spill);
991
992 let pack_refs: Vec<PackRef> = stream.finish()?;
995
996 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 entries.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
1022
1023 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 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 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 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 let manifest = Manifest {
1107 format: PACKED_FORMAT.to_string(),
1108 format_version,
1109 capabilities,
1110 schemas,
1111 compression: "zstd".to_string(),
1112 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
1137fn 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
1150fn 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
1169fn write_atomic(dir: &Path, final_path: &Path, bytes: &[u8]) -> Result<()> {
1173 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
1192struct PackStreamWriter {
1205 out_dir: PathBuf,
1206 packs_dir: PathBuf,
1207 format_version: u32,
1208 pack_target_bytes: u64,
1209 data_start: u64,
1212 current: Option<OpenPack>,
1213 next_pack_id: u32,
1214 refs: Vec<PackRef>,
1215}
1216
1217struct 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 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 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 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 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 fn finish(mut self) -> Result<Vec<PackRef>> {
1335 self.seal()?;
1336 Ok(std::mem::take(&mut self.refs))
1337 }
1338}
1339
1340pub 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 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 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 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 Err(_) => {}
1477 }
1478
1479 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
1503fn 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
1522fn 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
1534fn 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 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 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
1633struct LoadedPack {
1639 mmap: Option<Mmap>,
1641 path: PathBuf,
1643 length: u64,
1645}
1646
1647pub struct PackedReader {
1656 entries: Vec<TileEntry>,
1657 metadata: Metadata,
1658 compression: Compression,
1659 packs: Vec<std::cell::RefCell<LoadedPack>>,
1660 capabilities: Vec<String>,
1661 format_version: u32,
1666 templates: Option<TemplateRegistry>,
1669 bundle: Option<BundleBacking>,
1675}
1676
1677struct BundleBacking {
1680 mmap: Mmap,
1681 windows: Vec<(u64, u64)>,
1684}
1685
1686fn 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 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 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 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 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 let templates = (manifest.format_version == PACKED_FORMAT_VERSION_V2)
1758 .then(|| build_template_registry(&manifest.schemas))
1759 .transpose()?;
1760
1761 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 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 pub fn open_bundle<P: AsRef<Path>>(path: P) -> Result<Self> {
1803 let file = File::open(path.as_ref())?;
1804 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 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 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 pub fn entries(&self) -> &[TileEntry] {
1886 &self.entries
1887 }
1888
1889 pub fn metadata(&self) -> &Metadata {
1891 &self.metadata
1892 }
1893
1894 pub fn capabilities(&self) -> &[String] {
1899 &self.capabilities
1900 }
1901
1902 pub fn read_payload(&self, entry: &TileEntry) -> Result<Vec<u8>> {
1908 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 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 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 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 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 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 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 pub fn format_version(&self) -> u32 {
2060 self.format_version
2061 }
2062
2063 pub fn templates(&self) -> Option<&TemplateRegistry> {
2068 self.templates.as_ref()
2069 }
2070}
2071
2072pub const BUNDLE_MAGIC: [u8; 4] = *b"STTB";
2094pub const BUNDLE_VERSION: u8 = 1;
2096const BUNDLE_PRELUDE_LEN: usize = 12;
2099
2100#[derive(Debug, Clone, Serialize, Deserialize)]
2106pub struct BundleObject {
2107 pub key: String,
2109 pub offset: u64,
2111 pub length: u64,
2113}
2114
2115#[derive(Serialize, Deserialize)]
2118struct BundleHeader {
2119 manifest: Box<serde_json::value::RawValue>,
2120 objects: Vec<BundleObject>,
2121}
2122
2123#[derive(Debug, Clone, Copy)]
2125pub struct BundleSummary {
2126 pub objects: usize,
2129 pub bytes: u64,
2132}
2133
2134fn align8_u64(n: u64) -> u64 {
2137 n.div_ceil(8) * 8
2138}
2139
2140fn 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
2158fn 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
2168struct ManifestObjectKey {
2170 key: String,
2171 declared_length: Option<u64>,
2173}
2174
2175fn 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 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
2237fn 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
2278pub 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 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 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 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 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
2431pub 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 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 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 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 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
2508pub fn read_bundle_manifest<P: AsRef<Path>>(bundle_path: P) -> Result<Manifest> {
2510 let file = File::open(bundle_path.as_ref())?;
2511 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
2518pub fn verify_bundle_objects<P: AsRef<Path>>(bundle_path: P) -> Result<Vec<String>> {
2529 let file = File::open(bundle_path.as_ref())?;
2530 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 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 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 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 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 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 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 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 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 assert!(!s.contains("\"capabilities\""), "{s}");
2780 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 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 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 #[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 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; let t = b * bucket;
2828 let payload = if k == 13 || k == 27 {
2829 static_payload.clone() } 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 assert!(manifest.packs.len() > 1, "expected multiple packs, got {}", manifest.packs.len());
2845
2846 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 }
2855
2856 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 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 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 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 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 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 #[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 assert_eq!(manifest.blob_ordering.as_deref(), Some("spatial"));
2941 }
2942
2943 #[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 #[test]
2982 fn paged_directory_writer_roundtrips_and_matches_single() {
2983 let bucket = 3_600_000i64;
2984 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 let paged = build(&paged_out, Some(16));
3010
3011 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 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 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 let issues = verify_packed_objects(paged_out.join("manifest.json")).unwrap();
3048 assert!(issues.is_empty(), "paged verify issues: {issues:?}");
3049 }
3050
3051 #[test]
3053 fn oversized_blob_gets_its_own_pack() {
3054 let dir = tempfile::tempdir().unwrap();
3055 let out = dir.path().join("dataset");
3056
3057 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 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 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 #[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 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 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 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 #[test]
3136 fn rebuilds_are_byte_reproducible() {
3137 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 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 #[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 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 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 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 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 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 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 #[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 #[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 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 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 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 #[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 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 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 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 #[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 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 #[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 #[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 .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 assert!(PackedReader::open(&manifest_path).is_ok());
3489 }
3490
3491 #[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 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 assert!(
3520 msg.contains("does not implement: from-the-future ("),
3521 "unexpected error: {msg}"
3522 );
3523 }
3524
3525 #[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 assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
3544
3545 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 fn build_bundle_fixture(out: &Path) -> Manifest {
3564 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() } 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 #[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 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 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 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 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 assert!(verify_bundle_objects(&bundle_a).unwrap().is_empty());
3653 }
3654
3655 #[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 #[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 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 #[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 open_err(&good[..5]);
3753 let mut bad = good.clone();
3755 bad[0] = b'X';
3756 open_err(&bad);
3757 let mut bad = good.clone();
3759 bad[4] = 9;
3760 open_err(&bad);
3761 let mut bad = good.clone();
3763 bad[5] = 1;
3764 open_err(&bad);
3765 let mut bad = good.clone();
3767 bad[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
3768 open_err(&bad);
3769 open_err(&good[..good.len() - 32]);
3771 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 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 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 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)); 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 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 #[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 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 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 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 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 #[test]
3952 fn mixed_frame_and_manifest_versions_hard_error() {
3953 let dir = tempfile::tempdir().unwrap();
3954
3955 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 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 #[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 #[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 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 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 #[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 #[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 #[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 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 #[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 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 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}