road distance: pin-fingerprint guard so embedded/live/cached never cross-pollute

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.
This commit is contained in:
FuelBoard Contributor
2026-08-20 17:26:33 +01:00
parent 01517ba687
commit 0b27d3d4f6
3 changed files with 61 additions and 14 deletions
+7 -2
View File
@@ -51,11 +51,16 @@ enum RoadDistanceService {
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM } .filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
var entries: [String: Double] = [:] var entries: [String: CachedRoadDistance] = [:]
for station in nearest { for station in nearest {
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
if let meters = await roadMeters(from: origin, to: dest) { 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 } guard !entries.isEmpty else { return }
@@ -647,7 +647,8 @@ final class RoadDistanceCacheTests: XCTestCase {
func testRoadDistanceUsedWhenCachedNear() { func testRoadDistanceUsedWhenCachedNear() {
let s = station("a", 51.5074, -0.1278) let s = station("a", 51.5074, -0.1278)
// Cache a road distance of 3.2 km for this station from the user's fix. // 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) let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, 3.2, accuracy: 0.0001) XCTAssertEqual(km, 3.2, accuracy: 0.0001)
} }
@@ -655,8 +656,9 @@ final class RoadDistanceCacheTests: XCTestCase {
func testRoadDistanceNilWhenOriginFar() { func testRoadDistanceNilWhenOriginFar() {
let s = station("a", 51.5074, -0.1278) let s = station("a", 51.5074, -0.1278)
// Cache built in London, but the user is now ~200 km away -> stale. // Cache built in London, but the user is now ~200 km away -> stale.
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278, entries: ["a": 3200]) FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
let meters = FuelStore.roadDistanceMeters(for: "a", userLat: 53.4808, userLng: -2.2426) 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) XCTAssertNil(meters)
// And display falls back to straight-line. // And display falls back to straight-line.
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426) let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
@@ -664,12 +666,26 @@ final class RoadDistanceCacheTests: XCTestCase {
} }
func testRoadDistanceUsedForOtherStationNotFound() { 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. // A station that isn't in the cache falls back to straight-line.
let s = station("z", 51.51, -0.13) let s = station("z", 51.51, -0.13)
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) 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) 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 // MARK: - Install identity
+34 -8
View File
@@ -999,7 +999,16 @@ struct FuelStore {
/// user position before a cached road distance is treated as stale. /// user position before a cached road distance is treated as stale.
static let roadDistanceOriginToleranceMeters: Double = 600 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, let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
updatedAt: Date().timeIntervalSince1970, entries: entries) updatedAt: Date().timeIntervalSince1970, entries: entries)
if let data = try? JSONEncoder().encode(cache) { 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 /// 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 /// nil when not cached / the cache was built too far from where the user
/// is now. /// is now / the station's own pin doesn't match the coordinate that was
static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? { /// routed.
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
guard let cache = loadRoadDistances(), 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. // The cache is only valid near the location it was built from.
let dLat = (userLat - cache.sourceLat) * .pi / 180 let dLat = (userLat - cache.sourceLat) * .pi / 180
let dLng = (userLng - cache.sourceLng) * .pi / 180 let dLng = (userLng - cache.sourceLng) * .pi / 180
@@ -1030,13 +1040,18 @@ struct FuelStore {
sin(dLng / 2) * sin(dLng / 2) sin(dLng / 2) * sin(dLng / 2)
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a)) let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil } 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 /// Distance (km) to display for a station: cached ROAD distance when
/// available (matches Apple Maps), else straight-line haversine. /// available (matches Apple Maps), else straight-line haversine.
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double { 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 meters / 1000.0
} }
return station.distanceKM(to: userLat, lng2: userLng) return station.distanceKM(to: userLat, lng2: userLng)
@@ -1103,10 +1118,21 @@ struct FuelStore {
} }
/// Cached Apple-Maps road distances for nearby stations (see /// 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 { struct RoadDistanceCache: Codable {
let sourceLat: Double let sourceLat: Double
let sourceLng: Double let sourceLng: Double
let updatedAt: TimeInterval let updatedAt: TimeInterval
let entries: [String: Double] let entries: [String: CachedRoadDistance]
} }