feat: keep what was loaded between runs, and start where you left off #2

Merged
keb merged 3 commits from feat/disk-cache into main 2026-10-11 09:53:33 +02:00
11 changed files with 506 additions and 13 deletions
Showing only changes of commit d6c4b33d1d - Show all commits

No files matched your search

+5 -3
View File
@@ -90,9 +90,11 @@ use most:
## Privacy ## Privacy
lazysecrets never stores, caches or sends your secret values. It talks only lazysecrets never stores, caches or sends your secret values. It keeps the
to the services you use it with, and collects no usage data. The one call names and metadata of what you've loaded in a cache file readable only by
of its own is a daily check for a new release on this server, which you, to show it at once next time (`cache: false` turns it off). It talks
only to the services you use it with, and collects no usage data. The one
call of its own is a daily check for a new release on this server, which
`updateCheck: false` turns off. `updateCheck: false` turns off.
+21 -1
View File
@@ -334,6 +334,21 @@ Flags:
onActive = srv.Touch onActive = srv.Touch
} }
// What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string
if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
if placeDir == "" {
placeDir, _ = os.Getwd()
}
if abs, err := filepath.Abs(placeDir); err == nil {
placeDir = abs
}
}
m := app.New(providers, app.Options{ m := app.New(providers, app.Options{
Timeout: *timeout, Timeout: *timeout,
Icons: *icons, Icons: *icons,
@@ -353,11 +368,16 @@ Flags:
Commands: cfg.CustomCommands, Commands: cfg.CustomCommands,
Version: versionString(), Version: versionString(),
Store: st, Store: st,
CacheFile: cacheFile,
Dir: placeDir,
Open: open, Open: open,
Styles: &theme, Styles: &theme,
DefaultFg: defaultFg, DefaultFg: defaultFg,
}) })
p = tea.NewProgram(m) p = tea.NewProgram(m)
_, err = p.Run() final, err := p.Run()
if fm, ok := final.(app.Model); ok {
_ = fm.Persist() // a cache it couldn't save is no reason to fail
}
return err return err
} }
+20
View File
@@ -16,6 +16,7 @@ sourcesView: grouped # or flat; ` toggles it
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way) updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
history: true # count uses of items for Recent (R) history: true # count uses of items for Recent (R)
cache: true # keep what was loaded (never values) for the next start
providers: providers:
azure: # applies to every az account azure: # applies to every az account
enabled: true # default enabled: true # default
@@ -102,6 +103,25 @@ count as using it: its record keeps when it was last used and how often,
for Recent (`R`). Records kept only for usage are dropped after 90 days for Recent (`R`). Records kept only for usage are dropped after 90 days
unused, and beyond the 100 most recent. `history: false` stops counting. unused, and beyond the 100 most recent. `history: false` stops counting.
It also keeps where you left lazysecrets in each folder (the selected
source, vault or secret), and goes back there the next time it starts in
that folder, unless it was started on a link.
## Cache
What lazysecrets has loaded is kept in
`~/.cache/lazysecrets/cache.json` (`$XDG_CACHE_HOME`), readable only by
you: the Sources lists, vaults and scopes, and secrets' names and metadata
(tags, dates, references), never values. On the next start they show at
once, and anything older than two minutes reloads in the background when
you go to it, or when Go to searches it. Revealing and copying always
fetch the value live.
Lists not loaded for 30 days are dropped, and so is everything from an
account you're no longer signed in to. Repos aren't cached: their files
are read from disk anyway. Deleting the file is always safe, and
`cache: false` stops using it.
## Updates ## Updates
Once a day (`updateCheck`), in the background, lazysecrets asks the Once a day (`updateCheck`), in the background, lazysecrets asks the
+9
View File
@@ -40,6 +40,9 @@ type Config struct {
// background; defaults to true. U and lazysecrets update work either // background; defaults to true. U and lazysecrets update work either
// way. // way.
UpdateCheck *bool `yaml:"updateCheck"` UpdateCheck *bool `yaml:"updateCheck"`
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// CollapseGroups starts the Sources pane with its groups folded; // CollapseGroups starts the Sources pane with its groups folded;
// defaults to true. // defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"` CollapseGroups *bool `yaml:"collapseGroups"`
@@ -119,6 +122,12 @@ func Dir() (string, error) {
return xdgDir("XDG_CONFIG_HOME", ".config") return xdgDir("XDG_CONFIG_HOME", ".config")
} }
// CacheDir is $XDG_CACHE_HOME/lazysecrets, defaulting to
// ~/.cache/lazysecrets. It holds what was loaded, to show it at once.
func CacheDir() (string, error) {
return xdgDir("XDG_CACHE_HOME", ".cache")
}
// StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to // StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to
// ~/.local/state/lazysecrets. It holds data the app writes, like favourites. // ~/.local/state/lazysecrets. It holds data the app writes, like favourites.
func StateDir() (string, error) { func StateDir() (string, error) {
+41
View File
@@ -69,6 +69,33 @@ func (s *Store) SetUpdateCheck(c UpdateCheck) error {
return s.save(func(d *file) { d.Update = c }) 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 // Layout is the user's pane sizing, as offsets from the default layout so
// it adapts to the terminal size. // it adapts to the terminal size.
type Layout struct { type Layout struct {
@@ -84,8 +111,22 @@ type file struct {
Flags map[string]bool `json:"flags,omitempty"` Flags map[string]bool `json:"flags,omitempty"`
// Update is the last check for a newer release. // Update is the last check for a newer release.
Update UpdateCheck `json:"update,omitzero"` 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 // 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 // latest was (its tag, changelog and page), so the next start within a
// day shows it without asking again. // day shows it without asking again.
+40 -1
View File
@@ -68,6 +68,12 @@ type Options struct {
ClipboardClear time.Duration ClipboardClear time.Duration
// OpenURL opens a web page; defaults to the system browser. // OpenURL opens a web page; defaults to the system browser.
OpenURL func(url string) error OpenURL func(url string) error
// CacheFile keeps what was loaded between runs; "" keeps it for this
// run only.
CacheFile string
// Dir is the folder lazysecrets runs in: it goes back to where it was
// left there, unless it was started on a link. "" doesn't.
Dir string
// FlatSources lists sources without provider groups. // FlatSources lists sources without provider groups.
FlatSources bool FlatSources bool
// CollapseGroups starts with the Sources pane's groups folded, when // CollapseGroups starts with the Sources pane's groups folded, when
@@ -156,7 +162,13 @@ type Model struct {
clip Clipboard clip Clipboard
clipClear time.Duration clipClear time.Duration
openURL func(string) error openURL func(string) error
now func() time.Time // The cache file, whether the cache changed since it was saved, and
// when each provider's Sources list was loaded (persist.go).
cachePath string
cacheDirty bool
sourcesAt map[string]time.Time
placeDir string
now func() time.Time
// A pending clear of a copied value, counted down in the footer. // A pending clear of a copied value, counted down in the footer.
// clipToken identifies the latest copy so older ticks are ignored. // clipToken identifies the latest copy so older ticks are ignored.
@@ -410,6 +422,17 @@ func New(providers []provider.Provider, opts Options) Model {
m.sourcesGen = 1 m.sourcesGen = 1
m.sourcesPending = len(providers) m.sourcesPending = len(providers)
m.spinning = true m.spinning = true
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
if m.cachePath != "" {
m.loadCacheFile()
if len(m.sourceResults) > 0 {
m.rebuildSources()
}
}
if m.placeDir != "" && opts.Open == nil {
m.restorePlace()
}
return m return m
} }
@@ -433,6 +456,9 @@ func (m *Model) setVersions(items []provider.Object) {
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()} cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()}
if m.cachePath != "" {
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
}
if m.fetchOnStart { if m.fetchOnStart {
cmds = append(cmds, m.fetchLatest(false)) cmds = append(cmds, m.fetchLatest(false))
} }
@@ -522,6 +548,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case commandDoneMsg: case commandDoneMsg:
m.handleCommandDone(msg) m.handleCommandDone(msg)
case persistTickMsg:
cmds = append(cmds, m.persistTick())
case persistedMsg:
m.handlePersisted(msg)
case crawlMsg: case crawlMsg:
cmds = append(cmds, m.handleCrawl(msg)) cmds = append(cmds, m.handleCrawl(msg))
@@ -552,6 +584,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.containers.SetError(msg.err) m.containers.SetError(msg.err)
} else { } else {
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()} m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
m.cacheDirty = true
m.containers.SetItems(m.starredContainersFirst(msg.items)) m.containers.SetItems(m.starredContainersFirst(msg.items))
} }
@@ -572,6 +605,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
msg.key = m.rebind(*msg.bound) msg.key = m.rebind(*msg.bound)
} }
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
m.cacheDirty = true
if m.versionsOf != nil { if m.versionsOf != nil {
m.setVersions(msg.items) m.setVersions(msg.items)
} else { } else {
@@ -601,6 +635,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Errors and unsupported providers just leave the count out. // Errors and unsupported providers just leave the count out.
if msg.err == nil { if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
m.cacheDirty = true
m.detail.SetVersions(msg.id, msg.items) m.detail.SetVersions(msg.id, msg.items)
} }
@@ -614,6 +649,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.detail.SetDescribeError(msg.err) m.detail.SetDescribeError(msg.err)
} else { } else {
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()} m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
m.cacheDirty = true
m.detail.SetDescribed(msg.obj) m.detail.SetDescribed(msg.obj)
} }
@@ -1203,6 +1239,9 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
} }
m.sourcesPending-- m.sourcesPending--
m.sourceResults[msg.provider] = msg m.sourceResults[msg.provider] = msg
if msg.err == nil {
m.sourcesAt[msg.provider], m.cacheDirty = m.now(), true
}
var errs []string var errs []string
for _, p := range m.providers { for _, p := range m.providers {
+2 -2
View File
@@ -10,8 +10,8 @@ import (
// entries are still shown immediately, then reloaded in the background. // entries are still shown immediately, then reloaded in the background.
const cacheTTL = 2 * time.Minute const cacheTTL = 2 * time.Minute
// cache holds metadata for this session only. It never holds secret values // cache holds metadata: never secret values or errors. It's kept between
// or errors, and is never written to disk. // runs in the cache file (persist.go).
type cache struct { type cache struct {
containers map[string]entry[[]provider.Container] // by source key containers map[string]entry[[]provider.Container] // by source key
objects map[string]entry[[]provider.Object] // by container key (ID|type) objects map[string]entry[[]provider.Object] // by container key (ID|type)
+6 -1
View File
@@ -43,6 +43,9 @@ type nav struct {
sourceRow string // Sources row to select sourceRow string // Sources row to select
stage navStage stage navStage
// quiet gives up without a word: going back to where lazysecrets
// was left, which may be gone.
quiet bool
} }
type resolvedMsg struct { type resolvedMsg struct {
@@ -460,7 +463,9 @@ func (m *Model) addLinked(c provider.Container) {
} }
func (m *Model) navFail(format string, a ...any) { func (m *Model) navFail(format string, a ...any) {
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true} if !m.nav.quiet {
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
}
m.nav = nil m.nav = nil
} }
+243
View File
@@ -0,0 +1,243 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// What was loaded is kept between runs in a cache file (Options.CacheFile):
// the Sources lists, vaults, secrets' metadata and where the cursor was in
// each. Starting again shows it at once, and lists older than cacheTTL
// reload in the background as usual. Values are never in it; neither are
// repos, whose files are read from disk anyway.
//
// And where lazysecrets was left, per folder it ran in, goes in the store,
// to go back there on the next start.
// cacheFileVersion is the file's format; a file of another version is
// ignored, and replaced on the next save.
const cacheFileVersion = 1
// cacheMaxAge drops what hasn't been loaded again for this long.
const cacheMaxAge = 30 * 24 * time.Hour
// persistEvery is how often a changed cache is saved while running, so
// little is lost if lazysecrets is killed rather than quit.
const persistEvery = 30 * time.Second
type cacheFile 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"`
}
type stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
type persistTickMsg struct{}
type persistedMsg struct{ err error }
// keepCached reports whether a cache key's account still exists and
// isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool {
id, _ := splitSourceRow(key)
p, ok := m.byID[id]
return ok && p.Kind() != provider.KindRepo
}
// loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() {
b, err := os.ReadFile(m.cachePath)
if err != nil {
return
}
var f cacheFile
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
return
}
now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
for id, e := range f.Sources {
if m.keepCached(id+"|") && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At
}
}
for k, e := range f.Containers {
if m.keepCached(k) && recent(e.At) {
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
}
}
for k, e := range f.Objects {
if m.keepCached(k) && recent(e.At) {
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
}
}
for k, e := range f.Details {
if m.keepCached(k) && recent(e.At) {
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
}
}
for k, v := range f.Cursor {
if m.keepCached(k) {
m.cache.cursor[k] = v
}
}
}
// cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cacheFile {
f := cacheFile{
Version: cacheFileVersion,
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{},
}
for id, r := range m.sourceResults {
if r.err == nil && m.keepCached(id+"|") {
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
}
}
for k, e := range m.cache.containers {
if m.keepCached(k) {
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
}
}
for k, e := range m.cache.objects {
if m.keepCached(k) {
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
}
}
for k, e := range m.cache.details {
if m.keepCached(k) {
f.Details[k] = stamped[provider.Object]{e.at, e.value}
}
}
for k, v := range m.cache.cursor {
if m.keepCached(k) {
f.Cursor[k] = v
}
}
return f
}
// writeCacheFile writes f atomically, readable by the user only.
func writeCacheFile(path string, f cacheFile) 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)
}
// persistTick saves the cache in the background when it changed.
func (m *Model) persistTick() tea.Cmd {
next := tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })
if !m.cacheDirty {
return next
}
m.cacheDirty = false
f, path := m.cacheSnapshot(), m.cachePath
return tea.Batch(next, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
}
// Persist saves the cache and where lazysecrets was left; run it after
// the program ends.
func (m Model) Persist() error {
var errs []error
if m.cachePath != "" {
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
}
if m.placeDir != "" {
if p, ok := m.place(); ok {
errs = append(errs, m.store.SetPlace(m.placeDir, p))
}
}
return errors.Join(errs...)
}
// place is the selected item, as deep as the focus.
func (m *Model) place() (store.Place, bool) {
src, ok := m.selectedSource()
if !ok || m.nav != nil {
return store.Place{}, false
}
var r ref.Ref
var err error
switch c, cok := m.containers.Selected(); {
case m.focus == paneSources || !cok:
r, err = ref.ForSource(src)
case m.focus == paneContainers:
r, err = ref.ForContainer(c, m.objType)
default:
o, ook := m.detail.Secret()
if !ook {
r, err = ref.ForContainer(c, m.objType)
} else {
r, err = ref.ForObject(o, "")
}
}
if err != nil {
return store.Place{}, false
}
return store.Place{Link: r.String(), Account: src.Provider, Source: src.ID, At: m.now()}, true
}
// restorePlace goes back to where lazysecrets was left in dir, if that
// account is still signed in.
func (m *Model) restorePlace() {
p, ok := m.store.Place(m.placeDir)
if !ok {
return
}
if _, ok := m.byID[p.Account]; !ok {
return
}
r, err := ref.Parse(p.Link)
if err != nil {
return
}
m.startNav(r, actGo, sourceRowKey(p.Account, p.Source))
m.nav.sourceOnly = r.IsSource()
m.nav.quiet = true
}
func (m *Model) handlePersisted(msg persistedMsg) {
if msg.err != nil {
m.noteErr(fmt.Errorf("saving the cache: %w", msg.err))
}
}
+110
View File
@@ -0,0 +1,110 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "lazysecrets", "cache.json")
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
d.keys("3", "space")
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
t.Fatalf("value not revealed:\n%s", d.screen())
}
src, vault, secret := d.selected()
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "correct-horse") {
t.Error("a value is in the cache file")
}
if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 {
t.Errorf("cache file mode %v", fi.Mode().Perm())
}
// Next run, with a backend too slow to answer during the test: the
// lists show from the file, and the cursor is where it was.
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
d2.keys("2")
if _, v, _ := d2.selected(); v != vault {
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
}
d2.keys("3")
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
t.Errorf("selected %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
}
}
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
az := fake.Azure(0)
now := time.Now()
f := cacheFile{
Version: cacheFileVersion,
Containers: map[string]stamped[[]provider.Container]{
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
},
}
if err := writeCacheFile(file, f); err != nil {
t.Fatal(err)
}
d := newDriverOpts(t, Options{CacheFile: file}, az)
c := d.m.cache.containers
if _, ok := c[az.ID()+"|recent"]; !ok {
t.Error("dropped a recent entry")
}
if _, ok := c[az.ID()+"|old"]; ok {
t.Error("kept an entry older than cacheMaxAge")
}
if _, ok := c["Azure:gone@x/t|recent"]; ok {
t.Error("kept an entry of an account that isn't signed in")
}
}
func TestBackWhereItWasLeft(t *testing.T) {
t.Parallel()
st, _ := store.Load("")
dir := "/work/project"
d := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
d.keys("3", "j", "j")
src, vault, secret := d.selected()
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
if p, ok := st.Place(dir); !ok || !strings.Contains(p.Link, secret) {
t.Fatalf("place %+v", p)
}
d2 := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
t.Errorf("back at %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
}
// Another folder starts as usual; so does opening a link.
d3 := newDriverOpts(t, Options{Store: st, Dir: "/elsewhere"}, fake.Azure(0))
if _, _, o := d3.selected(); o == secret {
t.Errorf("another folder went to %s too", o)
}
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password")
d4 := newDriverOpts(t, Options{Store: st, Dir: dir, Open: &r}, fake.Azure(0))
if d4.m.nav != nil && d4.m.nav.quiet {
t.Error("a link was overridden by where it was left")
}
}
+9 -5
View File
@@ -232,11 +232,12 @@ func (m *Model) crawlStart() tea.Cmd {
return cmd return cmd
} }
// queueSource queues listing src's vaults or, with them listed and not // queueSource queues listing src's vaults or, with them listed lately
// force, loading their secrets. // and not force, loading their secrets. Lists from an earlier run (the
// cache file) are listed again, so searching brings them up to date.
func (m *Model) queueSource(src provider.Source, force bool) { func (m *Model) queueSource(src provider.Source, force bool) {
row := sourceRowKey(src.Provider, src.ID) row := sourceRowKey(src.Provider, src.ID)
if e, ok := m.cache.containers[row]; ok && !force { if e, ok := m.cache.containers[row]; ok && e.fresh(m.now()) && !force {
for _, c := range e.value { for _, c := range e.value {
m.queueContainer(c, row, false) m.queueContainer(c, row, false)
} }
@@ -245,10 +246,11 @@ func (m *Model) queueSource(src provider.Source, force bool) {
m.enqueue(crawlJob{src: &src, row: row, force: force}, row) m.enqueue(crawlJob{src: &src, row: row, force: force}, row)
} }
// queueContainer queues loading c's secrets, unless loaded and not force. // queueContainer queues loading c's secrets, unless loaded lately and not
// force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) { func (m *Model) queueContainer(c provider.Container, row string, force bool) {
key := containerKey(c) + "|" + string(provider.TypeSecret) key := containerKey(c) + "|" + string(provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok && !force { if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return return
} }
m.enqueue(crawlJob{container: &c, row: row, force: force}, key) m.enqueue(crawlJob{container: &c, row: row, force: force}, key)
@@ -319,12 +321,14 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
case msg.job.src != nil: case msg.job.src != nil:
delete(m.crawl.failed, key) delete(m.crawl.failed, key)
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()} m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
m.cacheDirty = true
for _, c := range msg.containers { for _, c := range msg.containers {
m.queueContainer(c, key, msg.job.force) m.queueContainer(c, key, msg.job.force)
} }
default: default:
delete(m.crawl.failed, key) delete(m.crawl.failed, key)
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()} m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
m.cacheDirty = true
} }
cmd := m.crawlNext() cmd := m.crawlNext()
m.refreshSearch() m.refreshSearch()