diff --git a/docs/repos.md b/docs/repos.md index 2aa0c67..a2ee7b3 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -104,11 +104,12 @@ they are. ## Decrypting -Listing never decrypts: SOPS encrypts values only, so keys and metadata -(encryption, last modified) are read straight from the files. Revealing, -copying or `lazysecrets get` decrypts one value with your `sops` binary, so -whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP -KMS, PGP). When sops can't decrypt, its whole error message is shown. +Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and +metadata (encryption, last modified) are read straight from the files. +Revealing, copying or `lazysecrets get` decrypts one value; SOPS's with +your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault +through az, AWS/GCP KMS, PGP). When sops can't decrypt, its whole error +message is shown. On a file, the right pane shows how it's encrypted and the keys it's encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP @@ -117,6 +118,22 @@ tracked, ignored or neither, and its last commit. A plain `.env` file that git tracks, or that isn't ignored, is marked in red: its values are in the repo's history, or easily could be. +### Jasypt + +A value like `ENC(...)` in a plain `.env` or Spring file is +[Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied +with jasypt-spring-boot's default algorithm (3.0+: +`PBEWITHHMACSHA512ANDAES_256`). The password is, as in Spring: + +1. `$JASYPT_ENCRYPTOR_PASSWORD`, +2. the file's own: `jasypt.encryptor.password` in a Spring file, + `JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` (or + `${VAR:default}`) in it is read from the environment. + +Environment variables are lazysecrets' own: a link opened from the browser +starts it without your shell's. A `.env` whose values are all Jasypt's +isn't marked in red. + ## Opening files `e` opens the selected file, or on a secret, its file at that key's line: diff --git a/internal/jasypt/jasypt.go b/internal/jasypt/jasypt.go new file mode 100644 index 0000000..3587b01 --- /dev/null +++ b/internal/jasypt/jasypt.go @@ -0,0 +1,67 @@ +// Package jasypt decrypts values encrypted with Jasypt the way +// jasypt-spring-boot 3.0 and later does by default: ENC(...) around the +// base64 of a random salt, a random IV and the ciphertext, with +// PBEWITHHMACSHA512ANDAES_256 (PBKDF2-HMAC-SHA512, 1000 iterations, +// AES-256-CBC). +package jasypt + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/pbkdf2" + "crypto/sha512" + "encoding/base64" + "errors" + "strings" + "unicode/utf8" +) + +const iterations = 1000 + +// ErrDecrypt is a value that doesn't decrypt with the password: a wrong +// password, or one encrypted with another algorithm. +var ErrDecrypt = errors.New("doesn't decrypt: a wrong password, or not Jasypt's default algorithm (PBEWITHHMACSHA512ANDAES_256)") + +// Is reports whether v is a Jasypt value, ENC(...). +func Is(v string) bool { + return len(v) > len("ENC()") && strings.HasPrefix(v, "ENC(") && strings.HasSuffix(v, ")") +} + +// Decrypt decrypts an ENC(...) value. +func Decrypt(v, password string) (string, error) { + b, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(v, "ENC("), ")")) + if err != nil { + return "", errors.New("not Jasypt: ENC(...) isn't base64") + } + const size = aes.BlockSize // the salt's and the IV's too + if len(b) < 3*size || len(b)%size != 0 { + return "", ErrDecrypt + } + salt, iv, data := b[:size], b[size:2*size], b[2*size:] + key, err := pbkdf2.Key(sha512.New, password, salt, iterations, 32) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + plain := make([]byte, len(data)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data) + // A wrong password decrypts to noise: its padding is almost never + // valid, and the rest rarely UTF-8. + n := int(plain[len(plain)-1]) + if n == 0 || n > size { + return "", ErrDecrypt + } + for _, c := range plain[len(plain)-n:] { + if int(c) != n { + return "", ErrDecrypt + } + } + plain = plain[:len(plain)-n] + if !utf8.Valid(plain) { + return "", ErrDecrypt + } + return string(plain), nil +} diff --git a/internal/jasypt/jasypt_test.go b/internal/jasypt/jasypt_test.go new file mode 100644 index 0000000..7ab998f --- /dev/null +++ b/internal/jasypt/jasypt_test.go @@ -0,0 +1,37 @@ +package jasypt + +import ( + "errors" + "testing" +) + +// Encrypted by Jasypt 1.9.3 itself, set up as jasypt-spring-boot's default +// encryptor, with the password "test-password". +var vectors = map[string]string{ + "ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)": "hunter2", + "ENC(w4pm2JVZcbG5R+Ud9uKHg8VhJ/0aAK7sFXtNBv+Vo450SAchTQtfhw0TnIJ9gN+D0VFbwqJzfXU369coTJz2rw==)": "blåbærsyltetøy ✓", +} + +func TestDecrypt(t *testing.T) { + for enc, want := range vectors { + if !Is(enc) { + t.Errorf("%s: not Jasypt", enc) + } + if got, err := Decrypt(enc, "test-password"); err != nil || got != want { + t.Errorf("%s: %q %v, want %q", enc, got, err, want) + } + if _, err := Decrypt(enc, "wrong"); !errors.Is(err, ErrDecrypt) { + t.Errorf("%s with a wrong password: %v", enc, err) + } + } + for _, v := range []string{"ENC(not base64!)", "ENC(aGVsbG8=)"} { + if _, err := Decrypt(v, "test-password"); err == nil { + t.Errorf("%s: no error", v) + } + } + for _, v := range []string{"ENC()", "hunter2", "ENC[AES256_GCM,data:x]", "${DB_PASSWORD}"} { + if Is(v) { + t.Errorf("%s: Jasypt", v) + } + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4a05cf0..82eaefb 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -133,6 +133,7 @@ type Object struct { Type ObjectType ID string Name string + Detail string // shown after the name, e.g. "jasypt" for how its value is kept Version string Enabled *bool Updated time.Time diff --git a/internal/provider/repo/details.go b/internal/provider/repo/details.go index dd97a68..7a9ebcd 100644 --- a/internal/provider/repo/details.go +++ b/internal/provider/repo/details.go @@ -2,6 +2,8 @@ package repo import ( "context" + "fmt" + "os" "os/exec" "path/filepath" "strings" @@ -22,8 +24,8 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide } var out []provider.Attr meta, isSops := readMeta(c.ID) - // A plain file's values are at risk in git; a Spring file's only when - // one of its secrets is in plain text. + // A plain file's values are at risk in git, unless they're all Jasypt; + // a Spring file's only when one of its secrets is in plain text. plain := !isSops if !isSops && IsSpring(c.ID) { out, plain = p.springDetails(c) @@ -40,7 +42,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")}) } } else { - out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"}) + out, plain = envDetails(c.ID) } git := func(args ...string) (string, bool) { @@ -65,3 +67,28 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide } return out, nil } + +// envDetails says how a plain env file's values are encrypted, and whether +// any is in plain text. +func envDetails(file string) (attrs []provider.Attr, plain bool) { + b, err := os.ReadFile(file) + if err != nil { + return []provider.Attr{{Label: "encryption", Value: err.Error(), Warn: true}}, true + } + keys, _ := parseKeys(file, b) + n := 0 + for _, k := range keys { + if k.jasypt { + n++ + } + } + return []provider.Attr{{Label: "encryption", Value: jasyptShare(n, len(keys))}}, n < len(keys) +} + +// jasyptShare is how many of a plain file's values are Jasypt's. +func jasyptShare(n, of int) string { + if n == 0 { + return "none: plain text" + } + return fmt.Sprintf("jasypt: %d of %d values", n, of) +} diff --git a/internal/provider/repo/jasypt.go b/internal/provider/repo/jasypt.go new file mode 100644 index 0000000..c1bb68d --- /dev/null +++ b/internal/provider/repo/jasypt.go @@ -0,0 +1,74 @@ +package repo + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + + "git.kebcloud.no/keb/lazysecrets/internal/jasypt" + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// jasyptEnv is where Spring looks for the password first, before the +// config's jasypt.encryptor.password, and so does lazysecrets. +const jasyptEnv = "JASYPT_ENCRYPTOR_PASSWORD" + +// jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain +// file; other values are as they are. +func jasyptValue(file string, v provider.SecretValue) (provider.SecretValue, error) { + s := v.Reveal() + if !jasypt.Is(s) { + return v, nil + } + password, from, err := jasyptPassword(file) + if err != nil { + return provider.SecretValue{}, err + } + plain, err := jasypt.Decrypt(s, password) + if errors.Is(err, jasypt.ErrDecrypt) { + return provider.SecretValue{}, fmt.Errorf("jasypt: with the password from %s: %w", from, err) + } + if err != nil { + return provider.SecretValue{}, fmt.Errorf("jasypt: %w", err) + } + return provider.NewSecretValue([]byte(plain)), nil +} + +// jasyptPassword is the password for a file's Jasypt values, and where it's +// from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's own setting +// (jasypt.encryptor.password in a Spring file, JASYPT_ENCRYPTOR_PASSWORD +// in an env file), with a ${VAR} in it read from the environment. +func jasyptPassword(file string) (password, from string, err error) { + if v := os.Getenv(jasyptEnv); v != "" { + return v, "$" + jasyptEnv, nil + } + setting, read := jasyptEnv, plainValue + if IsSpring(file) { + setting, read = "jasypt.encryptor.password", springValue + } + v, err := read(file, setting) + if err != nil { + return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file)) + } + return resolvePlaceholder(v.Reveal(), setting+" in "+filepath.Base(file)) +} + +// placeholder is Spring's ${VAR} or ${VAR:default}. +var placeholder = regexp.MustCompile(`^\$\{([^:}]+)(:[^}]*)?\}$`) + +// resolvePlaceholder reads a ${VAR} value from the environment, falling +// back to its default; other values are as they are. +func resolvePlaceholder(v, from string) (string, string, error) { + m := placeholder.FindStringSubmatch(v) + switch { + case m == nil: + return v, from, nil + case os.Getenv(m[1]) != "": + return os.Getenv(m[1]), "$" + m[1] + " (" + from + ")", nil + case m[2] != "": + return m[2][1:], "the default in " + from, nil + } + return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v, m[1]) +} diff --git a/internal/provider/repo/jasypt_test.go b/internal/provider/repo/jasypt_test.go new file mode 100644 index 0000000..f311b5b --- /dev/null +++ b/internal/provider/repo/jasypt_test.go @@ -0,0 +1,102 @@ +package repo + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// hunter2, encrypted by Jasypt with the password "test-password". +const jasyptHunter2 = "ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)" + +func TestJasyptPassword(t *testing.T) { + dir := t.TempDir() + value := func(name, body string) (string, error) { + file := filepath.Join(dir, name) + if err := os.WriteFile(file, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + key := "app.token" + if !IsSpring(name) { + key = "TOKEN" + } + v, err := (&Provider{}).Value(context.Background(), provider.Object{ID: file + "\x00" + key, Name: key}) + return v.Reveal(), err + } + t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "") + t.Setenv("MY_PASSWORD", "") + spring := func(password string) string { + return "jasypt.encryptor.password: " + password + "\napp.token: " + jasyptHunter2 + "\n" + } + + for _, c := range []struct{ name, file, body, env, want string }{ + {"the file's own", "application.yml", spring("test-password"), "", "hunter2"}, + {"a ${VAR}", "application.yml", spring("${MY_PASSWORD}"), "test-password", "hunter2"}, + {"a ${VAR}'s default", "application.yml", spring("${MY_PASSWORD:test-password}"), "", "hunter2"}, + {"properties", "application-local.properties", "jasypt.encryptor.password=test-password\napp.token=" + jasyptHunter2 + "\n", "", "hunter2"}, + {"env file", ".env", "JASYPT_ENCRYPTOR_PASSWORD=test-password\nTOKEN=" + jasyptHunter2 + "\n", "", "hunter2"}, + {"not jasypt", ".env", "TOKEN=hunter2\n", "", "hunter2"}, + } { + t.Setenv("MY_PASSWORD", c.env) + if got, err := value(c.file, c.body); err != nil || got != c.want { + t.Errorf("%s: %q %v", c.name, got, err) + } + } + + // $JASYPT_ENCRYPTOR_PASSWORD wins over the file's, as in Spring. + t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password") + if got, err := value("application.yml", spring("wrong")); err != nil || got != "hunter2" { + t.Errorf("environment first: %q %v", got, err) + } + t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "wrong") + if _, err := value("application.yml", spring("test-password")); err == nil || !strings.Contains(err.Error(), "the password from $JASYPT_ENCRYPTOR_PASSWORD") { + t.Errorf("a wrong one says where it's from: %v", err) + } + + t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "") + t.Setenv("MY_PASSWORD", "") + for body, want := range map[string]string{ + spring("${MY_PASSWORD}"): "$MY_PASSWORD isn't set", + "app.token: " + jasyptHunter2: "set $JASYPT_ENCRYPTOR_PASSWORD, or jasypt.encryptor.password in application.yml", + } { + if _, err := value("application.yml", body); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("no password: %v, want %q", err, want) + } + } +} + +func TestJasyptEnvFile(t *testing.T) { + root := gitRepo(t, map[string]string{ + "jasypt.env": "TOKEN=" + jasyptHunter2 + "\n", + "mixed.env": "TOKEN=" + jasyptHunter2 + "\nURL=https://example.com\n", + }) + ctx := context.Background() + p := New(Settings{Dir: root}) + srcs, _ := p.Sources(ctx) + cs, _ := p.Containers(ctx, srcs[0]) + for _, c := range cs { + attrs, _ := p.Details(ctx, c) + got := map[string]provider.Attr{} + for _, a := range attrs { + got[a.Label] = a + } + switch c.Name { + case "jasypt.env": + if got["encryption"].Value != "jasypt: 1 of 1 values" || got["git"].Warn { + t.Errorf("all jasypt: %+v", got) + } + keys, _ := p.Objects(ctx, c, provider.TypeSecret) + if len(keys) != 1 || keys[0].Attrs[1].Value != "jasypt" || keys[0].Detail != "jasypt" { + t.Errorf("keys: %+v", keys) + } + case "mixed.env": + if got["encryption"].Value != "jasypt: 1 of 2 values" || !got["git"].Warn { + t.Errorf("mixed: %+v", got) + } + } + } +} diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index c101cbc..5acd054 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -32,6 +32,7 @@ import ( "go.yaml.in/yaml/v3" + "git.kebcloud.no/keb/lazysecrets/internal/jasypt" "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -380,8 +381,11 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs, } - if isSops && !k.encrypted { + switch { + case isSops && !k.encrypted: o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"}) + case !isSops && k.jasypt: + o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt" } out = append(out, o) } @@ -396,17 +400,23 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object return nil, provider.ErrUnsupported } -// Value decrypts one key with sops. +// Value reads one key: from a plain file, decrypting a Jasypt value; from +// a SOPS file, with sops. func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { file, extract, ok := strings.Cut(o.ID, "\x00") if !ok { // made from a link: the repo's root, the file's path, the key file = filePath(o.Container) } if _, isSops := readMeta(file); !isSops { + read := plainValue if IsSpring(file) { - return springValue(file, o.Name) + read = springValue } - return plainValue(file, o.Name) + v, err := read(file, o.Name) + if err != nil { + return v, err + } + return jasyptValue(file, v) } if !ok { var err error @@ -1045,7 +1055,8 @@ type key struct { name string // db.password, list[0], with . [ ] \ in keys escaped extract string // sops --extract: ["db"]["password"] encrypted bool - line int // 1-based + jasypt bool // a plain env file's ENC(...) value + line int // 1-based } // parseKeys lists a sops file's keys without decrypting anything. @@ -1070,7 +1081,7 @@ func parseKeys(file string, b []byte) ([]key, error) { continue } sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key - out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) + out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), jasypt: jasypt.Is(v), line: i + 1}) } return out, nil case "ini": diff --git a/internal/provider/repo/spring.go b/internal/provider/repo/spring.go index 299a299..de64c4d 100644 --- a/internal/provider/repo/spring.go +++ b/internal/provider/repo/spring.go @@ -15,6 +15,7 @@ import ( "go.yaml.in/yaml/v3" + "git.kebcloud.no/keb/lazysecrets/internal/jasypt" "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -137,7 +138,7 @@ func (k springKey) reference() bool { } func (k springKey) jasypt() bool { - return strings.HasPrefix(k.value, "ENC(") && strings.HasSuffix(k.value, ")") + return jasypt.Is(k.value) } // plain reports whether the file holds the value itself, readable. @@ -346,15 +347,15 @@ func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Obj if !filter.secret(k) { continue } - state := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"} + state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, "" switch { case k.reference(): - state = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"} + state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference" case k.jasypt(): - state = provider.Attr{Label: "encrypted", Value: "jasypt"} + state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt" } out = append(out, provider.Object{ - Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, + Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail, Attrs: []provider.Attr{format, state}, }) } @@ -403,12 +404,8 @@ func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, p jasypt++ } } - encryption := "none: plain text" - if jasypt > 0 { - encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, len(keys)) - } return []provider.Attr{ - {Label: "encryption", Value: encryption}, + {Label: "encryption", Value: jasyptShare(jasypt, len(keys))}, {Label: "other 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 7f92860..1685d70 100644 --- a/internal/provider/repo/spring_test.go +++ b/internal/provider/repo/spring_test.go @@ -141,7 +141,7 @@ jasypt: encryptor: password: ${JASYPT_ENCRYPTOR_PASSWORD} app: - api-key: ENC(abc==) + api-key: ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw) `, "src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n", "applicationset/applications.yaml": "kind: ApplicationSet\n", @@ -180,12 +180,13 @@ app: keys, _ := p.Objects(ctx, yml, provider.TypeSecret) var names []string for _, k := range keys { - names = append(names, k.Name) + names = append(names, strings.TrimSpace(k.Name+" "+k.Detail)) } - if want := []string{"spring.datasource.password", "jasypt.encryptor.password", "app.api-key"}; !slices.Equal(names, want) { + 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) } - if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "ENC(abc==)" { + t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password") + if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "hunter2" { t.Errorf("value: %q %v", v.Reveal(), err) } link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"} diff --git a/internal/ui/app/favourites.go b/internal/ui/app/favourites.go index 77bd261..ce2bab2 100644 --- a/internal/ui/app/favourites.go +++ b/internal/ui/app/favourites.go @@ -137,6 +137,9 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string { line = sty.Muted.Render(o.Name + " (disabled)") } line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line + if o.Detail != "" { + line += sty.Muted.Render(" " + o.Detail) + } switch state, left := m.expiry(o); state { case expiry.Expired: line += " " + sty.Error.Render("✗ expired") diff --git a/internal/ui/app/jump.go b/internal/ui/app/jump.go index 12cec16..78c0352 100644 --- a/internal/ui/app/jump.go +++ b/internal/ui/app/jump.go @@ -38,11 +38,13 @@ func (m *Model) storeEntries() (favs, recent []option) { return nil } var out []entry + detail := m.objectDetail(key) add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) { account := "" if p, ok := m.byID[acct]; ok { account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()}) } + account = strings.Trim(detail+" · "+account, " ·") sort := strings.ToLower(strings.Join(path, "/")) + " " + account out = append(out, entry{depth, sort, option{ label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ", @@ -157,6 +159,19 @@ func (m Model) inPlainEnv(r ref.Ref) bool { return false } +// objectDetail is the Detail of the secret with the store key key, as far +// as the lists loaded tell. +func (m Model) objectDetail(key string) string { + for _, e := range m.cache.objects { + for _, o := range e.value { + if o.Detail != "" && objectKey(o) == key { + return o.Detail + } + } + } + return "" +} + // repoLabel draws a repo, or a file in it, as everywhere else: the repo // icon and colour, "app / apps/api/.env". func (m Model) repoLabel(repo string, file ...string) string { diff --git a/internal/ui/app/search.go b/internal/ui/app/search.go index 609757f..6e6fb50 100644 --- a/internal/ui/app/search.go +++ b/internal/ui/app/search.go @@ -90,7 +90,7 @@ func (m *Model) searchEntries() (sources, items []option) { if err != nil { continue } - suffix := accountOf(src) + suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·") if t != provider.TypeSecret { typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key" suffix = strings.Trim(typ+" · "+suffix, " ·")