feat: lazysecrets list prints every source, vault and secret, for fzf and editor pickers
This commit is contained in:
13 files changed
+886
-91
No files matched your search
@@ -0,0 +1,146 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package cachefile
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func list(at time.Time, names ...string) Stamped[[]provider.Container] {
|
||||
var cs []provider.Container
|
||||
for _, n := range names {
|
||||
cs = append(cs, provider.Container{Name: n})
|
||||
}
|
||||
return Stamped[[]provider.Container]{At: at, Value: cs}
|
||||
}
|
||||
|
||||
func TestSaveMerges(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
now := time.Now()
|
||||
all := func(string) bool { return true }
|
||||
|
||||
// The TUI saved two lists.
|
||||
tui := New()
|
||||
tui.Containers["az|a"] = list(now.Add(-time.Hour), "kv-a-old")
|
||||
tui.Containers["az|b"] = list(now.Add(-time.Minute), "kv-b")
|
||||
tui.Cursor["az|b"] = "kv-b"
|
||||
if err := Save(path, tui, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// lazysecrets list loads a again, and c.
|
||||
listed := Read(path)
|
||||
listed.Containers["az|a"] = list(now, "kv-a-new")
|
||||
listed.Containers["az|c"] = list(now, "kv-c")
|
||||
if err := Save(path, listed, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The TUI saves what it has, older: list's lists stay.
|
||||
if err := Save(path, tui, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := Read(path)
|
||||
for key, want := range map[string]string{"az|a": "kv-a-new", "az|b": "kv-b", "az|c": "kv-c"} {
|
||||
if got := f.Containers[key].Value; len(got) != 1 || got[0].Name != want {
|
||||
t.Errorf("%s = %v, want %s", key, got, want)
|
||||
}
|
||||
}
|
||||
if f.Cursor["az|b"] != "kv-b" {
|
||||
t.Error("cursor lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDrops(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
now := time.Now()
|
||||
f := New()
|
||||
f.Containers["az|old"] = list(now.Add(-MaxAge-time.Hour), "kv-old")
|
||||
f.Containers["gone|x"] = list(now, "kv-gone")
|
||||
f.Containers["az|x"] = list(now, "kv-x")
|
||||
if err := Save(path, f, func(id string) bool { return true }, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Signed out of "gone" since: dropped from the file too.
|
||||
if err := Save(path, New(), func(id string) bool { return id == "az" }, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := Read(path).Containers
|
||||
if _, ok := got["az|old"]; ok {
|
||||
t.Error("kept a list older than MaxAge")
|
||||
}
|
||||
if _, ok := got["gone|x"]; ok {
|
||||
t.Error("kept a signed-out account's list")
|
||||
}
|
||||
if _, ok := got["az|x"]; !ok {
|
||||
t.Error("dropped a list on disk the save didn't have")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOtherVersion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
f := New()
|
||||
f.Version = Version + 1
|
||||
f.Containers["az|x"] = list(time.Now(), "kv-x")
|
||||
if err := write(path, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := Read(path); len(got.Containers) != 0 || got.Version != Version {
|
||||
t.Errorf("read another version's file: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user