10 Commits
37 changed files with 509 additions and 228 deletions

No files matched your search

+23 -11
View File
@@ -12,16 +12,12 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching. fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it - **Share links**: Share `lazysecrets://` links with colleagues.
with their own access, from the terminal or with a click in the browser
or chat. `.env` files can be shared too, as a link encrypted with a
secret your team can read.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim) - **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and - **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pipes, and `lazysecrets list` everything it can find, for fzf and editor pipes.
pickers ([integration](docs/integration.md)).
- **Favourites**: star the secrets you use most for easy access. - **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are - **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane. marked, listed together in the status pane.
@@ -81,21 +77,37 @@ use most:
| `?` | every key, searchable | | `?` | every key, searchable |
| `q` | quit | | `q` | quit |
## Integration
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
Here is an example of implementing a picker in `fzf`:
```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](docs/assets/fzf-example.png)
More defails in the [Integration](docs/integration.md) docs
## Docs ## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys - [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults - [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes - [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting - [SOPS and .env files](docs/repos.md): which repos and files, decrypting
- [Links](docs/links.md): sharing, opening, and opening links from the - [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
browser and chat opening links, also from the browser and chat
- [Integration](docs/integration.md): `lazysecrets get` in scripts, and - [Integration](docs/integration.md): how lazysecrets can integrate into other systems
`lazysecrets list` for fzf and editor pickers
- [Custom commands](docs/custom-commands.md): run your own commands on a - [Custom commands](docs/custom-commands.md): run your own commands on a
secret secret
- [Config](docs/config.md): every option, themes, layered configs, the - [Config](docs/config.md): every option, themes, layered configs, the
state file state file
- [Development](docs/development.md): building, tests, how changes land and - [Development](docs/development.md): building, tests, pull requests and
releasing releasing
## Privacy ## Privacy
+45 -34
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"cmp"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -17,6 +18,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/listing" "git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -51,7 +53,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)") iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)") stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)")
recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)") recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)")
notes := fs.Bool("notes", true, "put ✎ before names with a note, in mutedColor (text; the note is printed either way)") notes := fs.Bool("notes", true, "start a note with a note icon (✎ without --icons) (text; the note is printed either way)")
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)") expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)") kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")") showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
@@ -75,7 +77,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
return fmt.Errorf("--color: %q is not always or never", *color) return fmt.Errorf("--color: %q is not always or never", *color)
} }
now := time.Now() now := time.Now()
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, notes: *notes, expiry: *expiry, m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry,
color: *color == "always", warn: expiryWarn, now: now} color: *color == "always", warn: expiryWarn, now: now}
if *iconsOn { if *iconsOn {
m.icons = icons.NerdFont m.icons = icons.NerdFont
@@ -93,7 +95,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
if status == "" && *expiry { if status == "" && *expiry {
status = expiryOf(e, theme, expiryWarn, now, colored) status = expiryOf(e, theme, expiryWarn, now, colored)
} }
note := renderer(colored)(theme.Muted, e.Note) note := noteOf(e, theme, m.icons, *notes, colored)
// A tab or line break in a name would break the line up. // A tab or line break in a name would break the line up.
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(status), _, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(status),
oneLine.Replace(note), e.Account, e.Link) oneLine.Replace(note), e.Account, e.Link)
@@ -126,20 +128,18 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
if cachePath != "" { if cachePath != "" {
f = cachefile.Read(cachePath) f = cachefile.Read(cachePath)
} }
f = listing.Run(context.Background(), listing.Options{ f, loaded := listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
Mark: func(link, account string) (bool, time.Time, int, string) { Mark: func(link, account string) listing.Marks {
n := st.Node(link) n := st.Node(link)
return n.StarredFor(account), n.LastUsed, n.Uses, n.Note return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
}, },
}, emit) }, emit)
if cachePath != "" && !*cached { // Only what was loaded: list never moves a cursor, so the TUI's stay.
byID := map[string]provider.Provider{} if cachePath != "" && loaded {
for _, p := range providers { f.Cursor = nil
byID[p.ID()] = p if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil {
}
keep := func(id string) bool { p, ok := byID[id]; return ok && p.Kind() != provider.KindRepo }
if err := cachefile.Save(cachePath, f, keep, time.Now()); err != nil {
return err return err
} }
} }
@@ -151,21 +151,31 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ") var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
// noteOf is the entry's note, muted, after a note icon (✎ without Nerd
// Font icons) with icon; or "".
func noteOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, icon, color bool) string {
if e.Note == "" {
return ""
}
note := e.Note
if icon {
note = cmp.Or(iconSet.Note, "✎") + " " + note
}
return renderer(color)(theme.Muted, note)
}
// expiryOf says that a secret, key or certificate has expired, in red, // expiryOf says that a secret, key or certificate has expired, in red,
// or when it will if that's within warn (expires in 12d), in yellow, or // or when it will if that's within warn (expires in 12d), in yellow, or
// is "". Its mark (✗, ⚠) is before the name. // is "". Its mark (✗, ⚠) is before the name.
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string { func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
render := renderer(color) render := renderer(color)
left := e.Expires.Sub(now) switch state, left := expiry.Of(e.Expires, now, warn); state {
switch { case expiry.Expired:
case e.Expires.IsZero() || left > warn:
return ""
case left <= 0:
return render(theme.Error, "expired") return render(theme.Error, "expired")
case left < 24*time.Hour: case expiry.Soon:
return render(theme.Warning, fmt.Sprintf("expires in %dh", max(1, int(left.Hours())))) return render(theme.Warning, "expires in "+expiry.Left(left))
} }
return render(theme.Warning, fmt.Sprintf("expires in %dd", int(left.Hours()/24))) return ""
} }
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗) // errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
@@ -194,15 +204,15 @@ func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
// marks says what goes before the name, in a field of its own so a // marks says what goes before the name, in a field of its own so a
// search leaves it out: ✗ in red when something's wrong (it couldn't be // search leaves it out: ✗ in red when something's wrong (it couldn't be
// read, or has expired) or ⚠ when it expires within warn; ★ for a starred // read, or has expired) or ⚠ when it expires within warn; ★ for a starred
// entry and ◷ for one used lately, as the TUI marks them; ✎ for one with a // entry and ◷ for one used lately, as the TUI marks them; then the
// note; then the provider's icon. The marks are right-aligned in room for every kind // provider's icon. The marks are right-aligned in room for every kind
// turned on, so they sit by the icon and names line up. // turned on, so they sit by the icon and names line up.
type marks struct { type marks struct {
theme styles.Styles theme styles.Styles
icons icons.Set icons icons.Set
stars, recent, notes, expiry, color bool stars, recent, expiry, color bool
warn time.Duration // ui.expiryWarningDays warn time.Duration // ui.expiryWarningDays
now time.Time now time.Time
} }
func (m marks) of(e listing.Entry) string { func (m marks) of(e listing.Entry) string {
@@ -216,10 +226,14 @@ func (m marks) of(e listing.Entry) string {
} }
} }
// What's wrong, then what to watch: room for one of them. // What's wrong, then what to watch: room for one of them.
switch left := e.Expires.Sub(m.now); { state := expiry.None
case e.Error != "" || (m.expiry && !e.Expires.IsZero() && left <= 0): if m.expiry {
state, _ = expiry.Of(e.Expires, m.now, m.warn)
}
switch {
case e.Error != "" || state == expiry.Expired:
mark(true, m.theme.Error, "✗") mark(true, m.theme.Error, "✗")
case m.expiry && !e.Expires.IsZero() && left <= m.warn: case state == expiry.Soon:
mark(true, m.theme.Warning, "⚠") mark(true, m.theme.Warning, "⚠")
default: default:
mark(false, m.theme.Error, "") mark(false, m.theme.Error, "")
@@ -230,9 +244,6 @@ func (m marks) of(e listing.Entry) string {
if m.recent { if m.recent {
mark(e.Recent, m.theme.Muted, "◷") mark(e.Recent, m.theme.Muted, "◷")
} }
if m.notes {
mark(e.Note != "", m.theme.Muted, "✎")
}
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
kind := provider.Kind("") kind := provider.Kind("")
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} { for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
+13 -14
View File
@@ -87,20 +87,19 @@ func TestListExpiry(t *testing.T) {
} }
} }
func TestListNoteMark(t *testing.T) { func TestListNotes(t *testing.T) {
az := icons.NerdFont.Azure e := listing.Entry{Note: "rotate in May"}
m := marks{icons: icons.NerdFont, stars: true, notes: true} if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" {
for _, c := range []struct { t.Errorf("icons: %q", got)
e listing.Entry }
want string if got := noteOf(e, styles.Default(), icons.None, true, false); got != "✎ rotate in May" {
}{ t.Errorf("no icons: %q", got)
{listing.Entry{Provider: "azure", Note: "rotate in May"}, " ✎ " + az}, }
{listing.Entry{Provider: "azure", Note: "x", Starred: true}, " ★ ✎ " + az}, if got := noteOf(e, styles.Default(), icons.NerdFont, false, false); got != "rotate in May" {
{listing.Entry{Provider: "azure"}, " " + az}, t.Errorf("--notes=false: %q", got)
} { }
if got := m.of(c.e); got != c.want { if got := noteOf(listing.Entry{}, styles.Default(), icons.NerdFont, true, true); got != "" {
t.Errorf("%+v: %q, want %q", c.e, got, c.want) t.Errorf("no note: %q", got)
}
} }
} }
+4 -1
View File
@@ -322,8 +322,11 @@ Flags:
// Two Azure accounts, like a personal and an admin login in az. // Two Azure accounts, like a personal and an admin login in az.
alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo" alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
fake.Seed(st, alice, admin) fake.Seed(st, alice, admin)
az := fake.Azure(400*time.Millisecond).As(alice, "Azure · alice")
// Due soon whenever the demo runs (the tests' dates are fixed).
az.Expire("kv-app-prod", "api-key", time.Now().AddDate(0, 0, 9).Truncate(24*time.Hour))
providers = []provider.Provider{ providers = []provider.Provider{
fake.Azure(400*time.Millisecond).As(alice, "Azure · alice"), az,
fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"), fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
fake.Databricks(600 * time.Millisecond), fake.Databricks(600 * time.Millisecond),
fake.Repos(), fake.Repos(),
Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 258 KiB

+2 -2
View File
@@ -54,8 +54,8 @@ sharing: # sharing env files as encrypted links
``` ```
More, each on its own page: [repos](repos.md) (`providers.repos`), More, each on its own page: [repos](repos.md) (`providers.repos`),
[sharing env files](links.md#sharing-env-files) (`sharing`), the [sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`), [link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`). [custom commands](custom-commands.md) (`customCommands`).
+54 -57
View File
@@ -2,7 +2,7 @@
lazysecrets works outside its own window too: `lazysecrets get` reads a lazysecrets works outside its own window too: `lazysecrets get` reads a
value for scripts, and `lazysecrets list` lists everything for fzf, editor value for scripts, and `lazysecrets list` lists everything for fzf, editor
pickers and your own tools. Both take the [links](links.md) lazysecrets pickers and your own tools. Both take the [links](sharing.md) lazysecrets
copies and shares. For Neovim, there's copies and shares. For Neovim, there's
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim). [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
@@ -19,7 +19,8 @@ It reads the value directly, with your az accounts (the one that last read
it first), the Databricks profile for the workspace, or sops in your clone it first), the Databricks profile for the workspace, or sops in your clone
of the repo; a version in the link reads that version. `--account` tries of the repo; a version in the link reads that version. `--account` tries
that account first, as [`list`](#listing-for-fzf-and-pickers) prints it. that account first, as [`list`](#listing-for-fzf-and-pickers) prints it.
The value ends with a newline only on a terminal. The value ends with a newline only on a terminal. When it can't read the
value, it says why on stderr and exits with status 1.
## Listing, for fzf and pickers ## Listing, for fzf and pickers
@@ -29,69 +30,65 @@ tab-separated:
| Field | | | Field | |
|---|---| |---|---|
| kind | `source`, `vault`, `secret`, `key` or `certificate` | | kind | `source`, `vault`, `secret`, `key` or `certificate` |
| mark | ✗ when something's wrong (expired, or couldn't be read) or ⚠ when it expires soon; ★ when starred; ◷ when used lately (with `--recent`); and the provider's icon |
| name | `platform-prod / kv-app-prod / db-password` | | name | `platform-prod / kv-app-prod / db-password` |
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` | | account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
| link | its `lazysecrets://` link | | link | its `lazysecrets://` link |
It's what Go to searches, so fzf, or an editor's picker, can search it What's cached prints at once, then what loads follows as it arrives: read
instead, showing the names and acting on the links: it as a stream. Values are never listed. It stops quietly when the reader
goes, like fzf after a pick. Kind stays the first field, and account and
link the last two; new fields only ever go between. The mark has a field of
its own so a search can leave it out: in fzf, `--nth` searches the name,
so `^prod` finds names starting with prod.
| Flag | |
|---|---|
| `--format json` | an object per line instead, for editor plugins (below) |
| `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded |
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read |
| `--color never` | no colours, for scripts |
| `--icons=false` | no provider icons |
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
| `--notes=false` | no note icon before a note |
| `--recent` | ◷ before names used lately, in `mutedColor`; default `ui.recentIcon` |
| `--expiry=false` | no expiry: not in the status, and no ✗ or ⚠ for it |
With `--format json`, each line is an object with these fields; new ones
may be added:
| Field | |
|---|---|
| `link`, `kind`, `account` | as above |
| `error` | why it couldn't be read, only then |
| `provider` | `azure`, `databricks` or `repo` |
| `path` | the names: `["platform-prod", "kv-app-prod", "db-password"]` |
| `accountLabel` | the account's short name, when several az accounts are signed in |
| `starred` | starred for this account |
| `note` | your note on it, if any |
| `reference` | how config refers to a secret: `@Microsoft.KeyVault(SecretUri=…)` or `{{secrets/scope/key}}` (`Y` in lazysecrets) |
| `url` | its web page, in the Azure portal or the Databricks workspace (`o` in lazysecrets) |
| `recent` | used lately, as Recent (`R`) lists: in the last 90 days |
| `lastUsed`, `uses` | when it was last used, and how often; only when `recent`, to sort by |
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
`lazysecrets open --action go --account <account> <link>` opens one in
lazysecrets without asking what to do or which account, ending in the
secrets list, like Go to.
### Example usage with fzf
```sh ```sh
lazysecrets list | fzf --ansi --with-nth='2..-3' --accept-nth=-1 \ lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \ --header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \ --bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {2..-3})' \ --bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)' --bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
``` ```
![fzf-example](assets/fzf-example.png)
`--with-nth='2..-3'` shows and searches only the names; `{-1}` and
`--accept-nth=-1` are the link, `{-2}` the account. All but the name are
one word, so this works without `--delimiter`. To filter by kind
yourself: `awk -F'\t' '$1 == "secret"'`.
`enter` prints the link (`lazysecrets get $(…)`), `ctrl-o` opens it in
lazysecrets, `ctrl-y` copies the value, saying so in the header, and
`ctrl-x` copies it and closes fzf (`pbcopy` on macOS; `wl-copy` or `xclip`
on Linux). A value copied this way isn't cleared from the
clipboard after `values.clipboardClear`, as one copied in lazysecrets is.
`--account` makes `open` and `get` use the account it was listed through,
when several of yours can see it: without, `open` asks which, and `get`
tries the one that last read it first. The link stays as it is, to copy
and share; the account isn't part of it. `--action` tells `open` what to
do rather than asking, as it does for a link clicked elsewhere: `go` to
it, `copy` its value too, or `show` it. It ends in the secrets list, like
Go to, rather than on the details as a clicked link does.
What's [cached](config.md#cache) prints at once. Then, like Go to, it loads
what's missing or older than `--max-age` (2m), and what's new in it follows
as it arrives: fzf shows it while you type. `--cached` loads nothing from
Azure or Databricks, for an answer at once. Repos are always read, from
disk. What it loads is cached for the TUI too. It stops when the reader
goes, after a pick in fzf.
`--kind` lists only some kinds, comma-separated: `source`, `vault` (also
Databricks scopes and repo files), `secret`, `key`, `certificate`. Lists
only other kinds need aren't loaded: `--kind source,vault` asks for no
secrets. Keys and certificates load only when asked for this way; else,
like Go to, they're listed as far as you opened them in lazysecrets.
A vault or source it can't read is a line too, as it fails: its name,
then `✗` and why in red (`errorColor`), with its link, so opening it
shows more. They're in the list, not on stderr, which would draw over fzf.
`--errors=false` leaves them out.
The name is coloured from your [theme](config.md#theme), with the
provider's icon when [icons](config.md#theme) are on (`ui.icons`); fzf
shows the colours with `--ansi`. `--color never` leaves them out, for
scripts, and `--icons=false` the icons. The link is never coloured.
`--format json` prints an object per line instead, for editor plugins,
without colours or icons, and with `error` on a list it couldn't read:
```json
{"link":"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password","kind":"secret","provider":"azure","path":["platform-prod","kv-app-prod","db-password"],"account":"Azure:alice@example.com/<tenant>","accountLabel":"alice"}
```
`accountLabel`, the account's short name, is there when several az
accounts are signed in. Values are never listed.
+1 -1
View File
@@ -54,7 +54,7 @@ match case exactly.
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) | | `o` | open the selected item in the browser (over SSH: copy the link) |
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) | | `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-files)) | | `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
| `ctrl+o` | open a link or reference (or just paste one) | | `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) | | `x` | [custom commands](custom-commands.md) |
| `L` | sign in again (shown when a login has expired) | | `L` | sign in again (shown when a login has expired) |
+3 -3
View File
@@ -58,7 +58,7 @@ providers:
``` ```
A repo's `.lazysecrets.yaml` can also name the secret its env files are A repo's `.lazysecrets.yaml` can also name the secret its env files are
[shared](links.md#sharing-env-files) with: [shared](sharing.md#sharing-env-files) with:
```yaml ```yaml
sharing: sharing:
@@ -100,7 +100,7 @@ sops 3.8 or later.
## Links ## Links
`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files). `S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files).
Links name the repo by its git remote, so they open in a colleague's clone Links name the repo by its git remote, so they open in a colleague's clone
wherever it is: wherever it is:
@@ -109,4 +109,4 @@ wherever it is:
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
``` ```
See [links](links.md). See [sharing](sharing.md).
+5 -5
View File
@@ -1,7 +1,7 @@
# Links # Sharing
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can Subscriptions, workspaces, vaults, scopes, repos, files and secrets are
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
it lands on that item, reading it with their own account and it lands on that item, reading it with their own account and
permissions: the link names the item, never its value. Env files can permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself. also be [shared encrypted](#sharing-env-files), in the link itself.
@@ -15,7 +15,7 @@ lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
``` ```
## Sharing ## Sharing a link
`s` copies a link to the selected item (from the versions view, pinned to `s` copies a link to the selected item (from the versions view, pinned to
that version). `S` opens the share menu: that version). `S` opens the share menu:
@@ -99,7 +99,7 @@ the value breaks every old link. Whoever has read the secret's value
before can decrypt links made with it without asking the vault, so rotate before can decrypt links made with it without asking the vault, so rotate
it now and then: new links use the newest version. it now and then: new links use the newest version.
## Opening ## Opening a link
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard - Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
and fills in any reference it finds there. Key Vault URLs, and fills in any reference it finds there. Key Vault URLs,
+42 -16
View File
@@ -32,6 +32,10 @@ type File struct {
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"` Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]Stamped[provider.Object] `json:"details,omitempty"` Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"` Cursor map[string]string `json:"cursor,omitempty"`
// Dropped are keys deleted since the file was read (a list that
// failed to load): Save deletes them from the file too, rather than
// merging the old entry back. Not saved.
Dropped map[string]bool `json:"-"`
} }
// Stamped is a list or detail with when it was loaded. // Stamped is a list or detail with when it was loaded.
@@ -69,6 +73,16 @@ func ProviderOf(key string) string {
return id return id
} }
// Keep is Save's keep for these providers: their lists are kept, but not
// repos', whose files are read from disk anyway, nor other accounts'.
func Keep(providers []provider.Provider) func(providerID string) bool {
keep := map[string]bool{}
for _, p := range providers {
keep[p.ID()] = p.Kind() != provider.KindRepo
}
return func(id string) bool { return keep[id] }
}
// Read reads path; a missing, unreadable or other version's file is an // Read reads path; a missing, unreadable or other version's file is an
// empty cache. // empty cache.
func Read(path string) File { func Read(path string) File {
@@ -90,17 +104,27 @@ func Read(path string) File {
} }
// Save writes f to path, merged with the file there: of a list in both, // 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 // the one loaded last wins, and f's cursors win (a nil Cursor keeps the
// MaxAge, and those of providers keep says no to (accounts signed out // file's). Entries older than MaxAge, those of providers keep says no to
// of), are dropped, from the file's side as well. // (accounts signed out of), and f.Dropped are dropped, from the file's
// side as well. Saves from several lazysecrets take turns (a lock file),
// so none undoes another's.
func Save(path string, f File, keep func(providerID string) bool, now time.Time) error { func Save(path string, f File, keep func(providerID string) bool, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
unlock, err := lock(path + ".lock")
if err != nil {
return err
}
defer unlock()
out := New() out := New()
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge } ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
disk := Read(path) disk := Read(path)
merge(out.Sources, disk.Sources, f.Sources, ok) merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped)
merge(out.Containers, disk.Containers, f.Containers, ok) merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped)
merge(out.Objects, disk.Objects, f.Objects, ok) merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped)
merge(out.Details, disk.Details, f.Details, ok) merge(out.Details, disk.Details, f.Details, ok, f.Dropped)
for _, m := range []map[string]string{disk.Cursor, f.Cursor} { for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
for k, v := range m { for k, v := range m {
if keep(ProviderOf(k)) { if keep(ProviderOf(k)) {
@@ -111,12 +135,17 @@ func Save(path string, f File, keep func(providerID string) bool, now time.Time)
return write(path, out) return write(path, out)
} }
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool) { // merge puts disk's entries in out, but not those dropped, then ours
for _, m := range []map[string]Stamped[T]{disk, ours} { // where they're newer.
for k, e := range m { func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool, dropped map[string]bool) {
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) { for k, e := range disk {
out[k] = e if ok(k, e.At) && !dropped[k] {
} out[k] = e
}
}
for k, e := range ours {
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
out[k] = e
} }
} }
} }
@@ -127,9 +156,6 @@ func write(path string, f File) error {
if err != nil { if err != nil {
return err return err
} }
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json") tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil { if err != nil {
return err return err
+31
View File
@@ -89,3 +89,34 @@ func TestReadOtherVersion(t *testing.T) {
t.Errorf("read another version's file: %+v", got) t.Errorf("read another version's file: %+v", got)
} }
} }
func TestSaveDroppedAndCursors(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
all := func(string) bool { return true }
f := New()
f.Containers["az|bad"] = list(now, "kv-old")
f.Cursor["az|x"] = "kv-tui"
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
// The TUI fails to load it again and drops it: it stays gone.
f = New()
f.Dropped = map[string]bool{"az|bad": true}
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
if _, ok := Read(path).Containers["az|bad"]; ok {
t.Error("a dropped list came back")
}
// lazysecrets list saves without cursors: the TUI's stay.
listed := New()
listed.Cursor = nil
listed.Containers["az|y"] = list(now, "kv-y")
if err := Save(path, listed, all, now); err != nil {
t.Fatal(err)
}
if Read(path).Cursor["az|x"] != "kv-tui" {
t.Error("the TUI's cursor was lost")
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !unix
package cachefile
// lock does nothing here: saves can then race, losing lists that load
// again.
func lock(string) (func(), error) { return func() {}, nil }
+25
View File
@@ -0,0 +1,25 @@
//go:build unix
package cachefile
import (
"os"
"syscall"
)
// lock takes path as a lock file, waiting while another lazysecrets has
// it, and returns how to let go.
func lock(path string) (unlock func(), err error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
f.Close()
return nil, err
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
+41
View File
@@ -0,0 +1,41 @@
// Package expiry says whether a secret, key or certificate has expired
// or will soon, and how long it has left, the same way everywhere: the
// TUI's lists and status pane, and lazysecrets list.
package expiry
import (
"fmt"
"time"
)
type State int
const (
None State = iota // no expiry, or not within the warning period
Soon // within the warning period
Expired // already
)
// Of tells whether something expiring at expires (zero: never) has
// expired by now or will within warn, and how long it has left.
func Of(expires, now time.Time, warn time.Duration) (State, time.Duration) {
if expires.IsZero() {
return None, 0
}
left := expires.Sub(now)
switch {
case left <= 0:
return Expired, 0
case left <= warn:
return Soon, left
}
return None, left
}
// Left is a short "12d", or "5h" on the last day.
func Left(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
+27
View File
@@ -0,0 +1,27 @@
package expiry
import (
"testing"
"time"
)
func TestOf(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
warn := 30 * 24 * time.Hour
for _, c := range []struct {
expires time.Time
want State
left string
}{
{time.Time{}, None, ""},
{now.Add(-time.Hour), Expired, ""},
{now.Add(12*24*time.Hour + time.Hour), Soon, "12d"},
{now.Add(5 * time.Hour), Soon, "5h"},
{now.Add(60 * 24 * time.Hour), None, ""},
} {
state, left := Of(c.expires, now, warn)
if state != c.want || (c.left != "" && Left(left) != c.left) {
t.Errorf("%v: %v %s, want %v %s", c.expires, state, Left(left), c.want, c.left)
}
}
}
+39 -15
View File
@@ -51,6 +51,11 @@ type Entry struct {
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
// Note is the user's note on it, if any. // Note is the user's note on it, if any.
Note string `json:"note,omitempty"` Note string `json:"note,omitempty"`
// Reference is how config refers to a secret, where the provider has
// a way (@Microsoft.KeyVault(...), {{secrets/scope/key}}); URL its
// web page (portal, workspace), if any.
Reference string `json:"reference,omitempty"`
URL string `json:"url,omitempty"`
// Error says why a source's vaults or a vault's secrets couldn't be // Error says why a source's vaults or a vault's secrets couldn't be
// listed. The entry is then that source or vault, again, or with an // listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link. // account's sources unreadable, the account without a link.
@@ -69,25 +74,37 @@ type Options struct {
// Lists only needed for other kinds aren't loaded: sources and vaults // Lists only needed for other kinds aren't loaded: sources and vaults
// alone don't load any secrets. Lists that fail are always listed. // alone don't load any secrets. Lists that fail are always listed.
Kinds []string Kinds []string
// Mark tells whether the entry with link is starred for account, // Mark tells what's remembered about the entry with link, seen
// when it was last used and how often (zero: not lately), and its // through account; nil marks nothing.
// note; nil marks nothing. Mark func(link, account string) Marks
Mark func(link, account string) (starred bool, lastUsed time.Time, uses int, note string) Timeout func(provider.Kind) time.Duration // nil: 30s
Timeout func(provider.Kind) time.Duration
Now func() time.Time Now func() time.Time
} }
// Marks are what's remembered about an entry: starred for the account,
// used lately (Recent) and when and how often, and its note.
type Marks struct {
Starred, Recent bool
LastUsed time.Time
Uses int
Note string
}
// Run lists everything, calling emit for each entry once, from this // Run lists everything, calling emit for each entry once, from this
// goroutine, and for each list that can't be loaded, as it fails. An // goroutine, and for each list that can't be loaded, as it fails. An
// error from emit (the reader went away) stops it. It returns the cache // error from emit (the reader went away) stops it. It returns the cache
// with what was loaded. // with what was loaded, and whether anything was.
func Run(ctx context.Context, o Options, emit func(Entry) error) cachefile.File { func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, results: make(chan result)} l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
results: make(chan result)}
if l.o.Now == nil { if l.o.Now == nil {
l.o.Now = time.Now l.o.Now = time.Now
} }
if l.o.Timeout == nil {
l.o.Timeout = func(provider.Kind) time.Duration { return 30 * time.Second }
}
for _, p := range o.Providers { for _, p := range o.Providers {
if l.stopped != nil { if l.stopped != nil {
break break
@@ -106,7 +123,7 @@ func Run(ctx context.Context, o Options, emit func(Entry) error) cachefile.File
cancel() cancel()
} }
} }
return l.o.Cache return l.o.Cache, l.loaded
} }
type job struct { type job struct {
@@ -130,6 +147,8 @@ type lister struct {
emit func(Entry) error emit func(Entry) error
seen map[string]bool // provider ID + link of entries emitted seen map[string]bool // provider ID + link of entries emitted
pending map[string]bool // cache keys queued or loading pending map[string]bool // cache keys queued or loading
failed map[string]bool // cache keys that couldn't be loaded: not again
loaded bool // something was cached
queue []job queue []job
active int active int
results chan result results chan result
@@ -207,12 +226,12 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
} }
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind), l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source), Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
Expires: o.Expires, Enabled: o.Enabled}) Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL})
} }
} }
func sourceEntry(src provider.Source) Entry { func sourceEntry(src provider.Source) Entry {
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src)} e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
if r, err := ref.ForSource(src); err == nil { if r, err := ref.ForSource(src); err == nil {
e.Link = r.String() e.Link = r.String()
} }
@@ -220,7 +239,7 @@ func sourceEntry(src provider.Source) Entry {
} }
func containerEntry(c provider.Container) Entry { func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source)} e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil { if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String() e.Link = r.String()
} }
@@ -235,8 +254,11 @@ func (l *lister) put(p provider.Provider, e Entry) {
} }
e.Account = p.ID() e.Account = p.ID()
if l.o.Mark != nil && e.Link != "" { if l.o.Mark != nil && e.Link != "" {
e.Starred, e.LastUsed, e.Uses, e.Note = l.o.Mark(e.Link, e.Account) m := l.o.Mark(e.Link, e.Account)
e.Recent = !e.LastUsed.IsZero() e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
if m.Recent {
e.LastUsed, e.Uses = m.LastUsed, m.Uses
}
} }
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] { if l.stopped != nil || l.seen[id] {
@@ -260,7 +282,7 @@ func (l *lister) wants(kinds ...string) bool {
} }
func (l *lister) enqueue(j job) { func (l *lister) enqueue(j job) {
if l.pending[j.key] { if l.pending[j.key] || l.failed[j.key] {
return return
} }
l.pending[j.key] = true l.pending[j.key] = true
@@ -296,9 +318,11 @@ func (l *lister) handle(r result) {
delete(l.pending, r.key) delete(l.pending, r.key)
now := l.o.Now() now := l.o.Now()
if r.err != nil { if r.err != nil {
l.failed[r.key] = true
l.put(r.p, r.failed()) l.put(r.p, r.failed())
return return
} }
l.loaded = l.loaded || l.cacheable(r.p)
cache := l.cacheable(r.p) cache := l.cacheable(r.p)
switch { switch {
case r.c != nil: case r.c != nil:
+37 -4
View File
@@ -42,7 +42,7 @@ func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
} }
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second } o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
var got, failed []Entry var got, failed []Entry
f := Run(context.Background(), o, func(e Entry) error { f, _ := Run(context.Background(), o, func(e Entry) error {
if e.Error != "" { if e.Error != "" {
failed = append(failed, e) failed = append(failed, e)
} else { } else {
@@ -87,6 +87,11 @@ func TestListLoadsAndCaches(t *testing.T) {
t.Errorf("provider %q", e.Provider) t.Errorf("provider %q", e.Provider)
} }
} }
for _, e := range got {
if e.Kind == "secret" && e.Provider == "azure" && (!strings.HasPrefix(e.Reference, "@Microsoft.KeyVault(") || e.URL == "") {
t.Errorf("%s: reference %q, url %q", e.Link, e.Reference, e.URL)
}
}
if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 { if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 {
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects)) t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
} }
@@ -229,14 +234,17 @@ func TestListMarks(t *testing.T) {
az := fake.Azure(0) az := fake.Azure(0)
var starredLink string var starredLink string
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"}, got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
Mark: func(link, account string) (bool, time.Time, int, string) { Mark: func(link, account string) Marks {
if starredLink == "" && strings.HasSuffix(link, "/db-password") { if starredLink == "" && strings.HasSuffix(link, "/db-password") {
starredLink = link starredLink = link
} }
if strings.HasSuffix(link, "/api-key") { if strings.HasSuffix(link, "/api-key") {
return false, time.Now(), 3, "rotate in May" return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
} }
return link == starredLink && account == az.ID(), time.Time{}, 0, "" if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
}
return Marks{Starred: link == starredLink && account == az.ID()}
}}) }})
var starred, recent int var starred, recent int
for _, e := range got { for _, e := range got {
@@ -262,3 +270,28 @@ func TestListMarks(t *testing.T) {
t.Errorf("%d starred, %d recent", starred, recent) t.Errorf("%d starred, %d recent", starred, recent)
} }
} }
func TestFailedListNotRetried(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Everything stale: sources reload, and their vaults are queued again
// as each list arrives. kv-restricted fails once, and isn't asked again.
before := az.calls.Load()
_, _, failed := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if len(failed) != 1 {
t.Errorf("failed: %+v", failed)
}
fresh := &counting{Provider: fake.Azure(0)}
run(t, Options{Providers: []provider.Provider{fresh}, MaxAge: time.Minute})
if n := az.calls.Load() - before; n > fresh.calls.Load() {
t.Errorf("%d requests with a stale cache, more than %d from scratch: something was asked twice", n, fresh.calls.Load())
}
}
func TestRunWithoutTimeout(t *testing.T) {
t.Parallel()
Run(context.Background(), Options{Providers: []provider.Provider{fake.Repos()}, Cache: cachefile.New()},
func(Entry) error { return nil }) // no Timeout: a default, not a panic
}
+18 -1
View File
@@ -218,6 +218,19 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
return c return c
} }
// Expire sets when the object name in vault expires, for --demo: a date
// relative to today in the test data would change the tests' screenshots
// from day to day.
func (p *Provider) Expire(vault, name string, at time.Time) {
for _, objs := range p.objects {
for i := range objs {
if objs[i].Container.Name == vault && objs[i].Name == name {
objs[i].Expires = at
}
}
}
}
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object { func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
o.Container = c o.Container = c
o.Type = t o.Type = t
@@ -287,9 +300,13 @@ func Azure(delay time.Duration) *Provider {
Reference: secretRef("kv-app-prod", "api-key"), Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000") }, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0), Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"), Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated") }, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0), Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
+5 -1
View File
@@ -32,9 +32,13 @@ func Seed(st *store.Store, alice, admin string) {
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"}, path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"},
{link: azure + "kv-shared-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod", {link: azure + "kv-shared-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-shared-prod"}, uses: 2, ago: 26 * time.Hour}, path: []string{"platform-prod", "kv-shared-prod"}, uses: 2, ago: 26 * time.Hour},
{link: azure + "kv-app-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, note: "migrated to Microsoft Graph sendMail; delete after Q4"},
{link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod", {link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour}, path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
// A subscription, and a repo's file. // A subscription, for both accounts, and a repo's file.
{link: azure + "subscriptions/sub-prod", account: alice, source: "sub-prod",
name: "platform-prod", star: true},
{link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod", {link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
name: "platform-prod", star: true}, name: "platform-prod", star: true},
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos", {link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
+7
View File
@@ -43,6 +43,13 @@ type Node struct {
// alone isn't. // alone isn't.
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 } func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
// UsedLately reports whether the item was used within the last 90 days
// (what Recent lists). Starred and noted records are kept longer, so a
// use alone doesn't say.
func (n Node) UsedLately(now time.Time) bool {
return !n.LastUsed.IsZero() && now.Sub(n.LastUsed) <= usageMaxAge
}
// StarredFor reports whether the item is starred for account. // StarredFor reports whether the item is starred for account.
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) } func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
+12
View File
@@ -119,3 +119,15 @@ func TestStarsPerAccount(t *testing.T) {
t.Errorf("unstar alice: %v", n.StarredBy) t.Errorf("unstar alice: %v", n.StarredBy)
} }
} }
func TestUsedLately(t *testing.T) {
now := time.Now()
for ago, want := range map[time.Duration]bool{time.Hour: true, 89 * 24 * time.Hour: true, 2 * 365 * 24 * time.Hour: false} {
if got := (Node{LastUsed: now.Add(-ago)}).UsedLately(now); got != want {
t.Errorf("used %v ago: %v", ago, got)
}
}
if (Node{}).UsedLately(now) {
t.Error("never used is lately")
}
}
+8 -5
View File
@@ -213,9 +213,12 @@ type Model struct {
statusW int // the status pane's width: the left column's, zoomed or not statusW int // the status pane's width: the left column's, zoomed or not
changelog []panes.Field changelog []panes.Field
cacheDirty bool cacheDirty bool
sourcesAt map[string]time.Time // cacheDropped are cache keys deleted since the last save, so saving
placeDir string // deletes them from the file too.
savedPlace string // the link last saved as the place cacheDropped map[string]bool
sourcesAt map[string]time.Time
placeDir string
savedPlace string // the link last saved as the place
// 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.
@@ -688,7 +691,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if src, ok := m.selectedSource(); ok { if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err) m.logErrOf(src.Name, msg.err)
} }
delete(m.cache.containers, msg.key) uncache(&m, m.cache.containers, msg.key)
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()}
@@ -709,7 +712,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if c, ok := m.containers.Selected(); ok { if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err) m.logErrOf(c.Name, msg.err)
} }
delete(m.cache.objects, msg.key) uncache(&m, m.cache.objects, msg.key)
m.secrets.SetError(msg.err) m.secrets.SetError(msg.err)
} else { } else {
if msg.bound != nil { if msg.bound != nil {
+7 -7
View File
@@ -686,7 +686,7 @@ func TestFilter(t *testing.T) {
d.keys("/") d.keys("/")
d.typeText("api-k") d.typeText("api-k")
d.keys("enter", "esc") // apply, then esc clears the applied filter d.keys("enter", "esc") // apply, then esc clears the applied filter
if d.m.secrets.Len() != 3 { if d.m.secrets.Len() != 4 {
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len()) t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
} }
if _, _, s := d.selected(); s != "api-key" { if _, _, s := d.selected(); s != "api-key" {
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.typeText("a") // all three secrets contain an a d.typeText("a") // all four secrets contain an a
if d.m.secrets.Len() != 3 { if d.m.secrets.Len() != 4 {
t.Fatalf("visible = %d", d.m.secrets.Len()) t.Fatalf("visible = %d", d.m.secrets.Len())
} }
_, _, initial := d.selected() // kept from before filtering: the middle row _, _, initial := d.selected() // kept from before filtering: the middle row
var seen []string var seen []string
for _, k := range []rune{'j', 'j', 'k'} { for _, k := range []rune{'j', 'j', 'j', 'k'} {
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl}) d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
_, _, s := d.selected() _, _, s := d.selected()
seen = append(seen, s) seen = append(seen, s)
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
if !d.m.secrets.Filtering() { if !d.m.secrets.Filtering() {
t.Fatal("ctrl+j/k left filter mode") t.Fatal("ctrl+j/k left filter mode")
} }
// down to the bottom, stay there, back up to where it started // down to the bottom, stay there, back up one
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial { if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen) t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
} }
if !strings.Contains(d.screen(), "ctrl+j/n down") { if !strings.Contains(d.screen(), "ctrl+j/n down") {
t.Errorf("filter help missing ctrl+j:\n%s", d.screen()) t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
+14 -1
View File
@@ -67,10 +67,23 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
return nil return nil
} }
m.detail.Refresh(s) m.detail.Refresh(s)
delete(m.cache.details, detailKey(s)) uncache(m, m.cache.details, detailKey(s))
return m.describe(s) return m.describe(s)
} }
// uncache deletes key from a cache, and, at the next save, from the file.
func uncache[T any](m *Model, c map[string]entry[T], key string) {
if _, ok := c[key]; !ok {
return
}
delete(c, key)
if m.cacheDropped == nil {
m.cacheDropped = map[string]bool{}
}
m.cacheDropped[key] = true
m.cacheDirty = true
}
// sameListing reports whether a and b list the same: what a list // sameListing reports whether a and b list the same: what a list
// says of an object, not what describing it adds. // says of an object, not what describing it adds.
func sameListing(a, b provider.Object) bool { func sameListing(a, b provider.Object) bool {
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"slices" "slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -28,7 +29,7 @@ func (m Model) expiringItems() []expiringItem {
row := sourceRowKey(p.ID(), src.ID) row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value { for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != notExpiring { if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row}) out = append(out, expiringItem{o, row})
} }
+8 -22
View File
@@ -1,9 +1,10 @@
package app package app
import ( import (
"fmt"
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -11,28 +12,18 @@ import (
// list once they have expired (✗ expired) or will within the configured // list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them. // warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState int type expiryState = expiry.State
const ( const (
notExpiring expiryState = iota notExpiring = expiry.None
expiresSoon expiresSoon = expiry.Soon
expired expired = expiry.Expired
) )
// expiry tells whether o has expired or will within the warning period, // expiry tells whether o has expired or will within the warning period,
// and how long it has left. // and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) { func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
if o.Expires.IsZero() { return expiry.Of(o.Expires, m.now(), m.expiryWarning)
return notExpiring, 0
}
left := o.Expires.Sub(m.now())
switch {
case left <= 0:
return expired, 0
case left <= m.expiryWarning:
return expiresSoon, left
}
return notExpiring, left
} }
// expiryWord is matched by the filter: "expired" or "expiring". // expiryWord is matched by the filter: "expired" or "expiring".
@@ -47,9 +38,4 @@ func (m Model) expiryWord(o provider.Object) string {
} }
// daysLeft is a short "12d", or "5h" on the last day. // daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { func daysLeft(d time.Duration) string { return expiry.Left(d) }
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
+7 -5
View File
@@ -3,9 +3,11 @@ package app
import ( import (
"slices" "slices"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -24,12 +26,12 @@ const recentMark = "◷ "
// marks is what goes before a list row's name: ★ when it's starred for // marks is what goes before a list row's name: ★ when it's starred for
// account and, with recent, ◷ when it was used lately. // account and, with recent, ◷ when it was used lately.
func marks(st styles.Styles, n store.Node, account string, recent bool) string { func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
var s string var s string
if n.StarredFor(account) { if n.StarredFor(account) {
s = st.Favourite.Render(star) s = st.Favourite.Render(star)
} }
if recent && !n.LastUsed.IsZero() { if recent && n.UsedLately(now) {
s += st.Muted.Render(recentMark) s += st.Muted.Render(recentMark)
} }
return s return s
@@ -110,7 +112,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
} }
line += sty.Muted.Render(" " + c.Detail) line += sty.Muted.Render(" " + c.Detail)
} }
line = marks(sty, n, c.Source.Provider, m.recentIcon) + line line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
if withSource { if withSource {
src := c.Source.Name src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok { if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
@@ -130,7 +132,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Enabled != nil && !*o.Enabled { if o.Enabled != nil && !*o.Enabled {
line = sty.Muted.Render(o.Name + " (disabled)") line = sty.Muted.Render(o.Name + " (disabled)")
} }
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon) + line line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
switch state, left := m.expiry(o); state { switch state, left := m.expiry(o); state {
case expired: case expired:
line += " " + sty.Error.Render("✗ expired") line += " " + sty.Error.Render("✗ expired")
@@ -179,7 +181,7 @@ func (m *Model) rebind(c provider.Container) string {
m.linked[i].Source = c.Source m.linked[i].Source = c.Source
} }
} }
key := containerKey(c) + "|" + string(m.objType) key := cachefile.ObjectsKey(c, m.objType)
if m.onLinks() { if m.onLinks() {
m.containers.SetItems(m.linked) m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c)) m.containers.Select(containerKey(c))
+2 -1
View File
@@ -8,6 +8,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
) )
@@ -160,7 +161,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL) add("web", c.URL)
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok { if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value))) add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
} }
} }
+1 -1
View File
@@ -107,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
if m.history { if m.history {
var used []string var used []string
for _, k := range keys { for _, k := range keys {
if !nodes[k].LastUsed.IsZero() { if nodes[k].UsedLately(m.now()) {
used = append(used, k) used = append(used, k)
} }
} }
+3 -9
View File
@@ -33,14 +33,7 @@ type persistedMsg struct{ err error }
// isn't a repo: every key starts with the provider ID. // isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) } func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
func (m *Model) keepProvider(id string) bool { return keepIn(m.byID)(id) } func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
func keepIn(byID map[string]provider.Provider) func(id string) bool {
return func(id string) bool {
p, ok := byID[id]
return ok && p.Kind() != provider.KindRepo
}
}
// loadCacheFile fills the cache from the file, leaving out what's too // loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more. // old or belongs to accounts that aren't signed in any more.
@@ -79,6 +72,7 @@ func (m *Model) loadCacheFile() {
// cacheSnapshot is the cache as the file holds it. // cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File { func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New() f := cachefile.New()
f.Dropped, m.cacheDropped = m.cacheDropped, nil
for id, r := range m.sourceResults { for id, r := range m.sourceResults {
if r.err == nil && m.keepProvider(id) { if r.err == nil && m.keepProvider(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources} f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
@@ -110,7 +104,7 @@ func (m *Model) cacheSnapshot() cachefile.File {
// saveCache saves f, merged with the file (lazysecrets list may have // saveCache saves f, merged with the file (lazysecrets list may have
// loaded more since), from any goroutine. // loaded more since), from any goroutine.
func (m *Model) saveCache(f cachefile.File) func() error { func (m *Model) saveCache(f cachefile.File) func() error {
path, keep, now := m.cachePath, keepIn(m.byID), m.now() path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
return func() error { return cachefile.Save(path, f, keep, now) } return func() error { return cachefile.Save(path, f, keep, now) }
} }
+7 -6
View File
@@ -10,6 +10,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
) )
@@ -89,7 +90,7 @@ func (m *Model) searchEntries() (sources, items []option) {
return nil return nil
}, reload) }, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} { for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
r, err := ref.ForObject(o, "") r, err := ref.ForObject(o, "")
if err != nil { if err != nil {
continue continue
@@ -166,7 +167,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
} }
for _, v := range e.value { for _, v := range e.value {
c.vaults++ c.vaults++
key := containerKey(v) + "|" + string(provider.TypeSecret) key := cachefile.ObjectsKey(v, provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok { if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++ c.vaultsLoaded++
c.secrets += len(objs.value) c.secrets += len(objs.value)
@@ -263,7 +264,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
// queueContainer queues loading c's secrets, unless loaded lately and not // queueContainer queues loading c's secrets, unless loaded lately and not
// force. // 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 := cachefile.ObjectsKey(c, provider.TypeSecret)
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force { if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return return
} }
@@ -327,7 +328,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active-- m.crawl.active--
key := msg.job.row key := msg.job.row
if msg.job.container != nil { if msg.job.container != nil {
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret) key = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
} }
delete(m.crawl.pending, key) delete(m.crawl.pending, key)
switch { switch {
@@ -365,7 +366,7 @@ func (m *Model) sourceMark(row string) string {
} }
loaded, loading := 0, false loaded, loading := 0, false
for _, c := range e.value { for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret) key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key] loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] { if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++ loaded++
@@ -386,7 +387,7 @@ func (m *Model) sourceMark(row string) string {
// (✗) or are loading (◌): whether a subscription is loaded says whether // (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable. // its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string { func (m *Model) vaultMark(c provider.Container) string {
key := containerKey(c) + "|" + string(provider.TypeSecret) key := cachefile.ObjectsKey(c, provider.TypeSecret)
switch { switch {
case m.crawl.pending[key]: case m.crawl.pending[key]:
return m.st.Muted.Render("◌") return m.st.Muted.Render("◌")
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/match" "git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
@@ -617,7 +618,7 @@ func (m Model) loadHint() string {
} }
loaded := 0 loaded := 0
for _, c := range e.value { for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret) key := cachefile.ObjectsKey(c, provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] { if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++ loaded++
} }
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -80,7 +81,7 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
kind := st.Kind(r.group) kind := st.Kind(r.group)
starred := "" starred := ""
if r.kind == rowSource { if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent) starred = marks(st, node, r.source.Provider, recent, time.Now())
} }
switch { switch {
case r.kind == rowLinks: case r.kind == rowLinks:
+3 -3
View File
@@ -20,7 +20,8 @@
╭─ [3] Secrets - Keys - Certs ──╮│ │ ╭─ [3] Secrets - Keys - Certs ──╮│ │
│ db-password ││ │ │ db-password ││ │
│ api-key ││ │ │ api-key ││ │
│ legacy-token (disabled) ✗ exp…││ │ │ legacy-token ✗ expired ││ │
│ smtp-password (disabled) ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
@@ -28,6 +29,5 @@
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ ╰────────────────────── 1 of 4 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
+2
View File
@@ -11,6 +11,7 @@ type Set struct {
Repo string Repo string
Encrypted string // an encrypted file (SOPS) Encrypted string // an encrypted file (SOPS)
PlainFile string // a plain text secrets file (.env) PlainFile string // a plain text secrets file (.env)
Note string // has a note (lazysecrets list)
} }
// None renders no icons. // None renders no icons.
@@ -24,6 +25,7 @@ var NerdFont = Set{
Repo: "\U000F02A2", // md-git Repo: "\U000F02A2", // md-git
Encrypted: "\U000F033E", // md-lock Encrypted: "\U000F033E", // md-lock
PlainFile: "\U000F0214", // md-file PlainFile: "\U000F0214", // md-file
Note: "\U000F09ED",
} }
// Prefix returns the icon for kind followed by a space, or "" if the set has // Prefix returns the icon for kind followed by a space, or "" if the set has