1use crate::archive::{ArchiveReader, TileEntry};
35use crate::compression;
36use crate::curve::BlobOrdering;
37use crate::error::{Error, Result};
38use crate::metadata::Metadata;
39use crate::tile::TileId;
40use crate::types::Compression;
41use memmap2::Mmap;
42use rayon::prelude::*;
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45use std::fs::{self, File, OpenOptions};
46use std::io::Write;
47use std::path::{Path, PathBuf};
48
49pub const PACKED_FORMAT: &str = "stt-packed";
51
52pub const PACKED_FORMAT_VERSION: u32 = 1;
55
56pub const DEFAULT_PACK_TARGET_BYTES: u64 = 64 * 1024 * 1024;
59
60fn crc32c_tag(bytes: &[u8]) -> u32 {
62 crc32c::crc32c(bytes)
63}
64
65fn blake3_128_hex(bytes: &[u8]) -> String {
67 let hash = blake3::hash(bytes);
68 hash.as_bytes()[..16]
70 .iter()
71 .map(|b| format!("{b:02x}"))
72 .collect()
73}
74
75pub const DIRECTORY_ENCODING_ZSTD: &str = "zstd";
81
82pub const DIRECTORY_LAYOUT_PAGED: &str = "paged";
85pub const DIRECTORY_LAYOUT_SINGLE: &str = "single";
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct DirectoryRef {
91 pub key: String,
93 pub length: u64,
96 #[serde(rename = "directoryVersion")]
100 pub directory_version: u8,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub encoding: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub layout: Option<String>,
115 #[serde(default, rename = "rootLength", skip_serializing_if = "Option::is_none")]
119 pub root_length: Option<u64>,
120 #[serde(default, rename = "pageCount", skip_serializing_if = "Option::is_none")]
122 pub page_count: Option<u64>,
123 #[serde(default, rename = "pageEntries", skip_serializing_if = "Option::is_none")]
125 pub page_entries: Option<u64>,
126}
127
128impl DirectoryRef {
129 pub fn is_paged(&self) -> bool {
131 self.layout.as_deref() == Some(DIRECTORY_LAYOUT_PAGED)
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct PackRef {
139 pub key: String,
141 pub length: u64,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Manifest {
152 pub format: String,
154 #[serde(rename = "formatVersion")]
156 pub format_version: u32,
157 pub compression: String,
159 pub directory: DirectoryRef,
161 pub packs: Vec<PackRef>,
163 pub metadata: Metadata,
165}
166
167impl Manifest {
168 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
170 serde_json::from_slice(bytes)
171 .map_err(|e| Error::InvalidArchive(format!("manifest JSON decode failed: {e}")))
172 }
173
174 pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
176 serde_json::to_vec_pretty(self)
177 .map_err(|e| Error::Other(format!("manifest JSON encode failed: {e}")))
178 }
179}
180
181struct PendingTile {
187 z: u8,
188 x: u32,
189 y: u32,
190 hilbert: u64,
191 time_start: i64,
192 time_end: i64,
193 cover_t_min: Option<i64>,
194 feature_count: u32,
195 temporal_bucket_ms: Option<u64>,
196 payload: Vec<u8>,
197}
198
199pub struct PackWriter {
210 out_dir: PathBuf,
211 ordering: BlobOrdering,
212 pack_target_bytes: u64,
213 pending: Vec<PendingTile>,
214 page_entries: Option<usize>,
218 zstd_level: i32,
223}
224
225impl PackWriter {
226 pub fn create<P: AsRef<Path>>(
234 out_dir: P,
235 ordering: BlobOrdering,
236 pack_target_bytes: u64,
237 ) -> Result<Self> {
238 Ok(Self {
239 out_dir: out_dir.as_ref().to_path_buf(),
240 ordering,
241 pack_target_bytes: pack_target_bytes.max(1),
242 pending: Vec::new(),
243 page_entries: None,
244 zstd_level: compression::ZSTD_LEVEL,
245 })
246 }
247
248 pub fn with_paging(mut self, page_entries: Option<usize>) -> Self {
254 self.page_entries = page_entries.map(|k| k.max(1));
255 self
256 }
257
258 pub fn with_zstd_level(mut self, level: i32) -> Self {
266 self.zstd_level = level.clamp(1, compression::ZSTD_LEVEL_MAX);
267 self
268 }
269
270 #[allow(clippy::too_many_arguments)]
276 pub fn add_tile_full(
277 &mut self,
278 id: &TileId,
279 time_start: i64,
280 time_end: i64,
281 cover_t_min: Option<i64>,
282 feature_count: u32,
283 temporal_bucket_ms: Option<u64>,
284 payload: &[u8],
285 ) -> Result<()> {
286 self.pending.push(PendingTile {
287 z: id.z,
288 x: id.x,
289 y: id.y,
290 hilbert: id.hilbert_index(),
291 time_start,
292 time_end,
293 cover_t_min,
294 feature_count,
295 temporal_bucket_ms,
296 payload: payload.to_vec(),
297 });
298 Ok(())
299 }
300
301 pub fn tile_count(&self) -> usize {
303 self.pending.len()
304 }
305
306 pub fn finalize(self, metadata: &Metadata) -> Result<Manifest> {
315 let PackWriter {
316 out_dir,
317 ordering,
318 pack_target_bytes,
319 mut pending,
320 page_entries,
321 zstd_level,
322 } = self;
323
324 let base_bucket = metadata.temporal_bucket_ms.max(1) as i64;
326 let tb = |p: &PendingTile| {
327 let b = p
328 .temporal_bucket_ms
329 .map(|v| v as i64)
330 .unwrap_or(base_bucket)
331 .max(1);
332 p.time_start.div_euclid(b)
333 };
334 let (tb_min, tb_max) = pending.iter().fold((i64::MAX, i64::MIN), |(lo, hi), p| {
335 let t = tb(p);
336 (lo.min(t), hi.max(t))
337 });
338 let tb_span = if pending.is_empty() { 0 } else { tb_max - tb_min };
339 let ordering = match ordering {
341 BlobOrdering::Auto => {
342 let max_z = pending.iter().map(|p| p.z).max().unwrap_or(0) as u32;
343 let time_bits = crate::curve::bits_for((tb_span.max(0) + 1) as u64);
344 BlobOrdering::choose(max_z, time_bits)
345 }
346 other => other,
347 };
348 pending.sort_by_key(|p| {
356 (
357 crate::curve::space_time_key(
358 ordering,
359 p.z,
360 p.x,
361 p.y,
362 p.hilbert,
363 p.time_start,
364 tb(p),
365 tb_min,
366 tb_span,
367 ),
368 p.z,
369 p.x,
370 p.y,
371 p.time_start,
372 p.temporal_bucket_ms,
373 )
374 });
375
376 struct Blob {
383 compressed: Vec<u8>,
384 uncompressed_size: u32,
385 crc: u32,
386 }
387 let mut blobs: Vec<Blob> = Vec::new();
388 let mut blob_dedup: HashMap<[u8; 32], usize> = HashMap::new();
390 let mut tile_blob: Vec<usize> = Vec::with_capacity(pending.len());
392 const COMPRESS_CHUNK: usize = 8192;
400 for chunk in pending.chunks(COMPRESS_CHUNK) {
401 let compressed_chunk: Vec<Vec<u8>> = chunk
402 .par_iter()
403 .map(|p| compression::compress_zstd_with_dict_level(&p.payload, None, zstd_level))
404 .collect::<Result<Vec<_>>>()?;
405 for (p, compressed) in chunk.iter().zip(compressed_chunk) {
406 let key = *blake3::hash(&compressed).as_bytes();
407 let idx = if let Some(&i) = blob_dedup.get(&key) {
408 i
409 } else {
410 let i = blobs.len();
411 let crc = crc32c_tag(&compressed);
412 let uncompressed_size = p.payload.len() as u32;
413 blobs.push(Blob {
414 compressed,
415 uncompressed_size,
416 crc,
417 });
418 blob_dedup.insert(key, i);
419 i
420 };
421 tile_blob.push(idx);
422 }
423 }
424
425 struct Placement {
432 pack_id: u32,
433 offset: u64,
434 }
435 let mut placements: Vec<Placement> = Vec::with_capacity(blobs.len());
436 let mut packs_blob_ranges: Vec<(usize, usize)> = Vec::new(); if !blobs.is_empty() {
439 let mut pack_start = 0usize;
440 let mut pack_id = 0u32;
441 let mut cur_offset = 0u64;
442 for (i, blob) in blobs.iter().enumerate() {
443 let blen = blob.compressed.len() as u64;
444 if i > pack_start && cur_offset + blen > pack_target_bytes {
448 packs_blob_ranges.push((pack_start, i));
449 pack_start = i;
450 pack_id += 1;
451 cur_offset = 0;
452 }
453 placements.push(Placement {
454 pack_id,
455 offset: cur_offset,
456 });
457 cur_offset += blen;
458 }
459 packs_blob_ranges.push((pack_start, blobs.len()));
460 }
461
462 let mut entries: Vec<TileEntry> = Vec::with_capacity(pending.len());
464 for (p, &bi) in pending.iter().zip(tile_blob.iter()) {
465 let blob = &blobs[bi];
466 let pl = &placements[bi];
467 entries.push(TileEntry {
468 zoom: p.z,
469 x: p.x,
470 y: p.y,
471 time_start: p.time_start,
472 time_end: p.time_end,
473 pack_id: pl.pack_id,
474 offset: pl.offset,
475 length: blob.compressed.len() as u32,
476 uncompressed_size: blob.uncompressed_size,
477 feature_count: p.feature_count,
478 hilbert: p.hilbert,
479 crc32c: blob.crc,
480 temporal_bucket_ms: p.temporal_bucket_ms,
481 cover_t_min: p.cover_t_min,
482 });
483 }
484 entries.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
488
489 let mut metadata = metadata.clone();
496 metadata.tile_count = entries.len() as u64;
497 metadata.feature_count = entries.iter().map(|e| u64::from(e.feature_count)).sum();
498
499 fs::create_dir_all(&out_dir)?;
501 let index_dir = out_dir.join("index");
502 let packs_dir = out_dir.join("packs");
503 fs::create_dir_all(&index_dir)?;
504 fs::create_dir_all(&packs_dir)?;
505
506 let mut pack_refs: Vec<PackRef> = Vec::with_capacity(packs_blob_ranges.len());
509 for (start, end) in &packs_blob_ranges {
510 let mut bytes: Vec<u8> = Vec::new();
511 for blob in &blobs[*start..*end] {
512 bytes.extend_from_slice(&blob.compressed);
513 }
514 let hex = blake3_128_hex(&bytes);
515 let rel = format!("packs/{hex}.sttp");
516 let final_path = out_dir.join(&rel);
517 write_atomic(&packs_dir, &final_path, &bytes)?;
518 pack_refs.push(PackRef {
519 key: rel,
520 length: bytes.len() as u64,
521 });
522 }
523
524 let (index_bytes, directory_ref_fields): (Vec<u8>, _) = if let Some(k) = page_entries {
533 let paged =
534 crate::directory_page::encode_paged_directory_level(&entries, k, true, zstd_level)?;
535 (
536 paged.bytes,
537 (
538 Some(DIRECTORY_LAYOUT_PAGED.to_string()),
539 Some(paged.root_length),
540 Some(paged.page_count as u64),
541 Some(paged.page_entries as u64),
542 ),
543 )
544 } else {
545 let index_plain = crate::directory::encode_directory(&entries);
546 let bytes = compression::compress_zstd_with_dict_level(&index_plain, None, zstd_level)?;
547 (bytes, (None, None, None, None))
548 };
549 let index_hex = blake3_128_hex(&index_bytes);
550 let index_rel = format!("index/{index_hex}.sttd");
551 let index_path = out_dir.join(&index_rel);
552 write_atomic(&index_dir, &index_path, &index_bytes)?;
553
554 let (layout, root_length, page_count, page_entries_field) = directory_ref_fields;
555
556 let manifest = Manifest {
558 format: PACKED_FORMAT.to_string(),
559 format_version: PACKED_FORMAT_VERSION,
560 compression: "zstd".to_string(),
561 directory: DirectoryRef {
562 key: index_rel,
563 length: index_bytes.len() as u64,
564 directory_version: crate::directory::DIRECTORY_VERSION,
565 encoding: Some(DIRECTORY_ENCODING_ZSTD.to_string()),
566 layout,
567 root_length,
568 page_count,
569 page_entries: page_entries_field,
570 },
571 packs: pack_refs,
572 metadata,
573 };
574 let manifest_bytes = manifest.to_json_bytes()?;
575 let manifest_path = out_dir.join("manifest.json");
576 let mut f = File::create(&manifest_path)?;
577 f.write_all(&manifest_bytes)?;
578 f.flush()?;
579
580 Ok(manifest)
581 }
582}
583
584fn decode_directory_object(bytes: &[u8], encoding: Option<&str>) -> Result<Vec<u8>> {
588 match encoding {
589 None => Ok(bytes.to_vec()),
590 Some(DIRECTORY_ENCODING_ZSTD) => compression::decompress_zstd_with_dict(bytes, None),
591 Some(other) => Err(Error::InvalidArchive(format!(
592 "unknown directory encoding {other:?} (this reader supports absent or \"zstd\")"
593 ))),
594 }
595}
596
597fn decode_directory_entries(bytes: &[u8], dref: &DirectoryRef) -> Result<Vec<TileEntry>> {
604 if dref.is_paged() {
605 let root_length = dref.root_length.ok_or_else(|| {
606 Error::InvalidArchive("paged directory: manifest missing rootLength".into())
607 })?;
608 let zstd = dref.encoding.as_deref() == Some(DIRECTORY_ENCODING_ZSTD);
609 crate::directory_page::decode_paged_directory(bytes, root_length, zstd)
610 } else {
611 let raw = decode_directory_object(bytes, dref.encoding.as_deref())?;
612 crate::directory::decode_directory(&raw)
613 }
614}
615
616fn write_atomic(dir: &Path, final_path: &Path, bytes: &[u8]) -> Result<()> {
620 let tmp = dir.join(format!(
622 ".tmp-{}-{}",
623 std::process::id(),
624 blake3_128_hex(bytes)
625 ));
626 {
627 let mut f = OpenOptions::new()
628 .write(true)
629 .create(true)
630 .truncate(true)
631 .open(&tmp)?;
632 f.write_all(bytes)?;
633 f.flush()?;
634 }
635 fs::rename(&tmp, final_path)?;
636 Ok(())
637}
638
639pub fn transcode_archive_to_packs<I: AsRef<Path>, O: AsRef<Path>>(
655 in_archive: I,
656 out_dir: O,
657 ordering: BlobOrdering,
658 pack_target_bytes: u64,
659) -> Result<Manifest> {
660 transcode_archive_to_packs_paged(in_archive, out_dir, ordering, pack_target_bytes, None)
661}
662
663pub fn transcode_archive_to_packs_paged<I: AsRef<Path>, O: AsRef<Path>>(
672 in_archive: I,
673 out_dir: O,
674 ordering: BlobOrdering,
675 pack_target_bytes: u64,
676 page_entries: Option<usize>,
677) -> Result<Manifest> {
678 transcode_archive_to_packs_paged_level(
679 in_archive,
680 out_dir,
681 ordering,
682 pack_target_bytes,
683 page_entries,
684 compression::ZSTD_LEVEL,
685 )
686}
687
688pub fn transcode_archive_to_packs_paged_level<I: AsRef<Path>, O: AsRef<Path>>(
692 in_archive: I,
693 out_dir: O,
694 ordering: BlobOrdering,
695 pack_target_bytes: u64,
696 page_entries: Option<usize>,
697 zstd_level: i32,
698) -> Result<Manifest> {
699 let reader = ArchiveReader::open(in_archive)?;
700 let meta = reader.metadata().clone();
701 let entries = reader.entries().to_vec();
702
703 let mut writer = PackWriter::create(out_dir, ordering, pack_target_bytes)?
704 .with_paging(page_entries)
705 .with_zstd_level(zstd_level);
706 for e in &entries {
707 let payload = reader.read_payload(e)?;
708 writer.add_tile_full(
709 &TileId::new(e.zoom, e.x, e.y, e.time_start.max(0) as u64),
710 e.time_start,
711 e.time_end,
712 e.cover_t_min,
713 e.feature_count,
714 e.temporal_bucket_ms,
715 &payload,
716 )?;
717 }
718 writer.finalize(&meta)
719}
720
721pub fn transcode_packs_to_packs_paged_level<I: AsRef<Path>, O: AsRef<Path>>(
733 in_manifest: I,
734 out_dir: O,
735 ordering: BlobOrdering,
736 pack_target_bytes: u64,
737 page_entries: Option<usize>,
738 zstd_level: i32,
739) -> Result<Manifest> {
740 let reader = PackedReader::open(in_manifest)?;
741 let meta = reader.metadata().clone();
742 let entries = reader.entries().to_vec();
743
744 let mut writer = PackWriter::create(out_dir, ordering, pack_target_bytes)?
745 .with_paging(page_entries)
746 .with_zstd_level(zstd_level);
747 for e in &entries {
748 let payload = reader.read_payload(e)?;
749 writer.add_tile_full(
750 &TileId::new(e.zoom, e.x, e.y, e.time_start.max(0) as u64),
751 e.time_start,
752 e.time_end,
753 e.cover_t_min,
754 e.feature_count,
755 e.temporal_bucket_ms,
756 &payload,
757 )?;
758 }
759 writer.finalize(&meta)
760}
761
762pub fn repack_directory<P: AsRef<Path>, Q: AsRef<Path>>(
778 in_manifest: P,
779 out_dir: Q,
780 page_entries: Option<usize>,
781) -> Result<Manifest> {
782 let in_manifest = in_manifest.as_ref();
783 let in_dir = in_manifest
784 .parent()
785 .ok_or_else(|| Error::InvalidArchive("manifest path has no parent dir".into()))?;
786 let out_dir = out_dir.as_ref();
787
788 let reader = PackedReader::open(in_manifest)?;
789 let entries = reader.entries().to_vec();
790 let metadata = reader.metadata().clone();
791 let src = Manifest::from_json_bytes(&fs::read(in_manifest)?)?;
792
793 fs::create_dir_all(out_dir.join("index"))?;
794 fs::create_dir_all(out_dir.join("packs"))?;
795
796 let same_dir = fs::canonicalize(in_dir).ok() == fs::canonicalize(out_dir).ok();
798 if !same_dir {
799 for p in &src.packs {
800 let dst = out_dir.join(&p.key);
801 if !dst.exists() {
802 fs::copy(in_dir.join(&p.key), &dst)?;
803 }
804 }
805 }
806
807 let (index_bytes, layout, root_length, page_count, page_entries_field): (
809 Vec<u8>,
810 Option<String>,
811 Option<u64>,
812 Option<u64>,
813 Option<u64>,
814 ) = if let Some(k) = page_entries {
815 let paged = crate::directory_page::encode_paged_directory(&entries, k, true)?;
816 (
817 paged.bytes,
818 Some(DIRECTORY_LAYOUT_PAGED.to_string()),
819 Some(paged.root_length),
820 Some(paged.page_count as u64),
821 Some(paged.page_entries as u64),
822 )
823 } else {
824 let plain = crate::directory::encode_directory(&entries);
825 (
826 compression::compress_zstd_with_dict(&plain, None)?,
827 None,
828 None,
829 None,
830 None,
831 )
832 };
833
834 let index_hex = blake3_128_hex(&index_bytes);
835 let index_rel = format!("index/{index_hex}.sttd");
836 write_atomic(&out_dir.join("index"), &out_dir.join(&index_rel), &index_bytes)?;
837
838 let manifest = Manifest {
839 format: PACKED_FORMAT.to_string(),
840 format_version: PACKED_FORMAT_VERSION,
841 compression: src.compression.clone(),
842 directory: DirectoryRef {
843 key: index_rel,
844 length: index_bytes.len() as u64,
845 directory_version: crate::directory::DIRECTORY_VERSION,
846 encoding: Some(DIRECTORY_ENCODING_ZSTD.to_string()),
847 layout,
848 root_length,
849 page_count,
850 page_entries: page_entries_field,
851 },
852 packs: src.packs.clone(),
853 metadata,
854 };
855 let manifest_bytes = manifest.to_json_bytes()?;
856 let mut f = File::create(out_dir.join("manifest.json"))?;
857 f.write_all(&manifest_bytes)?;
858 f.flush()?;
859 Ok(manifest)
860}
861
862pub fn verify_packed_objects<P: AsRef<Path>>(manifest_path: P) -> Result<Vec<String>> {
878 let manifest_path = manifest_path.as_ref();
879 let root = manifest_path
880 .parent()
881 .ok_or_else(|| Error::InvalidArchive("manifest path has no parent dir".into()))?;
882 let manifest = Manifest::from_json_bytes(&fs::read(manifest_path)?)?;
883
884 let mut issues = Vec::new();
885
886 if manifest.format != PACKED_FORMAT {
887 issues.push(format!(
888 "manifest format is {:?}, expected {PACKED_FORMAT:?}",
889 manifest.format
890 ));
891 }
892 if manifest.format_version != PACKED_FORMAT_VERSION {
893 issues.push(format!(
894 "manifest formatVersion is {}, expected {PACKED_FORMAT_VERSION}",
895 manifest.format_version
896 ));
897 }
898 if manifest.directory.directory_version != crate::directory::DIRECTORY_VERSION {
899 issues.push(format!(
900 "directoryVersion is {}, expected {}",
901 manifest.directory.directory_version,
902 crate::directory::DIRECTORY_VERSION
903 ));
904 }
905
906 fn check_object(
909 root: &Path,
910 key: &str,
911 declared_len: u64,
912 prefix: &str,
913 ext: &str,
914 issues: &mut Vec<String>,
915 ) {
916 match fs::read(root.join(key)) {
917 Ok(bytes) => {
918 if bytes.len() as u64 != declared_len {
919 issues.push(format!(
920 "{key}: on-disk length {} != manifest-declared {declared_len}",
921 bytes.len()
922 ));
923 }
924 let expected = format!("{prefix}/{}.{ext}", blake3_128_hex(&bytes));
925 if key != expected {
926 issues.push(format!(
927 "{key}: content-address mismatch (bytes hash to {expected})"
928 ));
929 }
930 }
931 Err(e) => issues.push(format!("{key}: cannot read object ({e})")),
932 }
933 }
934
935 check_object(
936 root,
937 &manifest.directory.key,
938 manifest.directory.length,
939 "index",
940 "sttd",
941 &mut issues,
942 );
943 for p in &manifest.packs {
944 check_object(root, &p.key, p.length, "packs", "sttp", &mut issues);
945 }
946
947 match fs::read(root.join(&manifest.directory.key)) {
950 Ok(dir_bytes) => match decode_directory_entries(&dir_bytes, &manifest.directory) {
951 Ok(entries) => {
952 if let Some(max_pid) = entries.iter().map(|e| e.pack_id).max() {
953 if max_pid as usize >= manifest.packs.len() {
954 issues.push(format!(
955 "directory references pack_id {max_pid} but the manifest lists only {} pack(s)",
956 manifest.packs.len()
957 ));
958 }
959 }
960 }
961 Err(e) => issues.push(format!("directory failed to decode: {e}")),
962 },
963 Err(_) => {}
965 }
966
967 if manifest.directory.is_paged() {
971 match manifest.directory.root_length {
972 Some(rl) => {
973 if let Ok(dir_bytes) = fs::read(root.join(&manifest.directory.key)) {
974 let zstd =
975 manifest.directory.encoding.as_deref() == Some(DIRECTORY_ENCODING_ZSTD);
976 match crate::directory_page::verify_paged_structure(&dir_bytes, rl, zstd) {
977 Ok(mut more) => issues.append(&mut more),
978 Err(e) => issues.push(format!("paged structure check failed: {e}")),
979 }
980 }
981 }
982 None => issues.push("paged directory: manifest missing rootLength".into()),
983 }
984 }
985
986 Ok(issues)
987}
988
989struct LoadedPack {
995 mmap: Option<Mmap>,
997 path: PathBuf,
999 length: u64,
1001}
1002
1003pub struct PackedReader {
1012 entries: Vec<TileEntry>,
1013 metadata: Metadata,
1014 compression: Compression,
1015 packs: Vec<std::cell::RefCell<LoadedPack>>,
1016}
1017
1018impl PackedReader {
1019 pub fn open<P: AsRef<Path>>(manifest_path: P) -> Result<Self> {
1022 let manifest_path = manifest_path.as_ref();
1023 let root = manifest_path
1024 .parent()
1025 .ok_or_else(|| Error::InvalidArchive("manifest path has no parent dir".into()))?
1026 .to_path_buf();
1027
1028 let manifest_bytes = fs::read(manifest_path)?;
1029 let manifest = Manifest::from_json_bytes(&manifest_bytes)?;
1030
1031 if manifest.format != PACKED_FORMAT {
1032 return Err(Error::InvalidArchive(format!(
1033 "not a packed manifest: format={:?} (expected {PACKED_FORMAT:?})",
1034 manifest.format
1035 )));
1036 }
1037 let compression = match manifest.compression.as_str() {
1038 "zstd" => Compression::Zstd,
1039 "none" => Compression::None,
1040 other => {
1041 return Err(Error::InvalidArchive(format!(
1042 "unknown packed compression {other:?}"
1043 )))
1044 }
1045 };
1046
1047 let dir_path = root.join(&manifest.directory.key);
1052 let dir_bytes = fs::read(&dir_path)?;
1053 let entries = decode_directory_entries(&dir_bytes, &manifest.directory)?;
1054
1055 let packs = manifest
1057 .packs
1058 .iter()
1059 .map(|p| {
1060 std::cell::RefCell::new(LoadedPack {
1061 mmap: None,
1062 path: root.join(&p.key),
1063 length: p.length,
1064 })
1065 })
1066 .collect();
1067
1068 Ok(Self {
1069 entries,
1070 metadata: manifest.metadata,
1071 compression,
1072 packs,
1073 })
1074 }
1075
1076 pub fn entries(&self) -> &[TileEntry] {
1078 &self.entries
1079 }
1080
1081 pub fn metadata(&self) -> &Metadata {
1083 &self.metadata
1084 }
1085
1086 pub fn read_payload(&self, entry: &TileEntry) -> Result<Vec<u8>> {
1092 let cell = self.packs.get(entry.pack_id as usize).ok_or_else(|| {
1093 Error::InvalidArchive(format!(
1094 "tile {:?} references pack {} but only {} packs exist",
1095 entry.tile_id(),
1096 entry.pack_id,
1097 self.packs.len()
1098 ))
1099 })?;
1100
1101 let payload = {
1102 let mut pack = cell.borrow_mut();
1103 if pack.mmap.is_none() {
1104 let file = File::open(&pack.path)?;
1105 let mmap = unsafe { Mmap::map(&file) }
1108 .map_err(|e| Error::Other(format!("mmap failed: {e}")))?;
1109 if mmap.len() as u64 != pack.length {
1110 return Err(Error::InvalidArchive(format!(
1111 "pack {} is {} bytes, manifest declared {}",
1112 pack.path.display(),
1113 mmap.len(),
1114 pack.length
1115 )));
1116 }
1117 pack.mmap = Some(mmap);
1118 }
1119 let mmap = pack.mmap.as_ref().expect("just loaded");
1120
1121 let start = entry.offset as usize;
1122 let end = start + entry.length as usize;
1123 if end > mmap.len() {
1124 return Err(Error::InvalidArchive(format!(
1125 "tile {:?} blob range {start}..{end} exceeds pack size {}",
1126 entry.tile_id(),
1127 mmap.len()
1128 )));
1129 }
1130 let compressed = &mmap[start..end];
1131
1132 if crc32c_tag(compressed) != entry.crc32c {
1133 return Err(Error::InvalidArchive(format!(
1134 "tile {:?} failed integrity check (corrupt pack)",
1135 entry.tile_id()
1136 )));
1137 }
1138
1139 if self.compression == Compression::Zstd {
1140 compression::decompress_zstd_with_dict(compressed, None)?
1141 } else {
1142 compression::decompress(compressed, self.compression)?
1143 }
1144 };
1145
1146 if payload.len() != entry.uncompressed_size as usize {
1147 return Err(Error::InvalidArchive(format!(
1148 "tile {:?} decompressed to {} bytes, expected {}",
1149 entry.tile_id(),
1150 payload.len(),
1151 entry.uncompressed_size
1152 )));
1153 }
1154 Ok(payload)
1155 }
1156
1157 pub fn read_layers(&self, entry: &TileEntry) -> Result<Vec<crate::arrow_tile::DecodedLayer>> {
1159 let payload = self.read_payload(entry)?;
1160 crate::arrow_tile::decode_tile(&payload)
1161 }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use super::*;
1167 use crate::arrow_tile::{encode_tile, ColumnarLayer, GeometryColumn};
1168
1169 fn point_layer(name: &str, ids: Vec<u64>, t0: i64) -> ColumnarLayer {
1170 let n = ids.len();
1171 ColumnarLayer {
1172 name: name.to_string(),
1173 feature_ids: ids,
1174 start_times: vec![t0; n],
1175 end_times: vec![t0 + 100; n],
1176 geometry: GeometryColumn::Point(vec![[-122.4, 37.7]; n]),
1177 vertex_times: None,
1178 vertex_values: None,
1179 triangles: None,
1180 vertex_value_matrix: None,
1181 properties: vec![],
1182 }
1183 }
1184
1185 fn distinct_tile(seed: u64) -> Vec<u8> {
1188 let ids: Vec<u64> = (0..6).map(|i| seed * 100 + i).collect();
1189 encode_tile(&[point_layer("default", ids, (seed as i64) * 7)]).unwrap()
1190 }
1191
1192 #[test]
1193 fn blake3_128_hex_is_32_chars() {
1194 let h = blake3_128_hex(b"hello");
1195 assert_eq!(h.len(), 32);
1196 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1197 }
1198
1199 #[test]
1200 fn manifest_json_roundtrips() {
1201 let m = Manifest {
1202 format: PACKED_FORMAT.to_string(),
1203 format_version: PACKED_FORMAT_VERSION,
1204 compression: "zstd".to_string(),
1205 directory: DirectoryRef {
1206 key: "index/abc.sttd".to_string(),
1207 length: 42,
1208 directory_version: 5,
1209 encoding: Some(DIRECTORY_ENCODING_ZSTD.to_string()),
1210 layout: None,
1211 root_length: None,
1212 page_count: None,
1213 page_entries: None,
1214 },
1215 packs: vec![
1216 PackRef { key: "packs/a.sttp".to_string(), length: 100 },
1217 PackRef { key: "packs/b.sttp".to_string(), length: 200 },
1218 ],
1219 metadata: Metadata::new("manifest-test"),
1220 };
1221 let bytes = m.to_json_bytes().unwrap();
1222 let s = String::from_utf8(bytes.clone()).unwrap();
1224 assert!(s.contains("\"formatVersion\""), "{s}");
1225 assert!(s.contains("\"directoryVersion\""), "{s}");
1226 assert!(s.contains("\"stt-packed\""), "{s}");
1227 let back = Manifest::from_json_bytes(&bytes).unwrap();
1228 assert_eq!(back.format, m.format);
1229 assert_eq!(back.format_version, m.format_version);
1230 assert_eq!(back.packs.len(), 2);
1231 assert_eq!(back.directory.directory_version, 5);
1232 assert_eq!(back.directory.encoding.as_deref(), Some("zstd"));
1233 assert_eq!(back.metadata.name, "manifest-test");
1234
1235 let mut legacy_json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1239 legacy_json["directory"]
1240 .as_object_mut()
1241 .unwrap()
1242 .remove("encoding");
1243 let legacy_back =
1244 Manifest::from_json_bytes(&serde_json::to_vec(&legacy_json).unwrap()).unwrap();
1245 assert_eq!(legacy_back.directory.encoding, None);
1246 let legacy_out =
1247 String::from_utf8(legacy_back.to_json_bytes().unwrap()).unwrap();
1248 assert!(!legacy_out.contains("\"encoding\""), "{legacy_out}");
1249 }
1250
1251 #[test]
1255 fn packwriter_roundtrips_through_multiple_packs() {
1256 let dir = tempfile::tempdir().unwrap();
1257 let out = dir.path().join("dataset");
1258
1259 let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024).unwrap();
1260
1261 let static_payload = encode_tile(&[point_layer("default", vec![7, 8, 9], 0)]).unwrap();
1264 let mut expected: Vec<(TileId, i64, i64, Vec<u8>)> = Vec::new();
1265 let bucket = 3_600_000i64;
1266 for k in 0..30u64 {
1267 let zoom = if k % 2 == 0 { 9u8 } else { 10u8 };
1268 let b = (k % 5) as i64; let t = b * bucket;
1270 let payload = if k == 13 || k == 27 {
1271 static_payload.clone() } else {
1273 distinct_tile(k)
1274 };
1275 let id = TileId::new(zoom, (k % 7) as u32, (k / 7) as u32, t as u64);
1276 w.add_tile_full(&id, t, t + bucket - 1, Some(t), 6, Some(bucket as u64), &payload)
1277 .unwrap();
1278 expected.push((id, t, t + bucket - 1, payload));
1279 }
1280 assert_eq!(w.tile_count(), 30);
1281
1282 let meta = Metadata::new("packed-roundtrip").with_temporal_bucket_ms(bucket as u64);
1283 let manifest = w.finalize(&meta).unwrap();
1284
1285 assert!(manifest.packs.len() > 1, "expected multiple packs, got {}", manifest.packs.len());
1287
1288 for p in &manifest.packs {
1290 let bytes = fs::read(out.join(&p.key)).unwrap();
1291 assert_eq!(bytes.len() as u64, p.length);
1292 }
1297
1298 for p in &manifest.packs {
1300 let bytes = fs::read(out.join(&p.key)).unwrap();
1301 let hex = blake3_128_hex(&bytes);
1302 assert_eq!(p.key, format!("packs/{hex}.sttp"));
1303 }
1304 let dir_bytes = fs::read(out.join(&manifest.directory.key)).unwrap();
1305 assert_eq!(
1306 manifest.directory.key,
1307 format!("index/{}.sttd", blake3_128_hex(&dir_bytes))
1308 );
1309
1310 let reader = PackedReader::open(out.join("manifest.json")).unwrap();
1312 let max_pack = reader.entries().iter().map(|e| e.pack_id).max().unwrap();
1313 assert_eq!(max_pack as usize, manifest.packs.len() - 1);
1314 let observed: std::collections::BTreeSet<u32> =
1315 reader.entries().iter().map(|e| e.pack_id).collect();
1316 for pid in 0..manifest.packs.len() as u32 {
1317 assert!(observed.contains(&pid), "pack_id {pid} unused");
1318 }
1319
1320 assert_eq!(reader.metadata().name, "packed-roundtrip");
1322 assert_eq!(reader.metadata().temporal_bucket_ms, bucket as u64);
1323 assert_eq!(reader.entries().len(), 30);
1324
1325 assert_eq!(manifest.metadata.tile_count, 30);
1328 assert_eq!(manifest.metadata.feature_count, 30 * 6);
1329 assert_eq!(reader.metadata().tile_count, 30);
1330 assert_eq!(reader.metadata().feature_count, 30 * 6);
1331
1332 for (id, ts, _te, payload) in &expected {
1334 let e = reader
1335 .entries()
1336 .iter()
1337 .find(|e| {
1338 e.zoom == id.z && e.x == id.x && e.y == id.y && e.time_start == *ts
1339 })
1340 .expect("entry present");
1341 let got = reader.read_payload(e).unwrap();
1342 assert_eq!(&got, payload, "payload mismatch for {id:?}");
1343 }
1344
1345 let static_entries: Vec<&TileEntry> = reader
1347 .entries()
1348 .iter()
1349 .filter(|e| {
1350 let g = reader.read_payload(e).unwrap();
1351 g == static_payload
1352 })
1353 .collect();
1354 assert_eq!(static_entries.len(), 2);
1355 assert_eq!(static_entries[0].pack_id, static_entries[1].pack_id);
1356 assert_eq!(static_entries[0].offset, static_entries[1].offset);
1357 }
1358
1359 #[test]
1368 fn paged_directory_writer_roundtrips_and_matches_single() {
1369 let bucket = 3_600_000i64;
1370 let mut input: Vec<(TileId, i64, i64, Vec<u8>)> = Vec::new();
1372 for k in 0..120u64 {
1373 let zoom = [6u8, 10, 13][(k % 3) as usize];
1374 let b = (k % 4) as i64;
1375 let t = b * bucket;
1376 let id = TileId::new(zoom, (k % 11) as u32, (k / 11) as u32, t as u64);
1377 input.push((id, t, t + bucket - 1, distinct_tile(k)));
1378 }
1379 let build = |out: &Path, page_entries: Option<usize>| -> Manifest {
1380 let mut w = PackWriter::create(out, BlobOrdering::Auto, 16 * 1024)
1381 .unwrap()
1382 .with_paging(page_entries);
1383 for (id, ts, te, payload) in &input {
1384 w.add_tile_full(id, *ts, *te, Some(*ts), 6, Some(bucket as u64), payload)
1385 .unwrap();
1386 }
1387 let meta = Metadata::new("paged-roundtrip").with_temporal_bucket_ms(bucket as u64);
1388 w.finalize(&meta).unwrap()
1389 };
1390
1391 let dir = tempfile::tempdir().unwrap();
1392 let single_out = dir.path().join("single");
1393 let paged_out = dir.path().join("paged");
1394 let single = build(&single_out, None);
1395 let paged = build(&paged_out, Some(16));
1397
1398 assert!(single.directory.layout.is_none());
1400 assert_eq!(paged.directory.layout.as_deref(), Some(DIRECTORY_LAYOUT_PAGED));
1401 assert!(paged.directory.root_length.unwrap() > 0);
1402 assert!(paged.directory.page_count.unwrap() >= 2, "expected multiple leaf pages");
1403 assert_eq!(paged.directory.page_entries, Some(16));
1404 assert_eq!(paged.directory.directory_version, crate::directory::DIRECTORY_VERSION);
1405 assert_eq!(paged.directory.encoding.as_deref(), Some(DIRECTORY_ENCODING_ZSTD));
1406
1407 let r_single = PackedReader::open(single_out.join("manifest.json")).unwrap();
1408 let r_paged = PackedReader::open(paged_out.join("manifest.json")).unwrap();
1409 assert_eq!(r_paged.entries().len(), 120);
1410 assert_eq!(r_single.entries().len(), 120);
1411
1412 let keys = |r: &PackedReader| -> Vec<(u8, u32, u32, i64, i64, u32, Option<u64>, Option<i64>)> {
1415 let mut v: Vec<_> = r
1416 .entries()
1417 .iter()
1418 .map(|e| {
1419 (
1420 e.zoom, e.x, e.y, e.time_start, e.time_end, e.feature_count,
1421 e.temporal_bucket_ms, e.cover_t_min,
1422 )
1423 })
1424 .collect();
1425 v.sort();
1426 v
1427 };
1428 assert_eq!(keys(&r_single), keys(&r_paged));
1429
1430 for (id, ts, _te, payload) in &input {
1432 let e = r_paged
1433 .entries()
1434 .iter()
1435 .find(|x| x.zoom == id.z && x.x == id.x && x.y == id.y && x.time_start == *ts)
1436 .expect("paged entry present");
1437 assert_eq!(&r_paged.read_payload(e).unwrap(), payload, "payload mismatch {id:?}");
1438 }
1439
1440 let issues = verify_packed_objects(paged_out.join("manifest.json")).unwrap();
1442 assert!(issues.is_empty(), "paged verify issues: {issues:?}");
1443 }
1444
1445 #[test]
1449 fn repack_directory_to_paged_preserves_packs_and_entries() {
1450 let dir = tempfile::tempdir().unwrap();
1451 let src = dir.path().join("src");
1452 let out = dir.path().join("out");
1453
1454 let mut w = PackWriter::create(&src, BlobOrdering::Auto, 16 * 1024).unwrap();
1456 for k in 0..80u64 {
1457 let zoom = [8u8, 11][(k % 2) as usize];
1458 let t = (k % 3) as i64 * 3_600_000;
1459 let id = TileId::new(zoom, (k % 9) as u32, (k / 9) as u32, t as u64);
1460 w.add_tile_full(&id, t, t + 3_599_999, Some(t), 4, Some(3_600_000), &distinct_tile(k))
1461 .unwrap();
1462 }
1463 let src_manifest = w.finalize(&Metadata::new("repack-src")).unwrap();
1464 assert!(src_manifest.directory.layout.is_none());
1465
1466 let out_manifest = repack_directory(src.join("manifest.json"), &out, Some(8)).unwrap();
1468 assert_eq!(out_manifest.directory.layout.as_deref(), Some(DIRECTORY_LAYOUT_PAGED));
1469 assert!(out_manifest.directory.page_count.unwrap() >= 2);
1470
1471 let src_keys: std::collections::BTreeSet<&str> =
1473 src_manifest.packs.iter().map(|p| p.key.as_str()).collect();
1474 let out_keys: std::collections::BTreeSet<&str> =
1475 out_manifest.packs.iter().map(|p| p.key.as_str()).collect();
1476 assert_eq!(src_keys, out_keys);
1477
1478 let r_src = PackedReader::open(src.join("manifest.json")).unwrap();
1480 let r_out = PackedReader::open(out.join("manifest.json")).unwrap();
1481 assert_eq!(r_src.entries(), r_out.entries());
1482 let e = &r_out.entries()[0];
1484 assert_eq!(r_out.read_payload(e).unwrap(), r_src.read_payload(e).unwrap());
1485 assert!(verify_packed_objects(out.join("manifest.json")).unwrap().is_empty());
1486 }
1487
1488 #[test]
1490 fn oversized_blob_gets_its_own_pack() {
1491 let dir = tempfile::tempdir().unwrap();
1492 let out = dir.path().join("dataset");
1493
1494 let big_ids: Vec<u64> = (0..4000).collect();
1497 let big = encode_tile(&[ColumnarLayer {
1498 name: "default".to_string(),
1499 feature_ids: big_ids.clone(),
1500 start_times: vec![0; big_ids.len()],
1501 end_times: vec![100; big_ids.len()],
1502 geometry: GeometryColumn::Point(
1503 (0..big_ids.len()).map(|i| [i as f64 * 0.01, i as f64 * 0.013]).collect(),
1504 ),
1505 vertex_times: None,
1506 vertex_values: None,
1507 triangles: None,
1508 vertex_value_matrix: None,
1509 properties: vec![],
1510 }])
1511 .unwrap();
1512 let big_compressed_len = compression::compress_zstd_with_dict(&big, None).unwrap().len();
1513
1514 let mut w = PackWriter::create(&out, BlobOrdering::SpatialMajor, 4 * 1024).unwrap();
1515 assert!(big_compressed_len as u64 > 4 * 1024);
1517 w.add_tile_full(&TileId::new(10, 0, 0, 0), 0, 100, None, 4000, None, &big).unwrap();
1518 for k in 1..4u64 {
1519 let p = distinct_tile(k);
1520 w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p).unwrap();
1521 }
1522 let _manifest = w.finalize(&Metadata::new("big")).unwrap();
1523
1524 let reader = PackedReader::open(out.join("manifest.json")).unwrap();
1526 let big_entry = reader.entries().iter().find(|e| e.x == 0).unwrap();
1527 assert_eq!(reader.read_payload(big_entry).unwrap(), big);
1528 }
1529
1530 #[test]
1536 fn rebuilds_are_byte_reproducible() {
1537 let bucket = 3_600_000i64;
1541 let mut tiles: Vec<(TileId, i64, Option<u64>, Vec<u8>)> = Vec::new();
1542 for k in 0..10u64 {
1543 let t = (k % 4) as i64 * bucket;
1544 tiles.push((
1545 TileId::new(9, (k % 5) as u32, (k / 5) as u32, t as u64),
1546 t,
1547 None,
1548 distinct_tile(k),
1549 ));
1550 }
1551 tiles.push((TileId::new(9, 1, 0, 0), 0, None, distinct_tile(100)));
1553 tiles.push((
1554 TileId::new(9, 1, 0, 0),
1555 0,
1556 Some(24 * bucket as u64),
1557 distinct_tile(101),
1558 ));
1559
1560 let meta = Metadata::new("repro").with_temporal_bucket_ms(bucket as u64);
1561 let build = |order: &[usize]| {
1562 let dir = tempfile::tempdir().unwrap();
1563 let out = dir.path().join("dataset");
1564 let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024).unwrap();
1565 for &i in order {
1566 let (id, t, b, payload) = &tiles[i];
1567 w.add_tile_full(id, *t, t + bucket - 1, Some(*t), 6, *b, payload)
1568 .unwrap();
1569 }
1570 let manifest = w.finalize(&meta).unwrap();
1571 (dir, manifest)
1572 };
1573
1574 let forward: Vec<usize> = (0..tiles.len()).collect();
1575 let reverse: Vec<usize> = (0..tiles.len()).rev().collect();
1576 let (_d1, m1) = build(&forward);
1577 let (_d2, m2) = build(&reverse);
1578
1579 assert_eq!(m1.directory.key, m2.directory.key, "directory hash must be stable");
1580 assert_eq!(
1581 m1.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
1582 m2.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
1583 "pack hashes must be stable across rebuilds"
1584 );
1585 assert_eq!(m1.to_json_bytes().unwrap(), m2.to_json_bytes().unwrap());
1586 }
1587
1588 #[test]
1593 fn directory_encoding_compressed_and_raw_both_read() {
1594 let dir = tempfile::tempdir().unwrap();
1595 let out = dir.path().join("dataset");
1596 let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024).unwrap();
1597 for k in 0..8u64 {
1598 let p = distinct_tile(k);
1599 w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p)
1600 .unwrap();
1601 }
1602 let manifest = w.finalize(&Metadata::new("dir-enc")).unwrap();
1603 let manifest_path = out.join("manifest.json");
1604
1605 assert_eq!(manifest.directory.encoding.as_deref(), Some("zstd"));
1608 let at_rest = fs::read(out.join(&manifest.directory.key)).unwrap();
1609 assert_eq!(at_rest.len() as u64, manifest.directory.length);
1610 let raw = compression::decompress_zstd_with_dict(&at_rest, None).unwrap();
1611 assert!(crate::directory::decode_directory(&raw).is_ok());
1612 assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
1613 let entries_compressed = PackedReader::open(&manifest_path).unwrap().entries().to_vec();
1614
1615 let raw_hex = blake3_128_hex(&raw);
1618 let raw_rel = format!("index/{raw_hex}.sttd");
1619 fs::write(out.join(&raw_rel), &raw).unwrap();
1620 let mut legacy = manifest.clone();
1621 legacy.directory = DirectoryRef {
1622 key: raw_rel,
1623 length: raw.len() as u64,
1624 directory_version: crate::directory::DIRECTORY_VERSION,
1625 encoding: None,
1626 layout: None,
1627 root_length: None,
1628 page_count: None,
1629 page_entries: None,
1630 };
1631 fs::write(&manifest_path, legacy.to_json_bytes().unwrap()).unwrap();
1632
1633 assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
1634 let entries_raw = PackedReader::open(&manifest_path).unwrap().entries().to_vec();
1635 assert_eq!(entries_raw, entries_compressed);
1636
1637 legacy.directory.encoding = Some("br".to_string());
1639 fs::write(&manifest_path, legacy.to_json_bytes().unwrap()).unwrap();
1640 assert!(PackedReader::open(&manifest_path).is_err());
1641 }
1642
1643 #[test]
1647 fn verify_packed_objects_clean_then_detects_corruption() {
1648 let dir = tempfile::tempdir().unwrap();
1649 let out = dir.path().join("dataset");
1650
1651 let mut w = PackWriter::create(&out, BlobOrdering::Auto, 8 * 1024).unwrap();
1652 for k in 0..12u64 {
1653 let p = distinct_tile(k);
1654 w.add_tile_full(&TileId::new(10, k as u32, 0, 0), 0, 100, None, 6, None, &p)
1655 .unwrap();
1656 }
1657 let manifest = w.finalize(&Metadata::new("verify")).unwrap();
1658 let manifest_path = out.join("manifest.json");
1659
1660 assert!(verify_packed_objects(&manifest_path).unwrap().is_empty());
1662
1663 let pack0 = out.join(&manifest.packs[0].key);
1666 let mut bytes = fs::read(&pack0).unwrap();
1667 bytes[0] ^= 0xff;
1668 fs::write(&pack0, &bytes).unwrap();
1669
1670 let issues = verify_packed_objects(&manifest_path).unwrap();
1671 assert!(
1672 issues.iter().any(|s| s.contains("content-address mismatch")),
1673 "expected a content-address mismatch, got {issues:?}"
1674 );
1675 }
1676}