From ef63130565a11735bdc280728a7dc1b45a9bf80e Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 11 Jul 2026 22:39:26 +0100 Subject: [PATCH 1/2] Surface Handoff cache parse failures Distinguish valid empty caches from unreadable JSON and plist candidates while preserving recoverable activities from mixed caches. Add malformed, empty, and mixed fixture coverage.\n\nCloses #82. --- Sources/ICloudCLICore/WalletAndHandoff.swift | 40 ++++++++++++++----- Tests/Fixtures/HandoffEmpty/activities.json | 1 + .../HandoffMalformedJSON/malformed.json | 1 + .../HandoffMalformedPlist/malformed.plist | 2 + Tests/Fixtures/HandoffMixed/activities.json | 12 ++++++ Tests/Fixtures/HandoffMixed/malformed.plist | 1 + .../WalletAndHandoffTests.swift | 26 ++++++++++++ 7 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 Tests/Fixtures/HandoffEmpty/activities.json create mode 100644 Tests/Fixtures/HandoffMalformedJSON/malformed.json create mode 100644 Tests/Fixtures/HandoffMalformedPlist/malformed.plist create mode 100644 Tests/Fixtures/HandoffMixed/activities.json create mode 100644 Tests/Fixtures/HandoffMixed/malformed.plist diff --git a/Sources/ICloudCLICore/WalletAndHandoff.swift b/Sources/ICloudCLICore/WalletAndHandoff.swift index bb7f44b..1e41a2e 100644 --- a/Sources/ICloudCLICore/WalletAndHandoff.swift +++ b/Sources/ICloudCLICore/WalletAndHandoff.swift @@ -70,7 +70,16 @@ public struct HandoffActivity: Codable, Equatable, Sendable { public enum HandoffError: Error, LocalizedError, Equatable { case unreadable(String) - public var errorDescription: String? { if case .unreadable(let path) = self { return "Handoff cache directory is unreadable: \(path)" }; return nil } + case unparseable(String) + + public var errorDescription: String? { + switch self { + case .unreadable(let path): + return "Handoff cache directory is unreadable: \(path)" + case .unparseable(let path): + return "Handoff cache contains JSON or plist files, but none could be parsed: \(path)" + } + } } private struct HandoffActivitiesEnvelope: Codable { let activities: [HandoffActivity] } @@ -82,14 +91,23 @@ public struct HandoffActivityReader: Sendable { public func listActivities(limit: Int = 10) throws -> [HandoffActivity] { guard let enumerator = FileManager.default.enumerator(at: handoffDirectory, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else { throw HandoffError.unreadable(handoffDirectory.path) } var activities: [HandoffActivity] = [] + var candidateFileCount = 0 + var parsedFileCount = 0 for case let url as URL in enumerator where ["json", "plist"].contains(url.pathExtension) { - activities.append(contentsOf: readActivities(from: url)) + candidateFileCount += 1 + if let parsedActivities = readActivities(from: url) { + parsedFileCount += 1 + activities.append(contentsOf: parsedActivities) + } + } + if candidateFileCount > 0, parsedFileCount == 0 { + throw HandoffError.unparseable(handoffDirectory.path) } return Array(activities.sorted { $0.updatedAt > $1.updatedAt }.prefix(max(0, limit))) } - private func readActivities(from url: URL) -> [HandoffActivity] { - guard let data = try? Data(contentsOf: url) else { return [] } + private func readActivities(from url: URL) -> [HandoffActivity]? { + guard let data = try? Data(contentsOf: url) else { return nil } if url.pathExtension == "json" { let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 if let wrapped = try? decoder.decode(HandoffActivitiesEnvelope.self, from: data) { return wrapped.activities } @@ -97,11 +115,15 @@ public struct HandoffActivityReader: Sendable { if let object = try? JSONSerialization.jsonObject(with: data) { return parseActivities(object) } } if let object = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) { return parseActivities(object) } - return [] + return nil } - private func parseActivities(_ value: Any) -> [HandoffActivity] { - if let array = value as? [Any] { return array.flatMap(parseActivities) } + private func parseActivities(_ value: Any) -> [HandoffActivity]? { + if let array = value as? [Any] { + if array.isEmpty { return [] } + let parsed = array.compactMap(parseActivities).flatMap { $0 } + return parsed.isEmpty ? nil : parsed + } if let dict = value as? NSDictionary { var swiftDict: [String: Any] = [:] for (key, child) in dict { if let key = key as? String { swiftDict[key] = child } } @@ -113,10 +135,10 @@ public struct HandoffActivityReader: Sendable { let bundle = stringValue(dict["appBundleId"] ?? dict["bundleIdentifier"] ?? dict["bundleId"]), let app = stringValue(dict["appName"] ?? dict["localizedAppName"]), let type = stringValue(dict["activityType"] ?? dict["type"]), - let updated = dateValue(dict["updatedAt"] ?? dict["lastUpdated"] ?? dict["timestamp"]) else { return [] } + let updated = dateValue(dict["updatedAt"] ?? dict["lastUpdated"] ?? dict["timestamp"]) else { return nil } return [HandoffActivity(deviceName: device, appBundleId: bundle, appName: app, activityType: type, title: stringValue(dict["title"]), url: stringValue(dict["url"]), updatedAt: updated)] } - return [] + return nil } } diff --git a/Tests/Fixtures/HandoffEmpty/activities.json b/Tests/Fixtures/HandoffEmpty/activities.json new file mode 100644 index 0000000..74f002c --- /dev/null +++ b/Tests/Fixtures/HandoffEmpty/activities.json @@ -0,0 +1 @@ +{"activities":[]} diff --git a/Tests/Fixtures/HandoffMalformedJSON/malformed.json b/Tests/Fixtures/HandoffMalformedJSON/malformed.json new file mode 100644 index 0000000..791bb4e --- /dev/null +++ b/Tests/Fixtures/HandoffMalformedJSON/malformed.json @@ -0,0 +1 @@ +{"activities":[ diff --git a/Tests/Fixtures/HandoffMalformedPlist/malformed.plist b/Tests/Fixtures/HandoffMalformedPlist/malformed.plist new file mode 100644 index 0000000..a5aadd9 --- /dev/null +++ b/Tests/Fixtures/HandoffMalformedPlist/malformed.plist @@ -0,0 +1,2 @@ + + diff --git a/Tests/Fixtures/HandoffMixed/activities.json b/Tests/Fixtures/HandoffMixed/activities.json new file mode 100644 index 0000000..2ff544d --- /dev/null +++ b/Tests/Fixtures/HandoffMixed/activities.json @@ -0,0 +1,12 @@ +{ + "activities": [ + { + "deviceName": "Example Mac", + "appBundleId": "com.apple.Safari", + "appName": "Safari", + "activityType": "NSUserActivityTypeBrowsingWeb", + "title": "Synthetic Activity", + "updatedAt": "2026-07-11T10:00:00Z" + } + ] +} diff --git a/Tests/Fixtures/HandoffMixed/malformed.plist b/Tests/Fixtures/HandoffMixed/malformed.plist new file mode 100644 index 0000000..0876d31 --- /dev/null +++ b/Tests/Fixtures/HandoffMixed/malformed.plist @@ -0,0 +1 @@ +not a plist diff --git a/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift b/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift index 9c4c31b..d2529be 100644 --- a/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift +++ b/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift @@ -28,6 +28,32 @@ private let fixtures = URL(fileURLWithPath: #filePath) #expect(activities[0].appBundleId == "com.apple.mobilesafari") } +@Test func reportsMalformedHandoffJSON() { + let directory = fixtures.appendingPathComponent("HandoffMalformedJSON") + #expect(throws: HandoffError.unparseable(directory.path)) { + try HandoffActivityReader(handoffDirectory: directory).listActivities() + } +} + +@Test func reportsMalformedHandoffPlist() { + let directory = fixtures.appendingPathComponent("HandoffMalformedPlist") + #expect(throws: HandoffError.unparseable(directory.path)) { + try HandoffActivityReader(handoffDirectory: directory).listActivities() + } +} + +@Test func validEmptyHandoffCacheRemainsEmpty() throws { + let directory = fixtures.appendingPathComponent("HandoffEmpty") + #expect(try HandoffActivityReader(handoffDirectory: directory).listActivities().isEmpty) +} + +@Test func validHandoffActivitySurvivesMalformedNeighbor() throws { + let directory = fixtures.appendingPathComponent("HandoffMixed") + let activities = try HandoffActivityReader(handoffDirectory: directory).listActivities() + #expect(activities.count == 1) + #expect(activities[0].deviceName == "Example Mac") +} + @Test func rendersWalletAndHandoffText() throws { let runner = CommandRunner() let pass = WalletPass(passType: .eventTicket, description: "Synthetic Event", organizationName: "Example Venue", relevantDate: nil, expirationDate: nil, serialNumber: "SERIAL") From d23d64d7f61e5359acea30dfbaf3587b2db002fb Mon Sep 17 00:00:00 2001 From: Daedalus <268206840+daedalus-omt@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:57:47 +0000 Subject: [PATCH 2/2] fix: surface mixed empty handoff corruption Signed-off-by: Daedalus <268206840+daedalus-omt@users.noreply.github.com> --- Sources/ICloudCLICore/WalletAndHandoff.swift | 9 ++++++--- Tests/Fixtures/HandoffEmptyMixed/activities.json | 1 + Tests/Fixtures/HandoffEmptyMixed/malformed.plist | 1 + Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift | 7 +++++++ 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 Tests/Fixtures/HandoffEmptyMixed/activities.json create mode 100644 Tests/Fixtures/HandoffEmptyMixed/malformed.plist diff --git a/Sources/ICloudCLICore/WalletAndHandoff.swift b/Sources/ICloudCLICore/WalletAndHandoff.swift index 1e41a2e..842554e 100644 --- a/Sources/ICloudCLICore/WalletAndHandoff.swift +++ b/Sources/ICloudCLICore/WalletAndHandoff.swift @@ -92,15 +92,18 @@ public struct HandoffActivityReader: Sendable { guard let enumerator = FileManager.default.enumerator(at: handoffDirectory, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else { throw HandoffError.unreadable(handoffDirectory.path) } var activities: [HandoffActivity] = [] var candidateFileCount = 0 - var parsedFileCount = 0 + var recoveredActivityCount = 0 + var malformedCandidateCount = 0 for case let url as URL in enumerator where ["json", "plist"].contains(url.pathExtension) { candidateFileCount += 1 if let parsedActivities = readActivities(from: url) { - parsedFileCount += 1 activities.append(contentsOf: parsedActivities) + recoveredActivityCount += parsedActivities.count + } else { + malformedCandidateCount += 1 } } - if candidateFileCount > 0, parsedFileCount == 0 { + if candidateFileCount > 0, recoveredActivityCount == 0, malformedCandidateCount > 0 { throw HandoffError.unparseable(handoffDirectory.path) } return Array(activities.sorted { $0.updatedAt > $1.updatedAt }.prefix(max(0, limit))) diff --git a/Tests/Fixtures/HandoffEmptyMixed/activities.json b/Tests/Fixtures/HandoffEmptyMixed/activities.json new file mode 100644 index 0000000..74f002c --- /dev/null +++ b/Tests/Fixtures/HandoffEmptyMixed/activities.json @@ -0,0 +1 @@ +{"activities":[]} diff --git a/Tests/Fixtures/HandoffEmptyMixed/malformed.plist b/Tests/Fixtures/HandoffEmptyMixed/malformed.plist new file mode 100644 index 0000000..0876d31 --- /dev/null +++ b/Tests/Fixtures/HandoffEmptyMixed/malformed.plist @@ -0,0 +1 @@ +not a plist diff --git a/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift b/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift index d2529be..413d2a7 100644 --- a/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift +++ b/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift @@ -47,6 +47,13 @@ private let fixtures = URL(fileURLWithPath: #filePath) #expect(try HandoffActivityReader(handoffDirectory: directory).listActivities().isEmpty) } +@Test func emptyHandoffCacheDoesNotHideMalformedNeighbor() { + let directory = fixtures.appendingPathComponent("HandoffEmptyMixed") + #expect(throws: HandoffError.unparseable(directory.path)) { + try HandoffActivityReader(handoffDirectory: directory).listActivities() + } +} + @Test func validHandoffActivitySurvivesMalformedNeighbor() throws { let directory = fixtures.appendingPathComponent("HandoffMixed") let activities = try HandoffActivityReader(handoffDirectory: directory).listActivities()