Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
45fd80b418
130 files changed
+26739
No files matched your search
@@ -0,0 +1,342 @@
|
||||
// Package store keeps what lazysecrets remembers between runs in one JSON
|
||||
// file in the XDG state directory: a record per subscription, vault or
|
||||
// secret the user has starred, annotated or used, keyed by its
|
||||
// lazysecrets:// link, and the layout. It never holds secret values.
|
||||
//
|
||||
// JSON rather than YAML (the config's format) because the app writes it,
|
||||
// and so an editor plugin can read it with a built-in parser
|
||||
// (vim.json.decode).
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Node is what's remembered about one subscription, vault or secret. Name,
|
||||
// Path, Source and Account are context, so lists of nodes (favourites,
|
||||
// recent) can show and jump to them before their subscription is loaded.
|
||||
type Node struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Path []string `json:"path,omitempty"` // parents' names: [source] or [source, vault]
|
||||
Source string `json:"source,omitempty"` // ID of the subscription or workspace profile
|
||||
Account string `json:"account,omitempty"` // provider ID that last read it
|
||||
|
||||
// StarredBy lists the accounts (provider IDs) the item is starred
|
||||
// for: a star is the item through that account. A note describes the
|
||||
// item itself, whichever account shows it.
|
||||
StarredBy []string `json:"starredBy,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
|
||||
LastUsed time.Time `json:"lastUsed,omitzero"`
|
||||
Uses int `json:"uses,omitempty"`
|
||||
}
|
||||
|
||||
// keep reports whether the record holds anything worth keeping; context
|
||||
// alone isn't.
|
||||
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
|
||||
|
||||
// StarredFor reports whether the item is starred for account.
|
||||
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
|
||||
|
||||
// ToggleStar stars or unstars the item for account, reporting whether
|
||||
// it's starred now.
|
||||
func (n *Node) ToggleStar(account string) bool {
|
||||
if i := slices.Index(n.StarredBy, account); i >= 0 {
|
||||
n.StarredBy = slices.Delete(n.StarredBy, i, i+1)
|
||||
return false
|
||||
}
|
||||
n.StarredBy = append(n.StarredBy, account)
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateCheck is the last check for a newer release.
|
||||
func (s *Store) UpdateCheck() UpdateCheck {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Update
|
||||
}
|
||||
|
||||
// SetUpdateCheck records a check for a newer release.
|
||||
func (s *Store) SetUpdateCheck(c UpdateCheck) error {
|
||||
return s.save(func(d *file) { d.Update = c })
|
||||
}
|
||||
|
||||
// Place is where lazysecrets was left when it last ran in dir.
|
||||
func (s *Store) Place(dir string) (Place, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, ok := s.data.Places[dir]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// SetPlace remembers where lazysecrets was left in dir.
|
||||
func (s *Store) SetPlace(dir string, p Place) error {
|
||||
return s.save(func(d *file) {
|
||||
if d.Places == nil {
|
||||
d.Places = map[string]Place{}
|
||||
}
|
||||
d.Places[dir] = p
|
||||
for len(d.Places) > maxPlaces {
|
||||
oldest := ""
|
||||
for k, v := range d.Places {
|
||||
if oldest == "" || v.At.Before(d.Places[oldest].At) {
|
||||
oldest = k
|
||||
}
|
||||
}
|
||||
delete(d.Places, oldest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Layout is the user's pane sizing, as offsets from the default layout so
|
||||
// it adapts to the terminal size.
|
||||
type Layout struct {
|
||||
Split int `json:"split,omitempty"` // columns the divider moved
|
||||
Heights [3]int `json:"heights,omitzero"` // rows added to each left pane
|
||||
}
|
||||
|
||||
type file struct {
|
||||
Version int `json:"version"`
|
||||
Nodes map[string]Node `json:"nodes"`
|
||||
Layout Layout `json:"layout,omitzero"`
|
||||
// Flags are one-off choices, like "don't ask again".
|
||||
Flags map[string]bool `json:"flags,omitempty"`
|
||||
// Update is the last check for a newer release.
|
||||
Update UpdateCheck `json:"update,omitzero"`
|
||||
// Places is where lazysecrets was left, by the folder it ran in.
|
||||
Places map[string]Place `json:"places,omitempty"`
|
||||
}
|
||||
|
||||
// Place is what was selected when lazysecrets quit: an item's link, and
|
||||
// the account and source it was read through.
|
||||
type Place struct {
|
||||
Link string `json:"link"`
|
||||
Account string `json:"account"`
|
||||
Source string `json:"source"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// maxPlaces bounds Places: the folders used most recently are kept.
|
||||
const maxPlaces = 100
|
||||
|
||||
// UpdateCheck is the last look for a newer release: when, and what the
|
||||
// latest was (its tag, changelog and page), so the next start within a
|
||||
// day shows it without asking again.
|
||||
type UpdateCheck struct {
|
||||
At time.Time `json:"at,omitzero"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
const version = 1
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
data file
|
||||
}
|
||||
|
||||
// Load reads path; a missing file is an empty store. path "" gives a store
|
||||
// that never saves, for tests and --demo.
|
||||
func Load(path string) (*Store, error) {
|
||||
s := &Store{path: path}
|
||||
d, err := read(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.data = d
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func read(path string) (file, error) {
|
||||
d := file{Version: version, Nodes: map[string]Node{}}
|
||||
if path == "" {
|
||||
return d, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return d, nil
|
||||
}
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
return d, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if d.Version > version {
|
||||
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
|
||||
}
|
||||
if d.Nodes == nil {
|
||||
d.Nodes = map[string]Node{}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Node returns the record for key, empty if there is none.
|
||||
func (s *Store) Node(key string) Node {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Nodes[key]
|
||||
}
|
||||
|
||||
// Has reports whether there's a record for key.
|
||||
func (s *Store) Has(key string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, ok := s.data.Nodes[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Nodes returns a copy of all records.
|
||||
func (s *Store) Nodes() map[string]Node {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return maps.Clone(s.data.Nodes)
|
||||
}
|
||||
|
||||
// Update changes the record for key and saves. Another lazysecrets may
|
||||
// have saved in the meantime, so the file is read again first and only
|
||||
// this record is changed in it. A record left with nothing worth keeping
|
||||
// is removed.
|
||||
func (s *Store) Update(key string, change func(*Node)) error {
|
||||
return s.save(func(d *file) {
|
||||
n := d.Nodes[key]
|
||||
change(&n)
|
||||
if n.keep() {
|
||||
d.Nodes[key] = n
|
||||
} else {
|
||||
delete(d.Nodes, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Usage records — ones kept only for their use — are pruned when they
|
||||
// go unused this long, and beyond this many, oldest first.
|
||||
const (
|
||||
usageMaxAge = 90 * 24 * time.Hour
|
||||
usageMax = 100
|
||||
)
|
||||
|
||||
// Use counts a use of key at the given time, with change updating its
|
||||
// context, and prunes old usage records.
|
||||
func (s *Store) Use(key string, at time.Time, change func(*Node)) error {
|
||||
return s.save(func(d *file) {
|
||||
n := d.Nodes[key]
|
||||
change(&n)
|
||||
n.Uses++
|
||||
n.LastUsed = at
|
||||
d.Nodes[key] = n
|
||||
prune(d.Nodes, at)
|
||||
})
|
||||
}
|
||||
|
||||
// prune drops usage records unused since usageMaxAge before now, and the
|
||||
// oldest beyond usageMax. Starred and noted records stay.
|
||||
func prune(nodes map[string]Node, now time.Time) {
|
||||
var usage []string
|
||||
for k, n := range nodes {
|
||||
if len(n.StarredBy) > 0 || n.Note != "" {
|
||||
continue
|
||||
}
|
||||
if now.Sub(n.LastUsed) > usageMaxAge {
|
||||
delete(nodes, k)
|
||||
continue
|
||||
}
|
||||
usage = append(usage, k)
|
||||
}
|
||||
if len(usage) <= usageMax {
|
||||
return
|
||||
}
|
||||
slices.SortFunc(usage, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
|
||||
for _, k := range usage[usageMax:] {
|
||||
delete(nodes, k)
|
||||
}
|
||||
}
|
||||
|
||||
// Flag reports a one-off choice.
|
||||
func (s *Store) Flag(name string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Flags[name]
|
||||
}
|
||||
|
||||
// SetFlag records a one-off choice.
|
||||
func (s *Store) SetFlag(name string, on bool) error {
|
||||
return s.save(func(d *file) {
|
||||
if d.Flags == nil {
|
||||
d.Flags = map[string]bool{}
|
||||
}
|
||||
if on {
|
||||
d.Flags[name] = true
|
||||
} else {
|
||||
delete(d.Flags, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) Layout() Layout {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Layout
|
||||
}
|
||||
|
||||
func (s *Store) SetLayout(l Layout) error {
|
||||
return s.save(func(d *file) { d.Layout = l })
|
||||
}
|
||||
|
||||
// save applies change to the file as it is now on disk (or to memory, for
|
||||
// an unsaved store) and writes it atomically, readable by the user only.
|
||||
func (s *Store) save(change func(*file)) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
d := s.data
|
||||
if s.path != "" {
|
||||
var err error
|
||||
if d, err = read(s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
d.Nodes = maps.Clone(d.Nodes)
|
||||
}
|
||||
change(&d)
|
||||
d.Version = version
|
||||
if s.path != "" {
|
||||
b, err := json.MarshalIndent(d, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(s.path, append(b, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.data = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomic(path string, b []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // no-op after a successful rename
|
||||
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,114 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const key = "lazysecrets://azure/kv.vault.azure.net/secrets/db-password"
|
||||
|
||||
func TestUpdateAndReload(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state", "state.json")
|
||||
s, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Update(key, func(n *Node) {
|
||||
n.Name, n.Path, n.StarredBy, n.Note = "db-password", []string{"prod", "kv"}, []string{"Azure"}, "prod SQL admin"
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetLayout(Layout{Split: 8}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := again.Node(key); !n.StarredFor("Azure") || n.Note != "prod SQL admin" || n.Path[1] != "kv" {
|
||||
t.Errorf("reloaded %+v", n)
|
||||
}
|
||||
if again.Layout().Split != 8 {
|
||||
t.Error("layout lost")
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("mode %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextAloneIsntKept(t *testing.T) {
|
||||
s, _ := Load("")
|
||||
_ = s.Update(key, func(n *Node) { n.StarredBy, n.Name = []string{"Azure"}, "db-password" })
|
||||
_ = s.Update(key, func(n *Node) { n.StarredBy = nil })
|
||||
if _, ok := s.Nodes()[key]; ok {
|
||||
t.Error("a record with only context left was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
a, _ := Load(path)
|
||||
b, _ := Load(path)
|
||||
_ = a.Update("one", func(n *Node) { n.StarredBy = []string{"Azure"} })
|
||||
_ = b.Update("two", func(n *Node) { n.Note = "hi" }) // b hasn't seen "one"
|
||||
_ = a.Update("one", func(n *Node) { n.Uses++; n.LastUsed = time.Now() })
|
||||
|
||||
final, _ := Load(path)
|
||||
if n := final.Node("one"); !n.StarredFor("Azure") || n.Uses != 1 {
|
||||
t.Errorf("one = %+v", n)
|
||||
}
|
||||
if final.Node("two").Note != "hi" {
|
||||
t.Error("b's change lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerVersionRefused(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Error("a newer file was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseCountsAndPrunes(t *testing.T) {
|
||||
s, _ := Load("")
|
||||
now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
|
||||
ctx := func(n *Node) { n.Name = "x" }
|
||||
_ = s.Use("old", now.Add(-100*24*time.Hour), ctx)
|
||||
_ = s.Update("old-starred", func(n *Node) { n.StarredBy = []string{"Azure"}; n.Uses = 1 })
|
||||
_ = s.Use("a", now.Add(-time.Hour), ctx)
|
||||
_ = s.Use("a", now, ctx)
|
||||
if n := s.Node("a"); n.Uses != 2 || !n.LastUsed.Equal(now) || n.Name != "x" {
|
||||
t.Errorf("a = %+v", n)
|
||||
}
|
||||
if s.Has("old") {
|
||||
t.Error("record unused for 100 days kept")
|
||||
}
|
||||
if !s.Has("old-starred") {
|
||||
t.Error("starred record pruned")
|
||||
}
|
||||
|
||||
for i := range usageMax + 5 {
|
||||
_ = s.Use(fmt.Sprint("n", i), now.Add(time.Duration(i)*time.Minute), ctx)
|
||||
}
|
||||
if got := len(s.Nodes()); got != usageMax+1 { // + old-starred
|
||||
t.Errorf("%d records, want %d", got, usageMax+1)
|
||||
}
|
||||
if s.Has("n0") || !s.Has(fmt.Sprint("n", usageMax+4)) {
|
||||
t.Error("pruned the wrong end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarsPerAccount(t *testing.T) {
|
||||
var n Node
|
||||
if !n.ToggleStar("Azure:alice") || !n.StarredFor("Azure:alice") || n.StarredFor("Azure:bob") {
|
||||
t.Errorf("star for alice: %v", n.StarredBy)
|
||||
}
|
||||
n.ToggleStar("Azure:bob")
|
||||
if n.ToggleStar("Azure:alice") || n.StarredFor("Azure:alice") || !n.StarredFor("Azure:bob") {
|
||||
t.Errorf("unstar alice: %v", n.StarredBy)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user