1 Commits
Author SHA1 Message Date
keb ca83e95354 fix: local builds are dev-<commit> again, not a Go pseudo-version
CI / Checks (pull_request) Successful in 36s
PR title / Conventional title (pull_request) Successful in 2s
2026-10-14 21:08:26 +02:00
115 changed files with 1289 additions and 5179 deletions

No files matched your search

+28 -8
View File
@@ -1,13 +1,33 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Build output
/lazysecrets
# Release builds (GoReleaser, see .goreleaser.yaml)
/dist/
# go test -c, coverage profiles
*.test
*.out
go.work
go.work.sum
.env
-34
View File
@@ -2,40 +2,6 @@
Every release of lazysecrets, newest first.
## v0.3.0 – 2026-10-15
### Added
- Spring Boot config files in repos ([#79](https://git.kebcloud.no/keb/lazysecrets/pulls/79)) ([96395cb](https://git.kebcloud.no/keb/lazysecrets/commit/96395cb6a27a94293e35401790296a894821d075))
- Decrypt Jasypt values in .env and Spring files ([#80](https://git.kebcloud.no/keb/lazysecrets/pulls/80)) ([17da9ea](https://git.kebcloud.no/keb/lazysecrets/commit/17da9eaa3271dfa315a517ad0092ab5cda0b0597))
- H shows what's hidden; Spring keys that don't look secret are hidden ([#81](https://git.kebcloud.no/keb/lazysecrets/pulls/81)) ([913b3db](https://git.kebcloud.no/keb/lazysecrets/commit/913b3db6a938d21c6d8c9285a3111418495c7345))
- Secret patterns for all of a repo's config files, with per-type overrides ([#82](https://git.kebcloud.no/keb/lazysecrets/pulls/82)) ([4bac06a](https://git.kebcloud.no/keb/lazysecrets/commit/4bac06ae5ba6fcb8c96d46198f01235391e29f1a))
- Example env files are listed hidden, not skipped ([#83](https://git.kebcloud.no/keb/lazysecrets/pulls/83)) ([580c7ef](https://git.kebcloud.no/keb/lazysecrets/commit/580c7ef985599b98277d80be38e39abe169b2dc4))
- Docker Compose files in repos ([#84](https://git.kebcloud.no/keb/lazysecrets/pulls/84)) ([0f5248d](https://git.kebcloud.no/keb/lazysecrets/commit/0f5248d8f6d27e3f95f8b9cbc28310ee7b0581f7))
- Go to shows a repo file's icon before its name ([#85](https://git.kebcloud.no/keb/lazysecrets/pulls/85)) ([e2f289d](https://git.kebcloud.no/keb/lazysecrets/commit/e2f289da5c54dab869e7ba10ca35bdc1ab74bf28))
### Fixed
- Decrypt SOPS .env keys written with export or spaces ([#78](https://git.kebcloud.no/keb/lazysecrets/pulls/78)) ([39cbac9](https://git.kebcloud.no/keb/lazysecrets/commit/39cbac9aabcea11e9915363e4adae5873f9724de))
## v0.2.1 – 2026-10-15
### Fixed
- A link opens lazysecrets with your cache and next to your running instances ([#71](https://git.kebcloud.no/keb/lazysecrets/pulls/71)) ([bb41767](https://git.kebcloud.no/keb/lazysecrets/commit/bb41767cbad00bab4f1824f4e0754cc7abd98c59))
## v0.2.0 – 2026-10-14
### Added
- Lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers ([#70](https://git.kebcloud.no/keb/lazysecrets/pulls/70)) ([39454d4](https://git.kebcloud.no/keb/lazysecrets/commit/39454d47a864c8628c50b023aee9539d33be2f44))
### Fixed
- Lazysecrets starts from fzf's become, on a non-blocking terminal ([#67](https://git.kebcloud.no/keb/lazysecrets/pulls/67)) ([4480e8f](https://git.kebcloud.no/keb/lazysecrets/commit/4480e8fed00024691091eb7096abb9dc83b8044d))
- Local builds are dev-<commit> again, not a Go pseudo-version ([#68](https://git.kebcloud.no/keb/lazysecrets/pulls/68)) ([e825a51](https://git.kebcloud.no/keb/lazysecrets/commit/e825a51b30261b55c536af18fefde79dc6308377))
- A wrapped value in the details keeps its colour on every line ([#69](https://git.kebcloud.no/keb/lazysecrets/pulls/69)) ([dbb0eb2](https://git.kebcloud.no/keb/lazysecrets/commit/dbb0eb2bbdf6d695cf0d9ac15a1b513af8ba431d))
## v0.1.2 – 2026-10-13
### Added
+16 -19
View File
@@ -7,23 +7,27 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
That's by design and may stay that way.
- **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues.
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
with their own access, from the terminal or with a click in the browser
or chat. `.env` files can be shared too, as a link encrypted with a
secret your team can read.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor
pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
for Neovim.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pipes.
- **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane.
- **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env`, Spring Boot, Docker Compose and .NET appsettings files in your git repos.
- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
@@ -44,8 +48,9 @@ Both approaches will install the latest release from [GitHub](https://github.com
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else.
`U` (or `lazysecrets update`) updates in place when there's a newer
release.
lazysecrets looks for a newer release once a day: the footer then shows
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
`lazysecrets update`).
Or build it yourself:
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
@@ -68,29 +73,21 @@ use most:
| `l` / `h` | the details of what's selected / back |
| `/` | filter the list you're in |
| `space` | reveal / hide the value |
| `y` | copy the value |
| `y` | copy the value (cleared from the clipboard after 30s) |
| `ctrl+s` | Go to: search everything and jump there |
| `f` / `F` | star / your favourites |
| `s` | copy a link to share |
| `?` | every key, searchable |
| `q` | quit |
## Integration
`lazysecrets list`, `get` and `open` plug lazysecrets into fzf, editor
pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
![fzf-example](docs/assets/fzf-example.png)
## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
- [SOPS, .env, Spring, compose and .NET files](docs/repos.md): which repos and files, decrypting
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
opening links, also from the browser and chat
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts,
and opening links from the browser and chat
- [Custom commands](docs/custom-commands.md): run your own commands on a
secret
- [Config](docs/config.md): every option, themes, layered configs, the
+10 -9
View File
@@ -18,7 +18,7 @@ import (
// It ends with a newline only when printing to a terminal, so command
// substitution and pipes get the value exactly.
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
v, _, err := readKey(ctx, providers, st, r)
v, err := value(ctx, providers, st, r)
if err != nil {
return err
}
@@ -30,16 +30,17 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err
}
// readKey reads what r points at (ref.Read), and the version read. The
// account asked for (--account) goes first, else the one that last read
// it.
// value reads what r points at (ref.Read), with the account that last
// read it first.
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
v, _, err := readKey(ctx, providers, st, r)
return v, err
}
// readKey is value, also returning the version read.
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
key := r
key.Version = ""
node := st.Node(key.String())
account, source := node.Account, node.Source
if r.Account != "" && r.Account != account {
account, source = r.Account, ""
}
return ref.Read(ctx, providers, r, account, source)
return ref.Read(ctx, providers, r, node.Account, node.Source)
}
+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",
} {
r, _ := ref.Parse(link)
v, _, err := readKey(context.Background(), providers, st, r)
v, err := value(context.Background(), providers, st, r)
if err != nil || v.Reveal() != want {
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",
} {
r, _ := ref.Parse(link)
if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Errorf("%s: %v, want %q", link, err, wantErr)
}
}
+8 -10
View File
@@ -69,7 +69,11 @@ func installHandler(cfg config.Config, configPath, term string) (string, string,
if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
return "", "", err
}
launch := append(append([]string{exe}, configArgs(configPath)...), "launch")
launch := []string{exe}
if configPath != defaultConfigPath() {
launch = append(launch, "--config", configPath)
}
launch = append(launch, "launch")
if term != "" {
launch = append(launch, "--terminal", term)
}
@@ -184,14 +188,8 @@ func launchCommand(cfg config.Config, configPath, term, exe, link string) ([]str
// innerCommand runs lazysecrets on link, with the environment it needs.
func innerCommand(configPath, exe, link string) []string {
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe)
inner = append(inner, configArgs(configPath)...)
if configPath != defaultConfigPath() {
inner = append(inner, "--config", configPath)
}
return append(inner, "open", link)
}
// configArgs passes the config files on, unless they're the default.
func configArgs(configPath string) []string {
if configPath == defaultConfigPath() {
return nil
}
return []string{"--config", configPath}
}
-270
View File
@@ -1,270 +0,0 @@
package main
import (
"cmp"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// listCmd prints every source, vault and secret lazysecrets can find, one
// per line, for fzf, editor pickers and scripts:
//
// lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1
//
// Each line is the kind, a mark (✗ or ⚠, ★, ◷ and the provider's icon),
// the name, a status (why it couldn't be read, or that it has expired or
// expires soon; else empty), its note (muted, or empty), the account and
// the link, tab-separated. The mark has a field of its own so a search can
// leave it out (fzf --nth), and ^prod finds names starting with prod. The
// name is coloured from the theme,
// with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS,
// ui.icons); the link never is.
//
// What's cached prints at once; what's missing or older than --max-age
// follows as it loads. A list that can't be read is a line too, as it
// fails: the vault or source, then ✗ and why in the theme's error colour.
// They're in the output rather than on stderr, which would draw over fzf.
func listCmd(providers []provider.Provider, st *store.Store, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons, withRecent bool, expiryWarn time.Duration, args []string, out io.Writer) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks")
maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again")
format := fs.String("format", "text", "text: kind, mark, name, status, note, account and link, tab-separated; json: an object per line")
color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi")
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)")
recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)")
notes := fs.Bool("notes", true, "start a note with a note icon (✎ without --icons) (text; the note is printed either way)")
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: config file keys (Spring, compose, .NET) that don't look secret, example files, and files and repos with only those (json: \"hidden\")")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() > 0 {
return fmt.Errorf("usage: lazysecrets list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]")
}
var only []string
for k := range strings.SplitSeq(*kinds, ",") {
if k = strings.TrimSpace(k); k == "" {
continue
}
if !slices.Contains(listing.Kinds, k) {
return fmt.Errorf("--kind: %q is not one of %s", k, strings.Join(listing.Kinds, ", "))
}
only = append(only, k)
}
if *color != "always" && *color != "never" {
return fmt.Errorf("--color: %q is not always or never", *color)
}
now := time.Now()
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry,
color: *color == "always", warn: expiryWarn, now: now}
if *iconsOn {
m.icons = icons.NerdFont
}
var write func(listing.Entry) error
switch *format {
case "text":
write = func(e listing.Entry) error {
colored := *color == "always"
mark := m.of(e)
name := nameOf(e, theme, colored)
// Why it couldn't be read (vaults, sources), or when it
// expires (secrets, keys, certificates): never both.
status := errorOf(e, theme, colored)
if status == "" && *expiry {
status = expiryOf(e, theme, expiryWarn, now, colored)
}
note := noteOf(e, theme, m.icons, *notes, colored)
// A tab or line break in a name would break the line up.
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(status),
oneLine.Replace(note), e.Account, e.Link)
return err
}
case "json":
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
write = func(e listing.Entry) error { return enc.Encode(e) }
default:
return fmt.Errorf("--format: %q is not text or json", *format)
}
// When the reader goes away (a pick in fzf), writing fails instead of
// killing the process, so what loaded is still saved.
signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
var gone error
emit := func(e listing.Entry) error {
if e.Error != "" && !*showErrors {
return nil
}
if err := write(e); err != nil {
gone = err
return err
}
return nil
}
f := cachefile.New()
if cachePath != "" {
f = cachefile.Read(cachePath)
}
f, loaded := listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, Hidden: *hidden,
Mark: func(link, account string) listing.Marks {
n := st.Node(link)
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
},
}, emit)
// Only what was loaded: list never moves a cursor, so the TUI's stay.
if cachePath != "" && loaded {
f.Cursor = nil
if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil {
return err
}
}
if gone != nil && !errors.Is(gone, syscall.EPIPE) {
return gone
}
return nil // or the reader has what it wanted
}
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
// noteOf is the entry's note, muted, after a note icon (✎ without Nerd
// Font icons) with icon; or "".
func noteOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, icon, color bool) string {
if e.Note == "" {
return ""
}
note := e.Note
if icon {
note = cmp.Or(iconSet.Note, "✎") + " " + note
}
return renderer(color)(theme.Muted, note)
}
// expiryOf says that a secret, key or certificate has expired, in red,
// or when it will if that's within warn (expires in 12d), in yellow, or
// is "". Its mark (✗, ⚠) is before the name.
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
render := renderer(color)
switch state, left := expiry.Of(e.Expires, now, warn); state {
case expiry.Expired:
return render(theme.Error, "expired")
case expiry.Soon:
return render(theme.Warning, "expires in "+expiry.Left(left))
}
return ""
}
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
// is before the name.
func errorOf(e listing.Entry, theme styles.Styles, color bool) string {
return renderer(color)(theme.Error, e.Error)
}
// nameOf is an entry's name for the text format: the path with the
// item's own name standing out, and the account.
func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
render := renderer(color)
var name string
if n := len(e.Path); n > 0 {
if n > 1 {
name = render(theme.Muted, strings.Join(e.Path[:n-1], " / ")+" / ")
}
name += e.Path[n-1]
}
if e.AccountLabel != "" {
name += render(theme.Muted, " · "+e.AccountLabel)
}
return name
}
// marks says what goes before the name, in a field of its own so a
// search leaves it out: ✗ in red when something's wrong (it couldn't be
// read, or has expired) or ⚠ when it expires within warn; ★ for a starred
// entry and ◷ for one used lately, as the TUI marks them; then the
// provider's icon. The marks are right-aligned in room for every kind
// turned on, so they sit by the icon and names line up.
type marks struct {
theme styles.Styles
icons icons.Set
stars, recent, expiry, color bool
warn time.Duration // ui.expiryWarningDays
now time.Time
}
func (m marks) of(e listing.Entry) string {
render := renderer(m.color)
var parts []string
room := 0 // marks turned on
mark := func(on bool, st lipgloss.Style, s string) {
room++
if on {
parts = append(parts, render(st, s))
}
}
// What's wrong, then what to watch: room for one of them.
state := expiry.None
if m.expiry {
state, _ = expiry.Of(e.Expires, m.now, m.warn)
}
switch {
case e.Error != "" || state == expiry.Expired:
mark(true, m.theme.Error, "✗")
case state == expiry.Soon:
mark(true, m.theme.Warning, "⚠")
default:
mark(false, m.theme.Error, "")
}
if m.stars {
mark(e.Starred, m.theme.Favourite, "★")
}
if m.recent {
mark(e.Recent, m.theme.Muted, "◷")
}
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
kind := provider.Kind("")
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
if strings.EqualFold(string(k), e.Provider) {
kind = k
}
}
if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" {
parts = append(parts, render(m.theme.Kind(kind), icon))
} else if len(parts) == 0 {
pad = strings.TrimSuffix(pad, " ") // no mark after the room
}
return pad + strings.Join(parts, " ")
}
func renderer(color bool) func(lipgloss.Style, string) string {
return func(st lipgloss.Style, s string) string {
if !color || s == "" {
return s
}
return st.Render(s)
}
}
-152
View File
@@ -1,152 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
func TestListNames(t *testing.T) {
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, AccountLabel: "alice"}
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-app-prod / db-password · alice" {
t.Errorf("plain: %q", got)
}
// The item's own name isn't muted with its path.
if got := nameOf(e, styles.Default(), true); !strings.Contains(got, "\x1b[mdb-password") {
t.Errorf("name not set apart: %q", got)
}
}
func TestListMarks(t *testing.T) {
az := icons.NerdFont.Azure
plain := marks{theme: styles.Default(), icons: icons.NerdFont, stars: true}
noIcons := plain
noIcons.icons = icons.None
colored := noIcons
colored.color = true
for _, c := range []struct {
m marks
e listing.Entry
want string
}{
// Room for the status mark and the star, so names line up.
{plain, listing.Entry{Provider: "azure", Starred: true}, " ★ " + az},
{plain, listing.Entry{Provider: "azure"}, " " + az},
{marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, " " + az},
{noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"},
{marks{}, listing.Entry{Provider: "azure", Starred: true}, " "},
{colored, listing.Entry{Provider: "azure", Starred: true}, " " + styles.Default().Favourite.Render("★")},
} {
if got := c.m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
}
}
func TestListRecentMarks(t *testing.T) {
az := icons.NerdFont.Azure
m := marks{icons: icons.NerdFont, stars: true, recent: true}
for _, c := range []struct {
e listing.Entry
want string
}{
// Right-aligned in room for three: by the icon, names lined up.
{listing.Entry{Provider: "azure", Starred: true, Recent: true}, " ★ ◷ " + az},
{listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az},
{listing.Entry{Provider: "azure"}, " " + az},
} {
if got := m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
}
}
func TestListExpiry(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
warn := 30 * 24 * time.Hour
for _, c := range []struct {
expires time.Time
want string
}{
{time.Time{}, ""},
{now.Add(-time.Hour), "expired"},
{now.Add(12*24*time.Hour + time.Hour), "expires in 12d"},
{now.Add(5 * time.Hour), "expires in 5h"},
{now.Add(60 * 24 * time.Hour), ""},
} {
if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want {
t.Errorf("expires %v: %q, want %q", c.expires, got, c.want)
}
}
if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("expired") {
t.Errorf("expired isn't red: %q", got)
}
}
func TestListNotes(t *testing.T) {
e := listing.Entry{Note: "rotate in May"}
if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" {
t.Errorf("icons: %q", got)
}
if got := noteOf(e, styles.Default(), icons.None, true, false); got != "✎ rotate in May" {
t.Errorf("no icons: %q", got)
}
if got := noteOf(e, styles.Default(), icons.NerdFont, false, false); got != "rotate in May" {
t.Errorf("--notes=false: %q", got)
}
if got := noteOf(listing.Entry{}, styles.Default(), icons.NerdFont, true, true); got != "" {
t.Errorf("no note: %q", got)
}
}
func TestListStatusMark(t *testing.T) {
az := icons.NerdFont.Azure
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
m := marks{icons: icons.NerdFont, stars: true, expiry: true, warn: 30 * 24 * time.Hour, now: now}
for _, c := range []struct {
e listing.Entry
want string
}{
// Right-aligned in room for two, as with ★ and ◷.
{listing.Entry{Provider: "azure", Error: "403 Forbidden"}, " ✗ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour), Starred: true}, "✗ ★ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(5 * 24 * time.Hour)}, " ⚠ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour), Starred: true}, " ★ " + az},
} {
if got := m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
}
m.expiry = false // --expiry=false: no ⚠ or ✗ for expiry; errors still
if got := m.of(listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}); got != " "+az {
t.Errorf("--expiry=false: %q", got)
}
m.expiry, m.color, m.theme = true, true, styles.Default()
if got := m.of(listing.Entry{Error: "403"}); !strings.Contains(got, styles.Default().Error.Render("✗")) {
t.Errorf("not a red ✗: %q", got)
}
if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) {
t.Errorf("not a yellow ⚠: %q", got)
}
}
func TestListErrors(t *testing.T) {
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"}
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-restricted" {
t.Errorf("the name has more: %q", got)
}
if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" {
t.Errorf("plain: %q", got)
}
if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("403 Forbidden") {
t.Errorf("why isn't red: %q", got)
}
if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" {
t.Errorf("no error: %q", got)
}
}
+69 -99
View File
@@ -68,44 +68,25 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
if c.Key == "" {
continue
}
name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil {
return err
}
for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
}
}
}
return nil
}
// refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column) and,
// for open, what to do with it rather than asking (--action).
func refArg(cmd string, args []string) (r ref.Ref, action string, err error) {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
account := fs.String("account", "", "the account to use when several could, as lazysecrets list prints it")
usage := "usage: lazysecrets get [--account <account>] <ref>"
act := new(string)
if cmd == "open" {
act = fs.String("action", "", "do this rather than ask: go (to it), copy (go, and copy its value) or show (go, and show it)")
usage = "usage: lazysecrets open [--account <account>] [--action go|copy|show] <ref>"
func cmpOr(a, b string) string {
if a != "" {
return a
}
if err := fs.Parse(args); err != nil {
return r, "", err
}
if fs.NArg() != 1 {
return r, "", errors.New(usage)
}
if _, ok := app.OpenActions[*act]; *act != "" && !ok {
return r, "", fmt.Errorf("--action: %q is not go, copy or show", *act)
}
r, err = ref.Parse(fs.Arg(0))
r.Account = *account
return r, *act, err
return b
}
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
@@ -134,19 +115,12 @@ func run() error {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets
lazysecrets [flags] [open [--account <account>] [--action go|copy|show]] <ref>
lazysecrets [flags] [open] <ref>
open a lazysecrets:// link (also a share link),
Key Vault URL, @Microsoft.KeyVault(...) or
{{secrets/scope/key}}
lazysecrets [flags] get [--account <account>] <ref>
print the value a lazysecrets:// link points at,
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false]
[--stars=false] [--notes=false] [--recent] [--expiry=false]
print every source, vault and secret it can
find, one per line, for fzf and pickers:
what's cached at once, then what loads
lazysecrets [flags] share [--key <secret>] <env file>...
print a link to env files of a repo, encrypted
with a secret's value (default: sharing.key)
@@ -171,22 +145,23 @@ Flags:
return nil
}
sub := "" // the subcommand; "" browses
var subArgs []string
var open *ref.Ref
var getRef ref.Ref
var openAction string
var open, getRef *ref.Ref
var shareArgs []string
printKeys := false
switch args := flag.Args(); {
case len(args) > 0 && (args[0] == "share" || args[0] == "list"):
sub, subArgs = args[0], args[1:]
case len(args) > 0 && args[0] == "share":
shareArgs = args[1:]
if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how
}
case len(args) == 1 && args[0] == "keybindings":
sub = "keybindings"
case len(args) > 0 && args[0] == "get":
r, _, err := refArg("get", args[1:])
printKeys = true
case len(args) == 2 && args[0] == "get":
r, err := ref.Parse(args[1])
if err != nil {
return err
}
sub, getRef = "get", r
getRef = &r
case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch":
@@ -194,12 +169,12 @@ Flags:
case len(args) > 0 && args[0] == "update":
return updateCmd(*configPath, args[1:])
case len(args) == 0:
case args[0] == "open" && len(args) >= 2:
r, act, err := refArg("open", args[1:])
case args[0] == "open" && len(args) == 2:
r, err := ref.Parse(args[1])
if err != nil {
return err
}
open, openAction = &r, act
open = &r
case len(args) == 1 && looksLikeRef(args[0]):
// lazysecrets <link>, like lazysecrets open <link>
r, err := ref.Parse(args[0])
@@ -232,7 +207,7 @@ Flags:
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
return err
}
if sub == "keybindings" {
if printKeys {
fmt.Print(keymap.YAML())
return nil
}
@@ -260,13 +235,25 @@ Flags:
}
// timeoutFor is how long a request to a provider of kind k may take.
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] {
*clipClear = valueOr(cfg.Values.ClipboardClear, *clipClear)
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
*clipClear = *cfg.Values.ClipboardClear
}
expiryDays := 30
if cfg.UI.ExpiryWarningDays != nil {
expiryDays = *cfg.UI.ExpiryWarningDays
}
revealTimeout := 60 * time.Second
if cfg.Values.RevealTimeout != nil {
revealTimeout = *cfg.Values.RevealTimeout
}
loadDelay := 150 * time.Millisecond
if cfg.UI.LoadDelay != nil {
loadDelay = *cfg.UI.LoadDelay
}
var updateEvery time.Duration // 0: the app's default, a day
if cfg.Updates.AutoCheckInterval != nil {
updateEvery = *cfg.Updates.AutoCheckInterval
}
expiryWarning := time.Duration(valueOr(cfg.UI.ExpiryWarningDays, 30)) * 24 * time.Hour
revealTimeout := valueOr(cfg.Values.RevealTimeout, 60*time.Second)
loadDelay := valueOr(cfg.UI.LoadDelay, 150*time.Millisecond)
updateEvery := valueOr(cfg.Updates.AutoCheckInterval, 0) // 0: the app's default, a day
if !set["icons"] {
switch {
case os.Getenv("LAZYSECRETS_ICONS") != "":
@@ -276,18 +263,6 @@ Flags:
}
}
recentIcon := valueOr(cfg.UI.RecentIcon, false)
startDir := *dir
if startDir == "" {
startDir, _ = os.Getwd()
}
// What was loaded last time.
cacheFile := ""
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
// Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file.
stateDir, err := config.StateDir()
@@ -308,14 +283,9 @@ Flags:
var providers []provider.Provider
if *demo {
// Two Azure accounts, like a personal and an admin login in az.
alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
fake.Seed(st, alice, admin)
az := fake.Azure(400*time.Millisecond).As(alice, "Azure · alice")
// Due soon whenever the demo runs (the tests' dates are fixed).
az.Expire("kv-app-prod", "api-key", time.Now().AddDate(0, 0, 9).Truncate(24*time.Hour))
providers = []provider.Provider{
az,
fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
fake.Databricks(600 * time.Millisecond),
fake.Repos(),
}
@@ -344,10 +314,17 @@ Flags:
providers = append(providers, dbx)
}
if config.On(pc.Repos.Enabled) {
start := *dir
if start == "" {
start, _ = os.Getwd()
}
roots := repo.DefaultRoots
if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots
}
repos := repo.New(repo.Settings{
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
SecretPatterns: pc.Repos.SecretPatterns, Types: map[string]repo.FileType{"spring": pc.Repos.Spring, "compose": pc.Repos.Compose, "dotnet": pc.Repos.Dotnet},
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
})
// The repo it started in (shown on top as Repo) and the
// others, checked for secret files in the background like
@@ -363,14 +340,12 @@ Flags:
// the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard)
switch sub {
case "get":
if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
defer cancel()
return get(ctx, providers, st, getRef)
case "list":
return listCmd(providers, st, cacheFile, timeoutFor, theme, *icons, recentIcon, expiryWarning, subArgs, os.Stdout)
case "share":
return get(ctx, providers, st, *getRef)
}
if shareArgs != nil {
// The files from a repo, the key from whichever provider it's
// in: the longest timeout.
d := *timeout
@@ -379,7 +354,7 @@ Flags:
}
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel()
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), subArgs, os.Stdout)
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
}
// Links opened elsewhere (lazysecrets launch) can come here.
@@ -413,10 +388,16 @@ Flags:
onActive = srv.Touch
}
// Where it was left in this folder.
placeDir := ""
// What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string
if !*demo {
placeDir = startDir
if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
if placeDir == "" {
placeDir, _ = os.Getwd()
}
if abs, err := filepath.Abs(placeDir); err == nil {
placeDir = abs
}
@@ -433,7 +414,7 @@ Flags:
NoHistory: !config.On(cfg.Storage.History),
OnActive: onActive,
RevealTimeout: revealTimeout,
ExpiryWarning: expiryWarning,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.Values.HideOnBlur),
HandlerSetup: setup,
Updater: updater(cfg.Updates.Sources),
@@ -452,12 +433,9 @@ Flags:
ShareKey: cfg.Sharing.Key,
ShareRules: shareRules(cfg),
Open: open,
OpenAction: openAction,
RecentIcon: recentIcon,
Styles: &theme,
DefaultFg: defaultFg,
})
blockingStdin()
p = tea.NewProgram(m)
final, err := p.Run()
if fm, ok := final.(app.Model); ok {
@@ -466,14 +444,6 @@ Flags:
return err
}
// valueOr is *p, or def when p is nil (not set in the config).
func valueOr[T any](p *T, def T) T {
if p == nil {
return def
}
return *p
}
// configFiles are the config files given, for the status pane: those
// that exist, or the first if none does (where to create one).
func configFiles(list string) []string {
-5
View File
@@ -1,5 +0,0 @@
//go:build !unix
package main
func blockingStdin() {}
-20
View File
@@ -1,20 +0,0 @@
//go:build unix
package main
import (
"os"
"syscall"
"golang.org/x/term"
)
// blockingStdin makes a terminal on stdin blocking again. fzf's become
// hands on its /dev/tty non-blocking; Go can't poll /dev/tty on macOS,
// so it reads it directly, and the TUI's first read would fail with
// EAGAIN ("resource temporarily unavailable").
func blockingStdin() {
if fd := int(os.Stdin.Fd()); term.IsTerminal(fd) {
_ = syscall.SetNonblock(fd, false)
}
}
+4 -9
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"strings"
"time"
@@ -33,12 +32,8 @@ func updater(servers []string) *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it.
func updateCmd(configPath string, args []string) error {
fs := flag.NewFlagSet("update", flag.ContinueOnError)
check := fs.Bool("check", false, "just say if there's a newer release")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
check := len(args) == 1 && args[0] == "--check"
if len(args) > 0 && !check {
return errors.New("usage: lazysecrets update [--check]")
}
cfg, err := config.LoadAll(strings.Split(configPath, ","))
@@ -54,14 +49,14 @@ func updateCmd(configPath string, args []string) error {
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
}
if !update.Newer(current, r.Tag) {
if strings.HasPrefix(current, "dev") {
if !update.Newer(current, "v999.0.0") {
return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag)
}
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil
}
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
if *check {
if check {
fmt.Println("lazysecrets update installs it.")
return nil
}
+5 -4
View File
@@ -3,6 +3,7 @@ package main
import (
"regexp"
"runtime/debug"
"strings"
)
// version is set by release builds:
@@ -10,13 +11,13 @@ import (
// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets
var version string
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
// maybe +dirty): its commit.
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
func versionString() string {
if version != "" {
return version
@@ -30,7 +31,7 @@ func versionString() string {
// go install …@<commit>.
if m := pseudo.FindStringSubmatch(v); m != nil {
dirty := ""
if m[2] != "" {
if strings.HasSuffix(v, "+dirty") {
dirty = "-dirty"
}
return "dev-" + m[1][:7] + dirty
Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 232 KiB

+4 -3
View File
@@ -16,7 +16,7 @@ A service principal is used instead when `AZURE_CLIENT_ID`,
On an account's group row, the fourth pane shows who it signs in as: name,
user, tenant, object ID, whether the session used MFA, directory roles
(built-in ones by name) and its subscriptions, read from az's account list
and the token lazysecrets already has. `y` on
and the token lazysecrets already has: no requests of their own. `y` on
the row copies the object ID, the one access policies and role
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
field, `/` filters them (`/tenant`) and `y` copies the selected one.
@@ -67,8 +67,9 @@ fetched. `v` lists an item's versions. Items expiring within
The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
on one goes to it. Expiry dates come with the lists. It says how many
vaults it covers; `ctrl+a` in search loads the rest.
on one goes to it. That makes no requests of its own: expiry dates come
with the lists. It says how many vaults it covers; `ctrl+a` in search
loads the rest.
On a vault, the right pane shows its settings: resource group, location,
SKU, whether access is by Azure roles or access policies, soft delete and
+41 -29
View File
@@ -1,24 +1,22 @@
# Config
Optional, at `~/.config/lazysecrets/config.yaml` (or
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags (noted
below) override it. Unknown keys are an error, so typos don't go
unnoticed. `e` on the status pane (`0`) opens it in your editor, as in
lazygit (making it if it isn't there yet); changes apply when lazysecrets
starts again.
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the
status pane (`0`) opens it in your editor, as in lazygit (making it if
it isn't there yet); changes apply when lazysecrets starts again.
```yaml
ui:
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
recentIcon: false # mark items used lately (Recent, R) with ◷, here and in lazysecrets list
sourcesView: grouped # or flat; ` toggles it
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d; ⚠ in lazysecrets list); expired ones get ✗
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
values:
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables; also --clipboard-clear
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
storage:
cache: true # keep what was loaded (never values) for the next start
history: true # count uses of items for Recent (R)
@@ -29,17 +27,34 @@ updates:
- https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets
providers:
timeout: 30s # per backend request; a provider's own timeout overrides it; also --timeout
azure: # likewise databricks and repos; azure covers every az account
timeout: 30s # per backend request; a provider's own timeout overrides it
azure: # applies to every az account
enabled: true # default
timeout: 30s
hideSubscriptions: [platform-mgmt] # by name or ID
vaults: # listed under "Pinned vaults", for vaults
- https://kv-foo.vault.azure.net/ # you can read but not discover
databricks:
enabled: true
timeout: 30s
hideProfiles: [test]
repos:
enabled: true
timeout: 30s
roots: [~/git]
paths: [~/work/infra]
include: ["secrets/**"]
exclude: ["**/testdata/**"]
sharing: # sharing env files as encrypted links
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
keyRules: # what a key's value must be
minLength: 32 # characters, 16 at least
minClasses: 2 # kinds: lowercase, uppercase, digits, symbols
```
More, each on its own page: [Azure](azure.md#vaults-you-cant-discover)
(`providers.azure`), [Databricks](databricks.md) (`providers.databricks`),
[repos](repos.md#which-files) (`providers.repos`),
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
More, each on its own page: [repos](repos.md) (`providers.repos`),
[sharing env files](links.md#sharing-env-files) (`sharing`), the
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`).
@@ -74,9 +89,6 @@ theme:
azureColor: ["#82aaff"]
databricksColor: ["#ff757f"]
repoColor: ["#c099ff"]
springColor: ["#c3e88d"] # Spring Boot config files
composeColor: ["#82aaff"] # Docker Compose files
dotnetColor: ["#c099ff"] # .NET appsettings files
```
A role lazysecrets doesn't know, a typo or one from a newer version, is
@@ -89,8 +101,9 @@ as boxes, turn them off with `ui.icons: false`.
Several config files can be layered, later ones winning (theme roles and
keybindings merge one by one), with a comma-separated `--config` or
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your
config plus an editor-generated theme:
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor
plugin can so add a generated theme file to your own config, the way
snacks.nvim themes lazygit from Neovim's highlight groups:
```sh
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
@@ -103,10 +116,11 @@ Stars, notes, usage and the layout are kept in
by you: one record per source, vault or secret, keyed by its
`lazysecrets://` link, never with values. A star belongs to the account you
starred the item under (a vault two of your accounts can see can be starred
for one); a note describes the item, so it shows under every account.
Several lazysecrets windows can save to it without losing each other's
changes. One written by a newer lazysecrets is read but not saved to,
with a warning, until you update.
for one); a note describes the item, so it shows under every account. It's
JSON so an editor plugin can read it with a built-in parser; several
lazysecrets windows can save to it without losing each other's changes.
One written by a newer lazysecrets is read but not saved to, with a
warning, until you update.
Revealing, copying, opening in the browser, sharing and jumping to an item
count as using it: its record keeps when it was last used and how often,
@@ -126,8 +140,7 @@ you: the Sources lists, vaults and scopes, and secrets' names and metadata
once, and anything older than two minutes reloads in the background when
you go to it, or when Go to searches it. While it does, the pane's
bottom border says how old what it shows is (`cached 3h ago`). Revealing
and copying always fetch the value live. [`lazysecrets list`](integration.md#listing-for-fzf-and-pickers)
reads it and adds to it too.
and copying always fetch the value live.
Lists not loaded for 30 days are dropped, and so is everything from an
account you're no longer signed in to. Repos aren't cached: their files
@@ -142,9 +155,8 @@ until then it reuses the answer from the state file. Releases are
published on GitHub and on git.kebcloud.no, and it asks them in that
order, the next when one doesn't answer within 5 seconds;
`updates.sources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the status
pane, `↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what
changed since your version, across the last 10 releases.
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against
+7 -9
View File
@@ -12,7 +12,7 @@ repo, pick `kv-shared-prod / env-share` (the second account's) as the
key, then paste the link back. The demo repos aren't on disk, so opening
asks where to save, as for a repo you haven't cloned.
A stripped binary is a third smaller:
A stripped binary is about 21 MB instead of 30 MB:
```sh
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
@@ -35,8 +35,10 @@ subject, checked by `pr-title.yml`:
| `fix` | a bug fix | Fixed |
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no |
`feat!:` marks a breaking change, `feat(scope):` names a part.
`[skip changelog]` in the description leaves a pull request out.
`feat!:` marks a breaking change, `feat(scope):` names a part. Brackets
and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all
become `feat: x` in the changelog. `[skip changelog]` in the description
leaves a pull request out.
CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each
platform on pull requests into `main`. `main` is protected: a pull request
@@ -49,14 +51,10 @@ what its pull request tested, and CI doesn't run again there.
Merge as many pull requests as you like, then release `main`:
```sh
scripts/release.sh --dry-run minor # the tag and notes the release would have
scripts/release.sh minor # CHANGELOG.md, commit, tag, push
scripts/release.sh --dry-run v0.2.0 # the notes the release would have
scripts/release.sh v0.2.0 # CHANGELOG.md, commit, tag, push
```
`patch`, `minor` and `major` bump the latest release (`v0.2.1` → `v0.2.2`,
`v0.3.0`, `v1.0.0`; pre-releases don't count); a tag (`v0.3.0`,
`v0.3.0-rc.1`) is taken as it is.
It refuses unless you're on a clean `main` that matches `origin/main`.
[git-cliff](https://git-cliff.org) (`cliff.toml`) writes `CHANGELOG.md`
from the pull request titles. The tag runs the release workflow
-95
View File
@@ -1,95 +0,0 @@
# Integration
lazysecrets works outside its own window too: `lazysecrets get` reads a
value for scripts, and `lazysecrets list` lists everything for fzf, editor
pickers and your own tools. Both take the [links](sharing.md) lazysecrets
copies and shares. For Neovim, there's
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
## Reading a value
`lazysecrets get <link>` prints the value, for scripts and pipes:
```sh
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
```
It reads the value directly, with your az accounts (the one that last read
it first), the Databricks profile for the workspace, or sops in your clone
of the repo; a version in the link reads that version. `--account` tries
that account first, as [`list`](#listing-for-fzf-and-pickers) prints it.
The value ends with a newline only on a terminal. When it can't read the
value, it says why on stderr and exits with status 1.
## Listing, for fzf and pickers
`lazysecrets list` prints every source, vault and secret, one per line,
tab-separated:
| Field | |
|---|---|
| kind | `source`, `vault`, `secret`, `key` or `certificate` |
| mark | ✗ when something's wrong (expired, or couldn't be read) or ⚠ when it expires soon; ★ when starred; ◷ when used lately (with `--recent`); and the provider's icon |
| name | `platform-prod / kv-app-prod / db-password` |
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
| account | what it's listed through: `Azure` (`Azure:<user>/<tenant>` with several az accounts), `Databricks` or `Repos` |
| link | its `lazysecrets://` link |
What's cached prints at once, then what loads follows as it arrives: read
it as a stream. Values are never listed. It stops quietly when the reader
goes, like fzf after a pick. Kind stays the first field, and account and
link the last two; new fields only ever go between. The mark has a field of
its own so a search can leave it out: in fzf, `--nth` searches the name,
so `^prod` finds names starting with prod.
| Flag | |
|---|---|
| `--format json` | an object per line instead, for editor plugins (below) |
| `--kind secret,vault` | only these kinds: `source`, `vault` (also scopes and repo files), `secret`, `key`, `certificate`; lists they don't need aren't loaded |
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read |
| `--hidden` | list what's [hidden](repos.md#spring-boot-config) too, as `H` shows it (json: `"hidden": true`) |
| `--color never` | no colours, for scripts |
| `--icons=false` | no provider icons |
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
| `--notes=false` | no note icon before a note |
| `--recent` | ◷ before names used lately, in `mutedColor`; default `ui.recentIcon` |
| `--expiry=false` | no expiry: not in the status, and no ✗ or ⚠ for it |
With `--format json`, each line is an object with these fields; new ones
may be added:
| Field | |
|---|---|
| `link`, `kind`, `account` | as above |
| `error` | why it couldn't be read, only then |
| `provider` | `azure`, `databricks` or `repo` |
| `path` | the names: `["platform-prod", "kv-app-prod", "db-password"]` |
| `accountLabel` | the account's short name, when several az accounts are signed in |
| `starred` | starred for this account |
| `note` | your note on it, if any |
| `reference` | how config refers to a secret: `@Microsoft.KeyVault(SecretUri=…)` or `{{secrets/scope/key}}` (`Y` in lazysecrets) |
| `url` | its web page, in the Azure portal or the Databricks workspace (`o` in lazysecrets) |
| `recent` | used lately, as Recent (`R`) lists: in the last 90 days |
| `lastUsed`, `uses` | when it was last used, and how often; only when `recent`, to sort by |
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
[`lazysecrets open --action go --account <account> <link>`](sharing.md#opening-a-link)
opens one in lazysecrets without asking what to do or which account.
### An fzf picker
```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](assets/fzf-example.png)
+21 -19
View File
@@ -20,7 +20,6 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
| `H` | show / hide what's hidden: [config file keys](repos.md#spring-boot-config) (Spring, compose, .NET) that don't look secret, example files, and files and repos with only those; shown dimmed |
| `r` | refresh the focused pane |
| `q`, `ctrl+c` | quit |
@@ -50,12 +49,12 @@ match case exactly.
| Key | Action |
|---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) |
| `y` | copy the value without revealing it. On an Azure account's row: its object ID; in its pane, the selected field |
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) |
| `e` | open the selected SOPS, `.env`, Spring, compose or .NET file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-files)) |
| `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) |
| `L` | sign in again (shown when a login has expired) |
@@ -64,8 +63,10 @@ match case exactly.
A revealed value is shown as it reads best: JSON indented, and PEM
certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored. It hides again after a while, and when the terminal
loses focus ([config](config.md)).
it exactly as stored.
A revealed value hides again after `values.revealTimeout` (60s) and
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
## Stars and notes
@@ -74,7 +75,8 @@ loses focus ([config](config.md)).
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
| `n` | write a short note on it, shown dimmed on its row and in the detail pane |
Both are kept in the [state file](config.md#state).
A star belongs to the account you starred the item under; a note shows
under every account. Both are kept in the [state file](config.md#state).
## Go to
@@ -122,16 +124,15 @@ it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how
your accounts are doing; `e` there opens your [config](config.md) in your
editor, as in lazygit. Focused, the right pane lists more under the
logo; `y` copies any row:
- the version and updates (`U`)
- each account (`L` on one that needs it signs in)
- what's expiring (`enter` goes to it)
- this session's errors, newest first (not kept after quitting)
- the config, state and cache files (`e` opens one in your editor)
- links to the docs, source, issues, releases and changelog (`o` opens one)
- the changelog of every release up to yours, built in: `/` filters it
(`/fixed`, `/search`)
logo (when there's room): the version and updates (`U`), each account
(`L` on one that needs it signs in), what's expiring (`enter` goes to
it), this session's errors, newest first (what a pane showed, or the
footer flashed before you moved on; not kept after quitting), the config,
state and cache files (`e` opens one in your editor), and links to the
docs, source, issues, releases and changelog (`o` opens one; on the
version or update, its release), then the changelog of every release up
to yours, built in so it needs no network: `/` filters it (`/fixed`,
`/search`). `y` copies any row.
| Key | Action |
|---|---|
@@ -140,8 +141,9 @@ logo; `y` copies any row:
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
| `=` | reset the layout (divider, heights, zoom) |
The divider and pane heights are remembered (zoom isn't). `<>{}` are for
macOS, where ctrl+arrows switch desktops.
The divider and pane heights are remembered (zoom isn't). macOS uses
ctrl+arrows for Mission Control and switching desktops by default, hence
the `<` `>` `{` `}` alternatives.
## Rebinding keys
+24 -18
View File
@@ -1,24 +1,21 @@
# Sharing
# Links
Subscriptions, workspaces, vaults, scopes, repos, files and secrets are
shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
it lands on that item, reading it with their own account and
permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself.
```
lazysecrets://azure/subscriptions/<subscription-id> subscription
lazysecrets://azure/kv-app-prod.vault.azure.net vault
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net workspace
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app scope
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
## Sharing a link
## Sharing
`s` copies a link to the selected item (from the versions view, pinned to
that version). `S` opens the share menu:
@@ -102,11 +99,11 @@ the value breaks every old link. Whoever has read the secret's value
before can decrypt links made with it without asking the vault, so rotate
it now and then: new links use the newest version.
## Opening a link
## Opening
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
and fills in any reference it finds there. Key Vault URLs, Azure portal
links, `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
and fills in any reference it finds there. Key Vault URLs,
`@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
set up.
@@ -124,10 +121,20 @@ account) and jumps to it, asking which account to use when several can see
it, ending on a secret's details as `enter` on it would. Links say what,
not what to do: opening a link to a secret asks whether to just go to it,
also copy or show its value, or (`y`) copy its value without going there.
`lazysecrets open --action go|copy|show <link>` does one without asking
and ends in the list, like Go to,
and `--account <account>` picks the account (as [`list`](integration.md#listing-for-fzf-and-pickers)
prints it) when several can see it.
## In scripts
`lazysecrets get <link>` prints the value, for scripts and pipes:
```sh
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
```
It reads the value directly, with your az accounts (the one that last read
it first), the Databricks profile for the workspace, or sops in your clone
of the repo; a version in the link reads that version. The value ends with
a newline only on a terminal.
## From the browser and chat
@@ -146,10 +153,9 @@ turns the question off too.
link then opens a new window of the terminal you ran it in, with
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
tmux; `--terminal NAME` picks another. It records your `PATH`, config
locations and XDG directories, since apps start without your shell's
environment (and so without `az`): run it again after moving lazysecrets
or changing those.
tmux; `--terminal NAME` picks another. It records your `PATH` and config
locations, since apps start without your shell's environment (and so
without `az`): run it again after moving lazysecrets or changing those.
- macOS: a small background app, `~/Applications/Lazysecrets Links.app`
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
+22 -113
View File
@@ -1,10 +1,9 @@
# SOPS, .env, Spring, compose and .NET files in git repos
# SOPS and .env files in git repos
lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI), plain env files, [Spring Boot config](#spring-boot-config)
[Docker Compose files](#docker-compose) and [.NET appsettings](#net-appsettings)
as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
`.env`, INI) and plain env files as vaults, and the keys in them
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
escaped, `app\.name`, so it doesn't read as nesting.
## Which repos
@@ -30,9 +29,8 @@ anything by hand.
## Which files
**Env files**: `.env`, `.env.local`, `prod.env` and the like, also when
git-ignored (as they usually are), in the folders git tracks files in.
`.env.example`, `.sample`, `.template` and the like are listed hidden
(`H` shows them): someone may have put a real value in one. They're read
git-ignored (as they usually are), in the folders git tracks files in;
not `.env.example`, `.sample`, `.template` and the like. They're read
directly; a file with SOPS metadata is always treated as SOPS, whatever
its name.
@@ -48,7 +46,7 @@ Folders of dependencies and caches are never looked in, tracked or not:
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
4. the whole repo.
`include`/`exclude` apply to env, Spring, compose and .NET files too.
`include`/`exclude` apply to env files too.
```yaml
providers:
@@ -60,99 +58,20 @@ providers:
```
A repo's `.lazysecrets.yaml` can also name the secret its env files are
[shared](sharing.md#sharing-env-files) with:
[shared](links.md#sharing-env-files) with:
```yaml
sharing:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
```
## Spring Boot config
`application.yml`, `application-prod.properties`, `bootstrap.yaml` and the
like, tracked by git (not those in a Helm chart's `templates/`). Most of
their keys aren't secret, so only those that look like one are listed; the
rest are hidden:
- **keys** named like a secret: ending in `password`, `pwd`, `secret`,
`token`, `key`, `credentials`, `salt`, `connection-string` and the
like (`spring.datasource.password`, `client-secret`, `checkoutKey`),
- **values** that look like one: Jasypt's `ENC(...)`, a URL with a
password (`jdbc:postgresql://app:pw@db/app`), or a connection string
with `AccountKey=`, `SharedAccessKey=` or `Password=`.
A file with none is hidden too, and a repo with only such files. `H` shows
what's hidden, dimmed, in the panes and in Go to (`lazysecrets list
--hidden` lists it); a pane's counter says how many it hides. A file's
details count its hidden keys, and `e` opens it whole. A `${DB_PASSWORD}`
value is marked as a reference: the secret is set elsewhere. A tracked
file is marked in red only when a listed key has its value in plain text.
The patterns are regexps, for all of a repo's config files and per type of
file; each list replaces the one it falls back to, and `keys: [".*"]`
lists every key:
```yaml
providers:
repos:
secretPatterns: # all config files
keys: ["(?i)(password|secret|token|key)$"] # matched against the key's name
values: ["^ENC\\(.+\\)$"] # and against its value
spring:
secretPatterns: # Spring's: only what differs
keys: ["(?i)(password|secret)$", "bearer$"]
compose:
secretPatterns: # and compose's
values: []
```
A repo's `.lazysecrets.yaml` can set them too, the same way
(`secretPatterns:`, `spring:`, `compose:` or `dotnet: secretPatterns:`),
for everyone. Each list
comes from the first that sets it: the repo's for the type, the repo's,
the config's for the type, the config's, the defaults.
Keys are named as Spring names them, `spring.datasource.password`; in a
file with several documents (`---`, or `#---` in `.properties`), a later
document's keys get its profile, `spring.datasource.password (prod)`.
Values Maven fills in at build time (`@project.version@`) are read as
they are.
## Docker Compose
`compose.yaml`, `docker-compose.yml` and their variants
(`docker-compose.test.yml`), tracked by git; `docker-compose.example.yml`
and the like are hidden. Their secrets are the services' `environment`
variables, as a map (`DB_PASSWORD: x`) or a list (`- DB_PASSWORD=x`),
named `service.VAR` (`api.DB_PASSWORD`); a `<<: *anchor` shared between
services is read too. The same [secret patterns](#spring-boot-config)
pick them, the rest hidden, with `compose: secretPatterns:` for compose
files' own.
`${DB_PASSWORD}`, `$DB_PASSWORD` and `${DB_PASSWORD:?…}`, and a variable
without a value (`DB_PASSWORD:`, `- DB_PASSWORD`), taken from where
compose runs, are marked as references; `${DB_PASSWORD:-x}` has its
default in the file.
## .NET appsettings
`appsettings.json` and `appsettings.<Environment>.json`, tracked by git.
Keys are named as .NET's configuration names them, sections joined by
colons: `ConnectionStrings:Default`, `Servers:0:Name`. The same
[secret patterns](#spring-boot-config) pick them, the rest hidden, with
`dotnet: secretPatterns:` for .NET files' own; a connection string with
`Password=` is listed by its value. An App Service Key Vault reference,
`@Microsoft.KeyVault(…)`, is marked as a reference. Jasypt values decrypt
with `$JASYPT_ENCRYPTOR_PASSWORD` only.
## Decrypting
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.
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.
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
@@ -161,24 +80,6 @@ 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`, Spring, compose or .NET 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, the
service's `JASYPT_ENCRYPTOR_PASSWORD` in a compose file,
`JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` in it (with a
default: `${VAR:default}` in Spring, `${VAR:-default}` elsewhere) 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:
@@ -199,5 +100,13 @@ sops 3.8 or later.
## Links
Repo links name the repo by its git remote, so they open in a colleague's
clone wherever it is ([sharing](sharing.md)).
`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files).
Links name the repo by its git remote, so they open in a colleague's clone
wherever it is:
```
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
See [links](links.md).
-172
View File
@@ -1,172 +0,0 @@
// Package cachefile is the cache of what lazysecrets has loaded
// (cache.json): the Sources lists, vaults, secrets' metadata and where
// the cursor was in each, never values. The TUI and lazysecrets list
// both read it and save to it; a save merges with what's on disk, so
// neither undoes what the other loaded.
package cachefile
import (
"encoding/json"
"maps"
"os"
"path/filepath"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Version is the file's format; a file of another version is ignored,
// and replaced on the next save.
const Version = 1
// MaxAge drops what hasn't been loaded again for this long.
const MaxAge = 30 * 24 * time.Hour
// File is the cache. Every key starts with the provider ID: Sources are
// keyed by it, the rest by Row, Objects' keys and the like.
type File struct {
Version int `json:"version"`
Sources map[string]Stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
Containers map[string]Stamped[[]provider.Container] `json:"containers,omitempty"`
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"`
// Dropped are keys deleted since the file was read (a list that
// failed to load): Save deletes them from the file too, rather than
// merging the old entry back. Not saved.
Dropped map[string]bool `json:"-"`
}
// Stamped is a list or detail with when it was loaded.
type Stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
// New is an empty cache.
func New() File {
return File{
Version: Version,
Sources: map[string]Stamped[[]provider.Source]{},
Containers: map[string]Stamped[[]provider.Container]{},
Objects: map[string]Stamped[[]provider.Object]{},
Details: map[string]Stamped[provider.Object]{},
Cursor: map[string]string{},
}
}
// Row keys a source's vaults or scopes.
func Row(providerID, sourceID string) string { return providerID + "|" + sourceID }
// ContainerKey keys what's cached about a vault or scope.
func ContainerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
// ObjectsKey keys a vault's secrets, keys or certificates.
func ObjectsKey(c provider.Container, t provider.ObjectType) string {
return ContainerKey(c) + "|" + string(t)
}
// ProviderOf is the provider ID a key starts with.
func ProviderOf(key string) string {
id, _, _ := strings.Cut(key, "|")
return id
}
// Keep is Save's keep for these providers: their lists are kept, but not
// repos', whose files are read from disk anyway, nor other accounts'.
func Keep(providers []provider.Provider) func(providerID string) bool {
keep := map[string]bool{}
for _, p := range providers {
keep[p.ID()] = p.Kind() != provider.KindRepo
}
return func(id string) bool { return keep[id] }
}
// Read reads path; a missing, unreadable or other version's file is an
// empty cache.
func Read(path string) File {
f := New()
b, err := os.ReadFile(path)
if err != nil {
return f
}
var d File
if json.Unmarshal(b, &d) != nil || d.Version != Version {
return f
}
maps.Copy(f.Sources, d.Sources)
maps.Copy(f.Containers, d.Containers)
maps.Copy(f.Objects, d.Objects)
maps.Copy(f.Details, d.Details)
maps.Copy(f.Cursor, d.Cursor)
return f
}
// Save writes f to path, merged with the file there: of a list in both,
// the one loaded last wins, and f's cursors win (a nil Cursor keeps the
// file's). Entries older than MaxAge, those of providers keep says no to
// (accounts signed out of), and f.Dropped are dropped, from the file's
// side as well. Saves from several lazysecrets take turns (a lock file),
// so none undoes another's.
func Save(path string, f File, keep func(providerID string) bool, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
unlock, err := lock(path + ".lock")
if err != nil {
return err
}
defer unlock()
out := New()
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
disk := Read(path)
merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped)
merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped)
merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped)
merge(out.Details, disk.Details, f.Details, ok, f.Dropped)
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
for k, v := range m {
if keep(ProviderOf(k)) {
out.Cursor[k] = v
}
}
}
return write(path, out)
}
// merge puts disk's entries in out, but not those dropped, then ours
// where they're newer.
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool, dropped map[string]bool) {
for k, e := range disk {
if ok(k, e.At) && !dropped[k] {
out[k] = e
}
}
for k, e := range ours {
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
out[k] = e
}
}
}
// write writes f atomically, readable by the user only.
func write(path string, f File) error {
b, err := json.Marshal(f)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
-122
View File
@@ -1,122 +0,0 @@
package cachefile
import (
"path/filepath"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func list(at time.Time, names ...string) Stamped[[]provider.Container] {
var cs []provider.Container
for _, n := range names {
cs = append(cs, provider.Container{Name: n})
}
return Stamped[[]provider.Container]{At: at, Value: cs}
}
func TestSaveMerges(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
all := func(string) bool { return true }
// The TUI saved two lists.
tui := New()
tui.Containers["az|a"] = list(now.Add(-time.Hour), "kv-a-old")
tui.Containers["az|b"] = list(now.Add(-time.Minute), "kv-b")
tui.Cursor["az|b"] = "kv-b"
if err := Save(path, tui, all, now); err != nil {
t.Fatal(err)
}
// lazysecrets list loads a again, and c.
listed := Read(path)
listed.Containers["az|a"] = list(now, "kv-a-new")
listed.Containers["az|c"] = list(now, "kv-c")
if err := Save(path, listed, all, now); err != nil {
t.Fatal(err)
}
// The TUI saves what it has, older: list's lists stay.
if err := Save(path, tui, all, now); err != nil {
t.Fatal(err)
}
f := Read(path)
for key, want := range map[string]string{"az|a": "kv-a-new", "az|b": "kv-b", "az|c": "kv-c"} {
if got := f.Containers[key].Value; len(got) != 1 || got[0].Name != want {
t.Errorf("%s = %v, want %s", key, got, want)
}
}
if f.Cursor["az|b"] != "kv-b" {
t.Error("cursor lost")
}
}
func TestSaveDrops(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
f := New()
f.Containers["az|old"] = list(now.Add(-MaxAge-time.Hour), "kv-old")
f.Containers["gone|x"] = list(now, "kv-gone")
f.Containers["az|x"] = list(now, "kv-x")
if err := Save(path, f, func(id string) bool { return true }, now); err != nil {
t.Fatal(err)
}
// Signed out of "gone" since: dropped from the file too.
if err := Save(path, New(), func(id string) bool { return id == "az" }, now); err != nil {
t.Fatal(err)
}
got := Read(path).Containers
if _, ok := got["az|old"]; ok {
t.Error("kept a list older than MaxAge")
}
if _, ok := got["gone|x"]; ok {
t.Error("kept a signed-out account's list")
}
if _, ok := got["az|x"]; !ok {
t.Error("dropped a list on disk the save didn't have")
}
}
func TestReadOtherVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
f := New()
f.Version = Version + 1
f.Containers["az|x"] = list(time.Now(), "kv-x")
if err := write(path, f); err != nil {
t.Fatal(err)
}
if got := Read(path); len(got.Containers) != 0 || got.Version != Version {
t.Errorf("read another version's file: %+v", got)
}
}
func TestSaveDroppedAndCursors(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
all := func(string) bool { return true }
f := New()
f.Containers["az|bad"] = list(now, "kv-old")
f.Cursor["az|x"] = "kv-tui"
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
// The TUI fails to load it again and drops it: it stays gone.
f = New()
f.Dropped = map[string]bool{"az|bad": true}
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
if _, ok := Read(path).Containers["az|bad"]; ok {
t.Error("a dropped list came back")
}
// lazysecrets list saves without cursors: the TUI's stay.
listed := New()
listed.Cursor = nil
listed.Containers["az|y"] = list(now, "kv-y")
if err := Save(path, listed, all, now); err != nil {
t.Fatal(err)
}
if Read(path).Cursor["az|x"] != "kv-tui" {
t.Error("the TUI's cursor was lost")
}
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !unix
package cachefile
// lock does nothing here: saves can then race, losing lists that load
// again.
func lock(string) (func(), error) { return func() {}, nil }
-25
View File
@@ -1,25 +0,0 @@
//go:build unix
package cachefile
import (
"os"
"syscall"
)
// lock takes path as a lock file, waiting while another lazysecrets has
// it, and returns how to let go.
func lock(path string) (unlock func(), err error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
f.Close()
return nil, err
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
+3
View File
@@ -42,6 +42,9 @@ 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.
func (c Clipboard) Write(s string) tea.Cmd {
if !c.native {
+4 -2
View File
@@ -16,7 +16,6 @@
package commands
import (
"cmp"
"fmt"
"regexp"
"strings"
@@ -61,7 +60,10 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config.
func (c Command) Validate() error {
name := cmp.Or(c.Description, c.Command)
name := c.Description
if name == "" {
name = c.Command
}
switch {
case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description)
+11 -22
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"strings"
@@ -15,7 +14,6 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
@@ -46,11 +44,7 @@ type Config struct {
type UI struct {
Icons *bool `yaml:"icons"`
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
// and in lazysecrets list. Defaults to false.
RecentIcon *bool `yaml:"recentIcon"`
// SourcesView is "grouped" (default) or "flat"; the backtick key
// toggles it.
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
@@ -154,12 +148,6 @@ type Repos struct {
// a repo's .sops.yaml creation_rules say where to look.
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
// SecretPatterns say which keys of config files are secrets; Spring,
// Compose and Dotnet override them for their files, list by list.
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
Spring repo.FileType `yaml:"spring"`
Compose repo.FileType `yaml:"compose"`
Dotnet repo.FileType `yaml:"dotnet"`
}
type Azure struct {
@@ -212,12 +200,17 @@ func xdgDir(env, fallback string) (string, error) {
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
// 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
// plugin add a generated theme file to the user's own config. Missing
// files are skipped. Unknown keys are an error, so typos don't silently do
// nothing.
// files are skipped.
func LoadAll(paths []string) (Config, error) {
var c Config
keybinding := map[string]map[string][]string{}
@@ -232,7 +225,9 @@ func LoadAll(paths []string) (Config, error) {
if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{}
}
maps.Copy(keybinding[ctx], actions)
for name, keys := range actions {
keybinding[ctx][name] = keys
}
}
}
c.Keybinding = keybinding
@@ -299,12 +294,6 @@ func (c Config) validate(path string) error {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
}
}
if err := errors.Join(c.Providers.Repos.SecretPatterns.Check("providers.repos.secretPatterns"),
c.Providers.Repos.Spring.SecretPatterns.Check("providers.repos.spring.secretPatterns"),
c.Providers.Repos.Compose.SecretPatterns.Check("providers.repos.compose.secretPatterns"),
c.Providers.Repos.Dotnet.SecretPatterns.Check("providers.repos.dotnet.secretPatterns")); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
+7 -9
View File
@@ -18,7 +18,7 @@ func write(t *testing.T, content string) string {
}
func TestLoad(t *testing.T) {
c, err := LoadAll([]string{write(t, `
c, err := Load(write(t, `
ui:
icons: false
sourcesView: flat
@@ -36,7 +36,7 @@ providers:
databricks:
enabled: false
hideProfiles: [test]
`)})
`))
if err != nil {
t.Fatal(err)
}
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""),
} {
c, err := LoadAll([]string{path})
c, err := Load(path)
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err)
}
@@ -84,10 +84,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
"regexp": "providers:\n repos:\n secretPatterns:\n keys: [\"(pass\"]\n",
"spring re": "providers:\n repos:\n spring:\n secretPatterns:\n values: [\"(\"]\n",
} {
if _, err := LoadAll([]string{write(t, content)}); err == nil {
if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err)
@@ -134,18 +132,18 @@ theme:
}
func TestUpdates(t *testing.T) {
c, err := LoadAll([]string{write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")})
c, err := Load(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 {
t.Errorf("updates = %+v, %v", c.Updates, err)
}
}
func TestLoadDelay(t *testing.T) {
c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
}
if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted")
}
}
-41
View File
@@ -1,41 +0,0 @@
// Package expiry says whether a secret, key or certificate has expired
// or will soon, and how long it has left, the same way everywhere: the
// TUI's lists and status pane, and lazysecrets list.
package expiry
import (
"fmt"
"time"
)
type State int
const (
None State = iota // no expiry, or not within the warning period
Soon // within the warning period
Expired // already
)
// Of tells whether something expiring at expires (zero: never) has
// expired by now or will within warn, and how long it has left.
func Of(expires, now time.Time, warn time.Duration) (State, time.Duration) {
if expires.IsZero() {
return None, 0
}
left := expires.Sub(now)
switch {
case left <= 0:
return Expired, 0
case left <= warn:
return Soon, left
}
return None, left
}
// Left is a short "12d", or "5h" on the last day.
func Left(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
-27
View File
@@ -1,27 +0,0 @@
package expiry
import (
"testing"
"time"
)
func TestOf(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
warn := 30 * 24 * time.Hour
for _, c := range []struct {
expires time.Time
want State
left string
}{
{time.Time{}, None, ""},
{now.Add(-time.Hour), Expired, ""},
{now.Add(12*24*time.Hour + time.Hour), Soon, "12d"},
{now.Add(5 * time.Hour), Soon, "5h"},
{now.Add(60 * 24 * time.Hour), None, ""},
} {
state, left := Of(c.expires, now, warn)
if state != c.want || (c.left != "" && Left(left) != c.left) {
t.Errorf("%v: %v %s, want %v %s", c.expires, state, Left(left), c.want, c.left)
}
}
}
+7 -9
View File
@@ -9,6 +9,7 @@
package instance
import (
"bufio"
"context"
"encoding/json"
"errors"
@@ -35,18 +36,15 @@ func Dir(stateDir string) string {
type Status struct {
PID int `json:"pid"`
LastActive time.Time `json:"lastActive"` // last key press or focus
Reach int `json:"reach"` // how readily it can be shown: unreachable, switchable or inView
// Reach is how readily it can be shown: 2 in view (outside tmux, or
// in a tmux session a client shows), 1 in a tmux session that a tmux
// client can be switched to, 0 not at all.
Reach int `json:"reach"`
// App is the terminal app it would show in (macOS), preferring the
// app the link was clicked in.
App string `json:"app,omitempty"`
}
const (
unreachable = 0
switchable = 1 // in a tmux session that a tmux client can be switched to
inView = 2 // outside tmux, or in a tmux session a client shows
)
type request struct {
Op string `json:"op"` // "status" or "open"
Link string `json:"link,omitempty"`
@@ -116,7 +114,7 @@ func (s *Server) handle(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request
if err := json.NewDecoder(c).Decode(&req); err != nil {
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
return
}
var resp response
@@ -190,7 +188,7 @@ func Best(found []Found, front string, terminal bool) (Found, bool) {
best = f
}
}
return best, best.Reach > unreachable
return best, best.Reach > 0
}
// Open hands link, clicked in front, to f.
+23 -22
View File
@@ -1,7 +1,6 @@
package instance
import (
"cmp"
"os"
"os/exec"
"slices"
@@ -26,8 +25,7 @@ func tmux(args ...string) (string, error) {
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
type client struct {
name, session string
activity int // Unix seconds
name, session, activity string // activity: Unix seconds
pid int
app string
}
@@ -44,31 +42,30 @@ func clients() []client {
if len(f) < 4 {
continue
}
activity, err1 := strconv.Atoi(f[0])
pid, err2 := strconv.Atoi(f[1])
if err1 != nil || err2 != nil {
pid, err := strconv.Atoi(f[1])
if err != nil {
continue
}
cs = append(cs, client{name: f[2], session: f[3], activity: activity, pid: pid, app: linkhandler.HostApp(pid)})
cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)})
}
slices.SortStableFunc(cs, func(a, b client) int { return cmp.Compare(b.activity, a.activity) })
// Activity has the same width for centuries: strings compare like
// numbers.
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
return cs
}
// place is where this instance would show: the tmux client (zero outside
// tmux), the app, and its reach.
// tmux), the app, and reach: 2 already on screen there (outside tmux, or
// a client on its session), 1 after switching a client, 0 nowhere.
func place(front string) (client, string, int) {
if !inTmux() {
return client{}, linkhandler.HostApp(os.Getpid()), inView
return client{}, linkhandler.HostApp(os.Getpid()), 2
}
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
if err != nil {
return client{}, "", unreachable
return client{}, "", 0
}
cs := clients()
if len(cs) == 0 {
return client{}, "", unreachable
}
// Best first: in the front app on its session, in the front app,
// on its session, any.
rank := func(c client) int {
@@ -81,16 +78,20 @@ func place(front string) (client, string, int) {
}
return r
}
best := cs[0]
for _, c := range cs[1:] {
if rank(c) > rank(best) {
best = c
var best client
found := false
for _, c := range cs {
if !found || rank(c) > rank(best) {
best, found = c, true
}
}
if !found {
return client{}, "", 0
}
if best.session == session {
return best, best.app, inView
return best, best.app, 2
}
return best, best.app, switchable
return best, best.app, 1
}
// Raise brings this instance into view for a link clicked in front: in
@@ -98,14 +99,14 @@ func place(front string) (client, string, int) {
// then its app to the front, showing a hidden dropdown terminal.
func Raise(front string) {
c, app, reach := place(front)
if reach == unreachable {
if reach == 0 {
return
}
if inTmux() {
pane := os.Getenv("TMUX_PANE")
_, _ = tmux("select-window", "-t", pane)
_, _ = tmux("select-pane", "-t", pane)
if reach == switchable {
if reach == 1 {
_, _ = tmux("switch-client", "-c", c.name, "-t", pane)
}
}
-67
View File
@@ -1,67 +0,0 @@
// 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
}
-37
View File
@@ -1,37 +0,0 @@
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)
}
}
}
+7 -3
View File
@@ -114,15 +114,19 @@ func Check() string {
}
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
if err != nil {
return "" // can't tell: assume it works
return "" // installed before the key was recorded: assume it works
}
exe := strings.TrimSpace(string(out))
if _, err := os.Stat(exe); err != nil {
if exe := strings.TrimSpace(string(out)); !exists(exe) {
return "lazysecrets:// links run " + exe + ", which no longer exists"
}
return ""
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration.
func Uninstall() (string, error) {
app, err := AppPath()
+4 -5
View File
@@ -140,10 +140,11 @@ func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
// Launch opens a new terminal window running argv, without waiting for
// Launch opens a new terminal window running inner, without waiting for
// it.
func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil {
return err
}
@@ -165,11 +166,9 @@ func Executable() (string, error) {
// passEnv lists the variables carried from the install's environment to
// lazysecrets in the new window: apps start with a minimal environment,
// without Homebrew's PATH (so no az) or the user's XDG directories, so
// the window would use another cache and miss running instances' sockets.
// without Homebrew's PATH (so no az) or the user's config locations.
var passEnv = []string{
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS",
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
}
-410
View File
@@ -1,410 +0,0 @@
// Package listing lists what lazysecrets can find, for lazysecrets list:
// what Go to searches, without the TUI. What's cached comes first, at
// once; then lists that are missing or older than MaxAge load in the
// background, a few at a time, and what's new in them follows as they
// arrive. Repos are always read, from disk. Values are never listed.
package listing
import (
"cmp"
"context"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// Workers is how many lists load at once, as in Go to.
const Workers = 4
// Kinds are the kinds of entry, from sources down.
var Kinds = []string{"source", "vault", "secret", "key", "certificate"}
// Entry is a source, vault or item, by its lazysecrets:// link.
type Entry struct {
Link string `json:"link"`
// Kind is source (a subscription, workspace or repo), vault (a Key
// Vault, secret scope or repo file), secret, key or certificate.
Kind string `json:"kind"`
// Provider is azure, databricks or repo, as in links.
Provider string `json:"provider"`
// Path names it: its source's, vault's and own names.
Path []string `json:"path"`
// Account is the provider it's listed through: an az account
// (Azure:<user>/<tenant>), Databricks or Repos. lazysecrets open and
// get take it (--account) to use that account when several could.
Account string `json:"account"`
// AccountLabel names the az account, when there are several.
AccountLabel string `json:"accountLabel,omitempty"`
// Starred is starred for this account; Recent is used lately (Recent,
// R, in the TUI): LastUsed is when, Uses how often.
Starred bool `json:"starred"`
Recent bool `json:"recent"`
LastUsed time.Time `json:"lastUsed,omitzero"`
Uses int `json:"uses,omitempty"`
// Expires is when a secret, key or certificate expires, if it does;
// Enabled whether it's enabled, where the provider says (Azure).
Expires time.Time `json:"expires,omitzero"`
Enabled *bool `json:"enabled,omitempty"`
// Note is the user's note on it, if any.
Note string `json:"note,omitempty"`
// Reference is how config refers to a secret, where the provider has
// a way (@Microsoft.KeyVault(...), {{secrets/scope/key}}); URL its
// web page (portal, workspace), if any.
Reference string `json:"reference,omitempty"`
URL string `json:"url,omitempty"`
// Hidden is left out by its provider's rules (a Spring file's key
// that doesn't look secret), listed only with Options.Hidden.
Hidden bool `json:"hidden,omitempty"`
// Error says why a source's vaults or a vault's secrets couldn't be
// listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link.
Error string `json:"error,omitempty"`
}
type Options struct {
Providers []provider.Provider
// Cache is what was loaded before; Run adds what it loads.
Cache cachefile.File
// MaxAge: cached lists loaded longer ago are loaded again.
MaxAge time.Duration
// Cached lists only what's cached (and repos): nothing is loaded.
Cached bool
// Hidden lists what providers hide too, as H does in the TUI.
Hidden bool
// Kinds are the kinds of entry to list (Kinds' names); none is all.
// Lists only needed for other kinds aren't loaded: sources and vaults
// alone don't load any secrets. Lists that fail are always listed.
Kinds []string
// Mark tells what's remembered about the entry with link, seen
// through account; nil marks nothing.
Mark func(link, account string) Marks
Timeout func(provider.Kind) time.Duration // nil: 30s
Now func() time.Time
}
// Marks are what's remembered about an entry: starred for the account,
// used lately (Recent) and when and how often, and its note.
type Marks struct {
Starred, Recent bool
LastUsed time.Time
Uses int
Note string
}
// Run lists everything, calling emit for each entry once, from this
// goroutine, and for each list that can't be loaded, as it fails. An
// error from emit (the reader went away) stops it. It returns the cache
// with what was loaded, and whether anything was.
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
results: make(chan result)}
if l.o.Now == nil {
l.o.Now = time.Now
}
if l.o.Timeout == nil {
l.o.Timeout = func(provider.Kind) time.Duration { return 30 * time.Second }
}
for _, p := range o.Providers {
if l.stopped != nil {
break
}
l.provider(p)
}
l.start(ctx)
for l.active > 0 {
r := <-l.results
l.active--
if l.stopped == nil {
l.handle(r)
l.start(ctx)
}
if l.stopped != nil {
cancel()
}
}
return l.o.Cache, l.loaded
}
type job struct {
p provider.Provider
src *provider.Source // list its vaults
c *provider.Container // list its secrets, keys or certificates (t)
t provider.ObjectType
key string // where the list is cached
}
type result struct {
job
sources []provider.Source
containers []provider.Container
objects []provider.Object
err error
}
type lister struct {
o Options
emit func(Entry) error
seen map[string]bool // provider ID + link of entries emitted
pending map[string]bool // cache keys queued or loading
failed map[string]bool // cache keys that couldn't be loaded: not again
loaded bool // something was cached
queue []job
active int
results chan result
stopped error // emit's error
}
func (l *lister) cacheable(p provider.Provider) bool { return p.Kind() != provider.KindRepo }
// load reports whether to load a list: a repo's always (from disk), else
// unless Cached, when it isn't cached or is older than MaxAge.
func (l *lister) load(p provider.Provider, cached bool, at time.Time) bool {
if !l.cacheable(p) {
return true
}
return !l.o.Cached && (!cached || l.o.Now().Sub(at) >= l.o.MaxAge)
}
func (l *lister) provider(p provider.Provider) {
e, ok := l.o.Cache.Sources[p.ID()]
if l.cacheable(p) && ok {
for _, src := range sorted(e.Value, func(s provider.Source) string { return s.Name }) {
l.source(p, src)
}
}
if l.load(p, ok, e.At) {
l.enqueue(job{p: p, key: p.ID()})
}
}
// source emits src, and what's cached under it, and queues what's not.
func (l *lister) source(p provider.Provider, src provider.Source) {
if src.Hidden && !l.o.Hidden {
return
}
src.Provider, src.ProviderLabel = p.ID(), p.Label()
l.put(p, sourceEntry(src))
row := cachefile.Row(p.ID(), src.ID)
e, ok := l.o.Cache.Containers[row]
if l.cacheable(p) && ok {
l.containers(p, src, e.Value)
}
if l.load(p, ok, e.At) {
if l.wants("vault", "secret", "key", "certificate") {
l.enqueue(job{p: p, src: &src, key: row})
}
}
}
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
if c.Hidden && !l.o.Hidden {
continue
}
c.Source = src
l.put(p, containerEntry(c))
for _, t := range c.ObjectTypes() {
key := cachefile.ObjectsKey(c, t)
e, ok := l.o.Cache.Objects[key]
if l.cacheable(p) {
l.objects(p, c, e.Value)
}
// Keys and certificates load only when asked for; else,
// like Go to, only as far as the TUI opened them.
asked := l.wants(strings.ToLower(string(t)))
if t != provider.TypeSecret {
asked = len(l.o.Kinds) > 0 && asked
}
if asked && l.load(p, ok, e.At) {
l.enqueue(job{p: p, c: &c, t: t, key: key})
}
}
}
}
func (l *lister) objects(p provider.Provider, c provider.Container, objs []provider.Object) {
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
o.Container = c
r, err := ref.ForObject(o, "")
if err != nil || (o.Hidden && !l.o.Hidden) {
continue
}
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL, Hidden: o.Hidden})
}
}
func sourceEntry(src provider.Source) Entry {
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL, Hidden: src.Hidden}
if r, err := ref.ForSource(src); err == nil {
e.Link = r.String()
}
return e
}
func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL, Hidden: c.Hidden}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String()
}
return e
}
// put emits e, unless it was already (or emitting has failed). Entries
// without a link (one couldn't be made) are left out, but not errors.
func (l *lister) put(p provider.Provider, e Entry) {
if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
return
}
e.Account = p.ID()
if l.o.Mark != nil && e.Link != "" {
m := l.o.Mark(e.Link, e.Account)
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
if m.Recent {
e.LastUsed, e.Uses = m.LastUsed, m.Uses
}
}
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] {
return
}
l.seen[id] = true
l.stopped = l.emit(e)
}
// wants reports whether any of kinds is listed.
func (l *lister) wants(kinds ...string) bool {
if len(l.o.Kinds) == 0 {
return true
}
for _, k := range kinds {
if slices.Contains(l.o.Kinds, k) {
return true
}
}
return false
}
func (l *lister) enqueue(j job) {
if l.pending[j.key] || l.failed[j.key] {
return
}
l.pending[j.key] = true
l.queue = append(l.queue, j)
}
func (l *lister) start(ctx context.Context) {
for l.stopped == nil && l.active < Workers && len(l.queue) > 0 {
j := l.queue[0]
l.queue = l.queue[1:]
l.active++
go func() { l.results <- l.fetch(ctx, j) }()
}
}
func (l *lister) fetch(ctx context.Context, j job) result {
kind := j.p.Kind()
ctx, cancel := context.WithTimeout(ctx, l.o.Timeout(kind))
defer cancel()
r := result{job: j}
switch {
case j.c != nil:
r.objects, r.err = j.p.Objects(ctx, *j.c, j.t)
case j.src != nil:
r.containers, r.err = j.p.Containers(ctx, *j.src)
default:
r.sources, r.err = j.p.Sources(ctx)
}
return r
}
func (l *lister) handle(r result) {
delete(l.pending, r.key)
now := l.o.Now()
if r.err != nil {
l.failed[r.key] = true
l.put(r.p, r.failed())
return
}
l.loaded = l.loaded || l.cacheable(r.p)
cache := l.cacheable(r.p)
switch {
case r.c != nil:
if cache {
l.o.Cache.Objects[r.key] = cachefile.Stamped[[]provider.Object]{At: now, Value: r.objects}
}
l.objects(r.p, *r.c, r.objects)
case r.src != nil:
r.containers = slices.Clone(r.containers) // the provider's may be shared
for i := range r.containers {
r.containers[i].Source = *r.src
}
if cache {
l.o.Cache.Containers[r.key] = cachefile.Stamped[[]provider.Container]{At: now, Value: r.containers}
}
l.containers(r.p, *r.src, r.containers)
default:
if cache {
l.o.Cache.Sources[r.key] = cachefile.Stamped[[]provider.Source]{At: now, Value: r.sources}
}
for _, src := range r.sources {
l.source(r.p, src)
}
}
}
// failed is the entry for a list that couldn't be loaded.
func (r result) failed() Entry {
var e Entry
switch {
case r.c != nil:
e = containerEntry(*r.c)
case r.src != nil:
src := *r.src
src.Provider, src.ProviderLabel = r.p.ID(), r.p.Label()
e = sourceEntry(src)
default:
e = Entry{Kind: "source", Provider: kindOf(r.p.Kind()), Path: []string{r.p.Label()}}
}
e.Error = firstLine(r.err.Error())
return e
}
// firstLine is an error's first line, the summary (Azure's go on with
// the caller's IDs and more): an entry is one line. Tabs, which would
// split it into columns, are spaces.
func firstLine(s string) string {
for line := range strings.Lines(s) {
if line = strings.TrimSpace(line); line != "" {
return strings.ReplaceAll(line, "\t", " ")
}
}
return "unreadable"
}
func kindOf(k provider.Kind) string { return strings.ToLower(string(k)) }
// accountOf is the account part of a source's provider label ("alice" in
// "Azure · alice"), or "" when there's a single account.
func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok {
return ""
}
return account
}
func sorted[T any](s []T, name func(T) string) []T {
s = slices.Clone(s)
slices.SortStableFunc(s, func(a, b T) int {
return cmp.Compare(strings.ToLower(name(a)), strings.ToLower(name(b)))
})
return s
}
-297
View File
@@ -1,297 +0,0 @@
package listing
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// counting counts a provider's requests.
type counting struct {
provider.Provider
calls atomic.Int32
}
func (c *counting) Sources(ctx context.Context) ([]provider.Source, error) {
c.calls.Add(1)
return c.Provider.Sources(ctx)
}
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
c.calls.Add(1)
return c.Provider.Containers(ctx, src)
}
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
c.calls.Add(1)
return c.Provider.Objects(ctx, ct, t)
}
// run lists, returning the entries and, apart, the lists that failed.
func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
t.Helper()
if o.Cache.Version == 0 {
o.Cache = cachefile.New()
}
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
var got, failed []Entry
f, _ := Run(context.Background(), o, func(e Entry) error {
if e.Error != "" {
failed = append(failed, e)
} else {
got = append(got, e)
}
return nil
})
return got, f, failed
}
func has(es []Entry, kind, path string) bool {
for _, e := range es {
if e.Kind == kind && strings.Join(e.Path, "/") == path {
return true
}
}
return false
}
func TestListLoadsAndCaches(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
got, f, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, MaxAge: time.Minute})
for _, want := range [][2]string{
{"source", "platform-prod"}, {"vault", "platform-prod/kv-app-prod"}, {"secret", "platform-prod/kv-app-prod/db-password"},
{"source", "lazysecrets-demo"}, {"secret", "lazysecrets-demo/.env/DATABASE_URL"},
} {
if !has(got, want[0], want[1]) {
t.Errorf("no %s %s", want[0], want[1])
}
}
if len(errs) != 1 || errs[0].Kind != "vault" || errs[0].Path[1] != "kv-restricted" || !strings.Contains(errs[0].Error, "403") || errs[0].Link == "" {
t.Errorf("failed = %+v", errs)
}
seen := map[string]bool{}
for _, e := range got {
if seen[e.Link] {
t.Errorf("%s listed twice", e.Link)
}
seen[e.Link] = true
if e.Provider != "azure" && e.Provider != "repo" {
t.Errorf("provider %q", e.Provider)
}
}
for _, e := range got {
if e.Kind == "secret" && e.Provider == "azure" && (!strings.HasPrefix(e.Reference, "@Microsoft.KeyVault(") || e.URL == "") {
t.Errorf("%s: reference %q, url %q", e.Link, e.Reference, e.URL)
}
}
if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 {
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
}
for k := range f.Containers {
if cachefile.ProviderOf(k) != az.ID() {
t.Errorf("cached a repo: %s", k)
}
}
// Again with that cache: the same, without asking Azure.
before := az.calls.Load()
again, _, _ := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, MaxAge: time.Minute})
if len(again) != len(got) {
t.Errorf("%d entries from the cache, %d loaded", len(again), len(got))
}
// The vault that couldn't be read wasn't cached: it's asked again.
if n := az.calls.Load() - before; n != 1 {
t.Errorf("%d requests with a fresh cache, want 1 (kv-restricted)", n)
}
// Stale: loaded again, but each entry is still listed once.
before = az.calls.Load()
stale, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if az.calls.Load()-before < 3 {
t.Error("stale lists weren't loaded again")
}
links := map[string]bool{}
for _, e := range stale {
if links[e.Link] {
t.Errorf("%s listed twice", e.Link)
}
links[e.Link] = true
}
}
func TestListCachedOnly(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
before := az.calls.Load()
got, _, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, Cached: true,
Now: func() time.Time { return time.Now().Add(24 * time.Hour) }})
if az.calls.Load() != before || len(errs) != 0 {
t.Errorf("--cached asked Azure: %d requests, %v", az.calls.Load()-before, errs)
}
if !has(got, "secret", "platform-prod/kv-app-prod/db-password") || !has(got, "secret", "lazysecrets-demo/.env/DATABASE_URL") {
t.Error("cached secrets or repos missing")
}
}
func TestListCachedFirst(t *testing.T) {
t.Parallel()
az := fake.Azure(0)
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Forget one vault's secrets: they load, after everything cached.
var dropped string
for k := range f.Objects {
if strings.Contains(k, "kv-app-prod") && strings.HasSuffix(k, "|Secret") {
dropped = k
delete(f.Objects, k)
break
}
}
if dropped == "" {
t.Fatalf("no kv-app-prod in %v", f.Objects)
}
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute})
last := got[len(got)-1]
if last.Kind != "secret" || last.Path[1] != "kv-app-prod" {
t.Errorf("last = %+v, want a kv-app-prod secret, loaded after the cache", last)
}
}
func TestListStopsWhenTheReaderGoes(t *testing.T) {
t.Parallel()
gone := errors.New("broken pipe")
n := 0
var errs []Entry
Run(context.Background(), Options{
Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute,
Timeout: func(provider.Kind) time.Duration { return time.Second },
}, func(e Entry) error {
if e.Error != "" {
errs = append(errs, e)
}
n++
if n == 2 {
return gone
}
return nil
})
if n != 2 || len(errs) != 0 {
t.Errorf("emitted %d after the reader went, errs %v", n, errs)
}
}
func TestListKinds(t *testing.T) {
t.Parallel()
// Sources only: nothing under them is asked for.
az := &counting{Provider: fake.Azure(0)}
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"source"}})
if len(got) == 0 || az.calls.Load() != 1 {
t.Errorf("%d sources, %d requests, want just the sources'", len(got), az.calls.Load())
}
for _, e := range got {
if e.Kind != "source" {
t.Errorf("listed a %s", e.Kind)
}
}
// Keys, asked for: loaded, though Go to wouldn't; no secrets.
got, _, failed := run(t, Options{Providers: []provider.Provider{fake.Azure(0)}, MaxAge: time.Minute, Kinds: []string{"key"}})
if !has(got, "key", "platform-prod/kv-app-prod/signing-key") {
t.Errorf("no keys: %+v", got)
}
for _, e := range got {
if e.Kind != "key" {
t.Errorf("listed a %s", e.Kind)
}
}
if len(failed) == 0 {
t.Error("a list that failed wasn't listed")
}
}
func TestErrorsAreOneLine(t *testing.T) {
for in, want := range map[string]string{
"403 Forbidden: Caller is not authorized\nCaller: appid=x;oid=y\nVault: kv;location=z": "403 Forbidden: Caller is not authorized",
"\n timeout\tafter 30s\n": "timeout after 30s",
"": "unreadable",
} {
if got := firstLine(in); got != want {
t.Errorf("firstLine(%q) = %q, want %q", in, got, want)
}
}
}
func TestListMarks(t *testing.T) {
t.Parallel()
az := fake.Azure(0)
var starredLink string
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
Mark: func(link, account string) Marks {
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
starredLink = link
}
if strings.HasSuffix(link, "/api-key") {
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
}
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
}
return Marks{Starred: link == starredLink && account == az.ID()}
}})
var starred, recent int
for _, e := range got {
if e.Starred {
starred++
if e.Link != starredLink {
t.Errorf("%s starred", e.Link)
}
}
if (e.Note != "") != strings.HasSuffix(e.Link, "/api-key") {
t.Errorf("%s: note %q", e.Link, e.Note)
}
if e.Recent {
recent++
if e.Uses != 3 || e.LastUsed.IsZero() {
t.Errorf("%s: %d uses, last %v", e.Link, e.Uses, e.LastUsed)
}
} else if e.Uses != 0 || !e.LastUsed.IsZero() {
t.Errorf("%s not recent, but %d uses, last %v", e.Link, e.Uses, e.LastUsed)
}
}
if starred != 1 || recent == 0 {
t.Errorf("%d starred, %d recent", starred, recent)
}
}
func TestFailedListNotRetried(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Everything stale: sources reload, and their vaults are queued again
// as each list arrives. kv-restricted fails once, and isn't asked again.
before := az.calls.Load()
_, _, failed := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if len(failed) != 1 {
t.Errorf("failed: %+v", failed)
}
fresh := &counting{Provider: fake.Azure(0)}
run(t, Options{Providers: []provider.Provider{fresh}, MaxAge: time.Minute})
if n := az.calls.Load() - before; n > fresh.calls.Load() {
t.Errorf("%d requests with a stale cache, more than %d from scratch: something was asked twice", n, fresh.calls.Load())
}
}
func TestRunWithoutTimeout(t *testing.T) {
t.Parallel()
Run(context.Background(), Options{Providers: []provider.Provider{fake.Repos()}, Cache: cachefile.New()},
func(Entry) error { return nil }) // no Timeout: a default, not a panic
}
+3
View File
@@ -107,6 +107,9 @@ func parseTerm(tok string) (term, bool) {
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
// 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.
+6
View File
@@ -99,3 +99,9 @@ func TestAnchorsOnTheName(t *testing.T) {
t.Errorf("password$: %v", got)
}
}
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+109 -87
View File
@@ -16,7 +16,6 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
@@ -100,7 +99,13 @@ func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindA
// Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []provider.Source) {
var out []provider.Source
pager := p.subs.NewListPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
@@ -113,7 +118,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.mu.Lock()
p.tenants[id] = tenant
p.mu.Unlock()
srcs = append(srcs, provider.Source{
out = append(out, provider.Source{
Kind: provider.KindAzure,
ID: id,
Name: deref(s.DisplayName),
@@ -121,10 +126,6 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
})
}
return srcs
})
if err != nil {
return nil, err
}
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
if len(p.opts.Vaults) > 0 {
@@ -229,29 +230,22 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
if err != nil {
return nil, err
}
return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) {
for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
vaults = append(vaults, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
return vaults
})
}
// all lists every page, items picking out what to keep of each.
func all[P, T any](ctx context.Context, pager *runtime.Pager[P], items func(P) []T) ([]T, error) {
var out []T
var out []provider.Container
pager := client.NewListBySubscriptionPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
out = append(out, items(page)...)
for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
out = append(out, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
}
return out, nil
}
@@ -294,40 +288,51 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
var out []provider.Object
switch t {
case provider.TypeSecret:
tenant := p.tenant(c.Source.ID)
out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) {
pager := vc.secrets.NewListSecretPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates.
if s.ID != nil && !deref(s.Managed) {
objs = append(objs, secretObject(c, s, tenant))
if s.ID == nil || deref(s.Managed) {
continue
}
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
}
}
return objs
})
case provider.TypeKey:
out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) {
pager := vc.keys.NewListKeyPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID != nil && !deref(k.Managed) {
objs = append(objs, keyObject(c, k))
if k.KID == nil || deref(k.Managed) {
continue
}
out = append(out, keyObject(c, k))
}
}
return objs
})
case provider.TypeCertificate:
out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) {
pager := vc.certs.NewListCertificatePropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID != nil {
objs = append(objs, certObject(c, cp))
if cp.ID == nil {
continue
}
out = append(out, certObject(c, cp))
}
}
return objs
})
default:
return nil, provider.ErrUnsupported
}
if err != nil {
return nil, err
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil
}
@@ -348,7 +353,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
if resp.Key != nil {
if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID))
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
}
o.Attrs = keyAttrs(resp.Key)
}
@@ -359,7 +364,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
}
if resp.ID != nil {
o.Version = resp.ID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.ID))
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
}
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType)
@@ -376,46 +381,56 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err != nil {
return nil, err
}
tenant := p.tenant(o.Container.Source.ID)
var out []provider.Object
switch o.Type {
case provider.TypeSecret:
out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (objs []provider.Object) {
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
if s.ID != nil {
objs = append(objs, secretObject(o.Container, s, tenant))
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID)))
}
}
}
return objs
})
case provider.TypeKey:
out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) {
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID != nil {
objs = append(objs, keyObject(o.Container, k))
out = append(out, keyObject(o.Container, k))
}
}
}
return objs
})
case provider.TypeCertificate:
out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) {
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID != nil {
objs = append(objs, certObject(o.Container, cp))
out = append(out, certObject(o.Container, cp))
}
}
}
return objs
})
default:
return nil, provider.ErrUnsupported
}
if err != nil {
return nil, err
}
tenant := p.tenant(o.Container.Source.ID)
for i := range out {
out[i].Reference = versionedReference(out[i])
if o.Type != provider.TypeSecret { // secretObject's is the version's already
out[i].URL = portalAssetURL(tenant, o.Type, out[i].ID+"/"+out[i].Version) // the version's page
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type]
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
}
}
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
@@ -437,19 +452,47 @@ func versionedReference(o provider.Object) string {
// Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty).
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, err
}
switch o.Type {
case provider.TypeSecret:
v, _, err := p.VersionedValue(ctx, o)
return v, err
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
pemKey, err := publicKeyPEM(resp.Key)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
}
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
}
return provider.SecretValue{}, provider.ErrUnsupported
}
// VersionedValue is Value, with the version read.
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
if o.Type != provider.TypeSecret {
v, err := p.Value(ctx, o)
return v, o.Version, err
}
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, "", err
}
var text string
switch o.Type {
case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
@@ -459,27 +502,6 @@ func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provi
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if text, err = publicKeyPEM(resp.Key); err != nil {
return provider.SecretValue{}, "", err
}
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, "", errors.New("certificate has no public certificate yet (pending issuance?)")
}
text = certPEM(resp.CER)
default:
return provider.SecretValue{}, "", provider.ErrUnsupported
}
return provider.NewSecretValue([]byte(text)), o.Version, nil
}
var (
+15 -9
View File
@@ -55,8 +55,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
// portalAssetURL links to a Key Vault object's page: a version's with
// one in objectID; a secret's own (its versions) without.
func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID
func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
}
func tenantPrefix(tenant string) string {
@@ -73,13 +73,19 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
if !ok {
return ""
}
return base + "/" + t.Collection()
switch t {
case provider.TypeKey:
return base + "/keys"
case provider.TypeCertificate:
return base + "/certificates"
}
return base + "/secrets"
}
// objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, t provider.ObjectType, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name
func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
}
// secretObject is a secret, or a version of one, from a listing. Its URL
@@ -87,7 +93,7 @@ func objectURL(c provider.Container, t provider.ObjectType, name string) string
// a version, that version's.
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
name := p.ID.Name()
url := objectURL(c, provider.TypeSecret, name)
url := objectURL(c, "secrets", name)
o := provider.Object{
Container: c,
Type: provider.TypeSecret,
@@ -97,7 +103,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
ContentType: deref(p.ContentType),
Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: portalAssetURL(tenant, provider.TypeSecret, strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
@@ -107,7 +113,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name()
url := objectURL(c, provider.TypeKey, name)
url := objectURL(c, "keys", name)
o := provider.Object{
Container: c,
Type: provider.TypeKey,
@@ -126,7 +132,7 @@ func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, provider.TypeCertificate, name)
url := objectURL(c, "certificates", name)
o := provider.Object{
Container: c,
Type: provider.TypeCertificate,
+13 -13
View File
@@ -2,7 +2,6 @@ package azure
import (
"fmt"
"maps"
"slices"
"strings"
@@ -71,10 +70,13 @@ func graphInfo(m map[string]any) vaultInfo {
// armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo {
info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)}
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
info.group = id.ResourceGroupName
}
for k, t := range v.Tags {
info.tags[k] = deref(t)
}
pr := v.Properties
if pr == nil {
return info
@@ -120,11 +122,7 @@ func (v vaultInfo) attrs() []provider.Attr {
} else {
add("soft delete", "off")
}
if on(v.purge, false) {
add("purge prot.", "on")
} else {
add("purge prot.", "off")
}
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
switch {
case strings.EqualFold(v.publicAccess, "disabled"):
add("network", "private endpoints only")
@@ -133,13 +131,15 @@ func (v vaultInfo) attrs() []provider.Attr {
default:
add("network", "all networks")
}
switch {
case v.endpoints == 1:
add("private", "1 endpoint")
case v.endpoints > 1:
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
if v.endpoints > 0 {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
}
for i, k := range slices.Sorted(maps.Keys(v.tags)) {
keys := make([]string, 0, len(v.tags))
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := ""
if i == 0 {
label = "tags"
+11 -14
View File
@@ -203,20 +203,17 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
return out, nil
}
// fallbackVault is the backing vault of an AKV-backed scope, to read it
// from when Databricks fails, or "" when there's none to fall back to. It
// falls back to the scope's Detail, so a scope remembered from an earlier
// session (a favourite) works before its workspace's scopes are listed.
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
if p.keyVault == nil || ctx.Err() != nil {
return ""
}
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
// back to the scope's Detail, so a scope remembered from an earlier session
// (a favourite) works before its workspace's scopes are listed.
func (p *Provider) vaultURL(c provider.Container) string {
p.mu.Lock()
url := p.vaults[c.ID]
p.mu.Unlock()
if url == "" {
if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok {
url = detail
url, _ = strings.CutPrefix(c.Detail, akvDetail)
if url == c.Detail {
url = ""
}
}
return url
@@ -267,8 +264,8 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
url := p.fallbackVault(ctx, c)
if url == "" {
url := p.vaultURL(c)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return nil, errNoFallback
}
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
@@ -305,8 +302,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
}
dbxErr := classify(pr, err)
url := p.fallbackVault(ctx, o.Container)
if url == "" {
url := p.vaultURL(o.Container)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return provider.SecretValue{}, dbxErr
}
v, kvErr := p.keyVault.Value(ctx, provider.Object{
+45 -63
View File
@@ -48,8 +48,19 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
func (p *Provider) ID() string {
if p.id != "" {
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.
func (p *Provider) As(id, label string) *Provider {
@@ -207,25 +218,14 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
return c
}
// Expire sets when the object name in vault expires, for --demo: a date
// relative to today in the test data would change the tests' screenshots
// from day to day.
func (p *Provider) Expire(vault, name string, at time.Time) {
for _, objs := range p.objects {
for i := range objs {
if objs[i].Container.Name == vault && objs[i].Name == name {
objs[i].Expires = at
}
}
}
}
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
o.Container = c
o.Type = t
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
o.URL = base + "/" + t.Collection()
o.URL = base + map[provider.ObjectType]string{
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
}[t]
}
k := listKey{c.ID, t}
p.objects[k] = append(p.objects[k], o)
@@ -239,7 +239,11 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
}
}
var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
var (
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.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
@@ -254,13 +258,12 @@ const (
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
func secretRef(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
// Azure returns a fake Azure provider with two subscriptions.
func Azure(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
objectURL := func(vault, kind, name string) string {
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
}
@@ -268,32 +271,28 @@ func Azure(delay time.Duration) *Provider {
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...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
{Version: "3f9c2a", Enabled: &yes, 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: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
}
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{
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"),
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"},
@@ -301,7 +300,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"},
@@ -310,7 +309,7 @@ func Azure(delay time.Duration) *Provider {
}, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"},
@@ -322,7 +321,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"},
@@ -335,10 +334,10 @@ func Azure(delay time.Duration) *Provider {
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.
p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
@@ -348,21 +347,8 @@ func Azure(delay time.Duration) *Provider {
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...)
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-password")
// Left behind, as dev vaults are: expired, disabled, or both.
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
}, "whsec_demo_expired")
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0),
Reference: secretRef("kv-app-dev", "test-user-password"),
}, "disabled-demo-password")
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled")
return p
}
@@ -372,21 +358,23 @@ func Azure(delay time.Duration) *Provider {
// its own, like an admin account next to a personal one.
func AzureOps(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{
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
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
// Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
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",
}, demoPublicKey)
@@ -394,14 +382,8 @@ func AzureOps(delay time.Duration) *Provider {
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...)
p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token")
// Expiring soon whenever the demo runs: the other dates are fixed,
// and would have passed. Tests use the first account only.
p.add(kv, provider.TypeSecret, provider.Object{
Name: "grafana-api-key", Enabled: 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"),
}, "glsa_demo_expiring")
return p
}
+2 -98
View File
@@ -2,7 +2,6 @@ package fake
import (
"context"
"fmt"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -19,9 +18,8 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
return attrs, ctx.Err()
}
// Repos returns a fake repo provider: four git repos with SOPS, plain
// .env, Spring and compose files, the first one the repo lazysecrets was
// started in.
// Repos returns a fake repo provider: three git repos with SOPS and plain
// .env files, the first one the repo lazysecrets was started in.
func Repos() *Provider {
p := newProvider(provider.KindRepo, 0)
p.id, p.label = "Repos", "Repos"
@@ -115,90 +113,6 @@ func Repos() *Provider {
"AZURE_CLIENT_SECRET", "azure-demo-client-secret",
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000")
// config adds a Spring or compose file (kind) and its keys as name,
// value and how it's listed: "" a secret, "hidden", "reference" (the
// value is a placeholder) or "jasypt" (the value is the decrypted one).
config := func(src provider.Source, path, kind, git, commit string, kv ...string) {
form := "yaml"
if strings.HasSuffix(path, ".properties") {
form = "properties"
}
c := p.addContainer(src, path, kind+" · "+form, provider.TypeSecret)
format := provider.Attr{Label: "format", Value: kind + " " + form}
secrets, hidden, jasypt, plain := 0, 0, 0, false
for i := 0; i+2 < len(kv); i += 3 {
o := provider.Object{Name: kv[i], Attrs: []provider.Attr{format, {Label: "encrypted", Value: "no: plain text in the file"}}}
switch kv[i+2] {
case "hidden":
o.Hidden = true
hidden++
continue
case "reference":
o.Detail, o.Attrs[1] = "reference", provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}
case "jasypt":
o.Detail, o.Attrs[1] = "jasypt", provider.Attr{Label: "encrypted", Value: "jasypt"}
jasypt++
default:
plain = plain || kv[i+1] != ""
}
secrets++
p.add(c, provider.TypeSecret, o, kv[i+1])
}
// Hidden keys after the rest, so the list opens on a secret.
for i := 0; i+2 < len(kv); i += 3 {
if kv[i+2] == "hidden" {
p.add(c, provider.TypeSecret, provider.Object{Name: kv[i], Hidden: true, Attrs: []provider.Attr{format}}, kv[i+1])
}
}
if secrets == 0 {
p.hide(src, path)
}
encryption := "none: plain text"
if jasypt > 0 {
encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, secrets+hidden)
}
d := []provider.Attr{
{Label: "encryption", Value: encryption},
{Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", hidden)},
}
if git == "tracked" && plain {
d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
} else {
d = append(d, provider.Attr{Label: "git", Value: git})
}
p.details[c.ID] = append(d, provider.Attr{Label: "commit", Value: commit})
}
orders := repo("orders-service", false)
config(orders, "src/main/resources/application.yml", "spring", "tracked", "5be2c07 · 6 days ago",
"spring.application.name", "orders-service", "hidden",
"server.port", "8080", "hidden",
"spring.datasource.url", "jdbc:postgresql://orders-db:5432/orders", "hidden",
"spring.datasource.password", "${DB_PASSWORD}", "reference",
"spring.security.oauth2.client.provider.idp.token-uri", "https://idp.acme.example/token", "hidden",
"spring.security.oauth2.client.registration.orders.client-secret", "orders-demo-client-secret", "jasypt",
"jasypt.encryptor.password", "${JASYPT_ENCRYPTOR_PASSWORD}", "reference",
"app.payment.api-key", "pay_live_demo_7Hq2", "jasypt")
config(orders, "src/main/resources/application-local.properties", "spring", "tracked", "5be2c07 · 6 days ago",
"spring.datasource.url", "jdbc:postgresql://localhost:5432/orders", "hidden",
"spring.datasource.password", "local-orders-pw", "",
"logging.level.com.acme", "DEBUG", "hidden")
config(orders, "src/test/resources/application-test.yml", "spring", "tracked", "d81a3f9 · 3 weeks ago",
"spring.datasource.url", "jdbc:h2:mem:orders", "hidden",
"spring.jpa.hibernate.ddl-auto", "create-drop", "hidden")
config(orders, "docker-compose.yml", "compose", "tracked", "5be2c07 · 6 days ago",
"api.SPRING_PROFILES_ACTIVE", "local", "hidden",
"api.DB_PASSWORD", "${DB_PASSWORD}", "reference",
"db.POSTGRES_USER", "orders", "hidden",
"db.POSTGRES_PASSWORD", "orders-db-demo-pw", "")
config(orders, "docker-compose.example.yml", "compose", "tracked", "5be2c07 · 6 days ago",
"db.POSTGRES_PASSWORD", "changeme", "")
p.hide(orders, "docker-compose.example.yml")
file(orders, ".env.example", "", "tracked", "5be2c07 · 6 days ago",
"DB_PASSWORD", "changeme",
"JASYPT_ENCRYPTOR_PASSWORD", "changeme")
p.hide(orders, ".env.example")
web := repo("web-frontend", false)
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
"SENTRY_AUTH_TOKEN", "sntrys_demo_token",
@@ -235,13 +149,3 @@ func (p *Provider) Contents(ctx context.Context, c provider.Container) ([]byte,
}
return []byte(b.String()), ctx.Err()
}
// hide marks a file hidden, as the repo provider does an example file or
// a config file without secrets.
func (p *Provider) hide(src provider.Source, name string) {
for i, c := range p.containers[src.ID] {
if c.Name == name {
p.containers[src.ID][i].Hidden = true
}
}
}
-65
View File
@@ -1,65 +0,0 @@
package fake
import (
"cmp"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// Seed stars, notes and uses a few of the demo's secrets, so --demo
// shows Favourites (F), Recent (R), notes, and stars in lazysecrets list. alice and
// admin are the two Azure accounts' provider IDs.
func Seed(st *store.Store, alice, admin string) {
type item struct {
link, account, source string
name string // else the link's last part
note string
path []string
star bool
uses int
ago time.Duration
}
const azure, dbx = "lazysecrets://azure/", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/"
for _, it := range []item{
{link: azure + "kv-app-prod.vault.azure.net/secrets/db-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, star: true, uses: 12, ago: 20 * time.Minute,
note: "rotated by the data team each quarter"},
{link: azure + "kv-app-prod.vault.azure.net/secrets/api-key", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, uses: 4, ago: 2 * time.Hour},
{link: azure + "kv-breakglass.vault.azure.net/secrets/emergency-admin", account: admin, source: "sub-prod",
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"},
{link: azure + "kv-shared-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-shared-prod"}, uses: 2, ago: 26 * time.Hour},
{link: azure + "kv-app-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, note: "migrated to Microsoft Graph sendMail; delete after Q4"},
{link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
// A subscription, for both accounts, and a repo's file.
{link: azure + "subscriptions/sub-prod", account: alice, source: "sub-prod",
name: "platform-prod", star: true},
{link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
name: "platform-prod", star: true},
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
source: "/home/dev/git/lazysecrets-demo", name: "deploy/prod/secrets.yaml",
path: []string{"lazysecrets-demo"}, star: true},
} {
context := func(n *store.Node) {
n.Name = cmp.Or(it.name, it.link[strings.LastIndex(it.link, "/")+1:])
n.Path, n.Source, n.Account = it.path, it.source, it.account
}
if it.star || it.note != "" {
_ = st.Update(it.link, func(n *store.Node) {
context(n)
if it.star {
n.ToggleStar(it.account)
}
n.Note = it.note
})
}
for range it.uses {
_ = st.Use(it.link, time.Now().Add(-it.ago), context)
}
}
}
-21
View File
@@ -73,10 +73,6 @@ type Source struct {
// Here marks the source lazysecrets was started in (the current git
// repo): listed first, starred or not, and labelled.
Here bool
// Hidden marks a source, container or object the provider's rules
// left out (a Spring file with no secret-looking keys): listed only
// while the app shows hidden ones.
Hidden bool `json:",omitempty"`
}
// ObjectType is the kind of object stored in a container.
@@ -88,20 +84,6 @@ const (
TypeCertificate ObjectType = "Certificate"
)
// Collection is the type's path segment in Key Vault URLs and links, e.g.
// "secrets".
func (t ObjectType) Collection() string {
switch t {
case TypeSecret:
return "secrets"
case TypeKey:
return "keys"
case TypeCertificate:
return "certificates"
}
return ""
}
// Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct {
Source Source
@@ -114,7 +96,6 @@ type Container struct {
URL string // web page for the container, if any
// Attrs describe it further, from the listing (a vault's settings).
Attrs []Attr `json:",omitempty"`
Hidden bool `json:",omitempty"` // see Source.Hidden
}
// ObjectTypes returns the container's object types, defaulting to secrets.
@@ -138,8 +119,6 @@ type Object struct {
Type ObjectType
ID string
Name string
Detail string // shown after the name, e.g. "jasypt" for how its value is kept
Hidden bool `json:",omitempty"` // see Source.Hidden
Version string
Enabled *bool
Updated time.Time
-141
View File
@@ -1,141 +0,0 @@
package repo
import (
"path"
"regexp"
"strings"
"go.yaml.in/yaml/v3"
)
// Docker Compose files: compose.yaml, docker-compose.yml and their
// variants (docker-compose.test.yml). Their keys are the services'
// environment variables, named service.VAR.
var composeType = configType{
name: "compose", is: IsCompose, format: func(string) string { return "yaml" }, parse: parseCompose,
jasyptSetting: func(name string) string {
service, _, _ := strings.Cut(name, ".")
return service + "." + jasyptEnv
},
placeholder: composePlaceholder,
}
var composeName = regexp.MustCompile(`^(docker-)?compose([.-][^/]+)?\.ya?ml$`)
// IsCompose reports whether f is named like a Docker Compose file.
func IsCompose(f string) bool {
return composeName.MatchString(strings.ToLower(path.Base(f)))
}
// composePlaceholderRe is compose's ${VAR}, ${VAR:-default}, ${VAR-default},
// ${VAR:?error}, ${VAR?error} or $VAR.
var composePlaceholderRe = regexp.MustCompile(`^\$(?:\{(\w+)(?:(:?-)([^}]*)|:?\?[^}]*)?\}|(\w+))$`)
func composePlaceholder(v string) (name, def string, hasDef, ok bool) {
m := composePlaceholderRe.FindStringSubmatch(v)
if m == nil {
return "", "", false, false
}
return m[1] + m[4], m[3], m[2] != "", true
}
func parseCompose(_ string, b []byte) ([]configKey, error) {
var doc yaml.Node
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
if len(doc.Content) == 0 {
return nil, nil
}
var out []configKey
for _, service := range pairs(field(doc.Content[0], "services")) {
for _, k := range environment(field(service.value, "environment")) {
k.name = service.key.Value + "." + k.key
out = append(out, k)
}
}
return out, nil
}
// environment reads a service's environment, a map (KEY: value) or a list
// (- KEY=value). A variable without a value (KEY:, - KEY) is taken from
// where compose runs: a reference.
func environment(n *yaml.Node) []configKey {
var out []configKey
switch n = deref(n); {
case n == nil:
case n.Kind == yaml.MappingNode:
for _, p := range pairs(n) {
v := deref(p.value)
ref := v.Tag == "!!null" || reference(composePlaceholder, v.Value)
out = append(out, configKey{key: p.key.Value, value: v.Value, ref: ref, line: p.key.Line})
}
case n.Kind == yaml.SequenceNode:
for _, item := range n.Content {
item = deref(item)
k, v, ok := strings.Cut(item.Value, "=")
out = append(out, configKey{key: k, value: v, ref: !ok || reference(composePlaceholder, v), line: item.Line})
}
}
return out
}
type pair struct{ key, value *yaml.Node }
// pairs are a mapping's entries, with those merged in by << (anchors
// shared between services) first and its own over them.
func pairs(n *yaml.Node) []pair {
n = deref(n)
if n == nil || n.Kind != yaml.MappingNode {
return nil
}
var merged, own []pair
for i := 0; i+1 < len(n.Content); i += 2 {
k, v := n.Content[i], n.Content[i+1]
if k.Value != "<<" {
own = append(own, pair{k, v})
continue
}
from := []*yaml.Node{v}
if v.Kind == yaml.SequenceNode {
from = v.Content
}
for _, m := range from {
merged = append(merged, pairs(m)...)
}
}
var out []pair
for _, p := range append(merged, own...) {
if i := indexOf(out, p.key.Value); i >= 0 {
out[i] = p
} else {
out = append(out, p)
}
}
return out
}
func indexOf(ps []pair, key string) int {
for i, p := range ps {
if p.key.Value == key {
return i
}
}
return -1
}
func field(n *yaml.Node, key string) *yaml.Node {
ps := pairs(n)
if i := indexOf(ps, key); i >= 0 {
return ps[i].value
}
return nil
}
func deref(n *yaml.Node) *yaml.Node {
for n != nil && n.Kind == yaml.AliasNode {
n = n.Alias
}
return n
}
-140
View File
@@ -1,140 +0,0 @@
package repo
import (
"context"
"maps"
"os"
"path/filepath"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestIsCompose(t *testing.T) {
for f, want := range map[string]bool{
"docker-compose.yml": true,
"deploy/compose.yaml": true,
"docker-compose.test.yml": true,
"docker-compose-dev.yaml": true,
"docker-compose.example.yml": true, // listed hidden
"applications.yaml": false,
"composer.json": false,
"compose.yml.bak": false,
} {
if IsCompose(f) != want {
t.Errorf("%s: %t", f, !want)
}
}
}
func TestComposePlaceholder(t *testing.T) {
for v, want := range map[string]bool{
"${DB_PASSWORD}": true, "$DB_PASSWORD": true, "${DB_PASSWORD:?set it}": true, "${DB_PASSWORD?}": true,
"${DB_PASSWORD:-hunter2}": false, "${DB_PASSWORD-hunter2}": false, // the default is in the file
"hunter2": false, "pre-${DB_PASSWORD}": false, "$": false,
} {
if got := reference(composePlaceholder, v); got != want {
t.Errorf("%s: reference %t", v, got)
}
}
if name, def, hasDef, _ := composePlaceholder("${PW:-x}"); name != "PW" || def != "x" || !hasDef {
t.Errorf("default: %s %s %t", name, def, hasDef)
}
}
func TestParseCompose(t *testing.T) {
keys, err := parseCompose("", []byte(`x-db: &db
DB_USER: app
DB_PASSWORD: shared
services:
api:
image: api
environment:
<<: *db
DB_PASSWORD: own
API_KEY: ${API_KEY}
FROM_HOST:
worker:
environment:
- QUEUE_PASSWORD=hunter2
- TOKEN
- EMPTY=
web:
image: web
`))
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, k := range keys {
ref := ""
if k.ref {
ref = " (ref)"
}
got[k.name] = k.value + ref
}
want := map[string]string{
"api.DB_USER": "app", "api.DB_PASSWORD": "own", "api.API_KEY": "${API_KEY} (ref)", "api.FROM_HOST": " (ref)",
"worker.QUEUE_PASSWORD": "hunter2", "worker.TOKEN": " (ref)", "worker.EMPTY": "",
}
if !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
for _, k := range keys {
if k.name == "worker.QUEUE_PASSWORD" && (k.line != 14 || k.key != "QUEUE_PASSWORD") {
t.Errorf("%s: line %d, key %s", k.name, k.line, k.key)
}
}
}
func TestComposeFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"docker-compose.yml": `services:
api:
environment:
PORT: "8080"
DB_PASSWORD: ${DB_PASSWORD}
JASYPT_ENCRYPTOR_PASSWORD: test-password
API_TOKEN: ` + jasyptHunter2 + `
`,
"docker-compose.example.yml": "services:\n api:\n environment:\n DB_PASSWORD: changeme\n",
})
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, err := p.Containers(ctx, srcs[0])
if err != nil || len(cs) != 2 {
t.Fatalf("files: %+v %v", cs, err)
}
for _, c := range cs {
if c.Detail != "compose · yaml" || c.Hidden != (c.Name == "docker-compose.example.yml") {
t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden)
}
}
compose := cs[1] // after docker-compose.example.yml
keys, _ := p.Objects(ctx, compose, provider.TypeSecret)
got := map[string]string{}
for _, k := range shown(keys) {
got[k.Name] = k.Detail
}
if want := map[string]string{"api.DB_PASSWORD": "reference", "api.JASYPT_ENCRYPTOR_PASSWORD": "", "api.API_TOKEN": "jasypt"}; !maps.Equal(got, want) {
t.Errorf("keys %v, want %v", got, want)
}
// The service's JASYPT_ENCRYPTOR_PASSWORD decrypts its values.
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "docker-compose.yml"}, Name: "api.API_TOKEN"}
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "hunter2" {
t.Errorf("jasypt: %q %v", v.Reveal(), err)
}
if f, _ := p.File(compose, &link); f.Line != 7 {
t.Errorf("line %d", f.Line)
}
// compose.secretPatterns over the shared ones.
os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte("secretPatterns:\n keys: [\"^nothing$\"]\ncompose:\n secretPatterns:\n keys: [\"^PORT$\"]\n values: []\n"), 0o644)
keys, _ = p.Objects(ctx, compose, provider.TypeSecret)
if s := shown(keys); len(s) != 1 || s[0].Name != "api.PORT" {
t.Errorf("compose's patterns: %v", s)
}
}
-170
View File
@@ -1,170 +0,0 @@
package repo
import (
"fmt"
"os"
"path/filepath"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Config files (Spring's, compose's) are read directly, like env files,
// but most of their keys aren't secret: those the secret patterns
// (patterns.go) don't match are hidden, and a file with none of them is.
// configType is a type of config file: what it's called (in settings, as
// spring:, and on the file's row) and how its keys are read.
type configType struct {
name string
is func(file string) bool
format func(file string) string // yaml, properties
parse func(file string, b []byte) ([]configKey, error)
// jasyptSetting is the key that holds the Jasypt password for the
// key named name; nil when only $JASYPT_ENCRYPTOR_PASSWORD can.
jasyptSetting func(name string) string
// placeholder reads a value that only refers to another,
// ${DB_PASSWORD}: what it names, and its default if it has one.
placeholder func(v string) (name, def string, hasDef, ok bool)
}
var configTypes = []configType{springType, composeType, dotnetType}
func isConfig(f string) bool {
_, ok := configTypeOf(f)
return ok
}
// configTypeOf is the type of config file f is, if it's one.
func configTypeOf(f string) (configType, bool) {
for _, t := range configTypes {
if t.is(f) {
return t, true
}
}
return configType{}, false
}
// detail is the file's row: "spring · yaml".
func (t configType) detail(file string) string { return t.name + " · " + t.format(file) }
// configKey is a key of a config file, with its value.
type configKey struct {
key string // as the app sees it: spring.datasource.password, DB_PASSWORD
name string // one key in the file: spring.datasource.password (prod), api.DB_PASSWORD
value string
ref bool // the value only refers to the secret, set elsewhere: ${DB_PASSWORD}
line int // 1-based
}
func (k configKey) jasypt() bool { return jasypt.Is(k.value) }
// plain reports whether the file holds the value itself, readable.
func (k configKey) plain() bool { return k.value != "" && !k.ref && !k.jasypt() }
// reference reports whether v is only a placeholder without a default:
// one with a default has the default in the file.
func reference(placeholder func(string) (string, string, bool, bool), v string) bool {
_, _, hasDef, ok := placeholder(v)
return ok && !hasDef
}
func (t configType) read(file string) ([]configKey, error) {
b, err := os.ReadFile(file)
if err != nil {
return nil, err
}
return t.parse(file, b)
}
func (t configType) keyNamed(file, name string) (configKey, error) {
keys, err := t.read(file)
if err != nil {
return configKey{}, err
}
for _, k := range keys {
if k.name == name {
return k, nil
}
}
return configKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
}
func (t configType) value(file, name string) (provider.SecretValue, error) {
k, err := t.keyNamed(file, name)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(k.value)), nil
}
// configHidden reports whether a config file has no secret keys. One that
// can't be read isn't hidden: its error is worth seeing.
func (p *Provider) configHidden(t configType, root, file string) bool {
keys, err := t.read(file)
if err != nil {
return false
}
filter, err := p.filterFor(root, t.name)
if err != nil {
return false
}
return !slices.ContainsFunc(keys, func(k configKey) bool { return filter.secret(k.key, k.value) })
}
// configObjects lists the file's keys, those that don't look like secrets
// hidden.
func (p *Provider) configObjects(t configType, c provider.Container, b []byte) ([]provider.Object, error) {
keys, err := t.parse(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
filter, err := p.filterFor(c.Source.ID, t.name)
if err != nil {
return nil, err
}
format := provider.Attr{Label: "format", Value: t.name + " " + t.format(c.ID)}
var out []provider.Object
for _, k := range keys {
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
switch {
case k.ref:
state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference"
case k.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, Detail: detail,
Hidden: !filter.secret(k.key, k.value), Attrs: []provider.Attr{format, state},
})
}
return out, nil
}
// configDetails says how many of the file's keys aren't listed, and
// whether any listed one is in plain text.
func (p *Provider) configDetails(t configType, c provider.Container) (attrs []provider.Attr, plain bool) {
keys, err := t.read(c.ID)
if err != nil {
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
}
filter, err := p.filterFor(c.Source.ID, t.name)
if err != nil {
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
}
secrets, jasypt := 0, 0
for _, k := range keys {
if filter.secret(k.key, k.value) {
secrets++
plain = plain || k.plain()
}
if k.jasypt() {
jasypt++
}
}
return []provider.Attr{
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
{Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
}, plain
}
+4 -36
View File
@@ -2,8 +2,6 @@ package repo
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -24,12 +22,7 @@ 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, unless they're all Jasypt;
// a config file's only when one of its secrets is in plain text.
plain := !isSops
if t, ok := configTypeOf(c.ID); ok && !isSops {
out, plain = p.configDetails(t, c)
} else if isSops {
if isSops {
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
for i, k := range meta.recipients {
label := ""
@@ -42,7 +35,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, plain = envDetails(c.ID)
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
}
git := func(args ...string) (string, bool) {
@@ -50,7 +43,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
return strings.TrimSpace(string(b)), err == nil
}
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
case tracked && plain:
case tracked && !isSops:
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
case tracked:
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
@@ -58,7 +51,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
} else {
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: plain})
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
}
return out, nil
}
@@ -67,28 +60,3 @@ 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)
}
-74
View File
@@ -1,74 +0,0 @@
package repo
import (
"fmt"
"path"
"regexp"
"strings"
"go.yaml.in/yaml/v3"
)
// .NET config files: appsettings.json and appsettings.<Environment>.json.
// Keys are named as .NET's configuration names them, sections joined by
// colons: ConnectionStrings:Default, Servers:0:Name.
var dotnetType = configType{
name: "dotnet", is: IsDotnet, format: func(string) string { return "json" }, parse: parseDotnet,
placeholder: dotnetPlaceholder,
}
var dotnetName = regexp.MustCompile(`^appsettings(\.[^/]+)?\.json$`)
// IsDotnet reports whether f is named like a .NET appsettings file.
func IsDotnet(f string) bool {
return dotnetName.MatchString(strings.ToLower(path.Base(f)))
}
// dotnetPlaceholder reads an App Service Key Vault reference,
// @Microsoft.KeyVault(SecretUri=…): the secret is in the vault.
func dotnetPlaceholder(v string) (name, def string, hasDef, ok bool) {
if strings.HasPrefix(v, "@Microsoft.KeyVault(") && strings.HasSuffix(v, ")") {
return v, "", false, true
}
return "", "", false, false
}
func parseDotnet(_ string, b []byte) ([]configKey, error) {
var doc yaml.Node // JSON is YAML, and the YAML parser says where each key is
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
var out []configKey
if len(doc.Content) > 0 {
dotnetWalk(doc.Content[0], "", &out)
}
for i := range out {
out[i].name = out[i].key
out[i].ref = reference(dotnetPlaceholder, out[i].value)
}
return out, nil
}
func dotnetWalk(n *yaml.Node, name string, out *[]configKey) {
join := func(child string) string {
if name == "" {
return child
}
return name + ":" + child
}
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
dotnetWalk(n.Content[i+1], join(n.Content[i].Value), out)
}
case yaml.SequenceNode:
for i, c := range n.Content {
dotnetWalk(c, join(fmt.Sprint(i)), out)
}
case yaml.ScalarNode:
if name != "" {
*out = append(*out, configKey{key: name, value: n.Value, line: n.Line})
}
}
}
-100
View File
@@ -1,100 +0,0 @@
package repo
import (
"context"
"maps"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestIsDotnet(t *testing.T) {
for f, want := range map[string]bool{
"appsettings.json": true,
"src/Api/appsettings.Production.json": true,
"appsettings.Example.json": true, // listed hidden
"appsettings.json.bak": false,
"settings.json": false,
"package.json": false,
} {
if IsDotnet(f) != want {
t.Errorf("%s: %t", f, !want)
}
}
}
func TestParseDotnet(t *testing.T) {
keys, err := parseDotnet("", []byte("\xef\xbb\xbf"+`{
"ConnectionStrings": { "Default": "Server=db;Password=hunter2" },
"AzureAd": { "ClientId": "abc", "ClientSecret": "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/ad)" },
"Servers": [ { "Name": "a", "ApiKey": "k" } ],
"Logging": { "LogLevel": { "Default": "Information" } }
}`))
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, k := range keys {
ref := ""
if k.ref {
ref = " (ref)"
}
got[k.name] = k.value + ref
}
want := map[string]string{
"ConnectionStrings:Default": "Server=db;Password=hunter2",
"AzureAd:ClientId": "abc",
"AzureAd:ClientSecret": "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/ad) (ref)",
"Servers:0:Name": "a",
"Servers:0:ApiKey": "k",
"Logging:LogLevel:Default": "Information",
}
if !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestDotnetFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"src/Api/appsettings.json": `{
"Logging": { "LogLevel": { "Default": "Information" } },
"ConnectionStrings": { "Default": "Server=db;User Id=app;Password=hunter2" },
"AzureAd": { "ClientSecret": "@Microsoft.KeyVault(VaultName=kv;SecretName=ad)" }
}`,
"src/Api/appsettings.Development.json": `{ "Logging": { "LogLevel": { "Default": "Debug" } } }`,
})
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, err := p.Containers(ctx, srcs[0])
if err != nil || len(cs) != 2 {
t.Fatalf("files: %+v %v", cs, err)
}
for _, c := range cs {
if c.Detail != "dotnet · json" || c.Hidden != (c.Name == "src/Api/appsettings.Development.json") {
t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden)
}
}
settings := cs[1]
keys, _ := p.Objects(ctx, settings, provider.TypeSecret)
got := map[string]string{}
for _, k := range shown(keys) {
got[k.Name] = k.Detail
}
// The connection string by its value, the client secret by its name.
if want := map[string]string{"ConnectionStrings:Default": "", "AzureAd:ClientSecret": "reference"}; !maps.Equal(got, want) {
t.Errorf("keys %v, want %v", got, want)
}
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/Api/appsettings.json"}, Name: "ConnectionStrings:Default"}
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "Server=db;User Id=app;Password=hunter2" {
t.Errorf("value: %q %v", v.Reveal(), err)
}
if f, _ := p.File(settings, &link); f.Line != 3 {
t.Errorf("line %d", f.Line)
}
if d, plain := p.configDetails(dotnetType, settings); !plain || d[1].Value != "1, not matched by the secret patterns" {
t.Errorf("details: %+v %t", d, plain)
}
}
-69
View File
@@ -1,69 +0,0 @@
package repo
import (
"errors"
"fmt"
"os"
"path/filepath"
"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, name string, v provider.SecretValue) (provider.SecretValue, error) {
s := v.Reveal()
if !jasypt.Is(s) {
return v, nil
}
password, from, err := jasyptPassword(file, name)
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 the Jasypt value of the key named
// name, and where it's from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's
// own setting (jasypt.encryptor.password in a Spring file; the service's
// JASYPT_ENCRYPTOR_PASSWORD in a compose file, the file's in an env file),
// with a ${VAR} in it read from the environment.
func jasyptPassword(file, name string) (password, from string, err error) {
if v := os.Getenv(jasyptEnv); v != "" {
return v, "$" + jasyptEnv, nil
}
setting, read, placeholder := jasyptEnv, plainValue, composePlaceholder // env files interpolate like compose
if t, ok := configTypeOf(file); ok {
if t.jasyptSetting == nil {
return "", "", fmt.Errorf("jasypt: no password: set $%s", jasyptEnv)
}
setting, read, placeholder = t.jasyptSetting(name), t.value, t.placeholder
}
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))
}
from = setting + " in " + filepath.Base(file)
env, def, hasDef, ok := placeholder(v.Reveal())
switch {
case !ok:
return v.Reveal(), from, nil
case os.Getenv(env) != "":
return os.Getenv(env), "$" + env + " (" + from + ")", nil
case hasDef:
return def, "the default in " + from, nil
}
return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v.Reveal(), env)
}
-102
View File
@@ -1,102 +0,0 @@
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)
}
}
}
}
-113
View File
@@ -1,113 +0,0 @@
package repo
import (
"errors"
"fmt"
"path/filepath"
"regexp"
)
// Config files (Spring's) hold all of an app's config, most of it not
// secret: of their keys, those named like a secret or with a value that
// looks like one are listed, the rest hidden (H shows them). The patterns
// can be set for all of a repo's files and per file type, in the config
// and in a repo's .lazysecrets.yaml.
// DefaultSecretKeys and DefaultSecretValues are the patterns used when
// nothing sets them. The key pattern is anchored at the end of the name:
// spring.datasource.password, client-secret, DB_PASSWORD and checkoutKey,
// not the oauth2.*, keycloak.* and token-uri keys every Spring app has.
// passord is Norwegian. They err on listing too much: a row too many is
// cheap, a secret missed isn't.
var (
DefaultSecretKeys = []string{
`(?i)(pass(word|wd|phrase|ord)?|pwd|secret|token|key|credentials?|salt|connection-?string)$`,
}
DefaultSecretValues = []string{
`^ENC\(.+\)$`, // Jasypt
`://[^/\s:@]*:[^/\s@]+@`, // a URL with a password: jdbc:postgresql://user:pass@host
`(?i)(AccountKey|SharedAccessKey|Password|Pwd)=`, // a connection string with one
}
)
// SecretPatterns are regexps matched against a key's name and its value:
// a key is a secret when either matches. A list that isn't set is taken
// from the next place that sets it (see filterFor).
type SecretPatterns struct {
Keys *[]string `yaml:"keys"`
Values *[]string `yaml:"values"`
}
// FileType holds the settings of one type of file (spring:), for now its
// own secret patterns.
type FileType struct {
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
}
// Check says which pattern doesn't compile; field is where they're set
// (spring.secretPatterns), for the message.
func (p SecretPatterns) Check(field string) error {
var errs []error
for name, list := range map[string]*[]string{"keys": p.Keys, "values": p.Values} {
if list == nil {
continue
}
for i, s := range *list {
if _, err := regexp.Compile(s); err != nil {
errs = append(errs, fmt.Errorf("%s.%s[%d]: %w", field, name, i, err))
}
}
}
return errors.Join(errs...)
}
type secretFilter struct{ keys, values []*regexp.Regexp }
func (f secretFilter) secret(key, value string) bool {
return anyMatch(f.keys, key) || anyMatch(f.values, value)
}
// filterFor is the patterns for a repo's files of one type (spring), each
// list from the first that sets it: the repo's .lazysecrets.yaml for the
// type, then for all its files, the config for the type, then for all
// repos, then the defaults.
func (p *Provider) filterFor(root, fileType string) (secretFilter, error) {
f, err := readLazysecretsFile(root)
if err != nil {
return secretFilter{}, err
}
own := filepath.Join(root, ".lazysecrets.yaml") + ": "
type source struct {
field string
patterns SecretPatterns
}
sources := []source{
{own + fileType + ".secretPatterns", f.types()[fileType].SecretPatterns},
{own + "secretPatterns", f.SecretPatterns},
{"providers.repos." + fileType + ".secretPatterns", p.s.Types[fileType].SecretPatterns},
{"providers.repos.secretPatterns", p.s.SecretPatterns},
}
pick := func(name string, list func(SecretPatterns) *[]string, defaults []string) ([]*regexp.Regexp, error) {
for _, s := range sources {
if l := list(s.patterns); l != nil {
return compileAll(*l, s.field+"."+name)
}
}
return compileAll(defaults, "default "+name)
}
keys, kerr := pick("keys", func(p SecretPatterns) *[]string { return p.Keys }, DefaultSecretKeys)
values, verr := pick("values", func(p SecretPatterns) *[]string { return p.Values }, DefaultSecretValues)
return secretFilter{keys, values}, errors.Join(kerr, verr)
}
func compileAll(patterns []string, field string) ([]*regexp.Regexp, error) {
out := make([]*regexp.Regexp, 0, len(patterns))
for i, s := range patterns {
re, err := regexp.Compile(s)
if err != nil {
return nil, fmt.Errorf("%s[%d]: %w", field, i, err)
}
out = append(out, re)
}
return out, nil
}
-49
View File
@@ -1,49 +0,0 @@
package repo
import (
"os"
"path/filepath"
"testing"
)
// Each list comes from the first that sets it: the repo's spring, the
// repo's, the config's spring, the config's, the defaults.
func TestSecretPatternsOrder(t *testing.T) {
list := func(s ...string) *[]string { return &s }
root := t.TempDir()
write := func(body string) {
if err := os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
p := New(Settings{
SecretPatterns: SecretPatterns{Keys: list("^config$"), Values: list("^config$")},
Types: map[string]FileType{"spring": {SecretPatterns: SecretPatterns{Keys: list("^config-spring$")}}},
})
secret := func(key, value string) bool {
t.Helper()
f, err := p.filterFor(root, "spring")
if err != nil {
t.Fatal(err)
}
return f.secret(key, value)
}
if !secret("config-spring", "") || secret("config", "") || !secret("", "config") {
t.Error("the config's spring keys, its shared values")
}
write("secretPatterns:\n keys: [\"^repo$\"]\n")
if !secret("repo", "") || secret("config-spring", "") || !secret("", "config") {
t.Error("the repo's shared keys over the config's")
}
write("secretPatterns:\n keys: [\"^repo$\"]\nspring:\n secretPatterns:\n keys: [\"^repo-spring$\"]\n values: []\n")
if !secret("repo-spring", "") || secret("repo", "") || secret("", "config") {
t.Error("the repo's spring over everything; an empty list matches nothing")
}
p = New(Settings{})
write("")
if !secret("spring.datasource.password", "") || secret("server.port", "") {
t.Error("the defaults")
}
}
+99 -148
View File
@@ -1,7 +1,6 @@
// Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and
// their keys the secrets. Secret files are SOPS files, plain env files and
// config files: Spring Boot's, Docker Compose's and .NET's (configfile.go).
// their keys the secrets. SOPS files are the one type so far.
//
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
// metadata are read directly. A value is decrypted when it's revealed or
@@ -32,7 +31,6 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -51,11 +49,6 @@ type Settings struct {
// Include and Exclude are globs relative to a repo's root (** matches
// any depth), for repos without their own .lazysecrets.yaml.
Include, Exclude []string
// SecretPatterns say which keys of config files are secrets, Types
// per type of file (spring), for repos whose .lazysecrets.yaml
// doesn't.
SecretPatterns SecretPatterns
Types map[string]FileType
// Sops is the sops binary; "sops" (on PATH) when empty.
Sops string
}
@@ -113,6 +106,8 @@ func (p *Provider) load() {
roots = append(roots, root)
}
}
// The repo lazysecrets started in, if any: marked, so the UI can
// put it on top, with its files under Dir first.
here, inRepo := p.current()
if inRepo {
seen[here.root] = true
@@ -142,6 +137,7 @@ func (p *Provider) load() {
add(root)
}
}
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil {
add(root)
@@ -192,19 +188,12 @@ func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
return provider.Source{}, false
}
src := p.source(r)
files, err := p.Containers(ctx, src)
if err == nil && len(files) == 0 {
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 {
return provider.Source{}, false
}
src.Hidden = err == nil && allHidden(files)
return src, true
}
// allHidden reports whether a repo's files are all hidden: the repo is too.
func allHidden(files []provider.Container) bool {
return !slices.ContainsFunc(files, func(c provider.Container) bool { return !c.Hidden })
}
func (p *Provider) source(r repoInfo) provider.Source {
return provider.Source{
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
@@ -217,11 +206,13 @@ func parallel(n int, f func(i int)) {
var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
f(i)
})
}()
}
wg.Wait()
}
@@ -273,7 +264,6 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
parallel(len(srcs), func(i int) {
files, err := p.Containers(ctx, srcs[i])
keep[i] = err != nil || len(files) > 0
srcs[i].Hidden = err == nil && allHidden(files)
})
var out []provider.Source
for i, src := range srcs {
@@ -333,13 +323,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
case isSops:
return // a SOPS file where .sops.yaml says SOPS files don't go
case isEnvNamed(f):
case IsEnv(f):
c.Detail = provider.DetailPlainEnv
c.Hidden = isExample(f)
case isConfig(f):
t, _ := configTypeOf(f)
c.Detail = t.detail(f)
c.Hidden = isExample(f) || p.configHidden(t, r.root, path)
default:
return
}
@@ -376,29 +361,30 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
if err != nil {
return nil, err
}
meta, isSops := parseMeta(c.ID, b)
if t, ok := configTypeOf(c.ID); ok && !isSops {
return p.configObjects(t, c, b)
}
meta, isSops := readMeta(c.ID)
keys, err := parseKeys(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
if isSops {
attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}
if !isSops { // a plain env file
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
})
}
return out, nil
}
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
o := provider.Object{
Container: c, Type: provider.TypeSecret,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
}
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"
if !k.encrypted {
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
}
out = append(out, o)
}
@@ -413,29 +399,18 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
return nil, provider.ErrUnsupported
}
// Value reads one key: from a plain file, decrypting a Jasypt value; from
// a SOPS file, with sops.
// Value decrypts one key 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)
file = o.Container.ID
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
extract = extractFor(file, o.Name)
}
if _, isSops := readMeta(file); !isSops {
read := plainValue
if t, ok := configTypeOf(file); ok {
read = t.value
}
v, err := read(file, o.Name)
if err != nil {
return v, err
}
return jasyptValue(file, o.Name, v)
}
if !ok {
var err error
if extract, err = extractOf(file, o.Name); err != nil {
return provider.SecretValue{}, err
}
return plainValue(file, o.Name)
}
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
@@ -459,18 +434,12 @@ func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, er
return os.ReadFile(f.Path)
}
// filePath is the file c is. A container made from a link has no ID: the
// file is its path in the repo.
func filePath(c provider.Container) string {
if c.ID != "" {
return c.ID
}
return filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
// File is the file c is, and the line o's key is on.
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := filePath(c)
path := c.ID
if path == "" { // made from a link
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
f := provider.File{Path: path}
if _, isSops := readMeta(path); isSops {
f.Sops = p.s.Sops
@@ -478,12 +447,6 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File
if o == nil {
return f, nil
}
if t, ok := configTypeOf(path); ok && f.Sops == "" {
if k, err := t.keyNamed(path, o.Name); err == nil {
f.Line = k.line
}
return f, nil
}
b, err := os.ReadFile(path)
if err != nil {
return f, err
@@ -637,10 +600,6 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile struct {
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
Spring FileType `yaml:"spring"`
Compose FileType `yaml:"compose"`
Dotnet FileType `yaml:"dotnet"`
Sharing struct {
// Key is the link to the secret that encrypts the repo's
// shared env files (package share).
@@ -648,45 +607,36 @@ type lazysecretsFile struct {
} `yaml:"sharing"`
}
func (f lazysecretsFile) types() map[string]FileType {
return map[string]FileType{"spring": f.Spring, "compose": f.Compose, "dotnet": f.Dotnet}
}
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) {
f, err := readLazysecretsFile(root)
return f.Sharing.Key, err
}
// readLazysecretsFile reads the repo's .lazysecrets.yaml; without one, it's
// empty.
func readLazysecretsFile(root string) (lazysecretsFile, error) {
var f lazysecretsFile
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) {
return f, nil
return "", nil
}
if err != nil {
return f, err
return "", err
}
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return f, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return f, nil
return f.Sharing.Key, nil
}
// selector says which files to look at: user (the include/exclude of the
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
f, err := readLazysecretsFile(root)
if err != nil {
return nil, nil, err
}
include, exclude := p.s.Include, p.s.Exclude
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude
}
}
inc, err := globs(include)
if err != nil {
return nil, nil, err
@@ -721,7 +671,7 @@ func envFiles(root string, tracked []string) []string {
dirs := map[string]bool{".": true}
for _, f := range tracked {
isTracked[f] = true
if isEnvNamed(f) {
if IsEnv(f) {
out = append(out, f)
}
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
@@ -735,7 +685,7 @@ func envFiles(root string, tracked []string) []string {
}
for _, e := range entries {
f := path.Join(d, e.Name())
if e.Type().IsRegular() && !isTracked[f] && isEnvNamed(f) {
if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) {
out = append(out, f)
}
}
@@ -770,29 +720,16 @@ func inPackages(f string) bool {
// .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies.
func IsEnv(f string) bool {
return isEnvNamed(f) && !isExample(f)
}
// isEnvNamed reports whether a file outside dependencies is named like an
// env file, examples too.
func isEnvNamed(f string) bool {
base := strings.ToLower(path.Base(f))
return !inPackages(f) && (base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env"))
}
var exampleWords = []string{"example", "sample", "template", "dist", "tmpl"}
// isExample reports whether a file is an example or template of another:
// .env.example, docker-compose.example.yml. They're listed hidden: someone
// may have put a real value in one.
func isExample(f string) bool {
base := strings.ToLower(path.Base(f))
for _, w := range exampleWords {
if strings.HasSuffix(base, "."+w) || strings.Contains(base, "."+w+".") {
return true
}
}
if inPackages(f) {
return false
}
base := strings.ToLower(path.Base(f))
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} {
if strings.HasSuffix(base, ext) {
return false
}
}
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
}
// plainValue reads a key from a plain env file.
@@ -970,21 +907,12 @@ func readMeta(file string) (sopsMeta, bool) {
return sopsMeta{}, false // not a format sops writes: skip reading
}
b, err := os.ReadFile(file)
if err != nil {
return sopsMeta{}, false
}
return parseMeta(file, b)
}
// parseMeta is readMeta, of the file's contents b.
func parseMeta(file string, b []byte) (sopsMeta, bool) {
format := formatOf(file)
if len(b) > maxFileSize || format == "" || !bytes.Contains(b, []byte("sops")) {
if err != nil || !bytes.Contains(b, []byte("sops")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
}
var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch format {
switch formatOf(file) {
case "yaml", "json":
var doc struct {
Sops map[string]any `yaml:"sops"`
@@ -1088,7 +1016,6 @@ type key struct {
name string // db.password, list[0], with . [ ] \ in keys escaped
extract string // sops --extract: ["db"]["password"]
encrypted bool
jasypt bool // a plain env file's ENC(...) value
line int // 1-based
}
@@ -1113,8 +1040,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
if !ok || strings.HasPrefix(k, "sops_") {
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["), jasypt: jasypt.Is(v), line: i + 1})
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
}
return out, nil
case "ini":
@@ -1179,23 +1105,48 @@ func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractOf is the sops --extract path of the key named name, for objects
// made from a link.
func extractOf(file, name string) (string, error) {
b, err := os.ReadFile(file)
if err != nil {
return "", err
// extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
// its names are the keys as they are.
func extractFor(file, name string) string {
if formatOf(file) == "dotenv" {
return quote(name)
}
keys, err := parseKeys(file, b)
if err != nil {
return "", err
}
for _, k := range keys {
if k.name == name {
return k.extract, nil
var b, part strings.Builder
flush := func() {
if part.Len() > 0 {
b.WriteString(quote(part.String()))
part.Reset()
}
}
return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
for i := 0; i < len(name); i++ {
switch c := name[i]; c {
case '\\':
if i+1 < len(name) {
i++
}
part.WriteByte(name[i])
case '.':
flush()
case '[':
end := strings.IndexByte(name[i:], ']')
if end < 0 || !digits(name[i+1:i+end]) {
part.WriteByte(c)
continue
}
flush()
b.WriteString(name[i : i+end+1])
i += end
default:
part.WriteByte(c)
}
}
flush()
return b.String()
}
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
}
func absPath(p string) string {
+8 -37
View File
@@ -237,25 +237,6 @@ func TestDecrypt(t *testing.T) {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
// A dotenv file's export lines: sops keeps export in the key.
os.WriteFile(plain, []byte("export TOKEN=s3cret\n"), 0o600)
enc, err = exec.Command("sops", "encrypt", "--age", recipient, "--input-type", "dotenv", "--output-type", "dotenv", plain).Output()
if err != nil {
t.Fatal(err)
}
envFile := filepath.Join(root, "prod.env")
os.WriteFile(envFile, enc, 0o600)
envC := provider.Container{Source: srcs[0], ID: envFile, Name: "prod.env"}
envKeys, _ := p.Objects(ctx, envC, provider.TypeSecret)
if len(envKeys) != 1 {
t.Fatalf("env keys: %v", envKeys)
}
for _, o := range []provider.Object{envKeys[0], {Container: provider.Container{Source: srcs[0], Name: "prod.env"}, Name: "TOKEN"}} {
if v, err := p.Value(ctx, o); err != nil || v.Reveal() != "s3cret" {
t.Errorf("export line (link: %t): %q %v", o.ID == "", v.Reveal(), err)
}
}
// Without the key: sops' own error.
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
@@ -292,12 +273,9 @@ func TestPlainEnvFiles(t *testing.T) {
got := map[string]string{}
for _, c := range files {
got[c.Name] = c.Detail
if c.Hidden != (c.Name == ".env.example") {
t.Errorf("%s: hidden %t", c.Name, c.Hidden)
}
}
if len(got) != 3 || got[".env"] != "env · plain text" || got[".env.example"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
t.Fatalf("files %v (want .env, the example, hidden, and the sops file; not node_modules)", got)
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
}
var env provider.Container
@@ -322,7 +300,7 @@ func TestPlainEnvFiles(t *testing.T) {
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env", ".env.example"}) {
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
t.Errorf("with rules elsewhere: %v", names)
}
}
@@ -576,22 +554,15 @@ func TestKeyNamesEscaped(t *testing.T) {
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
keys, err = parseKeys(".env", []byte("spring.url=a\nexport TOKEN=b\nSPACED = c\n"))
if err != nil {
t.Fatal(err)
}
got = nil
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
}
want = `spring.url ["spring.url"]|TOKEN ["export TOKEN"]|SPACED ["SPACED "]`
if strings.Join(got, "|") != want {
t.Errorf("dotenv keys = %s\nwant %s", strings.Join(got, "|"), want)
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
-236
View File
@@ -1,236 +0,0 @@
package repo
import (
"bytes"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode"
"go.yaml.in/yaml/v3"
)
// Spring Boot config files: application*.properties/.yml and bootstrap*,
// their keys filtered by the secret patterns (patterns.go).
// springName is application.yml, application-prod.properties, bootstrap.yaml
// and the like; not applications.yaml (Argo CD's) or application.yml.example.
var springName = regexp.MustCompile(`^(application|bootstrap)(-[^.]+)?\.(properties|ya?ml)$`)
// IsSpring reports whether f is named like a Spring Boot config file, and
// isn't a Helm chart's template of one.
func IsSpring(f string) bool {
return springName.MatchString(strings.ToLower(path.Base(f))) && path.Base(path.Dir(f)) != "templates"
}
func springFormat(f string) string {
if strings.ToLower(filepath.Ext(f)) == ".properties" {
return "properties"
}
return "yaml"
}
var springType = configType{
name: "spring", is: IsSpring, format: springFormat, parse: parseSpring,
jasyptSetting: func(string) string { return "jasypt.encryptor.password" },
placeholder: springPlaceholder,
}
// springPlaceholderRe is Spring's ${name} or ${name:default}.
var springPlaceholderRe = regexp.MustCompile(`^\$\{([^:}]+)(:([^}]*))?\}$`)
func springPlaceholder(v string) (name, def string, hasDef, ok bool) {
m := springPlaceholderRe.FindStringSubmatch(v)
if m == nil {
return "", "", false, false
}
return m[1], m[3], m[2] != "", true
}
func parseSpring(file string, b []byte) ([]configKey, error) {
var keys []configKey
var err error
if springFormat(file) == "properties" {
keys = parseProperties(b)
} else {
keys, err = parseSpringYAML(b)
}
for i := range keys {
keys[i].ref = reference(springPlaceholder, keys[i].value)
}
return keys, err
}
// springDocs names the keys of a multi-document file's later documents
// after the profile they're for, so each name is one key:
// spring.datasource.password (prod).
func springDocs(docs [][]configKey) []configKey {
var out []configKey
for i, doc := range docs {
suffix := ""
if i > 0 {
suffix = fmt.Sprintf(" (document %d)", i+1)
for _, k := range doc {
if k.key == "spring.config.activate.on-profile" || k.key == "spring.profiles" {
suffix = " (" + k.value + ")"
}
}
}
for _, k := range doc {
k.name = k.key + suffix
out = append(out, k)
}
}
return out
}
// mavenFiltered is a value Maven fills in at build time, @project.version@:
// not YAML until it's quoted.
var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`)
func parseSpringYAML(b []byte) ([]configKey, error) {
b = mavenFiltered.ReplaceAllFunc(b, func(m []byte) []byte {
sub := mavenFiltered.FindSubmatch(m)
return fmt.Appendf(nil, "%s'%s'", sub[1], bytes.ReplaceAll(sub[2], []byte("'"), []byte("''")))
})
dec := yaml.NewDecoder(bytes.NewReader(b))
var docs [][]configKey
for {
var doc yaml.Node
err := dec.Decode(&doc)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
var keys []configKey
if len(doc.Content) > 0 {
springWalk(doc.Content[0], "", &keys)
}
docs = append(docs, keys)
}
return springDocs(docs), nil
}
// springWalk names keys the way Spring does: nested keys joined by dots,
// list items by index (servers[0]).
func springWalk(n *yaml.Node, name string, out *[]configKey) {
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
child := n.Content[i].Value
if name != "" {
child = name + "." + child
}
springWalk(n.Content[i+1], child, out)
}
case yaml.SequenceNode:
for i, c := range n.Content {
springWalk(c, fmt.Sprintf("%s[%d]", name, i), out)
}
case yaml.AliasNode:
if n.Alias != nil {
springWalk(n.Alias, name, out)
}
case yaml.ScalarNode:
if name != "" {
*out = append(*out, configKey{key: name, value: n.Value, line: n.Line})
}
}
}
// parseProperties reads a Java .properties file: key=value, key: value or
// key value, lines continued with a trailing backslash, # and ! comments,
// and #--- between documents (Spring Boot 2.4+).
func parseProperties(b []byte) []configKey {
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
var docs [][]configKey
var doc []configKey
for i := 0; i < len(lines); i++ {
start := i + 1
line := strings.TrimLeftFunc(lines[i], unicode.IsSpace)
if line == "#---" || line == "!---" {
docs, doc = append(docs, doc), nil
continue
}
if line == "" || line[0] == '#' || line[0] == '!' {
continue
}
for continued(line) && i+1 < len(lines) {
i++
line = line[:len(line)-1] + strings.TrimLeftFunc(lines[i], unicode.IsSpace)
}
k, v := splitProperty(line)
doc = append(doc, configKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start})
}
return springDocs(append(docs, doc))
}
// continued reports whether the line ends in an odd number of backslashes.
func continued(line string) bool {
n := len(line) - len(strings.TrimRight(line, `\`))
return n%2 == 1
}
// splitProperty cuts a logical line at its first unescaped =, : or
// whitespace; whitespace around the separator isn't part of either side.
func splitProperty(line string) (key, value string) {
const space = " \t\f"
for i := 0; i < len(line); i++ {
switch c := line[i]; {
case c == '\\':
i++
case c == '=' || c == ':':
return line[:i], strings.TrimLeft(line[i+1:], space)
case strings.IndexByte(space, c) >= 0:
rest := strings.TrimLeft(line[i:], space)
if rest != "" && (rest[0] == '=' || rest[0] == ':') {
rest = strings.TrimLeft(rest[1:], space)
}
return line[:i], rest
}
}
return line, ""
}
func unescapeProperty(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '\\' || i+1 == len(s) {
b.WriteByte(s[i])
continue
}
i++
switch s[i] {
case 't':
b.WriteByte('\t')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 'f':
b.WriteByte('\f')
case 'u':
if i+5 <= len(s) {
if r, err := strconv.ParseUint(s[i+1:i+5], 16, 32); err == nil {
b.WriteRune(rune(r))
i += 4
continue
}
}
b.WriteByte('u')
default:
b.WriteByte(s[i])
}
}
return b.String()
}
-255
View File
@@ -1,255 +0,0 @@
package repo
import (
"context"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestIsSpring(t *testing.T) {
for f, want := range map[string]bool{
"src/main/resources/application.yml": true,
"application-prod.properties": true,
"config/application-local.yaml": true,
"bootstrap.yml": true,
"applicationset/dev/applications.yaml": false, // Argo CD's
"chart/templates/application.yaml": false, // Helm's
"application.yml.example": false,
"application.json": false,
"src/main/resources/applicationContext.xml": false,
} {
if IsSpring(f) != want {
t.Errorf("%s: %t", f, !want)
}
}
}
func springNames(keys []configKey) map[string]string {
out := map[string]string{}
for _, k := range keys {
out[k.name] = k.value
}
return out
}
func TestParseProperties(t *testing.T) {
got := springNames(parseProperties([]byte(`# a comment
! another
a=1
b : 2
c 3
d\:e=4
long=one, \
two
unicode=café
empty=
#---
spring.config.activate.on-profile=prod
a=prod-1
`)))
want := map[string]string{
"a": "1", "b": "2", "c": "3", "d:e": "4", "long": "one, two", "unicode": "café", "empty": "",
"spring.config.activate.on-profile (prod)": "prod", "a (prod)": "prod-1",
}
if !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestParseSpringYAML(t *testing.T) {
keys, err := parseSpringYAML([]byte(`info.version: @project.version@
artifacts:
- @project.artifactId@
spring:
datasource:
password: ${DB_PASSWORD}
servers:
- name: a
secret: x
logging.level:
com.example: DEBUG
---
spring:
config.activate.on-profile: prod
datasource:
password: hunter2
---
other: y
`))
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"info.version": "@project.version@", "artifacts[0]": "@project.artifactId@",
"spring.datasource.password": "${DB_PASSWORD}", "servers[0].name": "a", "servers[0].secret": "x",
"logging.level.com.example": "DEBUG",
"spring.config.activate.on-profile (prod)": "prod", "spring.datasource.password (prod)": "hunter2",
"other (document 3)": "y",
}
if got := springNames(keys); !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestSpringDefaults(t *testing.T) {
f, err := (&Provider{}).filterFor(t.TempDir(), "spring")
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]string{
"spring.datasource.password": "x",
"spring.security.oauth2.client.registration.a.client-secret": "x",
"profapp.payment.checkoutKey": "x",
"app.clients.sgig.sgig-passord": "x",
"jasypt.encryptor.password": "${JASYPT_ENCRYPTOR_PASSWORD}",
"spring.datasource.url": "jdbc:postgresql://app:hunter2@db:5432/app",
"app.servicebus.connection": "Endpoint=sb://x/;SharedAccessKeyName=a;SharedAccessKey=b",
"app.name": "ENC(abc==)",
} {
if !f.secret(key, value) {
t.Errorf("%s=%s isn't listed", key, value)
}
}
for key, value := range map[string]string{
"spring.security.oauth2.client.provider.a.token-uri": "https://idp/token",
"spring.security.oauth2.client.registration.a.authorization-grant-type": "client_credentials",
"app.frontend.keycloak.url": "https://keycloak",
"server.port": "8080",
"spring.datasource.url": "jdbc:postgresql://db:5432/app",
} {
if f.secret(key, value) {
t.Errorf("%s=%s is listed", key, value)
}
}
}
func TestSpringFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"src/main/resources/application.yml": `server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://db/app
password: ${DB_PASSWORD}
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD}
app:
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",
})
ctx := context.Background()
details := func(p *Provider, c provider.Container) map[string]provider.Attr {
attrs, err := p.Details(ctx, c)
if err != nil {
t.Fatal(err)
}
out := map[string]provider.Attr{}
for _, a := range attrs {
out[a.Label] = a
}
return out
}
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, err := p.Containers(ctx, srcs[0])
if err != nil {
t.Fatal(err)
}
var files []string
for _, c := range cs {
files = append(files, c.Name+" · "+c.Detail)
}
if want := []string{
"src/main/resources/application-local.properties · spring · properties",
"src/main/resources/application.yml · spring · yaml",
}; !slices.Equal(files, want) {
t.Fatalf("files %v\nwant %v", files, want)
}
local, yml := cs[0], cs[1]
listed, _ := p.Objects(ctx, yml, provider.TypeSecret)
keys := shown(listed)
var names []string
for _, k := range keys {
names = append(names, strings.TrimSpace(k.Name+" "+k.Detail))
}
if len(listed) != 5 || !listed[0].Hidden || listed[0].Name != "server.port" {
t.Errorf("hidden keys: %+v", listed)
}
if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) {
t.Errorf("keys %v, want %v", names, want)
}
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"}
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "${DB_PASSWORD}" {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
if f, _ := p.File(yml, &keys[1]); f.Line != 9 || f.Sops != "" {
t.Errorf("file: %+v", f)
}
// References and Jasypt aren't plain text in git; a password is.
if d := details(p, yml); d["hidden keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" {
t.Errorf("yml: %+v", d)
}
if d := details(p, local); !d["git"].Warn {
t.Errorf("local: %+v", d)
}
// The config's patterns, and the repo's over them.
all := []string{".*"}
p = New(Settings{Dir: root, SecretPatterns: SecretPatterns{Keys: &all}})
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 5 {
t.Errorf("every key: %d", len(shown(keys)))
}
lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"url$\"]\n values: []\n"), 0o644)
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 1 || shown(keys)[0].Name != "spring.datasource.url" {
t.Errorf("repo's patterns: %v", keys)
}
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"(\"]\n"), 0o644)
if _, err := p.Objects(ctx, yml, provider.TypeSecret); err == nil || !strings.Contains(err.Error(), ".lazysecrets.yaml: spring.secretPatterns.keys[0]") {
t.Errorf("bad pattern: %v", err)
}
}
func shown(objects []provider.Object) []provider.Object {
return slices.DeleteFunc(slices.Clone(objects), func(o provider.Object) bool { return o.Hidden })
}
func TestHiddenFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"a/application.yml": "server:\n port: 8080\n",
"b/application.yml": "spring.datasource.password: x\n",
})
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
if len(srcs) != 1 || srcs[0].Hidden {
t.Fatalf("sources: %+v", srcs)
}
cs, _ := p.Containers(ctx, srcs[0])
if len(cs) != 2 || !cs[0].Hidden || cs[1].Hidden {
t.Errorf("files: %+v", cs)
}
// A repo with only hidden files is hidden too.
root = gitRepo(t, map[string]string{"application.yml": "server:\n port: 8080\n"})
srcs, _ = New(Settings{Dir: root}).Sources(ctx)
if len(srcs) != 1 || !srcs[0].Hidden {
t.Errorf("hidden repo: %+v", srcs)
}
}
+2
View File
@@ -21,6 +21,8 @@ func NewSecretValue(b []byte) SecretValue {
// 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) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked }
+13 -11
View File
@@ -56,10 +56,6 @@ type Ref struct {
// Share is a share link's sealed files, as in the link; the other
// fields are empty. Package share reads it.
Share string
// Account, when set, is the provider (its ID) to open or read it
// with when several could, from lazysecrets list. It isn't part of
// the link.
Account string
}
// IsShare reports whether r is a share link.
@@ -92,9 +88,15 @@ func (r Ref) ContainerName() string {
// Container.Detail.
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
var collections = map[provider.ObjectType]string{
provider.TypeSecret: "secrets",
provider.TypeKey: "keys",
provider.TypeCertificate: "certificates",
}
func typeOf(collection string) (provider.ObjectType, bool) {
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
if strings.EqualFold(t.Collection(), collection) {
for t, c := range collections {
if strings.EqualFold(c, collection) {
return t, true
}
}
@@ -112,7 +114,7 @@ func (r Ref) IdentifierURL() string {
if r.Type == "" {
return u + "/"
}
for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} {
for _, seg := range []string{collections[r.Type], r.Name, r.Version} {
if seg != "" {
u += "/" + url.PathEscape(seg)
}
@@ -134,7 +136,7 @@ func (r Ref) String() string {
segs = append(segs, r.Scope, r.Name)
default:
if r.Type != "" {
segs = append(segs, r.Type.Collection())
segs = append(segs, collections[r.Type])
}
segs = append(segs, r.Name, r.Version)
}
@@ -267,7 +269,7 @@ func parseLink(s string) (Ref, error) {
r.Host = strings.ToLower(segs[0])
rest := segs[1:]
switch host := strings.ToLower(u.Host); host {
switch strings.ToLower(u.Host) {
case "azure":
r.Kind = provider.KindAzure
if r.Host == "subscriptions" {
@@ -294,11 +296,11 @@ func parseLink(s string) (Ref, error) {
}
case "databricks", "repo":
r.Kind = provider.KindDatabricks
if host == "repo" {
if strings.EqualFold(u.Host, "repo") {
r.Kind = provider.KindRepo
}
if len(rest) > 2 {
return Ref{}, fmt.Errorf("%s link has too many parts", host)
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host))
}
if len(rest) > 0 {
r.Scope = rest[0]
+2 -7
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// Rules are what a key's value must be to share with it: random enough
@@ -41,18 +40,14 @@ func (r Rules) Check(key []byte) error {
kinds++
}
}
n := utf8.RuneCountInString(s)
n := len([]rune(s))
var has, needs []string
if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n))
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
}
if kinds < r.MinClasses {
kind := "kinds"
if kinds == 1 {
kind = "kind"
}
has = append(has, fmt.Sprintf("%d %s of character", kinds, kind))
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
}
if len(has) == 0 {
+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)")
}
n, size := binary.Uvarint(b[1:])
if size <= 0 {
if size <= 0 || n > uint64(len(b)) {
return Sealed{}, bad
}
rest := b[1+size:]
-7
View File
@@ -43,13 +43,6 @@ type Node struct {
// alone isn't.
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
// UsedLately reports whether the item was used within the last 90 days
// (what Recent lists). Starred and noted records are kept longer, so a
// use alone doesn't say.
func (n Node) UsedLately(now time.Time) bool {
return !n.LastUsed.IsZero() && now.Sub(n.LastUsed) <= usageMaxAge
}
// StarredFor reports whether the item is starred for account.
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
-12
View File
@@ -119,15 +119,3 @@ func TestStarsPerAccount(t *testing.T) {
t.Errorf("unstar alice: %v", n.StarredBy)
}
}
func TestUsedLately(t *testing.T) {
now := time.Now()
for ago, want := range map[time.Duration]bool{time.Hour: true, 89 * 24 * time.Hour: true, 2 * 365 * 24 * time.Hour: false} {
if got := (Node{LastUsed: now.Add(-ago)}).UsedLately(now); got != want {
t.Errorf("used %v ago: %v", ago, got)
}
}
if (Node{}).UsedLately(now) {
t.Error("never used is lately")
}
}
+38 -59
View File
@@ -3,11 +3,11 @@
package app
import (
"cmp"
"context"
"errors"
"fmt"
"image/color"
"math"
"os"
"os/exec"
"strings"
@@ -131,15 +131,8 @@ type Options struct {
HandlerSetup *HandlerSetup
// NoHistory stops counting uses of items for the Recent list.
NoHistory bool
// RecentIcon marks items used lately with ◷ in the lists
// (ui.recentIcon).
RecentIcon bool
// Open is a reference to open at startup (lazysecrets open <ref>).
Open *ref.Ref
// OpenAction, if set, is what to do with Open, rather than asking:
// go to it, and maybe copy or show its value (OpenActions). It ends in
// the list, like Go to, rather than on the details.
OpenAction string
// Styles, if set, replaces the default theme (styles.FromTheme), and
// DefaultFg the terminal's default text colour.
Styles *styles.Styles
@@ -213,9 +206,6 @@ type Model struct {
statusW int // the status pane's width: the left column's, zoomed or not
changelog []panes.Field
cacheDirty bool
// cacheDropped are cache keys deleted since the last save, so saving
// deletes them from the file too.
cacheDropped map[string]bool
sourcesAt map[string]time.Time
placeDir string
savedPlace string // the link last saved as the place
@@ -232,7 +222,6 @@ type Model struct {
// provider finds it (provider.Current), before Sources lists them all.
current *provider.Source
flatSources bool // Sources pane without provider groups
showHidden bool // H: what providers hide is listed too, dimmed
sourcesFavs bool // Sources pane on its Favourites tab
history bool // count uses of items (usage.go)
onActive func()
@@ -294,8 +283,6 @@ type Model struct {
prompt *modal // popup that has the keyboard
linked []provider.Container // vaults opened from links, this session
open *ref.Ref // reference to open at startup
recentIcon bool // mark items used lately (◷)
openAction string // and what to do with it, or ask
shareKey string // the config's sharing.key
shareRules share.Rules // and sharing.keyRules
@@ -428,8 +415,7 @@ func New(providers []provider.Provider, opts Options) Model {
if m.store == nil {
m.store, _ = store.Load("")
}
m.recentIcon = opts.RecentIcon
m.sources = newSourcesList(iconSet, m.store, m.recentIcon)
m.sources = newSourcesList(iconSet, m.store)
m.sources.Empty = "no sources found"
m.collapsed = map[string]bool{}
m.flatSources = opts.FlatSources
@@ -461,7 +447,7 @@ func New(providers []provider.Provider, opts Options) Model {
}
m.defaultFg = opts.DefaultFg
m.help = newHelp(m.st)
m.open, m.openAction = opts.Open, opts.OpenAction
m.open = opts.Open
saved := m.store.Layout()
m.split, m.heights = saved.Split, saved.Heights
@@ -470,7 +456,6 @@ func New(providers []provider.Provider, opts Options) Model {
Filter: func(c provider.Container) string { return c.Name },
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
Render: m.containerRender(false),
Hidden: func(c provider.Container) bool { return c.Hidden },
})
m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{
@@ -480,7 +465,6 @@ func New(providers []provider.Provider, opts Options) Model {
// Typing "expir" finds what has expired or soon will.
FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
Render: m.objectRender(),
Hidden: func(o provider.Object) bool { return o.Hidden },
})
m.secrets.Empty = "no secrets"
m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret
@@ -537,26 +521,18 @@ func (m Model) Init() tea.Cmd {
cmds = append(cmds, m.fetchLatest(false))
}
if m.open != nil {
msg := openRefMsg{ref: *m.open, action: m.openAction}
cmds = append(cmds, func() tea.Msg { return msg })
r := *m.open
cmds = append(cmds, func() tea.Msg { return openRefMsg{r} })
}
return tea.Batch(cmds...)
}
// openRefMsg starts opening a reference from outside the key handler:
// asking what to do with it, or doing action (OpenActions).
type openRefMsg struct {
ref ref.Ref
action string
}
// OpenActions are what lazysecrets open --action takes, the Open link
// menu's choices that go there.
var OpenActions = map[string]linkAction{"go": actGo, "copy": actCopy, "show": actShow}
// openRefMsg starts opening a reference from outside the key handler.
type openRefMsg struct{ ref ref.Ref }
// OpenLink is a message offering r in the Open link popup, for links
// handed over by the link handler (Program.Send).
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{ref: r} }
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
@@ -613,11 +589,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case openRefMsg:
m.deferHandlerSetup() // the link first
if act, ok := OpenActions[msg.action]; ok && !msg.ref.IsShare() {
cmds = append(cmds, m.findRef(msg.ref, act))
} else {
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
}
case resolvedMsg:
m.handleResolved(msg)
@@ -657,7 +629,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case detailsMsg:
if msg.req == m.reqID[slotDetails] {
m.cancelReq(slotDetails)
m.done(slotDetails)
}
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again
@@ -688,13 +660,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotContainers] {
break
}
m.cancelReq(slotContainers)
m.done(slotContainers)
if msg.err != nil {
m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err)
}
uncache(&m, m.cache.containers, msg.key)
delete(m.cache.containers, msg.key)
m.containers.SetError(msg.err)
} else {
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
@@ -706,7 +678,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotObjects] {
break
}
m.cancelReq(slotObjects)
m.done(slotObjects)
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.closeVersions()
@@ -715,7 +687,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err)
}
uncache(&m, m.cache.objects, msg.key)
delete(m.cache.objects, msg.key)
m.secrets.SetError(msg.err)
} else {
if msg.bound != nil {
@@ -727,7 +699,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.versionsOf != nil {
m.setVersions(msg.items)
} else {
m.secrets.SetItems(m.starredObjectsFirst(msg.items))
m.secrets.SetItems(m.starredFirst(msg.items))
cmds = append(cmds, m.refreshShown(before))
}
}
@@ -736,7 +708,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotValue] {
break
}
m.cancelReq(slotValue)
m.done(slotValue)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -751,7 +723,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotVersions] {
break
}
m.cancelReq(slotVersions)
m.done(slotVersions)
// Errors and unsupported providers just leave the count out.
if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
@@ -763,7 +735,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotDescribe] {
break
}
m.cancelReq(slotDescribe)
m.done(slotDescribe)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -778,7 +750,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotCopy] {
break
}
m.cancelReq(slotCopy)
m.done(slotCopy)
if msg.err != nil {
m.noteErr(msg.err)
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
@@ -890,7 +862,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Quit):
return nil, true
case key.Matches(msg, k.Help):
return m.showKeybindingsFor(""), false
return m.showKeybindings(), false
case key.Matches(msg, k.Up):
m.move(-1)
case key.Matches(msg, k.Down):
@@ -982,8 +954,6 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.cycleZoom(-1)
case key.Matches(msg, k.SourcesView):
m.toggleSourcesView()
case key.Matches(msg, k.ToggleHidden):
m.toggleHidden()
case key.Matches(msg, k.Versions):
if m.versionsOf != nil {
m.closeVersions()
@@ -1172,11 +1142,14 @@ func (m *Model) syncSelection() tea.Cmd {
if m.onLinks() {
m.showLinkedContainers()
} else if src, ok := m.selectedSource(); ok {
m.containers.Title = "[2] Vaults"
m.containers.Empty = "no vaults found"
switch src.Kind {
case provider.KindDatabricks:
m.containers.Title = "[2] Scopes"
m.containers.Empty = "no secret scopes"
case provider.KindRepo:
m.containers.Title = "[2] Files"
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
}
cmds = append(cmds, m.showContainers(src, id))
@@ -1261,7 +1234,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
m.secrets.SetLoading()
return m.loadObjects(c, m.objType, key)
}
m.secrets.SetItems(m.starredObjectsFirst(e.value))
m.secrets.SetItems(m.starredFirst(e.value))
m.secrets.Select(m.cache.cursor[key])
if e.fresh(m.now()) {
return nil
@@ -1321,7 +1294,8 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
})
}
// closeVersions returns the Secrets pane to the object list.
// closeVersions returns the Secrets pane to the object list, with the
// cursor on the object whose versions were shown.
func (m *Model) closeVersions() {
m.versionsOf = nil
}
@@ -1518,6 +1492,9 @@ 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
// flight.
func (m *Model) loadSources() tea.Cmd {
@@ -1637,7 +1614,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
}
return nil
}
@@ -1680,8 +1657,12 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
// clipTick fires when the countdown's displayed second next changes.
func (m *Model) clipTick() tea.Cmd {
wait := m.clipDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.clipToken
return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} })
return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} })
}
func (m *Model) cancelClipClear() {
@@ -1788,15 +1769,13 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else {
reveal := "show"
if m.detail.State() == panes.ValueShown {
reveal = "hide"
}
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), reveal: reveal}.ShortHelp(), 2)
revealed := m.detail.State() == panes.ValueShown
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
}
var lines []string
if !m.clipDeadline.IsZero() {
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, secondsLeft(m.clipDeadline, m.now())))))
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
}
if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash))
+19 -52
View File
@@ -536,7 +536,7 @@ func TestBackspaceGoesBack(t *testing.T) {
}
// While typing a filter, backspace deletes a character instead.
d.keys("/")
d.typeText("api-x")
d.typeText("apx")
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 {
t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len())
@@ -666,7 +666,7 @@ func TestFilter(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.typeText("api-k")
d.typeText("api")
if d.m.secrets.Len() != 1 {
t.Fatalf("visible = %d, want 1", d.m.secrets.Len())
}
@@ -684,9 +684,9 @@ func TestFilter(t *testing.T) {
}
d.keys("esc") // leave filter mode, clearing the query
d.keys("/")
d.typeText("api-k")
d.typeText("api")
d.keys("enter", "esc") // apply, then esc clears the applied filter
if d.m.secrets.Len() != 4 {
if d.m.secrets.Len() != 3 {
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
}
if _, _, s := d.selected(); s != "api-key" {
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.typeText("a") // all four secrets contain an a
if d.m.secrets.Len() != 4 {
d.typeText("a") // all three secrets contain an a
if d.m.secrets.Len() != 3 {
t.Fatalf("visible = %d", d.m.secrets.Len())
}
_, _, initial := d.selected() // kept from before filtering: the middle row
var seen []string
for _, k := range []rune{'j', 'j', 'j', 'k'} {
for _, k := range []rune{'j', 'j', 'k'} {
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
_, _, s := d.selected()
seen = append(seen, s)
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
if !d.m.secrets.Filtering() {
t.Fatal("ctrl+j/k left filter mode")
}
// down to the bottom, stay there, back up one
if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
// down to the bottom, stay there, back up to where it started
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial {
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen)
}
if !strings.Contains(d.screen(), "ctrl+j/n down") {
t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
@@ -848,7 +848,7 @@ func TestCopyAndRevealWhileFiltering(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.typeText("api-k")
d.typeText("api")
d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
if d.clip().content != "sk_live_51HxDEMO0000000000000000" {
t.Errorf("ctrl+y copied %q", d.clip().content)
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
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)
}
if !strings.Contains(lastLines(scr, 1), "z show") {
t.Errorf("help bar: %q", lastLines(scr, 1))
if !strings.Contains(lastLine(scr), "z show") {
t.Errorf("help bar: %q", lastLine(scr))
}
d.keys("z")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar)
}
d.keys("z")
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr)
}
bar := lastLines(scr, 1)
bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar)
}
@@ -1187,8 +1187,7 @@ func TestDetailsFollowFocus(t *testing.T) {
func TestStatusLineCountsRepos(t *testing.T) {
t.Parallel()
d := newDriver(t, fake.Azure(0), fake.Repos())
// The status line, not the pane, which an expired secret's ⚠ fills.
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 4 repos") {
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") {
t.Errorf("status line: %q", line)
}
d = newDriver(t, fake.Azure(0))
@@ -1203,8 +1202,8 @@ func TestStatusPane(t *testing.T) {
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
OpenURL: func(u string) error { opened = append(opened, u); return nil }},
fake.Azure(0), signedOut{fake.Azure(0)})
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "2 accounts · 1 need sign-in") {
t.Errorf("status line: %q", line)
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") {
t.Errorf("status line:\n%s", d.screen())
}
d.keys("0")
if d.m.focus != paneStatus {
@@ -1304,8 +1303,7 @@ func TestExpiringInStatus(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
d.keys("3", "]", "]") // load kv-app-prod's certificates
// old-tls and legacy-token.
if !strings.Contains(d.screen(), "⚠ 2 expiring") {
if !strings.Contains(d.screen(), "⚠ 1 expiring") {
t.Errorf("status line:\n%s", d.screen())
}
d.keys("0")
@@ -1344,34 +1342,3 @@ func TestErrorLog(t *testing.T) {
t.Errorf("log %+v", d.m.errLog)
}
}
// hiding marks the secret db-password hidden, as the repo provider does
// a Spring key that doesn't look secret.
type hiding struct{ provider.Provider }
func (h hiding) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
objs, err := h.Provider.Objects(ctx, c, t)
for i := range objs {
objs[i].Hidden = objs[i].Name == "db-password"
}
return objs, err
}
func TestToggleHidden(t *testing.T) {
t.Parallel()
d := newDriver(t, hiding{fake.Azure(0)})
if _, _, s := d.selected(); s == "db-password" || s == "" {
t.Fatalf("selected %q: the hidden one, or none", s)
}
if strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "1 hidden") {
t.Fatalf("hidden one listed:\n%s", d.screen())
}
d.keys("H")
if !strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "showing hidden") {
t.Fatalf("H doesn't show it:\n%s", d.screen())
}
d.keys("H")
if strings.Contains(d.screen(), "db-password") {
t.Fatalf("H again doesn't hide it:\n%s", d.screen())
}
}
+2 -15
View File
@@ -66,24 +66,11 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
if i < 0 || sameListing(before[i], s) {
return nil
}
m.detail.SetDescribed(s)
uncache(m, m.cache.details, detailKey(s))
m.detail.Refresh(s)
delete(m.cache.details, detailKey(s))
return m.describe(s)
}
// uncache deletes key from a cache, and, at the next save, from the file.
func uncache[T any](m *Model, c map[string]entry[T], key string) {
if _, ok := c[key]; !ok {
return
}
delete(c, key)
if m.cacheDropped == nil {
m.cacheDropped = map[string]bool{}
}
m.cacheDropped[key] = true
m.cacheDirty = true
}
// sameListing reports whether a and b list the same: what a list
// says of an object, not what describing it adds.
func sameListing(a, b provider.Object) bool {
+20 -10
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"context"
"fmt"
"os"
@@ -85,7 +84,10 @@ func (m *Model) showCommands() tea.Cmd {
}
var opts []option
for _, c := range cmds {
label := cmp.Or(c.Description, c.Command)
label := c.Description
if label == "" {
label = c.Command
}
if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
}
@@ -120,7 +122,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
in.SetValue(p.Default)
in.CursorEnd()
m.prompt = &modal{
title: cmp.Or(c.Description, "Command"), input: &in,
title: cmpOr(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1)
@@ -149,7 +151,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
}
m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
})
return nil
@@ -175,7 +177,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
// execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) {
switch {
case c.Value == "stdin":
@@ -193,7 +195,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
}
m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
@@ -217,7 +219,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmp.Or(msg.cmd.Description, "command")
name := cmpOr(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch {
case msg.err != nil:
@@ -296,7 +298,11 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String())
}
o.Version = version // "": the current value, like copying it
if version != "" {
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true
}
@@ -307,5 +313,9 @@ func newTextInput(prompt string, width int) textinput.Model {
return in
}
// pathInputWidth fits a link or a folder path in a prompt, in the window.
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
+1 -2
View File
@@ -8,7 +8,6 @@ import (
"time"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"github.com/charmbracelet/x/ansi"
)
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
@@ -35,7 +34,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
// temporary path's length.
shown := ""
if d.m.prompt != nil {
shown = ansi.Strip(strings.Join(d.m.prompt.lines, ""))
shown = ansiStrip(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") {
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{}
}
}
if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{}
+5 -7
View File
@@ -5,8 +5,6 @@ import (
"fmt"
"slices"
"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/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -30,8 +28,8 @@ func (m Model) expiringItems() []expiringItem {
row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != expiry.None {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
}
@@ -52,11 +50,11 @@ func (m Model) expiringFields() []panes.Field {
var out []panes.Field
for _, it := range items {
state, left := m.expiry(it.o)
mark := "⚠ " + expiry.Left(left)
if state == expiry.Expired {
mark := "⚠ " + daysLeft(left)
if state == expired {
mark = "✗ expired"
}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String()
}
+31 -5
View File
@@ -1,9 +1,9 @@
package app
import (
"fmt"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -11,19 +11,45 @@ import (
// list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState int
const (
notExpiring expiryState = iota
expiresSoon
expired
)
// expiry tells whether o has expired or will within the warning period,
// and how long it has left.
func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
if o.Expires.IsZero() {
return notExpiring, 0
}
left := o.Expires.Sub(m.now())
switch {
case left <= 0:
return expired, 0
case left <= m.expiryWarning:
return expiresSoon, left
}
return notExpiring, left
}
// expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state {
case expiry.Expired:
case expired:
return "expired"
case expiry.Soon:
case expiresSoon:
return "expiring"
}
return ""
}
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
+35 -95
View File
@@ -3,13 +3,9 @@ package app
import (
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -23,22 +19,6 @@ import (
const star = "★ "
// recentMark marks items used lately (Recent), with ui.recentIcon.
const recentMark = "◷ "
// marks is what goes before a list row's name: ★ when it's starred for
// account and, with recent, ◷ when it was used lately.
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
var s string
if n.StarredFor(account) {
s = st.Favourite.Render(star)
}
if recent && n.UsedLately(now) {
s += st.Muted.Render(recentMark)
}
return s
}
func sourceKey(s provider.Source) string {
if r, err := ref.ForSource(s); err == nil {
return r.String()
@@ -97,22 +77,29 @@ func (m *Model) refreshContext(key, name string, path []string, src provider.Sou
// containerRender draws a vault or scope, starred or not. In the
// Opened links list it also names the source, since they come from many.
func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Container, int) string {
st := m.store
st, iconSet := m.store, m.icons
return func(sty styles.Styles, c provider.Container, width int) string {
n := st.Node(storeKey(c))
line := c.Name
if c.Source.Kind == provider.KindRepo {
// A secret file: its icon, and its type after it ("sops ·
// age", "env · plain text").
if icon := m.fileIcon(sty, c); icon != "" {
line = icon + " " + line
// A secret file: a lock for an encrypted one, a file in the
// warning colour for plain text, and its type after it
// ("sops · age", "env · plain text").
icon, style := iconSet.Encrypted, sty.KindRepo
if !strings.HasPrefix(c.Detail, "sops") {
icon, style = iconSet.PlainFile, sty.Warning
}
if icon != "" {
line = style.Render(icon) + " " + line
}
line += sty.Muted.Render(" " + c.Detail)
}
line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line)
if n.StarredFor(c.Source.Provider) {
line = sty.Favourite.Render(star) + line
}
if withSource {
src := c.Source.Name
if account := accountOf(c.Source); account != "" {
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
src = account + " / " + src
}
line += sty.Muted.Render(" · " + src)
@@ -129,84 +116,37 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Enabled != nil && !*o.Enabled {
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)
if n.StarredFor(o.Container.Source.Provider) {
line = sty.Favourite.Render(star) + line
}
line = dimIf(sty, o.Hidden, line)
switch state, left := m.expiry(o); state {
case expiry.Expired:
case expired:
line += " " + sty.Error.Render("✗ expired")
case expiry.Soon:
line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
case expiresSoon:
line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
}
return withNote(sty, line, n.Note, width)
}
}
// fileIcon is a repo file's icon, in its colour: a lock for an encrypted
// one, a leaf for Spring config, a whale for compose, .NET's for
// appsettings, a file in the warning colour for plain text; "" for other vaults, or without icons.
func (m Model) fileIcon(sty styles.Styles, c provider.Container) string {
if c.Source.Kind != provider.KindRepo {
return ""
}
icon, style := m.icons.Encrypted, sty.KindRepo
switch {
case strings.HasPrefix(c.Detail, "spring"):
icon, style = m.icons.Spring, sty.Spring
case strings.HasPrefix(c.Detail, "compose"):
icon, style = m.icons.Compose, sty.Compose
case strings.HasPrefix(c.Detail, "dotnet"):
icon, style = m.icons.Dotnet, sty.Dotnet
case !strings.HasPrefix(c.Detail, "sops"):
icon, style = m.icons.PlainFile, sty.Warning
}
if icon == "" {
return ""
}
return style.Render(icon)
}
// dimIf draws a hidden item's row (shown with H) in the muted colour,
// whole, so it stands apart from what's listed by the provider's rules.
func dimIf(sty styles.Styles, hidden bool, line string) string {
if !hidden {
return line
}
return sty.Muted.Render(ansi.Strip(line))
}
// starredFirst moves starred items to the front, keeping the order
// otherwise. It doesn't modify items, which may be cached.
func starredFirst[T any](items []T, starred func(T) bool) []T {
rank := func(x T) int {
if starred(x) {
return 0
}
return 1
}
// starredContainersFirst moves starred vaults and scopes to the front,
// keeping the order otherwise. It doesn't modify items, which may be cached.
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
slices.SortStableFunc(out, func(a, b provider.Container) int {
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
})
return out
}
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
return starredFirst(sources, func(s provider.Source) bool {
return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
})
}
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
return starredFirst(items, func(c provider.Container) bool {
return m.store.Node(storeKey(c)).StarredFor(c.Source.Provider)
})
}
func (m Model) starredObjectsFirst(items []provider.Object) []provider.Object {
return starredFirst(items, func(o provider.Object) bool {
return m.store.Node(objectKey(o)).StarredFor(o.Container.Source.Provider)
// starredFirst moves starred objects to the front, keeping the order
// otherwise. It doesn't modify items, which may be cached.
func (m Model) starredFirst(items []provider.Object) []provider.Object {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Object) int {
return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider))
})
return out
}
// showLinkedContainers lists the vaults opened from links.
@@ -227,7 +167,7 @@ func (m *Model) rebind(c provider.Container) string {
m.linked[i].Source = c.Source
}
}
key := cachefile.ObjectsKey(c, m.objType)
key := containerKey(c) + "|" + string(m.objType)
if m.onLinks() {
m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c))
@@ -244,7 +184,7 @@ func (m *Model) resortStarred() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
}
if m.versionsOf == nil {
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
@@ -290,7 +230,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
switch {
+2 -21
View File
@@ -6,7 +6,6 @@ import (
"slices"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
@@ -14,7 +13,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"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.
@@ -396,7 +394,7 @@ func TestStarWhileFiltering(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
for _, r := range "api-k" {
for _, r := range "api" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
@@ -430,26 +428,9 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("F")
var labels []string
for _, o := range d.m.search.shown {
labels = append(labels, ansi.Strip(o.label))
labels = append(labels, ansiStrip(o.label))
}
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels)
}
}
func TestRecentIcon(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
_ = mem.Use("lazysecrets://azure/"+kv+"/secrets/api-key", time.Now(), func(*store.Node) {})
d := newDriverOpts(t, Options{Store: mem, RecentIcon: true})
d.keys("3")
scr := d.screen()
if !strings.Contains(scr, "◷ api-key") || strings.Contains(scr, "◷ db-password") {
t.Errorf("want ◷ on api-key only:\n%s", scr)
}
off := newDriverOpts(t, Options{Store: mem})
off.keys("3")
if strings.Contains(off.screen(), "◷") {
t.Errorf("◷ without ui.recentIcon:\n%s", off.screen())
}
}
+5 -7
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"errors"
"fmt"
"strings"
@@ -9,7 +8,6 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
@@ -62,7 +60,7 @@ func (m *Model) syncFields() tea.Cmd {
case !ok:
m.fields.Show("", nil)
case m.selectedProblem() != "":
m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup:
m.showAccount(r)
case r.kind == rowSource:
@@ -135,7 +133,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
add("web", src.URL)
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]
add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
}
return out
}
@@ -159,10 +157,10 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
case provider.KindRepo:
add("format", c.Detail)
}
add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL)
for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
}
}
@@ -222,5 +220,5 @@ func (m Model) problemFields(problem string) []panes.Field {
// fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value.
func fieldName(f panes.Field) string {
return cmp.Or(f.Label, f.Value)
return cmpOr(f.Label, f.Value)
}
+18 -9
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"fmt"
"slices"
"strings"
@@ -17,7 +16,7 @@ import (
)
// The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindingsFor).
// of them in a menu (showKeybindings).
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The
@@ -39,9 +38,10 @@ type contextHelp struct {
commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes
// reveal, when set, is what the reveal key will do, "show" or "hide":
// the same width so the bar doesn't shift.
reveal string
// revealed, when set, says whether the selected value is shown, so
// the reveal key says just what it'll do: show or hide, the same
// width so the bar doesn't shift.
revealed *bool
}
func (c contextHelp) ShortHelp() []key.Binding {
@@ -69,8 +69,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default:
reveal := k.Reveal
if c.reveal != "" {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal))
if c.revealed != nil {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
}
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
}
@@ -84,6 +84,11 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
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
// 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
@@ -149,7 +154,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom")
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(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) }})
}
}
@@ -258,7 +263,11 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des
// move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint {
return keyHint{joinKeys(up, down), "move"}
u, d := keys.Name(up), keys.Name(down)
if u == "" || d == "" {
return keyHint{u + d, "move"}
}
return keyHint{u + "/" + d, "move"}
}
// withKey fills a hint's format with the binding's key ("· %s loads
+21 -43
View File
@@ -38,16 +38,14 @@ 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, m.refFileIcon(r)), note: n.Note, mark: " ",
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
plain: m.inPlainEnv(r),
run: func(m *Model) tea.Cmd {
@@ -109,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
if m.history {
var used []string
for _, k := range keys {
if nodes[k].UsedLately(m.now()) {
if !nodes[k].LastUsed.IsZero() {
used = append(used, k)
}
}
@@ -128,15 +126,12 @@ const recentMax = 20
// pathLabel draws a jump list entry: the provider's icon and the path in
// its colour, then the account.
func (m Model) pathLabel(kind provider.Kind, path []string, account, fileIcon string) string {
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string {
style := m.st.Kind(kind)
parts := make([]string, len(path))
for i, p := range path {
parts[i] = style.Render(p)
}
if fileIcon != "" && len(parts) > 1 {
parts[1] = fileIcon + " " + parts[1]
}
label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
if account != "" {
label += m.st.Muted.Render(" · " + account)
@@ -147,53 +142,25 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account, fileIcon st
// inPlainEnv reports whether r is a key in a plain env file, as far as
// the files loaded tell.
func (m Model) inPlainEnv(r ref.Ref) bool {
c, ok := m.repoFile(r)
return ok && r.Name != "" && c.Detail == provider.DetailPlainEnv
}
// repoFile is the repo file r is or is in, as far as the files loaded
// tell.
func (m Model) repoFile(r ref.Ref) (provider.Container, bool) {
if r.Kind != provider.KindRepo || r.IsSource() {
return provider.Container{}, false
if r.Kind != provider.KindRepo || r.Name == "" {
return false
}
file := r
file.Name, file.Type = "", ""
for _, e := range m.cache.containers {
for _, c := range e.value {
if storeKey(c) == file.String() {
return c, true
if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
return true
}
}
}
return provider.Container{}, false
}
// refFileIcon is the icon of the repo file r is or is in, if it's loaded.
func (m Model) refFileIcon(r ref.Ref) string {
if c, ok := m.repoFile(r); ok {
return m.fileIcon(m.st, c)
}
return ""
}
// 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 ""
return false
}
// 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 {
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "", "")
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "")
}
// sourceMatches reports whether src is the subscription or workspace r
@@ -232,7 +199,18 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil
}
choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" })
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if src.ID == n.Source && n.Source != "" {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) {
case 0:
return m.openRef(r, actGo)
+8 -3
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) {
t.Parallel()
d := newDriver(t)
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
if bar := lastLine(d.screen()); !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)
}
d.keys("3")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar)
}
}
@@ -225,6 +225,11 @@ 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) {
t.Parallel()
d := newDriver(t)
@@ -259,7 +264,7 @@ func TestVersionInStatus(t *testing.T) {
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen())
}
if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar)
}
}
+22 -74
View File
@@ -8,9 +8,9 @@ import (
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -56,9 +56,6 @@ type resolvedMsg struct {
act linkAction
sourceRow string
others []string // other accounts' rows that also found the vault
// find ends in the list, like Go to, not on the details: opened by a
// tool that found it (lazysecrets open --action, from fzf).
find bool
choices []choice // several candidates: ask
linked *provider.Container
err error
@@ -209,9 +206,17 @@ const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) {
choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) })
if row, ok := preferredRow(r, choices); ok {
choices = []choice{{sourceRow: row}}
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) {
case 0:
@@ -231,24 +236,6 @@ func (m *Model) openSource(r ref.Ref) {
}
}
// openWith offers each loaded source of kind that matches, one per
// account that lists it.
func (m Model) openWith(kind provider.Kind, matches func(provider.Source) bool) []choice {
var choices []choice
for _, p := range m.providers {
if p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if matches(src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
return choices
}
// refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The
@@ -269,7 +256,7 @@ func (m Model) refLabel(r ref.Ref) string {
default:
path = append(path, r.ContainerName(), r.Name)
}
label := m.pathLabel(r.Kind, path, "", m.refFileIcon(r))
label := m.pathLabel(r.Kind, path, "")
var extra []string
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
if r.IsContainer() {
@@ -417,59 +404,18 @@ func (m *Model) handleResolved(msg resolvedMsg) {
for i := range msg.choices {
msg.choices[i].label = "Open in " + msg.choices[i].label
}
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, !msg.find)
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true)
case len(msg.others) > 0:
var choices []choice
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
if row, ok := preferredRow(msg.ref, choices); ok {
m.navResolved(msg, row)
return
}
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, !msg.find)
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
case msg.linked != nil:
m.addLinked(*msg.linked)
m.navResolved(msg, linksRowID)
m.startLinkNav(msg.ref, msg.act, linksRowID)
default:
m.navResolved(msg, msg.sourceRow)
}
}
// preferredRow is the choice under the account the reference asks for
// (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices {
if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account {
return c.sourceRow, true
}
}
return "", false
}
// navResolved goes to what a resolved link points at, under row.
func (m *Model) navResolved(msg resolvedMsg, row string) {
if msg.find {
m.startNav(msg.ref, msg.act, row)
return
}
m.startLinkNav(msg.ref, msg.act, row)
}
// findRef is openRef ending in the list, like Go to: for a link a tool
// found (lazysecrets open --action).
func (m *Model) findRef(r ref.Ref, act linkAction) tea.Cmd {
cmd := m.openRef(r, act)
if cmd == nil {
return nil
}
return func() tea.Msg {
msg := cmd()
if rm, ok := msg.(resolvedMsg); ok {
rm.find = true
return rm
}
return msg
m.startLinkNav(msg.ref, msg.act, msg.sourceRow)
}
}
@@ -693,7 +639,7 @@ func (m *Model) share(menu bool) tea.Cmd {
share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
}
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
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")})
}
@@ -721,7 +667,7 @@ func (m *Model) share(menu bool) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
}
opts := []option{
{key: "o", label: "Lazysecrets link", run: share("o")},
option{key: "o", label: "Lazysecrets link", run: share("o")},
}
if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
@@ -837,8 +783,10 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
}
func (m *Model) startOpenPrompt() tea.Cmd {
in := newTextInput("open: ", m.pathInputWidth())
in := textinput.New()
in.Prompt = "open: "
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{
title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
+3 -30
View File
@@ -181,7 +181,7 @@ func TestPasteWhileFilteringFilters(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.send(tea.PasteMsg{Content: "api-k"})
d.send(tea.PasteMsg{Content: "api"})
if d.m.secrets.Len() != 1 || d.m.nav != nil {
t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
}
@@ -203,21 +203,8 @@ func TestOpenPromptAndStartupOpen(t *testing.T) {
t.Fatal("open at startup didn't ask what to do")
}
d2.keys("o")
if _, _, s := d2.selected(); s != "api-key" || d2.m.focus != paneDetail {
t.Errorf("open at startup: selected %q, focus %v, want the details", s, d2.m.focus)
}
// --action: no question, it does that.
d3 := newDriverOpts(t, Options{Open: &r, OpenAction: "go"})
if d3.m.prompt != nil {
t.Fatalf("--action go asked: %q", d3.m.prompt.title)
}
if _, _, s := d3.selected(); s != "api-key" || d3.m.focus != paneSecrets {
t.Errorf("--action go: selected %q, focus %v, want the secrets list, like Go to", s, d3.m.focus)
}
d4 := newDriverOpts(t, Options{Open: &r, OpenAction: "copy"})
if _, _, s := d4.selected(); s != "api-key" || d4.clip().content == "" {
t.Errorf("--action copy: selected %q, copied %q", s, d4.clip().content)
if _, _, s := d2.selected(); s != "api-key" {
t.Errorf("open at startup: selected %q", s)
}
}
@@ -318,20 +305,6 @@ func TestOpenAsksWhichAccount(t *testing.T) {
t.Errorf("j enter picked %q", c.Source.Provider)
}
// lazysecrets open --account: no question, that account.
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
r.Account = "Azure:bob@example.com/t1"
d.send(OpenLink(r))
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
d.keys("o")
}
if strings.Contains(d.screen(), "Several accounts can see") {
t.Fatalf("asked, with an account given:\n%s", d.screen())
}
if c, _ := d.m.containers.Selected(); c.Source.Provider != r.Account {
t.Errorf("--account opened via %q", c.Source.Provider)
}
// Opening into a folded account unfolds it.
d.keys("1", "g") // alice's header
d.keys("enter") // fold it
+3 -1
View File
@@ -75,8 +75,10 @@ type modal struct {
// showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := newTextInput("/", 40)
in := textinput.New()
in.Prompt = "/"
in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0)
m.flash = flash{}
+4 -1
View File
@@ -3,6 +3,7 @@ package app
import (
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
@@ -57,9 +58,11 @@ func (m *Model) editNote() tea.Cmd {
c := o.Container
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source
}
in := newTextInput("note: ", 48)
in := textinput.New()
in.Prompt = "note: "
in.Placeholder = "e.g. prod SQL admin"
in.CharLimit = 80
in.SetWidth(48)
in.SetValue(m.store.Node(key).Note)
in.CursorEnd()
m.prompt = &modal{
+7 -5
View File
@@ -77,15 +77,17 @@ func TestNotesOnRows(t *testing.T) {
func TestWithNoteGivesWay(t *testing.T) {
t.Parallel()
st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 ||
!strings.HasSuffix(ansi.Strip(got), "prod SQL admin") {
t.Errorf("fits: %q", ansi.Strip(got))
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
t.Errorf("fits: %q", stripANSI(got))
}
if got := ansi.Strip(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
!strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", got)
}
if got := ansi.Strip(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
t.Errorf("no room: %q", got)
}
}
func stripANSI(s string) string { return ansi.Strip(s) }
+87 -35
View File
@@ -1,12 +1,14 @@
package app
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -21,94 +23,144 @@ import (
// And where lazysecrets was left, per folder it ran in, goes in the store,
// to go back there on the next start.
// cacheFileVersion is the file's format; a file of another version is
// ignored, and replaced on the next save.
const cacheFileVersion = 1
// cacheMaxAge drops what hasn't been loaded again for this long.
const cacheMaxAge = 30 * 24 * time.Hour
// persistEvery is how often a changed cache is saved while running, so
// little is lost if lazysecrets is killed rather than quit.
const persistEvery = 30 * time.Second
type cacheFile struct {
Version int `json:"version"`
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"`
}
type stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
type persistTickMsg struct{}
type persistedMsg struct{ err error }
// keepers report whether a provider ID, or a cache key's, is an account
// that still exists and isn't a repo.
func (m *Model) keepers() (keep, keepKey func(string) bool) {
keep = cachefile.Keep(m.providers)
return keep, func(key string) bool { return keep(cachefile.ProviderOf(key)) }
// keepCached reports whether a cache key's account still exists and
// isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool {
id, _ := splitSourceRow(key)
p, ok := m.byID[id]
return ok && p.Kind() != provider.KindRepo
}
// loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() {
f := cachefile.Read(m.cachePath)
keep, keepKey := m.keepers()
b, err := os.ReadFile(m.cachePath)
if err != nil {
return
}
var f cacheFile
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
return
}
now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
for id, e := range f.Sources {
if keep(id) && recent(e.At) {
if m.keepCached(id+"|") && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At
}
}
for k, e := range f.Containers {
if keepKey(k) && recent(e.At) {
if m.keepCached(k) && recent(e.At) {
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
}
}
for k, e := range f.Objects {
if keepKey(k) && recent(e.At) {
if m.keepCached(k) && recent(e.At) {
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
}
}
for k, e := range f.Details {
if keepKey(k) && recent(e.At) {
if m.keepCached(k) && recent(e.At) {
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
}
}
for k, v := range f.Cursor {
if keepKey(k) {
if m.keepCached(k) {
m.cache.cursor[k] = v
}
}
}
// cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New()
keep, keepKey := m.keepers()
f.Dropped, m.cacheDropped = m.cacheDropped, nil
func (m *Model) cacheSnapshot() cacheFile {
f := cacheFile{
Version: cacheFileVersion,
Sources: map[string]stamped[[]provider.Source]{},
Containers: map[string]stamped[[]provider.Container]{},
Objects: map[string]stamped[[]provider.Object]{},
Details: map[string]stamped[provider.Object]{},
Cursor: map[string]string{},
}
for id, r := range m.sourceResults {
if r.err == nil && keep(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
if r.err == nil && m.keepCached(id+"|") {
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
}
}
for k, e := range m.cache.containers {
if keepKey(k) {
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
if m.keepCached(k) {
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
}
}
for k, e := range m.cache.objects {
if keepKey(k) {
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
if m.keepCached(k) {
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
}
}
for k, e := range m.cache.details {
if keepKey(k) {
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
if m.keepCached(k) {
f.Details[k] = stamped[provider.Object]{e.at, e.value}
}
}
for k, v := range m.cache.cursor {
if keepKey(k) {
if m.keepCached(k) {
f.Cursor[k] = v
}
}
return f
}
// saveCache saves f, merged with the file (lazysecrets list may have
// loaded more since), from any goroutine.
func (m *Model) saveCache(f cachefile.File) func() error {
path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
return func() error { return cachefile.Save(path, f, keep, now) }
// writeCacheFile writes f atomically, readable by the user only.
func writeCacheFile(path string, f cacheFile) error {
b, err := json.Marshal(f)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// persistTick saves the cache, and where lazysecrets is, in the
@@ -117,8 +169,8 @@ func (m *Model) persistTick() tea.Cmd {
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
if m.cacheDirty && m.cachePath != "" {
m.cacheDirty = false
save := m.saveCache(m.cacheSnapshot())
cmds = append(cmds, func() tea.Msg { return persistedMsg{save()} })
f, path := m.cacheSnapshot(), m.cachePath
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
}
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
m.savedPlace = p.Link
@@ -133,7 +185,7 @@ func (m *Model) persistTick() tea.Cmd {
func (m Model) Persist() error {
var errs []error
if m.cachePath != "" {
errs = append(errs, m.saveCache(m.cacheSnapshot())())
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
}
if m.placeDir != "" {
if p, ok := m.place(); ok {
+8 -11
View File
@@ -1,14 +1,12 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
@@ -82,16 +80,15 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
file := filepath.Join(t.TempDir(), "cache.json")
az := fake.Azure(0)
now := time.Now()
f := cachefile.File{
Version: cachefile.Version,
Containers: map[string]cachefile.Stamped[[]provider.Container]{
az.ID() + "|recent": {At: now.Add(-time.Hour), Value: []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {At: now.Add(-cachefile.MaxAge - time.Hour), Value: []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {At: now, Value: []provider.Container{{Name: "kv-gone"}}},
f := cacheFile{
Version: cacheFileVersion,
Containers: map[string]stamped[[]provider.Container]{
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
},
}
b, _ := json.Marshal(f) // as an older lazysecrets left it
if err := os.WriteFile(file, b, 0o600); err != nil {
if err := writeCacheFile(file, f); err != nil {
t.Fatal(err)
}
d := newDriverOpts(t, Options{CacheFile: file}, az)
@@ -100,7 +97,7 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Error("dropped a recent entry")
}
if _, ok := c[az.ID()+"|old"]; ok {
t.Error("kept an entry older than cachefile.MaxAge")
t.Error("kept an entry older than cacheMaxAge")
}
if _, ok := c["Azure:gone@x/t|recent"]; ok {
t.Error("kept an entry of an account that isn't signed in")
+2 -3
View File
@@ -16,7 +16,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
)
const sopsFile = `db:
@@ -226,9 +225,9 @@ func TestSearchAllRepos(t *testing.T) {
var got []string
for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansi.Strip(o.label))
t.Errorf("not a repo: %s", ansiStrip(o.label))
}
got = append(got, ansi.Strip(o.label))
got = append(got, ansiStrip(o.label))
}
if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got)
+7 -16
View File
@@ -28,22 +28,12 @@ func (m *Model) startRevealTimer() tea.Cmd {
// revealTickCmd fires when the displayed second next changes.
func (m *Model) revealTickCmd() tea.Cmd {
token := m.revealToken
return tea.Tick(untilNextSecond(m.revealDeadline, m.now()), func(time.Time) tea.Msg { return revealTickMsg{token} })
}
// untilNextSecond is how long until a countdown to deadline shows its
// next whole second.
func untilNextSecond(deadline, now time.Time) time.Duration {
if wait := deadline.Sub(now) % time.Second; wait > 0 {
return wait
wait := m.revealDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
return time.Second
}
// secondsLeft is a countdown to deadline, in whole seconds rounded up.
func secondsLeft(deadline, now time.Time) int {
return max(0, int(math.Ceil(deadline.Sub(now).Seconds())))
token := m.revealToken
return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} })
}
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
@@ -59,7 +49,8 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
}
func (m *Model) showHidesIn() {
m.detail.SetHidesIn(fmt.Sprintf("%ds", secondsLeft(m.revealDeadline, m.now())))
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds())))
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
}
// hideValue hides the revealed value and stops its countdown.
+1 -2
View File
@@ -5,14 +5,13 @@ import (
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// containerKey identifies a container in the UI and caches. The same vault
// seen through two accounts must not share cached lists, since each
// account may see different things.
func containerKey(c provider.Container) string { return cachefile.ContainerKey(c) }
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
// providerFor finds the provider instance a source came from. Sources
// remembered from an earlier run (favourites) may name an account that
+45 -51
View File
@@ -6,11 +6,10 @@ import (
"fmt"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
@@ -46,6 +45,10 @@ type crawlMsg struct {
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
// each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group.
@@ -58,63 +61,49 @@ func (m *Model) searchEntries() (sources, items []option) {
var es []entry
// key is the store key, so ctrl+f stars the entry; mark shows how
// much is loaded, reload (ctrl+r) loads it again.
add := func(depth int, row string, kind provider.Kind, path []string, account, fileIcon, key, mark string, run, reload func(*Model) tea.Cmd) *option {
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) *option {
n := m.store.Node(key)
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account, fileIcon), note: n.Note, star: key,
starAccount: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)),
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt
}
// What providers hide is left out, unless H shows it, dimmed.
skip := func(hidden bool) bool { return hidden && !m.showHidden }
dim := func(hidden bool, e *option) {
if hidden {
e.label = m.st.Muted.Render(ansi.Strip(e.label))
}
}
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
if skip(src.Hidden) {
continue
}
src.Provider, src.ProviderLabel = p.ID(), p.Label()
row := sourceRowKey(p.ID(), src.ID)
dim(src.Hidden, add(0, row, src.Kind, []string{src.Name}, accountOf(src), "", sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil
}, func(m *Model) tea.Cmd { return m.loadSource(src) }))
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
for _, c := range m.cache.containers[row].value {
if skip(c.Hidden) {
continue
}
r, err := ref.ForContainer(c, provider.TypeSecret)
if err != nil {
continue
}
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
dim(c.Hidden, add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), m.fileIcon(m.st, c), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload))
}, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
r, err := ref.ForObject(o, "")
if err != nil || skip(o.Hidden) {
if err != nil {
continue
}
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·")
suffix := accountOf(src)
if t != provider.TypeSecret {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·")
}
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, m.fileIcon(m.st, c), objectKey(o), " ", func(m *Model) tea.Cmd {
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload)
e.plain = c.Detail == provider.DetailPlainEnv
dim(o.Hidden, e)
}
}
}
@@ -144,6 +133,8 @@ func (c coverage) complete() bool {
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
// all of them.
type providerFilter func(provider.Provider) bool
@@ -156,7 +147,7 @@ func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id }
}
// coverageOf is the coverage of the providers in.
// coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage
for _, p := range m.providers {
@@ -175,7 +166,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
}
for _, v := range e.value {
c.vaults++
key := cachefile.ObjectsKey(v, provider.TypeSecret)
key := containerKey(v) + "|" + string(provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++
c.secrets += len(objs.value)
@@ -191,7 +182,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
// searchLines says what the search covers.
func (m *Model) searchLines() []string {
c := m.coverageOf(nil)
c := m.searchCoverage()
switch {
case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
@@ -218,8 +209,11 @@ func plural(n int, word string) string {
return fmt.Sprintf("%d %ss", n, word)
}
// startCrawlOf loads, for the providers in, every unlisted subscription's
// vaults and every vault's secrets not loaded yet.
// startCrawl loads every unlisted subscription's 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 {
for _, p := range m.providers {
if in != nil && !in(p) {
@@ -269,7 +263,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
// queueContainer queues loading c's secrets, unless loaded lately and not
// force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return
}
@@ -306,7 +300,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Containers(ctx, src)
for i := range items {
@@ -319,7 +313,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items {
@@ -333,7 +327,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active--
key := msg.job.row
if msg.job.container != nil {
key = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
}
delete(m.crawl.pending, key)
switch {
@@ -356,19 +350,6 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
return cmd
}
// vaultsLoaded counts the vaults whose secrets are cached or can't be
// read, and says whether any are loading.
func (m *Model) vaultsLoaded(vaults []provider.Container) (loaded int, loading bool) {
for _, c := range vaults {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
return loaded, loading
}
// sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string {
@@ -382,7 +363,14 @@ func (m *Model) sourceMark(row string) string {
if !ok {
return m.st.Muted.Render("○")
}
loaded, loading := m.vaultsLoaded(e.value)
loaded, loading := 0, false
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
switch {
case loading:
return m.st.Muted.Render("◌")
@@ -398,7 +386,7 @@ func (m *Model) sourceMark(row string) string {
// (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
switch {
case m.crawl.pending[key]:
return m.st.Muted.Render("◌")
@@ -407,3 +395,9 @@ func (m *Model) vaultMark(c provider.Container) string {
}
return " "
}
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+20 -19
View File
@@ -6,7 +6,6 @@ import (
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi"
)
@@ -116,19 +115,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Fatal("platform-dev not in sources")
}
}
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "○") {
if !strings.Contains(ansiStrip(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 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
}
d.send(ctrl('r'))
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "●") {
if !strings.Contains(ansiStrip(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 {
if !strings.Contains(ansi.Strip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansi.Strip(o.label))
if !strings.Contains(ansiStrip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
}
}
if len(d.m.search.shown) == 0 {
@@ -178,17 +177,19 @@ func TestStarFromSearch(t *testing.T) {
}
}
func ansiStrip(s string) string { return ansi.Strip(s) }
func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch()
v := d.m.search
scope := func() string { return ansi.Strip(v.scopes[v.src].label) }
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
if scope() != "All" {
t.Errorf("opened on %q, want All", scope())
}
if v.res >= len(v.shown) || !strings.Contains(ansi.Strip(v.shown[v.res].label), "kv-shared-prod") {
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen())
}
d.send(ctrl('s')) // narrows to the source, and back
@@ -232,13 +233,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
t.Errorf("no results in the account:\n%s", d.screen())
}
for _, o := range v.shown {
if cachefile.ProviderOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansi.Strip(o.label))
if providerOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label))
}
}
// The account's sources are under it, indented.
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", ansi.Strip(next.label))
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label))
}
}
@@ -326,7 +327,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
t.Fatal("no results")
}
o := d.m.search.shown[0]
plain := []rune(ansi.Strip(o.label))
plain := []rune(ansiStrip(o.label))
var got string
for _, h := range o.hits {
got += string(plain[h])
@@ -347,14 +348,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("F")
bar := lastLines(d.screen(), 1)
bar := lastLine(d.screen())
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want)
}
}
d.keys("/")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
t.Errorf("typing: help bar %q", bar)
}
}
@@ -374,8 +375,8 @@ func TestHelpFromPopups(t *testing.T) {
if o.header {
sections = append(sections, o.label)
}
if strings.Contains(ansi.Strip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label))
if strings.Contains(ansiStrip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
}
}
if strings.Join(sections, ",") != "Go to,While typing, after /" {
@@ -427,7 +428,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLines(d.screen(), 1))
bar := strings.TrimSpace(lastLine(d.screen()))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar)
}
@@ -442,11 +443,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for i := 0; !strings.Contains(ansi.Strip(d.m.search.scope().label), "platform-dev"); i++ {
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
if i > len(d.m.search.scopes) {
var labels []string
for _, o := range d.m.search.scopes {
labels = append(labels, ansi.Strip(o.label))
labels = append(labels, ansiStrip(o.label))
}
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
}
+31 -8
View File
@@ -12,7 +12,6 @@ import (
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
@@ -95,7 +94,9 @@ func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
in := newTextInput("/", searchWidth(m.w)-6) // as wide as the popup
in := textinput.New()
in.Prompt = "/"
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
v := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries()
m.search = v
@@ -201,7 +202,7 @@ func (m *Model) fillSearch(keep bool) {
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
})
for _, o := range sources {
if cachefile.ProviderOf(o.group) == p.ID() {
if providerOf(o.group) == p.ID() {
o.label = " " + o.label
scopes = append(scopes, o)
}
@@ -265,7 +266,7 @@ func (v *searchView) match() []option {
default:
account, ofAccount := v.scopeAccount()
for _, o := range v.items {
if sc.id == allSources || ofAccount && cachefile.ProviderOf(o.group) == account || sc.group != "" && o.group == sc.group {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o)
}
}
@@ -521,7 +522,7 @@ func (m *Model) searchLoads() tea.Cmd {
return m.startCrawlOf(v.scopeFilter())
}
if !v.onSource() {
return m.startCrawlOf(nil)
return m.startCrawl()
}
providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources {
@@ -614,7 +615,13 @@ func (m Model) loadHint() string {
}
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
}
loaded, _ := m.vaultsLoaded(e.value)
loaded := 0
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
if loaded == len(e.value) {
return ""
}
@@ -762,7 +769,7 @@ func (m Model) searchViewBox() string {
body = append(body, " "+m.st.Error.Render(l))
}
}
body = append(body, "", " "+panes.Tabs(m.st, ts, tabNames[:], m.search.tab))
body = append(body, "", " "+m.goToTabs(ts))
}
body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") {
@@ -795,7 +802,7 @@ func (m Model) searchViewBox() string {
// Tabs in the title, the current one underlined, like the panes; a
// picker has its own title, and the tabs over the input.
if v.picker == nil {
head := ts.Render(goToTitle+" ") + panes.Tabs(m.st, ts, tabNames[:], m.search.tab)
head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
}
// A picker: in its own border, under what it's for.
@@ -848,3 +855,19 @@ func pickable(o option) (ref.Ref, bool) {
}
return r, true
}
// goToTabs draws Go to's tabs, the current one underlined.
func (m Model) goToTabs(ts lipgloss.Style) string {
var out string
for i, name := range tabNames {
if i > 0 {
out += ts.Render(" - ")
}
if i == m.search.tab {
out += ts.Underline(true).Render(name)
} else {
out += m.st.Muted.Render(name)
}
}
return out
}
+6 -3
View File
@@ -10,6 +10,7 @@ import (
"slices"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
@@ -71,7 +72,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
return msg
}
for _, c := range all {
if c.Detail == provider.DetailPlainEnv && !c.Hidden { // not .env.example
if c.Detail == provider.DetailPlainEnv {
cs = append(cs, c)
}
}
@@ -303,7 +304,7 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
}
// openFailed says why a share link couldn't be decrypted, in a popup
// where its offer was: its first line (wrapped), or, with whole, all of it
// where its offer was: its first line (wrapped), or with whole all of it
// (Key Vault's refusals say what to check on later lines). From there,
// the key's secret can be gone to: its version, which may be disabled.
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
@@ -337,7 +338,9 @@ func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
// askShareDir asks where to save files from a repo that isn't here. It
// stays until they're saved, saying why when they can't be.
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
in := newTextInput("folder: ", m.pathInputWidth())
in := textinput.New()
in.Prompt = "folder: "
in.SetWidth(max(20, min(m.w-12, 64)))
dir := m.placeDir
if dir == "" {
dir, _ = os.Getwd()
+33 -34
View File
@@ -5,9 +5,7 @@ import (
"fmt"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
@@ -41,7 +39,7 @@ type sourceRow struct {
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
// either; provider IDs contain "/" (Azure:user/tenant).
func sourceRowKey(providerID, sourceID string) string { return cachefile.Row(providerID, sourceID) }
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID }
func splitSourceRow(row string) (providerID, sourceID string) {
providerID, sourceID, _ = strings.Cut(row, "|")
@@ -58,8 +56,7 @@ func sourceRowID(r sourceRow) string {
return sourceRowKey(r.provider, r.source.ID)
}
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
render := renderSourceRow(iconSet, mem, recent)
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID,
// Headers have no filter text, so filtering shows just sources.
@@ -78,20 +75,11 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
},
Render: func(st styles.Styles, r sourceRow, width int) string {
return dimIf(st, r.kind == rowSource && r.source.Hidden, render(st, r, width))
},
})
}
// renderSourceRow draws a source, a provider's header or the Opened links
// row.
func renderSourceRow(iconSet icons.Set, mem *store.Store, recent bool) func(styles.Styles, sourceRow, int) string {
return func(st styles.Styles, r sourceRow, width int) string {
node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent, time.Now())
if r.kind == rowSource && node.StarredFor(r.source.Provider) {
starred = st.Favourite.Render(star)
}
switch {
case r.kind == rowLinks:
@@ -117,7 +105,8 @@ func renderSourceRow(iconSet icons.Set, mem *store.Store, recent bool) func(styl
status = st.Error.Render("(!)")
}
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
}
},
})
}
// rebuildSources lays out the Sources pane from the per-provider results.
@@ -138,16 +127,20 @@ func (m *Model) rebuildSources() {
}
// The repo lazysecrets started in gets a row of its own on top, so it
// opens on it; flat, it sorts first instead.
if src, ok := m.currentSource(); ok && !flat && (!src.Hidden || m.showHidden) {
if src, ok := m.currentSource(); ok && !flat {
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
}
for _, p := range m.providers {
kind, id := p.Kind(), p.ID()
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ")
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id]
sources := slices.DeleteFunc(slices.Clone(r.sources), func(s provider.Source) bool {
return (s.Here && !flat) || (s.Hidden && !m.showHidden)
})
sources := r.sources
if !flat {
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here })
}
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
continue // repos show once some are found to have secret files
}
@@ -220,6 +213,17 @@ func (m Model) currentSource() (provider.Source, bool) {
return provider.Source{}, false
}
// starredSourcesFirst moves starred sources to the front, keeping the
// order otherwise. It doesn't modify sources.
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
out := slices.Clone(sources)
rank := func(s provider.Source) int {
return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider))
}
slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) })
return out
}
// starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int {
switch {
@@ -233,6 +237,13 @@ func starRank(mem *store.Store, r sourceRow) int {
return 3
}
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected()
@@ -247,18 +258,6 @@ func (m Model) onLinks() bool {
return m.sources.SelectedID() == linksRowID
}
// toggleHidden lists what providers hide, dimmed, or leaves it out again.
func (m *Model) toggleHidden() {
m.showHidden = !m.showHidden
m.containers.SetShowHidden(m.showHidden)
m.secrets.SetShowHidden(m.showHidden)
m.rebuildSources()
m.flash = flash{text: "hidden items hidden"}
if m.showHidden {
m.flash = flash{text: "showing hidden items, dimmed"}
}
}
// toggleSourcesView switches between grouped and flat, keeping the cursor
// on the same source.
func (m *Model) toggleSourcesView() {
+2 -3
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"errors"
"fmt"
"strings"
@@ -84,7 +83,7 @@ func (m Model) statusFields() []panes.Field {
}
}
// A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")}
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
}
@@ -160,7 +159,7 @@ func (m Model) accountStatus(p provider.Provider) string {
return r.err.Error()
}
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmp.Or(noun, "source"))
return plural(len(r.sources), cmpOr(noun, "source"))
}
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more