feat: keep what was loaded between runs, and start where you left off
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6fab41703e
commit
d6c4b33d1d
11 files changed
+504
-11
No files matched your search
@@ -90,9 +90,11 @@ use most:
|
||||
|
||||
## Privacy
|
||||
|
||||
lazysecrets never stores, caches or sends your secret values. 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
|
||||
lazysecrets never stores, caches or sends your secret values. It keeps the
|
||||
names and metadata of what you've loaded in a cache file readable only by
|
||||
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.
|
||||
|
||||
|
||||
|
||||
+21
-1
@@ -334,6 +334,21 @@ Flags:
|
||||
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{
|
||||
Timeout: *timeout,
|
||||
Icons: *icons,
|
||||
@@ -353,11 +368,16 @@ Flags:
|
||||
Commands: cfg.CustomCommands,
|
||||
Version: versionString(),
|
||||
Store: st,
|
||||
CacheFile: cacheFile,
|
||||
Dir: placeDir,
|
||||
Open: open,
|
||||
Styles: &theme,
|
||||
DefaultFg: defaultFg,
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
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)
|
||||
cache: true # keep what was loaded (never values) for the next start
|
||||
providers:
|
||||
azure: # applies to every az account
|
||||
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
|
||||
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
|
||||
|
||||
Once a day (`updateCheck`), in the background, lazysecrets asks the
|
||||
|
||||
@@ -40,6 +40,9 @@ type Config struct {
|
||||
// background; defaults to true. U and lazysecrets update work either
|
||||
// way.
|
||||
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;
|
||||
// defaults to true.
|
||||
CollapseGroups *bool `yaml:"collapseGroups"`
|
||||
@@ -119,6 +122,12 @@ func Dir() (string, error) {
|
||||
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
|
||||
// ~/.local/state/lazysecrets. It holds data the app writes, like favourites.
|
||||
func StateDir() (string, error) {
|
||||
|
||||
@@ -69,6 +69,33 @@ 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 {
|
||||
@@ -84,8 +111,22 @@ type file struct {
|
||||
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.
|
||||
|
||||
@@ -68,6 +68,12 @@ type Options struct {
|
||||
ClipboardClear time.Duration
|
||||
// OpenURL opens a web page; defaults to the system browser.
|
||||
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 bool
|
||||
// CollapseGroups starts with the Sources pane's groups folded, when
|
||||
@@ -156,6 +162,12 @@ type Model struct {
|
||||
clip Clipboard
|
||||
clipClear time.Duration
|
||||
openURL func(string) error
|
||||
// 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.
|
||||
@@ -410,6 +422,17 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m.sourcesGen = 1
|
||||
m.sourcesPending = len(providers)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -433,6 +456,9 @@ func (m *Model) setVersions(items []provider.Object) {
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
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 {
|
||||
cmds = append(cmds, m.fetchLatest(false))
|
||||
}
|
||||
@@ -522,6 +548,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case commandDoneMsg:
|
||||
m.handleCommandDone(msg)
|
||||
|
||||
case persistTickMsg:
|
||||
cmds = append(cmds, m.persistTick())
|
||||
|
||||
case persistedMsg:
|
||||
m.handlePersisted(msg)
|
||||
|
||||
case crawlMsg:
|
||||
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)
|
||||
} else {
|
||||
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
|
||||
m.cacheDirty = true
|
||||
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)
|
||||
}
|
||||
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||
m.cacheDirty = true
|
||||
if m.versionsOf != nil {
|
||||
m.setVersions(msg.items)
|
||||
} 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.
|
||||
if msg.err == nil {
|
||||
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||
m.cacheDirty = true
|
||||
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)
|
||||
} else {
|
||||
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
|
||||
m.cacheDirty = true
|
||||
m.detail.SetDescribed(msg.obj)
|
||||
}
|
||||
|
||||
@@ -1203,6 +1239,9 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
|
||||
}
|
||||
m.sourcesPending--
|
||||
m.sourceResults[msg.provider] = msg
|
||||
if msg.err == nil {
|
||||
m.sourcesAt[msg.provider], m.cacheDirty = m.now(), true
|
||||
}
|
||||
|
||||
var errs []string
|
||||
for _, p := range m.providers {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
// entries are still shown immediately, then reloaded in the background.
|
||||
const cacheTTL = 2 * time.Minute
|
||||
|
||||
// cache holds metadata for this session only. It never holds secret values
|
||||
// or errors, and is never written to disk.
|
||||
// cache holds metadata: never secret values or errors. It's kept between
|
||||
// runs in the cache file (persist.go).
|
||||
type cache struct {
|
||||
containers map[string]entry[[]provider.Container] // by source key
|
||||
objects map[string]entry[[]provider.Object] // by container key (ID|type)
|
||||
|
||||
@@ -43,6 +43,9 @@ type nav struct {
|
||||
|
||||
sourceRow string // Sources row to select
|
||||
stage navStage
|
||||
// quiet gives up without a word: going back to where lazysecrets
|
||||
// was left, which may be gone.
|
||||
quiet bool
|
||||
}
|
||||
|
||||
type resolvedMsg struct {
|
||||
@@ -460,7 +463,9 @@ func (m *Model) addLinked(c provider.Container) {
|
||||
}
|
||||
|
||||
func (m *Model) navFail(format string, a ...any) {
|
||||
if !m.nav.quiet {
|
||||
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
|
||||
}
|
||||
m.nav = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -232,11 +232,12 @@ func (m *Model) crawlStart() tea.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// queueSource queues listing src's vaults or, with them listed and not
|
||||
// force, loading their secrets.
|
||||
// queueSource queues listing src's vaults or, with them listed lately
|
||||
// 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) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
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:
|
||||
delete(m.crawl.failed, key)
|
||||
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
|
||||
m.cacheDirty = true
|
||||
for _, c := range msg.containers {
|
||||
m.queueContainer(c, key, msg.job.force)
|
||||
}
|
||||
default:
|
||||
delete(m.crawl.failed, key)
|
||||
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
|
||||
m.cacheDirty = true
|
||||
}
|
||||
cmd := m.crawlNext()
|
||||
m.refreshSearch()
|
||||
|
||||
Reference in new issue
Block a user