34 Commits
Author SHA1 Message Date
keb 5946f855bd feat: lazysecrets list marks entries with a note (✎) 2026-10-14 21:24:01 +02:00
keb 824f7249c3 feat: lazysecrets list prints notes, and --demo has a couple 2026-10-14 21:24:01 +02:00
keb 2c9c37f809 feat: lazysecrets list merges error and expiry into one status field 2026-10-14 21:24:01 +02:00
keb 37164158ca feat: lazysecrets list marks what's wrong (✗) or due (⚠) by the icon, and writes expired or expires in 4d 2026-10-14 21:24:01 +02:00
keb 4473e037f8 test: --demo's emergency-admin expires soon 2026-10-14 21:24:01 +02:00
keb 2db3d28d6d feat: lazysecrets list says expired or how soon, as the TUI does, in a field of its own 2026-10-14 21:24:01 +02:00
keb e49a2d8b53 test: --demo's legacy-token has expired too 2026-10-14 21:24:01 +02:00
keb 94aa74b05d feat: lazysecrets list right-aligns the marks by the icon, so names line up 2026-10-14 21:24:01 +02:00
keb 419a5f3047 feat: lazysecrets list's marks take space only when present 2026-10-14 21:24:01 +02:00
keb c548688715 feat: --demo has expired, disabled and expiring secrets 2026-10-14 21:24:01 +02:00
keb b4e4c1515b feat: lazysecrets list marks expiring and expired secrets, and its JSON has expires and enabled 2026-10-14 21:24:01 +02:00
keb 33fa064104 feat: ui.recentIcon marks items used lately with ◷, and lazysecrets list --recent 2026-10-14 21:24:01 +02:00
keb 33a9b0864d feat: lazysecrets list's JSON says when an entry was last used, and how often 2026-10-14 21:24:01 +02:00
keb 0e4580f245 feat: --demo also stars a subscription and a repo file 2026-10-14 21:24:01 +02:00
keb 46af233bd3 feat: lazysecrets list puts the star and icon in a field of their own, so ^ searches match names 2026-10-14 21:24:01 +02:00
keb 3bc0fc8fd6 feat: --demo starts with a few starred and recently used secrets 2026-10-14 21:24:01 +02:00
keb 4c822e42f1 feat: lazysecrets list's --starred-icon is --stars 2026-10-14 21:24:01 +02:00
keb 4cb40a1598 feat: lazysecrets list shows stars by default 2026-10-14 21:24:01 +02:00
keb 77e3fcd551 feat: lazysecrets list marks starred and recent entries, and --starred-icon shows the stars 2026-10-14 21:24:01 +02:00
keb 50156d706e feat: lazysecrets list puts why a list failed in a column of its own 2026-10-14 21:24:01 +02:00
keb b0ea217d87 docs: get and list move to their own page, integration.md 2026-10-14 21:24:01 +02:00
keb 470f0c11e4 docs: the fzf example copies on ctrl-y and copies and closes on ctrl-x 2026-10-14 21:24:01 +02:00
keb e8d3a3337a docs: the fzf example copies and closes on ctrl-y, copies and stays on alt-y 2026-10-14 21:24:01 +02:00
keb bdf949339b feat: lazysecrets open --action ends in the secrets list, like Go to 2026-10-14 21:24:01 +02:00
keb a4002a785c feat: lazysecrets open --action goes, copies or shows without asking 2026-10-14 21:24:01 +02:00
keb b05d19209c feat: lazysecrets list prints the account, and open and get take it (--account) 2026-10-14 21:24:01 +02:00
keb 1f330bc619 feat: lazysecrets list's text lines start with the kind 2026-10-14 21:24:01 +02:00
keb 572cea8351 docs: the fzf example for lazysecrets list hides the link and binds open and copy 2026-10-14 21:24:01 +02:00
keb 3734989b15 fix: lazysecrets list keeps the provider icon on error lines 2026-10-14 21:24:01 +02:00
keb 24b38d7321 fix: lazysecrets list keeps each entry on one line, errors to their first 2026-10-14 21:24:01 +02:00
keb 7b377fd468 feat: lazysecrets list shows unreadable lists as red ✗ lines, and filters by kind 2026-10-14 21:24:01 +02:00
keb baee1dfe61 fix: lazysecrets list puts the name first, and keeps errors out of fzf 2026-10-14 21:24:01 +02:00
keb e99462bd21 feat: lazysecrets list colours names from the theme, with icons 2026-10-14 21:24:01 +02:00
keb b1455451e6 feat: lazysecrets list prints every source, vault and secret, for fzf and editor pickers 2026-10-14 21:24:01 +02:00
108 changed files with 1316 additions and 3331 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 # Build output
/lazysecrets /lazysecrets
# Release builds (GoReleaser, see .goreleaser.yaml) # Release builds (GoReleaser, see .goreleaser.yaml)
/dist/ /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. 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 ## v0.1.2 – 2026-10-13
### Added ### Added
+19 -19
View File
@@ -7,23 +7,28 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Main features ## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret. - **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 - **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching. 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 - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor - **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim) pipes, and `lazysecrets list` everything it can find, for fzf and editor
for Neovim. pickers ([integration](docs/integration.md)).
- **Favourites**: star the secrets you use most for easy access. - **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are - **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane. marked, listed together in the status pane.
- **Your way**: rebind any key, pick a theme, and add custom commands to run - **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret. on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data. - **Try it first**: `lazysecrets --demo` runs on built-in sample data.
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env`, Spring Boot and Docker Compose 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. > **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
@@ -44,8 +49,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` `sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else. somewhere else.
`U` (or `lazysecrets update`) updates in place when there's a newer lazysecrets looks for a newer release once a day: the footer then shows
release. `↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
`lazysecrets update`).
Or build it yourself: Or build it yourself:
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`. `go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
@@ -68,29 +74,23 @@ use most:
| `l` / `h` | the details of what's selected / back | | `l` / `h` | the details of what's selected / back |
| `/` | filter the list you're in | | `/` | filter the list you're in |
| `space` | reveal / hide the value | | `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 | | `ctrl+s` | Go to: search everything and jump there |
| `f` / `F` | star / your favourites | | `f` / `F` | star / your favourites |
| `s` | copy a link to share | | `s` | copy a link to share |
| `?` | every key, searchable | | `?` | every key, searchable |
| `q` | quit | | `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 ## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys - [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults - [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes - [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
- [SOPS, .env, Spring and compose files](docs/repos.md): which repos and files, decrypting - [SOPS and .env files](docs/repos.md): which repos and files, decrypting
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and - [Links](docs/links.md): sharing, opening, and opening links from the
opening links, also from the browser and chat browser and chat
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems - [Integration](docs/integration.md): `lazysecrets get` in scripts, and
`lazysecrets list` for fzf and editor pickers
- [Custom commands](docs/custom-commands.md): run your own commands on a - [Custom commands](docs/custom-commands.md): run your own commands on a
secret secret
- [Config](docs/config.md): every option, themes, layered configs, the - [Config](docs/config.md): every option, themes, layered configs, the
+10 -4
View File
@@ -18,7 +18,7 @@ import (
// It ends with a newline only when printing to a terminal, so command // It ends with a newline only when printing to a terminal, so command
// substitution and pipes get the value exactly. // substitution and pipes get the value exactly.
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error { func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
v, _, err := readKey(ctx, providers, st, r) v, err := value(ctx, providers, st, r)
if err != nil { if err != nil {
return err return err
} }
@@ -30,9 +30,15 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err return err
} }
// readKey reads what r points at (ref.Read), and the version read. The // value reads what r points at (ref.Read), with the account that last
// account asked for (--account) goes first, else the one that last read // read it first.
// it. 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. The account asked
// for (--account) goes first, else the one that last read it.
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) { func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
key := r key := r
key.Version = "" key.Version = ""
+2 -2
View File
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
v, _, err := readKey(context.Background(), providers, st, r) v, err := value(context.Background(), providers, st, r)
if err != nil || v.Reveal() != want { if err != nil || v.Reveal() != want {
t.Errorf("%s: %q, %v", link, v.Reveal(), err) t.Errorf("%s: %q, %v", link, v.Reveal(), err)
} }
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
"{{secrets/app/storage-key}}": "names no workspace", "{{secrets/app/storage-key}}": "names no workspace",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
if _, _, err := 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) 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 { if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
return "", "", err 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 != "" { if term != "" {
launch = append(launch, "--terminal", 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. // innerCommand runs lazysecrets on link, with the environment it needs.
func innerCommand(configPath, exe, link string) []string { func innerCommand(configPath, exe, link string) []string {
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe) 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) 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}
}
+32 -44
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"cmp"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -18,7 +17,6 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/listing" "git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -53,11 +51,10 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)") 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)") 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)") 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)") notes := fs.Bool("notes", true, "put ✎ before names with a note, in mutedColor (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)") 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)") 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\")") showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: Spring and compose keys that don't look secret, example files, and files and repos with only those (json: \"hidden\")")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
@@ -78,7 +75,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
return fmt.Errorf("--color: %q is not always or never", *color) return fmt.Errorf("--color: %q is not always or never", *color)
} }
now := time.Now() now := time.Now()
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry, m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, notes: *notes, expiry: *expiry,
color: *color == "always", warn: expiryWarn, now: now} color: *color == "always", warn: expiryWarn, now: now}
if *iconsOn { if *iconsOn {
m.icons = icons.NerdFont m.icons = icons.NerdFont
@@ -96,7 +93,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
if status == "" && *expiry { if status == "" && *expiry {
status = expiryOf(e, theme, expiryWarn, now, colored) status = expiryOf(e, theme, expiryWarn, now, colored)
} }
note := noteOf(e, theme, m.icons, *notes, colored) note := renderer(colored)(theme.Muted, e.Note)
// A tab or line break in a name would break the line up. // 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), _, 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) oneLine.Replace(note), e.Account, e.Link)
@@ -129,18 +126,20 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
if cachePath != "" { if cachePath != "" {
f = cachefile.Read(cachePath) f = cachefile.Read(cachePath)
} }
f, loaded := listing.Run(context.Background(), listing.Options{ f = listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, Hidden: *hidden, Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
Mark: func(link, account string) listing.Marks { Mark: func(link, account string) (bool, time.Time, int, string) {
n := st.Node(link) n := st.Node(link)
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now), return n.StarredFor(account), n.LastUsed, n.Uses, n.Note
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
}, },
}, emit) }, emit)
// Only what was loaded: list never moves a cursor, so the TUI's stay. if cachePath != "" && !*cached {
if cachePath != "" && loaded { byID := map[string]provider.Provider{}
f.Cursor = nil for _, p := range providers {
if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil { byID[p.ID()] = p
}
keep := func(id string) bool { p, ok := byID[id]; return ok && p.Kind() != provider.KindRepo }
if err := cachefile.Save(cachePath, f, keep, time.Now()); err != nil {
return err return err
} }
} }
@@ -152,31 +151,21 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ") 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, // 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 // or when it will if that's within warn (expires in 12d), in yellow, or
// is "". Its mark (✗, ⚠) is before the name. // is "". Its mark (✗, ⚠) is before the name.
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string { func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
render := renderer(color) render := renderer(color)
switch state, left := expiry.Of(e.Expires, now, warn); state { left := e.Expires.Sub(now)
case expiry.Expired: switch {
return render(theme.Error, "expired") case e.Expires.IsZero() || left > warn:
case expiry.Soon:
return render(theme.Warning, "expires in "+expiry.Left(left))
}
return "" return ""
case left <= 0:
return render(theme.Error, "expired")
case left < 24*time.Hour:
return render(theme.Warning, fmt.Sprintf("expires in %dh", max(1, int(left.Hours()))))
}
return render(theme.Warning, fmt.Sprintf("expires in %dd", int(left.Hours()/24)))
} }
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗) // errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
@@ -205,13 +194,13 @@ func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
// marks says what goes before the name, in a field of its own so a // 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 // 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 // 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 // entry and ◷ for one used lately, as the TUI marks them; ✎ for one with a
// provider's icon. The marks are right-aligned in room for every kind // note; 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. // turned on, so they sit by the icon and names line up.
type marks struct { type marks struct {
theme styles.Styles theme styles.Styles
icons icons.Set icons icons.Set
stars, recent, expiry, color bool stars, recent, notes, expiry, color bool
warn time.Duration // ui.expiryWarningDays warn time.Duration // ui.expiryWarningDays
now time.Time now time.Time
} }
@@ -227,14 +216,10 @@ func (m marks) of(e listing.Entry) string {
} }
} }
// What's wrong, then what to watch: room for one of them. // What's wrong, then what to watch: room for one of them.
state := expiry.None switch left := e.Expires.Sub(m.now); {
if m.expiry { case e.Error != "" || (m.expiry && !e.Expires.IsZero() && left <= 0):
state, _ = expiry.Of(e.Expires, m.now, m.warn)
}
switch {
case e.Error != "" || state == expiry.Expired:
mark(true, m.theme.Error, "✗") mark(true, m.theme.Error, "✗")
case state == expiry.Soon: case m.expiry && !e.Expires.IsZero() && left <= m.warn:
mark(true, m.theme.Warning, "⚠") mark(true, m.theme.Warning, "⚠")
default: default:
mark(false, m.theme.Error, "") mark(false, m.theme.Error, "")
@@ -245,6 +230,9 @@ func (m marks) of(e listing.Entry) string {
if m.recent { if m.recent {
mark(e.Recent, m.theme.Muted, "◷") mark(e.Recent, m.theme.Muted, "◷")
} }
if m.notes {
mark(e.Note != "", m.theme.Muted, "✎")
}
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
kind := provider.Kind("") kind := provider.Kind("")
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} { for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
+13 -12
View File
@@ -87,19 +87,20 @@ func TestListExpiry(t *testing.T) {
} }
} }
func TestListNotes(t *testing.T) { func TestListNoteMark(t *testing.T) {
e := listing.Entry{Note: "rotate in May"} az := icons.NerdFont.Azure
if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" { m := marks{icons: icons.NerdFont, stars: true, notes: true}
t.Errorf("icons: %q", got) for _, c := range []struct {
e listing.Entry
want string
}{
{listing.Entry{Provider: "azure", Note: "rotate in May"}, " ✎ " + az},
{listing.Entry{Provider: "azure", Note: "x", Starred: 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)
} }
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)
} }
} }
+74 -55
View File
@@ -68,20 +68,27 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
if c.Key == "" { if c.Key == "" {
continue 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 { if err := km.CheckExtra(c.Key, name); err != nil {
return err return err
} }
for _, d := range cmds[:i] { for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap { if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, 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 return nil
} }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// refArg parses get's and open's arguments: a reference, and optionally // refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column) and, // the account to use when several could (lazysecrets list's column) and,
// for open, what to do with it rather than asking (--action). // for open, what to do with it rather than asking (--action).
@@ -143,7 +150,6 @@ func run() error {
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json] lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false] [--color always|never] [--icons=false] [--errors=false]
[--stars=false] [--notes=false] [--recent] [--expiry=false]
print every source, vault and secret it can print every source, vault and secret it can
find, one per line, for fzf and pickers: find, one per line, for fzf and pickers:
what's cached at once, then what loads what's cached at once, then what loads
@@ -171,22 +177,26 @@ Flags:
return nil return nil
} }
sub := "" // the subcommand; "" browses var open, getRef *ref.Ref
var subArgs []string
var open *ref.Ref
var getRef ref.Ref
var openAction string var openAction string
var shareArgs, listArgs []string
printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) > 0 && (args[0] == "share" || args[0] == "list"): case len(args) > 0 && args[0] == "share":
sub, subArgs = args[0], args[1:] shareArgs = args[1:]
if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how
}
case len(args) > 0 && args[0] == "list":
listArgs = append([]string{}, args[1:]...) // not nil: listing
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
sub = "keybindings" printKeys = true
case len(args) > 0 && args[0] == "get": case len(args) > 0 && args[0] == "get":
r, _, err := refArg("get", args[1:]) r, _, err := refArg("get", args[1:])
if err != nil { if err != nil {
return err return err
} }
sub, getRef = "get", r getRef = &r
case len(args) > 0 && args[0] == "handler": case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:]) return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch": case len(args) > 0 && args[0] == "launch":
@@ -232,7 +242,7 @@ Flags:
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil { if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
return err return err
} }
if sub == "keybindings" { if printKeys {
fmt.Print(keymap.YAML()) fmt.Print(keymap.YAML())
return nil return nil
} }
@@ -260,13 +270,25 @@ Flags:
} }
// timeoutFor is how long a request to a provider of kind k may take. // 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) } timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] { if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
*clipClear = valueOr(cfg.Values.ClipboardClear, *clipClear) *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"] { if !set["icons"] {
switch { switch {
case os.Getenv("LAZYSECRETS_ICONS") != "": case os.Getenv("LAZYSECRETS_ICONS") != "":
@@ -276,17 +298,7 @@ Flags:
} }
} }
recentIcon := valueOr(cfg.UI.RecentIcon, false) recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon
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 // Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file. // sample vaults never end up in the real file.
@@ -310,11 +322,8 @@ Flags:
// Two Azure accounts, like a personal and an admin login in az. // 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" alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
fake.Seed(st, alice, admin) 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{ providers = []provider.Provider{
az, fake.Azure(400*time.Millisecond).As(alice, "Azure · alice"),
fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"), fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
fake.Databricks(600 * time.Millisecond), fake.Databricks(600 * time.Millisecond),
fake.Repos(), fake.Repos(),
@@ -344,10 +353,17 @@ Flags:
providers = append(providers, dbx) providers = append(providers, dbx)
} }
if config.On(pc.Repos.Enabled) { 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{ repos := repo.New(repo.Settings{
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots), Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, 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},
}) })
// The repo it started in (shown on top as Repo) and the // The repo it started in (shown on top as Repo) and the
// others, checked for secret files in the background like // others, checked for secret files in the background like
@@ -363,14 +379,19 @@ Flags:
// the standard logger, which would draw over the TUI. // the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
switch sub { if getRef != nil {
case "get":
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind)) ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
defer cancel() defer cancel()
return get(ctx, providers, st, getRef) return get(ctx, providers, st, *getRef)
case "list": }
return listCmd(providers, st, cacheFile, timeoutFor, theme, *icons, recentIcon, expiryWarning, subArgs, os.Stdout) if listArgs != nil {
case "share": cachePath := ""
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cachePath = filepath.Join(d, "cache.json")
}
return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout)
}
if shareArgs != nil {
// The files from a repo, the key from whichever provider it's // The files from a repo, the key from whichever provider it's
// in: the longest timeout. // in: the longest timeout.
d := *timeout d := *timeout
@@ -379,7 +400,7 @@ Flags:
} }
ctx, cancel := context.WithTimeout(context.Background(), d) ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel() 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. // Links opened elsewhere (lazysecrets launch) can come here.
@@ -413,10 +434,16 @@ Flags:
onActive = srv.Touch onActive = srv.Touch
} }
// Where it was left in this folder. // What was loaded last time, and where it was left in this folder.
placeDir := "" var cacheFile, placeDir string
if !*demo { 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 { if abs, err := filepath.Abs(placeDir); err == nil {
placeDir = abs placeDir = abs
} }
@@ -433,7 +460,7 @@ Flags:
NoHistory: !config.On(cfg.Storage.History), NoHistory: !config.On(cfg.Storage.History),
OnActive: onActive, OnActive: onActive,
RevealTimeout: revealTimeout, RevealTimeout: revealTimeout,
ExpiryWarning: expiryWarning, ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.Values.HideOnBlur), HideOnBlur: config.On(cfg.Values.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(cfg.Updates.Sources), Updater: updater(cfg.Updates.Sources),
@@ -466,14 +493,6 @@ Flags:
return err 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 // configFiles are the config files given, for the status pane: those
// that exist, or the first if none does (where to create one). // that exist, or the first if none does (where to create one).
func configFiles(list string) []string { func configFiles(list string) []string {
+4 -9
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"flag"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -33,12 +32,8 @@ func updater(servers []string) *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's // updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it. // changelog, then (unless --check) install it.
func updateCmd(configPath string, args []string) error { func updateCmd(configPath string, args []string) error {
fs := flag.NewFlagSet("update", flag.ContinueOnError) check := len(args) == 1 && args[0] == "--check"
check := fs.Bool("check", false, "just say if there's a newer release") if len(args) > 0 && !check {
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
return errors.New("usage: lazysecrets update [--check]") return errors.New("usage: lazysecrets update [--check]")
} }
cfg, err := config.LoadAll(strings.Split(configPath, ",")) 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) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
} }
if !update.Newer(current, r.Tag) { 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) 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) fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil return nil
} }
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current))) 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.") fmt.Println("lazysecrets update installs it.")
return nil return nil
} }
+5 -4
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"regexp" "regexp"
"runtime/debug" "runtime/debug"
"strings"
) )
// version is set by release builds: // version is set by release builds:
@@ -10,13 +11,13 @@ import (
// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets // go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets
var version string 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, // pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
// maybe +dirty): its commit. // maybe +dirty): its commit.
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`) 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 { func versionString() string {
if version != "" { if version != "" {
return version return version
@@ -30,7 +31,7 @@ func versionString() string {
// go install …@<commit>. // go install …@<commit>.
if m := pseudo.FindStringSubmatch(v); m != nil { if m := pseudo.FindStringSubmatch(v); m != nil {
dirty := "" dirty := ""
if m[2] != "" { if strings.HasSuffix(v, "+dirty") {
dirty = "-dirty" dirty = "-dirty"
} }
return "dev-" + m[1][:7] + 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, 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 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 (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 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 assignments ask for; `l` goes into the pane, where `j`/`k` pick any
field, `/` filters them (`/tenant`) and `y` copies the selected one. 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 The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` 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 on one goes to it. That makes no requests of its own: expiry dates come
vaults it covers; `ctrl+a` in search loads the rest. 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, 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 SKU, whether access is by Azure roles or access policies, soft delete and
+39 -24
View File
@@ -1,11 +1,10 @@
# Config # Config
Optional, at `~/.config/lazysecrets/config.yaml` (or Optional, at `~/.config/lazysecrets/config.yaml` (or
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags (noted `$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
below) override it. Unknown keys are an error, so typos don't go it. Unknown keys are an error, so typos don't go unnoticed. `e` on the
unnoticed. `e` on the status pane (`0`) opens it in your editor, as in status pane (`0`) opens it in your editor, as in lazygit (making it if
lazygit (making it if it isn't there yet); changes apply when lazysecrets it isn't there yet); changes apply when lazysecrets starts again.
starts again.
```yaml ```yaml
ui: ui:
@@ -18,7 +17,7 @@ ui:
values: values:
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown 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) 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: storage:
cache: true # keep what was loaded (never values) for the next start cache: true # keep what was loaded (never values) for the next start
history: true # count uses of items for Recent (R) history: true # count uses of items for Recent (R)
@@ -29,17 +28,34 @@ updates:
- https://github.com/karleinarb/lazysecrets - https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets - https://git.kebcloud.no/keb/lazysecrets
providers: providers:
timeout: 30s # per backend request; a provider's own timeout overrides it; also --timeout timeout: 30s # per backend request; a provider's own timeout overrides it
azure: # likewise databricks and repos; azure covers every az account azure: # applies to every az account
enabled: true # default enabled: true # default
timeout: 30s 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) More, each on its own page: [repos](repos.md) (`providers.repos`),
(`providers.azure`), [Databricks](databricks.md) (`providers.databricks`), [sharing env files](links.md#sharing-env-files) (`sharing`), the
[repos](repos.md#which-files) (`providers.repos`), [link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`). [custom commands](custom-commands.md) (`customCommands`).
@@ -74,8 +90,6 @@ theme:
azureColor: ["#82aaff"] azureColor: ["#82aaff"]
databricksColor: ["#ff757f"] databricksColor: ["#ff757f"]
repoColor: ["#c099ff"] repoColor: ["#c099ff"]
springColor: ["#c3e88d"] # Spring Boot config files
composeColor: ["#82aaff"] # Docker Compose files
``` ```
A role lazysecrets doesn't know, a typo or one from a newer version, is A role lazysecrets doesn't know, a typo or one from a newer version, is
@@ -88,8 +102,9 @@ as boxes, turn them off with `ui.icons: false`.
Several config files can be layered, later ones winning (theme roles and Several config files can be layered, later ones winning (theme roles and
keybindings merge one by one), with a comma-separated `--config` or keybindings merge one by one), with a comma-separated `--config` or
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your `LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor
config plus an editor-generated theme: plugin can so add a generated theme file to your own config, the way
snacks.nvim themes lazygit from Neovim's highlight groups:
```sh ```sh
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
@@ -102,10 +117,11 @@ Stars, notes, usage and the layout are kept in
by you: one record per source, vault or secret, keyed by its by you: one record per source, vault or secret, keyed by its
`lazysecrets://` link, never with values. A star belongs to the account you `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 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. for one); a note describes the item, so it shows under every account. It's
Several lazysecrets windows can save to it without losing each other's JSON so an editor plugin can read it with a built-in parser; several
changes. One written by a newer lazysecrets is read but not saved to, lazysecrets windows can save to it without losing each other's changes.
with a warning, until you update. 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 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, count as using it: its record keeps when it was last used and how often,
@@ -141,9 +157,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 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; 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 `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 every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
pane, `↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what `lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
changed since your version, across the last 10 releases.
Updating (`u` there, or `lazysecrets update`) downloads the release for Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against 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 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. 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 ```sh
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets 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 | | `fix` | a bug fix | Fixed |
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no | | `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. `feat!:` marks a breaking change, `feat(scope):` names a part. Brackets
`[skip changelog]` in the description leaves a pull request out. 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 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 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`: Merge as many pull requests as you like, then release `main`:
```sh ```sh
scripts/release.sh --dry-run minor # the tag and notes the release would have scripts/release.sh --dry-run v0.2.0 # the notes the release would have
scripts/release.sh minor # CHANGELOG.md, commit, tag, push 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`. It refuses unless you're on a clean `main` that matches `origin/main`.
[git-cliff](https://git-cliff.org) (`cliff.toml`) writes `CHANGELOG.md` [git-cliff](https://git-cliff.org) (`cliff.toml`) writes `CHANGELOG.md`
from the pull request titles. The tag runs the release workflow from the pull request titles. The tag runs the release workflow
+57 -55
View File
@@ -2,7 +2,7 @@
lazysecrets works outside its own window too: `lazysecrets get` reads a lazysecrets works outside its own window too: `lazysecrets get` reads a
value for scripts, and `lazysecrets list` lists everything for fzf, editor value for scripts, and `lazysecrets list` lists everything for fzf, editor
pickers and your own tools. Both take the [links](sharing.md) lazysecrets pickers and your own tools. Both take the [links](links.md) lazysecrets
copies and shares. For Neovim, there's copies and shares. For Neovim, there's
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim). [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
@@ -19,8 +19,7 @@ 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 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 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. 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 The value ends with a newline only on a terminal.
value, it says why on stderr and exits with status 1.
## Listing, for fzf and pickers ## Listing, for fzf and pickers
@@ -30,66 +29,69 @@ tab-separated:
| Field | | | Field | |
|---|---| |---|---|
| kind | `source`, `vault`, `secret`, `key` or `certificate` | | 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` | | 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 | | account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
| 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 | | link | its `lazysecrets://` link |
What's cached prints at once, then what loads follows as it arrives: read It's what Go to searches, so fzf, or an editor's picker, can search it
it as a stream. Values are never listed. It stops quietly when the reader instead, showing the names and acting on the links:
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 ```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \ lazysecrets list | fzf --ansi --with-nth='2..-3' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \ --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-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-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {2..-3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)' --bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
``` ```
![fzf-example](assets/fzf-example.png) `--with-nth='2..-3'` shows and searches only the names; `{-1}` and
`--accept-nth=-1` are the link, `{-2}` the account. All but the name are
one word, so this works without `--delimiter`. To filter by kind
yourself: `awk -F'\t' '$1 == "secret"'`.
`enter` prints the link (`lazysecrets get $(…)`), `ctrl-o` opens it in
lazysecrets, `ctrl-y` copies the value, saying so in the header, and
`ctrl-x` copies it and closes fzf (`pbcopy` on macOS; `wl-copy` or `xclip`
on Linux). A value copied this way isn't cleared from the
clipboard after `values.clipboardClear`, as one copied in lazysecrets is.
`--account` makes `open` and `get` use the account it was listed through,
when several of yours can see it: without, `open` asks which, and `get`
tries the one that last read it first. The link stays as it is, to copy
and share; the account isn't part of it. `--action` tells `open` what to
do rather than asking, as it does for a link clicked elsewhere: `go` to
it, `copy` its value too, or `show` it. It ends in the secrets list, like
Go to, rather than on the details as a clicked link does.
What's [cached](config.md#cache) prints at once. Then, like Go to, it loads
what's missing or older than `--max-age` (2m), and what's new in it follows
as it arrives: fzf shows it while you type. `--cached` loads nothing from
Azure or Databricks, for an answer at once. Repos are always read, from
disk. What it loads is cached for the TUI too. It stops when the reader
goes, after a pick in fzf.
`--kind` lists only some kinds, comma-separated: `source`, `vault` (also
Databricks scopes and repo files), `secret`, `key`, `certificate`. Lists
only other kinds need aren't loaded: `--kind source,vault` asks for no
secrets. Keys and certificates load only when asked for this way; else,
like Go to, they're listed as far as you opened them in lazysecrets.
A vault or source it can't read is a line too, as it fails: its name,
then `✗` and why in red (`errorColor`), with its link, so opening it
shows more. They're in the list, not on stderr, which would draw over fzf.
`--errors=false` leaves them out.
The name is coloured from your [theme](config.md#theme), with the
provider's icon when [icons](config.md#theme) are on (`ui.icons`); fzf
shows the colours with `--ansi`. `--color never` leaves them out, for
scripts, and `--icons=false` the icons. The link is never coloured.
`--format json` prints an object per line instead, for editor plugins,
without colours or icons, and with `error` on a list it couldn't read:
```json
{"link":"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password","kind":"secret","provider":"azure","path":["platform-prod","kv-app-prod","db-password"],"account":"Azure:alice@example.com/<tenant>","accountLabel":"alice"}
```
`accountLabel`, the account's short name, is there when several az
accounts are signed in. Values are never listed.
+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 | | `[` / `]` | 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 | | `/` | 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 | | `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
| `H` | show / hide what's hidden: [Spring and compose keys](repos.md#spring-boot-config) that don't look secret, example files, and files and repos with only those; shown dimmed |
| `r` | refresh the focused pane | | `r` | refresh the focused pane |
| `q`, `ctrl+c` | quit | | `q`, `ctrl+c` | quit |
@@ -50,12 +49,12 @@ match case exactly.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) | | `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}}`) | | `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 | | `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) | | `o` | open the selected item in the browser (over SSH: copy the link) |
| `e` | open the selected SOPS, `.env`, Spring or compose file, or a secret's file at its line ([how](repos.md#opening-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](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-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) | | `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) | | `x` | [custom commands](custom-commands.md) |
| `L` | sign in again (shown when a login has expired) | | `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 A revealed value is shown as it reads best: JSON indented, and PEM
certificates as their subject, issuer, dates, names and thumbprint, with certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies 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 it exactly as stored.
loses focus ([config](config.md)).
A revealed value hides again after `values.revealTimeout` (60s) and
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
## Stars and notes ## 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 | | `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 | | `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 ## Go to
@@ -122,16 +124,15 @@ it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how 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 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 editor, as in lazygit. Focused, the right pane lists more under the
logo; `y` copies any row: 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
- the version and updates (`U`) it), this session's errors, newest first (what a pane showed, or the
- each account (`L` on one that needs it signs in) footer flashed before you moved on; not kept after quitting), the config,
- what's expiring (`enter` goes to it) state and cache files (`e` opens one in your editor), and links to the
- this session's errors, newest first (not kept after quitting) docs, source, issues, releases and changelog (`o` opens one; on the
- the config, state and cache files (`e` opens one in your editor) version or update, its release), then the changelog of every release up
- links to the docs, source, issues, releases and changelog (`o` opens one) to yours, built in so it needs no network: `/` filters it (`/fixed`,
- the changelog of every release up to yours, built in: `/` filters it `/search`). `y` copies any row.
(`/fixed`, `/search`)
| Key | Action | | Key | Action |
|---|---| |---|---|
@@ -140,8 +141,9 @@ logo; `y` copies any row:
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back | | `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
| `=` | reset the layout (divider, heights, zoom) | | `=` | reset the layout (divider, heights, zoom) |
The divider and pane heights are remembered (zoom isn't). `<>{}` are for The divider and pane heights are remembered (zoom isn't). macOS uses
macOS, where ctrl+arrows switch desktops. ctrl+arrows for Mission Control and switching desktops by default, hence
the `<` `>` `{` `}` alternatives.
## Rebinding keys ## Rebinding keys
+10 -14
View File
@@ -1,24 +1,21 @@
# Sharing # Links
Subscriptions, workspaces, vaults, scopes, repos, files and secrets are Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens 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 it lands on that item, reading it with their own account and
permissions: the link names the item, never its value. Env files can permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself. 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 vault
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab 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 secret
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version 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://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password 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 `s` copies a link to the selected item (from the versions view, pinned to
that version). `S` opens the share menu: 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 before can decrypt links made with it without asking the vault, so rotate
it now and then: new links use the newest version. 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 - 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 and fills in any reference it finds there. Key Vault URLs,
links, `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too. `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal. - `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is - Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
set up. set up.
@@ -146,10 +143,9 @@ turns the question off too.
link then opens a new window of the terminal you ran it in, with link then opens a new window of the terminal you ran it in, with
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2, lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
tmux; `--terminal NAME` picks another. It records your `PATH`, config tmux; `--terminal NAME` picks another. It records your `PATH` and config
locations and XDG directories, since apps start without your shell's locations, since apps start without your shell's environment (and so
environment (and so without `az`): run it again after moving lazysecrets without `az`): run it again after moving lazysecrets or changing those.
or changing those.
- macOS: a small background app, `~/Applications/Lazysecrets Links.app` - macOS: a small background app, `~/Applications/Lazysecrets Links.app`
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as - Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
+22 -101
View File
@@ -1,9 +1,9 @@
# SOPS, .env, Spring and compose files in git repos # SOPS and .env files in git repos
lazysecrets shows git repositories under **Repos**: their lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, [SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI), plain env files, [Spring Boot config](#spring-boot-config) `.env`, INI) and plain env files as vaults, and the keys in them
and [Docker Compose files](#docker-compose) as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
escaped, `app\.name`, so it doesn't read as nesting. escaped, `app\.name`, so it doesn't read as nesting.
## Which repos ## Which repos
@@ -29,9 +29,8 @@ anything by hand.
## Which files ## Which files
**Env files**: `.env`, `.env.local`, `prod.env` and the like, also when **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. git-ignored (as they usually are), in the folders git tracks files in;
`.env.example`, `.sample`, `.template` and the like are listed hidden not `.env.example`, `.sample`, `.template` and the like. They're read
(`H` shows them): someone may have put a real value in one. They're read
directly; a file with SOPS metadata is always treated as SOPS, whatever directly; a file with SOPS metadata is always treated as SOPS, whatever
its name. its name.
@@ -47,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, 3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
4. the whole repo. 4. the whole repo.
`include`/`exclude` apply to env, Spring and compose files too. `include`/`exclude` apply to env files too.
```yaml ```yaml
providers: providers:
@@ -59,88 +58,20 @@ providers:
``` ```
A repo's `.lazysecrets.yaml` can also name the secret its env files are 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 ```yaml
sharing: sharing:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link 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: secretPatterns:`, `compose:
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.
## Decrypting ## Decrypting
Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and Listing never decrypts: SOPS encrypts values only, so keys and metadata
metadata (encryption, last modified) are read straight from the files. (encryption, last modified) are read straight from the files. Revealing,
Revealing, copying or `lazysecrets get` decrypts one value; SOPS's with copying or `lazysecrets get` decrypts one value with your `sops` binary, so
your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
through az, AWS/GCP KMS, PGP). When sops can't decrypt, its whole error KMS, PGP). When sops can't decrypt, its whole error message is shown.
message is shown.
On a file, the right pane shows how it's encrypted and the keys it's 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 encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
@@ -149,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 git tracks, or that isn't ignored, is marked in red: its values are in the
repo's history, or easily could be. repo's history, or easily could be.
### Jasypt
A value like `ENC(...)` in a plain `.env`, Spring or compose 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 ## Opening files
`e` opens the selected file, or on a secret, its file at that key's line: `e` opens the selected file, or on a secret, its file at that key's line:
@@ -187,5 +100,13 @@ sops 3.8 or later.
## Links ## Links
Repo links name the repo by its git remote, so they open in a colleague's `S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files).
clone wherever it is ([sharing](sharing.md)).
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).
+14 -40
View File
@@ -32,10 +32,6 @@ type File struct {
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"` Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]Stamped[provider.Object] `json:"details,omitempty"` Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,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. // Stamped is a list or detail with when it was loaded.
@@ -73,16 +69,6 @@ func ProviderOf(key string) string {
return id 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 // Read reads path; a missing, unreadable or other version's file is an
// empty cache. // empty cache.
func Read(path string) File { func Read(path string) File {
@@ -104,27 +90,17 @@ func Read(path string) File {
} }
// Save writes f to path, merged with the file there: of a list in both, // 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 // the one loaded last wins, and f's cursors win. Entries older than
// file's). Entries older than MaxAge, those of providers keep says no to // MaxAge, and those of providers keep says no to (accounts signed out
// (accounts signed out of), and f.Dropped are dropped, from the file's // of), are dropped, from the file's side as well.
// 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 { 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() out := New()
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge } ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
disk := Read(path) disk := Read(path)
merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped) merge(out.Sources, disk.Sources, f.Sources, ok)
merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped) merge(out.Containers, disk.Containers, f.Containers, ok)
merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped) merge(out.Objects, disk.Objects, f.Objects, ok)
merge(out.Details, disk.Details, f.Details, ok, f.Dropped) merge(out.Details, disk.Details, f.Details, ok)
for _, m := range []map[string]string{disk.Cursor, f.Cursor} { for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
for k, v := range m { for k, v := range m {
if keep(ProviderOf(k)) { if keep(ProviderOf(k)) {
@@ -135,19 +111,14 @@ func Save(path string, f File, keep func(providerID string) bool, now time.Time)
return write(path, out) return write(path, out)
} }
// merge puts disk's entries in out, but not those dropped, then ours func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool) {
// where they're newer. for _, m := range []map[string]Stamped[T]{disk, ours} {
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 m {
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)) { if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
out[k] = e out[k] = e
} }
} }
}
} }
// write writes f atomically, readable by the user only. // write writes f atomically, readable by the user only.
@@ -156,6 +127,9 @@ func write(path string, f File) error {
if err != nil { if err != nil {
return err return err
} }
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json") tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil { if err != nil {
return err return err
-31
View File
@@ -89,34 +89,3 @@ func TestReadOtherVersion(t *testing.T) {
t.Errorf("read another version's file: %+v", got) 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. // Write puts s on the clipboard.
func (c Clipboard) Write(s string) tea.Cmd { func (c Clipboard) Write(s string) tea.Cmd {
if !c.native { if !c.native {
+4 -2
View File
@@ -16,7 +16,6 @@
package commands package commands
import ( import (
"cmp"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@@ -61,7 +60,10 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config. // Validate checks a command from the config.
func (c Command) Validate() error { func (c Command) Validate() error {
name := cmp.Or(c.Description, c.Command) name := c.Description
if name == "" {
name = c.Command
}
switch { switch {
case c.Command == "": case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description) return fmt.Errorf("custom command %q has no command", c.Description)
+11 -17
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -15,7 +14,6 @@ import (
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "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/share"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
@@ -49,8 +47,7 @@ type UI struct {
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists // RecentIcon marks items used lately (Recent, R) with ◷, in the lists
// and in lazysecrets list. Defaults to false. // and in lazysecrets list. Defaults to false.
RecentIcon *bool `yaml:"recentIcon"` RecentIcon *bool `yaml:"recentIcon"`
// SourcesView is "grouped" (default) or "flat"; the backtick key // SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
// toggles it.
SourcesView string `yaml:"sourcesView"` SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded; // CollapseGroups starts the Sources pane with its groups folded;
// defaults to true. // defaults to true.
@@ -154,11 +151,6 @@ type Repos struct {
// a repo's .sops.yaml creation_rules say where to look. // a repo's .sops.yaml creation_rules say where to look.
Include []string `yaml:"include"` Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"` Exclude []string `yaml:"exclude"`
// SecretPatterns say which keys of config files are secrets; Spring
// and Compose override them for their files, list by list.
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
Spring repo.FileType `yaml:"spring"`
Compose repo.FileType `yaml:"compose"`
} }
type Azure struct { type Azure struct {
@@ -211,12 +203,17 @@ func xdgDir(env, fallback string) (string, error) {
return filepath.Join(base, "lazysecrets"), nil return filepath.Join(base, "lazysecrets"), nil
} }
// Load reads path. A missing file is an empty config. Unknown keys are an
// error, so typos don't silently do nothing.
func Load(path string) (Config, error) {
return LoadAll([]string{path})
}
// LoadAll reads several config files in order, each on top of the ones // LoadAll reads several config files in order, each on top of the ones
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the // before, like lazygit's LG_CONFIG_FILE: a later file overrides the
// settings it sets, and theme roles merge one by one. That lets an editor // settings it sets, and theme roles merge one by one. That lets an editor
// plugin add a generated theme file to the user's own config. Missing // plugin add a generated theme file to the user's own config. Missing
// files are skipped. Unknown keys are an error, so typos don't silently do // files are skipped.
// nothing.
func LoadAll(paths []string) (Config, error) { func LoadAll(paths []string) (Config, error) {
var c Config var c Config
keybinding := map[string]map[string][]string{} keybinding := map[string]map[string][]string{}
@@ -231,7 +228,9 @@ func LoadAll(paths []string) (Config, error) {
if keybinding[ctx] == nil { if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{} keybinding[ctx] = map[string][]string{}
} }
maps.Copy(keybinding[ctx], actions) for name, keys := range actions {
keybinding[ctx][name] = keys
}
} }
} }
c.Keybinding = keybinding c.Keybinding = keybinding
@@ -298,11 +297,6 @@ func (c Config) validate(path string) error {
return fmt.Errorf("%s: %s: %s is negative", path, name, d) 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")); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") { if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) 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) { func TestLoad(t *testing.T) {
c, err := LoadAll([]string{write(t, ` c, err := Load(write(t, `
ui: ui:
icons: false icons: false
sourcesView: flat sourcesView: flat
@@ -36,7 +36,7 @@ providers:
databricks: databricks:
enabled: false enabled: false
hideProfiles: [test] hideProfiles: [test]
`)}) `))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"missing": filepath.Join(t.TempDir(), "nope.yaml"), "missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""), "empty": write(t, ""),
} { } {
c, err := LoadAll([]string{path}) c, err := Load(path)
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil { if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err) t.Errorf("%s: %+v, %v", name, c, err)
} }
@@ -84,10 +84,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n", "workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "sharing:\n keyRules:\n minLength: 8\n", "short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n", "classes": "sharing:\n keyRules:\n minClasses: 5\n",
"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) t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") { } else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err) t.Errorf("%s: error doesn't name the file: %v", name, err)
@@ -134,18 +132,18 @@ theme:
} }
func TestUpdates(t *testing.T) { 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 { if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
t.Errorf("updates = %+v, %v", c.Updates, err) t.Errorf("updates = %+v, %v", c.Updates, err)
} }
} }
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := 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 { if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err) t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
} }
if _, err := 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") 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 package instance
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -35,18 +36,15 @@ func Dir(stateDir string) string {
type Status struct { type Status struct {
PID int `json:"pid"` PID int `json:"pid"`
LastActive time.Time `json:"lastActive"` // last key press or focus 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 is the terminal app it would show in (macOS), preferring the
// app the link was clicked in. // app the link was clicked in.
App string `json:"app,omitempty"` 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 { type request struct {
Op string `json:"op"` // "status" or "open" Op string `json:"op"` // "status" or "open"
Link string `json:"link,omitempty"` Link string `json:"link,omitempty"`
@@ -116,7 +114,7 @@ func (s *Server) handle(c net.Conn) {
defer c.Close() defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second)) _ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request var req request
if err := json.NewDecoder(c).Decode(&req); err != nil { if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
return return
} }
var resp response var resp response
@@ -190,7 +188,7 @@ func Best(found []Found, front string, terminal bool) (Found, bool) {
best = f best = f
} }
} }
return best, best.Reach > unreachable return best, best.Reach > 0
} }
// Open hands link, clicked in front, to f. // Open hands link, clicked in front, to f.
+23 -22
View File
@@ -1,7 +1,6 @@
package instance package instance
import ( import (
"cmp"
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
@@ -26,8 +25,7 @@ func tmux(args ...string) (string, error) {
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" } func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
type client struct { type client struct {
name, session string name, session, activity string // activity: Unix seconds
activity int // Unix seconds
pid int pid int
app string app string
} }
@@ -44,31 +42,30 @@ func clients() []client {
if len(f) < 4 { if len(f) < 4 {
continue continue
} }
activity, err1 := strconv.Atoi(f[0]) pid, err := strconv.Atoi(f[1])
pid, err2 := strconv.Atoi(f[1]) if err != nil {
if err1 != nil || err2 != nil {
continue 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 return cs
} }
// place is where this instance would show: the tmux client (zero outside // 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) { func place(front string) (client, string, int) {
if !inTmux() { 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}") session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
if err != nil { if err != nil {
return client{}, "", unreachable return client{}, "", 0
} }
cs := clients() cs := clients()
if len(cs) == 0 {
return client{}, "", unreachable
}
// Best first: in the front app on its session, in the front app, // Best first: in the front app on its session, in the front app,
// on its session, any. // on its session, any.
rank := func(c client) int { rank := func(c client) int {
@@ -81,16 +78,20 @@ func place(front string) (client, string, int) {
} }
return r return r
} }
best := cs[0] var best client
for _, c := range cs[1:] { found := false
if rank(c) > rank(best) { for _, c := range cs {
best = c if !found || rank(c) > rank(best) {
best, found = c, true
} }
} }
if !found {
return client{}, "", 0
}
if best.session == session { 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 // 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. // then its app to the front, showing a hidden dropdown terminal.
func Raise(front string) { func Raise(front string) {
c, app, reach := place(front) c, app, reach := place(front)
if reach == unreachable { if reach == 0 {
return return
} }
if inTmux() { if inTmux() {
pane := os.Getenv("TMUX_PANE") pane := os.Getenv("TMUX_PANE")
_, _ = tmux("select-window", "-t", pane) _, _ = tmux("select-window", "-t", pane)
_, _ = tmux("select-pane", "-t", pane) _, _ = tmux("select-pane", "-t", pane)
if reach == switchable { if reach == 1 {
_, _ = tmux("switch-client", "-c", c.name, "-t", pane) _, _ = 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() out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
if err != nil { 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 exe := strings.TrimSpace(string(out)); !exists(exe) {
if _, err := os.Stat(exe); err != nil {
return "lazysecrets:// links run " + exe + ", which no longer exists" return "lazysecrets:// links run " + exe + ", which no longer exists"
} }
return "" return ""
} }
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration. // Uninstall removes the applet and its registration.
func Uninstall() (string, error) { func Uninstall() (string, error) {
app, err := AppPath() app, err := AppPath()
+4 -5
View File
@@ -140,10 +140,11 @@ func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` 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. // it.
func Launch(argv []string) error { func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...) cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return err return err
} }
@@ -165,11 +166,9 @@ func Executable() (string, error) {
// passEnv lists the variables carried from the install's environment to // passEnv lists the variables carried from the install's environment to
// lazysecrets in the new window: apps start with a minimal environment, // 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 // without Homebrew's PATH (so no az) or the user's config locations.
// the window would use another cache and miss running instances' sockets.
var passEnv = []string{ var passEnv = []string{
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE", "AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
} }
+16 -51
View File
@@ -51,14 +51,6 @@ type Entry struct {
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
// Note is the user's note on it, if any. // Note is the user's note on it, if any.
Note string `json:"note,omitempty"` 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 // 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 // listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link. // account's sources unreadable, the account without a link.
@@ -73,43 +65,29 @@ type Options struct {
MaxAge time.Duration MaxAge time.Duration
// Cached lists only what's cached (and repos): nothing is loaded. // Cached lists only what's cached (and repos): nothing is loaded.
Cached bool 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. // 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 // Lists only needed for other kinds aren't loaded: sources and vaults
// alone don't load any secrets. Lists that fail are always listed. // alone don't load any secrets. Lists that fail are always listed.
Kinds []string Kinds []string
// Mark tells what's remembered about the entry with link, seen // Mark tells whether the entry with link is starred for account,
// through account; nil marks nothing. // when it was last used and how often (zero: not lately), and its
Mark func(link, account string) Marks // note; nil marks nothing.
Timeout func(provider.Kind) time.Duration // nil: 30s Mark func(link, account string) (starred bool, lastUsed time.Time, uses int, note string)
Timeout func(provider.Kind) time.Duration
Now func() time.Time 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 // 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 // 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 // error from emit (the reader went away) stops it. It returns the cache
// with what was loaded, and whether anything was. // with what was loaded.
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) { func Run(ctx context.Context, o Options, emit func(Entry) error) cachefile.File {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{}, l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, results: make(chan result)}
results: make(chan result)}
if l.o.Now == nil { if l.o.Now == nil {
l.o.Now = time.Now 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 { for _, p := range o.Providers {
if l.stopped != nil { if l.stopped != nil {
break break
@@ -128,7 +106,7 @@ func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File
cancel() cancel()
} }
} }
return l.o.Cache, l.loaded return l.o.Cache
} }
type job struct { type job struct {
@@ -152,8 +130,6 @@ type lister struct {
emit func(Entry) error emit func(Entry) error
seen map[string]bool // provider ID + link of entries emitted seen map[string]bool // provider ID + link of entries emitted
pending map[string]bool // cache keys queued or loading 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 queue []job
active int active int
results chan result results chan result
@@ -185,9 +161,6 @@ func (l *lister) provider(p provider.Provider) {
// source emits src, and what's cached under it, and queues what's not. // source emits src, and what's cached under it, and queues what's not.
func (l *lister) source(p provider.Provider, src provider.Source) { 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() src.Provider, src.ProviderLabel = p.ID(), p.Label()
l.put(p, sourceEntry(src)) l.put(p, sourceEntry(src))
row := cachefile.Row(p.ID(), src.ID) row := cachefile.Row(p.ID(), src.ID)
@@ -204,9 +177,6 @@ func (l *lister) source(p provider.Provider, src provider.Source) {
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) { 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 }) { for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
if c.Hidden && !l.o.Hidden {
continue
}
c.Source = src c.Source = src
l.put(p, containerEntry(c)) l.put(p, containerEntry(c))
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
@@ -232,17 +202,17 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) { for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
o.Container = c o.Container = c
r, err := ref.ForObject(o, "") r, err := ref.ForObject(o, "")
if err != nil || (o.Hidden && !l.o.Hidden) { if err != nil {
continue continue
} }
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind), l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source), Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL, Hidden: o.Hidden}) Expires: o.Expires, Enabled: o.Enabled})
} }
} }
func sourceEntry(src provider.Source) Entry { 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} e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src)}
if r, err := ref.ForSource(src); err == nil { if r, err := ref.ForSource(src); err == nil {
e.Link = r.String() e.Link = r.String()
} }
@@ -250,7 +220,7 @@ func sourceEntry(src provider.Source) Entry {
} }
func containerEntry(c provider.Container) Entry { func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL, Hidden: c.Hidden} e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source)}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil { if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String() e.Link = r.String()
} }
@@ -265,11 +235,8 @@ func (l *lister) put(p provider.Provider, e Entry) {
} }
e.Account = p.ID() e.Account = p.ID()
if l.o.Mark != nil && e.Link != "" { if l.o.Mark != nil && e.Link != "" {
m := l.o.Mark(e.Link, e.Account) e.Starred, e.LastUsed, e.Uses, e.Note = l.o.Mark(e.Link, e.Account)
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note e.Recent = !e.LastUsed.IsZero()
if m.Recent {
e.LastUsed, e.Uses = m.LastUsed, m.Uses
}
} }
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] { if l.stopped != nil || l.seen[id] {
@@ -293,7 +260,7 @@ func (l *lister) wants(kinds ...string) bool {
} }
func (l *lister) enqueue(j job) { func (l *lister) enqueue(j job) {
if l.pending[j.key] || l.failed[j.key] { if l.pending[j.key] {
return return
} }
l.pending[j.key] = true l.pending[j.key] = true
@@ -329,11 +296,9 @@ func (l *lister) handle(r result) {
delete(l.pending, r.key) delete(l.pending, r.key)
now := l.o.Now() now := l.o.Now()
if r.err != nil { if r.err != nil {
l.failed[r.key] = true
l.put(r.p, r.failed()) l.put(r.p, r.failed())
return return
} }
l.loaded = l.loaded || l.cacheable(r.p)
cache := l.cacheable(r.p) cache := l.cacheable(r.p)
switch { switch {
case r.c != nil: case r.c != nil:
+4 -37
View File
@@ -42,7 +42,7 @@ func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
} }
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second } o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
var got, failed []Entry var got, failed []Entry
f, _ := Run(context.Background(), o, func(e Entry) error { f := Run(context.Background(), o, func(e Entry) error {
if e.Error != "" { if e.Error != "" {
failed = append(failed, e) failed = append(failed, e)
} else { } else {
@@ -87,11 +87,6 @@ func TestListLoadsAndCaches(t *testing.T) {
t.Errorf("provider %q", e.Provider) 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 { 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)) t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
} }
@@ -234,17 +229,14 @@ func TestListMarks(t *testing.T) {
az := fake.Azure(0) az := fake.Azure(0)
var starredLink string var starredLink string
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"}, got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
Mark: func(link, account string) Marks { Mark: func(link, account string) (bool, time.Time, int, string) {
if starredLink == "" && strings.HasSuffix(link, "/db-password") { if starredLink == "" && strings.HasSuffix(link, "/db-password") {
starredLink = link starredLink = link
} }
if strings.HasSuffix(link, "/api-key") { if strings.HasSuffix(link, "/api-key") {
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"} return false, time.Now(), 3, "rotate in May"
} }
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago return link == starredLink && account == az.ID(), time.Time{}, 0, ""
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
}
return Marks{Starred: link == starredLink && account == az.ID()}
}}) }})
var starred, recent int var starred, recent int
for _, e := range got { for _, e := range got {
@@ -270,28 +262,3 @@ func TestListMarks(t *testing.T) {
t.Errorf("%d starred, %d recent", starred, recent) 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 return t, true
} }
// Empty reports whether the query has no terms: everything matches.
func (q Query) Empty() bool { return len(q.groups) == 0 }
// Item is a row to match: Text is everything searched, and runes // Item is a row to match: Text is everything searched, and runes
// NameStart up to NameEnd of it are the row's name (the rest is context, // NameStart up to NameEnd of it are the row's name (the rest is context,
// like a path or a note). Without a name, nothing gets the name bonus. // like a path or a note). Without a name, nothing gets the name bonus.
+6
View File
@@ -99,3 +99,9 @@ func TestAnchorsOnTheName(t *testing.T) {
t.Errorf("password$: %v", got) t.Errorf("password$: %v", got)
} }
} }
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+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"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "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/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" "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 // Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones. // disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) { 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 { for _, s := range page.Value {
switch deref(s.State) { switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted: case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
@@ -113,7 +118,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.mu.Lock() p.mu.Lock()
p.tenants[id] = tenant p.tenants[id] = tenant
p.mu.Unlock() p.mu.Unlock()
srcs = append(srcs, provider.Source{ out = append(out, provider.Source{
Kind: provider.KindAzure, Kind: provider.KindAzure,
ID: id, ID: id,
Name: deref(s.DisplayName), Name: deref(s.DisplayName),
@@ -121,10 +126,6 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"), 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)) }) 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 { if len(p.opts.Vaults) > 0 {
@@ -229,29 +230,22 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
if err != nil { if err != nil {
return nil, err return nil, err
} }
return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) { var out []provider.Container
for _, v := range page.Value { pager := client.NewListBySubscriptionPager(nil)
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
for pager.More() { for pager.More() {
page, err := pager.NextPage(ctx) page, err := pager.NextPage(ctx)
if err != nil { if err != nil {
return nil, cleanError(err) 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 return out, nil
} }
@@ -294,40 +288,51 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
var out []provider.Object var out []provider.Object
switch t { switch t {
case provider.TypeSecret: case provider.TypeSecret:
tenant := p.tenant(c.Source.ID) pager := vc.secrets.NewListSecretPropertiesPager(nil)
out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) { for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value { for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates. // Certificate-backing secrets appear under Certificates.
if s.ID != nil && !deref(s.Managed) { if s.ID == nil || deref(s.Managed) {
objs = append(objs, secretObject(c, s, tenant)) continue
}
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
} }
} }
return objs
})
case provider.TypeKey: 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 { for _, k := range page.Value {
if k.KID != nil && !deref(k.Managed) { if k.KID == nil || deref(k.Managed) {
objs = append(objs, keyObject(c, k)) continue
}
out = append(out, keyObject(c, k))
} }
} }
return objs
})
case provider.TypeCertificate: 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 { for _, cp := range page.Value {
if cp.ID != nil { if cp.ID == nil {
objs = append(objs, certObject(c, cp)) continue
}
out = append(out, certObject(c, cp))
} }
} }
return objs
})
default: default:
return nil, provider.ErrUnsupported 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)) }) slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil 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 != nil {
if resp.Key.KID != nil { if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version() 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) 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 { if resp.ID != nil {
o.Version = resp.ID.Version() 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 { if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType) o.ContentType = deref(pol.SecretProperties.ContentType)
@@ -376,46 +381,56 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err != nil { if err != nil {
return nil, err return nil, err
} }
tenant := p.tenant(o.Container.Source.ID)
var out []provider.Object var out []provider.Object
switch o.Type { switch o.Type {
case provider.TypeSecret: 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 { for _, s := range page.Value {
if s.ID != nil { 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: 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 { for _, k := range page.Value {
if k.KID != nil { if k.KID != nil {
objs = append(objs, keyObject(o.Container, k)) out = append(out, keyObject(o.Container, k))
}
} }
} }
return objs
})
case provider.TypeCertificate: 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 { for _, cp := range page.Value {
if cp.ID != nil { if cp.ID != nil {
objs = append(objs, certObject(o.Container, cp)) out = append(out, certObject(o.Container, cp))
}
} }
} }
return objs
})
default: default:
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
if err != nil { tenant := p.tenant(o.Container.Source.ID)
return nil, err
}
for i := range out { for i := range out {
out[i].Reference = versionedReference(out[i]) out[i].Reference = versionedReference(out[i])
if o.Type != provider.TypeSecret { // secretObject's is the version's already 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) }) 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 // Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty). // public certificate, as of o.Version (latest if empty).
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { 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) v, _, err := p.VersionedValue(ctx, o)
return v, err 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. // VersionedValue is Value, with the version read.
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) { 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) vc, err := p.clients(o.Container.Detail)
if err != nil { if err != nil {
return provider.SecretValue{}, "", err return provider.SecretValue{}, "", err
} }
var text string
switch o.Type {
case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil) resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil { if err != nil {
return provider.SecretValue{}, "", cleanError(err) return provider.SecretValue{}, "", cleanError(err)
@@ -459,27 +502,6 @@ func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provi
version = resp.ID.Version() version = resp.ID.Version()
} }
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil 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 ( 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 // portalAssetURL links to a Key Vault object's page: a version's with
// one in objectID; a secret's own (its versions) without. // one in objectID; a secret's own (its versions) without.
func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string { func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
} }
func tenantPrefix(tenant string) string { func tenantPrefix(tenant string) string {
@@ -73,13 +73,19 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
if !ok { if !ok {
return "" 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. // objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name. // https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, t provider.ObjectType, name string) string { func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
} }
// secretObject is a secret, or a version of one, from a listing. Its URL // 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. // a version, that version's.
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object { func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, provider.TypeSecret, name) url := objectURL(c, "secrets", name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeSecret, Type: provider.TypeSecret,
@@ -97,7 +103,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
ContentType: deref(p.ContentType), ContentType: deref(p.ContentType),
Tags: tags(p.Tags), Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")", 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 { if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) 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 { func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name() name := p.KID.Name()
url := objectURL(c, provider.TypeKey, name) url := objectURL(c, "keys", name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeKey, 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 { func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, provider.TypeCertificate, name) url := objectURL(c, "certificates", name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeCertificate, Type: provider.TypeCertificate,
+13 -13
View File
@@ -2,7 +2,6 @@ package azure
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"strings" "strings"
@@ -71,10 +70,13 @@ func graphInfo(m map[string]any) vaultInfo {
// armInfo reads a vault's settings from the management API. // armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo { 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 { if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
info.group = id.ResourceGroupName info.group = id.ResourceGroupName
} }
for k, t := range v.Tags {
info.tags[k] = deref(t)
}
pr := v.Properties pr := v.Properties
if pr == nil { if pr == nil {
return info return info
@@ -120,11 +122,7 @@ func (v vaultInfo) attrs() []provider.Attr {
} else { } else {
add("soft delete", "off") add("soft delete", "off")
} }
if on(v.purge, false) { add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
add("purge prot.", "on")
} else {
add("purge prot.", "off")
}
switch { switch {
case strings.EqualFold(v.publicAccess, "disabled"): case strings.EqualFold(v.publicAccess, "disabled"):
add("network", "private endpoints only") add("network", "private endpoints only")
@@ -133,13 +131,15 @@ func (v vaultInfo) attrs() []provider.Attr {
default: default:
add("network", "all networks") add("network", "all networks")
} }
switch { if v.endpoints > 0 {
case v.endpoints == 1: add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
add("private", "1 endpoint")
case v.endpoints > 1:
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
} }
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 := "" label := ""
if i == 0 { if i == 0 {
label = "tags" label = "tags"
+11 -14
View File
@@ -203,20 +203,17 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
return out, nil return out, nil
} }
// fallbackVault is the backing vault of an AKV-backed scope, to read it // vaultURL is the backing vault of an AKV-backed scope, or "". It falls
// from when Databricks fails, or "" when there's none to fall back to. It // back to the scope's Detail, so a scope remembered from an earlier session
// falls back to the scope's Detail, so a scope remembered from an earlier // (a favourite) works before its workspace's scopes are listed.
// session (a favourite) works before its workspace's scopes are listed. func (p *Provider) vaultURL(c provider.Container) string {
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
if p.keyVault == nil || ctx.Err() != nil {
return ""
}
p.mu.Lock() p.mu.Lock()
url := p.vaults[c.ID] url := p.vaults[c.ID]
p.mu.Unlock() p.mu.Unlock()
if url == "" { if url == "" {
if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok { url, _ = strings.CutPrefix(c.Detail, akvDetail)
url = detail if url == c.Detail {
url = ""
} }
} }
return 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. // 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) { func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
url := p.fallbackVault(ctx, c) url := p.vaultURL(c)
if url == "" { if url == "" || p.keyVault == nil || ctx.Err() != nil {
return nil, errNoFallback return nil, errNoFallback
} }
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret) 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) dbxErr := classify(pr, err)
url := p.fallbackVault(ctx, o.Container) url := p.vaultURL(o.Container)
if url == "" { if url == "" || p.keyVault == nil || ctx.Err() != nil {
return provider.SecretValue{}, dbxErr return provider.SecretValue{}, dbxErr
} }
v, kvErr := p.keyVault.Value(ctx, provider.Object{ v, kvErr := p.keyVault.Value(ctx, provider.Object{
+48 -46
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) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) } func (p *Provider) ID() string {
func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) } 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. // As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider { func (p *Provider) As(id, label string) *Provider {
@@ -207,25 +218,14 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
return c 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 { func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
o.Container = c o.Container = c
o.Type = t o.Type = t
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok { 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} k := listKey{c.ID, t}
p.objects[k] = append(p.objects[k], o) 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. // ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets") var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
@@ -254,13 +258,12 @@ const (
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} 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. // Azure returns a fake Azure provider with two subscriptions.
func Azure(delay time.Duration) *Provider { func Azure(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay) 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 { objectURL := func(vault, kind, name string) string {
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name) 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") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...) kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{ dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base, Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain", Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"), Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple") }, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{ p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"}, {Version: "3f9c2a", Enabled: &yes, 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: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)}, {Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
} }
p.values[dbPassword.ID+"@91d3e0"] = "previous-password" p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password" p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: 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"), Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000") }, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0), Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"), Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated") }, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{
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{ 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{ Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"}, {Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"}, {Label: "ops", Value: "sign, verify"},
@@ -301,7 +300,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "keys", "signing-key"), Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0), Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"}, {Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"}, {Label: "ops", Value: "wrapKey, unwrapKey"},
@@ -310,7 +309,7 @@ func Azure(delay time.Duration) *Provider {
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: 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", Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"}, {Label: "subject", Value: "CN=app.example.com"},
@@ -322,7 +321,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "certificates", "app-tls"), Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert) }, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: 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", Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"}, {Label: "subject", Value: "CN=old.example.com"},
@@ -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...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as sharing.keyRules wants. // A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: 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=") }, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{ 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") }, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey) p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
@@ -348,19 +347,19 @@ func Azure(delay time.Duration) *Provider {
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com") dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...) kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: 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") }, "dev-password")
// Left behind, as dev vaults are: expired, disabled, or both. // Left behind, as dev vaults are: expired, disabled, or both.
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0), Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"), Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
}, "whsec_demo_expired") }, "whsec_demo_expired")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0), Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0),
Reference: secretRef("kv-app-dev", "test-user-password"), Reference: secretRef("kv-app-dev", "test-user-password"),
}, "disabled-demo-password") }, "disabled-demo-password")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0), Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"), Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled") }, "expired-and-disabled")
@@ -372,21 +371,24 @@ func Azure(delay time.Duration) *Provider {
// its own, like an admin account next to a personal one. // its own, like an admin account next to a personal one.
func AzureOps(delay time.Duration) *Provider { func AzureOps(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay) 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") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...) breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{ p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0), Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"), Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password") }, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: 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") }, "smtp-demo-password")
// Unlike the first account, ops may read keys here. // Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{ p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: 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"}}, Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption", Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey) }, demoPublicKey)
@@ -394,12 +396,12 @@ func AzureOps(delay time.Duration) *Provider {
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com") sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...) kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: 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") }, "pd-demo-token")
// Expiring soon whenever the demo runs: the other dates are fixed, // Expiring soon whenever the demo runs: the other dates are fixed,
// and would have passed. Tests use the first account only. // and would have passed. Tests use the first account only.
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "grafana-api-key", Enabled: new(true), Updated: base.AddDate(0, -11, 0), Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0),
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"), Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
}, "glsa_demo_expiring") }, "glsa_demo_expiring")
+2 -98
View File
@@ -2,7 +2,6 @@ package fake
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "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() return attrs, ctx.Err()
} }
// Repos returns a fake repo provider: four git repos with SOPS, plain // Repos returns a fake repo provider: three git repos with SOPS and plain
// .env, Spring and compose files, the first one the repo lazysecrets was // .env files, the first one the repo lazysecrets was started in.
// started in.
func Repos() *Provider { func Repos() *Provider {
p := newProvider(provider.KindRepo, 0) p := newProvider(provider.KindRepo, 0)
p.id, p.label = "Repos", "Repos" p.id, p.label = "Repos", "Repos"
@@ -115,90 +113,6 @@ func Repos() *Provider {
"AZURE_CLIENT_SECRET", "azure-demo-client-secret", "AZURE_CLIENT_SECRET", "azure-demo-client-secret",
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000") "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) web := repo("web-frontend", false)
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago", file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
"SENTRY_AUTH_TOKEN", "sntrys_demo_token", "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() 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
}
}
}
+1 -5
View File
@@ -32,13 +32,9 @@ func Seed(st *store.Store, alice, admin string) {
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"}, 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", {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}, 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", {link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour}, path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
// A subscription, for both accounts, and a repo's file. // A subscription, 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", {link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
name: "platform-prod", star: true}, name: "platform-prod", star: true},
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos", {link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
-21
View File
@@ -73,10 +73,6 @@ type Source struct {
// Here marks the source lazysecrets was started in (the current git // Here marks the source lazysecrets was started in (the current git
// repo): listed first, starred or not, and labelled. // repo): listed first, starred or not, and labelled.
Here bool 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. // ObjectType is the kind of object stored in a container.
@@ -88,20 +84,6 @@ const (
TypeCertificate ObjectType = "Certificate" 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. // Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct { type Container struct {
Source Source Source Source
@@ -114,7 +96,6 @@ type Container struct {
URL string // web page for the container, if any URL string // web page for the container, if any
// Attrs describe it further, from the listing (a vault's settings). // Attrs describe it further, from the listing (a vault's settings).
Attrs []Attr `json:",omitempty"` Attrs []Attr `json:",omitempty"`
Hidden bool `json:",omitempty"` // see Source.Hidden
} }
// ObjectTypes returns the container's object types, defaulting to secrets. // ObjectTypes returns the container's object types, defaulting to secrets.
@@ -138,8 +119,6 @@ type Object struct {
Type ObjectType Type ObjectType
ID string ID string
Name 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 Version string
Enabled *bool Enabled *bool
Updated time.Time 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.
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}
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 ( import (
"context" "context"
"fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -24,12 +22,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
} }
var out []provider.Attr var out []provider.Attr
meta, isSops := readMeta(c.ID) meta, isSops := readMeta(c.ID)
// A plain file's values are at risk in git, unless they're all Jasypt; if isSops {
// 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 {
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")}) out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
for i, k := range meta.recipients { for i, k := range meta.recipients {
label := "" 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")}) out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
} }
} else { } else {
out, plain = envDetails(c.ID) out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
} }
git := func(args ...string) (string, bool) { 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 return strings.TrimSpace(string(b)), err == nil
} }
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); { 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}) out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
case tracked: case tracked:
out = append(out, provider.Attr{Label: "git", Value: "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 { if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
out = append(out, provider.Attr{Label: "git", Value: "ignored"}) out = append(out, provider.Attr{Label: "git", Value: "ignored"})
} else { } 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 return out, nil
} }
@@ -67,28 +60,3 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
} }
return out, nil 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)
}
-66
View File
@@ -1,66 +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 {
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 -147
View File
@@ -1,7 +1,6 @@
// Package repo browses the encrypted secret files in git repositories: // Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and // 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 // their keys the secrets. SOPS files are the one type so far.
// config files: Spring Boot's and Docker Compose's (configfile.go).
// //
// Listing never decrypts: SOPS encrypts values only, so a file's keys and // 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 // metadata are read directly. A value is decrypted when it's revealed or
@@ -32,7 +31,6 @@ import (
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "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 // Include and Exclude are globs relative to a repo's root (** matches
// any depth), for repos without their own .lazysecrets.yaml. // any depth), for repos without their own .lazysecrets.yaml.
Include, Exclude []string 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 is the sops binary; "sops" (on PATH) when empty.
Sops string Sops string
} }
@@ -113,6 +106,8 @@ func (p *Provider) load() {
roots = append(roots, root) 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() here, inRepo := p.current()
if inRepo { if inRepo {
seen[here.root] = true seen[here.root] = true
@@ -142,6 +137,7 @@ func (p *Provider) load() {
add(root) add(root)
} }
} }
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred { for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil { if root, err := gitRoot(dir); err == nil {
add(root) add(root)
@@ -192,19 +188,12 @@ func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
return provider.Source{}, false return provider.Source{}, false
} }
src := p.source(r) src := p.source(r)
files, err := p.Containers(ctx, src) if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 {
if err == nil && len(files) == 0 {
return provider.Source{}, false return provider.Source{}, false
} }
src.Hidden = err == nil && allHidden(files)
return src, true 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 { func (p *Provider) source(r repoInfo) provider.Source {
return provider.Source{ return provider.Source{
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(), 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 var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU())) sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n { for i := range n {
wg.Go(func() { wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
f(i) f(i)
}) }()
} }
wg.Wait() wg.Wait()
} }
@@ -273,7 +264,6 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
parallel(len(srcs), func(i int) { parallel(len(srcs), func(i int) {
files, err := p.Containers(ctx, srcs[i]) files, err := p.Containers(ctx, srcs[i])
keep[i] = err != nil || len(files) > 0 keep[i] = err != nil || len(files) > 0
srcs[i].Hidden = err == nil && allHidden(files)
}) })
var out []provider.Source var out []provider.Source
for i, src := range srcs { 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, ", ") c.Detail = "sops · " + strings.Join(meta.methods, ", ")
case isSops: case isSops:
return // a SOPS file where .sops.yaml says SOPS files don't go return // a SOPS file where .sops.yaml says SOPS files don't go
case isEnvNamed(f): case IsEnv(f):
c.Detail = provider.DetailPlainEnv 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: default:
return return
} }
@@ -376,29 +361,30 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
if err != nil { if err != nil {
return nil, err return nil, err
} }
meta, isSops := parseMeta(c.ID, b) meta, isSops := readMeta(c.ID)
if t, ok := configTypeOf(c.ID); ok && !isSops {
return p.configObjects(t, c, b)
}
keys, err := parseKeys(c.ID, b) keys, err := parseKeys(c.ID, b)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err) 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 { // a plain env file
if isSops { out := make([]provider.Object, 0, len(keys))
attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}} 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)) out := make([]provider.Object, 0, len(keys))
for _, k := range keys { for _, k := range keys {
o := provider.Object{ o := provider.Object{
Container: c, Type: provider.TypeSecret, 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 { if !k.encrypted {
case isSops && !k.encrypted: o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
case !isSops && k.jasypt:
o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt"
} }
out = append(out, o) out = append(out, o)
} }
@@ -413,29 +399,18 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
// Value reads one key: from a plain file, decrypting a Jasypt value; from // Value decrypts one key with sops.
// a SOPS file, with sops.
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00") file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key 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 { if _, isSops := readMeta(file); !isSops {
read := plainValue return plainValue(file, o.Name)
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
}
} }
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file) cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here 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) 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. // 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) { 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} f := provider.File{Path: path}
if _, isSops := readMeta(path); isSops { if _, isSops := readMeta(path); isSops {
f.Sops = p.s.Sops f.Sops = p.s.Sops
@@ -478,12 +447,6 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File
if o == nil { if o == nil {
return f, 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) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return f, err return f, err
@@ -637,9 +600,6 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile struct { type lazysecretsFile struct {
Include []string `yaml:"include"` Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"` Exclude []string `yaml:"exclude"`
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
Spring FileType `yaml:"spring"`
Compose FileType `yaml:"compose"`
Sharing struct { Sharing struct {
// Key is the link to the secret that encrypts the repo's // Key is the link to the secret that encrypts the repo's
// shared env files (package share). // shared env files (package share).
@@ -647,45 +607,36 @@ type lazysecretsFile struct {
} `yaml:"sharing"` } `yaml:"sharing"`
} }
func (f lazysecretsFile) types() map[string]FileType {
return map[string]FileType{"spring": f.Spring, "compose": f.Compose}
}
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any. // ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) { 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")) b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return f, nil return "", nil
} }
if err != nil { if err != nil {
return f, err return "", err
} }
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil { 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 // 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 // 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. // (.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) { 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 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 { if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude include, exclude = f.Include, f.Exclude
} }
}
inc, err := globs(include) inc, err := globs(include)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -720,7 +671,7 @@ func envFiles(root string, tracked []string) []string {
dirs := map[string]bool{".": true} dirs := map[string]bool{".": true}
for _, f := range tracked { for _, f := range tracked {
isTracked[f] = true isTracked[f] = true
if isEnvNamed(f) { if IsEnv(f) {
out = append(out, f) out = append(out, f)
} }
if d := path.Dir(f); !dirs[d] && !inPackages(f) { if d := path.Dir(f); !dirs[d] && !inPackages(f) {
@@ -734,7 +685,7 @@ func envFiles(root string, tracked []string) []string {
} }
for _, e := range entries { for _, e := range entries {
f := path.Join(d, e.Name()) 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) out = append(out, f)
} }
} }
@@ -769,29 +720,16 @@ func inPackages(f string) bool {
// .env, .env.local, prod.env; not .env.example and the like, nor one in // .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies. // dependencies.
func IsEnv(f string) bool { func IsEnv(f string) bool {
return isEnvNamed(f) && !isExample(f) if inPackages(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
}
}
return false 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. // plainValue reads a key from a plain env file.
@@ -969,21 +907,12 @@ func readMeta(file string) (sopsMeta, bool) {
return sopsMeta{}, false // not a format sops writes: skip reading return sopsMeta{}, false // not a format sops writes: skip reading
} }
b, err := os.ReadFile(file) b, err := os.ReadFile(file)
if err != nil { if err != nil || !bytes.Contains(b, []byte("sops")) {
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")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing return sopsMeta{}, false // cheap: most files aren't sops, no parsing
} }
var m sopsMeta var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"} known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch format { switch formatOf(file) {
case "yaml", "json": case "yaml", "json":
var doc struct { var doc struct {
Sops map[string]any `yaml:"sops"` Sops map[string]any `yaml:"sops"`
@@ -1087,7 +1016,6 @@ type key struct {
name string // db.password, list[0], with . [ ] \ in keys escaped name string // db.password, list[0], with . [ ] \ in keys escaped
extract string // sops --extract: ["db"]["password"] extract string // sops --extract: ["db"]["password"]
encrypted bool encrypted bool
jasypt bool // a plain env file's ENC(...) value
line int // 1-based line int // 1-based
} }
@@ -1112,8 +1040,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
if !ok || strings.HasPrefix(k, "sops_") { if !ok || strings.HasPrefix(k, "sops_") {
continue continue
} }
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), jasypt: jasypt.Is(v), line: i + 1})
} }
return out, nil return out, nil
case "ini": case "ini":
@@ -1178,23 +1105,48 @@ func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k) return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
} }
// extractOf is the sops --extract path of the key named name, for objects // extractFor rebuilds the extract path from a key name, for objects made
// made from a link. // from a link (db.password, list[0], app\.name). A dotenv file is flat:
func extractOf(file, name string) (string, error) { // its names are the keys as they are.
b, err := os.ReadFile(file) func extractFor(file, name string) string {
if err != nil { if formatOf(file) == "dotenv" {
return "", err return quote(name)
} }
keys, err := parseKeys(file, b) var b, part strings.Builder
if err != nil { flush := func() {
return "", err if part.Len() > 0 {
} b.WriteString(quote(part.String()))
for _, k := range keys { part.Reset()
if k.name == name {
return k.extract, nil
} }
} }
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 { 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) 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. // Without the key: sops' own error.
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt")) 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") { 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{} got := map[string]string{}
for _, c := range files { for _, c := range files {
got[c.Name] = c.Detail got[c.Name] = c.Detail
if c.Hidden != (c.Name == ".env.example") {
t.Errorf("%s: hidden %t", c.Name, c.Hidden)
} }
} if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
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 and the sops file; not the example or node_modules)", got)
t.Fatalf("files %v (want .env, the example, hidden, and the sops file; not node_modules)", got)
} }
var env provider.Container 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) 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() exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0]) 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) t.Errorf("with rules elsewhere: %v", names)
} }
} }
@@ -576,22 +554,15 @@ func TestKeyNamesEscaped(t *testing.T) {
var got []string var got []string
for _, k := range keys { for _, k := range keys {
got = append(got, k.name+" "+k.extract) 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]"]` 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 { if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want) t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
} }
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
keys, err = parseKeys(".env", []byte("spring.url=a\nexport TOKEN=b\nSPACED = c\n")) t.Errorf("dotenv: %s", e)
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)
} }
} }
-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. // Reveal returns the plaintext. Call it only to display or copy the value.
func (v SecretValue) Reveal() string { return string(v.b) } func (v SecretValue) Reveal() string { return string(v.b) }
func (v SecretValue) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked } func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked } func (v SecretValue) GoString() string { return masked }
+13 -7
View File
@@ -92,9 +92,15 @@ func (r Ref) ContainerName() string {
// Container.Detail. // Container.Detail.
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" } 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) { func typeOf(collection string) (provider.ObjectType, bool) {
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} { for t, c := range collections {
if strings.EqualFold(t.Collection(), collection) { if strings.EqualFold(c, collection) {
return t, true return t, true
} }
} }
@@ -112,7 +118,7 @@ func (r Ref) IdentifierURL() string {
if r.Type == "" { if r.Type == "" {
return u + "/" 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 != "" { if seg != "" {
u += "/" + url.PathEscape(seg) u += "/" + url.PathEscape(seg)
} }
@@ -134,7 +140,7 @@ func (r Ref) String() string {
segs = append(segs, r.Scope, r.Name) segs = append(segs, r.Scope, r.Name)
default: default:
if r.Type != "" { if r.Type != "" {
segs = append(segs, r.Type.Collection()) segs = append(segs, collections[r.Type])
} }
segs = append(segs, r.Name, r.Version) segs = append(segs, r.Name, r.Version)
} }
@@ -267,7 +273,7 @@ func parseLink(s string) (Ref, error) {
r.Host = strings.ToLower(segs[0]) r.Host = strings.ToLower(segs[0])
rest := segs[1:] rest := segs[1:]
switch host := strings.ToLower(u.Host); host { switch strings.ToLower(u.Host) {
case "azure": case "azure":
r.Kind = provider.KindAzure r.Kind = provider.KindAzure
if r.Host == "subscriptions" { if r.Host == "subscriptions" {
@@ -294,11 +300,11 @@ func parseLink(s string) (Ref, error) {
} }
case "databricks", "repo": case "databricks", "repo":
r.Kind = provider.KindDatabricks r.Kind = provider.KindDatabricks
if host == "repo" { if strings.EqualFold(u.Host, "repo") {
r.Kind = provider.KindRepo r.Kind = provider.KindRepo
} }
if len(rest) > 2 { 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 { if len(rest) > 0 {
r.Scope = rest[0] r.Scope = rest[0]
+2 -7
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8"
) )
// Rules are what a key's value must be to share with it: random enough // Rules are what a key's value must be to share with it: random enough
@@ -41,18 +40,14 @@ func (r Rules) Check(key []byte) error {
kinds++ kinds++
} }
} }
n := utf8.RuneCountInString(s) n := len([]rune(s))
var has, needs []string var has, needs []string
if n < r.MinLength { if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n)) has = append(has, fmt.Sprintf("%d characters", n))
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength)) needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
} }
if kinds < r.MinClasses { if kinds < r.MinClasses {
kind := "kinds" has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
if kinds == 1 {
kind = "kind"
}
has = append(has, fmt.Sprintf("%d %s of character", kinds, kind))
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses)) needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
} }
if len(has) == 0 { 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)") return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
} }
n, size := binary.Uvarint(b[1:]) n, size := binary.Uvarint(b[1:])
if size <= 0 { if size <= 0 || n > uint64(len(b)) {
return Sealed{}, bad return Sealed{}, bad
} }
rest := b[1+size:] rest := b[1+size:]
-7
View File
@@ -43,13 +43,6 @@ type Node struct {
// alone isn't. // alone isn't.
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 } 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. // StarredFor reports whether the item is starred for account.
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, 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) 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")
}
}
+31 -30
View File
@@ -3,11 +3,11 @@
package app package app
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"image/color" "image/color"
"math"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -213,9 +213,6 @@ type Model struct {
statusW int // the status pane's width: the left column's, zoomed or not statusW int // the status pane's width: the left column's, zoomed or not
changelog []panes.Field changelog []panes.Field
cacheDirty bool 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 sourcesAt map[string]time.Time
placeDir string placeDir string
savedPlace string // the link last saved as the place savedPlace string // the link last saved as the place
@@ -232,7 +229,6 @@ type Model struct {
// provider finds it (provider.Current), before Sources lists them all. // provider finds it (provider.Current), before Sources lists them all.
current *provider.Source current *provider.Source
flatSources bool // Sources pane without provider groups 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 sourcesFavs bool // Sources pane on its Favourites tab
history bool // count uses of items (usage.go) history bool // count uses of items (usage.go)
onActive func() onActive func()
@@ -470,7 +466,6 @@ func New(providers []provider.Provider, opts Options) Model {
Filter: func(c provider.Container) string { return c.Name }, Filter: func(c provider.Container) string { return c.Name },
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note }, FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
Render: m.containerRender(false), Render: m.containerRender(false),
Hidden: func(c provider.Container) bool { return c.Hidden },
}) })
m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{ m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{
@@ -480,7 +475,6 @@ func New(providers []provider.Provider, opts Options) Model {
// Typing "expir" finds what has expired or soon will. // 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) }, FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
Render: m.objectRender(), Render: m.objectRender(),
Hidden: func(o provider.Object) bool { return o.Hidden },
}) })
m.secrets.Empty = "no secrets" m.secrets.Empty = "no secrets"
m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret
@@ -657,7 +651,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case detailsMsg: case detailsMsg:
if msg.req == m.reqID[slotDetails] { if msg.req == m.reqID[slotDetails] {
m.cancelReq(slotDetails) m.done(slotDetails)
} }
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) { if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again delete(m.details, msg.key) // tried again when shown again
@@ -688,13 +682,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotContainers] { if msg.req != m.reqID[slotContainers] {
break break
} }
m.cancelReq(slotContainers) m.done(slotContainers)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok { if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err) m.logErrOf(src.Name, msg.err)
} }
uncache(&m, m.cache.containers, msg.key) delete(m.cache.containers, msg.key)
m.containers.SetError(msg.err) m.containers.SetError(msg.err)
} else { } else {
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()} m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
@@ -706,7 +700,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotObjects] { if msg.req != m.reqID[slotObjects] {
break break
} }
m.cancelReq(slotObjects) m.done(slotObjects)
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil { if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind)) m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
m.closeVersions() m.closeVersions()
@@ -715,7 +709,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if c, ok := m.containers.Selected(); ok { if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err) m.logErrOf(c.Name, msg.err)
} }
uncache(&m, m.cache.objects, msg.key) delete(m.cache.objects, msg.key)
m.secrets.SetError(msg.err) m.secrets.SetError(msg.err)
} else { } else {
if msg.bound != nil { if msg.bound != nil {
@@ -727,7 +721,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.versionsOf != nil { if m.versionsOf != nil {
m.setVersions(msg.items) m.setVersions(msg.items)
} else { } else {
m.secrets.SetItems(m.starredObjectsFirst(msg.items)) m.secrets.SetItems(m.starredFirst(msg.items))
cmds = append(cmds, m.refreshShown(before)) cmds = append(cmds, m.refreshShown(before))
} }
} }
@@ -736,7 +730,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotValue] { if msg.req != m.reqID[slotValue] {
break break
} }
m.cancelReq(slotValue) m.done(slotValue)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -751,7 +745,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotVersions] { if msg.req != m.reqID[slotVersions] {
break break
} }
m.cancelReq(slotVersions) m.done(slotVersions)
// Errors and unsupported providers just leave the count out. // Errors and unsupported providers just leave the count out.
if msg.err == nil { if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
@@ -763,7 +757,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotDescribe] { if msg.req != m.reqID[slotDescribe] {
break break
} }
m.cancelReq(slotDescribe) m.done(slotDescribe)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -778,7 +772,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotCopy] { if msg.req != m.reqID[slotCopy] {
break break
} }
m.cancelReq(slotCopy) m.done(slotCopy)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true} m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
@@ -890,7 +884,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Quit): case key.Matches(msg, k.Quit):
return nil, true return nil, true
case key.Matches(msg, k.Help): case key.Matches(msg, k.Help):
return m.showKeybindingsFor(""), false return m.showKeybindings(), false
case key.Matches(msg, k.Up): case key.Matches(msg, k.Up):
m.move(-1) m.move(-1)
case key.Matches(msg, k.Down): case key.Matches(msg, k.Down):
@@ -982,8 +976,6 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.cycleZoom(-1) m.cycleZoom(-1)
case key.Matches(msg, k.SourcesView): case key.Matches(msg, k.SourcesView):
m.toggleSourcesView() m.toggleSourcesView()
case key.Matches(msg, k.ToggleHidden):
m.toggleHidden()
case key.Matches(msg, k.Versions): case key.Matches(msg, k.Versions):
if m.versionsOf != nil { if m.versionsOf != nil {
m.closeVersions() m.closeVersions()
@@ -1172,11 +1164,14 @@ func (m *Model) syncSelection() tea.Cmd {
if m.onLinks() { if m.onLinks() {
m.showLinkedContainers() m.showLinkedContainers()
} else if src, ok := m.selectedSource(); ok { } else if src, ok := m.selectedSource(); ok {
m.containers.Title = "[2] Vaults"
m.containers.Empty = "no vaults found" m.containers.Empty = "no vaults found"
switch src.Kind { switch src.Kind {
case provider.KindDatabricks: case provider.KindDatabricks:
m.containers.Title = "[2] Scopes"
m.containers.Empty = "no secret scopes" m.containers.Empty = "no secret scopes"
case provider.KindRepo: case provider.KindRepo:
m.containers.Title = "[2] Files"
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)" m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
} }
cmds = append(cmds, m.showContainers(src, id)) cmds = append(cmds, m.showContainers(src, id))
@@ -1261,7 +1256,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
m.secrets.SetLoading() m.secrets.SetLoading()
return m.loadObjects(c, m.objType, key) 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]) m.secrets.Select(m.cache.cursor[key])
if e.fresh(m.now()) { if e.fresh(m.now()) {
return nil return nil
@@ -1321,7 +1316,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() { func (m *Model) closeVersions() {
m.versionsOf = nil m.versionsOf = nil
} }
@@ -1518,6 +1514,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 // loadSources starts a new generation of source loads, superseding any in
// flight. // flight.
func (m *Model) loadSources() tea.Cmd { func (m *Model) loadSources() tea.Cmd {
@@ -1637,7 +1636,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd { func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() { if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok { if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false) return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
} }
return nil return nil
} }
@@ -1680,8 +1679,12 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
// clipTick fires when the countdown's displayed second next changes. // clipTick fires when the countdown's displayed second next changes.
func (m *Model) clipTick() tea.Cmd { func (m *Model) clipTick() tea.Cmd {
wait := m.clipDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.clipToken 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() { func (m *Model) cancelClipClear() {
@@ -1788,15 +1791,13 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() { } else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else { } else {
reveal := "show" revealed := m.detail.State() == panes.ValueShown
if 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)
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)
} }
var lines []string var lines []string
if !m.clipDeadline.IsZero() { 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 != "" { if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash)) lines = append(lines, m.flashLine(m.flash))
+12 -43
View File
@@ -686,7 +686,7 @@ func TestFilter(t *testing.T) {
d.keys("/") d.keys("/")
d.typeText("api-k") d.typeText("api-k")
d.keys("enter", "esc") // apply, then esc clears the applied filter 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()) t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
} }
if _, _, s := d.selected(); s != "api-key" { if _, _, s := d.selected(); s != "api-key" {
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.typeText("a") // all four secrets contain an a d.typeText("a") // all three secrets contain an a
if d.m.secrets.Len() != 4 { if d.m.secrets.Len() != 3 {
t.Fatalf("visible = %d", d.m.secrets.Len()) t.Fatalf("visible = %d", d.m.secrets.Len())
} }
_, _, initial := d.selected() // kept from before filtering: the middle row _, _, initial := d.selected() // kept from before filtering: the middle row
var seen []string 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}) d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
_, _, s := d.selected() _, _, s := d.selected()
seen = append(seen, s) seen = append(seen, s)
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
if !d.m.secrets.Filtering() { if !d.m.secrets.Filtering() {
t.Fatal("ctrl+j/k left filter mode") t.Fatal("ctrl+j/k left filter mode")
} }
// down to the bottom, stay there, back up one // down to the bottom, stay there, back up to where it started
if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] { if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial {
t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen) t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen)
} }
if !strings.Contains(d.screen(), "ctrl+j/n down") { if !strings.Contains(d.screen(), "ctrl+j/n down") {
t.Errorf("filter help missing ctrl+j:\n%s", d.screen()) t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") { if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
t.Errorf("detail hint doesn't follow the binding:\n%s", scr) t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
} }
if !strings.Contains(lastLines(scr, 1), "z show") { if !strings.Contains(lastLine(scr), "z show") {
t.Errorf("help bar: %q", lastLines(scr, 1)) t.Errorf("help bar: %q", lastLine(scr))
} }
d.keys("z") 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) t.Errorf("help bar with the value shown: %q", bar)
} }
d.keys("z") d.keys("z")
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
if !strings.Contains(scr, "Alice Example") { if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr) t.Errorf("account not shown:\n%s", scr)
} }
bar := lastLines(scr, 1) bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar) t.Errorf("help bar: %q", bar)
} }
@@ -1188,7 +1188,7 @@ func TestStatusLineCountsRepos(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t, fake.Azure(0), fake.Repos()) d := newDriver(t, fake.Azure(0), fake.Repos())
// The status line, not the pane, which an expired secret's ⚠ fills. // 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 := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 3 repos") {
t.Errorf("status line: %q", line) t.Errorf("status line: %q", line)
} }
d = newDriver(t, fake.Azure(0)) d = newDriver(t, fake.Azure(0))
@@ -1344,34 +1344,3 @@ func TestErrorLog(t *testing.T) {
t.Errorf("log %+v", d.m.errLog) 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) { if i < 0 || sameListing(before[i], s) {
return nil return nil
} }
m.detail.SetDescribed(s) m.detail.Refresh(s)
uncache(m, m.cache.details, detailKey(s)) delete(m.cache.details, detailKey(s))
return m.describe(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 // sameListing reports whether a and b list the same: what a list
// says of an object, not what describing it adds. // says of an object, not what describing it adds.
func sameListing(a, b provider.Object) bool { func sameListing(a, b provider.Object) bool {
+20 -10
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -85,7 +84,10 @@ func (m *Model) showCommands() tea.Cmd {
} }
var opts []option var opts []option
for _, c := range cmds { for _, c := range cmds {
label := cmp.Or(c.Description, c.Command) label := c.Description
if label == "" {
label = c.Command
}
if c.Key != "" { if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
} }
@@ -120,7 +122,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
in.SetValue(p.Default) in.SetValue(p.Default)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
title: cmp.Or(c.Description, "Command"), input: &in, title: cmpOr(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd { submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text) it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1) return m.askPrompt(c, it, obj, i+1)
@@ -149,7 +151,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
case strings.HasPrefix(c.Value, "env:"): case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".") lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
} }
m.showMenu("Run "+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) }}, {key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
}) })
return nil 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. // execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd { func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh") shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) { setValue := func(cmd *exec.Cmd) {
switch { switch {
case c.Value == "stdin": case c.Value == "stdin":
@@ -193,7 +195,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
m.flash = flash{} m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} }) return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
} }
m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"} m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel() defer cancel()
@@ -217,7 +219,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
const maxOutputLines = 30 const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) { func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmp.Or(msg.cmd.Description, "command") name := cmpOr(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil { if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch { switch {
case msg.err != nil: case msg.err != nil:
@@ -296,7 +298,11 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
if r, err := ref.ForObject(o, version); err == nil { if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String()) it.Link = commands.Q(r.String())
} }
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 return it, &o, true
} }
@@ -307,5 +313,9 @@ func newTextInput(prompt string, width int) textinput.Model {
return in return in
} }
// pathInputWidth fits a link or a folder path in a prompt, in the window. func cmpOr(a, b string) string {
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) } if a != "" {
return a
}
return b
}
+1 -2
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"github.com/charmbracelet/x/ansi"
) )
func commandDriver(t *testing.T, cmds ...commands.Command) *driver { func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
@@ -35,7 +34,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
// temporary path's length. // temporary path's length.
shown := "" shown := ""
if d.m.prompt != nil { if d.m.prompt != nil {
shown = 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") { if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr) t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
+1 -1
View File
@@ -242,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
return tea.QuitMsg{} return tea.QuitMsg{}
} }
} }
if editor := 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"). // Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...) args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{} m.flash = flash{}
+5 -7
View File
@@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"slices" "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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -30,8 +28,8 @@ func (m Model) expiringItems() []expiringItem {
row := sourceRowKey(p.ID(), src.ID) row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value { for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value { for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
if state, _ := m.expiry(o); state != expiry.None { if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row}) out = append(out, expiringItem{o, row})
} }
} }
@@ -52,11 +50,11 @@ func (m Model) expiringFields() []panes.Field {
var out []panes.Field var out []panes.Field
for _, it := range items { for _, it := range items {
state, left := m.expiry(it.o) state, left := m.expiry(it.o)
mark := "⚠ " + expiry.Left(left) mark := "⚠ " + daysLeft(left)
if state == expiry.Expired { if state == expired {
mark = "✗ 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 { if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String() f.Link = r.String()
} }
+31 -5
View File
@@ -1,9 +1,9 @@
package app package app
import ( import (
"fmt"
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -11,19 +11,45 @@ import (
// list once they have expired (✗ expired) or will within the configured // list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them. // warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState int
const (
notExpiring expiryState = iota
expiresSoon
expired
)
// expiry tells whether o has expired or will within the warning period, // expiry tells whether o has expired or will within the warning period,
// and how long it has left. // and how long it has left.
func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) { func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning) 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". // expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string { func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state { switch state, _ := m.expiry(o); state {
case expiry.Expired: case expired:
return "expired" return "expired"
case expiry.Soon: case expiresSoon:
return "expiring" return "expiring"
} }
return "" 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))
}
+34 -80
View File
@@ -3,13 +3,9 @@ package app
import ( import (
"slices" "slices"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" 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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -28,12 +24,12 @@ const recentMark = "◷ "
// marks is what goes before a list row's name: ★ when it's starred for // marks is what goes before a list row's name: ★ when it's starred for
// account and, with recent, ◷ when it was used lately. // 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 { func marks(st styles.Styles, n store.Node, account string, recent bool) string {
var s string var s string
if n.StarredFor(account) { if n.StarredFor(account) {
s = st.Favourite.Render(star) s = st.Favourite.Render(star)
} }
if recent && n.UsedLately(now) { if recent && !n.LastUsed.IsZero() {
s += st.Muted.Render(recentMark) s += st.Muted.Render(recentMark)
} }
return s return s
@@ -97,22 +93,27 @@ func (m *Model) refreshContext(key, name string, path []string, src provider.Sou
// containerRender draws a vault or scope, starred or not. In the // containerRender draws a vault or scope, starred or not. In the
// Opened links list it also names the source, since they come from many. // 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 { 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 { return func(sty styles.Styles, c provider.Container, width int) string {
n := st.Node(storeKey(c)) n := st.Node(storeKey(c))
line := c.Name line := c.Name
if c.Source.Kind == provider.KindRepo { if c.Source.Kind == provider.KindRepo {
// A secret file: its icon, and its type after it ("sops · // A secret file: a lock for an encrypted one, a file in the
// age", "env · plain text"). // warning colour for plain text, and its type after it
if icon := m.fileIcon(sty, c); icon != "" { // ("sops · age", "env · plain text").
line = icon + " " + line 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 += sty.Muted.Render(" " + c.Detail)
} }
line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line) line = marks(sty, n, c.Source.Provider, m.recentIcon) + line
if withSource { if withSource {
src := c.Source.Name 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 src = account + " / " + src
} }
line += sty.Muted.Render(" · " + src) line += sty.Muted.Render(" · " + src)
@@ -129,82 +130,35 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Enabled != nil && !*o.Enabled { if o.Enabled != nil && !*o.Enabled {
line = sty.Muted.Render(o.Name + " (disabled)") line = sty.Muted.Render(o.Name + " (disabled)")
} }
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line line = marks(sty, n, o.Container.Source.Provider, m.recentIcon) + line
if o.Detail != "" {
line += sty.Muted.Render(" " + o.Detail)
}
line = dimIf(sty, o.Hidden, line)
switch state, left := m.expiry(o); state { switch state, left := m.expiry(o); state {
case expiry.Expired: case expired:
line += " " + sty.Error.Render("✗ expired") line += " " + sty.Error.Render("✗ expired")
case expiry.Soon: case expiresSoon:
line += " " + sty.Warning.Render("⚠ "+expiry.Left(left)) line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
} }
return withNote(sty, line, n.Note, width) return withNote(sty, line, n.Note, width)
} }
} }
// fileIcon is a repo file's icon, in its colour: a lock for an encrypted // starredContainersFirst moves starred vaults and scopes to the front,
// one, a leaf for Spring config, a whale for compose, a file in the // keeping the order otherwise. It doesn't modify items, which may be cached.
// warning colour for plain text; "" for other vaults, or without icons. func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
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, "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
}
out := slices.Clone(items) 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 return out
} }
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source { // starredFirst moves starred objects to the front, keeping the order
return starredFirst(sources, func(s provider.Source) bool { // otherwise. It doesn't modify items, which may be cached.
return m.store.Node(sourceKey(s)).StarredFor(s.Provider) 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))
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)
}) })
return out
} }
// showLinkedContainers lists the vaults opened from links. // showLinkedContainers lists the vaults opened from links.
@@ -225,7 +179,7 @@ func (m *Model) rebind(c provider.Container) string {
m.linked[i].Source = c.Source m.linked[i].Source = c.Source
} }
} }
key := cachefile.ObjectsKey(c, m.objType) key := containerKey(c) + "|" + string(m.objType)
if m.onLinks() { if m.onLinks() {
m.containers.SetItems(m.linked) m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c)) m.containers.Select(containerKey(c))
@@ -242,7 +196,7 @@ func (m *Model) resortStarred() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items())) m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
} }
if m.versionsOf == nil { if m.versionsOf == nil {
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items())) m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
} }
} }
@@ -288,7 +242,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
c := o.Container c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) } 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 { 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 { switch {
+1 -2
View File
@@ -14,7 +14,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
// jump opens the favourites list, filters it and jumps to the first match. // jump opens the favourites list, filters it and jumps to the first match.
@@ -430,7 +429,7 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("F") d.keys("F")
var labels []string var labels []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
labels = append(labels, ansi.Strip(o.label)) labels = append(labels, ansiStrip(o.label))
} }
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") { if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels) t.Errorf("favourites should list alice's star only: %q", labels)
+5 -7
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -9,7 +8,6 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
) )
@@ -62,7 +60,7 @@ func (m *Model) syncFields() tea.Cmd {
case !ok: case !ok:
m.fields.Show("", nil) m.fields.Show("", nil)
case m.selectedProblem() != "": case m.selectedProblem() != "":
m.fields.Show(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: case r.kind == rowGroup:
m.showAccount(r) m.showAccount(r)
case r.kind == rowSource: case r.kind == rowSource:
@@ -135,7 +133,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
add("web", src.URL) add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok { if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind] label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value))) add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
} }
return out return out
} }
@@ -159,10 +157,10 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
case provider.KindRepo: case provider.KindRepo:
add("format", c.Detail) add("format", c.Detail)
} }
add("source", 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) add("web", c.URL)
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok { if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value))) 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 // fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value. // one label (a tag), its value.
func fieldName(f panes.Field) string { func fieldName(f panes.Field) string {
return cmp.Or(f.Label, f.Value) return cmpOr(f.Label, f.Value)
} }
+18 -9
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"cmp"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
@@ -17,7 +16,7 @@ import (
) )
// The help bar shows the keys that matter in the focused pane; ? lists all // The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindingsFor). // of them in a menu (showKeybindings).
// newHelp styles the help bar to read clearly: keys like labels elsewhere, // newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The // descriptions in the normal text colour, only the separators dimmed. The
@@ -39,9 +38,10 @@ type contextHelp struct {
commands bool // custom commands apply here commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go) account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes of pane // what the fourth pane describes
// reveal, when set, is what the reveal key will do, "show" or "hide": // revealed, when set, says whether the selected value is shown, so
// the same width so the bar doesn't shift. // the reveal key says just what it'll do: show or hide, the same
reveal string // width so the bar doesn't shift.
revealed *bool
} }
func (c contextHelp) ShortHelp() []key.Binding { 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) b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default: default:
reveal := k.Reveal reveal := k.Reveal
if c.reveal != "" { if c.revealed != nil {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal)) 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) 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"} var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
// and group. Enter runs the selected one.
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with // showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the // what it does, filterable by key, description and group; enter runs the
// selected one. Opened from the panes, it lists the focused pane's keys // selected one. Opened from the panes, it lists the focused pane's keys
@@ -149,7 +154,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 { if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true}) opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds { for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", 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) }}) 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. // move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint { 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 // 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 return nil
} }
var out []entry var out []entry
detail := m.objectDetail(key)
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) { add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
account := "" account := ""
if p, ok := m.byID[acct]; ok { if p, ok := m.byID[acct]; ok {
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()}) account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
} }
account = strings.Trim(detail+" · "+account, " ·")
sort := strings.ToLower(strings.Join(path, "/")) + " " + account sort := strings.ToLower(strings.Join(path, "/")) + " " + account
out = append(out, entry{depth, sort, option{ 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), id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
plain: m.inPlainEnv(r), plain: m.inPlainEnv(r),
run: func(m *Model) tea.Cmd { run: func(m *Model) tea.Cmd {
@@ -109,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
if m.history { if m.history {
var used []string var used []string
for _, k := range keys { for _, k := range keys {
if nodes[k].UsedLately(m.now()) { if !nodes[k].LastUsed.IsZero() {
used = append(used, k) 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 // pathLabel draws a jump list entry: the provider's icon and the path in
// its colour, then the account. // 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) style := m.st.Kind(kind)
parts := make([]string, len(path)) parts := make([]string, len(path))
for i, p := range path { for i, p := range path {
parts[i] = style.Render(p) 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(" / ")) label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
if account != "" { if account != "" {
label += m.st.Muted.Render(" · " + 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 // inPlainEnv reports whether r is a key in a plain env file, as far as
// the files loaded tell. // the files loaded tell.
func (m Model) inPlainEnv(r ref.Ref) bool { func (m Model) inPlainEnv(r ref.Ref) bool {
c, ok := m.repoFile(r) if r.Kind != provider.KindRepo || r.Name == "" {
return ok && r.Name != "" && c.Detail == provider.DetailPlainEnv return false
}
// 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
} }
file := r file := r
file.Name, file.Type = "", "" file.Name, file.Type = "", ""
for _, e := range m.cache.containers { for _, e := range m.cache.containers {
for _, c := range e.value { for _, c := range e.value {
if storeKey(c) == file.String() { if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
return c, true return true
} }
} }
} }
return provider.Container{}, false return 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 ""
} }
// repoLabel draws a repo, or a file in it, as everywhere else: the repo // repoLabel draws a repo, or a file in it, as everywhere else: the repo
// icon and colour, "app / apps/api/.env". // icon and colour, "app / apps/api/.env".
func (m Model) repoLabel(repo string, file ...string) string { 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 // 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)) m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil 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) { switch len(choices) {
case 0: case 0:
return m.openRef(r, actGo) return m.openRef(r, actGo)
+8 -3
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) { func TestContextHelpBar(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
if bar := 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) t.Errorf("Sources help bar: %q", bar)
} }
d.keys("3") 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) 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) { func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
@@ -259,7 +264,7 @@ func TestVersionInStatus(t *testing.T) {
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") { if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen()) t.Errorf("no version in the status pane:\n%s", d.screen())
} }
if bar := 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) t.Errorf("help line %q", bar)
} }
} }
+20 -25
View File
@@ -8,9 +8,9 @@ import (
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -209,7 +209,18 @@ const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account // openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup. // when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) { func (m *Model) openSource(r ref.Ref) {
choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) }) 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})
}
}
}
if row, ok := preferredRow(r, choices); ok { if row, ok := preferredRow(r, choices); ok {
choices = []choice{{sourceRow: row}} choices = []choice{{sourceRow: row}}
} }
@@ -231,24 +242,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 // refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod / // provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The // kv-app-prod / api-key", with the type and version after it. The
@@ -269,7 +262,7 @@ func (m Model) refLabel(r ref.Ref) string {
default: default:
path = append(path, r.ContainerName(), r.Name) 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 var extra []string
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret { if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
if r.IsContainer() { if r.IsContainer() {
@@ -440,7 +433,7 @@ func (m *Model) handleResolved(msg resolvedMsg) {
// (ref.Ref.Account), if it's one of them. // (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) { func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices { for _, c := range choices {
if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account { if r.Account != "" && providerOf(c.sourceRow) == r.Account {
return c.sourceRow, true return c.sourceRow, true
} }
} }
@@ -693,7 +686,7 @@ func (m *Model) share(menu bool) tea.Cmd {
share := func(k string) func(*Model) tea.Cmd { share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) } return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
} }
opts := []option{{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 { if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")}) opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
} }
@@ -721,7 +714,7 @@ func (m *Model) share(menu bool) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) } return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
} }
opts := []option{ opts := []option{
{key: "o", label: "Lazysecrets link", run: share("o")}, option{key: "o", label: "Lazysecrets link", run: share("o")},
} }
if o.Container.Source.Kind == provider.KindAzure { if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")}) opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
@@ -837,8 +830,10 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
} }
func (m *Model) startOpenPrompt() 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.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{ m.prompt = &modal{
title: "Open a link", input: &in, title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")}, lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
+3 -1
View File
@@ -75,8 +75,10 @@ type modal struct {
// showFilterMenu opens a menu with a filter input above the options. // showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd { func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := newTextInput("/", 40) in := textinput.New()
in.Prompt = "/"
in.Placeholder = "type to filter" in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in} m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0) m.prompt.move(0)
m.flash = flash{} m.flash = flash{}
+4 -1
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"strings" "strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -57,9 +58,11 @@ func (m *Model) editNote() tea.Cmd {
c := o.Container c := o.Container
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source 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.Placeholder = "e.g. prod SQL admin"
in.CharLimit = 80 in.CharLimit = 80
in.SetWidth(48)
in.SetValue(m.store.Node(key).Note) in.SetValue(m.store.Node(key).Note)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
+7 -5
View File
@@ -77,15 +77,17 @@ func TestNotesOnRows(t *testing.T) {
func TestWithNoteGivesWay(t *testing.T) { func TestWithNoteGivesWay(t *testing.T) {
t.Parallel() t.Parallel()
st := styles.Default() st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 || if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
!strings.HasSuffix(ansi.Strip(got), "prod SQL admin") { !strings.HasSuffix(stripANSI(got), "prod SQL admin") {
t.Errorf("fits: %q", ansi.Strip(got)) 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, "…") { !strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", 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) t.Errorf("no room: %q", got)
} }
} }
func stripANSI(s string) string { return ansi.Strip(s) }
+22 -19
View File
@@ -29,43 +29,48 @@ type persistTickMsg struct{}
type persistedMsg struct{ err error } type persistedMsg struct{ err error }
// keepers report whether a provider ID, or a cache key's, is an account // keepCached reports whether a cache key's account still exists and
// that still exists and isn't a repo. // isn't a repo: every key starts with the provider ID.
func (m *Model) keepers() (keep, keepKey func(string) bool) { func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
keep = cachefile.Keep(m.providers)
return keep, func(key string) bool { return keep(cachefile.ProviderOf(key)) } func (m *Model) keepProvider(id string) bool { return keepIn(m.byID)(id) }
func keepIn(byID map[string]provider.Provider) func(id string) bool {
return func(id string) bool {
p, ok := byID[id]
return ok && p.Kind() != provider.KindRepo
}
} }
// loadCacheFile fills the cache from the file, leaving out what's too // loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more. // old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() { func (m *Model) loadCacheFile() {
f := cachefile.Read(m.cachePath) f := cachefile.Read(m.cachePath)
keep, keepKey := m.keepers()
now := m.now() 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) < cachefile.MaxAge }
for id, e := range f.Sources { for id, e := range f.Sources {
if keep(id) && recent(e.At) { if m.keepProvider(id) && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value} m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At m.sourcesAt[id] = e.At
} }
} }
for k, e := range f.Containers { 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} m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
} }
} }
for k, e := range f.Objects { 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} m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
} }
} }
for k, e := range f.Details { 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} m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
} }
} }
for k, v := range f.Cursor { for k, v := range f.Cursor {
if keepKey(k) { if m.keepCached(k) {
m.cache.cursor[k] = v m.cache.cursor[k] = v
} }
} }
@@ -74,30 +79,28 @@ func (m *Model) loadCacheFile() {
// cacheSnapshot is the cache as the file holds it. // cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File { func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New() f := cachefile.New()
keep, keepKey := m.keepers()
f.Dropped, m.cacheDropped = m.cacheDropped, nil
for id, r := range m.sourceResults { for id, r := range m.sourceResults {
if r.err == nil && keep(id) { if r.err == nil && m.keepProvider(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources} f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
} }
} }
for k, e := range m.cache.containers { for k, e := range m.cache.containers {
if keepKey(k) { if m.keepCached(k) {
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value} f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.objects { for k, e := range m.cache.objects {
if keepKey(k) { if m.keepCached(k) {
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value} f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.details { for k, e := range m.cache.details {
if keepKey(k) { if m.keepCached(k) {
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value} f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, v := range m.cache.cursor { for k, v := range m.cache.cursor {
if keepKey(k) { if m.keepCached(k) {
f.Cursor[k] = v f.Cursor[k] = v
} }
} }
@@ -107,7 +110,7 @@ func (m *Model) cacheSnapshot() cachefile.File {
// saveCache saves f, merged with the file (lazysecrets list may have // saveCache saves f, merged with the file (lazysecrets list may have
// loaded more since), from any goroutine. // loaded more since), from any goroutine.
func (m *Model) saveCache(f cachefile.File) func() error { func (m *Model) saveCache(f cachefile.File) func() error {
path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now() path, keep, now := m.cachePath, keepIn(m.byID), m.now()
return func() error { return cachefile.Save(path, f, keep, now) } return func() error { return cachefile.Save(path, f, keep, now) }
} }
+2 -3
View File
@@ -16,7 +16,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
const sopsFile = `db: const sopsFile = `db:
@@ -226,9 +225,9 @@ func TestSearchAllRepos(t *testing.T) {
var got []string var got []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo { if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", 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 { if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got) t.Errorf("want db.password in both repos: %v", got)
+7 -16
View File
@@ -28,22 +28,12 @@ func (m *Model) startRevealTimer() tea.Cmd {
// revealTickCmd fires when the displayed second next changes. // revealTickCmd fires when the displayed second next changes.
func (m *Model) revealTickCmd() tea.Cmd { func (m *Model) revealTickCmd() tea.Cmd {
token := m.revealToken wait := m.revealDeadline.Sub(m.now()) % time.Second
return tea.Tick(untilNextSecond(m.revealDeadline, m.now()), func(time.Time) tea.Msg { return revealTickMsg{token} }) if wait <= 0 {
} wait = time.Second
// 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
} }
return time.Second token := m.revealToken
} return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} })
// 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())))
} }
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd { func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
@@ -59,7 +49,8 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
} }
func (m *Model) showHidesIn() { 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. // hideValue hides the revealed value and stops its countdown.
+45 -51
View File
@@ -6,11 +6,10 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" 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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
) )
@@ -46,6 +45,10 @@ type crawlMsg struct {
err error err error
} }
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of // searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with // each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group. // its source's row key as group.
@@ -58,63 +61,49 @@ func (m *Model) searchEntries() (sources, items []option) {
var es []entry var es []entry
// key is the store key, so ctrl+f stars the entry; mark shows how // key is the store key, so ctrl+f stars the entry; mark shows how
// much is loaded, reload (ctrl+r) loads it again. // 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) n := m.store.Node(key)
sort := strings.ToLower(strings.Join(path, "/")) + " " + account sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort, es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account, fileIcon), note: n.Note, star: key, option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)), starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt return &es[len(es)-1].opt
} }
// 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 _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
if skip(src.Hidden) {
continue
}
src.Provider, src.ProviderLabel = p.ID(), p.Label() src.Provider, src.ProviderLabel = p.ID(), p.Label()
row := sourceRowKey(p.ID(), src.ID) 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} m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil 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 { for _, c := range m.cache.containers[row].value {
if skip(c.Hidden) {
continue
}
r, err := ref.ForContainer(c, provider.TypeSecret) r, err := ref.ForContainer(c, provider.TypeSecret)
if err != nil { if err != nil {
continue continue
} }
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) } 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) m.startNav(r, actGo, row)
return nil return nil
}, reload)) }, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} { 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, "") r, err := ref.ForObject(o, "")
if err != nil || skip(o.Hidden) { if err != nil {
continue continue
} }
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·") suffix := accountOf(src)
if t != provider.TypeSecret { if t != provider.TypeSecret {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key" typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·") 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) m.startNav(r, actGo, row)
return nil return nil
}, reload) }, reload)
e.plain = c.Detail == provider.DetailPlainEnv 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 return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
} }
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// providerFilter picks the providers a search or a load covers; nil is // providerFilter picks the providers a search or a load covers; nil is
// all of them. // all of them.
type providerFilter func(provider.Provider) bool type providerFilter func(provider.Provider) bool
@@ -156,7 +147,7 @@ func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id } return func(p provider.Provider) bool { return p.ID() == id }
} }
// coverageOf is the coverage of the providers in. // coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage { func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage var c coverage
for _, p := range m.providers { for _, p := range m.providers {
@@ -175,7 +166,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
} }
for _, v := range e.value { for _, v := range e.value {
c.vaults++ c.vaults++
key := cachefile.ObjectsKey(v, provider.TypeSecret) key := containerKey(v) + "|" + string(provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok { if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++ c.vaultsLoaded++
c.secrets += len(objs.value) c.secrets += len(objs.value)
@@ -191,7 +182,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
// searchLines says what the search covers. // searchLines says what the search covers.
func (m *Model) searchLines() []string { func (m *Model) searchLines() []string {
c := m.coverageOf(nil) c := m.searchCoverage()
switch { switch {
case m.crawl.running: case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.", return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
@@ -218,8 +209,11 @@ func plural(n int, word string) string {
return fmt.Sprintf("%d %ss", n, word) return fmt.Sprintf("%d %ss", n, word)
} }
// startCrawlOf loads, for the providers in, every unlisted subscription's // startCrawl loads every unlisted subscription's vaults and every vault's
// vaults and every vault's secrets not loaded yet. // secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd { func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if in != nil && !in(p) { if in != nil && !in(p) {
@@ -269,7 +263,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
// queueContainer queues loading c's secrets, unless loaded lately and not // queueContainer queues loading c's secrets, unless loaded lately and not
// force. // force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) { 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 { if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return return
} }
@@ -306,7 +300,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
src := *job.src src := *job.src
timeout := m.timeout.of(src.Kind) timeout := m.timeout.of(src.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
items, err := p.Containers(ctx, src) items, err := p.Containers(ctx, src)
for i := range items { for i := range items {
@@ -319,7 +313,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
p := m.providerFor(c.Source) p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind) timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret) items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items { for i := range items {
@@ -333,7 +327,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active-- m.crawl.active--
key := msg.job.row key := msg.job.row
if msg.job.container != nil { 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) delete(m.crawl.pending, key)
switch { switch {
@@ -356,19 +350,6 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
return 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: // sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable. // ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string { func (m *Model) sourceMark(row string) string {
@@ -382,7 +363,14 @@ func (m *Model) sourceMark(row string) string {
if !ok { if !ok {
return m.st.Muted.Render("○") 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 { switch {
case loading: case loading:
return m.st.Muted.Render("◌") 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 // (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable. // its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string { func (m *Model) vaultMark(c provider.Container) string {
key := cachefile.ObjectsKey(c, provider.TypeSecret) key := containerKey(c) + "|" + string(provider.TypeSecret)
switch { switch {
case m.crawl.pending[key]: case m.crawl.pending[key]:
return m.st.Muted.Render("◌") return m.st.Muted.Render("◌")
@@ -407,3 +395,9 @@ func (m *Model) vaultMark(c provider.Container) string {
} }
return " " return " "
} }
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+20 -19
View File
@@ -6,7 +6,6 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
@@ -116,19 +115,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Fatal("platform-dev not in sources") 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) t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
if len(d.m.search.shown) != 0 { if len(d.m.search.shown) != 0 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown)) t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
} }
d.send(ctrl('r')) d.send(ctrl('r'))
if !strings.Contains(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) t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if !strings.Contains(ansi.Strip(o.label), "platform-dev") { if !strings.Contains(ansiStrip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansi.Strip(o.label)) t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
} }
} }
if len(d.m.search.shown) == 0 { 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) { func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch() d.openSearch()
v := d.m.search v := d.m.search
scope := func() string { return ansi.Strip(v.scopes[v.src].label) } scope := func() string { return ansiStrip(v.scopes[v.src].label) }
if scope() != "All" { if scope() != "All" {
t.Errorf("opened on %q, want All", scope()) t.Errorf("opened on %q, want All", scope())
} }
if v.res >= len(v.shown) || !strings.Contains(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()) t.Errorf("vault not under the cursor:\n%s", d.screen())
} }
d.send(ctrl('s')) // narrows to the source, and back d.send(ctrl('s')) // narrows to the source, and back
@@ -232,13 +233,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
t.Errorf("no results in the account:\n%s", d.screen()) t.Errorf("no results in the account:\n%s", d.screen())
} }
for _, o := range v.shown { for _, o := range v.shown {
if cachefile.ProviderOf(o.group) != header.provider { if providerOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansi.Strip(o.label)) t.Errorf("result from another account: %q", ansiStrip(o.label))
} }
} }
// The account's sources are under it, indented. // The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || cachefile.ProviderOf(next.group) != header.provider || !strings.HasPrefix(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", ansi.Strip(next.label)) t.Errorf("after the account: %q", ansiStrip(next.label))
} }
} }
@@ -326,7 +327,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
t.Fatal("no results") t.Fatal("no results")
} }
o := d.m.search.shown[0] o := d.m.search.shown[0]
plain := []rune(ansi.Strip(o.label)) plain := []rune(ansiStrip(o.label))
var got string var got string
for _, h := range o.hits { for _, h := range o.hits {
got += string(plain[h]) got += string(plain[h])
@@ -347,14 +348,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("F") d.keys("F")
bar := lastLines(d.screen(), 1) bar := lastLine(d.screen())
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} { for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) { if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want) t.Errorf("browsing: help bar %q lacks %q", bar, want)
} }
} }
d.keys("/") d.keys("/")
if bar := 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) t.Errorf("typing: help bar %q", bar)
} }
} }
@@ -374,8 +375,8 @@ func TestHelpFromPopups(t *testing.T) {
if o.header { if o.header {
sections = append(sections, o.label) sections = append(sections, o.label)
} }
if strings.Contains(ansi.Strip(o.label), "copy value") { if strings.Contains(ansiStrip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label)) t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
} }
} }
if strings.Join(sections, ",") != "Go to,While typing, after /" { if strings.Join(sections, ",") != "Go to,While typing, after /" {
@@ -427,7 +428,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc") d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLines(d.screen(), 1)) bar := strings.TrimSpace(lastLine(d.screen()))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") { if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar) t.Errorf("help bar %q", bar)
} }
@@ -442,11 +443,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
for d.m.search.src > 0 { for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) d.send(tea.KeyPressMsg{Code: tea.KeyUp})
} }
for i := 0; !strings.Contains(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) { if i > len(d.m.search.scopes) {
var labels []string var labels []string
for _, o := range d.m.search.scopes { for _, o := range d.m.search.scopes {
labels = append(labels, 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) 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" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi" "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/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
@@ -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) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd { 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 := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries() v.favs, v.recent = m.storeEntries()
m.search = v 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())), id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
}) })
for _, o := range sources { for _, o := range sources {
if cachefile.ProviderOf(o.group) == p.ID() { if providerOf(o.group) == p.ID() {
o.label = " " + o.label o.label = " " + o.label
scopes = append(scopes, o) scopes = append(scopes, o)
} }
@@ -265,7 +266,7 @@ func (v *searchView) match() []option {
default: default:
account, ofAccount := v.scopeAccount() account, ofAccount := v.scopeAccount()
for _, o := range v.items { for _, o := range v.items {
if sc.id == allSources || ofAccount && 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) cand = append(cand, o)
} }
} }
@@ -521,7 +522,7 @@ func (m *Model) searchLoads() tea.Cmd {
return m.startCrawlOf(v.scopeFilter()) return m.startCrawlOf(v.scopeFilter())
} }
if !v.onSource() { if !v.onSource() {
return m.startCrawlOf(nil) return m.startCrawl()
} }
providerID, sourceID := splitSourceRow(sc.group) providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources { 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)) 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) { if loaded == len(e.value) {
return "" return ""
} }
@@ -762,7 +769,7 @@ func (m Model) searchViewBox() string {
body = append(body, " "+m.st.Error.Render(l)) 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(), "") body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") { 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 // Tabs in the title, the current one underlined, like the panes; a
// picker has its own title, and the tabs over the input. // picker has its own title, and the tabs over the input.
if v.picker == nil { 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) return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
} }
// A picker: in its own border, under what it's for. // 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 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" "slices"
"strings" "strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -71,7 +72,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
return msg return msg
} }
for _, c := range all { for _, c := range all {
if c.Detail == provider.DetailPlainEnv && !c.Hidden { // not .env.example if c.Detail == provider.DetailPlainEnv {
cs = append(cs, c) 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 // 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, // (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. // 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) { 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 // 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. // stays until they're saved, saying why when they can't be.
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { 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 dir := m.placeDir
if dir == "" { if dir == "" {
dir, _ = os.Getwd() dir, _ = os.Getwd()
+30 -30
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -59,7 +58,6 @@ func sourceRowID(r sourceRow) string {
} }
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] { func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
render := renderSourceRow(iconSet, mem, recent)
return panes.NewList("[1] Sources", panes.Item[sourceRow]{ return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID, ID: sourceRowID,
// Headers have no filter text, so filtering shows just sources. // Headers have no filter text, so filtering shows just sources.
@@ -78,20 +76,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 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 { 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)) node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group) kind := st.Kind(r.group)
starred := "" starred := ""
if r.kind == rowSource { if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent, time.Now()) starred = marks(st, node, r.source.Provider, recent)
} }
switch { switch {
case r.kind == rowLinks: case r.kind == rowLinks:
@@ -117,7 +106,8 @@ func renderSourceRow(iconSet icons.Set, mem *store.Store, recent bool) func(styl
status = st.Error.Render("(!)") status = st.Error.Render("(!)")
} }
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
} },
})
} }
// rebuildSources lays out the Sources pane from the per-provider results. // rebuildSources lays out the Sources pane from the per-provider results.
@@ -138,16 +128,20 @@ func (m *Model) rebuildSources() {
} }
// The repo lazysecrets started in gets a row of its own on top, so it // The repo lazysecrets started in gets a row of its own on top, so it
// opens on it; flat, it sorts first instead. // 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}) rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
} }
for _, p := range m.providers { for _, p := range m.providers {
kind, id := p.Kind(), p.ID() 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] r, done := m.sourceResults[id]
sources := slices.DeleteFunc(slices.Clone(r.sources), func(s provider.Source) bool { sources := r.sources
return (s.Here && !flat) || (s.Hidden && !m.showHidden) 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)) { if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
continue // repos show once some are found to have secret files continue // repos show once some are found to have secret files
} }
@@ -220,6 +214,17 @@ func (m Model) currentSource() (provider.Source, bool) {
return provider.Source{}, false 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. // starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int { func starRank(mem *store.Store, r sourceRow) int {
switch { switch {
@@ -233,6 +238,13 @@ func starRank(mem *store.Store, r sourceRow) int {
return 3 return 3
} }
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header. // selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) { func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected() r, ok := m.sources.Selected()
@@ -247,18 +259,6 @@ func (m Model) onLinks() bool {
return m.sources.SelectedID() == linksRowID 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 // toggleSourcesView switches between grouped and flat, keeping the cursor
// on the same source. // on the same source.
func (m *Model) toggleSourcesView() { func (m *Model) toggleSourcesView() {
+2 -3
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -84,7 +83,7 @@ func (m Model) statusFields() []panes.Field {
} }
} }
// A release's rows open its release page. // A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")} version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") { if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
} }
@@ -160,7 +159,7 @@ func (m Model) accountStatus(p provider.Provider) string {
return r.err.Error() return r.err.Error()
} }
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()] noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), 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". // ago is a duration as people say it: "just now", "5m ago", "2h ago".
+3 -3
View File
@@ -20,8 +20,7 @@
╭─ [3] Secrets - Keys - Certs ──╮│ │ ╭─ [3] Secrets - Keys - Certs ──╮│ │
│ db-password ││ │ │ db-password ││ │
│ api-key ││ │ │ api-key ││ │
│ legacy-token ✗ expired ││ │ │ legacy-token (disabled) ✗ exp…││ │
│ smtp-password (disabled) ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
@@ -29,5 +28,6 @@
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
╰────────────────────── 1 of 4 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯ │ ││ │
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
+11 -1
View File
@@ -2,11 +2,13 @@ package app
import ( import (
"context" "context"
"errors"
"strings" "strings"
"time" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
@@ -128,7 +130,15 @@ func (m *Model) showUpdate() {
run: func(m *Model) tea.Cmd { return m.install(r) }}} run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" { if r.URL != "" {
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd { opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
return m.openPage("the release page", r.URL, func() {}) switch err := m.openURL(r.URL); {
case errors.Is(err, browser.ErrRemote):
cmd := m.copyText("link to the release", r.URL, false)
m.flash.text += " (can't open a browser over SSH)"
return cmd
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
}
return nil
}}) }})
} }
m.showMenu("lazysecrets "+r.Tag, lines, opts) m.showMenu("lazysecrets "+r.Tag, lines, opts)
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"testing" "testing"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
func TestRecentScope(t *testing.T) { func TestRecentScope(t *testing.T) {
@@ -22,7 +21,7 @@ func TestRecentScope(t *testing.T) {
d.keys("R") d.keys("R")
var recent []string var recent []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
recent = append(recent, ansi.Strip(o.label)) recent = append(recent, ansiStrip(o.label))
} }
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") { if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
t.Fatalf("want newest first: %q", recent) t.Fatalf("want newest first: %q", recent)
@@ -32,7 +31,7 @@ func TestRecentScope(t *testing.T) {
t.Errorf("jumped to %q", s) t.Errorf("jumped to %q", s)
} }
d.keys("F") d.keys("F")
if len(d.m.search.shown) != 1 || !strings.Contains(ansi.Strip(d.m.search.shown[0].label), "kv-shared-prod") { if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") {
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen()) t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
} }
} }
-6
View File
@@ -11,9 +11,6 @@ type Set struct {
Repo string Repo string
Encrypted string // an encrypted file (SOPS) Encrypted string // an encrypted file (SOPS)
PlainFile string // a plain text secrets file (.env) PlainFile string // a plain text secrets file (.env)
Spring string // a Spring Boot config file
Compose string // a Docker Compose file
Note string // has a note (lazysecrets list)
} }
// None renders no icons. // None renders no icons.
@@ -27,9 +24,6 @@ var NerdFont = Set{
Repo: "\U000F02A2", // md-git Repo: "\U000F02A2", // md-git
Encrypted: "\U000F033E", // md-lock Encrypted: "\U000F033E", // md-lock
PlainFile: "\U000F0214", // md-file PlainFile: "\U000F0214", // md-file
Spring: "\U000F032A", // md-leaf
Compose: "\U000F0868", // md-docker
Note: "\U000F09ED",
} }
// Prefix returns the icon for kind followed by a space, or "" if the set has // Prefix returns the icon for kind followed by a space, or "" if the set has
+15 -8
View File
@@ -15,8 +15,8 @@ package keys
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"sort"
"strings" "strings"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
@@ -45,7 +45,6 @@ type Map struct {
Recent key.Binding Recent key.Binding
Note, Share, ShareMenu, OpenRef key.Binding Note, Share, ShareMenu, OpenRef key.Binding
Search, SourcesView, Commands key.Binding Search, SourcesView, Commands key.Binding
ToggleHidden key.Binding
SplitLeft, SplitRight, SplitReset key.Binding SplitLeft, SplitRight, SplitReset key.Binding
Taller, Shorter, ZoomIn, ZoomOut key.Binding Taller, Shorter, ZoomIn, ZoomOut key.Binding
Login, Refresh, Help, Quit key.Binding Login, Refresh, Help, Quit key.Binding
@@ -103,7 +102,6 @@ func (m *Map) Actions() []Action {
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."), a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: Spring and compose keys that don't look like secrets, example files (.env.example), and files and repos with only those. Shown, they're dimmed; Go to finds them too."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
@@ -187,7 +185,7 @@ var defaults = map[string][]string{
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, "lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"}, "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.open": {"enter"}, "lists.back": {"esc", "backspace"}, "lists.open": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.toggleHidden": {"H"}, "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"}, "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"},
"lists.commands": {"x"}, "lists.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
@@ -268,11 +266,11 @@ func (m *Map) Apply(cfg map[string]map[string][]string) error {
byName[a.Context+"."+a.Name] = a byName[a.Context+"."+a.Name] = a
contexts[a.Context] = true contexts[a.Context] = true
} }
for _, ctx := range slices.Sorted(maps.Keys(cfg)) { for _, ctx := range sortedKeys(cfg) {
if !contexts[ctx] { if !contexts[ctx] {
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(slices.Sorted(maps.Keys(contexts)), ", ")) return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
} }
for _, name := range slices.Sorted(maps.Keys(cfg[ctx])) { for _, name := range sortedKeys(cfg[ctx]) {
a, ok := byName[ctx+"."+name] a, ok := byName[ctx+"."+name]
if !ok { if !ok {
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", ")) return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
@@ -291,7 +289,16 @@ func (m *Map) names(ctx string) []string {
out = append(out, a.Name) out = append(out, a.Name)
} }
} }
slices.Sort(out) sort.Strings(out)
return out
}
func sortedKeys[V any](set map[string]V) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out return out
} }
-17
View File
@@ -69,20 +69,3 @@ func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
} }
return st.TitleBlurred return st.TitleBlurred
} }
// Tabs draws tab names for a title in style ts, the current one
// underlined and the rest muted.
func Tabs(st styles.Styles, ts lipgloss.Style, names []string, current int) string {
var out string
for i, name := range names {
if i > 0 {
out += ts.Render(" - ")
}
if i == current {
out += ts.Underline(true).Render(name)
} else {
out += st.Muted.Render(name)
}
}
return out
}
+31 -33
View File
@@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) {
d.container = &s.Container d.container = &s.Container
} }
// Refresh shows s's newer metadata if s is the secret shown, keeping
// its account and any value shown or loading.
func (d *Detail) Refresh(s provider.Object) {
if d.secret == nil || d.secret.ID != s.ID {
return
}
s.Container = d.secret.Container
d.secret = &s
}
// ShowContainer shows a container summary when no secret is selected. // ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) { func (d *Detail) ShowContainer(c provider.Container) {
d.Clear() d.Clear()
@@ -188,31 +198,20 @@ func carryStyle(lines []string) []string {
return lines return lines
} }
var valueIndent = strings.Repeat(" ", 12)
// fieldLabel starts a detail line: the label, padded so values line up
// at valueIndent.
func fieldLabel(st styles.Styles, label string) string {
return " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
}
// lines renders the body, wrapped to the pane width. // lines renders the body, wrapped to the pane width.
func (d Detail) lines(st styles.Styles) []string { func (d Detail) lines(st styles.Styles) []string {
inner := max(1, d.w-3) inner := max(1, d.w-3)
var out []string var out []string
field := func(label, value string) { field := func(label, value string) {
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
indent := strings.Repeat(" ", 12)
wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")) wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n"))
out = append(out, fieldLabel(st, label)+wrapped[0]) out = append(out, prefix+wrapped[0])
for _, w := range wrapped[1:] { for _, w := range wrapped[1:] {
out = append(out, valueIndent+w) out = append(out, indent+w)
} }
} }
muted := st.Muted.Render muted := st.Muted.Render
note := func() {
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
}
if d.container == nil { if d.container == nil {
return []string{muted(" Select a vault or scope to browse its secrets.")} return []string{muted(" Select a vault or scope to browse its secrets.")}
@@ -222,27 +221,30 @@ func (d Detail) lines(st styles.Styles) []string {
if containerLabel == "" { if containerLabel == "" {
containerLabel = "vault" containerLabel = "vault"
} }
source := d.Icons.Prefix(c.Source.Kind) + cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind)) + " / " + c.Source.Name
if d.secret == nil { if d.secret == nil {
field(containerLabel, c.Name) field(containerLabel, c.Name)
note() if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
if c.Detail != "" { if c.Detail != "" {
field("", muted(c.Detail)) field("", muted(c.Detail))
} }
field("source", source) field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
out = append(out, "", muted(" Select a secret to see its details.")) out = append(out, "", muted(" Select a secret to see its details."))
return out return out
} }
s := *d.secret s := *d.secret
field("name", s.Name) field("name", s.Name)
note() if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
field(containerLabel, c.Name) field(containerLabel, c.Name)
if c.Source.Kind != provider.KindAzure && c.Detail != "" { if c.Source.Kind != provider.KindAzure && c.Detail != "" {
field("", muted(c.Detail)) field("", muted(c.Detail))
} }
field("source", source) field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
if s.Version != "" { if s.Version != "" {
field("version", s.Version) field("version", s.Version)
} }
@@ -306,12 +308,14 @@ func (d Detail) lines(st styles.Styles) []string {
case provider.TypeCertificate: case provider.TypeCertificate:
label, hidden, reveal = "cert", "", "show the certificate" label, hidden, reveal = "cert", "", "show the certificate"
} }
indent := strings.Repeat(" ", 12)
out = append(out, "") out = append(out, "")
switch d.state { switch d.state {
case ValueHidden: case ValueHidden:
if hidden != "" { if hidden != "" {
field(label, hidden) field(label, hidden)
out = append(out, valueIndent+hints(reveal)) out = append(out, indent+hints(reveal))
} else { } else {
field(label, hints(reveal)) field(label, hints(reveal))
} }
@@ -328,29 +332,23 @@ func (d Detail) lines(st styles.Styles) []string {
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") { for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
w = lead + w w = lead + w
if i == 0 && j == 0 { if i == 0 && j == 0 {
out = append(out, fieldLabel(st, label)+st.Error.Render(w)) out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w))
} else { } else {
out = append(out, valueIndent+st.Error.Render(w)) out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
} }
} }
} }
case ValueShown: case ValueShown:
if d.value.Reveal() == "" {
// Not in the value's colour, nor a word a value could be.
field(label, st.Muted.Italic(true).Render("∅ no value (an empty string)"))
out = append(out, valueIndent+hints("hide"))
break
}
// Where the dots were, continuing under itself; the keys below. // Where the dots were, continuing under itself; the keys below.
first := true first := true
lines, note := valueView(d.value.Reveal()) lines, note := valueView(d.value.Reveal())
for _, line := range lines { for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") { for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first { if first {
out = append(out, fieldLabel(st, label)+st.Value.Render(w)) out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
first = false first = false
} else { } else {
out = append(out, valueIndent+st.Value.Render(w)) out = append(out, indent+st.Value.Render(w))
} }
} }
} }
@@ -364,7 +362,7 @@ func (d Detail) lines(st styles.Styles) []string {
if d.hidesIn != "" { if d.hidesIn != "" {
more = append(more, "hides in "+d.hidesIn) more = append(more, "hides in "+d.hidesIn)
} }
out = append(out, valueIndent+hints("hide", more...)) out = append(out, indent+hints("hide", more...))
} }
return append(out, d.versionLines(st)...) return append(out, d.versionLines(st)...)
} }
@@ -385,7 +383,7 @@ func (d Detail) versionLines(st styles.Styles) []string {
head += muted(" · " + d.VersionsKey + " to browse") head += muted(" · " + d.VersionsKey + " to browse")
} }
} }
out := []string{"", fieldLabel(st, "versions") + head} out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
current := d.versions[0].Version current := d.versions[0].Version
for i, v := range d.versions { for i, v := range d.versions {
if i == maxVersionLines { if i == maxVersionLines {
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more