Skip to main content

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}