@@ -12,6 +12,8 @@ import (
1212 "os"
1313 "path/filepath"
1414 "runtime"
15+ "slices"
16+ "sort"
1517 "strings"
1618 "time"
1719
@@ -27,15 +29,41 @@ const (
2729
2830var (
2931 AllKinds = []Kind {KindAccess , KindRefresh }
30- )
3132
32- var (
3333 parentDir = "chainguard"
3434)
3535
36+ type token struct {
37+ alias string
38+ }
39+
40+ type Option func (* token )
41+
42+ // WithAlias allows callers to organize tokens into subdirectories
43+ // under an audience to manage multiple tokens for the same audience
44+ // without overwriting.
45+ func WithAlias (a string ) Option {
46+ return func (token * token ) {
47+ token .alias = a
48+ }
49+ }
50+
51+ func newToken (opts ... Option ) token {
52+ t := token {}
53+ for _ , o := range opts {
54+ o (& t )
55+ }
56+ return t
57+ }
58+
3659// Save saves the given token to cache/audience
37- func Save (token []byte , kind Kind , audience string ) error {
38- path , err := Path (kind , audience )
60+ func Save (token []byte , kind Kind , audience string , opts ... Option ) error {
61+ t := newToken (opts ... )
62+ return t .save (token , kind , audience )
63+ }
64+
65+ func (t token ) save (token []byte , kind Kind , audience string ) error {
66+ path , err := t .path (kind , audience )
3967 if err != nil {
4068 return err
4169 }
@@ -48,8 +76,13 @@ func Save(token []byte, kind Kind, audience string) error {
4876
4977// Load returns the token for the given audience if it exists,
5078// or an error if it doesn't.
51- func Load (kind Kind , audience string ) ([]byte , error ) {
52- path , err := Path (kind , audience )
79+ func Load (kind Kind , audience string , opts ... Option ) ([]byte , error ) {
80+ t := newToken (opts ... )
81+ return t .load (kind , audience )
82+ }
83+
84+ func (t token ) load (kind Kind , audience string ) ([]byte , error ) {
85+ path , err := t .path (kind , audience )
5386 if err != nil {
5487 return nil , err
5588 }
@@ -63,8 +96,13 @@ func Load(kind Kind, audience string) ([]byte, error) {
6396
6497// Delete removes the token for the given audience, if it exists.
6598// No error is returned if the token doesn't exist.
66- func Delete (kind Kind , audience string ) error {
67- path , err := Path (kind , audience )
99+ func Delete (kind Kind , audience string , opts ... Option ) error {
100+ t := newToken (opts ... )
101+ return t .delete (kind , audience )
102+ }
103+
104+ func (t token ) delete (kind Kind , audience string ) error {
105+ path , err := t .path (kind , audience )
68106 if err != nil {
69107 return err
70108 }
@@ -81,56 +119,96 @@ func DeleteAll() error {
81119 if err != nil {
82120 return fmt .Errorf ("error locating Chainguard token dir: %w" , err )
83121 }
84- files , err := os .ReadDir (base )
85- if err != nil {
86- return fmt .Errorf ("error reading Chainguard token dir: %w" , err )
87- }
122+
88123 // Token directory is expected to be structured as group of audience-specific
89- // directories, with a single file containing the token
124+ // directories, with token files (oidc-token, refresh-token) or alias directories
125+ // nested within.
90126 //
91127 // $ tree ~/Library/Caches/chainguard
92128 // /Users/foo/Library/Caches/chainguard
93129 // ├── https:--console-api.enforce.dev
94- // │ └── oidc-token
95- // ├── https:--cgr.dev
96- // └── oidc-token
97- for _ , file := range files {
98- if ! file .IsDir () {
99- // Encountered a file in the directory. Skip.
100- continue
130+ // │ ├── oidc-token
131+ // │ ├── refresh-token
132+ // │ └── foo
133+ // │ ├── oidc-token
134+ // │ └── refresh-token
135+ // ├── cgr.dev
136+ // └── oidc-token
137+ var dirs []string
138+ if err = filepath .WalkDir (base , func (path string , d fs.DirEntry , err error ) error {
139+ // Return errors encountered reading the base directory.
140+ if err != nil {
141+ return err
101142 }
102- for _ , kind := range AllKinds {
103- // Try to remove a token, ignore file not exist errors
104- tokenFile := filepath .Join (base , file .Name (), string (kind ))
105- if err := os .Remove (tokenFile ); err != nil && ! errors .Is (err , fs .ErrNotExist ) {
106- return fmt .Errorf ("failed to remove %s: %w" , tokenFile , err )
143+
144+ switch {
145+ case path == base :
146+ // Skip the base directory, we don't want to remove it
147+ case d .IsDir ():
148+ // Keep track of directories we'll want to delete, if they end up empty.
149+ dirs = append (dirs , path )
150+ case slices .Contains (AllKinds , Kind (d .Name ())):
151+ // Remove recognized token files.
152+ if err := os .Remove (path ); err != nil {
153+ return fmt .Errorf ("removing file %s: %w" , path , err )
107154 }
108155 }
109- // Remove the (hopefully empty) audience directory.
110- // Ignore failures since other tools may have stored files in this cache.
111- dir := filepath .Join (base , file .Name ())
112- _ = os .Remove (dir )
156+ return nil
157+ }); err != nil {
158+ return err
113159 }
160+
161+ // Sort directories from longest to shortest to remove nested dirs first.
162+ sort .Slice (dirs , func (i , j int ) bool {
163+ return len (dirs [i ]) > len (dirs [j ])
164+ })
165+
166+ // Remove empty directories
167+ for _ , d := range dirs {
168+ ents , err := os .ReadDir (d )
169+ if err != nil {
170+ return fmt .Errorf ("reading %s: %w" , d , err )
171+ }
172+ // Remove the directory if it is empty
173+ if len (ents ) == 0 {
174+ if err := os .Remove (d ); err != nil {
175+ return fmt .Errorf ("removing directory %s: %w" , d , err )
176+ }
177+ }
178+ }
179+
114180 return nil
115181}
116182
117183// Path is the filepath of the token for the given audience.
118- func Path (kind Kind , audience string ) (string , error ) {
184+ func Path (kind Kind , audience string , opts ... Option ) (string , error ) {
185+ t := newToken (opts ... )
186+ return t .path (kind , audience )
187+ }
188+
189+ func (t token ) path (kind Kind , audience string ) (string , error ) {
119190 a := strings .ReplaceAll (audience , "/" , "-" )
120191 // Windows does not allow : as a valid character for directory names.
121192 // For backwards compatibility, keep : in directory names for non-Windows systems.
122193 // Ref: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
123194 if runtime .GOOS == "windows" {
124195 a = strings .ReplaceAll (a , ":" , "-" )
125196 }
126- fp := filepath .Join (a , string (kind ))
197+ // NB: empty elements in Join are ignored, so we don't need to
198+ // check the existence of t.alias here.
199+ fp := filepath .Join (a , t .alias , string (kind ))
127200 return cacheFilePath (fp )
128201}
129202
130203// RemainingLife returns the amount of time remaining before the token for
131204// the given audience expires. Returns 0 for expired and non-existent tokens.
132- func RemainingLife (kind Kind , audience string , less time.Duration ) time.Duration {
133- tok , err := Load (kind , audience )
205+ func RemainingLife (kind Kind , audience string , less time.Duration , opts ... Option ) time.Duration {
206+ t := newToken (opts ... )
207+ return t .remainingLife (kind , audience , less )
208+ }
209+
210+ func (t token ) remainingLife (kind Kind , audience string , less time.Duration ) time.Duration {
211+ tok , err := t .load (kind , audience )
134212 if err != nil {
135213 // Not a big deal, life is zero.
136214 return 0
@@ -156,10 +234,7 @@ var timeUntil = time.Until
156234// Safe calculation for duration remaining from a given time, less the given duration.
157235func subtractOrZero (expiry time.Time , less time.Duration ) time.Duration {
158236 life := timeUntil (expiry .Add (less * - 1 ))
159- if life < 0 {
160- return 0
161- }
162- return life
237+ return max (0 , life )
163238}
164239
165240func cacheFilePath (file string ) (string , error ) {
0 commit comments