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}