From 913b3db6a938d21c6d8c9285a3111418495c7345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Thu, 15 Oct 2026 13:44:29 +0200 Subject: [PATCH] feat: H shows what's hidden; Spring keys that don't look secret are hidden (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Karl-Einar Bergstrøm --- cmd/lazysecrets/list.go | 3 +- docs/integration.md | 1 + docs/keys.md | 1 + docs/repos.md | 11 ++-- internal/listing/listing.go | 19 ++++-- internal/provider/provider.go | 8 ++- internal/provider/repo/repo.go | 11 +++- internal/provider/repo/spring.go | 25 ++++++-- internal/provider/repo/spring_test.go | 42 +++++++++++-- internal/ui/app/app.go | 5 ++ internal/ui/app/app_test.go | 31 +++++++++ internal/ui/app/favourites.go | 13 +++- internal/ui/app/search.go | 25 ++++++-- internal/ui/app/sources.go | 90 ++++++++++++++++----------- internal/ui/keys/keys.go | 4 +- internal/ui/panes/detail.go | 6 ++ internal/ui/panes/detail_test.go | 20 ++++++ internal/ui/panes/list.go | 44 ++++++++++--- internal/ui/panes/list_test.go | 38 +++++++++++ 19 files changed, 323 insertions(+), 74 deletions(-) diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index 8bc6dc8..4781d4b 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -57,6 +57,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t 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\")") + hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: Spring config keys that don't look secret, and files and repos with only those (json: \"hidden\")") if err := fs.Parse(args); err != nil { return err } @@ -129,7 +130,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t f = cachefile.Read(cachePath) } f, loaded := listing.Run(context.Background(), listing.Options{ - Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, + Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, Hidden: *hidden, Mark: func(link, account string) listing.Marks { n := st.Node(link) return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now), diff --git a/docs/integration.md b/docs/integration.md index 65f2f62..7f63b68 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -51,6 +51,7 @@ so `^prod` finds names starting with prod. | `--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 | +| `--hidden` | list what's [hidden](repos.md#spring-boot-config) too, as `H` shows it (json: `"hidden": true`) | | `--color never` | no colours, for scripts | | `--icons=false` | no provider icons | | `--stars=false` | no ★ before starred names (in `favouriteColor`) | diff --git a/docs/keys.md b/docs/keys.md index 483b363..5f1d440 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -20,6 +20,7 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter | `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets | | `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars | | `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it | +| `H` | show / hide what's hidden: [Spring keys](repos.md#spring-boot-config) that don't look secret, and files and repos with only those; shown dimmed | | `r` | refresh the focused pane | | `q`, `ctrl+c` | quit | diff --git a/docs/repos.md b/docs/repos.md index a2ee7b3..c36f48b 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -69,7 +69,8 @@ sharing: `application.yml`, `application-prod.properties`, `bootstrap.yaml` and the like, tracked by git (not those in a Helm chart's `templates/`). Most of -their keys aren't secret, so only those that look like one are listed: +their keys aren't secret, so only those that look like one are listed; the +rest are hidden: - **keys** named like a secret: ending in `password`, `pwd`, `secret`, `token`, `key`, `credentials`, `salt`, `connection-string` and the @@ -78,9 +79,11 @@ their keys aren't secret, so only those that look like one are listed: password (`jdbc:postgresql://app:pw@db/app`), or a connection string with `AccountKey=`, `SharedAccessKey=` or `Password=`. -A file's details count the keys left out, and `e` opens it whole. A -`${DB_PASSWORD}` value is marked as a reference: the secret is set -elsewhere. A tracked file is marked in red only when a listed key has its +A file with none is hidden too, and a repo with only such files. `H` shows +what's hidden, dimmed, in the panes and in Go to (`lazysecrets list +--hidden` lists it); a pane's counter says how many it hides. A file's +details count its hidden keys, and `e` opens it whole. A `${DB_PASSWORD}` +value is marked as a reference: the secret is set elsewhere. A tracked file is marked in red only when a listed key has its value in plain text. Each list of regexps replaces its default; `keys: [".*"]` lists every key: diff --git a/internal/listing/listing.go b/internal/listing/listing.go index 8c6f4ff..cc06487 100644 --- a/internal/listing/listing.go +++ b/internal/listing/listing.go @@ -56,6 +56,9 @@ type Entry struct { // web page (portal, workspace), if any. Reference string `json:"reference,omitempty"` URL string `json:"url,omitempty"` + // Hidden is left out by its provider's rules (a Spring file's key + // that doesn't look secret), listed only with Options.Hidden. + Hidden bool `json:"hidden,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. @@ -70,6 +73,8 @@ type Options struct { MaxAge time.Duration // Cached lists only what's cached (and repos): nothing is loaded. Cached bool + // Hidden lists what providers hide too, as H does in the TUI. + Hidden 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. @@ -180,6 +185,9 @@ func (l *lister) provider(p provider.Provider) { // source emits src, and what's cached under it, and queues what's not. func (l *lister) source(p provider.Provider, src provider.Source) { + if src.Hidden && !l.o.Hidden { + return + } src.Provider, src.ProviderLabel = p.ID(), p.Label() l.put(p, sourceEntry(src)) row := cachefile.Row(p.ID(), src.ID) @@ -196,6 +204,9 @@ func (l *lister) source(p provider.Provider, src provider.Source) { 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 }) { + if c.Hidden && !l.o.Hidden { + continue + } c.Source = src l.put(p, containerEntry(c)) for _, t := range c.ObjectTypes() { @@ -221,17 +232,17 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) { o.Container = c r, err := ref.ForObject(o, "") - if err != nil { + if err != nil || (o.Hidden && !l.o.Hidden) { 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}) + Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL, Hidden: o.Hidden}) } } func sourceEntry(src provider.Source) Entry { - e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL} + e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL, Hidden: src.Hidden} if r, err := ref.ForSource(src); err == nil { e.Link = r.String() } @@ -239,7 +250,7 @@ func sourceEntry(src provider.Source) Entry { } func containerEntry(c provider.Container) Entry { - e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL} + e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL, Hidden: c.Hidden} if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil { e.Link = r.String() } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 82eaefb..6371e8c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -73,6 +73,10 @@ type Source struct { // Here marks the source lazysecrets was started in (the current git // repo): listed first, starred or not, and labelled. Here bool + // Hidden marks a source, container or object the provider's rules + // left out (a Spring file with no secret-looking keys): listed only + // while the app shows hidden ones. + Hidden bool `json:",omitempty"` } // ObjectType is the kind of object stored in a container. @@ -109,7 +113,8 @@ type Container struct { Types []ObjectType URL string // web page for the container, if any // Attrs describe it further, from the listing (a vault's settings). - Attrs []Attr `json:",omitempty"` + Attrs []Attr `json:",omitempty"` + Hidden bool `json:",omitempty"` // see Source.Hidden } // ObjectTypes returns the container's object types, defaulting to secrets. @@ -134,6 +139,7 @@ type Object struct { ID string Name string Detail string // shown after the name, e.g. "jasypt" for how its value is kept + Hidden bool `json:",omitempty"` // see Source.Hidden Version string Enabled *bool Updated time.Time diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index 5acd054..0b3fc14 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -190,12 +190,19 @@ func (p *Provider) Current(ctx context.Context) (provider.Source, bool) { return provider.Source{}, false } src := p.source(r) - if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 { + files, err := p.Containers(ctx, src) + if err == nil && len(files) == 0 { return provider.Source{}, false } + src.Hidden = err == nil && allHidden(files) return src, true } +// allHidden reports whether a repo's files are all hidden: the repo is too. +func allHidden(files []provider.Container) bool { + return !slices.ContainsFunc(files, func(c provider.Container) bool { return !c.Hidden }) +} + func (p *Provider) source(r repoInfo) provider.Source { return provider.Source{ Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(), @@ -264,6 +271,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) { parallel(len(srcs), func(i int) { files, err := p.Containers(ctx, srcs[i]) keep[i] = err != nil || len(files) > 0 + srcs[i].Hidden = err == nil && allHidden(files) }) var out []provider.Source for i, src := range srcs { @@ -327,6 +335,7 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi c.Detail = provider.DetailPlainEnv case IsSpring(f): c.Detail = "spring · " + springFormat(f) + c.Hidden = p.springHidden(r.root, path) default: return } diff --git a/internal/provider/repo/spring.go b/internal/provider/repo/spring.go index de64c4d..82f8687 100644 --- a/internal/provider/repo/spring.go +++ b/internal/provider/repo/spring.go @@ -9,6 +9,7 @@ import ( "path" "path/filepath" "regexp" + "slices" "strconv" "strings" "unicode" @@ -331,7 +332,22 @@ func unescapeProperty(s string) string { return b.String() } -// springObjects lists the file's secret keys. +// springHidden reports whether a Spring file has no secret keys. One that +// can't be read isn't hidden: its error is worth seeing. +func (p *Provider) springHidden(root, file string) bool { + keys, err := readSpring(file) + if err != nil { + return false + } + filter, err := p.springFilterFor(root) + if err != nil { + return false + } + return !slices.ContainsFunc(keys, filter.secret) +} + +// springObjects lists the file's keys, those that don't look like secrets +// hidden. func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Object, error) { keys, err := parseSpring(c.ID, b) if err != nil { @@ -344,9 +360,6 @@ func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Obj format := provider.Attr{Label: "format", Value: "spring " + springFormat(c.ID)} var out []provider.Object for _, k := range keys { - if !filter.secret(k) { - continue - } state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, "" switch { case k.reference(): @@ -356,7 +369,7 @@ func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Obj } out = append(out, provider.Object{ Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail, - Attrs: []provider.Attr{format, state}, + Hidden: !filter.secret(k), Attrs: []provider.Attr{format, state}, }) } return out, nil @@ -406,6 +419,6 @@ func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, p } return []provider.Attr{ {Label: "encryption", Value: jasyptShare(jasypt, len(keys))}, - {Label: "other keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)}, + {Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)}, }, plain } diff --git a/internal/provider/repo/spring_test.go b/internal/provider/repo/spring_test.go index 1685d70..da28ded 100644 --- a/internal/provider/repo/spring_test.go +++ b/internal/provider/repo/spring_test.go @@ -177,11 +177,15 @@ app: } local, yml := cs[0], cs[1] - keys, _ := p.Objects(ctx, yml, provider.TypeSecret) + listed, _ := p.Objects(ctx, yml, provider.TypeSecret) + keys := shown(listed) var names []string for _, k := range keys { names = append(names, strings.TrimSpace(k.Name+" "+k.Detail)) } + if len(listed) != 5 || !listed[0].Hidden || listed[0].Name != "server.port" { + t.Errorf("hidden keys: %+v", listed) + } if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) { t.Errorf("keys %v, want %v", names, want) } @@ -198,7 +202,7 @@ app: } // References and Jasypt aren't plain text in git; a password is. - if d := details(p, yml); d["other keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" { + if d := details(p, yml); d["hidden keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" { t.Errorf("yml: %+v", d) } if d := details(p, local); !d["git"].Warn { @@ -208,12 +212,12 @@ app: // The config's patterns, and the repo's over them. all := []string{".*"} p = New(Settings{Dir: root, Spring: SpringPatterns{Keys: &all}}) - if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 5 { - t.Errorf("every key: %d", len(keys)) + if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 5 { + t.Errorf("every key: %d", len(shown(keys))) } lazysecrets := filepath.Join(root, ".lazysecrets.yaml") os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"url$\"]\n values: []\n"), 0o644) - if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 1 || keys[0].Name != "spring.datasource.url" { + if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 1 || shown(keys)[0].Name != "spring.datasource.url" { t.Errorf("repo's patterns: %v", keys) } os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"(\"]\n"), 0o644) @@ -221,3 +225,31 @@ app: t.Errorf("bad pattern: %v", err) } } + +func shown(objects []provider.Object) []provider.Object { + return slices.DeleteFunc(slices.Clone(objects), func(o provider.Object) bool { return o.Hidden }) +} + +func TestHiddenFiles(t *testing.T) { + root := gitRepo(t, map[string]string{ + "a/application.yml": "server:\n port: 8080\n", + "b/application.yml": "spring.datasource.password: x\n", + }) + ctx := context.Background() + p := New(Settings{Dir: root}) + srcs, _ := p.Sources(ctx) + if len(srcs) != 1 || srcs[0].Hidden { + t.Fatalf("sources: %+v", srcs) + } + cs, _ := p.Containers(ctx, srcs[0]) + if len(cs) != 2 || !cs[0].Hidden || cs[1].Hidden { + t.Errorf("files: %+v", cs) + } + + // A repo with only hidden files is hidden too. + root = gitRepo(t, map[string]string{"application.yml": "server:\n port: 8080\n"}) + srcs, _ = New(Settings{Dir: root}).Sources(ctx) + if len(srcs) != 1 || !srcs[0].Hidden { + t.Errorf("hidden repo: %+v", srcs) + } +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index a410ea6..c274f0d 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -232,6 +232,7 @@ type Model struct { // provider finds it (provider.Current), before Sources lists them all. current *provider.Source flatSources bool // Sources pane without provider groups + showHidden bool // H: what providers hide is listed too, dimmed sourcesFavs bool // Sources pane on its Favourites tab history bool // count uses of items (usage.go) onActive func() @@ -469,6 +470,7 @@ func New(providers []provider.Provider, opts Options) Model { Filter: func(c provider.Container) string { return c.Name }, FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note }, Render: m.containerRender(false), + Hidden: func(c provider.Container) bool { return c.Hidden }, }) m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{ @@ -478,6 +480,7 @@ func New(providers []provider.Provider, opts Options) Model { // Typing "expir" finds what has expired or soon will. FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) }, Render: m.objectRender(), + Hidden: func(o provider.Object) bool { return o.Hidden }, }) m.secrets.Empty = "no secrets" m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret @@ -979,6 +982,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) { m.cycleZoom(-1) case key.Matches(msg, k.SourcesView): m.toggleSourcesView() + case key.Matches(msg, k.ToggleHidden): + m.toggleHidden() case key.Matches(msg, k.Versions): if m.versionsOf != nil { m.closeVersions() diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index 48ee44a..a7a6010 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1344,3 +1344,34 @@ func TestErrorLog(t *testing.T) { t.Errorf("log %+v", d.m.errLog) } } + +// hiding marks the secret db-password hidden, as the repo provider does +// a Spring key that doesn't look secret. +type hiding struct{ provider.Provider } + +func (h hiding) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) { + objs, err := h.Provider.Objects(ctx, c, t) + for i := range objs { + objs[i].Hidden = objs[i].Name == "db-password" + } + return objs, err +} + +func TestToggleHidden(t *testing.T) { + t.Parallel() + d := newDriver(t, hiding{fake.Azure(0)}) + if _, _, s := d.selected(); s == "db-password" || s == "" { + t.Fatalf("selected %q: the hidden one, or none", s) + } + if strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "1 hidden") { + t.Fatalf("hidden one listed:\n%s", d.screen()) + } + d.keys("H") + if !strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "showing hidden") { + t.Fatalf("H doesn't show it:\n%s", d.screen()) + } + d.keys("H") + if strings.Contains(d.screen(), "db-password") { + t.Fatalf("H again doesn't hide it:\n%s", d.screen()) + } +} diff --git a/internal/ui/app/favourites.go b/internal/ui/app/favourites.go index ce2bab2..980bf5e 100644 --- a/internal/ui/app/favourites.go +++ b/internal/ui/app/favourites.go @@ -6,6 +6,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/expiry" @@ -116,7 +117,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con } line += sty.Muted.Render(" " + c.Detail) } - line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line + line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line) if withSource { src := c.Source.Name if account := accountOf(c.Source); account != "" { @@ -140,6 +141,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string { if o.Detail != "" { line += sty.Muted.Render(" " + o.Detail) } + line = dimIf(sty, o.Hidden, line) switch state, left := m.expiry(o); state { case expiry.Expired: line += " " + sty.Error.Render("✗ expired") @@ -150,6 +152,15 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string { } } +// dimIf draws a hidden item's row (shown with H) in the muted colour, +// whole, so it stands apart from what's listed by the provider's rules. +func dimIf(sty styles.Styles, hidden bool, line string) string { + if !hidden { + return line + } + return sty.Muted.Render(ansi.Strip(line)) +} + // starredFirst moves starred items to the front, keeping the order // otherwise. It doesn't modify items, which may be cached. func starredFirst[T any](items []T, starred func(T) bool) []T { diff --git a/internal/ui/app/search.go b/internal/ui/app/search.go index 6e6fb50..977948d 100644 --- a/internal/ui/app/search.go +++ b/internal/ui/app/search.go @@ -8,6 +8,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/provider" @@ -66,28 +67,41 @@ func (m *Model) searchEntries() (sources, items []option) { mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) return &es[len(es)-1].opt } + // What providers hide is left out, unless H shows it, dimmed. + skip := func(hidden bool) bool { return hidden && !m.showHidden } + dim := func(hidden bool, e *option) { + if hidden { + e.label = m.st.Muted.Render(ansi.Strip(e.label)) + } + } for _, p := range m.providers { for _, src := range m.sourceResults[p.ID()].sources { + if skip(src.Hidden) { + continue + } src.Provider, src.ProviderLabel = p.ID(), p.Label() row := sourceRowKey(p.ID(), src.ID) - add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd { + dim(src.Hidden, add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd { m.nav = &nav{sourceRow: row, sourceOnly: true} return nil - }, func(m *Model) tea.Cmd { return m.loadSource(src) }) + }, func(m *Model) tea.Cmd { return m.loadSource(src) })) for _, c := range m.cache.containers[row].value { + if skip(c.Hidden) { + continue + } r, err := ref.ForContainer(c, provider.TypeSecret) if err != nil { continue } reload := func(m *Model) tea.Cmd { return m.loadContainer(c) } - add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd { + dim(c.Hidden, add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd { m.startNav(r, actGo, row) return nil - }, reload) + }, reload)) for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value { r, err := ref.ForObject(o, "") - if err != nil { + if err != nil || skip(o.Hidden) { continue } suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·") @@ -100,6 +114,7 @@ func (m *Model) searchEntries() (sources, items []option) { return nil }, reload) e.plain = c.Detail == provider.DetailPlainEnv + dim(o.Hidden, e) } } } diff --git a/internal/ui/app/sources.go b/internal/ui/app/sources.go index ccc8269..ffdfdbb 100644 --- a/internal/ui/app/sources.go +++ b/internal/ui/app/sources.go @@ -59,6 +59,7 @@ func sourceRowID(r sourceRow) string { } func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] { + render := renderSourceRow(iconSet, mem, recent) return panes.NewList("[1] Sources", panes.Item[sourceRow]{ ID: sourceRowID, // Headers have no filter text, so filtering shows just sources. @@ -77,40 +78,48 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note }, Render: func(st styles.Styles, r sourceRow, width int) string { - node := mem.Node(sourceKey(r.source)) - kind := st.Kind(r.group) - starred := "" - if r.kind == rowSource { - starred = marks(st, node, r.source.Provider, recent, time.Now()) - } - switch { - case r.kind == rowLinks: - return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status)) - case r.kind == rowSource && r.top: - return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width) - case r.kind == rowSource && r.flat: - line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name - if r.account != "" { - line += st.Muted.Render(" · " + r.account) - } - return withNote(st, line+here(st, r.source), node.Note, width) - case r.kind == rowSource: - // The provider's colour, so long groups stay recognisable. - return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width) - } - arrow := "▾" - if r.collapsed { - arrow = "▸" - } - status := st.Muted.Render(fmt.Sprintf("(%s)", r.status)) - if r.status == "!" { - status = st.Error.Render("(!)") - } - return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status + return dimIf(st, r.kind == rowSource && r.source.Hidden, render(st, r, width)) }, }) } +// renderSourceRow draws a source, a provider's header or the Opened links +// row. +func renderSourceRow(iconSet icons.Set, mem *store.Store, recent bool) func(styles.Styles, sourceRow, int) string { + return func(st styles.Styles, r sourceRow, width int) string { + node := mem.Node(sourceKey(r.source)) + kind := st.Kind(r.group) + starred := "" + if r.kind == rowSource { + starred = marks(st, node, r.source.Provider, recent, time.Now()) + } + switch { + case r.kind == rowLinks: + return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status)) + case r.kind == rowSource && r.top: + return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width) + case r.kind == rowSource && r.flat: + line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name + if r.account != "" { + line += st.Muted.Render(" · " + r.account) + } + return withNote(st, line+here(st, r.source), node.Note, width) + case r.kind == rowSource: + // The provider's colour, so long groups stay recognisable. + return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width) + } + arrow := "▾" + if r.collapsed { + arrow = "▸" + } + status := st.Muted.Render(fmt.Sprintf("(%s)", r.status)) + if r.status == "!" { + status = st.Error.Render("(!)") + } + return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status + } +} + // rebuildSources lays out the Sources pane from the per-provider results. func (m *Model) rebuildSources() { // While filtering, headers drop out, so rows show their provider and @@ -129,17 +138,16 @@ func (m *Model) rebuildSources() { } // The repo lazysecrets started in gets a row of its own on top, so it // opens on it; flat, it sorts first instead. - if src, ok := m.currentSource(); ok && !flat { + if src, ok := m.currentSource(); ok && !flat && (!src.Hidden || m.showHidden) { rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src}) } for _, p := range m.providers { kind, id := p.Kind(), p.ID() account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()}) r, done := m.sourceResults[id] - sources := r.sources - if !flat { - sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here }) - } + sources := slices.DeleteFunc(slices.Clone(r.sources), func(s provider.Source) bool { + return (s.Here && !flat) || (s.Hidden && !m.showHidden) + }) if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) { continue // repos show once some are found to have secret files } @@ -239,6 +247,18 @@ func (m Model) onLinks() bool { return m.sources.SelectedID() == linksRowID } +// toggleHidden lists what providers hide, dimmed, or leaves it out again. +func (m *Model) toggleHidden() { + m.showHidden = !m.showHidden + m.containers.SetShowHidden(m.showHidden) + m.secrets.SetShowHidden(m.showHidden) + m.rebuildSources() + m.flash = flash{text: "hidden items hidden"} + if m.showHidden { + m.flash = flash{text: "showing hidden items, dimmed"} + } +} + // toggleSourcesView switches between grouped and flat, keeping the cursor // on the same source. func (m *Model) toggleSourcesView() { diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go index 0ec340f..03e67f6 100644 --- a/internal/ui/keys/keys.go +++ b/internal/ui/keys/keys.go @@ -45,6 +45,7 @@ type Map struct { Recent key.Binding Note, Share, ShareMenu, OpenRef key.Binding Search, SourcesView, Commands key.Binding + ToggleHidden key.Binding SplitLeft, SplitRight, SplitReset key.Binding Taller, Shorter, ZoomIn, ZoomOut key.Binding Login, Refresh, Help, Quit key.Binding @@ -102,6 +103,7 @@ func (m *Map) Actions() []Action { a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."), a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), + a(Lists, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: Spring config keys that don't look like secrets, and files and repos with only those. Shown, they're dimmed; Go to finds them too."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), @@ -185,7 +187,7 @@ var defaults = map[string][]string{ "lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"}, "lists.open": {"enter"}, "lists.back": {"esc", "backspace"}, - "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, + "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.toggleHidden": {"H"}, "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"}, "lists.commands": {"x"}, "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index 38851a4..89f3a0a 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -335,6 +335,12 @@ func (d Detail) lines(st styles.Styles) []string { } } case ValueShown: + if d.value.Reveal() == "" { + // Not in the value's colour, nor a word a value could be. + field(label, st.Muted.Italic(true).Render("∅ no value (an empty string)")) + out = append(out, valueIndent+hints("hide")) + break + } // Where the dots were, continuing under itself; the keys below. first := true lines, note := valueView(d.value.Reveal()) diff --git a/internal/ui/panes/detail_test.go b/internal/ui/panes/detail_test.go index 1d47f72..e494ba2 100644 --- a/internal/ui/panes/detail_test.go +++ b/internal/ui/panes/detail_test.go @@ -3,6 +3,12 @@ package panes import ( "strings" "testing" + + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" ) func TestWrappedValueKeepsItsStyle(t *testing.T) { @@ -15,3 +21,17 @@ func TestWrappedValueKeepsItsStyle(t *testing.T) { t.Errorf("the style went on past its reset: %q", got[2]) } } + +func TestEmptyValue(t *testing.T) { + d := NewDetail(icons.None) + d.SetSize(80, 20) + d.ShowSecret(provider.Object{Name: "spring.datasource.password", Type: provider.TypeSecret}) + d.SetValue(provider.NewSecretValue(nil)) + if view := ansi.Strip(d.View(styles.Default(), true, "")); !strings.Contains(view, "∅ no value (an empty string)") { + t.Errorf("empty value:\n%s", view) + } + d.SetValue(provider.NewSecretValue([]byte("empty"))) + if view := ansi.Strip(d.View(styles.Default(), true, "")); strings.Contains(view, "∅") || !strings.Contains(view, "empty") { + t.Errorf("the word empty:\n%s", view) + } +} diff --git a/internal/ui/panes/list.go b/internal/ui/panes/list.go index d346c01..e509517 100644 --- a/internal/ui/panes/list.go +++ b/internal/ui/panes/list.go @@ -22,6 +22,9 @@ type Item[T any] struct { // text the row doesn't show (e.g. the provider of a grouped source). FilterExtra func(T) string Render func(st styles.Styles, v T, width int) string + // Hidden, if set, says which items are listed only while the list + // shows hidden ones (SetShowHidden). + Hidden func(T) bool } // List is a scrollable, filterable list pane with loading and error states. @@ -53,6 +56,9 @@ type List[T any] struct { input textinput.Model filtering bool + + showHidden bool + hidden int // items left out for being hidden } func NewList[T any](title string, item Item[T]) List[T] { @@ -77,6 +83,12 @@ func (l *List[T]) SetItems(items []T) { l.applyFilter(prev) } +// SetShowHidden lists hidden items too, or leaves them out again. +func (l *List[T]) SetShowHidden(on bool) { + l.showHidden = on + l.applyFilter(l.SelectedID()) +} + // Reset clears items, error, filter and cursor. func (l *List[T]) Reset() { l.items, l.visible = nil, nil @@ -237,21 +249,28 @@ func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd { // applyFilter recomputes the visible items and puts the cursor back on the // item with ID keep if it is still visible. func (l *List[T]) applyFilter(keep string) { - l.visible, l.hits = l.visible[:0], nil - if q := l.input.Value(); q == "" { - for i := range l.items { - l.visible = append(l.visible, i) + l.visible, l.hits, l.hidden = l.visible[:0], nil, 0 + shown := make([]int, 0, len(l.items)) + for i, v := range l.items { + if !l.showHidden && l.item.Hidden != nil && l.item.Hidden(v) { + l.hidden++ + continue } + shown = append(shown, i) + } + if q := l.input.Value(); q == "" { + l.visible = append(l.visible, shown...) } else { // The row's own text is the name; FilterExtra (a note, a provider) // is context, matched too but ranked lower. - items := make([]match.Item, len(l.items)) - for i, v := range l.items { + items := make([]match.Item, len(shown)) + for j, i := range shown { + v := l.items[i] text := l.item.Filter(v) - items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)} + items[j] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)} if l.item.FilterExtra != nil { if extra := l.item.FilterExtra(v); extra != "" { - items[i].Text += " " + extra + items[j].Text += " " + extra } } } @@ -262,7 +281,7 @@ func (l *List[T]) applyFilter(keep string) { hits = append(hits, p) } } - l.visible = append(l.visible, h.Index) + l.visible = append(l.visible, shown[h.Index]) l.hits = append(l.hits, hits) } } @@ -353,8 +372,10 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string { for _, line := range wrapLines(l.Err.Error(), inner-1) { body = append(body, st.Error.Render(" "+line)) } - case len(l.visible) == 0 && len(l.items) > 0: + case len(l.visible) == 0 && len(l.items) > l.hidden: body = append(body, st.Muted.Render(" no matches")) + case len(l.visible) == 0 && l.hidden > 0: + body = append(body, st.Muted.Render(fmt.Sprintf(" %s (%d hidden)", l.Empty, l.hidden))) case len(l.visible) == 0: body = append(body, st.Muted.Render(" "+l.Empty)) default: @@ -376,6 +397,9 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string { if len(l.visible) > 0 { counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible)) } + if l.hidden > 0 { + counter = strings.Trim(counter+fmt.Sprintf(" · %d hidden", l.hidden), " ·") + } if l.Loading && l.Age != "" && len(l.items) > 0 { counter = "cached " + l.Age + " · " + counter } diff --git a/internal/ui/panes/list_test.go b/internal/ui/panes/list_test.go index aa8aafc..5b7b6f8 100644 --- a/internal/ui/panes/list_test.go +++ b/internal/ui/panes/list_test.go @@ -201,3 +201,41 @@ func TestListHeader(t *testing.T) { t.Errorf("no room, the text:\n%s", v) } } + +func TestListHidden(t *testing.T) { + l := NewList("Items", Item[string]{ + ID: func(s string) string { return s }, + Filter: func(s string) string { return s }, + Render: func(_ styles.Styles, s string, _ int) string { return s }, + Hidden: func(s string) bool { return strings.HasPrefix(s, "hidden") }, + }) + l.SetSize(40, 7) + l.SetItems([]string{"shown-a", "hidden-b", "shown-c"}) + view := ansi.Strip(l.View(styles.Default(), true, "")) + if l.Len() != 2 || strings.Contains(view, "hidden-b") || !strings.Contains(view, "1 hidden") { + t.Fatalf("hidden listed:\n%s", view) + } + l.Move(1) + l.SetShowHidden(true) + if l.Len() != 3 || l.SelectedID() != "shown-c" { + t.Fatalf("shown: %d, selected %q", l.Len(), l.SelectedID()) + } + l.StartFilter() + l.input.SetValue("hid") + l.applyFilter("") + if l.Len() != 1 || l.SelectedID() != "hidden-b" { + t.Fatalf("filtered with hidden shown: %d %q", l.Len(), l.SelectedID()) + } + + l = NewList("Items", Item[string]{ + ID: func(s string) string { return s }, Filter: func(s string) string { return s }, + Render: func(_ styles.Styles, s string, _ int) string { return s }, + Hidden: func(string) bool { return true }, + }) + l.Empty = "no secrets" + l.SetSize(40, 7) + l.SetItems([]string{"a", "b"}) + if view := ansi.Strip(l.View(styles.Default(), true, "")); !strings.Contains(view, "no secrets (2 hidden)") { + t.Fatalf("all hidden:\n%s", view) + } +}