Skip to main content

oxigeo_pmtiles/
diff.rs

1//! Tile-set diff between two PMTiles archives.
2//!
3//! Compares two in-memory PMTiles v3 archives and reports per-tile
4//! changes — tiles added, removed, or content-changed. A 64-bit
5//! [FNV-1a](https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function)
6//! non-cryptographic hash (plus byte-length) is used only as a cheap
7//! *pre-filter*; a tile is never classified as "unchanged" on a hash match
8//! alone. Every hash-and-length hit is followed by a full raw-byte
9//! comparison before being trusted, matching the same guarantee
10//! [`crate::writer::PmTilesBuilder`]'s dedup path enforces for its own hash
11//! hits — a deliberately constructed FNV-1a collision between two distinct
12//! payloads can never be misreported as "unchanged".
13//!
14//! The diff is purely structural: tiles are identified by their PMTiles v3
15//! Hilbert-curve tile ID, and content-changed tiles are detected by
16//! byte-comparing their decompressed-as-stored (raw) payloads.
17//! Two tiles with identical bytes are treated as unchanged even when the
18//! source archives differ in unrelated layout (offsets, dedup runs, …).
19//!
20//! # Output stability
21//!
22//! Each `Vec` field of [`DiffReport`] is sorted by ascending `tile_id` so
23//! that the output is deterministic and suitable for direct comparison
24//! across runs.
25//!
26//! # Example
27//!
28//! ```no_run
29//! use oxigeo_pmtiles::{PmTilesBuilder, TileType, diff_archives};
30//!
31//! let mut old = PmTilesBuilder::new(TileType::Png, 0, 0);
32//! old.add_tile(0, 0, 0, b"v1").unwrap();
33//! let old_bytes = old.build().unwrap();
34//!
35//! let mut new = PmTilesBuilder::new(TileType::Png, 0, 0);
36//! new.add_tile(0, 0, 0, b"v2").unwrap();
37//! let new_bytes = new.build().unwrap();
38//!
39//! let report = diff_archives(&old_bytes, &new_bytes).unwrap();
40//! assert_eq!(report.added.len(), 0);
41//! assert_eq!(report.removed.len(), 0);
42//! assert_eq!(report.changed.len(), 1);
43//! ```
44
45use std::collections::HashMap;
46
47use crate::error::PmTilesError;
48use crate::pmtiles::PmTilesReader;
49
50// ---------------------------------------------------------------------------
51// Public types
52// ---------------------------------------------------------------------------
53
54/// A single per-tile change reported by [`diff_archives`].
55///
56/// Every variant carries the PMTiles v3 `tile_id` along with the resolved
57/// `(z, x, y)` coordinates and the relevant byte size(s).  This is enough to
58/// drive most downstream tooling (CDN purge lists, sync utilities, audit
59/// logs) without requiring access to the original archives.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum TileChange {
62    /// Tile present in the new archive but absent in the old archive.
63    Added {
64        /// PMTiles v3 Hilbert-curve tile ID.
65        tile_id: u64,
66        /// Zoom level resolved from the tile ID.
67        z: u8,
68        /// Tile column resolved from the tile ID.
69        x: u32,
70        /// Tile row resolved from the tile ID.
71        y: u32,
72        /// Byte length of the tile payload in the new archive.
73        new_bytes: usize,
74    },
75    /// Tile present in the old archive but absent in the new archive.
76    Removed {
77        /// PMTiles v3 Hilbert-curve tile ID.
78        tile_id: u64,
79        /// Zoom level resolved from the tile ID.
80        z: u8,
81        /// Tile column resolved from the tile ID.
82        x: u32,
83        /// Tile row resolved from the tile ID.
84        y: u32,
85        /// Byte length of the tile payload in the old archive.
86        old_bytes: usize,
87    },
88    /// Tile present in both archives with different content.
89    Changed {
90        /// PMTiles v3 Hilbert-curve tile ID.
91        tile_id: u64,
92        /// Zoom level resolved from the tile ID.
93        z: u8,
94        /// Tile column resolved from the tile ID.
95        x: u32,
96        /// Tile row resolved from the tile ID.
97        y: u32,
98        /// Byte length of the tile payload in the old archive.
99        old_bytes: usize,
100        /// Byte length of the tile payload in the new archive.
101        new_bytes: usize,
102    },
103}
104
105impl TileChange {
106    /// PMTiles v3 tile ID of the changed tile, regardless of variant.
107    pub fn tile_id(&self) -> u64 {
108        match self {
109            Self::Added { tile_id, .. }
110            | Self::Removed { tile_id, .. }
111            | Self::Changed { tile_id, .. } => *tile_id,
112        }
113    }
114
115    /// Resolved zoom level of the changed tile.
116    pub fn zoom(&self) -> u8 {
117        match self {
118            Self::Added { z, .. } | Self::Removed { z, .. } | Self::Changed { z, .. } => *z,
119        }
120    }
121
122    /// Resolved `(x, y)` tile column/row.
123    pub fn xy(&self) -> (u32, u32) {
124        match self {
125            Self::Added { x, y, .. } | Self::Removed { x, y, .. } | Self::Changed { x, y, .. } => {
126                (*x, *y)
127            }
128        }
129    }
130}
131
132/// Full per-tile diff between two PMTiles archives.
133///
134/// Each `Vec` is sorted by ascending `tile_id`.  `unchanged_count` counts
135/// tiles present in both archives whose FNV-1a content hashes match.
136#[derive(Debug, Clone, Default)]
137pub struct DiffReport {
138    /// Tiles present in the new archive but absent in the old archive.
139    pub added: Vec<TileChange>,
140    /// Tiles present in the old archive but absent in the new archive.
141    pub removed: Vec<TileChange>,
142    /// Tiles present in both with differing FNV-1a content hashes.
143    pub changed: Vec<TileChange>,
144    /// Tiles present in both with identical FNV-1a content hashes.
145    pub unchanged_count: u64,
146}
147
148impl DiffReport {
149    /// Total number of changes (added + removed + changed).
150    pub fn total_changes(&self) -> usize {
151        self.added.len() + self.removed.len() + self.changed.len()
152    }
153
154    /// Sum of byte lengths of all tiles in the [`Self::added`] list.
155    pub fn total_added_bytes(&self) -> usize {
156        self.added
157            .iter()
158            .map(|c| match c {
159                TileChange::Added { new_bytes, .. } => *new_bytes,
160                _ => 0,
161            })
162            .sum()
163    }
164
165    /// Sum of byte lengths of all tiles in the [`Self::removed`] list.
166    pub fn total_removed_bytes(&self) -> usize {
167        self.removed
168            .iter()
169            .map(|c| match c {
170                TileChange::Removed { old_bytes, .. } => *old_bytes,
171                _ => 0,
172            })
173            .sum()
174    }
175
176    /// Net byte delta of changed tiles (new total minus old total).
177    ///
178    /// A positive value means changed tiles grew on average; a negative
179    /// value means they shrank.
180    pub fn changed_byte_delta(&self) -> i64 {
181        self.changed
182            .iter()
183            .map(|c| match c {
184                TileChange::Changed {
185                    old_bytes,
186                    new_bytes,
187                    ..
188                } => *new_bytes as i64 - *old_bytes as i64,
189                _ => 0,
190            })
191            .sum()
192    }
193
194    /// Returns `true` when there are no added, removed, or changed tiles.
195    pub fn is_empty(&self) -> bool {
196        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
197    }
198}
199
200/// Compact summary of a [`DiffReport`] (counts only).
201///
202/// Useful when callers only need a tally of changes without the per-tile
203/// detail (e.g. for periodic monitoring or alerting).
204#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
205pub struct DiffSummary {
206    /// Number of tiles in the new archive that are absent from the old.
207    pub added: u64,
208    /// Number of tiles in the old archive that are absent from the new.
209    pub removed: u64,
210    /// Number of tiles present in both with differing content.
211    pub changed: u64,
212    /// Number of tiles present in both with identical content.
213    pub unchanged: u64,
214}
215
216impl DiffSummary {
217    /// Total number of changes (added + removed + changed).
218    pub fn total_changes(&self) -> u64 {
219        self.added + self.removed + self.changed
220    }
221
222    /// Total number of tiles considered (changes + unchanged).
223    pub fn total_tiles(&self) -> u64 {
224        self.total_changes() + self.unchanged
225    }
226}
227
228// ---------------------------------------------------------------------------
229// FNV-1a 64-bit content hash
230// ---------------------------------------------------------------------------
231
232/// FNV-1a 64-bit hash used for cheap tile-content comparison.
233///
234/// This matches the dedup hash used by [`crate::writer::PmTilesBuilder`] so
235/// that round-tripping an archive through a writer does not falsely report
236/// tiles as changed.
237///
238/// FNV-1a is non-cryptographic; collisions are theoretically possible but
239/// astronomically unlikely for tile-sized payloads.  This is acceptable for
240/// the diff use case (change detection, not security).
241fn fnv1a_64(data: &[u8]) -> u64 {
242    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
243    const FNV_PRIME: u64 = 0x0100_0000_01b3;
244    let mut hash = FNV_OFFSET;
245    for &byte in data {
246        hash ^= u64::from(byte);
247        hash = hash.wrapping_mul(FNV_PRIME);
248    }
249    hash
250}
251
252// ---------------------------------------------------------------------------
253// Index building
254// ---------------------------------------------------------------------------
255
256/// Compact per-tile record kept while building the diff index.
257///
258/// `content_hash` (FNV-1a, non-cryptographic) is only a cheap *filter*: two
259/// tiles are never classified as "unchanged" on the strength of a matching
260/// hash alone. [`diff_archives`] always follows up a hash match with an
261/// actual `bytes` comparison — mirroring the same safety bar
262/// [`crate::writer::PmTilesBuilder`]'s dedup path enforces ("a hash collision
263/// can never cause two distinct payloads to be merged/treated as identical").
264#[derive(Debug, Clone)]
265struct IndexedTile {
266    z: u8,
267    x: u32,
268    y: u32,
269    content_hash: u64,
270    byte_size: usize,
271    /// Full raw tile payload, kept so a hash match can be byte-verified
272    /// without a second fetch from the reader.
273    bytes: Vec<u8>,
274}
275
276/// Build a `tile_id → IndexedTile` map from a parsed reader.
277///
278/// Tiles whose directory entry exists but whose payload cannot be retrieved
279/// surface a [`PmTilesError::InvalidArchive`] so that diff results are not
280/// silently incorrect.
281fn build_index(reader: &PmTilesReader) -> Result<HashMap<u64, IndexedTile>, PmTilesError> {
282    let infos = reader.enumerate_tiles()?;
283    let mut map: HashMap<u64, IndexedTile> = HashMap::with_capacity(infos.len());
284
285    for info in infos {
286        let bytes = reader.get_tile(info.z, info.x, info.y)?.ok_or_else(|| {
287            PmTilesError::InvalidArchive(format!(
288                "tile_id {} (z={}, x={}, y={}) listed in directory but payload missing",
289                info.tile_id, info.z, info.x, info.y
290            ))
291        })?;
292        map.insert(
293            info.tile_id,
294            IndexedTile {
295                z: info.z,
296                x: info.x,
297                y: info.y,
298                content_hash: fnv1a_64(&bytes),
299                byte_size: bytes.len(),
300                bytes,
301            },
302        );
303    }
304
305    Ok(map)
306}
307
308/// Decide whether two tiles are truly identical.
309///
310/// The FNV-1a `content_hash` (and `byte_size`) are only a cheap pre-filter —
311/// a matching hash and length can *never*, on their own, classify two tiles
312/// as unchanged. This function always follows up with a full raw-byte
313/// comparison, so a deliberately constructed FNV-1a collision between two
314/// distinct same-length payloads is still correctly reported as `Changed`,
315/// matching the same guarantee [`crate::writer::PmTilesBuilder`]'s
316/// `find_verified_dedup_match` enforces for its own hash hits.
317fn tiles_are_identical(a: &IndexedTile, b: &IndexedTile) -> bool {
318    a.content_hash == b.content_hash && a.byte_size == b.byte_size && a.bytes == b.bytes
319}
320
321// ---------------------------------------------------------------------------
322// Public diff functions
323// ---------------------------------------------------------------------------
324
325/// Compare two PMTiles archives by raw bytes and return a full diff report.
326///
327/// Both inputs must be valid PMTiles v3 archives parsable by
328/// [`PmTilesReader::from_bytes`].  Tile content is compared using the raw
329/// (possibly compressed) payload bytes — this matches the byte stream the
330/// underlying storage actually holds and is consistent with the writer's
331/// dedup behaviour.
332///
333/// # Errors
334/// Propagates any [`PmTilesError`] from header parsing, directory decoding,
335/// or tile extraction.  An [`PmTilesError::InvalidArchive`] is returned when
336/// a tile listed in the directory cannot be retrieved.
337pub fn diff_archives(old_bytes: &[u8], new_bytes: &[u8]) -> Result<DiffReport, PmTilesError> {
338    let old_reader = PmTilesReader::from_bytes(old_bytes.to_vec())?;
339    let new_reader = PmTilesReader::from_bytes(new_bytes.to_vec())?;
340
341    let old_index = build_index(&old_reader)?;
342    let new_index = build_index(&new_reader)?;
343
344    let mut report = DiffReport::default();
345
346    // Removed: tiles in old but not in new.
347    for (&tile_id, tile) in &old_index {
348        if !new_index.contains_key(&tile_id) {
349            report.removed.push(TileChange::Removed {
350                tile_id,
351                z: tile.z,
352                x: tile.x,
353                y: tile.y,
354                old_bytes: tile.byte_size,
355            });
356        }
357    }
358
359    // Added + Changed + Unchanged: iterate new and look up old.
360    for (&tile_id, new_tile) in &new_index {
361        match old_index.get(&tile_id) {
362            None => report.added.push(TileChange::Added {
363                tile_id,
364                z: new_tile.z,
365                x: new_tile.x,
366                y: new_tile.y,
367                new_bytes: new_tile.byte_size,
368            }),
369            Some(old_tile) => {
370                if tiles_are_identical(old_tile, new_tile) {
371                    report.unchanged_count += 1;
372                } else {
373                    report.changed.push(TileChange::Changed {
374                        tile_id,
375                        z: new_tile.z,
376                        x: new_tile.x,
377                        y: new_tile.y,
378                        old_bytes: old_tile.byte_size,
379                        new_bytes: new_tile.byte_size,
380                    });
381                }
382            }
383        }
384    }
385
386    // Deterministic ordering for stable output and reproducible tests.
387    report.added.sort_by_key(TileChange::tile_id);
388    report.removed.sort_by_key(TileChange::tile_id);
389    report.changed.sort_by_key(TileChange::tile_id);
390
391    Ok(report)
392}
393
394/// Compare two PMTiles archives and return only the summary counts.
395///
396/// Convenience wrapper around [`diff_archives`] that drops the per-tile
397/// detail.  Has the same error semantics as [`diff_archives`].
398///
399/// # Errors
400/// Propagates errors from [`diff_archives`].
401pub fn diff_archives_summary(
402    old_bytes: &[u8],
403    new_bytes: &[u8],
404) -> Result<DiffSummary, PmTilesError> {
405    let report = diff_archives(old_bytes, new_bytes)?;
406    Ok(DiffSummary {
407        added: report.added.len() as u64,
408        removed: report.removed.len() as u64,
409        changed: report.changed.len() as u64,
410        unchanged: report.unchanged_count,
411    })
412}
413
414// ---------------------------------------------------------------------------
415// Unit tests
416// ---------------------------------------------------------------------------
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_fnv1a_64_known_offset_for_empty_input() {
424        // Standard FNV-1a 64 spec: empty input → FNV_OFFSET.
425        assert_eq!(fnv1a_64(b""), 0xcbf2_9ce4_8422_2325);
426    }
427
428    #[test]
429    fn test_fnv1a_64_distinguishes_different_inputs() {
430        assert_ne!(fnv1a_64(b"hello"), fnv1a_64(b"world"));
431    }
432
433    #[test]
434    fn test_fnv1a_64_is_deterministic() {
435        assert_eq!(fnv1a_64(b"oxigeo"), fnv1a_64(b"oxigeo"));
436    }
437
438    /// Regression test for a real defect: `diff_archives` used to classify
439    /// two tiles as "unchanged" based purely on `content_hash` equality, with
440    /// no byte-level fallback — unlike the writer's dedup path, which never
441    /// trusts a hash hit alone. This constructs a *manufactured* hash
442    /// collision (same `content_hash`, same `byte_size`, genuinely different
443    /// `bytes`) — the exact scenario a real FNV-1a collision would produce —
444    /// and confirms `tiles_are_identical` still reports them as different.
445    #[test]
446    fn tiles_are_identical_rejects_hash_collision_with_different_bytes() {
447        let a = IndexedTile {
448            z: 0,
449            x: 0,
450            y: 0,
451            content_hash: 0xDEAD_BEEF_0000_0001, // manufactured shared hash
452            byte_size: 4,
453            bytes: vec![1, 2, 3, 4],
454        };
455        let b = IndexedTile {
456            z: 0,
457            x: 0,
458            y: 0,
459            content_hash: 0xDEAD_BEEF_0000_0001, // same hash as `a`
460            byte_size: 4,                        // same length as `a`
461            bytes: vec![9, 9, 9, 9],             // genuinely different payload
462        };
463
464        assert!(
465            !tiles_are_identical(&a, &b),
466            "a hash+length match must never be trusted without a real byte comparison"
467        );
468    }
469
470    #[test]
471    fn tiles_are_identical_accepts_true_match() {
472        let a = IndexedTile {
473            z: 1,
474            x: 2,
475            y: 3,
476            content_hash: 42,
477            byte_size: 3,
478            bytes: vec![7, 8, 9],
479        };
480        let b = a.clone();
481        assert!(tiles_are_identical(&a, &b));
482    }
483
484    #[test]
485    fn test_diff_report_default_is_empty() {
486        let r = DiffReport::default();
487        assert!(r.is_empty());
488        assert_eq!(r.total_changes(), 0);
489        assert_eq!(r.total_added_bytes(), 0);
490        assert_eq!(r.total_removed_bytes(), 0);
491        assert_eq!(r.changed_byte_delta(), 0);
492    }
493
494    #[test]
495    fn test_diff_summary_aggregates_counts() {
496        let s = DiffSummary {
497            added: 1,
498            removed: 2,
499            changed: 3,
500            unchanged: 4,
501        };
502        assert_eq!(s.total_changes(), 6);
503        assert_eq!(s.total_tiles(), 10);
504    }
505
506    #[test]
507    fn test_tile_change_accessors() {
508        let added = TileChange::Added {
509            tile_id: 7,
510            z: 2,
511            x: 1,
512            y: 1,
513            new_bytes: 32,
514        };
515        assert_eq!(added.tile_id(), 7);
516        assert_eq!(added.zoom(), 2);
517        assert_eq!(added.xy(), (1, 1));
518
519        let removed = TileChange::Removed {
520            tile_id: 11,
521            z: 3,
522            x: 4,
523            y: 5,
524            old_bytes: 100,
525        };
526        assert_eq!(removed.tile_id(), 11);
527        assert_eq!(removed.zoom(), 3);
528        assert_eq!(removed.xy(), (4, 5));
529
530        let changed = TileChange::Changed {
531            tile_id: 21,
532            z: 4,
533            x: 2,
534            y: 3,
535            old_bytes: 10,
536            new_bytes: 20,
537        };
538        assert_eq!(changed.tile_id(), 21);
539        assert_eq!(changed.zoom(), 4);
540        assert_eq!(changed.xy(), (2, 3));
541    }
542}