oxigdal_pmtiles/validate.rs
1//! PMTiles v3 archive validation.
2//!
3//! Provides [`validate_archive`] for a full integrity scan that returns a
4//! [`ValidationReport`] enumerating every issue found, and
5//! [`validate_archive_strict`] which returns `Err` containing all issues when
6//! any are present.
7//!
8//! # Validation passes
9//!
10//! 1. **Magic / parse** — verifies the 7-byte magic and parses the 127-byte
11//! fixed header.
12//! 2. **Version** — spec version must be 3.
13//! 3. **Section bounds** — each section (root dir, metadata, leaf dirs, tile
14//! data) must fit within the reported file size.
15//! 4. **Root directory decode** — the root directory bytes must decode without
16//! error.
17//! 5. **Leaf directory bounds & decode** — every leaf-pointer entry in the
18//! root directory must point within the leaf-dirs section, and each leaf
19//! directory must decode successfully.
20//! 6. **Tile data bounds** — every tile entry (in root and leaves) must point
21//! to a byte range within the tile-data section.
22//! 7. **Monotonic tile IDs** — within each leaf directory the tile IDs must
23//! be strictly non-decreasing.
24//! 8. **Overlapping tile data ranges** — after collecting all tile entries,
25//! their data ranges must not overlap (excluding equal `offset+length`
26//! values that arise from valid deduplication).
27
28use crate::directory::{DirectoryEntry, decode_directory};
29use crate::header::{PMTILES_HEADER_SIZE, PMTILES_MAGIC, PmTilesHeader};
30
31// ---------------------------------------------------------------------------
32// ValidationIssue
33// ---------------------------------------------------------------------------
34
35/// A single integrity issue found during a validation pass over a PMTiles
36/// archive.
37#[derive(Debug, Clone, PartialEq)]
38pub enum ValidationIssue {
39 /// The first 7 bytes do not match the PMTiles magic string, or the header
40 /// could not be parsed at all.
41 HeaderMagicMismatch,
42
43 /// The spec-version byte is not 3.
44 UnsupportedVersion(u8),
45
46 /// The root-directory byte range `[offset, offset + length)` exceeds the
47 /// reported file size.
48 RootDirOutOfBounds {
49 /// Byte offset of the root directory.
50 offset: u64,
51 /// Byte length of the root directory.
52 length: u64,
53 /// Total file size as reported by the data slice.
54 file_size: u64,
55 },
56
57 /// A leaf-directory pointer entry's byte range `[offset, offset + length)`
58 /// within the leaf-dirs section exceeds the leaf-dirs section length.
59 LeafDirOutOfBounds {
60 /// Tile ID of the leaf-pointer directory entry.
61 tile_id: u64,
62 /// Byte offset within the leaf-dirs section.
63 offset: u64,
64 /// Byte length of the leaf directory.
65 length: u64,
66 /// Total length of the leaf-dirs section.
67 file_size: u64,
68 },
69
70 /// A tile entry's data range `[tile_data_offset + offset, + length)`
71 /// exceeds the total file size.
72 TileDataOutOfBounds {
73 /// Tile ID of the offending directory entry.
74 tile_id: u64,
75 /// Byte offset within the tile-data section.
76 offset: u64,
77 /// Byte length of the tile.
78 length: u64,
79 /// Total file size as reported by the data slice.
80 file_size: u64,
81 },
82
83 /// Two tile entries have data ranges that overlap (excluding the case where
84 /// both `offset+length` values are equal, which is valid deduplication).
85 OverlappingTiles {
86 /// Tile ID of the first (earlier offset) entry.
87 tile_id_a: u64,
88 /// Tile ID of the second (later offset) entry.
89 tile_id_b: u64,
90 },
91
92 /// Within a single leaf directory the tile IDs are not non-decreasing.
93 NonMonotonicTileIds {
94 /// Tile ID of the leaf-pointer directory entry whose decoded leaf
95 /// contains non-monotonic IDs.
96 leaf_dir_tile_id: u64,
97 },
98
99 /// The root or a leaf directory could not be decoded.
100 DirectoryDecodeError(String),
101
102 /// A varint in the directory was truncated before its terminal byte.
103 VarintTruncated {
104 /// Approximate file position at which the truncation was detected.
105 at: u64,
106 },
107}
108
109impl std::fmt::Display for ValidationIssue {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 Self::HeaderMagicMismatch => {
113 write!(f, "Header magic mismatch: not a valid PMTiles file")
114 }
115 Self::UnsupportedVersion(v) => {
116 write!(
117 f,
118 "Unsupported PMTiles spec version: {v} (only v3 is supported)"
119 )
120 }
121 Self::RootDirOutOfBounds {
122 offset,
123 length,
124 file_size,
125 } => write!(
126 f,
127 "Root directory [{offset}, {end}) is out of bounds (file size {file_size})",
128 end = offset.saturating_add(*length),
129 ),
130 Self::LeafDirOutOfBounds {
131 tile_id,
132 offset,
133 length,
134 file_size,
135 } => write!(
136 f,
137 "Leaf directory for tile_id={tile_id} [{offset}, {end}) exceeds \
138 leaf-dirs section length {file_size}",
139 end = offset.saturating_add(*length),
140 ),
141 Self::TileDataOutOfBounds {
142 tile_id,
143 offset,
144 length,
145 file_size,
146 } => write!(
147 f,
148 "Tile data for tile_id={tile_id} [{offset}, {end}) is out of bounds \
149 (file size {file_size})",
150 end = offset.saturating_add(*length),
151 ),
152 Self::OverlappingTiles {
153 tile_id_a,
154 tile_id_b,
155 } => write!(
156 f,
157 "Overlapping tile data ranges between tile_id={tile_id_a} and tile_id={tile_id_b}"
158 ),
159 Self::NonMonotonicTileIds { leaf_dir_tile_id } => write!(
160 f,
161 "Non-monotonic tile IDs in leaf directory pointed to by tile_id={leaf_dir_tile_id}"
162 ),
163 Self::DirectoryDecodeError(msg) => {
164 write!(f, "Directory decode error: {msg}")
165 }
166 Self::VarintTruncated { at } => {
167 write!(f, "Truncated varint at approximate byte position {at}")
168 }
169 }
170 }
171}
172
173// ---------------------------------------------------------------------------
174// ValidationReport
175// ---------------------------------------------------------------------------
176
177/// The result of a full validation pass over a PMTiles archive.
178///
179/// `passed` is `true` iff `issues` is empty.
180#[derive(Debug, Clone)]
181pub struct ValidationReport {
182 /// `true` when no issues were found.
183 pub passed: bool,
184 /// All issues discovered during the validation pass.
185 pub issues: Vec<ValidationIssue>,
186}
187
188impl ValidationReport {
189 /// Construct a report for a fully valid archive (no issues).
190 pub fn ok() -> Self {
191 Self {
192 passed: true,
193 issues: vec![],
194 }
195 }
196
197 /// Construct a report from a (potentially empty) list of issues.
198 ///
199 /// `passed` is set to `true` when `issues` is empty.
200 pub fn with_issues(issues: Vec<ValidationIssue>) -> Self {
201 Self {
202 passed: issues.is_empty(),
203 issues,
204 }
205 }
206}
207
208impl std::fmt::Display for ValidationReport {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 if self.passed {
211 write!(f, "PMTiles archive is valid")
212 } else {
213 write!(f, "PMTiles archive has {} issue(s):", self.issues.len())?;
214 for issue in &self.issues {
215 write!(f, "\n - {issue}")?;
216 }
217 Ok(())
218 }
219 }
220}
221
222// ---------------------------------------------------------------------------
223// Internal helpers
224// ---------------------------------------------------------------------------
225
226/// A compact representation of a single tile data range used for overlap
227/// detection.
228#[derive(Debug, Clone)]
229struct TileRange {
230 /// Absolute byte offset within the tile-data section (not file-absolute).
231 offset: u64,
232 /// Byte length of the tile.
233 length: u64,
234 /// Tile ID of the directory entry.
235 tile_id: u64,
236}
237
238/// Check whether the data for `bytes` starts with the PMTiles magic bytes and
239/// has at least [`PMTILES_HEADER_SIZE`] bytes.
240fn has_valid_magic(bytes: &[u8]) -> bool {
241 bytes.len() >= PMTILES_HEADER_SIZE && bytes.starts_with(PMTILES_MAGIC)
242}
243
244/// Verify that `offset + length <= limit`, pushing an issue if not.
245///
246/// Returns `true` when the range is in bounds.
247fn check_section_bounds(
248 offset: u64,
249 length: u64,
250 limit: u64,
251 make_issue: impl FnOnce() -> ValidationIssue,
252 issues: &mut Vec<ValidationIssue>,
253) -> bool {
254 let end = match offset.checked_add(length) {
255 Some(e) => e,
256 None => {
257 // Arithmetic overflow — definitely out of bounds.
258 issues.push(make_issue());
259 return false;
260 }
261 };
262 if end > limit {
263 issues.push(make_issue());
264 false
265 } else {
266 true
267 }
268}
269
270/// Decode the root directory from its (uncompressed) raw bytes, pushing a
271/// [`ValidationIssue::DirectoryDecodeError`] on failure.
272///
273/// Returns `Some(entries)` on success, `None` on failure.
274fn try_decode_directory(
275 bytes: &[u8],
276 issues: &mut Vec<ValidationIssue>,
277) -> Option<Vec<DirectoryEntry>> {
278 match decode_directory(bytes) {
279 Ok(entries) => Some(entries),
280 Err(e) => {
281 issues.push(ValidationIssue::DirectoryDecodeError(e.to_string()));
282 None
283 }
284 }
285}
286
287/// Check that tile IDs within a single slice of entries are non-decreasing.
288///
289/// Returns `true` when monotonic, `false` when a violation is found (an issue
290/// is pushed).
291fn check_monotonic_ids(
292 entries: &[DirectoryEntry],
293 leaf_ptr_tile_id: u64,
294 issues: &mut Vec<ValidationIssue>,
295) -> bool {
296 let mut last: u64 = 0;
297 let mut first = true;
298 for entry in entries {
299 if !first && entry.tile_id < last {
300 issues.push(ValidationIssue::NonMonotonicTileIds {
301 leaf_dir_tile_id: leaf_ptr_tile_id,
302 });
303 return false;
304 }
305 last = entry.tile_id;
306 first = false;
307 }
308 true
309}
310
311/// Validate the tile data ranges collected from all tile entries for overlaps.
312///
313/// Ranges with `offset_a + length_a == offset_b` are valid (adjacent), and
314/// ranges sharing the exact same `(offset, length)` pair are valid
315/// (deduplicated identical tile content). Only cases where one range partially
316/// intrudes into another are flagged.
317fn check_overlapping_ranges(ranges: &mut [TileRange], issues: &mut Vec<ValidationIssue>) {
318 if ranges.len() < 2 {
319 return;
320 }
321
322 // Sort by (offset, length) so equal-range pairs are adjacent and we can
323 // detect them easily.
324 ranges.sort_unstable_by(|a, b| a.offset.cmp(&b.offset).then(a.length.cmp(&b.length)));
325
326 let mut i = 0usize;
327 while i < ranges.len() {
328 let current = &ranges[i];
329 let current_end = match current.offset.checked_add(current.length) {
330 Some(e) => e,
331 None => {
332 // Arithmetic overflow — can't sensibly check further.
333 i += 1;
334 continue;
335 }
336 };
337
338 let j = i + 1;
339 if j >= ranges.len() {
340 break;
341 }
342 let next = &ranges[j];
343
344 // Skip over ranges that share the exact same (offset, length) — these
345 // are deduplicated tile data (valid by spec).
346 if next.offset == current.offset && next.length == current.length {
347 i += 1;
348 continue;
349 }
350
351 // A real overlap occurs when the next range starts before the current
352 // one ends.
353 if next.offset < current_end {
354 issues.push(ValidationIssue::OverlappingTiles {
355 tile_id_a: current.tile_id,
356 tile_id_b: next.tile_id,
357 });
358 }
359
360 i += 1;
361 }
362}
363
364// ---------------------------------------------------------------------------
365// Public API
366// ---------------------------------------------------------------------------
367
368/// Perform a non-strict validation pass over a PMTiles v3 archive.
369///
370/// Every issue found is recorded in the returned [`ValidationReport`]; the
371/// function does not short-circuit on the first problem (except when the header
372/// is completely unreadable, since later checks depend on the parsed header).
373///
374/// # Short-circuit behaviour
375/// When the magic bytes are wrong or the header cannot be parsed, a single
376/// [`ValidationIssue::HeaderMagicMismatch`] is returned immediately because
377/// all subsequent checks depend on the parsed header fields.
378///
379/// # Compression
380/// Directories are expected to be uncompressed (i.e. `internal_compression ==
381/// None`). Archives with compressed directories will have their directory data
382/// decoded as-is; if that fails, a [`ValidationIssue::DirectoryDecodeError`]
383/// is emitted. For proper compressed-directory validation, decompress first
384/// before calling this function.
385pub fn validate_archive(bytes: &[u8]) -> ValidationReport {
386 let mut issues: Vec<ValidationIssue> = Vec::new();
387 let file_size = bytes.len() as u64;
388
389 // ── Pass 1: magic + header parse ─────────────────────────────────────────
390 if !has_valid_magic(bytes) {
391 return ValidationReport::with_issues(vec![ValidationIssue::HeaderMagicMismatch]);
392 }
393
394 // At this point we know the magic is correct; attempt a full header parse.
395 // Header::parse also checks the version, so capture that separately below.
396 let header = match PmTilesHeader::parse(bytes) {
397 Ok(h) => h,
398 Err(e) => {
399 // Determine whether this is a version error or a format error.
400 let issue = if bytes.len() >= 8 && bytes[7] != 3 {
401 ValidationIssue::UnsupportedVersion(bytes[7])
402 } else {
403 // Treat parse errors as a magic / format mismatch for simplicity.
404 ValidationIssue::DirectoryDecodeError(e.to_string())
405 };
406 return ValidationReport::with_issues(vec![issue]);
407 }
408 };
409
410 // ── Pass 2: version ───────────────────────────────────────────────────────
411 if header.spec_version != 3 {
412 issues.push(ValidationIssue::UnsupportedVersion(header.spec_version));
413 // Continue — we can still check section bounds.
414 }
415
416 // ── Pass 3: section bounds ────────────────────────────────────────────────
417 // Root directory.
418 let root_in_bounds = check_section_bounds(
419 header.root_dir_offset,
420 header.root_dir_length,
421 file_size,
422 || ValidationIssue::RootDirOutOfBounds {
423 offset: header.root_dir_offset,
424 length: header.root_dir_length,
425 file_size,
426 },
427 &mut issues,
428 );
429
430 // Metadata.
431 check_section_bounds(
432 header.metadata_offset,
433 header.metadata_length,
434 file_size,
435 || {
436 // Metadata doesn't have a dedicated variant; reuse TileDataOutOfBounds
437 // with tile_id=u64::MAX as a sentinel isn't ideal, so we emit a
438 // DirectoryDecodeError with a descriptive message instead.
439 ValidationIssue::DirectoryDecodeError(format!(
440 "Metadata section [{}, {}) is out of bounds (file size {file_size})",
441 header.metadata_offset,
442 header
443 .metadata_offset
444 .saturating_add(header.metadata_length),
445 ))
446 },
447 &mut issues,
448 );
449
450 // Leaf dirs section.
451 let leaf_section_in_bounds = check_section_bounds(
452 header.leaf_dirs_offset,
453 header.leaf_dirs_length,
454 file_size,
455 || {
456 ValidationIssue::DirectoryDecodeError(format!(
457 "Leaf-dirs section [{}, {}) is out of bounds (file size {file_size})",
458 header.leaf_dirs_offset,
459 header
460 .leaf_dirs_offset
461 .saturating_add(header.leaf_dirs_length),
462 ))
463 },
464 &mut issues,
465 );
466
467 // Tile data section.
468 check_section_bounds(
469 header.tile_data_offset,
470 header.tile_data_length,
471 file_size,
472 || ValidationIssue::TileDataOutOfBounds {
473 // No specific tile_id for the section-level check.
474 tile_id: 0,
475 offset: header.tile_data_offset,
476 length: header.tile_data_length,
477 file_size,
478 },
479 &mut issues,
480 );
481
482 // If the root directory section itself is out of bounds, we cannot decode
483 // the directory at all, so stop here.
484 if !root_in_bounds {
485 return ValidationReport::with_issues(issues);
486 }
487
488 // ── Pass 4: root directory decode ─────────────────────────────────────────
489 let root_start = header.root_dir_offset as usize;
490 let root_end = root_start + header.root_dir_length as usize;
491 let root_bytes = &bytes[root_start..root_end];
492
493 let root_entries = match try_decode_directory(root_bytes, &mut issues) {
494 Some(e) => e,
495 None => {
496 // Cannot proceed with tile/leaf checks.
497 return ValidationReport::with_issues(issues);
498 }
499 };
500
501 // Accumulate all tile ranges for overlap detection.
502 let mut all_tile_ranges: Vec<TileRange> = Vec::new();
503
504 // ── Pass 5: leaf directory bounds, decode, and tile bounds ────────────────
505 for root_entry in &root_entries {
506 if root_entry.is_leaf_directory() {
507 // Validate the leaf pointer's byte range within the leaf-dirs section.
508 let leaf_offset = root_entry.offset;
509 let leaf_length = u64::from(root_entry.length);
510
511 let leaf_range_ok = if leaf_section_in_bounds {
512 check_section_bounds(
513 leaf_offset,
514 leaf_length,
515 header.leaf_dirs_length,
516 || ValidationIssue::LeafDirOutOfBounds {
517 tile_id: root_entry.tile_id,
518 offset: leaf_offset,
519 length: leaf_length,
520 file_size: header.leaf_dirs_length,
521 },
522 &mut issues,
523 )
524 } else {
525 // Leaf section itself is out of bounds — skip individual leaf checks.
526 false
527 };
528
529 if !leaf_range_ok {
530 continue;
531 }
532
533 // Decode the leaf directory.
534 let abs_leaf_start = (header.leaf_dirs_offset + leaf_offset) as usize;
535 let abs_leaf_end = abs_leaf_start + root_entry.length as usize;
536
537 // Guard against the file being smaller than we expect (should have
538 // been caught above, but be defensive).
539 if abs_leaf_end > bytes.len() {
540 issues.push(ValidationIssue::LeafDirOutOfBounds {
541 tile_id: root_entry.tile_id,
542 offset: leaf_offset,
543 length: leaf_length,
544 file_size: header.leaf_dirs_length,
545 });
546 continue;
547 }
548
549 let leaf_bytes = &bytes[abs_leaf_start..abs_leaf_end];
550 let leaf_entries = match try_decode_directory(leaf_bytes, &mut issues) {
551 Some(e) => e,
552 None => continue,
553 };
554
555 // Check monotonicity of tile IDs within this leaf.
556 check_monotonic_ids(&leaf_entries, root_entry.tile_id, &mut issues);
557
558 // Validate each tile entry inside the leaf.
559 for leaf_entry in &leaf_entries {
560 if !leaf_entry.is_tile() {
561 // Nested leaf pointers are not supported in PMTiles v3;
562 // just skip without flagging here (the reader will error
563 // on access).
564 continue;
565 }
566
567 let tile_abs_offset = header.tile_data_offset.saturating_add(leaf_entry.offset);
568 let tile_length = u64::from(leaf_entry.length);
569
570 let tile_in_bounds = check_section_bounds(
571 tile_abs_offset,
572 tile_length,
573 file_size,
574 || ValidationIssue::TileDataOutOfBounds {
575 tile_id: leaf_entry.tile_id,
576 offset: tile_abs_offset,
577 length: tile_length,
578 file_size,
579 },
580 &mut issues,
581 );
582
583 if tile_in_bounds {
584 all_tile_ranges.push(TileRange {
585 offset: leaf_entry.offset,
586 length: tile_length,
587 tile_id: leaf_entry.tile_id,
588 });
589 }
590 }
591 } else {
592 // Tile entry in root directory — validate data range.
593 let tile_abs_offset = header.tile_data_offset.saturating_add(root_entry.offset);
594 let tile_length = u64::from(root_entry.length);
595
596 let tile_in_bounds = check_section_bounds(
597 tile_abs_offset,
598 tile_length,
599 file_size,
600 || ValidationIssue::TileDataOutOfBounds {
601 tile_id: root_entry.tile_id,
602 offset: tile_abs_offset,
603 length: tile_length,
604 file_size,
605 },
606 &mut issues,
607 );
608
609 if tile_in_bounds {
610 all_tile_ranges.push(TileRange {
611 offset: root_entry.offset,
612 length: tile_length,
613 tile_id: root_entry.tile_id,
614 });
615 }
616 }
617 }
618
619 // ── Pass 6: monotonicity in root (when no leaves) ─────────────────────────
620 // For archives without leaf directories, also verify root tile-ID order.
621 if header.leaf_dirs_length == 0 {
622 // We reuse 0 as the "root" leaf_dir_tile_id sentinel.
623 check_monotonic_ids(&root_entries, 0, &mut issues);
624 }
625
626 // ── Pass 7: overlapping tile data ranges ──────────────────────────────────
627 check_overlapping_ranges(&mut all_tile_ranges, &mut issues);
628
629 ValidationReport::with_issues(issues)
630}
631
632/// Strict validation: returns `Ok(())` when the archive is valid, or
633/// `Err(issues)` containing every discovered issue when it is not.
634///
635/// Internally delegates to [`validate_archive`].
636pub fn validate_archive_strict(bytes: &[u8]) -> Result<(), Vec<ValidationIssue>> {
637 let report = validate_archive(bytes);
638 if report.passed {
639 Ok(())
640 } else {
641 Err(report.issues)
642 }
643}
644
645// ---------------------------------------------------------------------------
646// Unit tests (inline)
647// ---------------------------------------------------------------------------
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 #[test]
654 fn test_validate_report_ok_constructor() {
655 let r = ValidationReport::ok();
656 assert!(r.passed);
657 assert!(r.issues.is_empty());
658 }
659
660 #[test]
661 fn test_validate_report_with_empty_issues_passes() {
662 let r = ValidationReport::with_issues(vec![]);
663 assert!(r.passed);
664 }
665
666 #[test]
667 fn test_validate_report_with_issue_fails() {
668 let r = ValidationReport::with_issues(vec![ValidationIssue::HeaderMagicMismatch]);
669 assert!(!r.passed);
670 assert_eq!(r.issues.len(), 1);
671 }
672
673 #[test]
674 fn test_issue_display_header_magic() {
675 let s = format!("{}", ValidationIssue::HeaderMagicMismatch);
676 assert!(s.contains("magic") || s.contains("PMTiles"));
677 }
678
679 #[test]
680 fn test_issue_display_unsupported_version() {
681 let s = format!("{}", ValidationIssue::UnsupportedVersion(2));
682 assert!(s.contains('2'));
683 }
684
685 #[test]
686 fn test_check_section_bounds_ok() {
687 let mut issues = Vec::new();
688 let in_bounds = check_section_bounds(
689 0,
690 100,
691 200,
692 || ValidationIssue::HeaderMagicMismatch,
693 &mut issues,
694 );
695 assert!(in_bounds);
696 assert!(issues.is_empty());
697 }
698
699 #[test]
700 fn test_check_section_bounds_exact_edge() {
701 let mut issues = Vec::new();
702 let in_bounds = check_section_bounds(
703 100,
704 100,
705 200,
706 || ValidationIssue::HeaderMagicMismatch,
707 &mut issues,
708 );
709 assert!(in_bounds);
710 assert!(issues.is_empty());
711 }
712
713 #[test]
714 fn test_check_section_bounds_out_of_bounds() {
715 let mut issues = Vec::new();
716 let in_bounds = check_section_bounds(
717 150,
718 100,
719 200,
720 || ValidationIssue::HeaderMagicMismatch,
721 &mut issues,
722 );
723 assert!(!in_bounds);
724 assert_eq!(issues.len(), 1);
725 }
726
727 #[test]
728 fn test_check_monotonic_ids_ok() {
729 let entries = vec![
730 DirectoryEntry {
731 tile_id: 1,
732 offset: 0,
733 length: 10,
734 run_length: 1,
735 },
736 DirectoryEntry {
737 tile_id: 5,
738 offset: 10,
739 length: 10,
740 run_length: 1,
741 },
742 DirectoryEntry {
743 tile_id: 5,
744 offset: 20,
745 length: 10,
746 run_length: 1,
747 }, // equal is ok
748 DirectoryEntry {
749 tile_id: 9,
750 offset: 30,
751 length: 10,
752 run_length: 1,
753 },
754 ];
755 let mut issues = Vec::new();
756 let ok = check_monotonic_ids(&entries, 0, &mut issues);
757 assert!(ok);
758 assert!(issues.is_empty());
759 }
760
761 #[test]
762 fn test_check_monotonic_ids_violation() {
763 let entries = vec![
764 DirectoryEntry {
765 tile_id: 5,
766 offset: 0,
767 length: 10,
768 run_length: 1,
769 },
770 DirectoryEntry {
771 tile_id: 3,
772 offset: 10,
773 length: 10,
774 run_length: 1,
775 }, // 3 < 5 → violation
776 ];
777 let mut issues = Vec::new();
778 let ok = check_monotonic_ids(&entries, 42, &mut issues);
779 assert!(!ok);
780 assert_eq!(issues.len(), 1);
781 assert!(matches!(
782 &issues[0],
783 ValidationIssue::NonMonotonicTileIds {
784 leaf_dir_tile_id: 42
785 }
786 ));
787 }
788
789 #[test]
790 fn test_check_overlapping_ranges_no_overlap() {
791 let mut ranges = vec![
792 TileRange {
793 offset: 0,
794 length: 10,
795 tile_id: 1,
796 },
797 TileRange {
798 offset: 10,
799 length: 20,
800 tile_id: 2,
801 },
802 TileRange {
803 offset: 30,
804 length: 5,
805 tile_id: 3,
806 },
807 ];
808 let mut issues = Vec::new();
809 check_overlapping_ranges(&mut ranges, &mut issues);
810 assert!(issues.is_empty());
811 }
812
813 #[test]
814 fn test_check_overlapping_ranges_with_overlap() {
815 let mut ranges = vec![
816 TileRange {
817 offset: 0,
818 length: 15,
819 tile_id: 1,
820 },
821 TileRange {
822 offset: 10,
823 length: 10,
824 tile_id: 2,
825 }, // starts before offset 15
826 ];
827 let mut issues = Vec::new();
828 check_overlapping_ranges(&mut ranges, &mut issues);
829 assert_eq!(issues.len(), 1);
830 assert!(matches!(
831 &issues[0],
832 ValidationIssue::OverlappingTiles { .. }
833 ));
834 }
835
836 #[test]
837 fn test_check_overlapping_ranges_dedup_same_range() {
838 // Two entries pointing to the exact same range — deduplication, not an overlap.
839 let mut ranges = vec![
840 TileRange {
841 offset: 0,
842 length: 10,
843 tile_id: 1,
844 },
845 TileRange {
846 offset: 0,
847 length: 10,
848 tile_id: 2,
849 },
850 ];
851 let mut issues = Vec::new();
852 check_overlapping_ranges(&mut ranges, &mut issues);
853 assert!(
854 issues.is_empty(),
855 "identical ranges (dedup) should not be flagged"
856 );
857 }
858}