@@ -2,6 +2,7 @@ package oauth
22
33import (
44 "errors"
5+ "path/filepath"
56 "strconv"
67 "strings"
78 "testing"
@@ -30,7 +31,10 @@ func bigToken(seed string) Token {
3031
3132func newCappedKeyringStore (t * testing.T , kr KeyringClient ) * Store {
3233 t .Helper ()
33- t .Setenv ("XDG_CONFIG_HOME" , t .TempDir ())
34+ dir := t .TempDir ()
35+ t .Setenv ("HOME" , dir )
36+ t .Setenv ("USERPROFILE" , dir )
37+ t .Setenv ("XDG_CONFIG_HOME" , dir )
3438 s , err := NewStore (StoreOptions {Storage : "keyring" , Keyring : kr })
3539 if err != nil {
3640 t .Fatalf ("NewStore(keyring): %v" , err )
@@ -486,6 +490,8 @@ func TestStoreKeyringSweepsStrayChunksOnFirstGrowth(t *testing.T) {
486490// has deleted old-generation chunks).
487491func TestStoreKeyringReadSerializedWithLockDuringChunkedWrite (t * testing.T ) {
488492 dir := t .TempDir ()
493+ t .Setenv ("HOME" , dir )
494+ t .Setenv ("USERPROFILE" , dir )
489495 t .Setenv ("XDG_CONFIG_HOME" , dir )
490496
491497 kr := newCappedFakeKR (macOSLikeBudget )
@@ -565,6 +571,112 @@ func TestStoreKeyringReadSerializedWithLockDuringChunkedWrite(t *testing.T) {
565571 }
566572}
567573
574+ // TestStoreKeyringSharedLockAcrossDifferentEnvironmentRoots is the regression for
575+ // split lock domains when processes run with different ZERO_OAUTH_TOKENS_PATH,
576+ // FilePath, or XDG_CONFIG_HOME. Because the OS keychain is single-domain for the
577+ // user ("zero"/"oauth-tokens"), all stores must share the same lock file so
578+ // concurrent reads, writes, and manifest rotations remain strictly serialized.
579+ func TestStoreKeyringSharedLockAcrossDifferentEnvironmentRoots (t * testing.T ) {
580+ homeDir := t .TempDir ()
581+ t .Setenv ("HOME" , homeDir )
582+ t .Setenv ("USERPROFILE" , homeDir )
583+
584+ dirA := t .TempDir ()
585+ dirB := t .TempDir ()
586+
587+ kr := newCappedFakeKR (macOSLikeBudget )
588+ storeA , err := NewStore (StoreOptions {
589+ Storage : "keyring" ,
590+ Keyring : kr ,
591+ FilePath : filepath .Join (dirA , "custom-tokens.json" ),
592+ Env : map [string ]string {
593+ "ZERO_OAUTH_TOKENS_PATH" : filepath .Join (dirA , "custom-tokens.json" ),
594+ "XDG_CONFIG_HOME" : dirA ,
595+ },
596+ })
597+ if err != nil {
598+ t .Fatalf ("NewStore(storeA): %v" , err )
599+ }
600+
601+ storeB , err := NewStore (StoreOptions {
602+ Storage : "keyring" ,
603+ Keyring : kr ,
604+ FilePath : filepath .Join (dirB , "custom-tokens.json" ),
605+ Env : map [string ]string {
606+ "ZERO_OAUTH_TOKENS_PATH" : filepath .Join (dirB , "custom-tokens.json" ),
607+ "XDG_CONFIG_HOME" : dirB ,
608+ },
609+ })
610+ if err != nil {
611+ t .Fatalf ("NewStore(storeB): %v" , err )
612+ }
613+
614+ lockA := storeA .blob .(keyringBlob ).lockPath
615+ lockB := storeB .blob .(keyringBlob ).lockPath
616+ if lockA == "" || lockB == "" {
617+ t .Fatalf ("lockPath should not be empty: lockA=%q lockB=%q" , lockA , lockB )
618+ }
619+ if lockA != lockB {
620+ t .Fatalf ("stores with distinct env roots must share the keyring lock: lockA=%q != lockB=%q" , lockA , lockB )
621+ }
622+
623+ mustSave (t , storeA , "keyA" , bigToken ("a" ))
624+
625+ // 1. Force overlapping save/read: hold the lock manually to simulate an
626+ // in-flight multi-step write by storeA, and verify storeB's Load / Status blocks.
627+ unlock , err := acquireFileLock (lockA , time .Now )
628+ if err != nil {
629+ t .Fatalf ("acquireFileLock: %v" , err )
630+ }
631+
632+ loaded := make (chan Token , 1 )
633+ loadErr := make (chan error , 1 )
634+ go func () {
635+ tok , _ , err := storeB .Load (ProviderKey ("keyA" ))
636+ loadErr <- err
637+ loaded <- tok
638+ }()
639+
640+ select {
641+ case <- loaded :
642+ t .Fatal ("storeB.Load completed while storeA's lock was held" )
643+ case <- time .After (50 * time .Millisecond ):
644+ }
645+
646+ unlock ()
647+
648+ select {
649+ case err := <- loadErr :
650+ if err != nil {
651+ t .Fatalf ("storeB.Load failed after unlock: %v" , err )
652+ }
653+ tok := <- loaded
654+ if tok .AccessToken != bigToken ("a" ).AccessToken {
655+ t .Errorf ("storeB.Load got unexpected token: %v" , tok )
656+ }
657+ case <- time .After (2 * time .Second ):
658+ t .Fatal ("storeB.Load timed out waiting for lock release" )
659+ }
660+
661+ // 2. Force overlapping save/save: write from storeB and storeA consecutively,
662+ // verifying both updates are preserved and readable by either store.
663+ if err := storeB .Save (ProviderKey ("keyB" ), bigToken ("b" )); err != nil {
664+ t .Fatalf ("storeB.Save: %v" , err )
665+ }
666+ if err := storeA .Save (ProviderKey ("keyC" ), bigToken ("c" )); err != nil {
667+ t .Fatalf ("storeA.Save: %v" , err )
668+ }
669+
670+ gotA := mustLoad (t , storeB , "keyA" )
671+ gotB := mustLoad (t , storeA , "keyB" )
672+ gotC := mustLoad (t , storeB , "keyC" )
673+ if gotA .AccessToken != bigToken ("a" ).AccessToken ||
674+ gotB .AccessToken != bigToken ("b" ).AccessToken ||
675+ gotC .AccessToken != bigToken ("c" ).AccessToken {
676+ t .Errorf ("concurrent state was not preserved across stores" )
677+ }
678+ }
679+
568680// TestStoreKeyringShrinkResidueIsReclaimedOnRegrowth is the regression for a
569681// retired generation orphaned permanently. Cleanup is hygiene rather than
570682// correctness only while a manifest exists to state the counts; writeWhole
0 commit comments