fix: an unreadable credential must not brick the account - #435
Merged
JasonYeYuhe merged 1 commit intoAug 17, 2026
Conversation
`readResult` maps every OSStatus other than success/itemNotFound to `.failure`,
and `makeSecretPersistenceCheckpoint` returned nil if ANY of its four reads came
back that way. Both `saveSecrets` and `deleteSecrets` guarded on that checkpoint,
so a single unreadable entry left the account neither editable nor removable —
fail-closed had become fail-forever, with no route back for the user short of
Keychain Access.app.
Reproduced against the real login Keychain by planting a non-UTF8 payload:
readResult -> .failure
makeSecretPersistence… -> nil
saveSecrets -> false
deleteSecretsForAccount…-> false
This is a regression rather than a pre-existing gap: before the save transaction
landed, neither function read anything first, so overwriting an unreadable item
succeeded and so did deleting it.
The checkpoint now keeps the full `ProviderSecretReadResult` per entry instead of
collapsing to `String?`, so "unreadable" is recorded rather than fatal, and
`restoreSecrets` skips those entries. Skipping is the only honest option: writing
would invent a value we never saw and deleting would destroy one. It does not
count against the rollback, because we end up no worse off than before the
attempt. Readable entries are still rolled back exactly as before.
Same repro after the change: checkpoint is produced, save succeeds and the
replacement value is what is stored, and an account holding a corrupt credential
can be deleted.
`makeSecretPersistenceCheckpoint` keeps its optional return type on purpose, so
every existing `guard let` call site compiles unchanged and simply stops
tripping. The blast radius is one type and one function.
`testSaveSecretsFailsWithoutMutationWhenCheckpointReadFails` pinned the promise
that caused this ("if any entry cannot be read, refuse to write at all") and is
replaced by two tests that pin the corrected contract. Both were negative-
controlled: with the guard restored they fail, with it removed they pass.
2,717 tests, 4 skipped, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JasonYeYuhe
merged commit Aug 17, 2026
412fa95
into
fix/provider-account-safety-current-main
31 of 33 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Targets #432's branch, not
main— it's your PR, so this is yours to take or rework rather than something pushed over you.Fixes the blocking finding from my review of #432, with the reproduction that found it.
What was wrong
readResultmaps every OSStatus other thanerrSecSuccess/errSecItemNotFoundto.failure, andmakeSecretPersistenceCheckpointreturnednilif any of its four reads came back that way. BothsaveSecretsanddeleteSecretsguarded on that checkpoint.So one unreadable entry left the account neither editable nor removable. Fail-closed had become fail-forever, with no route back short of Keychain Access.app.
This is a regression, not a pre-existing gap — before the transaction landed, neither function read anything first, so overwriting an unreadable item worked and so did deleting it.
The change
The checkpoint now stores the full
ProviderSecretReadResultper entry instead of collapsing toString?, so unreadable is recorded rather than fatal, andrestoreSecretsskips those entries.Skipping is the only honest option: writing would invent a value we never observed, deleting would destroy one. It does not count against the rollback, because we end up no worse off than before the attempt. Readable entries are still rolled back exactly as before.
makeSecretPersistenceCheckpointdeliberately keeps its optional return type, so every existingguard letcall site compiles unchanged and simply stops tripping. Blast radius is one type and one function — I did not touch the editor, the transaction, or the ordering you designed.The trade this encodes
An entry we could never read cannot be rolled back either, because its previous value was never observed. That is a real cost, and it is strictly better than the alternative, which is the brick.
Verification
Real login Keychain, via a throwaway harness driving the actual code (non-UTF8 payload planted with
SecItemAdd):makeSecretPersistenceCheckpointnilsaveSecretsfalsetrue, and the replacement value is what's storeddeleteSecretsForAccountRemovalfalsetrue, and the entry is actually goneOffline suite: 2,717 tests, 4 skipped, 0 failures.
testSaveSecretsFailsWithoutMutationWhenCheckpointReadFailspinned the exact promise that caused this — "if any entry cannot be read, refuse to write at all" — so it is replaced by two tests pinning the corrected contract. Both were negative-controlled: I reverted the fix and confirmed they fail, then restored it and confirmed they pass. They are not tautologies.Not included
The harness is a throwaway
.executableTarget; I kept it out of this commit rather than adding a product target that CI can't run (it needs a real Keychain). Happy to hand it over — it inventories the shared provider-scoped namespace first and refuses to run unless the probe kind is empty, then cleans up every key it creates.Still open from the review, untouched here:
ProviderSharedCredentialOwner.defaultsresolves to the production suite under XCTest, so running the offline suite rewrites the developer's real shared App Group defaults. Pre-existing onmain, not yours.🤖 Generated with Claude Code