commit 9d1c128f68851bd3ae671043ad8022e226de25a1
parent 8a546ce89521d917cd4abb2ab4701dbfd891cb5a
Author: Michael Camilleri <[email protected]>
Date: Fri, 12 Jun 2026 23:07:10 +0900
Truncate identifiers in the diagnostics log
This commit scrubs every breadcrumb at the EventLog.append choke point
so a shared diagnostics dump can't expose complete game UUIDs, CloudKit
user/device record names, or iCloud share-link tokens. The first eight
characters survive — enough to correlate lines within one log, matching
the existing PlayerRoster[0A5207B4] and truncated-APNs-token
conventions. The share-link token is the one that matters most: it is a
bearer credential, not just an identifier.
Scrubbing at the append choke point covers every producer without
touching call sites — syncMonitor.note, eventLog.note, the phase
start/success/error wrappers, and the notification extension's drained
receipts — and applies equally to the persisted buffer and the os.Logger
mirror. SyncMonitor's stored lastErrorDescription is scrubbed too, since
CloudKit error text can embed full record and zone names and that string
surfaces raw in the dump's header.
The DiagnosticsView probes (userRecordID, zone lists) intentionally
keep full IDs: they never enter the dump, and the record editor on
Production builds needs the complete values.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
3 files changed, 137 insertions(+), 1 deletion(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -30,6 +30,7 @@
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */; };
+ 2641299DE1F2E84E8C21E037 /* LogScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */; };
267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */; };
26DC22F88FA10C47BC06975E /* PersistenceRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */; };
2A8FB9C020B2072659C24C8E /* CompactSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */; };
@@ -341,6 +342,7 @@
BD63A9B20168F3B81AF4348F /* RecordApplier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordApplier.swift; sourceTree = "<group>"; };
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; };
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; };
+ C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScrubberTests.swift; sourceTree = "<group>"; };
C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; };
C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayload.swift; sourceTree = "<group>"; };
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; };
@@ -443,6 +445,7 @@
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */,
2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */,
+ C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */,
78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */,
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */,
92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */,
@@ -811,6 +814,7 @@
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */,
6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */,
+ 2641299DE1F2E84E8C21E037 /* LogScrubberTests.swift in Sources */,
E454051BA4797000C8AD2B48 /* MovesCodecLegacyDecodeTests.swift in Sources */,
DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */,
F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */,
diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift
@@ -182,6 +182,41 @@ struct EventLogEntry: Identifiable, Sendable, Codable {
let message: String
}
+/// Truncates identifiers before they land in the event log, so a shared
+/// diagnostics dump can't expose a complete game UUID, CloudKit user/device
+/// record name, or share-link token. The first eight characters survive —
+/// enough to correlate lines within one log (matching the existing
+/// `PlayerRoster[0A5207B4]` and truncated-APNs-token conventions) without
+/// reconstructing the real identifier.
+///
+/// Main-actor-confined because `Regex` is not `Sendable`; both producers
+/// (`EventLog`, `SyncMonitor`) already live on the main actor.
+@MainActor
+enum LogScrubber {
+ private static let uuid =
+ /[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/
+ /// CloudKit user record names are `_` + 32 hex; device IDs inside ping
+ /// record names are bare 32-hex. `{32,}` swallows anything longer (e.g. a
+ /// full APNs token) in one match rather than leaving a usable tail.
+ /// Sentinels like `__defaultOwner__` contain non-hex letters and pass
+ /// through untouched.
+ private static let hexToken = /_?[0-9A-Fa-f]{32,}/
+ /// An iCloud share URL's path token is a bearer credential — anyone
+ /// holding the full link can join the game — so only a stub survives.
+ private static let shareURLToken = /(icloud\.com\/share\/)([A-Za-z0-9._~-]{9,})/
+
+ static func scrub(_ message: String) -> String {
+ var result = message
+ result = result.replacing(uuid) { "\($0.output.prefix(8))…" }
+ result = result.replacing(hexToken) { match in
+ let keep = match.output.hasPrefix("_") ? 9 : 8
+ return "\(match.output.prefix(keep))…"
+ }
+ result = result.replacing(shareURLToken) { "\($0.output.1)\($0.output.2.prefix(8))…" }
+ return result
+ }
+}
+
/// Off-main durable backing for `EventLog`. Owns a single atomically-rewritten
/// snapshot file in Application Support — chosen over Caches, which iOS can
/// purge under storage pressure, exactly when a tester needs the log most.
@@ -283,6 +318,7 @@ final class EventLog {
}
fileprivate func append(level: String, _ message: String) {
+ let message = LogScrubber.scrub(message)
switch level {
case "error":
systemLog.error("\(message, privacy: .public)")
@@ -372,7 +408,10 @@ final class SyncMonitor {
lastErrorPhase = phase
lastErrorDomain = nsError.domain
lastErrorCode = nsError.code
- lastErrorDescription = nsError.localizedDescription
+ // Scrub at storage, not display: this string surfaces both in the
+ // diagnostics view and in the shared dump's header, and CloudKit
+ // error text can embed full record and zone names.
+ lastErrorDescription = LogScrubber.scrub(nsError.localizedDescription)
let userInfoSummary = nsError.userInfo
.map { "\($0.key)=\($0.value)" }
.joined(separator: " | ")
diff --git a/Tests/Unit/LogScrubberTests.swift b/Tests/Unit/LogScrubberTests.swift
@@ -0,0 +1,93 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("Log scrubber")
+@MainActor
+struct LogScrubberTests {
+ @Test("UUIDs truncate to an eight-character prefix")
+ func uuids() {
+ let scrubbed = LogScrubber.scrub(
+ "engagement: reconnect backstop cancelled for 0A5207B4-CB66-45A5-A054-27861594B612"
+ )
+ #expect(scrubbed == "engagement: reconnect backstop cancelled for 0A5207B4…")
+ }
+
+ @Test("Ping record names keep their structure but lose full IDs")
+ func pingRecordNames() {
+ let scrubbed = LogScrubber.scrub(
+ "ping(invite): already-handled record "
+ + "ping-C05349C4-FEAE-49F7-86F7-03F34656B302"
+ + "-_f838827eeca157aeafb6901073c18676"
+ + "-02b1f8d0a782450ea5e4f4ca20ae7a48-1781217475858"
+ )
+ #expect(
+ scrubbed
+ == "ping(invite): already-handled record ping-C05349C4…-_f838827e…-02b1f8d0…-1781217475858"
+ )
+ }
+
+ @Test("User record names keep underscore plus eight hex; sentinels pass through")
+ func userRecordNames() {
+ let scrubbed = LogScrubber.scrub(
+ #"share=["_1960c4811a7e1a7210c2031998319301", "__defaultOwner__"]"#
+ )
+ #expect(scrubbed == #"share=["_1960c481…", "__defaultOwner__"]"#)
+ }
+
+ @Test("Share URL tokens truncate while the link stays recognisable")
+ func shareURLs() {
+ let scrubbed = LogScrubber.scrub(
+ "share link created for 0A5207B4-CB66-45A5-A054-27861594B612: "
+ + "https://www.icloud.com/share/0aBcDeFgHiJkLmNoPqRs#Monday_Puzzle"
+ )
+ #expect(
+ scrubbed
+ == "share link created for 0A5207B4…: https://www.icloud.com/share/0aBcDeFg…#Monday_Puzzle"
+ )
+ }
+
+ @Test("Full-length hex tokens collapse to a single stub")
+ func longHexTokens() {
+ let token = String(repeating: "c769792fd840", count: 6) // 72 hex chars
+ let scrubbed = LogScrubber.scrub("token=\(token)")
+ #expect(scrubbed == "token=c769792f…")
+ }
+
+ @Test("Prose, subscription IDs, and decimal timestamps pass through")
+ func passthrough() {
+ let messages = [
+ "private subscription already present (crossmate-private-db-subscription)",
+ "shared zone discovery: nothing new (server=23, known=23)",
+ "freshen game list foreground: private skipped (cooldown, last 7s ago)",
+ ]
+ for message in messages {
+ #expect(LogScrubber.scrub(message) == message)
+ }
+ }
+
+ @Test("EventLog scrubs at append time")
+ func eventLogAppliesScrubbing() {
+ let log = EventLog()
+ log.note("engagement: ending for 0A5207B4-CB66-45A5-A054-27861594B612")
+ #expect(log.entries.last?.message == "engagement: ending for 0A5207B4…")
+ }
+
+ @Test("SyncMonitor scrubs the stored error description")
+ func syncMonitorScrubsErrorDescription() {
+ let monitor = SyncMonitor(log: EventLog())
+ monitor.recordError(
+ "shared game/moves catch-up",
+ NSError(
+ domain: "CKErrorDomain",
+ code: 26,
+ userInfo: [
+ NSLocalizedDescriptionKey:
+ "Zone game-0A5207B4-CB66-45A5-A054-27861594B612 does not exist"
+ ]
+ )
+ )
+ #expect(monitor.lastErrorDescription == "Zone game-0A5207B4… does not exist")
+ }
+}