mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Archive the identity token by its guaranteed coding contract
Seventh review pass. No correctness findings this round; both are availability. The retry budget was only reset inside the tokenless branch of an identity change, so an account that exhausted its four attempts left the next one with none - its queued resolve() did no lookup at all and credentials stayed unavailable until another foreground transition. The budget belongs to the identity being resolved, so it now resets on every observed change. ubiquityIdentityToken was archived with requiringSecureCoding: true, but Foundation guarantees only NSCoding for it. Measured: a class adopting only NSCoding does throw under secure archiving, and the `try?` there would have silently produced no fingerprint - losing the scope cache, forcing a CloudKit lookup every launch and leaving credentials unavailable offline. This Mac's token happens to support secure coding, so nothing was failing yet; the archive is only ever hashed, never decoded, so secure coding buys nothing regardless. Also measured: the non-secure archive is deterministic, which is what the fingerprint comparison depends on. Note the digest differs between the two archiving modes, so any scope cached before this change resolves once more against CloudKit and re-caches.
This commit is contained in:
@@ -32,12 +32,22 @@ enum CloudKitIdentity {
|
||||
/// carries no account information — it only supports equality comparison
|
||||
/// against a previously persisted fingerprint.
|
||||
static var ubiquityFingerprint: String? {
|
||||
guard let token = FileManager.default.ubiquityIdentityToken,
|
||||
let data = try? NSKeyedArchiver.archivedData(
|
||||
withRootObject: token,
|
||||
requiringSecureCoding: true
|
||||
)
|
||||
else { return nil }
|
||||
guard let token = FileManager.default.ubiquityIdentityToken else { return nil }
|
||||
|
||||
// The token is only guaranteed to be `NSCoding` — Foundation types it
|
||||
// as `NSCoding & NSCopying & NSObjectProtocol`, with no
|
||||
// `NSSecureCoding`. Archiving with `requiringSecureCoding: true` would
|
||||
// therefore throw on a conforming implementation, and a `try?` there
|
||||
// would silently yield nil: the scope would lose its cache, forcing a
|
||||
// CloudKit lookup on every launch and leaving credentials unavailable
|
||||
// offline. Secure coding buys nothing here regardless — the archive is
|
||||
// only ever hashed, never decoded.
|
||||
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
|
||||
archiver.encode(token, forKey: NSKeyedArchiveRootObjectKey)
|
||||
archiver.finishEncoding()
|
||||
|
||||
let data = archiver.encodedData
|
||||
guard !data.isEmpty else { return nil }
|
||||
|
||||
return shortDigest(of: data)
|
||||
}
|
||||
|
||||
@@ -169,6 +169,12 @@ final class CredentialIdentityScope {
|
||||
// generation are refused even when the fingerprint looks unchanged.
|
||||
identityGeneration += 1
|
||||
|
||||
// The budget belongs to the identity being resolved, so it resets on
|
||||
// every change — not only the tokenless branch below. A previous
|
||||
// identity that exhausted its attempts would otherwise leave the new
|
||||
// one with none, and its queued resolve() would do no work at all.
|
||||
resolutionAttempts = 0
|
||||
|
||||
// A tokenless scope cannot be re-derived by `adoptCurrentIdentity()`
|
||||
// (there is no fingerprint to compare), so drop it outright: continuing
|
||||
// to serve it would hand the previous account's credentials to the new
|
||||
@@ -176,7 +182,6 @@ final class CredentialIdentityScope {
|
||||
if resolvedWithoutToken {
|
||||
state = .unresolved
|
||||
resolvedWithoutToken = false
|
||||
resolutionAttempts = 0
|
||||
}
|
||||
|
||||
adoptCurrentIdentity()
|
||||
|
||||
@@ -664,6 +664,35 @@ struct CredentialIdentityScopeTests {
|
||||
#expect(scope.current == "_recordB")
|
||||
}
|
||||
|
||||
@Test("An account change restores the retry budget")
|
||||
func identityChangeResetsAttemptBudget() async {
|
||||
let harness = Harness()
|
||||
harness.fingerprint = nil
|
||||
harness.availability = .available
|
||||
harness.tokenlessRecord = "_recordA"
|
||||
harness.failuresRemaining = 99 // exhaust every attempt for this identity
|
||||
|
||||
let scope = CredentialIdentityScope(
|
||||
defaults: makeDefaults(),
|
||||
fingerprintProvider: { harness.fingerprint },
|
||||
resolveUserRecordID: { await harness.resolve() },
|
||||
accountAvailabilityProvider: { harness.availability },
|
||||
retryDelay: { _ in }
|
||||
)
|
||||
|
||||
await scope.resolve()
|
||||
#expect(scope.current == nil)
|
||||
|
||||
// A different account signs in. Its lookup must not inherit an
|
||||
// exhausted budget and give up without trying.
|
||||
harness.failuresRemaining = 0
|
||||
harness.tokenlessRecord = "_recordB"
|
||||
scope.identityDidChange(source: "test")
|
||||
await scope.resolve()
|
||||
|
||||
#expect(scope.current == "_recordB")
|
||||
}
|
||||
|
||||
@Test("An undeterminable account status leaves the scope unresolved")
|
||||
func indeterminateStatusStaysUnresolved() async {
|
||||
let harness = Harness()
|
||||
|
||||
Reference in New Issue
Block a user