diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index 02f1b7e..f684129 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -41,7 +41,7 @@ import ( // 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, args []string, out io.Writer) error { +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") @@ -50,6 +50,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)") 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)") + expiry := fs.Bool("expiry", true, "put ⚠ before names expiring within ui.expiryWarningDays, ✗ before expired ones (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 { @@ -71,16 +72,17 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t if *color != "always" && *color != "never" { return fmt.Errorf("--color: %q is not always or never", *color) } - iconSet := icons.None + m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry, + color: *color == "always", warn: expiryWarn, now: time.Now()} if *iconsOn { - iconSet = icons.NerdFont + m.icons = icons.NerdFont } var write func(listing.Entry) error switch *format { case "text": write = func(e listing.Entry) error { colored := *color == "always" - mark := markOf(e, theme, iconSet, *stars, *recent, colored) + mark := m.of(e) name := nameOf(e, theme, colored) why := errorOf(e, theme, colored) // A tab or line break in a name would break the line up. @@ -165,12 +167,21 @@ func nameOf(e listing.Entry, theme styles.Styles, color bool) string { return name } -// markOf is what goes before the name, in a field of its own so a search -// leaves it out: ★ for a starred entry and ◷ for one used lately (as the -// TUI marks them; a space as wide for the rest, so names line up), and -// the provider's icon. -func markOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, stars, recent, color bool) string { - render := renderer(color) +// marks says what goes before the name, in a field of its own so a +// search leaves it out: ★ for a starred entry, ◷ for one used lately, +// ⚠ for one expiring within warn and ✗ for an expired one (as the TUI +// marks them; a space as wide where it isn't, so names line up), then the +// provider's icon. +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 mark := func(on bool, st lipgloss.Style, s string) { if !on { @@ -178,11 +189,22 @@ func markOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, stars, rece } parts = append(parts, render(st, s)) } - if stars { - mark(e.Starred, theme.Favourite, "★") + if m.stars { + mark(e.Starred, m.theme.Favourite, "★") } - if recent { - mark(e.Recent, theme.Muted, "◷") + if m.recent { + mark(e.Recent, m.theme.Muted, "◷") + } + if m.expiry { + left := e.Expires.Sub(m.now) + switch { + case e.Expires.IsZero() || left > m.warn: + mark(false, m.theme.Muted, "") + case left <= 0: + mark(true, m.theme.Error, "✗") + default: + mark(true, m.theme.Warning, "⚠") + } } kind := provider.Kind("") for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} { @@ -190,8 +212,8 @@ func markOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, stars, rece kind = k } } - if icon := strings.TrimSpace(iconSet.Prefix(kind)); icon != "" { - parts = append(parts, render(theme.Kind(kind), icon)) + if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" { + parts = append(parts, render(m.theme.Kind(kind), icon)) } return strings.Join(parts, " ") } diff --git a/cmd/lazysecrets/list_test.go b/cmd/lazysecrets/list_test.go index bed768a..95264fd 100644 --- a/cmd/lazysecrets/list_test.go +++ b/cmd/lazysecrets/list_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" "git.kebcloud.no/keb/lazysecrets/internal/listing" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" @@ -22,39 +23,54 @@ func TestListNames(t *testing.T) { 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 { - e listing.Entry - stars, color bool - set icons.Set - want string + m marks + e listing.Entry + want string }{ - {listing.Entry{Provider: "azure", Starred: true}, true, false, icons.NerdFont, "★ " + az}, - {listing.Entry{Provider: "azure"}, true, false, icons.NerdFont, " " + az}, // as wide as ★ - {listing.Entry{Provider: "azure", Starred: true}, false, false, icons.NerdFont, az}, - {listing.Entry{Provider: "azure", Starred: true}, true, false, icons.None, "★"}, - {listing.Entry{Provider: "azure", Starred: true}, false, false, icons.None, ""}, - {listing.Entry{Provider: "azure", Starred: true}, true, true, icons.None, styles.Default().Favourite.Render("★")}, + {plain, listing.Entry{Provider: "azure", Starred: true}, "★ " + az}, + {plain, listing.Entry{Provider: "azure"}, " " + az}, // as wide as ★ + {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 := markOf(c.e, styles.Default(), c.set, c.stars, false, c.color); got != c.want { - t.Errorf("markOf(starred %v, stars %v, colour %v) = %q, want %q", c.e.Starred, c.stars, c.color, got, c.want) + if got := c.m.of(c.e); got != c.want { + t.Errorf("%+v: %q, want %q", c.e, got, c.want) } } } -func TestListRecentMark(t *testing.T) { +func TestListRecentAndExpiryMarks(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, recent: true, expiry: true, warn: 30 * 24 * time.Hour, now: now} for _, c := range []struct { e listing.Entry want string }{ - {listing.Entry{Provider: "azure", Starred: true, Recent: true}, "★ ◷ " + az}, - {listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az}, - {listing.Entry{Provider: "azure", Starred: true}, "★ " + az}, // as wide as ◷ + {listing.Entry{Provider: "azure", Starred: true, Recent: true}, "★ ◷ " + az}, + {listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(10 * 24 * time.Hour)}, " ⚠ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour)}, " " + az}, } { - if got := markOf(c.e, styles.Default(), icons.NerdFont, true, true, false); got != c.want { - t.Errorf("markOf(starred %v, recent %v) = %q, want %q", c.e.Starred, c.e.Recent, got, c.want) + if got := m.of(c.e); got != c.want { + t.Errorf("%+v: %q, want %q", c.e, got, c.want) } } + m.color, m.theme = true, styles.Default() + if got := m.of(listing.Entry{Expires: now.Add(-time.Hour)}); !strings.Contains(got, styles.Default().Error.Render("✗")) { + t.Errorf("expired isn't a red ✗: %q", got) + } + if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) { + t.Errorf("expiring isn't a ⚠ in the warning colour: %q", got) + } } func TestListErrors(t *testing.T) { diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index a4c086f..d2b5398 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -389,7 +389,7 @@ Flags: 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, listArgs, os.Stdout) + 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 diff --git a/docs/config.md b/docs/config.md index 008a8c2..2429c84 100644 --- a/docs/config.md +++ b/docs/config.md @@ -13,7 +13,7 @@ ui: 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) diff --git a/internal/listing/listing.go b/internal/listing/listing.go index 42fac33..8f32e9b 100644 --- a/internal/listing/listing.go +++ b/internal/listing/listing.go @@ -45,6 +45,10 @@ type Entry struct { 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"` // 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. @@ -200,7 +204,8 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi 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)}) + Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source), + Expires: o.Expires, Enabled: o.Enabled}) } }