// Package cachefile is the cache of what lazysecrets has loaded // (cache.json): the Sources lists, vaults, secrets' metadata and where // the cursor was in each, never values. The TUI and lazysecrets list // both read it and save to it; a save merges with what's on disk, so // neither undoes what the other loaded. package cachefile import ( "encoding/json" "maps" "os" "path/filepath" "strings" "time" "git.kebcloud.no/keb/lazysecrets/internal/provider" ) // Version is the file's format; a file of another version is ignored, // and replaced on the next save. const Version = 1 // MaxAge drops what hasn't been loaded again for this long. const MaxAge = 30 * 24 * time.Hour // File is the cache. Every key starts with the provider ID: Sources are // keyed by it, the rest by Row, Objects' keys and the like. type File struct { Version int `json:"version"` Sources map[string]Stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID Containers map[string]Stamped[[]provider.Container] `json:"containers,omitempty"` Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"` Details map[string]Stamped[provider.Object] `json:"details,omitempty"` Cursor map[string]string `json:"cursor,omitempty"` } // Stamped is a list or detail with when it was loaded. type Stamped[T any] struct { At time.Time `json:"at"` Value T `json:"value"` } // New is an empty cache. func New() File { return File{ Version: Version, Sources: map[string]Stamped[[]provider.Source]{}, Containers: map[string]Stamped[[]provider.Container]{}, Objects: map[string]Stamped[[]provider.Object]{}, Details: map[string]Stamped[provider.Object]{}, Cursor: map[string]string{}, } } // Row keys a source's vaults or scopes. func Row(providerID, sourceID string) string { return providerID + "|" + sourceID } // ContainerKey keys what's cached about a vault or scope. func ContainerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID } // ObjectsKey keys a vault's secrets, keys or certificates. func ObjectsKey(c provider.Container, t provider.ObjectType) string { return ContainerKey(c) + "|" + string(t) } // ProviderOf is the provider ID a key starts with. func ProviderOf(key string) string { id, _, _ := strings.Cut(key, "|") return id } // Read reads path; a missing, unreadable or other version's file is an // empty cache. func Read(path string) File { f := New() b, err := os.ReadFile(path) if err != nil { return f } var d File if json.Unmarshal(b, &d) != nil || d.Version != Version { return f } maps.Copy(f.Sources, d.Sources) maps.Copy(f.Containers, d.Containers) maps.Copy(f.Objects, d.Objects) maps.Copy(f.Details, d.Details) maps.Copy(f.Cursor, d.Cursor) return f } // Save writes f to path, merged with the file there: of a list in both, // the one loaded last wins, and f's cursors win. Entries older than // MaxAge, and those of providers keep says no to (accounts signed out // of), are dropped, from the file's side as well. func Save(path string, f File, keep func(providerID string) bool, now time.Time) error { out := New() ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge } disk := Read(path) merge(out.Sources, disk.Sources, f.Sources, ok) merge(out.Containers, disk.Containers, f.Containers, ok) merge(out.Objects, disk.Objects, f.Objects, ok) merge(out.Details, disk.Details, f.Details, ok) for _, m := range []map[string]string{disk.Cursor, f.Cursor} { for k, v := range m { if keep(ProviderOf(k)) { out.Cursor[k] = v } } } return write(path, out) } func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool) { for _, m := range []map[string]Stamped[T]{disk, ours} { for k, e := range m { if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) { out[k] = e } } } } // write writes f atomically, readable by the user only. func write(path string, f File) error { b, err := json.Marshal(f) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json") if err != nil { return err } defer os.Remove(tmp.Name()) // after a successful rename, nothing there if _, err := tmp.Write(b); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), path) }