Skip to content

Commit 9e85ea5

Browse files
committed
fix(oauth): sweep both chunk generations when a store regrows
A shrink writes the blob back under the anchor, which replaces the manifest and takes the per-generation chunk counts with it. From then on nothing can name the chunks a failed cleanup left behind, so the growth branch's sweep of the target generation is the only one that will ever reach them — and it only ever targets family A. A keychain that refused one delete during the shrink therefore kept a superseded generation of access, ID and refresh tokens indefinitely, with no way for the user to know. Sweep the other generation alongside the target, and document that the reclaim waits for the next growth: a store that shrinks once and never grows again keeps the residue, which sweeping on every whole write would close at a cost of 128 `security` invocations per save on macOS. Also record why Load and Status take the cross-process lock, and give the missing-chunk error the same "log in again" advice the digest failure carries. Refs #937
1 parent 0566be0 commit 9e85ea5

3 files changed

Lines changed: 99 additions & 4 deletions

File tree

‎internal/oauth/store.go‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ func (b keyringBlob) read() ([]byte, bool, error) {
579579
return nil, false, err
580580
}
581581
if !ok {
582-
return nil, false, fmt.Errorf("oauth: keyring token blob at %s is missing chunk %d of %d", b.location(), index+1, count)
582+
return nil, false, fmt.Errorf("oauth: keyring token blob at %s is missing chunk %d of %d; the entries are incomplete, so log in again", b.location(), index+1, count)
583583
}
584584
encoded.WriteString(strings.TrimSpace(part))
585585
}
@@ -613,6 +613,14 @@ func (b keyringBlob) write(data []byte) error {
613613
// writeWhole stores the blob under the anchor account, the layout every backend
614614
// without a size limit uses and the one a shrinking store returns to. The Set
615615
// is the commit point; the chunks it retires are deleted after it lands.
616+
//
617+
// A delete that fails here leaves residue the manifest can no longer describe,
618+
// because the anchor now holds the blob rather than the counts. Nothing
619+
// reclaims it until the store next outgrows a single entry, where writeChunked
620+
// sweeps both generations; a store that shrinks once and never grows again
621+
// keeps it. Sweeping on every whole write would close that, but it costs a
622+
// keyringMaxChunks-wide probe per save — 128 `security` invocations on macOS —
623+
// for residue that only an already-failed delete can produce.
616624
func (b keyringBlob) writeWhole(encoded string, previous keyringManifest) error {
617625
if err := b.kr.Set(b.service, b.account, encoded); err != nil {
618626
return err
@@ -651,11 +659,24 @@ func (b keyringBlob) writeChunked(data []byte, encoded string, previous keyringM
651659
// commit below.
652660
if previous.live == "" {
653661
// Nothing to reserve against: the anchor still holds the whole blob, so
654-
// writing a manifest here would destroy the only copy. Sweep the target
655-
// generation instead. This runs once, when a store first outgrows a
662+
// writing a manifest here would destroy the only copy. Sweep both
663+
// generations instead. This runs once, when a store first outgrows a
656664
// single entry, and only has anything to find if an earlier shrink was
657665
// interrupted before its cleanup finished.
658-
if err := b.deleteChunkRange(family, count, keyringMaxChunks, nil); err != nil {
666+
//
667+
// Both, not just the target: writeWhole replaced the anchor with the
668+
// blob, so the counts that named the chunks a failed cleanup left
669+
// behind are gone. The manifest cannot state the range any more and no
670+
// later write derives it, so this is the only sweep that will ever
671+
// reach them. Sweeping just the target would leave the other
672+
// generation's chunks, and the token material in them, unreferenced
673+
// for good.
674+
other := keyringChunkFamilyB
675+
if family == keyringChunkFamilyB {
676+
other = keyringChunkFamilyA
677+
}
678+
err := b.deleteChunkRange(family, count, keyringMaxChunks, nil)
679+
if err = b.deleteChunkRange(other, 0, keyringMaxChunks, err); err != nil {
659680
return err
660681
}
661682
} else if count > previous.counts[family] {
@@ -739,6 +760,13 @@ func (b keyringBlob) chunkAccount(family string, index int) string {
739760
// processes can't both read the blob, modify, and write — dropping a token.
740761
// The chunked layout needs it for a second reason: it makes a write's chunk
741762
// fills and its manifest commit one indivisible sequence to other processes.
763+
//
764+
// Readers (Load, Status) take it as well, which is deliberate and not a
765+
// leftover: the generational layout already hands them a consistent view
766+
// without it, but holding it keeps a read from observing a torn manifest on a
767+
// backend whose Set is not atomic, and it bounds a reader behind at most one
768+
// writer. acquireFileLock reclaims after fileLockStaleAfter, so a crashed
769+
// holder cannot wedge the hot path.
742770
func (b keyringBlob) withLock(now func() time.Time, fn func() error) error {
743771
if b.lockPath == "" {
744772
return fn()

‎internal/oauth/store_keyring_chunked_test.go‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,3 +564,62 @@ func TestStoreKeyringReadSerializedWithLockDuringChunkedWrite(t *testing.T) {
564564
t.Fatal("reader.Status timed out waiting for lock release")
565565
}
566566
}
567+
568+
// TestStoreKeyringShrinkResidueIsReclaimedOnRegrowth is the regression for a
569+
// retired generation orphaned permanently. Cleanup is hygiene rather than
570+
// correctness only while a manifest exists to state the counts; writeWhole
571+
// replaces the anchor with the blob and the counts go with it. A shrink whose
572+
// delete of the retired generation fails therefore leaves chunks that the next
573+
// growth would never sweep, because that growth only ever targets family A.
574+
// The token material in them would sit in the keychain for good.
575+
func TestStoreKeyringShrinkResidueIsReclaimedOnRegrowth(t *testing.T) {
576+
kr := newCappedFakeKR(macOSLikeBudget)
577+
s := newCappedKeyringStore(t, kr)
578+
579+
// Grow until family B is the live generation, so a shrink from here is the
580+
// one that retires it.
581+
mustSave(t, s, "first", bigToken("a"))
582+
mustSave(t, s, "second", bigToken("b"))
583+
mustSave(t, s, "third", bigToken("c"))
584+
if live := manifestOf(t, kr).live; live != keyringChunkFamilyB {
585+
t.Fatalf("live generation = %q, want %q", live, keyringChunkFamilyB)
586+
}
587+
588+
// Shrink back to a whole entry with a keychain that refuses to remove
589+
// family B, the generation being retired on the way down.
590+
kr.failDelete = func(account string) error {
591+
if strings.HasPrefix(account, keyringAccount+"."+keyringChunkFamilyB+".") {
592+
return errors.New("keychain busy")
593+
}
594+
return nil
595+
}
596+
for _, key := range []string{"second", "third"} {
597+
if _, err := s.Delete(ProviderKey(key)); err != nil && !strings.Contains(err.Error(), "could not be removed") {
598+
t.Fatalf("Delete(%s): %v", key, err)
599+
}
600+
}
601+
kr.failDelete = nil
602+
if head, _, _ := kr.Get(keyringService, keyringAccount); strings.HasPrefix(head, keyringManifestPrefix) {
603+
t.Fatal("store did not shrink back to a whole entry, so the orphaning path was never taken")
604+
}
605+
orphans := kr.chunkAccounts(keyringChunkFamilyB)
606+
if len(orphans) == 0 {
607+
t.Fatal("setup did not strand any family-B chunks")
608+
}
609+
610+
// Grow back into the chunked layout. This is the only sweep that can still
611+
// reach the stranded generation, because the manifest that named it is gone.
612+
mustSave(t, s, "fourth", bigToken("d"))
613+
614+
manifest := manifestOf(t, kr)
615+
if manifest.live != keyringChunkFamilyA {
616+
t.Fatalf("live generation after regrowth = %q, want %q", manifest.live, keyringChunkFamilyA)
617+
}
618+
if got := kr.chunkAccounts(keyringChunkFamilyB); len(got) != 0 {
619+
t.Errorf("family B still holds %d unreferenced chunks after regrowth: %v", len(got), got)
620+
}
621+
assertNoStrayChunks(t, kr, manifest)
622+
if got := mustLoad(t, s, "fourth"); got.AccessToken != bigToken("d").AccessToken {
623+
t.Error("token stored across the regrowth did not survive")
624+
}
625+
}

‎internal/oauth/store_keyring_test.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ type fakeKR struct {
2424
// sets counts successful writes per account, so a test can assert the order
2525
// a write publishes in.
2626
sets map[string]int
27+
// failDelete, when non-nil, decides whether removing account fails, standing
28+
// in for a keychain that refuses a delete while it is busy or locked.
29+
failDelete func(account string) error
2730
}
2831

2932
func newFakeKR() *fakeKR { return &fakeKR{data: map[string]string{}, sets: map[string]int{}} }
@@ -54,6 +57,11 @@ func (f *fakeKR) Set(service, account, secret string) error {
5457
return nil
5558
}
5659
func (f *fakeKR) Delete(service, account string) (bool, error) {
60+
if f.failDelete != nil {
61+
if err := f.failDelete(account); err != nil {
62+
return false, err
63+
}
64+
}
5765
key := service + "/" + account
5866
_, ok := f.data[key]
5967
delete(f.data, key)

0 commit comments

Comments
 (0)