1use crate::archive::TileEntry;
46use crate::compression;
47use crate::directory::{decode_directory, encode_directory};
48use crate::error::{Error, Result};
49use crate::projection::tile_geo_bounds;
50
51pub const PAGED_ROOT_VERSION: u8 = 1;
54
55pub const DESCRIPTOR_GEO_BBOX: u8 = 0;
57
58pub const DEFAULT_PAGE_ENTRIES: usize = 4096;
61
62const ROOT_HEADER_LEN: usize = 12;
65const DESCRIPTOR_LEN: usize = 52;
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct PageDescriptor {
71 pub rel_offset: u64,
75 pub length: u32,
77 pub entry_count: u32,
79 pub min_zoom: u8,
81 pub max_zoom: u8,
82 pub min_lon_e7: i32,
86 pub min_lat_e7: i32,
87 pub max_lon_e7: i32,
88 pub max_lat_e7: i32,
89 pub t_min: i64,
92 pub t_max: i64,
93}
94
95impl PageDescriptor {
96 #[allow(clippy::too_many_arguments)]
100 pub fn overlaps(
101 &self,
102 zoom: u8,
103 q_min_lon_e7: i32,
104 q_min_lat_e7: i32,
105 q_max_lon_e7: i32,
106 q_max_lat_e7: i32,
107 t_start: i64,
108 t_end: i64,
109 ) -> bool {
110 zoom >= self.min_zoom
111 && zoom <= self.max_zoom
112 && self.min_lon_e7 <= q_max_lon_e7
113 && q_min_lon_e7 <= self.max_lon_e7
114 && self.min_lat_e7 <= q_max_lat_e7
115 && q_min_lat_e7 <= self.max_lat_e7
116 && self.t_max >= t_start
117 && self.t_min <= t_end
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct PagedRoot {
124 pub descriptor_kind: u8,
125 pub page_entries: u32,
127 pub pages: Vec<PageDescriptor>,
128}
129
130pub struct EncodedPagedDirectory {
133 pub bytes: Vec<u8>,
135 pub root_length: u64,
137 pub page_count: usize,
138 pub page_entries: usize,
139}
140
141#[inline]
142fn floor_e7(deg: f64) -> i32 {
143 (deg * 1e7).floor().clamp(i32::MIN as f64, i32::MAX as f64) as i32
144}
145
146#[inline]
147fn ceil_e7(deg: f64) -> i32 {
148 (deg * 1e7).ceil().clamp(i32::MIN as f64, i32::MAX as f64) as i32
149}
150
151fn put_u16(buf: &mut Vec<u8>, v: u16) {
152 buf.extend_from_slice(&v.to_le_bytes());
153}
154fn put_u32(buf: &mut Vec<u8>, v: u32) {
155 buf.extend_from_slice(&v.to_le_bytes());
156}
157fn put_u64(buf: &mut Vec<u8>, v: u64) {
158 buf.extend_from_slice(&v.to_le_bytes());
159}
160fn put_i32(buf: &mut Vec<u8>, v: i32) {
161 buf.extend_from_slice(&v.to_le_bytes());
162}
163fn put_i64(buf: &mut Vec<u8>, v: i64) {
164 buf.extend_from_slice(&v.to_le_bytes());
165}
166
167pub fn encode_root(page_entries: u32, descriptors: &[PageDescriptor]) -> Vec<u8> {
169 let mut buf = Vec::with_capacity(ROOT_HEADER_LEN + descriptors.len() * DESCRIPTOR_LEN);
170 buf.push(PAGED_ROOT_VERSION);
171 buf.push(DESCRIPTOR_GEO_BBOX);
172 put_u16(&mut buf, 0); put_u32(&mut buf, descriptors.len() as u32);
174 put_u32(&mut buf, page_entries);
175 for d in descriptors {
176 put_u64(&mut buf, d.rel_offset);
177 put_u32(&mut buf, d.length);
178 put_u32(&mut buf, d.entry_count);
179 buf.push(d.min_zoom);
180 buf.push(d.max_zoom);
181 put_u16(&mut buf, 0); put_i32(&mut buf, d.min_lon_e7);
183 put_i32(&mut buf, d.min_lat_e7);
184 put_i32(&mut buf, d.max_lon_e7);
185 put_i32(&mut buf, d.max_lat_e7);
186 put_i64(&mut buf, d.t_min);
187 put_i64(&mut buf, d.t_max);
188 }
189 buf
190}
191
192struct Reader<'a> {
193 bytes: &'a [u8],
194 pos: usize,
195}
196impl<'a> Reader<'a> {
197 fn take(&mut self, n: usize) -> Result<&'a [u8]> {
198 let end = self.pos + n;
199 let slice = self
200 .bytes
201 .get(self.pos..end)
202 .ok_or_else(|| Error::InvalidArchive("paged root: truncated".into()))?;
203 self.pos = end;
204 Ok(slice)
205 }
206 fn u16(&mut self) -> Result<u16> {
207 Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
208 }
209 fn u32(&mut self) -> Result<u32> {
210 Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
211 }
212 fn u64(&mut self) -> Result<u64> {
213 Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap()))
214 }
215 fn i32(&mut self) -> Result<i32> {
216 Ok(i32::from_le_bytes(self.take(4)?.try_into().unwrap()))
217 }
218 fn i64(&mut self) -> Result<i64> {
219 Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap()))
220 }
221}
222
223pub fn decode_root(bytes: &[u8]) -> Result<PagedRoot> {
225 let mut r = Reader { bytes, pos: 0 };
226 let version = *bytes
227 .first()
228 .ok_or_else(|| Error::InvalidArchive("paged root: empty".into()))?;
229 r.pos = 1;
230 if version != PAGED_ROOT_VERSION {
231 return Err(Error::InvalidArchive(format!(
232 "paged root: unsupported version {version} (expected {PAGED_ROOT_VERSION})"
233 )));
234 }
235 let descriptor_kind = *bytes.get(1).unwrap();
236 r.pos = 2;
237 if descriptor_kind != DESCRIPTOR_GEO_BBOX {
238 return Err(Error::InvalidArchive(format!(
239 "paged root: unsupported descriptor kind {descriptor_kind}"
240 )));
241 }
242 let _reserved = r.u16()?;
243 let page_count = r.u32()? as usize;
244 let page_entries = r.u32()?;
245 let mut pages = Vec::with_capacity(page_count);
246 for _ in 0..page_count {
247 let rel_offset = r.u64()?;
248 let length = r.u32()?;
249 let entry_count = r.u32()?;
250 let min_zoom = r.take(1)?[0];
251 let max_zoom = r.take(1)?[0];
252 let _res = r.u16()?;
253 let min_lon_e7 = r.i32()?;
254 let min_lat_e7 = r.i32()?;
255 let max_lon_e7 = r.i32()?;
256 let max_lat_e7 = r.i32()?;
257 let t_min = r.i64()?;
258 let t_max = r.i64()?;
259 pages.push(PageDescriptor {
260 rel_offset,
261 length,
262 entry_count,
263 min_zoom,
264 max_zoom,
265 min_lon_e7,
266 min_lat_e7,
267 max_lon_e7,
268 max_lat_e7,
269 t_min,
270 t_max,
271 });
272 }
273 Ok(PagedRoot {
274 descriptor_kind,
275 page_entries,
276 pages,
277 })
278}
279
280fn frame(raw: Vec<u8>, zstd: bool, level: i32) -> Result<Vec<u8>> {
281 if zstd {
282 compression::compress_zstd_with_dict_level(&raw, None, level)
283 } else {
284 Ok(raw)
285 }
286}
287
288fn unframe(frame: &[u8], zstd: bool) -> Result<Vec<u8>> {
289 if zstd {
290 compression::decompress_zstd_with_dict(frame, None)
291 } else {
292 Ok(frame.to_vec())
293 }
294}
295
296pub fn encode_paged_directory(
309 entries: &[TileEntry],
310 page_entries: usize,
311 zstd: bool,
312) -> Result<EncodedPagedDirectory> {
313 encode_paged_directory_level(entries, page_entries, zstd, compression::ZSTD_LEVEL)
314}
315
316pub fn encode_paged_directory_level(
320 entries: &[TileEntry],
321 page_entries: usize,
322 zstd: bool,
323 level: i32,
324) -> Result<EncodedPagedDirectory> {
325 let page_entries = page_entries.max(1);
326 let mut sorted: Vec<&TileEntry> = entries.iter().collect();
327 sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
328
329 let all_cover = !sorted.is_empty() && sorted.iter().all(|e| e.cover_t_min.is_some());
331
332 let mut descriptors: Vec<PageDescriptor> = Vec::new();
333 let mut leaf_frames: Vec<Vec<u8>> = Vec::new();
334 let mut rel_offset = 0u64;
335
336 for chunk in sorted.chunks(page_entries) {
337 let owned: Vec<TileEntry> = chunk
340 .iter()
341 .map(|&e| {
342 let mut c = e.clone();
343 if !all_cover {
344 c.cover_t_min = None;
345 }
346 c
347 })
348 .collect();
349 let raw = encode_directory(&owned);
350 let leaf = frame(raw, zstd, level)?;
351
352 let mut geo = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
353 let (mut zmin, mut zmax) = (u8::MAX, 0u8);
354 let (mut tmin, mut tmax) = (i64::MAX, i64::MIN);
355 for &e in chunk {
356 let b = tile_geo_bounds(e.zoom, e.x, e.y);
357 geo.0 = geo.0.min(b.0);
358 geo.1 = geo.1.min(b.1);
359 geo.2 = geo.2.max(b.2);
360 geo.3 = geo.3.max(b.3);
361 zmin = zmin.min(e.zoom);
362 zmax = zmax.max(e.zoom);
363 tmin = tmin.min(e.cover_t_min.unwrap_or(e.time_start));
364 tmax = tmax.max(e.time_end);
365 }
366
367 descriptors.push(PageDescriptor {
368 rel_offset,
369 length: leaf.len() as u32,
370 entry_count: chunk.len() as u32,
371 min_zoom: zmin,
372 max_zoom: zmax,
373 min_lon_e7: floor_e7(geo.0),
374 min_lat_e7: floor_e7(geo.1),
375 max_lon_e7: ceil_e7(geo.2),
376 max_lat_e7: ceil_e7(geo.3),
377 t_min: tmin,
378 t_max: tmax,
379 });
380 rel_offset += leaf.len() as u64;
381 leaf_frames.push(leaf);
382 }
383
384 let root_raw = encode_root(page_entries as u32, &descriptors);
385 let root_frame = frame(root_raw, zstd, level)?;
386 let root_length = root_frame.len() as u64;
387 let page_count = descriptors.len();
388
389 let mut bytes = root_frame;
390 for f in &leaf_frames {
391 bytes.extend_from_slice(f);
392 }
393
394 Ok(EncodedPagedDirectory {
395 bytes,
396 root_length,
397 page_count,
398 page_entries,
399 })
400}
401
402pub fn decode_paged_directory(
410 bytes: &[u8],
411 root_length: u64,
412 zstd: bool,
413) -> Result<Vec<TileEntry>> {
414 let rl = root_length as usize;
415 if rl > bytes.len() {
416 return Err(Error::InvalidArchive(format!(
417 "paged directory: rootLength {rl} exceeds object size {}",
418 bytes.len()
419 )));
420 }
421 let root_raw = unframe(&bytes[..rl], zstd)?;
422 let root = decode_root(&root_raw)?;
423 let mut entries = Vec::new();
424 for d in &root.pages {
425 let start = rl + d.rel_offset as usize;
426 let end = start + d.length as usize;
427 let frame = bytes.get(start..end).ok_or_else(|| {
428 Error::InvalidArchive(format!(
429 "paged directory: leaf range {start}..{end} exceeds object size {}",
430 bytes.len()
431 ))
432 })?;
433 let raw = unframe(frame, zstd)?;
434 let mut page = decode_directory(&raw)?;
435 if page.len() != d.entry_count as usize {
436 return Err(Error::InvalidArchive(format!(
437 "paged directory: leaf declared {} entries, decoded {}",
438 d.entry_count,
439 page.len()
440 )));
441 }
442 entries.append(&mut page);
443 }
444 Ok(entries)
445}
446
447pub fn verify_paged_structure(bytes: &[u8], root_length: u64, zstd: bool) -> Result<Vec<String>> {
462 const MAX_ISSUES: usize = 25;
463 let mut issues: Vec<String> = Vec::new();
464 let push = |issues: &mut Vec<String>, msg: String| {
465 if issues.len() < MAX_ISSUES {
466 issues.push(msg);
467 } else if issues.len() == MAX_ISSUES {
468 issues.push("… (further paged-structure issues truncated)".into());
469 }
470 };
471
472 let rl = root_length as usize;
473 if rl > bytes.len() {
474 return Ok(vec![format!(
475 "paged: rootLength {rl} exceeds object size {}",
476 bytes.len()
477 )]);
478 }
479 let root = decode_root(&unframe(&bytes[..rl], zstd)?)?;
480
481 let mut decoded_total = 0usize;
482 let mut declared_total = 0usize;
483 let mut prev_last: Option<(u8, u64, i64)> = None;
484 for (pi, d) in root.pages.iter().enumerate() {
485 declared_total += d.entry_count as usize;
486 let start = rl + d.rel_offset as usize;
487 let end = start + d.length as usize;
488 let frame = match bytes.get(start..end) {
489 Some(f) => f,
490 None => {
491 push(
492 &mut issues,
493 format!("page {pi}: leaf range {start}..{end} exceeds object size {}", bytes.len()),
494 );
495 continue;
496 }
497 };
498 let leaf = match unframe(frame, zstd).and_then(|raw| decode_directory(&raw)) {
499 Ok(l) => l,
500 Err(e) => {
501 push(&mut issues, format!("page {pi}: leaf decode failed: {e}"));
502 continue;
503 }
504 };
505 if leaf.len() != d.entry_count as usize {
506 push(
507 &mut issues,
508 format!("page {pi}: descriptor declares {} entries, leaf decoded {}", d.entry_count, leaf.len()),
509 );
510 }
511 decoded_total += leaf.len();
512
513 for e in &leaf {
514 if e.zoom < d.min_zoom || e.zoom > d.max_zoom {
515 push(
516 &mut issues,
517 format!("page {pi}: entry zoom {} outside descriptor [{}, {}]", e.zoom, d.min_zoom, d.max_zoom),
518 );
519 }
520 let b = tile_geo_bounds(e.zoom, e.x, e.y);
521 if floor_e7(b.0) < d.min_lon_e7
522 || floor_e7(b.1) < d.min_lat_e7
523 || ceil_e7(b.2) > d.max_lon_e7
524 || ceil_e7(b.3) > d.max_lat_e7
525 {
526 push(
527 &mut issues,
528 format!("page {pi}: descriptor bbox does not cover tile {}/{}/{}", e.zoom, e.x, e.y),
529 );
530 }
531 let lo = e.cover_t_min.unwrap_or(e.time_start);
532 if lo < d.t_min || e.time_end > d.t_max {
533 push(
534 &mut issues,
535 format!("page {pi}: descriptor t-bounds [{}, {}] do not cover entry [{}, {}]", d.t_min, d.t_max, lo, e.time_end),
536 );
537 }
538 }
539
540 if let (Some(first), Some(last)) = (leaf.first(), leaf.last()) {
542 let fk = (first.zoom, first.hilbert, first.time_start);
543 if let Some(pl) = prev_last {
544 if pl > fk {
545 push(
546 &mut issues,
547 format!("page {pi}: first key {fk:?} precedes previous page's last key {pl:?} (directory order violated)"),
548 );
549 }
550 }
551 prev_last = Some((last.zoom, last.hilbert, last.time_start));
552 }
553 }
554
555 if decoded_total != declared_total {
556 push(
557 &mut issues,
558 format!("paged: descriptors declare {declared_total} entries, leaves decoded {decoded_total}"),
559 );
560 }
561 Ok(issues)
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 fn entry(z: u8, x: u32, y: u32, ts: i64, te: i64, cover: Option<i64>) -> TileEntry {
569 TileEntry {
570 zoom: z,
571 x,
572 y,
573 time_start: ts,
574 time_end: te,
575 pack_id: 0,
576 offset: (x as u64) * 64,
577 length: 50 + x,
578 uncompressed_size: 100 + x,
579 feature_count: x,
580 hilbert: crate::tile::TileId::new(z, x, y, 0).hilbert_index(),
581 crc32c: 0x1000 + x,
582 temporal_bucket_ms: Some(3_600_000),
583 cover_t_min: cover,
584 }
585 }
586
587 fn corpus(all_cover: bool) -> Vec<TileEntry> {
589 let mut v = Vec::new();
590 for z in [4u8, 8, 12] {
591 let n = 1u32 << z;
592 for i in 0..40u32 {
593 let x = (i * 7) % n;
594 let y = (i * 13) % n;
595 for b in 0..3i64 {
596 let ts = b * 3_600_000 + i as i64 * 1000;
597 let cover = if all_cover { Some(ts - 500) } else { None };
598 v.push(entry(z, x, y, ts, ts + 3_599_000, cover));
599 }
600 }
601 }
602 v
603 }
604
605 fn whole_load(entries: &[TileEntry]) -> Vec<TileEntry> {
607 decode_directory(&encode_directory(entries)).unwrap()
608 }
609
610 #[test]
611 fn paged_decode_equals_whole_load_all_cover() {
612 let c = corpus(true);
613 let want = whole_load(&c);
614 for pe in [1usize, 2, 7, 50, 4096, 100_000] {
615 for zstd in [true, false] {
616 let enc = encode_paged_directory(&c, pe, zstd).unwrap();
617 let got = decode_paged_directory(&enc.bytes, enc.root_length, zstd).unwrap();
618 assert_eq!(got, want, "page_entries={pe} zstd={zstd}");
619 }
620 }
621 }
622
623 #[test]
624 fn paged_decode_equals_whole_load_mixed_cover() {
625 let mut c = corpus(true);
629 c[0].cover_t_min = None; let want = whole_load(&c);
631 assert!(want.iter().all(|e| e.cover_t_min.is_none()));
632 for pe in [1usize, 3, 4096] {
633 let enc = encode_paged_directory(&c, pe, true).unwrap();
634 let got = decode_paged_directory(&enc.bytes, enc.root_length, true).unwrap();
635 assert_eq!(got, want, "page_entries={pe}");
636 assert!(got.iter().all(|e| e.cover_t_min.is_none()));
637 }
638 }
639
640 #[test]
641 fn empty_roundtrips() {
642 let enc = encode_paged_directory(&[], 4096, true).unwrap();
643 assert_eq!(enc.page_count, 0);
644 let got = decode_paged_directory(&enc.bytes, enc.root_length, true).unwrap();
645 assert!(got.is_empty());
646 }
647
648 #[test]
649 fn small_dataset_is_one_page() {
650 let c = corpus(true);
651 let enc = encode_paged_directory(&c, 100_000, true).unwrap();
652 assert_eq!(enc.page_count, 1);
653 }
654
655 #[test]
656 fn page_count_matches_chunking() {
657 let c = corpus(true); assert_eq!(c.len(), 360);
659 let enc = encode_paged_directory(&c, 100, true).unwrap();
660 assert_eq!(enc.page_count, 4); }
662
663 #[test]
664 fn descriptor_bounds_cover_their_entries() {
665 let c = corpus(true);
666 let enc = encode_paged_directory(&c, 37, true).unwrap();
667 let root = decode_root(&unframe(&enc.bytes[..enc.root_length as usize], true).unwrap())
668 .unwrap();
669 let mut sorted = c.clone();
671 sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
672 let mut total = 0u32;
673 for (page, chunk) in root.pages.iter().zip(sorted.chunks(37)) {
674 assert_eq!(page.entry_count as usize, chunk.len());
675 total += page.entry_count;
676 for e in chunk {
677 let b = tile_geo_bounds(e.zoom, e.x, e.y);
678 assert!(e.zoom >= page.min_zoom && e.zoom <= page.max_zoom);
679 assert!(floor_e7(b.0) >= page.min_lon_e7, "min_lon under-covered");
682 assert!(floor_e7(b.1) >= page.min_lat_e7, "min_lat under-covered");
683 assert!(ceil_e7(b.2) <= page.max_lon_e7, "max_lon under-covered");
684 assert!(ceil_e7(b.3) <= page.max_lat_e7, "max_lat under-covered");
685 let lo = e.cover_t_min.unwrap_or(e.time_start);
686 assert!(lo >= page.t_min && e.time_end <= page.t_max);
687 }
688 }
689 assert_eq!(total as usize, c.len());
690 }
691
692 #[test]
693 fn cross_page_key_order_is_monotonic() {
694 let c = corpus(true);
695 let got = decode_paged_directory(
696 &encode_paged_directory(&c, 29, true).unwrap().bytes,
697 encode_paged_directory(&c, 29, true).unwrap().root_length,
698 true,
699 )
700 .unwrap();
701 for w in got.windows(2) {
703 let a = (w[0].zoom, w[0].hilbert, w[0].time_start);
704 let b = (w[1].zoom, w[1].hilbert, w[1].time_start);
705 assert!(a <= b, "directory order violated across pages: {a:?} > {b:?}");
706 }
707 }
708
709 #[test]
710 fn root_encode_decode_roundtrips() {
711 let descs = vec![
712 PageDescriptor {
713 rel_offset: 0,
714 length: 1234,
715 entry_count: 4096,
716 min_zoom: 8,
717 max_zoom: 9,
718 min_lon_e7: -740_000_000,
719 min_lat_e7: 400_000_000,
720 max_lon_e7: -730_000_000,
721 max_lat_e7: 410_000_000,
722 t_min: 1_000,
723 t_max: 9_999,
724 },
725 PageDescriptor {
726 rel_offset: 1234,
727 length: 5678,
728 entry_count: 12,
729 min_zoom: 0,
730 max_zoom: 14,
731 min_lon_e7: -1_800_000_000,
732 min_lat_e7: -850_000_000,
733 max_lon_e7: 1_800_000_000,
734 max_lat_e7: 850_000_000,
735 t_min: i64::MIN + 1,
736 t_max: i64::MAX - 1,
737 },
738 ];
739 let raw = encode_root(4096, &descs);
740 let root = decode_root(&raw).unwrap();
741 assert_eq!(root.page_entries, 4096);
742 assert_eq!(root.pages, descs);
743 }
744
745 #[test]
746 fn overlaps_selects_the_right_pages() {
747 let a = PageDescriptor {
749 rel_offset: 0,
750 length: 1,
751 entry_count: 1,
752 min_zoom: 10,
753 max_zoom: 10,
754 min_lon_e7: -740_000_000,
755 min_lat_e7: 400_000_000,
756 max_lon_e7: -730_000_000,
757 max_lat_e7: 410_000_000,
758 t_min: 0,
759 t_max: 1000,
760 };
761 let b = PageDescriptor {
762 min_lon_e7: 1_000_000_000,
763 max_lon_e7: 1_010_000_000,
764 min_lat_e7: -100_000_000,
765 max_lat_e7: -90_000_000,
766 t_min: 5000,
767 t_max: 6000,
768 ..a.clone()
769 };
770 assert!(a.overlaps(10, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 0, 1000));
772 assert!(!b.overlaps(10, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 0, 1000));
773 assert!(!a.overlaps(9, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 0, 1000));
775 assert!(!a.overlaps(10, -735_000_000, 405_000_000, -731_000_000, 408_000_000, 2000, 3000));
777 }
778
779 #[test]
780 fn verify_paged_structure_clean_then_detects_corruption() {
781 let c = corpus(true);
782 let enc = encode_paged_directory(&c, 19, true).unwrap();
783 let issues = verify_paged_structure(&enc.bytes, enc.root_length, true).unwrap();
785 assert!(issues.is_empty(), "clean build had issues: {issues:?}");
786
787 let mut sorted = c.clone();
791 sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
792 let root_raw = unframe(&enc.bytes[..enc.root_length as usize], true).unwrap();
793 let mut root = decode_root(&root_raw).unwrap();
794 root.pages[0].min_lon_e7 = 1_790_000_000;
796 root.pages[0].max_lon_e7 = 1_800_000_000;
797 root.pages[1].t_max = i64::MIN;
799 let bad_root = encode_root(root.page_entries, &root.pages);
800 let bad_root_frame = frame(bad_root, true, compression::ZSTD_LEVEL).unwrap();
801 let mut bad = bad_root_frame.clone();
803 bad.extend_from_slice(&enc.bytes[enc.root_length as usize..]);
804 let bad_issues =
805 verify_paged_structure(&bad, bad_root_frame.len() as u64, true).unwrap();
806 assert!(
807 bad_issues.iter().any(|s| s.contains("bbox does not cover")),
808 "expected a bbox-cover violation, got {bad_issues:?}"
809 );
810 assert!(
811 bad_issues.iter().any(|s| s.contains("t-bounds")),
812 "expected a t-bounds violation, got {bad_issues:?}"
813 );
814 }
815
816 #[test]
817 fn truncated_root_errors() {
818 let c = corpus(true);
819 let enc = encode_paged_directory(&c, 50, false).unwrap();
820 assert!(decode_paged_directory(&enc.bytes, enc.bytes.len() as u64 + 1, false).is_err());
822 let mut raw = encode_root(50, &[]);
824 raw[0] = 99;
825 assert!(decode_root(&raw).is_err());
826 }
827}