From 0b27d3d4f699eb71af69244340e2988b49375e44 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 17:26:33 +0100 Subject: [PATCH] road distance: pin-fingerprint guard so embedded/live/cached never cross-pollute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The road-distance cache was keyed purely by station ID with no coordinate, so a value routed to one pin could be served for a same-ID station whose stored coordinate came from a different source (live fetch vs bundled offline dump vs a corrected pin), reproducing Maps mismatches. - RoadDistanceCache now stores each routed pin (CachedRoadDistance{meters, lat, lng}) and roadDistanceMeters() only serves a value when the displayed station's coordinate matches the pinned one (within ~11 m). - Kept the existing origin-distance staleness guard. - Verified embedded sample data (ids y1/se1/...) never collides with real relay IDs, and the widget STRICT radius filter drops far-offline samples, so no actual leak existed in practice — this closes the theoretical stale-pin channel and future-proofs against coordinate fixes. --- FuelBoard/RoadDistanceService.swift | 9 +++- .../FuelBoardSharedTests/FuelBoardTests.swift | 24 +++++++++-- Shared/FuelStore.swift | 42 +++++++++++++++---- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/FuelBoard/RoadDistanceService.swift b/FuelBoard/RoadDistanceService.swift index 2205770..b2f8412 100644 --- a/FuelBoard/RoadDistanceService.swift +++ b/FuelBoard/RoadDistanceService.swift @@ -51,11 +51,16 @@ enum RoadDistanceService { .filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM } let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) - var entries: [String: Double] = [:] + var entries: [String: CachedRoadDistance] = [:] for station in nearest { let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) if let meters = await roadMeters(from: origin, to: dest) { - entries[station.id] = meters + // Store the exact pin that was routed so the display layer can + // refuse to serve this value if the station later appears with + // a different coordinate (corrected pin / other data source). + entries[station.id] = CachedRoadDistance(meters: meters, + lat: station.lat, + lng: station.lng) } } guard !entries.isEmpty else { return } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 86047a8..835342e 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -647,7 +647,8 @@ final class RoadDistanceCacheTests: XCTestCase { func testRoadDistanceUsedWhenCachedNear() { let s = station("a", 51.5074, -0.1278) // Cache a road distance of 3.2 km for this station from the user's fix. - FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200]) + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)]) let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) XCTAssertEqual(km, 3.2, accuracy: 0.0001) } @@ -655,8 +656,9 @@ final class RoadDistanceCacheTests: XCTestCase { func testRoadDistanceNilWhenOriginFar() { let s = station("a", 51.5074, -0.1278) // Cache built in London, but the user is now ~200 km away -> stale. - FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278, entries: ["a": 3200]) - let meters = FuelStore.roadDistanceMeters(for: "a", userLat: 53.4808, userLng: -2.2426) + FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)]) + let meters = FuelStore.roadDistanceMeters(for: s, userLat: 53.4808, userLng: -2.2426) XCTAssertNil(meters) // And display falls back to straight-line. let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426) @@ -664,12 +666,26 @@ final class RoadDistanceCacheTests: XCTestCase { } func testRoadDistanceUsedForOtherStationNotFound() { - FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200]) + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)]) // A station that isn't in the cache falls back to straight-line. let s = station("z", 51.51, -0.13) let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001) } + + func testRoadDistanceNotServedWhenStationPinDiffers() { + // Route a road distance to station "a" at pin P1. + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)]) + // The SAME station id appears with a moved pin (corrected coordinate / + // different embedded vs live source): the cached route to P1 must NOT + // be served — it belongs to a different location. + let moved = station("a", 51.5400, -0.1600) + let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1) + XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001, + "road value routed to the old pin leaked onto a different coordinate") + } } // MARK: - Install identity diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 33f354c..2ae5e16 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -999,7 +999,16 @@ struct FuelStore { /// user position before a cached road distance is treated as stale. static let roadDistanceOriginToleranceMeters: Double = 600 - static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) { + /// How far (degrees) a station's stored coordinate may drift from the pin + /// a road distance was actually routed to before that cached value is + /// treated as belonging to a different station. Guards against one data + /// source (live fetch, bundled offline dump, or a corrected pin) serving a + /// road distance that was computed for a different coordinate under the + /// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but + /// catches any real pin change. + static let roadDistancePinToleranceDegrees: Double = 1e-4 + + static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) { let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng, updatedAt: Date().timeIntervalSince1970, entries: entries) if let data = try? JSONEncoder().encode(cache) { @@ -1017,10 +1026,11 @@ struct FuelStore { /// Cached road distance (metres) to a station from the user's location, or /// nil when not cached / the cache was built too far from where the user - /// is now. - static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? { + /// is now / the station's own pin doesn't match the coordinate that was + /// routed. + static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? { guard let cache = loadRoadDistances(), - let meters = cache.entries[stationID] else { return nil } + let entry = cache.entries[station.id] else { return nil } // The cache is only valid near the location it was built from. let dLat = (userLat - cache.sourceLat) * .pi / 180 let dLng = (userLng - cache.sourceLng) * .pi / 180 @@ -1030,13 +1040,18 @@ struct FuelStore { sin(dLng / 2) * sin(dLng / 2) let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a)) guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil } - return meters + // Pin fingerprint: never serve a routed value for a coordinate we + // didn't actually route to. This is the guard that keeps embedded / + // live / cached station sets from injecting each other's road metres. + guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees, + abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil } + return entry.meters } /// Distance (km) to display for a station: cached ROAD distance when /// available (matches Apple Maps), else straight-line haversine. static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double { - if let meters = roadDistanceMeters(for: station.id, userLat: userLat, userLng: userLng) { + if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) { return meters / 1000.0 } return station.distanceKM(to: userLat, lng2: userLng) @@ -1103,10 +1118,21 @@ struct FuelStore { } /// Cached Apple-Maps road distances for nearby stations (see -/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres. +/// A single cached road distance plus the station pin it was routed to. Keeping +/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a +/// *different* coordinate under the same ID — the guard that stops embedded / +/// live / cached station sets cross-contaminating the distance display. +struct CachedRoadDistance: Codable { + let meters: Double + let lat: Double + let lng: Double +} + +/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres + +/// the routed pin. struct RoadDistanceCache: Codable { let sourceLat: Double let sourceLng: Double let updatedAt: TimeInterval - let entries: [String: Double] + let entries: [String: CachedRoadDistance] }