diff --git a/README.md b/README.md index f6fad48..d2c9e2c 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti CLI, `sops`, `git`). No config file needed. - **Search everything**: `ctrl+s` searches every source at once, with fzf-style matching. -- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it - 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. +- **Share links**: Share `lazysecrets://` links with colleagues. - **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim) - **Careful with values**: values stay hidden until you reveal them, and copied values are cleared from the clipboard after 30 seconds. @@ -80,14 +77,32 @@ use most: | `?` | every key, searchable | | `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 - [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys - [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults - [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes - [SOPS and .env files](docs/repos.md): which repos and files, decrypting -- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts, - and opening links from the browser and chat +- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and + opening links, also from the browser and chat +- [Integration](docs/integration.md): how lazysecrets can integrate into other systems - [Custom commands](docs/custom-commands.md): run your own commands on a secret - [Config](docs/config.md): every option, themes, layered configs, the diff --git a/cmd/lazysecrets/get.go b/cmd/lazysecrets/get.go index 7eff320..b02f192 100644 --- a/cmd/lazysecrets/get.go +++ b/cmd/lazysecrets/get.go @@ -37,10 +37,15 @@ func value(ctx context.Context, providers []provider.Provider, st *store.Store, return v, err } -// readKey is value, also returning the version read. +// readKey is value, also returning the version read. The account asked +// for (--account) goes first, else the one that last read it. func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) { key := r key.Version = "" node := st.Node(key.String()) - return ref.Read(ctx, providers, r, node.Account, node.Source) + account, source := node.Account, node.Source + if r.Account != "" && r.Account != account { + account, source = r.Account, "" + } + return ref.Read(ctx, providers, r, account, source) } diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go new file mode 100644 index 0000000..8bc6dc8 --- /dev/null +++ b/cmd/lazysecrets/list.go @@ -0,0 +1,269 @@ +package main + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "slices" + "strings" + "syscall" + "time" + + "charm.land/lipgloss/v2" + + "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/provider" + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// listCmd prints every source, vault and secret lazysecrets can find, one +// per line, for fzf, editor pickers and scripts: +// +// lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 +// +// Each line is the kind, a mark (✗ or ⚠, ★, ◷ and the provider's icon), +// the name, a status (why it couldn't be read, or that it has expired or +// expires soon; else empty), its note (muted, or empty), the account and +// the link, tab-separated. The mark has a field of its own so a search can +// leave it out (fzf --nth), and ^prod finds names starting with prod. The +// name is coloured from the theme, +// with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS, +// ui.icons); the link never is. +// +// What's cached prints at once; what's missing or older than --max-age +// follows as it loads. A list that can't be read is a line too, as it +// fails: the vault or source, then ✗ and why in the theme's error colour. +// They're in the output rather than on stderr, which would draw over fzf. +func listCmd(providers []provider.Provider, st *store.Store, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons, withRecent bool, expiryWarn time.Duration, args []string, out io.Writer) error { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks") + maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again") + format := fs.String("format", "text", "text: kind, mark, name, status, note, account and link, tab-separated; json: an object per line") + color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi") + 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)") + recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)") + 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)") + 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\")") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() > 0 { + return fmt.Errorf("usage: lazysecrets list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]") + } + var only []string + for k := range strings.SplitSeq(*kinds, ",") { + if k = strings.TrimSpace(k); k == "" { + continue + } + if !slices.Contains(listing.Kinds, k) { + return fmt.Errorf("--kind: %q is not one of %s", k, strings.Join(listing.Kinds, ", ")) + } + only = append(only, k) + } + if *color != "always" && *color != "never" { + return fmt.Errorf("--color: %q is not always or never", *color) + } + now := time.Now() + m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry, + color: *color == "always", warn: expiryWarn, now: now} + if *iconsOn { + m.icons = icons.NerdFont + } + var write func(listing.Entry) error + switch *format { + case "text": + write = func(e listing.Entry) error { + colored := *color == "always" + mark := m.of(e) + name := nameOf(e, theme, colored) + // Why it couldn't be read (vaults, sources), or when it + // expires (secrets, keys, certificates): never both. + status := errorOf(e, theme, colored) + if status == "" && *expiry { + status = expiryOf(e, theme, expiryWarn, now, colored) + } + note := noteOf(e, theme, m.icons, *notes, colored) + // 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), + oneLine.Replace(note), e.Account, e.Link) + return err + } + case "json": + enc := json.NewEncoder(out) + enc.SetEscapeHTML(false) + write = func(e listing.Entry) error { return enc.Encode(e) } + default: + return fmt.Errorf("--format: %q is not text or json", *format) + } + + // When the reader goes away (a pick in fzf), writing fails instead of + // killing the process, so what loaded is still saved. + signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE) + var gone error + emit := func(e listing.Entry) error { + if e.Error != "" && !*showErrors { + return nil + } + if err := write(e); err != nil { + gone = err + return err + } + return nil + } + + f := cachefile.New() + if cachePath != "" { + f = cachefile.Read(cachePath) + } + f, loaded := listing.Run(context.Background(), listing.Options{ + Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, + Mark: func(link, account string) listing.Marks { + n := st.Node(link) + return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now), + LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note} + }, + }, emit) + // Only what was loaded: list never moves a cursor, so the TUI's stay. + if cachePath != "" && loaded { + f.Cursor = nil + if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil { + return err + } + } + if gone != nil && !errors.Is(gone, syscall.EPIPE) { + return gone + } + return nil // or the reader has what it wanted +} + +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, +// or when it will if that's within warn (expires in 12d), in yellow, or +// is "". Its mark (✗, ⚠) is before the name. +func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string { + render := renderer(color) + switch state, left := expiry.Of(e.Expires, now, warn); state { + case expiry.Expired: + return render(theme.Error, "expired") + case expiry.Soon: + return render(theme.Warning, "expires in "+expiry.Left(left)) + } + return "" +} + +// errorOf is why a list couldn't be read, in red, or "". Its mark (✗) +// is before the name. +func errorOf(e listing.Entry, theme styles.Styles, color bool) string { + return renderer(color)(theme.Error, e.Error) +} + +// nameOf is an entry's name for the text format: the path with the +// item's own name standing out, and the account. +func nameOf(e listing.Entry, theme styles.Styles, color bool) string { + render := renderer(color) + var name string + if n := len(e.Path); n > 0 { + if n > 1 { + name = render(theme.Muted, strings.Join(e.Path[:n-1], " / ")+" / ") + } + name += e.Path[n-1] + } + if e.AccountLabel != "" { + name += render(theme.Muted, " · "+e.AccountLabel) + } + return name +} + +// 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 +// read, or has expired) or ⚠ when it expires within warn; ★ for a starred +// entry and ◷ for one used lately, as the TUI marks them; then the +// 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. +type marks struct { + theme styles.Styles + icons icons.Set + stars, recent, expiry, color bool + warn time.Duration // ui.expiryWarningDays + now time.Time +} + +func (m marks) of(e listing.Entry) string { + render := renderer(m.color) + var parts []string + room := 0 // marks turned on + mark := func(on bool, st lipgloss.Style, s string) { + room++ + if on { + parts = append(parts, render(st, s)) + } + } + // What's wrong, then what to watch: room for one of them. + state := expiry.None + if m.expiry { + state, _ = expiry.Of(e.Expires, m.now, m.warn) + } + switch { + case e.Error != "" || state == expiry.Expired: + mark(true, m.theme.Error, "✗") + case state == expiry.Soon: + mark(true, m.theme.Warning, "⚠") + default: + mark(false, m.theme.Error, "") + } + if m.stars { + mark(e.Starred, m.theme.Favourite, "★") + } + if m.recent { + mark(e.Recent, m.theme.Muted, "◷") + } + pad := strings.Repeat(" ", room-len(parts)) // each mark and its space + kind := provider.Kind("") + for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} { + if strings.EqualFold(string(k), e.Provider) { + kind = k + } + } + if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" { + parts = append(parts, render(m.theme.Kind(kind), icon)) + } else if len(parts) == 0 { + pad = strings.TrimSuffix(pad, " ") // no mark after the room + } + return pad + strings.Join(parts, " ") +} + +func renderer(color bool) func(lipgloss.Style, string) string { + return func(st lipgloss.Style, s string) string { + if !color || s == "" { + return s + } + return st.Render(s) + } +} diff --git a/cmd/lazysecrets/list_test.go b/cmd/lazysecrets/list_test.go new file mode 100644 index 0000000..5b2ce20 --- /dev/null +++ b/cmd/lazysecrets/list_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "strings" + "testing" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/listing" + "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +func TestListNames(t *testing.T) { + e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, AccountLabel: "alice"} + if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-app-prod / db-password · alice" { + t.Errorf("plain: %q", got) + } + // The item's own name isn't muted with its path. + if got := nameOf(e, styles.Default(), true); !strings.Contains(got, "\x1b[mdb-password") { + t.Errorf("name not set apart: %q", got) + } +} + +func TestListMarks(t *testing.T) { + az := icons.NerdFont.Azure + plain := marks{theme: styles.Default(), icons: icons.NerdFont, stars: true} + noIcons := plain + noIcons.icons = icons.None + colored := noIcons + colored.color = true + for _, c := range []struct { + m marks + e listing.Entry + want string + }{ + // Room for the status mark and the star, so names line up. + {plain, listing.Entry{Provider: "azure", Starred: true}, " ★ " + az}, + {plain, listing.Entry{Provider: "azure"}, " " + az}, + {marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, " " + az}, + {noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"}, + {marks{}, listing.Entry{Provider: "azure", Starred: true}, " "}, + {colored, listing.Entry{Provider: "azure", Starred: true}, " " + styles.Default().Favourite.Render("★")}, + } { + if got := c.m.of(c.e); got != c.want { + t.Errorf("%+v: %q, want %q", c.e, got, c.want) + } + } +} + +func TestListRecentMarks(t *testing.T) { + az := icons.NerdFont.Azure + m := marks{icons: icons.NerdFont, stars: true, recent: true} + for _, c := range []struct { + e listing.Entry + want string + }{ + // Right-aligned in room for three: by the icon, names lined up. + {listing.Entry{Provider: "azure", Starred: true, Recent: true}, " ★ ◷ " + az}, + {listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az}, + {listing.Entry{Provider: "azure"}, " " + az}, + } { + if got := m.of(c.e); got != c.want { + t.Errorf("%+v: %q, want %q", c.e, got, c.want) + } + } +} + +func TestListExpiry(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 string + }{ + {time.Time{}, ""}, + {now.Add(-time.Hour), "expired"}, + {now.Add(12*24*time.Hour + time.Hour), "expires in 12d"}, + {now.Add(5 * time.Hour), "expires in 5h"}, + {now.Add(60 * 24 * time.Hour), ""}, + } { + if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want { + t.Errorf("expires %v: %q, want %q", c.expires, got, c.want) + } + } + if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("expired") { + t.Errorf("expired isn't red: %q", got) + } +} + +func TestListNotes(t *testing.T) { + e := listing.Entry{Note: "rotate in May"} + if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" { + t.Errorf("icons: %q", got) + } + if got := noteOf(e, styles.Default(), icons.None, true, false); got != "✎ rotate in May" { + t.Errorf("no icons: %q", got) + } + if got := noteOf(e, styles.Default(), icons.NerdFont, false, false); got != "rotate in May" { + t.Errorf("--notes=false: %q", got) + } + if got := noteOf(listing.Entry{}, styles.Default(), icons.NerdFont, true, true); got != "" { + t.Errorf("no note: %q", got) + } +} + +func TestListStatusMark(t *testing.T) { + az := icons.NerdFont.Azure + now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + m := marks{icons: icons.NerdFont, stars: true, expiry: true, warn: 30 * 24 * time.Hour, now: now} + for _, c := range []struct { + e listing.Entry + want string + }{ + // Right-aligned in room for two, as with ★ and ◷. + {listing.Entry{Provider: "azure", Error: "403 Forbidden"}, " ✗ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour), Starred: true}, "✗ ★ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(5 * 24 * time.Hour)}, " ⚠ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour), Starred: true}, " ★ " + az}, + } { + if got := m.of(c.e); got != c.want { + t.Errorf("%+v: %q, want %q", c.e, got, c.want) + } + } + m.expiry = false // --expiry=false: no ⚠ or ✗ for expiry; errors still + if got := m.of(listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}); got != " "+az { + t.Errorf("--expiry=false: %q", got) + } + m.expiry, m.color, m.theme = true, true, styles.Default() + if got := m.of(listing.Entry{Error: "403"}); !strings.Contains(got, styles.Default().Error.Render("✗")) { + t.Errorf("not a red ✗: %q", got) + } + if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) { + t.Errorf("not a yellow ⚠: %q", got) + } +} + +func TestListErrors(t *testing.T) { + e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"} + if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-restricted" { + t.Errorf("the name has more: %q", got) + } + if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" { + t.Errorf("plain: %q", got) + } + if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("403 Forbidden") { + t.Errorf("why isn't red: %q", got) + } + if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" { + t.Errorf("no error: %q", got) + } +} diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 256fb45..736fb1f 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -89,6 +89,32 @@ func cmpOr(a, b string) string { return b } +// refArg parses get's and open's arguments: a reference, and optionally +// the account to use when several could (lazysecrets list's column) and, +// for open, what to do with it rather than asking (--action). +func refArg(cmd string, args []string) (r ref.Ref, action string, err error) { + fs := flag.NewFlagSet(cmd, flag.ContinueOnError) + account := fs.String("account", "", "the account to use when several could, as lazysecrets list prints it") + usage := "usage: lazysecrets get [--account ] " + act := new(string) + if cmd == "open" { + act = fs.String("action", "", "do this rather than ask: go (to it), copy (go, and copy its value) or show (go, and show it)") + usage = "usage: lazysecrets open [--account ] [--action go|copy|show] " + } + if err := fs.Parse(args); err != nil { + return r, "", err + } + if fs.NArg() != 1 { + return r, "", errors.New(usage) + } + if _, ok := app.OpenActions[*act]; *act != "" && !ok { + return r, "", fmt.Errorf("--action: %q is not go, copy or show", *act) + } + r, err = ref.Parse(fs.Arg(0)) + r.Account = *account + return r, *act, err +} + // defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the // config directory. func defaultConfigPath() string { @@ -115,12 +141,18 @@ func run() error { flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), `Usage: lazysecrets [flags] browse secrets - lazysecrets [flags] [open] + lazysecrets [flags] [open [--account ] [--action go|copy|show]] open a lazysecrets:// link (also a share link), Key Vault URL, @Microsoft.KeyVault(...) or {{secrets/scope/key}} - lazysecrets [flags] get print the value a lazysecrets:// link points at, + lazysecrets [flags] get [--account ] + print the value a lazysecrets:// link points at, for scripts: VAR=$(lazysecrets get ) + lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json] + [--color always|never] [--icons=false] [--errors=false] + print every source, vault and secret it can + find, one per line, for fzf and pickers: + what's cached at once, then what loads lazysecrets [flags] share [--key ] ... print a link to env files of a repo, encrypted with a secret's value (default: sharing.key) @@ -146,7 +178,8 @@ Flags: } var open, getRef *ref.Ref - var shareArgs []string + var openAction string + var shareArgs, listArgs []string printKeys := false switch args := flag.Args(); { case len(args) > 0 && args[0] == "share": @@ -154,10 +187,12 @@ Flags: if len(shareArgs) == 0 { shareArgs = []string{} // still sharing: says how } + case len(args) > 0 && args[0] == "list": + listArgs = append([]string{}, args[1:]...) // not nil: listing case len(args) == 1 && args[0] == "keybindings": printKeys = true - case len(args) == 2 && args[0] == "get": - r, err := ref.Parse(args[1]) + case len(args) > 0 && args[0] == "get": + r, _, err := refArg("get", args[1:]) if err != nil { return err } @@ -169,12 +204,12 @@ Flags: case len(args) > 0 && args[0] == "update": return updateCmd(*configPath, args[1:]) case len(args) == 0: - case args[0] == "open" && len(args) == 2: - r, err := ref.Parse(args[1]) + case args[0] == "open" && len(args) >= 2: + r, act, err := refArg("open", args[1:]) if err != nil { return err } - open = &r + open, openAction = &r, act case len(args) == 1 && looksLikeRef(args[0]): // lazysecrets , like lazysecrets open r, err := ref.Parse(args[0]) @@ -263,6 +298,8 @@ Flags: } } + recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon + // Stars, notes and the layout. Demo mode keeps them in memory, so // sample vaults never end up in the real file. stateDir, err := config.StateDir() @@ -283,9 +320,14 @@ Flags: var providers []provider.Provider if *demo { // 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" + 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{ - fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"), - fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"), + az, + fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"), fake.Databricks(600 * time.Millisecond), fake.Repos(), } @@ -345,6 +387,13 @@ Flags: defer cancel() return get(ctx, providers, st, *getRef) } + if listArgs != nil { + cachePath := "" + if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) { + cachePath = filepath.Join(d, "cache.json") + } + return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout) + } if shareArgs != nil { // The files from a repo, the key from whichever provider it's // in: the longest timeout. @@ -433,6 +482,8 @@ Flags: ShareKey: cfg.Sharing.Key, ShareRules: shareRules(cfg), Open: open, + OpenAction: openAction, + RecentIcon: recentIcon, Styles: &theme, DefaultFg: defaultFg, }) diff --git a/docs/assets/fzf-example.png b/docs/assets/fzf-example.png new file mode 100644 index 0000000..2f616ff Binary files /dev/null and b/docs/assets/fzf-example.png differ diff --git a/docs/assets/lazysecrets.png b/docs/assets/lazysecrets.png index 22ee0bd..03aa90c 100644 Binary files a/docs/assets/lazysecrets.png and b/docs/assets/lazysecrets.png differ diff --git a/docs/config.md b/docs/config.md index 40e4e3b..dcb47c2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -9,10 +9,11 @@ it isn't there yet); changes apply when lazysecrets starts again. ```yaml ui: icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0 + recentIcon: false # mark items used lately (Recent, R) with ◷, here and in lazysecrets list sourcesView: grouped # or flat; ` toggles it collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once - expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗ + expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d; ⚠ in lazysecrets list); expired ones get ✗ values: revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on) @@ -53,8 +54,8 @@ sharing: # sharing env files as encrypted links ``` More, each on its own page: [repos](repos.md) (`providers.repos`), -[sharing env files](links.md#sharing-env-files) (`sharing`), the -[link handler](links.md#from-the-browser-and-chat) (`linkHandler`), +[sharing env files](sharing.md#sharing-env-files) (`sharing`), the +[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`), [keybindings](keys.md#rebinding-keys) (`keybinding`) and [custom commands](custom-commands.md) (`customCommands`). @@ -140,7 +141,8 @@ you: the Sources lists, vaults and scopes, and secrets' names and metadata once, and anything older than two minutes reloads in the background when you go to it, or when Go to searches it. While it does, the pane's bottom border says how old what it shows is (`cached 3h ago`). Revealing -and copying always fetch the value live. +and copying always fetch the value live. [`lazysecrets list`](integration.md#listing-for-fzf-and-pickers) +reads it and adds to it too. 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 diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 0000000..79e6ee3 --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,94 @@ +# Integration + +lazysecrets works outside its own window too: `lazysecrets get` reads a +value for scripts, and `lazysecrets list` lists everything for fzf, editor +pickers and your own tools. Both take the [links](sharing.md) lazysecrets +copies and shares. For Neovim, there's +[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim). + +## Reading a value + +`lazysecrets get ` prints the value, for scripts and pipes: + +```sh +export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password) +lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin +``` + +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 +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. +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 + +`lazysecrets list` prints every source, vault and secret, one per line, +tab-separated: + +| Field | | +|---|---| +| 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` | +| 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:/`), `Databricks` or `Repos` | +| link | its `lazysecrets://` link | + +What's cached prints at once, then what loads follows as it arrives: read +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 ` 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 +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](assets/fzf-example.png) + diff --git a/docs/keys.md b/docs/keys.md index 3bc1536..90e862c 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -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 | | `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)) | -| `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) | | `x` | [custom commands](custom-commands.md) | | `L` | sign in again (shown when a login has expired) | diff --git a/docs/repos.md b/docs/repos.md index 47da3a9..3a994a7 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -58,7 +58,7 @@ providers: ``` 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 sharing: @@ -100,7 +100,7 @@ sops 3.8 or later. ## 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 wherever it is: @@ -109,4 +109,4 @@ wherever it is: lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password ``` -See [links](links.md). +See [sharing](sharing.md). diff --git a/docs/links.md b/docs/sharing.md similarity index 91% rename from docs/links.md rename to docs/sharing.md index 6b1ddf7..5145e84 100644 --- a/docs/links.md +++ b/docs/sharing.md @@ -1,7 +1,7 @@ -# Links +# Sharing -Subscriptions, workspaces, vaults, scopes, repos, files and secrets can -be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens +Subscriptions, workspaces, vaults, scopes, repos, files and secrets are +shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens it lands on that item, reading it with their own account and permissions: the link names the item, never its value. Env files can 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 ``` -## Sharing +## Sharing a link `s` copies a link to the selected item (from the versions view, pinned to 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 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 and fills in any reference it finds there. Key Vault URLs, @@ -121,20 +121,10 @@ account) and jumps to it, asking which account to use when several can see it, ending on a secret's details as `enter` on it would. Links say what, not what to do: opening a link to a secret asks whether to just go to it, also copy or show its value, or (`y`) copy its value without going there. - -## In scripts - -`lazysecrets get ` prints the value, for scripts and pipes: - -```sh -export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password) -lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin -``` - -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 -of the repo; a version in the link reads that version. The value ends with -a newline only on a terminal. +`lazysecrets open --action go|copy|show ` does one without asking +and ends in the list, like Go to, +and `--account ` picks the account (as [`list`](integration.md#listing-for-fzf-and-pickers) +prints it) when several can see it. ## From the browser and chat diff --git a/internal/cachefile/cachefile.go b/internal/cachefile/cachefile.go new file mode 100644 index 0000000..6f56b82 --- /dev/null +++ b/internal/cachefile/cachefile.go @@ -0,0 +1,172 @@ +// Package cachefile is the cache of what lazysecrets has loaded +// (cache.json): the Sources lists, vaults, secrets' metadata and where +// the cursor was in each, never values. The TUI and lazysecrets list +// both read it and save to it; a save merges with what's on disk, so +// neither undoes what the other loaded. +package cachefile + +import ( + "encoding/json" + "maps" + "os" + "path/filepath" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Version is the file's format; a file of another version is ignored, +// and replaced on the next save. +const Version = 1 + +// MaxAge drops what hasn't been loaded again for this long. +const MaxAge = 30 * 24 * time.Hour + +// File is the cache. Every key starts with the provider ID: Sources are +// keyed by it, the rest by Row, Objects' keys and the like. +type File 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"` + // 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. +type Stamped[T any] struct { + At time.Time `json:"at"` + Value T `json:"value"` +} + +// New is an empty cache. +func New() File { + return File{ + Version: Version, + 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{}, + } +} + +// Row keys a source's vaults or scopes. +func Row(providerID, sourceID string) string { return providerID + "|" + sourceID } + +// ContainerKey keys what's cached about a vault or scope. +func ContainerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID } + +// ObjectsKey keys a vault's secrets, keys or certificates. +func ObjectsKey(c provider.Container, t provider.ObjectType) string { + return ContainerKey(c) + "|" + string(t) +} + +// ProviderOf is the provider ID a key starts with. +func ProviderOf(key string) string { + id, _, _ := strings.Cut(key, "|") + 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 +// empty cache. +func Read(path string) File { + f := New() + b, err := os.ReadFile(path) + if err != nil { + return f + } + var d File + if json.Unmarshal(b, &d) != nil || d.Version != Version { + return f + } + maps.Copy(f.Sources, d.Sources) + maps.Copy(f.Containers, d.Containers) + maps.Copy(f.Objects, d.Objects) + maps.Copy(f.Details, d.Details) + maps.Copy(f.Cursor, d.Cursor) + return f +} + +// 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 (a nil Cursor keeps the +// file's). Entries older than MaxAge, those of providers keep says no to +// (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 { + 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() + ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge } + disk := Read(path) + merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped) + merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped) + merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped) + merge(out.Details, disk.Details, f.Details, ok, f.Dropped) + for _, m := range []map[string]string{disk.Cursor, f.Cursor} { + for k, v := range m { + if keep(ProviderOf(k)) { + out.Cursor[k] = v + } + } + } + return write(path, out) +} + +// merge puts disk's entries in out, but not those dropped, then ours +// where they're newer. +func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool, dropped map[string]bool) { + for k, e := range disk { + 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 + } + } +} + +// write writes f atomically, readable by the user only. +func write(path string, f File) error { + b, err := json.Marshal(f) + if 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) +} diff --git a/internal/cachefile/cachefile_test.go b/internal/cachefile/cachefile_test.go new file mode 100644 index 0000000..f1e9aad --- /dev/null +++ b/internal/cachefile/cachefile_test.go @@ -0,0 +1,122 @@ +package cachefile + +import ( + "path/filepath" + "testing" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +func list(at time.Time, names ...string) Stamped[[]provider.Container] { + var cs []provider.Container + for _, n := range names { + cs = append(cs, provider.Container{Name: n}) + } + return Stamped[[]provider.Container]{At: at, Value: cs} +} + +func TestSaveMerges(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + now := time.Now() + all := func(string) bool { return true } + + // The TUI saved two lists. + tui := New() + tui.Containers["az|a"] = list(now.Add(-time.Hour), "kv-a-old") + tui.Containers["az|b"] = list(now.Add(-time.Minute), "kv-b") + tui.Cursor["az|b"] = "kv-b" + if err := Save(path, tui, all, now); err != nil { + t.Fatal(err) + } + // lazysecrets list loads a again, and c. + listed := Read(path) + listed.Containers["az|a"] = list(now, "kv-a-new") + listed.Containers["az|c"] = list(now, "kv-c") + if err := Save(path, listed, all, now); err != nil { + t.Fatal(err) + } + // The TUI saves what it has, older: list's lists stay. + if err := Save(path, tui, all, now); err != nil { + t.Fatal(err) + } + f := Read(path) + for key, want := range map[string]string{"az|a": "kv-a-new", "az|b": "kv-b", "az|c": "kv-c"} { + if got := f.Containers[key].Value; len(got) != 1 || got[0].Name != want { + t.Errorf("%s = %v, want %s", key, got, want) + } + } + if f.Cursor["az|b"] != "kv-b" { + t.Error("cursor lost") + } +} + +func TestSaveDrops(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + now := time.Now() + f := New() + f.Containers["az|old"] = list(now.Add(-MaxAge-time.Hour), "kv-old") + f.Containers["gone|x"] = list(now, "kv-gone") + f.Containers["az|x"] = list(now, "kv-x") + if err := Save(path, f, func(id string) bool { return true }, now); err != nil { + t.Fatal(err) + } + // Signed out of "gone" since: dropped from the file too. + if err := Save(path, New(), func(id string) bool { return id == "az" }, now); err != nil { + t.Fatal(err) + } + got := Read(path).Containers + if _, ok := got["az|old"]; ok { + t.Error("kept a list older than MaxAge") + } + if _, ok := got["gone|x"]; ok { + t.Error("kept a signed-out account's list") + } + if _, ok := got["az|x"]; !ok { + t.Error("dropped a list on disk the save didn't have") + } +} + +func TestReadOtherVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + f := New() + f.Version = Version + 1 + f.Containers["az|x"] = list(time.Now(), "kv-x") + if err := write(path, f); err != nil { + t.Fatal(err) + } + if got := Read(path); len(got.Containers) != 0 || got.Version != Version { + 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") + } +} diff --git a/internal/cachefile/lock_other.go b/internal/cachefile/lock_other.go new file mode 100644 index 0000000..bb597f2 --- /dev/null +++ b/internal/cachefile/lock_other.go @@ -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 } diff --git a/internal/cachefile/lock_unix.go b/internal/cachefile/lock_unix.go new file mode 100644 index 0000000..f8281d3 --- /dev/null +++ b/internal/cachefile/lock_unix.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index d2844c2..11c7da3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,9 @@ type Config struct { type UI struct { Icons *bool `yaml:"icons"` + // RecentIcon marks items used lately (Recent, R) with ◷, in the lists + // and in lazysecrets list. Defaults to false. + RecentIcon *bool `yaml:"recentIcon"` // SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it. SourcesView string `yaml:"sourcesView"` // CollapseGroups starts the Sources pane with its groups folded; diff --git a/internal/expiry/expiry.go b/internal/expiry/expiry.go new file mode 100644 index 0000000..720a551 --- /dev/null +++ b/internal/expiry/expiry.go @@ -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)) +} diff --git a/internal/expiry/expiry_test.go b/internal/expiry/expiry_test.go new file mode 100644 index 0000000..92b30a7 --- /dev/null +++ b/internal/expiry/expiry_test.go @@ -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) + } + } +} diff --git a/internal/listing/listing.go b/internal/listing/listing.go new file mode 100644 index 0000000..8c6f4ff --- /dev/null +++ b/internal/listing/listing.go @@ -0,0 +1,399 @@ +// Package listing lists what lazysecrets can find, for lazysecrets list: +// what Go to searches, without the TUI. What's cached comes first, at +// once; then lists that are missing or older than MaxAge load in the +// background, a few at a time, and what's new in them follows as they +// arrive. Repos are always read, from disk. Values are never listed. +package listing + +import ( + "cmp" + "context" + "slices" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/cachefile" + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" +) + +// Workers is how many lists load at once, as in Go to. +const Workers = 4 + +// Kinds are the kinds of entry, from sources down. +var Kinds = []string{"source", "vault", "secret", "key", "certificate"} + +// Entry is a source, vault or item, by its lazysecrets:// link. +type Entry struct { + Link string `json:"link"` + // Kind is source (a subscription, workspace or repo), vault (a Key + // Vault, secret scope or repo file), secret, key or certificate. + Kind string `json:"kind"` + // Provider is azure, databricks or repo, as in links. + Provider string `json:"provider"` + // Path names it: its source's, vault's and own names. + Path []string `json:"path"` + // Account is the provider it's listed through: an az account + // (Azure:/), Databricks or Repos. lazysecrets open and + // get take it (--account) to use that account when several could. + Account string `json:"account"` + // AccountLabel names the az account, when there are several. + AccountLabel string `json:"accountLabel,omitempty"` + // Starred is starred for this account; Recent is used lately (Recent, + // R, in the TUI): LastUsed is when, Uses how often. + Starred bool `json:"starred"` + Recent bool `json:"recent"` + LastUsed time.Time `json:"lastUsed,omitzero"` + Uses int `json:"uses,omitempty"` + // Expires is when a secret, key or certificate expires, if it does; + // Enabled whether it's enabled, where the provider says (Azure). + Expires time.Time `json:"expires,omitzero"` + Enabled *bool `json:"enabled,omitempty"` + // Note is the user's note on it, if any. + 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 + // listed. The entry is then that source or vault, again, or with an + // account's sources unreadable, the account without a link. + Error string `json:"error,omitempty"` +} + +type Options struct { + Providers []provider.Provider + // Cache is what was loaded before; Run adds what it loads. + Cache cachefile.File + // MaxAge: cached lists loaded longer ago are loaded again. + MaxAge time.Duration + // Cached lists only what's cached (and repos): nothing is loaded. + Cached bool + // Kinds are the kinds of entry to list (Kinds' names); none is all. + // Lists only needed for other kinds aren't loaded: sources and vaults + // alone don't load any secrets. Lists that fail are always listed. + Kinds []string + // Mark tells what's remembered about the entry with link, seen + // through account; nil marks nothing. + Mark func(link, account string) Marks + Timeout func(provider.Kind) time.Duration // nil: 30s + 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 +// 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 +// with what was loaded, and whether anything was. +func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + 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 { + 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 { + if l.stopped != nil { + break + } + l.provider(p) + } + l.start(ctx) + for l.active > 0 { + r := <-l.results + l.active-- + if l.stopped == nil { + l.handle(r) + l.start(ctx) + } + if l.stopped != nil { + cancel() + } + } + return l.o.Cache, l.loaded +} + +type job struct { + p provider.Provider + src *provider.Source // list its vaults + c *provider.Container // list its secrets, keys or certificates (t) + t provider.ObjectType + key string // where the list is cached +} + +type result struct { + job + sources []provider.Source + containers []provider.Container + objects []provider.Object + err error +} + +type lister struct { + o Options + emit func(Entry) error + seen map[string]bool // provider ID + link of entries emitted + 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 + active int + results chan result + stopped error // emit's error +} + +func (l *lister) cacheable(p provider.Provider) bool { return p.Kind() != provider.KindRepo } + +// load reports whether to load a list: a repo's always (from disk), else +// unless Cached, when it isn't cached or is older than MaxAge. +func (l *lister) load(p provider.Provider, cached bool, at time.Time) bool { + if !l.cacheable(p) { + return true + } + return !l.o.Cached && (!cached || l.o.Now().Sub(at) >= l.o.MaxAge) +} + +func (l *lister) provider(p provider.Provider) { + e, ok := l.o.Cache.Sources[p.ID()] + if l.cacheable(p) && ok { + for _, src := range sorted(e.Value, func(s provider.Source) string { return s.Name }) { + l.source(p, src) + } + } + if l.load(p, ok, e.At) { + l.enqueue(job{p: p, key: p.ID()}) + } +} + +// source emits src, and what's cached under it, and queues what's not. +func (l *lister) source(p provider.Provider, src provider.Source) { + src.Provider, src.ProviderLabel = p.ID(), p.Label() + l.put(p, sourceEntry(src)) + row := cachefile.Row(p.ID(), src.ID) + e, ok := l.o.Cache.Containers[row] + if l.cacheable(p) && ok { + l.containers(p, src, e.Value) + } + if l.load(p, ok, e.At) { + if l.wants("vault", "secret", "key", "certificate") { + l.enqueue(job{p: p, src: &src, key: row}) + } + } +} + +func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) { + for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) { + c.Source = src + l.put(p, containerEntry(c)) + for _, t := range c.ObjectTypes() { + key := cachefile.ObjectsKey(c, t) + e, ok := l.o.Cache.Objects[key] + if l.cacheable(p) { + l.objects(p, c, e.Value) + } + // Keys and certificates load only when asked for; else, + // like Go to, only as far as the TUI opened them. + asked := l.wants(strings.ToLower(string(t))) + if t != provider.TypeSecret { + asked = len(l.o.Kinds) > 0 && asked + } + if asked && l.load(p, ok, e.At) { + l.enqueue(job{p: p, c: &c, t: t, key: key}) + } + } + } +} + +func (l *lister) objects(p provider.Provider, c provider.Container, objs []provider.Object) { + for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) { + o.Container = c + r, err := ref.ForObject(o, "") + if err != nil { + continue + } + 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), + Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL}) + } +} + +func sourceEntry(src provider.Source) Entry { + 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 { + e.Link = r.String() + } + return e +} + +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), URL: c.URL} + if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil { + e.Link = r.String() + } + return e +} + +// put emits e, unless it was already (or emitting has failed). Entries +// without a link (one couldn't be made) are left out, but not errors. +func (l *lister) put(p provider.Provider, e Entry) { + if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" { + return + } + e.Account = p.ID() + if l.o.Mark != nil && e.Link != "" { + m := l.o.Mark(e.Link, e.Account) + 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 + if l.stopped != nil || l.seen[id] { + return + } + l.seen[id] = true + l.stopped = l.emit(e) +} + +// wants reports whether any of kinds is listed. +func (l *lister) wants(kinds ...string) bool { + if len(l.o.Kinds) == 0 { + return true + } + for _, k := range kinds { + if slices.Contains(l.o.Kinds, k) { + return true + } + } + return false +} + +func (l *lister) enqueue(j job) { + if l.pending[j.key] || l.failed[j.key] { + return + } + l.pending[j.key] = true + l.queue = append(l.queue, j) +} + +func (l *lister) start(ctx context.Context) { + for l.stopped == nil && l.active < Workers && len(l.queue) > 0 { + j := l.queue[0] + l.queue = l.queue[1:] + l.active++ + go func() { l.results <- l.fetch(ctx, j) }() + } +} + +func (l *lister) fetch(ctx context.Context, j job) result { + kind := j.p.Kind() + ctx, cancel := context.WithTimeout(ctx, l.o.Timeout(kind)) + defer cancel() + r := result{job: j} + switch { + case j.c != nil: + r.objects, r.err = j.p.Objects(ctx, *j.c, j.t) + case j.src != nil: + r.containers, r.err = j.p.Containers(ctx, *j.src) + default: + r.sources, r.err = j.p.Sources(ctx) + } + return r +} + +func (l *lister) handle(r result) { + delete(l.pending, r.key) + now := l.o.Now() + if r.err != nil { + l.failed[r.key] = true + l.put(r.p, r.failed()) + return + } + l.loaded = l.loaded || l.cacheable(r.p) + cache := l.cacheable(r.p) + switch { + case r.c != nil: + if cache { + l.o.Cache.Objects[r.key] = cachefile.Stamped[[]provider.Object]{At: now, Value: r.objects} + } + l.objects(r.p, *r.c, r.objects) + case r.src != nil: + r.containers = slices.Clone(r.containers) // the provider's may be shared + for i := range r.containers { + r.containers[i].Source = *r.src + } + if cache { + l.o.Cache.Containers[r.key] = cachefile.Stamped[[]provider.Container]{At: now, Value: r.containers} + } + l.containers(r.p, *r.src, r.containers) + default: + if cache { + l.o.Cache.Sources[r.key] = cachefile.Stamped[[]provider.Source]{At: now, Value: r.sources} + } + for _, src := range r.sources { + l.source(r.p, src) + } + } +} + +// failed is the entry for a list that couldn't be loaded. +func (r result) failed() Entry { + var e Entry + switch { + case r.c != nil: + e = containerEntry(*r.c) + case r.src != nil: + src := *r.src + src.Provider, src.ProviderLabel = r.p.ID(), r.p.Label() + e = sourceEntry(src) + default: + e = Entry{Kind: "source", Provider: kindOf(r.p.Kind()), Path: []string{r.p.Label()}} + } + e.Error = firstLine(r.err.Error()) + return e +} + +// firstLine is an error's first line, the summary (Azure's go on with +// the caller's IDs and more): an entry is one line. Tabs, which would +// split it into columns, are spaces. +func firstLine(s string) string { + for line := range strings.Lines(s) { + if line = strings.TrimSpace(line); line != "" { + return strings.ReplaceAll(line, "\t", " ") + } + } + return "unreadable" +} + +func kindOf(k provider.Kind) string { return strings.ToLower(string(k)) } + +// accountOf is the account part of a source's provider label ("alice" in +// "Azure · alice"), or "" when there's a single account. +func accountOf(src provider.Source) string { + account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ") + if !ok { + return "" + } + return account +} + +func sorted[T any](s []T, name func(T) string) []T { + s = slices.Clone(s) + slices.SortStableFunc(s, func(a, b T) int { + return cmp.Compare(strings.ToLower(name(a)), strings.ToLower(name(b))) + }) + return s +} diff --git a/internal/listing/listing_test.go b/internal/listing/listing_test.go new file mode 100644 index 0000000..75bdc03 --- /dev/null +++ b/internal/listing/listing_test.go @@ -0,0 +1,297 @@ +package listing + +import ( + "context" + "errors" + "strings" + "sync/atomic" + "testing" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/cachefile" + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" +) + +// counting counts a provider's requests. +type counting struct { + provider.Provider + calls atomic.Int32 +} + +func (c *counting) Sources(ctx context.Context) ([]provider.Source, error) { + c.calls.Add(1) + return c.Provider.Sources(ctx) +} + +func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) { + c.calls.Add(1) + return c.Provider.Containers(ctx, src) +} + +func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) { + c.calls.Add(1) + return c.Provider.Objects(ctx, ct, t) +} + +// run lists, returning the entries and, apart, the lists that failed. +func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) { + t.Helper() + if o.Cache.Version == 0 { + o.Cache = cachefile.New() + } + o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second } + var got, failed []Entry + f, _ := Run(context.Background(), o, func(e Entry) error { + if e.Error != "" { + failed = append(failed, e) + } else { + got = append(got, e) + } + return nil + }) + return got, f, failed +} + +func has(es []Entry, kind, path string) bool { + for _, e := range es { + if e.Kind == kind && strings.Join(e.Path, "/") == path { + return true + } + } + return false +} + +func TestListLoadsAndCaches(t *testing.T) { + t.Parallel() + az := &counting{Provider: fake.Azure(0)} + got, f, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, MaxAge: time.Minute}) + for _, want := range [][2]string{ + {"source", "platform-prod"}, {"vault", "platform-prod/kv-app-prod"}, {"secret", "platform-prod/kv-app-prod/db-password"}, + {"source", "lazysecrets-demo"}, {"secret", "lazysecrets-demo/.env/DATABASE_URL"}, + } { + if !has(got, want[0], want[1]) { + t.Errorf("no %s %s", want[0], want[1]) + } + } + if len(errs) != 1 || errs[0].Kind != "vault" || errs[0].Path[1] != "kv-restricted" || !strings.Contains(errs[0].Error, "403") || errs[0].Link == "" { + t.Errorf("failed = %+v", errs) + } + seen := map[string]bool{} + for _, e := range got { + if seen[e.Link] { + t.Errorf("%s listed twice", e.Link) + } + seen[e.Link] = true + if e.Provider != "azure" && e.Provider != "repo" { + 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 { + t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects)) + } + for k := range f.Containers { + if cachefile.ProviderOf(k) != az.ID() { + t.Errorf("cached a repo: %s", k) + } + } + + // Again with that cache: the same, without asking Azure. + before := az.calls.Load() + again, _, _ := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, MaxAge: time.Minute}) + if len(again) != len(got) { + t.Errorf("%d entries from the cache, %d loaded", len(again), len(got)) + } + // The vault that couldn't be read wasn't cached: it's asked again. + if n := az.calls.Load() - before; n != 1 { + t.Errorf("%d requests with a fresh cache, want 1 (kv-restricted)", n) + } + + // Stale: loaded again, but each entry is still listed once. + before = az.calls.Load() + stale, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute, + Now: func() time.Time { return time.Now().Add(time.Hour) }}) + if az.calls.Load()-before < 3 { + t.Error("stale lists weren't loaded again") + } + links := map[string]bool{} + for _, e := range stale { + if links[e.Link] { + t.Errorf("%s listed twice", e.Link) + } + links[e.Link] = true + } +} + +func TestListCachedOnly(t *testing.T) { + t.Parallel() + az := &counting{Provider: fake.Azure(0)} + _, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute}) + before := az.calls.Load() + got, _, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, Cached: true, + Now: func() time.Time { return time.Now().Add(24 * time.Hour) }}) + if az.calls.Load() != before || len(errs) != 0 { + t.Errorf("--cached asked Azure: %d requests, %v", az.calls.Load()-before, errs) + } + if !has(got, "secret", "platform-prod/kv-app-prod/db-password") || !has(got, "secret", "lazysecrets-demo/.env/DATABASE_URL") { + t.Error("cached secrets or repos missing") + } +} + +func TestListCachedFirst(t *testing.T) { + t.Parallel() + az := fake.Azure(0) + _, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute}) + // Forget one vault's secrets: they load, after everything cached. + var dropped string + for k := range f.Objects { + if strings.Contains(k, "kv-app-prod") && strings.HasSuffix(k, "|Secret") { + dropped = k + delete(f.Objects, k) + break + } + } + if dropped == "" { + t.Fatalf("no kv-app-prod in %v", f.Objects) + } + got, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute}) + last := got[len(got)-1] + if last.Kind != "secret" || last.Path[1] != "kv-app-prod" { + t.Errorf("last = %+v, want a kv-app-prod secret, loaded after the cache", last) + } +} + +func TestListStopsWhenTheReaderGoes(t *testing.T) { + t.Parallel() + gone := errors.New("broken pipe") + n := 0 + var errs []Entry + Run(context.Background(), Options{ + Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute, + Timeout: func(provider.Kind) time.Duration { return time.Second }, + }, func(e Entry) error { + if e.Error != "" { + errs = append(errs, e) + } + n++ + if n == 2 { + return gone + } + return nil + }) + if n != 2 || len(errs) != 0 { + t.Errorf("emitted %d after the reader went, errs %v", n, errs) + } +} + +func TestListKinds(t *testing.T) { + t.Parallel() + // Sources only: nothing under them is asked for. + az := &counting{Provider: fake.Azure(0)} + got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"source"}}) + if len(got) == 0 || az.calls.Load() != 1 { + t.Errorf("%d sources, %d requests, want just the sources'", len(got), az.calls.Load()) + } + for _, e := range got { + if e.Kind != "source" { + t.Errorf("listed a %s", e.Kind) + } + } + // Keys, asked for: loaded, though Go to wouldn't; no secrets. + got, _, failed := run(t, Options{Providers: []provider.Provider{fake.Azure(0)}, MaxAge: time.Minute, Kinds: []string{"key"}}) + if !has(got, "key", "platform-prod/kv-app-prod/signing-key") { + t.Errorf("no keys: %+v", got) + } + for _, e := range got { + if e.Kind != "key" { + t.Errorf("listed a %s", e.Kind) + } + } + if len(failed) == 0 { + t.Error("a list that failed wasn't listed") + } +} + +func TestErrorsAreOneLine(t *testing.T) { + for in, want := range map[string]string{ + "403 Forbidden: Caller is not authorized\nCaller: appid=x;oid=y\nVault: kv;location=z": "403 Forbidden: Caller is not authorized", + "\n timeout\tafter 30s\n": "timeout after 30s", + "": "unreadable", + } { + if got := firstLine(in); got != want { + t.Errorf("firstLine(%q) = %q, want %q", in, got, want) + } + } +} + +func TestListMarks(t *testing.T) { + t.Parallel() + az := fake.Azure(0) + var starredLink string + got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"}, + Mark: func(link, account string) Marks { + if starredLink == "" && strings.HasSuffix(link, "/db-password") { + starredLink = link + } + if strings.HasSuffix(link, "/api-key") { + return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"} + } + 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 + for _, e := range got { + if e.Starred { + starred++ + if e.Link != starredLink { + t.Errorf("%s starred", e.Link) + } + } + if (e.Note != "") != strings.HasSuffix(e.Link, "/api-key") { + t.Errorf("%s: note %q", e.Link, e.Note) + } + if e.Recent { + recent++ + if e.Uses != 3 || e.LastUsed.IsZero() { + t.Errorf("%s: %d uses, last %v", e.Link, e.Uses, e.LastUsed) + } + } else if e.Uses != 0 || !e.LastUsed.IsZero() { + t.Errorf("%s not recent, but %d uses, last %v", e.Link, e.Uses, e.LastUsed) + } + } + if starred != 1 || recent == 0 { + 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 +} diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go index 572e7e2..6e467ef 100644 --- a/internal/provider/fake/fake.go +++ b/internal/provider/fake/fake.go @@ -218,6 +218,19 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types 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 { o.Container = c o.Type = t @@ -287,9 +300,13 @@ func Azure(delay time.Duration) *Provider { Reference: secretRef("kv-app-prod", "api-key"), }, "sk_live_51HxDEMO0000000000000000") p.add(kv, provider.TypeSecret, provider.Object{ - Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0), - Reference: secretRef("kv-app-prod", "legacy-token"), + 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"), }, "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{ Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0), @@ -349,6 +366,19 @@ func Azure(delay time.Duration) *Provider { p.add(kvDev, provider.TypeSecret, provider.Object{ Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"), }, "dev-password") + // Left behind, as dev vaults are: expired, disabled, or both. + p.add(kvDev, provider.TypeSecret, provider.Object{ + Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), + Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"), + }, "whsec_demo_expired") + p.add(kvDev, provider.TypeSecret, provider.Object{ + Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0), + Reference: secretRef("kv-app-dev", "test-user-password"), + }, "disabled-demo-password") + p.add(kvDev, provider.TypeSecret, provider.Object{ + Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0), + Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"), + }, "expired-and-disabled") return p } @@ -366,7 +396,8 @@ func AzureOps(delay time.Duration) *Provider { breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...) p.add(breakGlass, provider.TypeSecret, provider.Object{ Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0), - Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"), + Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs + Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"), }, "break-glass-demo-password") shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) p.add(shared, provider.TypeSecret, provider.Object{ @@ -384,6 +415,12 @@ func AzureOps(delay time.Duration) *Provider { p.add(kv, provider.TypeSecret, provider.Object{ Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"), }, "pd-demo-token") + // Expiring soon whenever the demo runs: the other dates are fixed, + // and would have passed. Tests use the first account only. + p.add(kv, provider.TypeSecret, provider.Object{ + Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0), + Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"), + }, "glsa_demo_expiring") return p } diff --git a/internal/provider/fake/seed.go b/internal/provider/fake/seed.go new file mode 100644 index 0000000..aeeca96 --- /dev/null +++ b/internal/provider/fake/seed.go @@ -0,0 +1,65 @@ +package fake + +import ( + "cmp" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +// Seed stars, notes and uses a few of the demo's secrets, so --demo +// shows Favourites (F), Recent (R), notes, and stars in lazysecrets list. alice and +// admin are the two Azure accounts' provider IDs. +func Seed(st *store.Store, alice, admin string) { + type item struct { + link, account, source string + name string // else the link's last part + note string + path []string + star bool + uses int + ago time.Duration + } + const azure, dbx = "lazysecrets://azure/", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/" + for _, it := range []item{ + {link: azure + "kv-app-prod.vault.azure.net/secrets/db-password", account: alice, source: "sub-prod", + path: []string{"platform-prod", "kv-app-prod"}, star: true, uses: 12, ago: 20 * time.Minute, + note: "rotated by the data team each quarter"}, + {link: azure + "kv-app-prod.vault.azure.net/secrets/api-key", account: alice, source: "sub-prod", + path: []string{"platform-prod", "kv-app-prod"}, uses: 4, ago: 2 * time.Hour}, + {link: azure + "kv-breakglass.vault.azure.net/secrets/emergency-admin", account: admin, source: "sub-prod", + 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", + 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", + path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour}, + // 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", + name: "platform-prod", star: true}, + {link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos", + source: "/home/dev/git/lazysecrets-demo", name: "deploy/prod/secrets.yaml", + path: []string{"lazysecrets-demo"}, star: true}, + } { + context := func(n *store.Node) { + n.Name = cmp.Or(it.name, it.link[strings.LastIndex(it.link, "/")+1:]) + n.Path, n.Source, n.Account = it.path, it.source, it.account + } + if it.star || it.note != "" { + _ = st.Update(it.link, func(n *store.Node) { + context(n) + if it.star { + n.ToggleStar(it.account) + } + n.Note = it.note + }) + } + for range it.uses { + _ = st.Use(it.link, time.Now().Add(-it.ago), context) + } + } +} diff --git a/internal/ref/ref.go b/internal/ref/ref.go index df5ff2b..d6fdc82 100644 --- a/internal/ref/ref.go +++ b/internal/ref/ref.go @@ -56,6 +56,10 @@ type Ref struct { // Share is a share link's sealed files, as in the link; the other // fields are empty. Package share reads it. Share string + // Account, when set, is the provider (its ID) to open or read it + // with when several could, from lazysecrets list. It isn't part of + // the link. + Account string } // IsShare reports whether r is a share link. diff --git a/internal/store/store.go b/internal/store/store.go index 04f708a..77689dc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -43,6 +43,13 @@ type Node struct { // alone isn't. 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. func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 0be187c..065e822 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -119,3 +119,15 @@ func TestStarsPerAccount(t *testing.T) { 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") + } +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 37fc60e..29c9f50 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -131,8 +131,15 @@ type Options struct { HandlerSetup *HandlerSetup // NoHistory stops counting uses of items for the Recent list. NoHistory bool + // RecentIcon marks items used lately with ◷ in the lists + // (ui.recentIcon). + RecentIcon bool // Open is a reference to open at startup (lazysecrets open ). Open *ref.Ref + // OpenAction, if set, is what to do with Open, rather than asking: + // go to it, and maybe copy or show its value (OpenActions). It ends in + // the list, like Go to, rather than on the details. + OpenAction string // Styles, if set, replaces the default theme (styles.FromTheme), and // DefaultFg the terminal's default text colour. Styles *styles.Styles @@ -206,9 +213,12 @@ type Model struct { statusW int // the status pane's width: the left column's, zoomed or not changelog []panes.Field cacheDirty bool - sourcesAt map[string]time.Time - placeDir string - savedPlace string // the link last saved as the place + // cacheDropped are cache keys deleted since the last save, so saving + // deletes them from the file too. + 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. // clipToken identifies the latest copy so older ticks are ignored. @@ -283,6 +293,8 @@ type Model struct { prompt *modal // popup that has the keyboard linked []provider.Container // vaults opened from links, this session open *ref.Ref // reference to open at startup + recentIcon bool // mark items used lately (◷) + openAction string // and what to do with it, or ask shareKey string // the config's sharing.key shareRules share.Rules // and sharing.keyRules @@ -415,7 +427,8 @@ func New(providers []provider.Provider, opts Options) Model { if m.store == nil { m.store, _ = store.Load("") } - m.sources = newSourcesList(iconSet, m.store) + m.recentIcon = opts.RecentIcon + m.sources = newSourcesList(iconSet, m.store, m.recentIcon) m.sources.Empty = "no sources found" m.collapsed = map[string]bool{} m.flatSources = opts.FlatSources @@ -447,7 +460,7 @@ func New(providers []provider.Provider, opts Options) Model { } m.defaultFg = opts.DefaultFg m.help = newHelp(m.st) - m.open = opts.Open + m.open, m.openAction = opts.Open, opts.OpenAction saved := m.store.Layout() m.split, m.heights = saved.Split, saved.Heights @@ -521,18 +534,26 @@ func (m Model) Init() tea.Cmd { cmds = append(cmds, m.fetchLatest(false)) } if m.open != nil { - r := *m.open - cmds = append(cmds, func() tea.Msg { return openRefMsg{r} }) + msg := openRefMsg{ref: *m.open, action: m.openAction} + cmds = append(cmds, func() tea.Msg { return msg }) } return tea.Batch(cmds...) } -// openRefMsg starts opening a reference from outside the key handler. -type openRefMsg struct{ ref ref.Ref } +// openRefMsg starts opening a reference from outside the key handler: +// asking what to do with it, or doing action (OpenActions). +type openRefMsg struct { + ref ref.Ref + action string +} + +// OpenActions are what lazysecrets open --action takes, the Open link +// menu's choices that go there. +var OpenActions = map[string]linkAction{"go": actGo, "copy": actCopy, "show": actShow} // OpenLink is a message offering r in the Open link popup, for links // handed over by the link handler (Program.Send). -func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} } +func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{ref: r} } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -589,7 +610,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case openRefMsg: m.deferHandlerSetup() // the link first - cmds = append(cmds, m.offerRef("Open link", msg.ref, false)) + if act, ok := OpenActions[msg.action]; ok && !msg.ref.IsShare() { + cmds = append(cmds, m.findRef(msg.ref, act)) + } else { + cmds = append(cmds, m.offerRef("Open link", msg.ref, false)) + } case resolvedMsg: m.handleResolved(msg) @@ -666,7 +691,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if src, ok := m.selectedSource(); ok { m.logErrOf(src.Name, msg.err) } - delete(m.cache.containers, msg.key) + uncache(&m, m.cache.containers, msg.key) m.containers.SetError(msg.err) } else { m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()} @@ -687,7 +712,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if c, ok := m.containers.Selected(); ok { m.logErrOf(c.Name, msg.err) } - delete(m.cache.objects, msg.key) + uncache(&m, m.cache.objects, msg.key) m.secrets.SetError(msg.err) } else { if msg.bound != nil { diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index 79be8ed..39d19e9 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -536,7 +536,7 @@ func TestBackspaceGoesBack(t *testing.T) { } // While typing a filter, backspace deletes a character instead. d.keys("/") - d.typeText("apx") + d.typeText("api-x") d.send(tea.KeyPressMsg{Code: tea.KeyBackspace}) if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 { t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len()) @@ -666,7 +666,7 @@ func TestFilter(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3", "/") - d.typeText("api") + d.typeText("api-k") if d.m.secrets.Len() != 1 { t.Fatalf("visible = %d, want 1", d.m.secrets.Len()) } @@ -684,9 +684,9 @@ func TestFilter(t *testing.T) { } d.keys("esc") // leave filter mode, clearing the query d.keys("/") - d.typeText("api") + d.typeText("api-k") 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()) } if _, _, s := d.selected(); s != "api-key" { @@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3", "/") - d.typeText("a") // all three secrets contain an a - if d.m.secrets.Len() != 3 { + d.typeText("a") // all four secrets contain an a + if d.m.secrets.Len() != 4 { t.Fatalf("visible = %d", d.m.secrets.Len()) } _, _, initial := d.selected() // kept from before filtering: the middle row 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}) _, _, s := d.selected() seen = append(seen, s) @@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) { if !d.m.secrets.Filtering() { t.Fatal("ctrl+j/k left filter mode") } - // down to the bottom, stay there, back up to where it started - if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial { - t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen) + // down to the bottom, stay there, back up one + if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] { + t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen) } if !strings.Contains(d.screen(), "ctrl+j/n down") { t.Errorf("filter help missing ctrl+j:\n%s", d.screen()) @@ -848,7 +848,7 @@ func TestCopyAndRevealWhileFiltering(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3", "/") - d.typeText("api") + d.typeText("api-k") d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) if d.clip().content != "sk_live_51HxDEMO0000000000000000" { t.Errorf("ctrl+y copied %q", d.clip().content) @@ -1187,7 +1187,8 @@ func TestDetailsFollowFocus(t *testing.T) { func TestStatusLineCountsRepos(t *testing.T) { t.Parallel() d := newDriver(t, fake.Azure(0), fake.Repos()) - if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") { + // The status line, not the pane, which an expired secret's ⚠ fills. + if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 3 repos") { t.Errorf("status line: %q", line) } d = newDriver(t, fake.Azure(0)) @@ -1202,8 +1203,8 @@ func TestStatusPane(t *testing.T) { d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json", OpenURL: func(u string) error { opened = append(opened, u); return nil }}, fake.Azure(0), signedOut{fake.Azure(0)}) - if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") { - t.Errorf("status line:\n%s", d.screen()) + if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "2 accounts · 1 need sign-in") { + t.Errorf("status line: %q", line) } d.keys("0") if d.m.focus != paneStatus { @@ -1303,7 +1304,8 @@ func TestExpiringInStatus(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0)) d.keys("3", "]", "]") // load kv-app-prod's certificates - if !strings.Contains(d.screen(), "⚠ 1 expiring") { + // old-tls and legacy-token. + if !strings.Contains(d.screen(), "⚠ 2 expiring") { t.Errorf("status line:\n%s", d.screen()) } d.keys("0") diff --git a/internal/ui/app/cache.go b/internal/ui/app/cache.go index 2de8090..69d6192 100644 --- a/internal/ui/app/cache.go +++ b/internal/ui/app/cache.go @@ -67,10 +67,23 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd { return nil } m.detail.Refresh(s) - delete(m.cache.details, detailKey(s)) + uncache(m, m.cache.details, detailKey(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 // says of an object, not what describing it adds. func sameListing(a, b provider.Object) bool { diff --git a/internal/ui/app/expiring.go b/internal/ui/app/expiring.go index dea864f..acdc123 100644 --- a/internal/ui/app/expiring.go +++ b/internal/ui/app/expiring.go @@ -5,6 +5,7 @@ import ( "fmt" "slices" + "git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" @@ -28,7 +29,7 @@ func (m Model) expiringItems() []expiringItem { row := sourceRowKey(p.ID(), src.ID) for _, c := range m.cache.containers[row].value { 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 { out = append(out, expiringItem{o, row}) } diff --git a/internal/ui/app/expiry.go b/internal/ui/app/expiry.go index 2d25440..afc6d93 100644 --- a/internal/ui/app/expiry.go +++ b/internal/ui/app/expiry.go @@ -1,9 +1,10 @@ package app import ( - "fmt" "time" + "git.kebcloud.no/keb/lazysecrets/internal/expiry" + "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -11,28 +12,18 @@ import ( // list once they have expired (✗ expired) or will within the configured // warning period (⚠ 12d), and typing "expir" in the filter finds them. -type expiryState int +type expiryState = expiry.State const ( - notExpiring expiryState = iota - expiresSoon - expired + notExpiring = expiry.None + expiresSoon = expiry.Soon + expired = expiry.Expired ) // expiry tells whether o has expired or will within the warning period, // and how long it has left. func (m Model) expiry(o provider.Object) (expiryState, time.Duration) { - if o.Expires.IsZero() { - 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 + return expiry.Of(o.Expires, m.now(), m.expiryWarning) } // 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. -func daysLeft(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)) -} +func daysLeft(d time.Duration) string { return expiry.Left(d) } diff --git a/internal/ui/app/favourites.go b/internal/ui/app/favourites.go index 9faf2d8..4932634 100644 --- a/internal/ui/app/favourites.go +++ b/internal/ui/app/favourites.go @@ -3,9 +3,11 @@ package app import ( "slices" "strings" + "time" 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/ref" "git.kebcloud.no/keb/lazysecrets/internal/store" @@ -19,6 +21,22 @@ import ( const star = "★ " +// recentMark marks items used lately (Recent), with ui.recentIcon. +const recentMark = "◷ " + +// marks is what goes before a list row's name: ★ when it's starred for +// account and, with recent, ◷ when it was used lately. +func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string { + var s string + if n.StarredFor(account) { + s = st.Favourite.Render(star) + } + if recent && n.UsedLately(now) { + s += st.Muted.Render(recentMark) + } + return s +} + func sourceKey(s provider.Source) string { if r, err := ref.ForSource(s); err == nil { return r.String() @@ -94,9 +112,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con } line += sty.Muted.Render(" " + c.Detail) } - if n.StarredFor(c.Source.Provider) { - line = sty.Favourite.Render(star) + line - } + line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line if withSource { src := c.Source.Name if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok { @@ -116,9 +132,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string { if o.Enabled != nil && !*o.Enabled { line = sty.Muted.Render(o.Name + " (disabled)") } - if n.StarredFor(o.Container.Source.Provider) { - line = sty.Favourite.Render(star) + line - } + line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line switch state, left := m.expiry(o); state { case expired: line += " " + sty.Error.Render("✗ expired") @@ -167,7 +181,7 @@ func (m *Model) rebind(c provider.Container) string { m.linked[i].Source = c.Source } } - key := containerKey(c) + "|" + string(m.objType) + key := cachefile.ObjectsKey(c, m.objType) if m.onLinks() { m.containers.SetItems(m.linked) m.containers.Select(containerKey(c)) diff --git a/internal/ui/app/favourites_test.go b/internal/ui/app/favourites_test.go index 9c1dd84..40ecbbb 100644 --- a/internal/ui/app/favourites_test.go +++ b/internal/ui/app/favourites_test.go @@ -6,6 +6,7 @@ import ( "slices" "strings" "testing" + "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -394,7 +395,7 @@ func TestStarWhileFiltering(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3", "/") - for _, r := range "api" { + for _, r := range "api-k" { d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) } d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}) @@ -434,3 +435,20 @@ func TestStarIsPerAccount(t *testing.T) { t.Errorf("favourites should list alice's star only: %q", labels) } } + +func TestRecentIcon(t *testing.T) { + t.Parallel() + mem, _ := store.Load("") + _ = mem.Use("lazysecrets://azure/"+kv+"/secrets/api-key", time.Now(), func(*store.Node) {}) + d := newDriverOpts(t, Options{Store: mem, RecentIcon: true}) + d.keys("3") + scr := d.screen() + if !strings.Contains(scr, "◷ api-key") || strings.Contains(scr, "◷ db-password") { + t.Errorf("want ◷ on api-key only:\n%s", scr) + } + off := newDriverOpts(t, Options{Store: mem}) + off.keys("3") + if strings.Contains(off.screen(), "◷") { + t.Errorf("◷ without ui.recentIcon:\n%s", off.screen()) + } +} diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index 4b6b2c8..71b3610 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "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/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("web", c.URL) 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))) } } diff --git a/internal/ui/app/jump.go b/internal/ui/app/jump.go index 905cbec..066c841 100644 --- a/internal/ui/app/jump.go +++ b/internal/ui/app/jump.go @@ -107,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) { if m.history { var used []string for _, k := range keys { - if !nodes[k].LastUsed.IsZero() { + if nodes[k].UsedLately(m.now()) { used = append(used, k) } } diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index 3063558..465e936 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -56,9 +56,12 @@ type resolvedMsg struct { act linkAction sourceRow string others []string // other accounts' rows that also found the vault - choices []choice // several candidates: ask - linked *provider.Container - err error + // find ends in the list, like Go to, not on the details: opened by a + // tool that found it (lazysecrets open --action, from fzf). + find bool + choices []choice // several candidates: ask + linked *provider.Container + err error } type choice struct { @@ -218,6 +221,9 @@ func (m *Model) openSource(r ref.Ref) { } } } + if row, ok := preferredRow(r, choices); ok { + choices = []choice{{sourceRow: row}} + } switch len(choices) { case 0: m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true} @@ -404,18 +410,59 @@ func (m *Model) handleResolved(msg resolvedMsg) { for i := range msg.choices { msg.choices[i].label = "Open in " + msg.choices[i].label } - m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true) + m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, !msg.find) case len(msg.others) > 0: var choices []choice for _, row := range append([]string{msg.sourceRow}, msg.others...) { choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) } - m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true) + if row, ok := preferredRow(msg.ref, choices); ok { + m.navResolved(msg, row) + return + } + m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, !msg.find) case msg.linked != nil: m.addLinked(*msg.linked) - m.startLinkNav(msg.ref, msg.act, linksRowID) + m.navResolved(msg, linksRowID) default: - m.startLinkNav(msg.ref, msg.act, msg.sourceRow) + m.navResolved(msg, msg.sourceRow) + } +} + +// preferredRow is the choice under the account the reference asks for +// (ref.Ref.Account), if it's one of them. +func preferredRow(r ref.Ref, choices []choice) (string, bool) { + for _, c := range choices { + if r.Account != "" && providerOf(c.sourceRow) == r.Account { + return c.sourceRow, true + } + } + return "", false +} + +// navResolved goes to what a resolved link points at, under row. +func (m *Model) navResolved(msg resolvedMsg, row string) { + if msg.find { + m.startNav(msg.ref, msg.act, row) + return + } + m.startLinkNav(msg.ref, msg.act, row) +} + +// findRef is openRef ending in the list, like Go to: for a link a tool +// found (lazysecrets open --action). +func (m *Model) findRef(r ref.Ref, act linkAction) tea.Cmd { + cmd := m.openRef(r, act) + if cmd == nil { + return nil + } + return func() tea.Msg { + msg := cmd() + if rm, ok := msg.(resolvedMsg); ok { + rm.find = true + return rm + } + return msg } } diff --git a/internal/ui/app/links_test.go b/internal/ui/app/links_test.go index 2ae1846..b5f218c 100644 --- a/internal/ui/app/links_test.go +++ b/internal/ui/app/links_test.go @@ -181,7 +181,7 @@ func TestPasteWhileFilteringFilters(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3", "/") - d.send(tea.PasteMsg{Content: "api"}) + d.send(tea.PasteMsg{Content: "api-k"}) if d.m.secrets.Len() != 1 || d.m.nav != nil { t.Errorf("paste in filter: visible %d", d.m.secrets.Len()) } @@ -203,8 +203,21 @@ func TestOpenPromptAndStartupOpen(t *testing.T) { t.Fatal("open at startup didn't ask what to do") } d2.keys("o") - if _, _, s := d2.selected(); s != "api-key" { - t.Errorf("open at startup: selected %q", s) + if _, _, s := d2.selected(); s != "api-key" || d2.m.focus != paneDetail { + t.Errorf("open at startup: selected %q, focus %v, want the details", s, d2.m.focus) + } + + // --action: no question, it does that. + d3 := newDriverOpts(t, Options{Open: &r, OpenAction: "go"}) + if d3.m.prompt != nil { + t.Fatalf("--action go asked: %q", d3.m.prompt.title) + } + if _, _, s := d3.selected(); s != "api-key" || d3.m.focus != paneSecrets { + t.Errorf("--action go: selected %q, focus %v, want the secrets list, like Go to", s, d3.m.focus) + } + d4 := newDriverOpts(t, Options{Open: &r, OpenAction: "copy"}) + if _, _, s := d4.selected(); s != "api-key" || d4.clip().content == "" { + t.Errorf("--action copy: selected %q, copied %q", s, d4.clip().content) } } @@ -305,6 +318,20 @@ func TestOpenAsksWhichAccount(t *testing.T) { t.Errorf("j enter picked %q", c.Source.Provider) } + // lazysecrets open --account: no question, that account. + r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key") + r.Account = "Azure:bob@example.com/t1" + d.send(OpenLink(r)) + if d.m.prompt != nil && d.m.prompt.title == "Open link" { + d.keys("o") + } + if strings.Contains(d.screen(), "Several accounts can see") { + t.Fatalf("asked, with an account given:\n%s", d.screen()) + } + if c, _ := d.m.containers.Selected(); c.Source.Provider != r.Account { + t.Errorf("--account opened via %q", c.Source.Provider) + } + // Opening into a folded account unfolds it. d.keys("1", "g") // alice's header d.keys("enter") // fold it diff --git a/internal/ui/app/persist.go b/internal/ui/app/persist.go index 659637d..1fab15f 100644 --- a/internal/ui/app/persist.go +++ b/internal/ui/app/persist.go @@ -1,14 +1,12 @@ package app import ( - "encoding/json" "errors" - "os" - "path/filepath" "time" 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/ref" "git.kebcloud.no/keb/lazysecrets/internal/store" @@ -23,58 +21,28 @@ import ( // 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 -} +func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) } + +func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) } // 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 - } + f := cachefile.Read(m.cachePath) now := m.now() - recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge } + recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge } for id, e := range f.Sources { - if m.keepCached(id+"|") && recent(e.At) { + if m.keepProvider(id) && recent(e.At) { m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value} m.sourcesAt[id] = e.At } @@ -102,33 +70,27 @@ func (m *Model) loadCacheFile() { } // 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{}, - } +func (m *Model) cacheSnapshot() cachefile.File { + f := cachefile.New() + f.Dropped, m.cacheDropped = m.cacheDropped, nil for id, r := range m.sourceResults { - if r.err == nil && m.keepCached(id+"|") { - f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources} + if r.err == nil && m.keepProvider(id) { + f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources} } } for k, e := range m.cache.containers { if m.keepCached(k) { - f.Containers[k] = stamped[[]provider.Container]{e.at, e.value} + f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value} } } for k, e := range m.cache.objects { if m.keepCached(k) { - f.Objects[k] = stamped[[]provider.Object]{e.at, e.value} + f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value} } } for k, e := range m.cache.details { if m.keepCached(k) { - f.Details[k] = stamped[provider.Object]{e.at, e.value} + f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value} } } for k, v := range m.cache.cursor { @@ -139,28 +101,11 @@ func (m *Model) cacheSnapshot() cacheFile { 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) +// saveCache saves f, merged with the file (lazysecrets list may have +// loaded more since), from any goroutine. +func (m *Model) saveCache(f cachefile.File) func() error { + path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now() + return func() error { return cachefile.Save(path, f, keep, now) } } // persistTick saves the cache, and where lazysecrets is, in the @@ -169,8 +114,8 @@ func (m *Model) persistTick() tea.Cmd { cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })} if m.cacheDirty && m.cachePath != "" { m.cacheDirty = false - f, path := m.cacheSnapshot(), m.cachePath - cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} }) + save := m.saveCache(m.cacheSnapshot()) + cmds = append(cmds, func() tea.Msg { return persistedMsg{save()} }) } if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace { m.savedPlace = p.Link @@ -185,7 +130,7 @@ func (m *Model) persistTick() tea.Cmd { func (m Model) Persist() error { var errs []error if m.cachePath != "" { - errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot())) + errs = append(errs, m.saveCache(m.cacheSnapshot())()) } if m.placeDir != "" { if p, ok := m.place(); ok { diff --git a/internal/ui/app/persist_test.go b/internal/ui/app/persist_test.go index e1c1b6c..967152f 100644 --- a/internal/ui/app/persist_test.go +++ b/internal/ui/app/persist_test.go @@ -1,12 +1,14 @@ package app import ( + "encoding/json" "os" "path/filepath" "strings" "testing" "time" + "git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/ref" @@ -80,15 +82,16 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) { 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"}}}, + f := cachefile.File{ + Version: cachefile.Version, + Containers: map[string]cachefile.Stamped[[]provider.Container]{ + az.ID() + "|recent": {At: now.Add(-time.Hour), Value: []provider.Container{{Name: "kv-recent"}}}, + az.ID() + "|old": {At: now.Add(-cachefile.MaxAge - time.Hour), Value: []provider.Container{{Name: "kv-old"}}}, + "Azure:gone@x/t|recent": {At: now, Value: []provider.Container{{Name: "kv-gone"}}}, }, } - if err := writeCacheFile(file, f); err != nil { + b, _ := json.Marshal(f) // as an older lazysecrets left it + if err := os.WriteFile(file, b, 0o600); err != nil { t.Fatal(err) } d := newDriverOpts(t, Options{CacheFile: file}, az) @@ -97,7 +100,7 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) { t.Error("dropped a recent entry") } if _, ok := c[az.ID()+"|old"]; ok { - t.Error("kept an entry older than cacheMaxAge") + t.Error("kept an entry older than cachefile.MaxAge") } if _, ok := c["Azure:gone@x/t|recent"]; ok { t.Error("kept an entry of an account that isn't signed in") diff --git a/internal/ui/app/route.go b/internal/ui/app/route.go index a57ff89..9c22a08 100644 --- a/internal/ui/app/route.go +++ b/internal/ui/app/route.go @@ -5,13 +5,14 @@ import ( "fmt" "slices" + "git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/provider" ) // containerKey identifies a container in the UI and caches. The same vault // seen through two accounts must not share cached lists, since each // account may see different things. -func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID } +func containerKey(c provider.Container) string { return cachefile.ContainerKey(c) } // providerFor finds the provider instance a source came from. Sources // remembered from an earlier run (favourites) may name an account that diff --git a/internal/ui/app/search.go b/internal/ui/app/search.go index 3a8ec03..7648853 100644 --- a/internal/ui/app/search.go +++ b/internal/ui/app/search.go @@ -10,6 +10,7 @@ import ( 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/ref" ) @@ -89,7 +90,7 @@ func (m *Model) searchEntries() (sources, items []option) { return nil }, reload) 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, "") if err != nil { continue @@ -166,7 +167,7 @@ func (m *Model) coverageOf(in providerFilter) coverage { } for _, v := range e.value { c.vaults++ - key := containerKey(v) + "|" + string(provider.TypeSecret) + key := cachefile.ObjectsKey(v, provider.TypeSecret) if objs, ok := m.cache.objects[key]; ok { c.vaultsLoaded++ 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 // force. 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 { return } @@ -327,7 +328,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd { m.crawl.active-- key := msg.job.row 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) switch { @@ -365,7 +366,7 @@ func (m *Model) sourceMark(row string) string { } loaded, loading := 0, false for _, c := range e.value { - key := containerKey(c) + "|" + string(provider.TypeSecret) + key := cachefile.ObjectsKey(c, provider.TypeSecret) loading = loading || m.crawl.pending[key] if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] { loaded++ @@ -386,7 +387,7 @@ func (m *Model) sourceMark(row string) string { // (✗) or are loading (◌): whether a subscription is loaded says whether // its vaults are searchable. func (m *Model) vaultMark(c provider.Container) string { - key := containerKey(c) + "|" + string(provider.TypeSecret) + key := cachefile.ObjectsKey(c, provider.TypeSecret) switch { case m.crawl.pending[key]: return m.st.Muted.Render("◌") diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index 4a467ad..8f7b469 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -12,6 +12,7 @@ import ( "charm.land/lipgloss/v2" "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/provider" "git.kebcloud.no/keb/lazysecrets/internal/ref" @@ -617,7 +618,7 @@ func (m Model) loadHint() string { } loaded := 0 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] { loaded++ } diff --git a/internal/ui/app/sources.go b/internal/ui/app/sources.go index 70ac125..e1b3bd4 100644 --- a/internal/ui/app/sources.go +++ b/internal/ui/app/sources.go @@ -5,7 +5,9 @@ import ( "fmt" "slices" "strings" + "time" + "git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" @@ -39,7 +41,7 @@ type sourceRow struct { // sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in // either; provider IDs contain "/" (Azure:user/tenant). -func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID } +func sourceRowKey(providerID, sourceID string) string { return cachefile.Row(providerID, sourceID) } func splitSourceRow(row string) (providerID, sourceID string) { providerID, sourceID, _ = strings.Cut(row, "|") @@ -56,7 +58,7 @@ func sourceRowID(r sourceRow) string { return sourceRowKey(r.provider, r.source.ID) } -func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] { +func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] { return panes.NewList("[1] Sources", panes.Item[sourceRow]{ ID: sourceRowID, // Headers have no filter text, so filtering shows just sources. @@ -78,8 +80,8 @@ func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] { node := mem.Node(sourceKey(r.source)) kind := st.Kind(r.group) starred := "" - if r.kind == rowSource && node.StarredFor(r.source.Provider) { - starred = st.Favourite.Render(star) + if r.kind == rowSource { + starred = marks(st, node, r.source.Provider, recent, time.Now()) } switch { case r.kind == rowLinks: diff --git a/internal/ui/app/testdata/TestGolden.golden b/internal/ui/app/testdata/TestGolden.golden index 3943763..e9ac20d 100644 --- a/internal/ui/app/testdata/TestGolden.golden +++ b/internal/ui/app/testdata/TestGolden.golden @@ -1,5 +1,5 @@ ╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮ -│ 2 accounts ││ name platform-prod │ +│ ⚠ 1 expiring · 2 accounts ││ name platform-prod │ ╰───────────────────────────────╯│ ID sub-prod │ ╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│ │ ▾ Azure (2) ││ vaults 3 │ @@ -20,7 +20,8 @@ ╭─ [3] Secrets - Keys - Certs ──╮│ │ │ db-password ││ │ │ api-key ││ │ -│ legacy-token (disabled) ││ │ +│ legacy-token ✗ expired ││ │ +│ smtp-password (disabled) ││ │ │ ││ │ │ ││ │ │ ││ │ @@ -28,6 +29,5 @@ │ ││ │ │ ││ │ │ ││ │ -│ ││ │ -╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯ +╰────────────────────── 1 of 4 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯ enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit \ No newline at end of file diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go index c726da4..15a3941 100644 --- a/internal/ui/icons/icons.go +++ b/internal/ui/icons/icons.go @@ -11,6 +11,7 @@ type Set struct { Repo string Encrypted string // an encrypted file (SOPS) PlainFile string // a plain text secrets file (.env) + Note string // has a note (lazysecrets list) } // None renders no icons. @@ -24,6 +25,7 @@ var NerdFont = Set{ Repo: "\U000F02A2", // md-git Encrypted: "\U000F033E", // md-lock PlainFile: "\U000F0214", // md-file + Note: "\U000F09ED", } // Prefix returns the icon for kind followed by a space, or "" if the set has