Compare commits

1 Commits
Author SHA1 Message Date
keb 19299631b8 refactor: drop dead code and use the standard library where it already does the job
CI / Checks (pull_request) Successful in 37s
PR title / Conventional title (pull_request) Successful in 1s
No behaviour change.

- Deleted, unused or one-line wrappers: panes.RuneIndexes, Detail.Refresh (SetDescribed does it), SecretValue.Len, Query.Empty, Clipboard.Native, config.Load, get's value, Model.done, providerOf (cachefile.ProviderOf), contextWithTimeout, searchCoverage, startCrawl, showKeybindings, the expiry aliases and daysLeft.
- cmp.Or instead of the two cmpOr copies; slices.Sorted(maps.Keys(…)), maps.Copy, wg.Go, utf8.RuneCountInString and new(true) instead of loops and helpers that do the same.
- A container's pane title is set once, not twice; a redundant length check in share links and a no-op assignment in linkhandler.Launch are gone.
- Tests call ansi.Strip and lastLines directly; gofmt -s on links.go.
2026-10-15 08:54:42 +02:00
43 changed files with 161 additions and 304 deletions

No files matched your search

+4 -10
View File
@@ -18,7 +18,7 @@ import (
// It ends with a newline only when printing to a terminal, so command // It ends with a newline only when printing to a terminal, so command
// substitution and pipes get the value exactly. // substitution and pipes get the value exactly.
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error { func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
v, err := value(ctx, providers, st, r) v, _, err := readKey(ctx, providers, st, r)
if err != nil { if err != nil {
return err return err
} }
@@ -30,15 +30,9 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err return err
} }
// value reads what r points at (ref.Read), with the account that last // readKey reads what r points at (ref.Read), and the version read. The
// read it first. // account asked for (--account) goes first, else the one that last read
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) { // it.
v, _, err := readKey(ctx, providers, st, r)
return v, err
}
// 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) { func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
key := r key := r
key.Version = "" key.Version = ""
+2 -2
View File
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
v, err := value(context.Background(), providers, st, r) v, _, err := readKey(context.Background(), providers, st, r)
if err != nil || v.Reveal() != want { if err != nil || v.Reveal() != want {
t.Errorf("%s: %q, %v", link, v.Reveal(), err) t.Errorf("%s: %q, %v", link, v.Reveal(), err)
} }
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
"{{secrets/app/storage-key}}": "names no workspace", "{{secrets/app/storage-key}}": "names no workspace",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) { if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Errorf("%s: %v, want %q", link, err, wantErr) t.Errorf("%s: %v, want %q", link, err, wantErr)
} }
} }
+2 -9
View File
@@ -68,27 +68,20 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
if c.Key == "" { if c.Key == "" {
continue continue
} }
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command)) name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil { if err := km.CheckExtra(c.Key, name); err != nil {
return err return err
} }
for _, d := range cmds[:i] { for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap { if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command)) return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
} }
} }
} }
return nil return nil
} }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// refArg parses get's and open's arguments: a reference, and optionally // refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column) and, // the account to use when several could (lazysecrets list's column) and,
// for open, what to do with it rather than asking (--action). // for open, what to do with it rather than asking (--action).
-3
View File
@@ -42,9 +42,6 @@ func New() Clipboard {
} }
} }
// Native reports whether the system clipboard is used rather than OSC52.
func (c Clipboard) Native() bool { return c.native }
// Write puts s on the clipboard. // Write puts s on the clipboard.
func (c Clipboard) Write(s string) tea.Cmd { func (c Clipboard) Write(s string) tea.Cmd {
if !c.native { if !c.native {
+2 -4
View File
@@ -16,6 +16,7 @@
package commands package commands
import ( import (
"cmp"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@@ -60,10 +61,7 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config. // Validate checks a command from the config.
func (c Command) Validate() error { func (c Command) Validate() error {
name := c.Description name := cmp.Or(c.Description, c.Command)
if name == "" {
name = c.Command
}
switch { switch {
case c.Command == "": case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description) return fmt.Errorf("custom command %q has no command", c.Description)
+4 -10
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -203,17 +204,12 @@ func xdgDir(env, fallback string) (string, error) {
return filepath.Join(base, "lazysecrets"), nil return filepath.Join(base, "lazysecrets"), nil
} }
// Load reads path. A missing file is an empty config. Unknown keys are an
// error, so typos don't silently do nothing.
func Load(path string) (Config, error) {
return LoadAll([]string{path})
}
// LoadAll reads several config files in order, each on top of the ones // LoadAll reads several config files in order, each on top of the ones
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the // before, like lazygit's LG_CONFIG_FILE: a later file overrides the
// settings it sets, and theme roles merge one by one. That lets an editor // settings it sets, and theme roles merge one by one. That lets an editor
// plugin add a generated theme file to the user's own config. Missing // plugin add a generated theme file to the user's own config. Missing
// files are skipped. // files are skipped. Unknown keys are an error, so typos don't silently do
// nothing.
func LoadAll(paths []string) (Config, error) { func LoadAll(paths []string) (Config, error) {
var c Config var c Config
keybinding := map[string]map[string][]string{} keybinding := map[string]map[string][]string{}
@@ -228,9 +224,7 @@ func LoadAll(paths []string) (Config, error) {
if keybinding[ctx] == nil { if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{} keybinding[ctx] = map[string][]string{}
} }
for name, keys := range actions { maps.Copy(keybinding[ctx], actions)
keybinding[ctx][name] = keys
}
} }
} }
c.Keybinding = keybinding c.Keybinding = keybinding
+7 -7
View File
@@ -18,7 +18,7 @@ func write(t *testing.T, content string) string {
} }
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
c, err := Load(write(t, ` c, err := LoadAll([]string{write(t, `
ui: ui:
icons: false icons: false
sourcesView: flat sourcesView: flat
@@ -36,7 +36,7 @@ providers:
databricks: databricks:
enabled: false enabled: false
hideProfiles: [test] hideProfiles: [test]
`)) `)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"missing": filepath.Join(t.TempDir(), "nope.yaml"), "missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""), "empty": write(t, ""),
} { } {
c, err := Load(path) c, err := LoadAll([]string{path})
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil { if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err) t.Errorf("%s: %+v, %v", name, c, err)
} }
@@ -85,7 +85,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
"short keys": "sharing:\n keyRules:\n minLength: 8\n", "short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n", "classes": "sharing:\n keyRules:\n minClasses: 5\n",
} { } {
if _, err := Load(write(t, content)); err == nil { if _, err := LoadAll([]string{write(t, content)}); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") { } else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err) t.Errorf("%s: error doesn't name the file: %v", name, err)
@@ -132,18 +132,18 @@ theme:
} }
func TestUpdates(t *testing.T) { func TestUpdates(t *testing.T) {
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")) c, err := LoadAll([]string{write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")})
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 { if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
t.Errorf("updates = %+v, %v", c.Updates, err) t.Errorf("updates = %+v, %v", c.Updates, err)
} }
} }
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n")) c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond { if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err) t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
} }
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil { if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
t.Error("a minute's delay accepted") t.Error("a minute's delay accepted")
} }
} }
+1 -2
View File
@@ -9,7 +9,6 @@
package instance package instance
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -114,7 +113,7 @@ func (s *Server) handle(c net.Conn) {
defer c.Close() defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second)) _ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request var req request
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil { if err := json.NewDecoder(c).Decode(&req); err != nil {
return return
} }
var resp response var resp response
+2 -6
View File
@@ -116,17 +116,13 @@ func Check() string {
if err != nil { if err != nil {
return "" // installed before the key was recorded: assume it works return "" // installed before the key was recorded: assume it works
} }
if exe := strings.TrimSpace(string(out)); !exists(exe) { exe := strings.TrimSpace(string(out))
if _, err := os.Stat(exe); err != nil {
return "lazysecrets:// links run " + exe + ", which no longer exists" return "lazysecrets:// links run " + exe + ", which no longer exists"
} }
return "" return ""
} }
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration. // Uninstall removes the applet and its registration.
func Uninstall() (string, error) { func Uninstall() (string, error) {
app, err := AppPath() app, err := AppPath()
-1
View File
@@ -144,7 +144,6 @@ func appleString(s string) string {
// it. // it.
func Launch(argv []string) error { func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...) cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return err return err
} }
-3
View File
@@ -107,9 +107,6 @@ func parseTerm(tok string) (term, bool) {
return t, true return t, true
} }
// Empty reports whether the query has no terms: everything matches.
func (q Query) Empty() bool { return len(q.groups) == 0 }
// Item is a row to match: Text is everything searched, and runes // Item is a row to match: Text is everything searched, and runes
// NameStart up to NameEnd of it are the row's name (the rest is context, // NameStart up to NameEnd of it are the row's name (the rest is context,
// like a path or a note). Without a name, nothing gets the name bonus. // like a path or a note). Without a name, nothing gets the name bonus.
-6
View File
@@ -99,9 +99,3 @@ func TestAnchorsOnTheName(t *testing.T) {
t.Errorf("password$: %v", got) t.Errorf("password$: %v", got)
} }
} }
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+2 -6
View File
@@ -2,6 +2,7 @@ package azure
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"strings" "strings"
@@ -134,12 +135,7 @@ func (v vaultInfo) attrs() []provider.Attr {
if v.endpoints > 0 { if v.endpoints > 0 {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1])) add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
} }
keys := make([]string, 0, len(v.tags)) for i, k := range slices.Sorted(maps.Keys(v.tags)) {
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := "" label := ""
if i == 0 { if i == 0 {
label = "tags" label = "tags"
+25 -40
View File
@@ -48,19 +48,8 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
func (p *Provider) Kind() provider.Kind { return p.kind } func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string { func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
if p.id != "" { func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
return p.id
}
return string(p.kind)
}
func (p *Provider) Label() string {
if p.label != "" {
return p.label
}
return string(p.kind)
}
// As renames the provider, to simulate several accounts of one kind. // As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider { func (p *Provider) As(id, label string) *Provider {
@@ -252,11 +241,7 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
} }
} }
var ( var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
yes = true
no = false
)
// ErrForbidden mimics a data-plane permission error from a backend. // ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets") var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
@@ -284,32 +269,32 @@ func Azure(delay time.Duration) *Provider {
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...) kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{ dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base, Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain", Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"), Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple") }, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{ p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"}, {Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)}, {Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)}, {Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
} }
p.values[dbPassword.ID+"@91d3e0"] = "previous-password" p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password" p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0), Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"), Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000") }, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"), Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated") }, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0), Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"), Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0), Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"}, {Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"}, {Label: "ops", Value: "sign, verify"},
@@ -317,7 +302,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "keys", "signing-key"), Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0), Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"}, {Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"}, {Label: "ops", Value: "wrapKey, unwrapKey"},
@@ -326,7 +311,7 @@ func Azure(delay time.Duration) *Provider {
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file", Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"}, {Label: "subject", Value: "CN=app.example.com"},
@@ -338,7 +323,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "certificates", "app-tls"), Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert) }, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12", Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"}, {Label: "subject", Value: "CN=old.example.com"},
@@ -351,10 +336,10 @@ func Azure(delay time.Duration) *Provider {
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as sharing.keyRules wants. // A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"), Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=") }, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey) p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
@@ -364,19 +349,19 @@ func Azure(delay time.Duration) *Provider {
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com") dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...) kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"), Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-password") }, "dev-password")
// Left behind, as dev vaults are: expired, disabled, or both. // Left behind, as dev vaults are: expired, disabled, or both.
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"), Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
}, "whsec_demo_expired") }, "whsec_demo_expired")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0), Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0),
Reference: secretRef("kv-app-dev", "test-user-password"), Reference: secretRef("kv-app-dev", "test-user-password"),
}, "disabled-demo-password") }, "disabled-demo-password")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0), Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"), Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled") }, "expired-and-disabled")
@@ -395,17 +380,17 @@ func AzureOps(delay time.Duration) *Provider {
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...) breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{ p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0), Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0),
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs 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"), Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password") }, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
// Unlike the first account, ops may read keys here. // Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{ p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0), Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}}, Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption", Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey) }, demoPublicKey)
@@ -413,12 +398,12 @@ func AzureOps(delay time.Duration) *Provider {
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com") sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...) kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{ 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"), Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token") }, "pd-demo-token")
// Expiring soon whenever the demo runs: the other dates are fixed, // Expiring soon whenever the demo runs: the other dates are fixed,
// and would have passed. Tests use the first account only. // and would have passed. Tests use the first account only.
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0), Name: "grafana-api-key", Enabled: new(true), 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"), Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
}, "glsa_demo_expiring") }, "glsa_demo_expiring")
+2 -4
View File
@@ -206,13 +206,11 @@ func parallel(n int, f func(i int)) {
var wg sync.WaitGroup var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU())) sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n { for i := range n {
wg.Add(1) wg.Go(func() {
go func() {
defer wg.Done()
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
f(i) f(i)
}() })
} }
wg.Wait() wg.Wait()
} }
-2
View File
@@ -21,8 +21,6 @@ func NewSecretValue(b []byte) SecretValue {
// Reveal returns the plaintext. Call it only to display or copy the value. // Reveal returns the plaintext. Call it only to display or copy the value.
func (v SecretValue) Reveal() string { return string(v.b) } func (v SecretValue) Reveal() string { return string(v.b) }
func (v SecretValue) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked } func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked } func (v SecretValue) GoString() string { return masked }
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8"
) )
// Rules are what a key's value must be to share with it: random enough // Rules are what a key's value must be to share with it: random enough
@@ -40,7 +41,7 @@ func (r Rules) Check(key []byte) error {
kinds++ kinds++
} }
} }
n := len([]rune(s)) n := utf8.RuneCountInString(s)
var has, needs []string var has, needs []string
if n < r.MinLength { if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n)) has = append(has, fmt.Sprintf("%d characters", n))
+1 -1
View File
@@ -174,7 +174,7 @@ func Read(r ref.Ref) (Sealed, error) {
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)") return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
} }
n, size := binary.Uvarint(b[1:]) n, size := binary.Uvarint(b[1:])
if size <= 0 || n > uint64(len(b)) { if size <= 0 {
return Sealed{}, bad return Sealed{}, bad
} }
rest := b[1+size:] rest := b[1+size:]
+10 -15
View File
@@ -3,6 +3,7 @@
package app package app
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -654,7 +655,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case detailsMsg: case detailsMsg:
if msg.req == m.reqID[slotDetails] { if msg.req == m.reqID[slotDetails] {
m.done(slotDetails) m.cancelReq(slotDetails)
} }
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) { if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again delete(m.details, msg.key) // tried again when shown again
@@ -685,7 +686,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotContainers] { if msg.req != m.reqID[slotContainers] {
break break
} }
m.done(slotContainers) m.cancelReq(slotContainers)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok { if src, ok := m.selectedSource(); ok {
@@ -703,7 +704,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotObjects] { if msg.req != m.reqID[slotObjects] {
break break
} }
m.done(slotObjects) m.cancelReq(slotObjects)
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil { if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind)) m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
m.closeVersions() m.closeVersions()
@@ -733,7 +734,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotValue] { if msg.req != m.reqID[slotValue] {
break break
} }
m.done(slotValue) m.cancelReq(slotValue)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -748,7 +749,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotVersions] { if msg.req != m.reqID[slotVersions] {
break break
} }
m.done(slotVersions) m.cancelReq(slotVersions)
// Errors and unsupported providers just leave the count out. // Errors and unsupported providers just leave the count out.
if msg.err == nil { if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
@@ -760,7 +761,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotDescribe] { if msg.req != m.reqID[slotDescribe] {
break break
} }
m.done(slotDescribe) m.cancelReq(slotDescribe)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -775,7 +776,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotCopy] { if msg.req != m.reqID[slotCopy] {
break break
} }
m.done(slotCopy) m.cancelReq(slotCopy)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true} m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
@@ -887,7 +888,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Quit): case key.Matches(msg, k.Quit):
return nil, true return nil, true
case key.Matches(msg, k.Help): case key.Matches(msg, k.Help):
return m.showKeybindings(), false return m.showKeybindingsFor(""), false
case key.Matches(msg, k.Up): case key.Matches(msg, k.Up):
m.move(-1) m.move(-1)
case key.Matches(msg, k.Down): case key.Matches(msg, k.Down):
@@ -1167,14 +1168,11 @@ func (m *Model) syncSelection() tea.Cmd {
if m.onLinks() { if m.onLinks() {
m.showLinkedContainers() m.showLinkedContainers()
} else if src, ok := m.selectedSource(); ok { } else if src, ok := m.selectedSource(); ok {
m.containers.Title = "[2] Vaults"
m.containers.Empty = "no vaults found" m.containers.Empty = "no vaults found"
switch src.Kind { switch src.Kind {
case provider.KindDatabricks: case provider.KindDatabricks:
m.containers.Title = "[2] Scopes"
m.containers.Empty = "no secret scopes" m.containers.Empty = "no secret scopes"
case provider.KindRepo: case provider.KindRepo:
m.containers.Title = "[2] Files"
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)" m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
} }
cmds = append(cmds, m.showContainers(src, id)) cmds = append(cmds, m.showContainers(src, id))
@@ -1517,9 +1515,6 @@ func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
} }
} }
// done releases the context of a completed request.
func (m *Model) done(p slot) { m.cancelReq(p) }
// loadSources starts a new generation of source loads, superseding any in // loadSources starts a new generation of source loads, superseding any in
// flight. // flight.
func (m *Model) loadSources() tea.Cmd { func (m *Model) loadSources() tea.Cmd {
@@ -1639,7 +1634,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd { func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() { if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok { if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false) return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
} }
return nil return nil
} }
+4 -4
View File
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") { if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
t.Errorf("detail hint doesn't follow the binding:\n%s", scr) t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
} }
if !strings.Contains(lastLine(scr), "z show") { if !strings.Contains(lastLines(scr, 1), "z show") {
t.Errorf("help bar: %q", lastLine(scr)) t.Errorf("help bar: %q", lastLines(scr, 1))
} }
d.keys("z") d.keys("z")
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar) t.Errorf("help bar with the value shown: %q", bar)
} }
d.keys("z") d.keys("z")
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
if !strings.Contains(scr, "Alice Example") { if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr) t.Errorf("account not shown:\n%s", scr)
} }
bar := lastLine(scr) bar := lastLines(scr, 1)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar) t.Errorf("help bar: %q", bar)
} }
+1 -1
View File
@@ -66,7 +66,7 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
if i < 0 || sameListing(before[i], s) { if i < 0 || sameListing(before[i], s) {
return nil return nil
} }
m.detail.Refresh(s) m.detail.SetDescribed(s)
uncache(m, m.cache.details, detailKey(s)) uncache(m, m.cache.details, detailKey(s))
return m.describe(s) return m.describe(s)
} }
+8 -21
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -84,10 +85,7 @@ func (m *Model) showCommands() tea.Cmd {
} }
var opts []option var opts []option
for _, c := range cmds { for _, c := range cmds {
label := c.Description label := cmp.Or(c.Description, c.Command)
if label == "" {
label = c.Command
}
if c.Key != "" { if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
} }
@@ -122,7 +120,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
in.SetValue(p.Default) in.SetValue(p.Default)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
title: cmpOr(c.Description, "Command"), input: &in, title: cmp.Or(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd { submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text) it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1) return m.askPrompt(c, it, obj, i+1)
@@ -151,7 +149,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
case strings.HasPrefix(c.Value, "env:"): case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".") lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
} }
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{ m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }}, {key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
}) })
return nil return nil
@@ -177,7 +175,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
// execCommand runs the command as its output mode says. // execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd { func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh") shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) { setValue := func(cmd *exec.Cmd) {
switch { switch {
case c.Value == "stdin": case c.Value == "stdin":
@@ -195,7 +193,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
m.flash = flash{} m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} }) return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
} }
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"} m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel() defer cancel()
@@ -219,7 +217,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
const maxOutputLines = 30 const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) { func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmpOr(msg.cmd.Description, "command") name := cmp.Or(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil { if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch { switch {
case msg.err != nil: case msg.err != nil:
@@ -298,11 +296,7 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
if r, err := ref.ForObject(o, version); err == nil { if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String()) it.Link = commands.Q(r.String())
} }
if version != "" { o.Version = version // "": the current value, like copying it
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true return it, &o, true
} }
@@ -312,10 +306,3 @@ func newTextInput(prompt string, width int) textinput.Model {
in.SetWidth(width) in.SetWidth(width)
return in return in
} }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"github.com/charmbracelet/x/ansi"
) )
func commandDriver(t *testing.T, cmds ...commands.Command) *driver { func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
@@ -34,7 +35,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
// temporary path's length. // temporary path's length.
shown := "" shown := ""
if d.m.prompt != nil { if d.m.prompt != nil {
shown = ansiStrip(strings.Join(d.m.prompt.lines, "")) shown = ansi.Strip(strings.Join(d.m.prompt.lines, ""))
} }
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") { if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr) t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
+1 -1
View File
@@ -242,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
return tea.QuitMsg{} return tea.QuitMsg{}
} }
} }
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" { if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w"). // Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...) args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{} m.flash = flash{}
+5 -4
View File
@@ -6,6 +6,7 @@ import (
"slices" "slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -30,7 +31,7 @@ func (m Model) expiringItems() []expiringItem {
for _, c := range m.cache.containers[row].value { for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != notExpiring { if state, _ := m.expiry(o); state != expiry.None {
out = append(out, expiringItem{o, row}) out = append(out, expiringItem{o, row})
} }
} }
@@ -51,11 +52,11 @@ func (m Model) expiringFields() []panes.Field {
var out []panes.Field var out []panes.Field
for _, it := range items { for _, it := range items {
state, left := m.expiry(it.o) state, left := m.expiry(it.o)
mark := "⚠ " + daysLeft(left) mark := "⚠ " + expiry.Left(left)
if state == expired { if state == expiry.Expired {
mark = "✗ expired" mark = "✗ expired"
} }
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row} f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil { if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String() f.Link = r.String()
} }
+3 -15
View File
@@ -4,7 +4,6 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry" "git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -12,30 +11,19 @@ import (
// list once they have expired (✗ expired) or will within the configured // list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them. // warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState = expiry.State
const (
notExpiring = expiry.None
expiresSoon = expiry.Soon
expired = expiry.Expired
)
// expiry tells whether o has expired or will within the warning period, // expiry tells whether o has expired or will within the warning period,
// and how long it has left. // and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) { func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning) return expiry.Of(o.Expires, m.now(), m.expiryWarning)
} }
// expiryWord is matched by the filter: "expired" or "expiring". // expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string { func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state { switch state, _ := m.expiry(o); state {
case expired: case expiry.Expired:
return "expired" return "expired"
case expiresSoon: case expiry.Soon:
return "expiring" return "expiring"
} }
return "" return ""
} }
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { return expiry.Left(d) }
+4 -3
View File
@@ -8,6 +8,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -134,10 +135,10 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
} }
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
switch state, left := m.expiry(o); state { switch state, left := m.expiry(o); state {
case expired: case expiry.Expired:
line += " " + sty.Error.Render("✗ expired") line += " " + sty.Error.Render("✗ expired")
case expiresSoon: case expiry.Soon:
line += " " + sty.Warning.Render("⚠ "+daysLeft(left)) line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
} }
return withNote(sty, line, n.Note, width) return withNote(sty, line, n.Note, width)
} }
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
// jump opens the favourites list, filters it and jumps to the first match. // jump opens the favourites list, filters it and jumps to the first match.
@@ -429,7 +430,7 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("F") d.keys("F")
var labels []string var labels []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
labels = append(labels, ansiStrip(o.label)) labels = append(labels, ansi.Strip(o.label))
} }
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") { if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels) t.Errorf("favourites should list alice's star only: %q", labels)
+5 -4
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -61,7 +62,7 @@ func (m *Model) syncFields() tea.Cmd {
case !ok: case !ok:
m.fields.Show("", nil) m.fields.Show("", nil)
case m.selectedProblem() != "": case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem())) m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup: case r.kind == rowGroup:
m.showAccount(r) m.showAccount(r)
case r.kind == rowSource: case r.kind == rowSource:
@@ -134,7 +135,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
add("web", src.URL) add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok { if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind] label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value))) add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
} }
return out return out
} }
@@ -158,7 +159,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
case provider.KindRepo: case provider.KindRepo:
add("format", c.Detail) add("format", c.Detail)
} }
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL) add("web", c.URL)
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok { if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
@@ -221,5 +222,5 @@ func (m Model) problemFields(problem string) []panes.Field {
// fieldName names a copied field: its label, or a row of several under // fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value. // one label (a tag), its value.
func fieldName(f panes.Field) string { func fieldName(f panes.Field) string {
return cmpOr(f.Label, f.Value) return cmp.Or(f.Label, f.Value)
} }
+3 -7
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
@@ -16,7 +17,7 @@ import (
) )
// The help bar shows the keys that matter in the focused pane; ? lists all // The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindings). // of them in a menu (showKeybindingsFor).
// newHelp styles the help bar to read clearly: keys like labels elsewhere, // newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The // descriptions in the normal text colour, only the separators dimmed. The
@@ -84,11 +85,6 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"} var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
// and group. Enter runs the selected one.
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with // showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the // what it does, filterable by key, description and group; enter runs the
// selected one. Opened from the panes, it lists the focused pane's keys // selected one. Opened from the panes, it lists the focused pane's keys
@@ -154,7 +150,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 { if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true}) opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds { for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom") label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom")
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }}) opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
} }
} }
+3 -8
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) { func TestContextHelpBar(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
t.Errorf("Sources help bar: %q", bar) t.Errorf("Sources help bar: %q", bar)
} }
d.keys("3") d.keys("3")
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar) t.Errorf("Secrets help bar: %q", bar)
} }
} }
@@ -225,11 +225,6 @@ func TestKeyPress(t *testing.T) {
} }
} }
func lastLine(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
return lines[len(lines)-1]
}
func TestNoHighlightWhileTerminalBlurred(t *testing.T) { func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
@@ -264,7 +259,7 @@ func TestVersionInStatus(t *testing.T) {
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") { if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen()) t.Errorf("no version in the status pane:\n%s", d.screen())
} }
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") { if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar) t.Errorf("help line %q", bar)
} }
} }
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -433,7 +434,7 @@ func (m *Model) handleResolved(msg resolvedMsg) {
// (ref.Ref.Account), if it's one of them. // (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) { func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices { for _, c := range choices {
if r.Account != "" && providerOf(c.sourceRow) == r.Account { if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account {
return c.sourceRow, true return c.sourceRow, true
} }
} }
@@ -686,7 +687,7 @@ func (m *Model) share(menu bool) tea.Cmd {
share := func(k string) func(*Model) tea.Cmd { share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) } return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
} }
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}} opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret { if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")}) opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
} }
@@ -714,7 +715,7 @@ func (m *Model) share(menu bool) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) } return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
} }
opts := []option{ opts := []option{
option{key: "o", label: "Lazysecrets link", run: share("o")}, {key: "o", label: "Lazysecrets link", run: share("o")},
} }
if o.Container.Source.Kind == provider.KindAzure { if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")}) opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
+5 -7
View File
@@ -77,17 +77,15 @@ func TestNotesOnRows(t *testing.T) {
func TestWithNoteGivesWay(t *testing.T) { func TestWithNoteGivesWay(t *testing.T) {
t.Parallel() t.Parallel()
st := styles.Default() st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 || if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 ||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") { !strings.HasSuffix(ansi.Strip(got), "prod SQL admin") {
t.Errorf("fits: %q", stripANSI(got)) t.Errorf("fits: %q", ansi.Strip(got))
} }
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") || if got := ansi.Strip(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
!strings.HasSuffix(got, "…") { !strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", got) t.Errorf("tight: %q", got)
} }
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" { if got := ansi.Strip(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
t.Errorf("no room: %q", got) t.Errorf("no room: %q", got)
} }
} }
func stripANSI(s string) string { return ansi.Strip(s) }
+3 -2
View File
@@ -16,6 +16,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
const sopsFile = `db: const sopsFile = `db:
@@ -225,9 +226,9 @@ func TestSearchAllRepos(t *testing.T) {
var got []string var got []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo { if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansiStrip(o.label)) t.Errorf("not a repo: %s", ansi.Strip(o.label))
} }
got = append(got, ansiStrip(o.label)) got = append(got, ansi.Strip(o.label))
} }
if len(got) != 2 { if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got) t.Errorf("want db.password in both repos: %v", got)
+7 -23
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -46,10 +45,6 @@ type crawlMsg struct {
err error err error
} }
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of // searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with // each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group. // its source's row key as group.
@@ -67,7 +62,7 @@ func (m *Model) searchEntries() (sources, items []option) {
sort := strings.ToLower(strings.Join(path, "/")) + " " + account sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort, es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key, option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)), starAccount: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt return &es[len(es)-1].opt
} }
@@ -134,8 +129,6 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
} }
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// providerFilter picks the providers a search or a load covers; nil is // providerFilter picks the providers a search or a load covers; nil is
// all of them. // all of them.
type providerFilter func(provider.Provider) bool type providerFilter func(provider.Provider) bool
@@ -148,7 +141,7 @@ func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id } return func(p provider.Provider) bool { return p.ID() == id }
} }
// coverageOf is searchCoverage for the providers in. // coverageOf is the coverage of the providers in.
func (m *Model) coverageOf(in providerFilter) coverage { func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage var c coverage
for _, p := range m.providers { for _, p := range m.providers {
@@ -183,7 +176,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
// searchLines says what the search covers. // searchLines says what the search covers.
func (m *Model) searchLines() []string { func (m *Model) searchLines() []string {
c := m.searchCoverage() c := m.coverageOf(nil)
switch { switch {
case m.crawl.running: case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.", return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
@@ -210,11 +203,8 @@ func plural(n int, word string) string {
return fmt.Sprintf("%d %ss", n, word) return fmt.Sprintf("%d %ss", n, word)
} }
// startCrawl loads every unlisted subscription's vaults and every vault's // startCrawlOf loads, for the providers in, every unlisted subscription's
// secrets not loaded yet. // vaults and every vault's secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd { func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if in != nil && !in(p) { if in != nil && !in(p) {
@@ -301,7 +291,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
src := *job.src src := *job.src
timeout := m.timeout.of(src.Kind) timeout := m.timeout.of(src.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
items, err := p.Containers(ctx, src) items, err := p.Containers(ctx, src)
for i := range items { for i := range items {
@@ -314,7 +304,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
p := m.providerFor(c.Source) p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind) timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret) items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items { for i := range items {
@@ -396,9 +386,3 @@ func (m *Model) vaultMark(c provider.Container) string {
} }
return " " return " "
} }
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+19 -20
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
@@ -115,19 +116,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Fatal("platform-dev not in sources") t.Fatal("platform-dev not in sources")
} }
} }
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") { if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "○") {
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark) t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
if len(d.m.search.shown) != 0 { if len(d.m.search.shown) != 0 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown)) t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
} }
d.send(ctrl('r')) d.send(ctrl('r'))
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") { if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "●") {
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark) t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if !strings.Contains(ansiStrip(o.label), "platform-dev") { if !strings.Contains(ansi.Strip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansiStrip(o.label)) t.Errorf("result from elsewhere: %q", ansi.Strip(o.label))
} }
} }
if len(d.m.search.shown) == 0 { if len(d.m.search.shown) == 0 {
@@ -177,19 +178,17 @@ func TestStarFromSearch(t *testing.T) {
} }
} }
func ansiStrip(s string) string { return ansi.Strip(s) }
func TestSearchStartsWhereYouAre(t *testing.T) { func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch() d.openSearch()
v := d.m.search v := d.m.search
scope := func() string { return ansiStrip(v.scopes[v.src].label) } scope := func() string { return ansi.Strip(v.scopes[v.src].label) }
if scope() != "All" { if scope() != "All" {
t.Errorf("opened on %q, want All", scope()) t.Errorf("opened on %q, want All", scope())
} }
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") { if v.res >= len(v.shown) || !strings.Contains(ansi.Strip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen()) t.Errorf("vault not under the cursor:\n%s", d.screen())
} }
d.send(ctrl('s')) // narrows to the source, and back d.send(ctrl('s')) // narrows to the source, and back
@@ -233,13 +232,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
t.Errorf("no results in the account:\n%s", d.screen()) t.Errorf("no results in the account:\n%s", d.screen())
} }
for _, o := range v.shown { for _, o := range v.shown {
if providerOf(o.group) != header.provider { if cachefile.ProviderOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label)) t.Errorf("result from another account: %q", ansi.Strip(o.label))
} }
} }
// The account's sources are under it, indented. // The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") { if next := v.scopes[v.src+1]; next.group == "" || cachefile.ProviderOf(next.group) != header.provider || !strings.HasPrefix(ansi.Strip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label)) t.Errorf("after the account: %q", ansi.Strip(next.label))
} }
} }
@@ -327,7 +326,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
t.Fatal("no results") t.Fatal("no results")
} }
o := d.m.search.shown[0] o := d.m.search.shown[0]
plain := []rune(ansiStrip(o.label)) plain := []rune(ansi.Strip(o.label))
var got string var got string
for _, h := range o.hits { for _, h := range o.hits {
got += string(plain[h]) got += string(plain[h])
@@ -348,14 +347,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("F") d.keys("F")
bar := lastLine(d.screen()) bar := lastLines(d.screen(), 1)
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} { for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) { if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want) t.Errorf("browsing: help bar %q lacks %q", bar, want)
} }
} }
d.keys("/") d.keys("/")
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
t.Errorf("typing: help bar %q", bar) t.Errorf("typing: help bar %q", bar)
} }
} }
@@ -375,8 +374,8 @@ func TestHelpFromPopups(t *testing.T) {
if o.header { if o.header {
sections = append(sections, o.label) sections = append(sections, o.label)
} }
if strings.Contains(ansiStrip(o.label), "copy value") { if strings.Contains(ansi.Strip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label)) t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label))
} }
} }
if strings.Join(sections, ",") != "Go to,While typing, after /" { if strings.Join(sections, ",") != "Go to,While typing, after /" {
@@ -428,7 +427,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc") d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLine(d.screen())) bar := strings.TrimSpace(lastLines(d.screen(), 1))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") { if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar) t.Errorf("help bar %q", bar)
} }
@@ -443,11 +442,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
for d.m.search.src > 0 { for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) d.send(tea.KeyPressMsg{Code: tea.KeyUp})
} }
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ { for i := 0; !strings.Contains(ansi.Strip(d.m.search.scope().label), "platform-dev"); i++ {
if i > len(d.m.search.scopes) { if i > len(d.m.search.scopes) {
var labels []string var labels []string
for _, o := range d.m.search.scopes { for _, o := range d.m.search.scopes {
labels = append(labels, ansiStrip(o.label)) labels = append(labels, ansi.Strip(o.label))
} }
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results) t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
} }
+3 -3
View File
@@ -203,7 +203,7 @@ func (m *Model) fillSearch(keep bool) {
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())), id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
}) })
for _, o := range sources { for _, o := range sources {
if providerOf(o.group) == p.ID() { if cachefile.ProviderOf(o.group) == p.ID() {
o.label = " " + o.label o.label = " " + o.label
scopes = append(scopes, o) scopes = append(scopes, o)
} }
@@ -267,7 +267,7 @@ func (v *searchView) match() []option {
default: default:
account, ofAccount := v.scopeAccount() account, ofAccount := v.scopeAccount()
for _, o := range v.items { for _, o := range v.items {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group { if sc.id == allSources || ofAccount && cachefile.ProviderOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o) cand = append(cand, o)
} }
} }
@@ -523,7 +523,7 @@ func (m *Model) searchLoads() tea.Cmd {
return m.startCrawlOf(v.scopeFilter()) return m.startCrawlOf(v.scopeFilter())
} }
if !v.onSource() { if !v.onSource() {
return m.startCrawl() return m.startCrawlOf(nil)
} }
providerID, sourceID := splitSourceRow(sc.group) providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources { for _, src := range m.sourceResults[providerID].sources {
+3 -2
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -83,7 +84,7 @@ func (m Model) statusFields() []panes.Field {
} }
} }
// A release's rows open its release page. // A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")} version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") { if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
} }
@@ -159,7 +160,7 @@ func (m Model) accountStatus(p provider.Provider) string {
return r.err.Error() return r.err.Error()
} }
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()] noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmpOr(noun, "source")) return plural(len(r.sources), cmp.Or(noun, "source"))
} }
// ago is a duration as people say it: "just now", "5m ago", "2h ago". // ago is a duration as people say it: "just now", "5m ago", "2h ago".
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
func TestRecentScope(t *testing.T) { func TestRecentScope(t *testing.T) {
@@ -21,7 +22,7 @@ func TestRecentScope(t *testing.T) {
d.keys("R") d.keys("R")
var recent []string var recent []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
recent = append(recent, ansiStrip(o.label)) recent = append(recent, ansi.Strip(o.label))
} }
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") { if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
t.Fatalf("want newest first: %q", recent) t.Fatalf("want newest first: %q", recent)
@@ -31,7 +32,7 @@ func TestRecentScope(t *testing.T) {
t.Errorf("jumped to %q", s) t.Errorf("jumped to %q", s)
} }
d.keys("F") d.keys("F")
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") { if len(d.m.search.shown) != 1 || !strings.Contains(ansi.Strip(d.m.search.shown[0].label), "kv-shared-prod") {
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen()) t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
} }
} }
+5 -14
View File
@@ -15,8 +15,8 @@ package keys
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"sort"
"strings" "strings"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
@@ -266,11 +266,11 @@ func (m *Map) Apply(cfg map[string]map[string][]string) error {
byName[a.Context+"."+a.Name] = a byName[a.Context+"."+a.Name] = a
contexts[a.Context] = true contexts[a.Context] = true
} }
for _, ctx := range sortedKeys(cfg) { for _, ctx := range slices.Sorted(maps.Keys(cfg)) {
if !contexts[ctx] { if !contexts[ctx] {
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", ")) return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(slices.Sorted(maps.Keys(contexts)), ", "))
} }
for _, name := range sortedKeys(cfg[ctx]) { for _, name := range slices.Sorted(maps.Keys(cfg[ctx])) {
a, ok := byName[ctx+"."+name] a, ok := byName[ctx+"."+name]
if !ok { if !ok {
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", ")) return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
@@ -289,16 +289,7 @@ func (m *Map) names(ctx string) []string {
out = append(out, a.Name) out = append(out, a.Name)
} }
} }
sort.Strings(out) slices.Sort(out)
return out
}
func sortedKeys[V any](set map[string]V) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out return out
} }
-10
View File
@@ -60,16 +60,6 @@ func (d *Detail) ShowSecret(s provider.Object) {
d.container = &s.Container d.container = &s.Container
} }
// Refresh shows s's newer metadata if s is the secret shown, keeping
// its account and any value shown or loading.
func (d *Detail) Refresh(s provider.Object) {
if d.secret == nil || d.secret.ID != s.ID {
return
}
s.Container = d.secret.Container
d.secret = &s
}
// ShowContainer shows a container summary when no secret is selected. // ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) { func (d *Detail) ShowContainer(c provider.Container) {
d.Clear() d.Clear()
-9
View File
@@ -301,15 +301,6 @@ func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Rang
return out return out
} }
// RuneIndexes converts byte offsets in s to rune offsets.
func RuneIndexes(s string, bytes []int) []int {
out := make([]int, 0, len(bytes))
for _, b := range bytes {
out = append(out, utf8.RuneCountInString(s[:b]))
}
return out
}
// highlight returns the rune indexes to highlight in a row's plain text: // highlight returns the rune indexes to highlight in a row's plain text:
// the filter hits, shifted to where the Filter text appears in the row. // the filter hits, shifted to where the Filter text appears in the row.
// It returns nil if the row doesn't contain the Filter text verbatim. // It returns nil if the row doesn't contain the Filter text verbatim.
+2 -7
View File
@@ -3,8 +3,8 @@ package styles
import ( import (
"fmt" "fmt"
"image/color" "image/color"
"maps"
"slices" "slices"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -51,12 +51,7 @@ var Roles = map[string]string{
func FromTheme(theme map[string][]string) (Styles, color.Color, error) { func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default() st := Default()
var fg color.Color var fg color.Color
roles := make([]string, 0, len(theme)) for _, role := range slices.Sorted(maps.Keys(theme)) {
for r := range theme {
roles = append(roles, r)
}
sort.Strings(roles)
for _, role := range roles {
if _, ok := Roles[role]; !ok { if _, ok := Roles[role]; !ok {
continue continue
} }