Hello world 👋
Release / Build and publish (push) Successful in 23s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-10 22:34:22 +02:00
commit 430875f200
111 files changed
+22958

No files matched your search

+301
View File
@@ -0,0 +1,301 @@
// 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 })
}
// 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"`
}
// 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)
}
+114
View File
@@ -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)
}
}