11 Commits
Author SHA1 Message Date
keb 96395cb6a2 feat: Spring Boot config files in repos (#79)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 13:11:02 +02:00
keb 39cbac9aab fix: decrypt SOPS .env keys written with export or spaces (#78)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 12:18:09 +02:00
keb 637bf6a253 chore(release): v0.2.1
Release / Build and publish (push) Successful in 51s
2026-10-15 10:52:09 +02:00
keb 29b0c76f40 chore: updated main doc image 2026-10-15 10:49:28 +02:00
keb df429002c9 refactor: one copy of the UI's repeated helpers (#77)
- Text inputs come from newTextInput; the link and folder prompts share one width.
- One starred-first sort for sources, vaults and secrets; accountOf where the account was cut from the label by hand.
- One helper each for the reveal and clipboard countdowns' tick and seconds, the "Open with" choices, the vaults-loaded count, and Go to's and the panes' tabs.
- The details pane builds its label prefix, indent, note and source once; list rows mark matches with MarkMatches.
- The release page opens through openPage, so it now says it opened, as other pages do.
- The cache's account filter is built once per load or save, not per key.

Reviewed-on: #77
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:43:42 +02:00
keb 0ff5204d4d refactor: one place for Key Vault collections and paging in the providers (#75)
- ObjectType.Collection names a type's segment in Key Vault URLs and links (secrets, keys, certificates), instead of four copies in ref, azure and fake.
- Azure lists through one pager helper instead of eight hand-written page loops; Value is VersionedValue without the version, rather than the two calling each other.
- Databricks' Key Vault fallback check lives in fallbackVault.
- The repo provider reads .lazysecrets.yaml in one place, builds plain and sops objects in one loop, and parses a file's sops metadata from the bytes Objects already read.

Reviewed-on: #75
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:41:43 +02:00
keb 6842c72662 refactor: tidy the command's setup, the update command and instance reach (#73)
- main: the subcommand is a name rather than nil-or-empty argument slices; the cache file and start directory are worked out once; config values with a default go through one valueOr helper.
- The link handler's --config argument comes from one configArgs helper.
- lazysecrets update parses its flags like the other subcommands, so -check and --check=true work too, and tells a development build by its dev version.
- Instance reach is named (unreachable, switchable, inView), tmux client activity is compared as a number, and the best client loop starts from the first.
- update: serverTimeout (it bounds the checksums request too), and parentheses in the platform check.

Reviewed-on: #73
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:40:40 +02:00
keb fae8e1cbb4 refactor: drop dead code and use the standard library where it already does the job (#76)
No behaviour change.

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

Reviewed-on: #76
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:39:38 +02:00
keb 1753630c73 chore: tidy comments that repeat the code or no longer match it (#72)
- Comments that only restate a name (sgr, fg, mdLink, the repo provider's starred and current-repo notes) are gone.
- Stale ones match the code again: the repo package covers plain env files too, Launch runs argv, closeVersions no longer claims to move the cursor, and Check's fallback says why rather than naming a state no release had.
- The doc for versionString was attached to pseudo; the backtick in SourcesView's doc was a string-concatenation leftover; openFailed's doc was garbled.
- recentReleases sits next to latest, its user.
- .gitignore drops the Go template's boilerplate and patterns for files this project doesn't make.

Reviewed-on: #72
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:38:56 +02:00
keb b773dfe296 docs: fix what no longer matches lazysecrets, and say each thing once (#74)
Fixes:
- A newer release shows in the status pane (`↑ v0.2.0`), not the footer.
- The account in `lazysecrets list` is plain `Azure` with one az account; `Azure:<user>/<tenant>` only with several.
- Sharing lists subscription, workspace and scope links, and opening takes Azure portal links.
- `--timeout` and `--clipboard-clear` are documented, next to their config keys.
- `--kind`'s description reads, and says `vault` covers scopes and repo files.
- `lazysecrets -h` lists `list`'s `--stars`, `--notes`, `--recent` and `--expiry`.
- README typos; "Supported platforms" is "Sources".

Trimmed:
- An option is documented on its topic's page; config.md's example keeps the general ones and links the rest.
- The fzf picker lives in docs/integration.md; the README keeps a link and the screenshot.
- The clipboard clear, reveal timeout, per-account stars, Azure's "no extra requests" and `open --action` are each said once.
- The status pane's contents are a list; build sizes and PR-title spellings are gone from development.md.

Reviewed-on: #74
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:38:14 +02:00
keb bb41767cba fix: a link opens lazysecrets with your cache and next to your running instances (#71)
The link handler's window starts with an app's minimal environment, and only PATH, the config locations and XDG_CONFIG_HOME/XDG_STATE_HOME were carried into it. With XDG_CACHE_HOME or XDG_RUNTIME_DIR set, lazysecrets opened from a link used another cache file and didn't see running instances' sockets; on Linux, without XDG_DATA_HOME it looked for its desktop entry in the wrong place. `handler install` now records those three too: run it again to pick them up.

Reviewed-on: #71
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:37:14 +02:00
77 changed files with 1425 additions and 951 deletions

No files matched your search

+8 -28
View File
@@ -1,33 +1,13 @@
# ---> 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
+6
View File
@@ -2,6 +2,12 @@
Every release of lazysecrets, newest first. Every release of lazysecrets, newest first.
## 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 ## v0.2.0 – 2026-10-14
### Added ### Added
+10 -22
View File
@@ -7,24 +7,23 @@ 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.
- **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**: `lazysecrets get <link>` prints a value for scripts and - **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor
pipes. pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
for Neovim.
- **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.
- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos. - **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env` and Spring Boot config 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.
@@ -45,9 +44,8 @@ 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.
lazysecrets looks for a newer release once a day: the footer then shows `U` (or `lazysecrets update`) updates in place when there's a newer
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run release.
`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`.
@@ -70,36 +68,26 @@ 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 (cleared from the clipboard after 30s) | | `y` | copy the value |
| `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 ## Integration
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
Here is an example of implementing a picker in `fzf`: `lazysecrets list`, `get` and `open` plug lazysecrets into fzf, editor
```sh pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](docs/assets/fzf-example.png) ![fzf-example](docs/assets/fzf-example.png)
More defails in the [Integration](docs/integration.md) docs
## 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 and .env files](docs/repos.md): which repos and files, decrypting - [SOPS, .env and Spring files](docs/repos.md): which repos and files, decrypting
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and - [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
opening links, also from the browser and chat opening links, also from the browser and chat
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems - [Integration](docs/integration.md): how lazysecrets can integrate into other systems
+4 -10
View File
@@ -18,7 +18,7 @@ import (
// It ends with a newline only when printing to a terminal, so command // It ends with a newline only when printing to a terminal, so command
// substitution and pipes get the value exactly. // substitution and pipes get the value exactly.
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error { func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
v, err := value(ctx, providers, st, r) v, _, err := readKey(ctx, providers, st, r)
if err != nil { if err != nil {
return err return err
} }
@@ -30,15 +30,9 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err return err
} }
// value reads what r points at (ref.Read), with the account that last // readKey reads what r points at (ref.Read), and the version read. The
// read it first. // account asked for (--account) goes first, else the one that last read
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) { // it.
v, _, err := readKey(ctx, providers, st, r)
return v, err
}
// readKey is value, also returning the version read. The account asked
// for (--account) goes first, else the one that last read it.
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) { func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
key := r key := r
key.Version = "" key.Version = ""
+2 -2
View File
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
v, err := value(context.Background(), providers, st, r) v, _, err := readKey(context.Background(), providers, st, r)
if err != nil || v.Reveal() != want { if err != nil || v.Reveal() != want {
t.Errorf("%s: %q, %v", link, v.Reveal(), err) t.Errorf("%s: %q, %v", link, v.Reveal(), err)
} }
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
"{{secrets/app/storage-key}}": "names no workspace", "{{secrets/app/storage-key}}": "names no workspace",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) { if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Errorf("%s: %v, want %q", link, err, wantErr) t.Errorf("%s: %v, want %q", link, err, wantErr)
} }
} }
+4 -10
View File
@@ -68,27 +68,20 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
if c.Key == "" { if c.Key == "" {
continue continue
} }
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command)) name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil { if err := km.CheckExtra(c.Key, name); err != nil {
return err return err
} }
for _, d := range cmds[:i] { for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap { if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command)) return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
} }
} }
} }
return nil return nil
} }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// refArg parses get's and open's arguments: a reference, and optionally // refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column) and, // the account to use when several could (lazysecrets list's column) and,
// for open, what to do with it rather than asking (--action). // for open, what to do with it rather than asking (--action).
@@ -150,6 +143,7 @@ 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
@@ -352,7 +346,7 @@ Flags:
if config.On(pc.Repos.Enabled) { if config.On(pc.Repos.Enabled) {
repos := repo.New(repo.Settings{ repos := repo.New(repo.Settings{
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots), Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, Spring: pc.Repos.Spring,
}) })
// 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
+3 -3
View File
@@ -10,13 +10,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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 113 KiB

+3 -4
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: no requests of their own. `y` on and the token lazysecrets already has. `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,9 +67,8 @@ 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. That makes no requests of its own: expiry dates come on one goes to it. Expiry dates come with the lists. It says how many
with the lists. It says how many vaults it covers; `ctrl+a` in search vaults it covers; `ctrl+a` in search loads the rest.
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
+22 -38
View File
@@ -1,10 +1,11 @@
# 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 override `$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags (noted
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the below) override it. Unknown keys are an error, so typos don't go
status pane (`0`) opens it in your editor, as in lazygit (making it if unnoticed. `e` on the status pane (`0`) opens it in your editor, as in
it isn't there yet); changes apply when lazysecrets starts again. lazygit (making it if it isn't there yet); changes apply when lazysecrets
starts again.
```yaml ```yaml
ui: ui:
@@ -17,7 +18,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 clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables; also --clipboard-clear
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)
@@ -28,32 +29,15 @@ 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 timeout: 30s # per backend request; a provider's own timeout overrides it; also --timeout
azure: # applies to every az account azure: # likewise databricks and repos; azure covers 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: [repos](repos.md) (`providers.repos`), More, each on its own page: [Azure](azure.md#vaults-you-cant-discover)
(`providers.azure`), [Databricks](databricks.md) (`providers.databricks`),
[repos](repos.md#which-files) (`providers.repos`),
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the [sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`), [link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
@@ -90,6 +74,7 @@ theme:
azureColor: ["#82aaff"] azureColor: ["#82aaff"]
databricksColor: ["#ff757f"] databricksColor: ["#ff757f"]
repoColor: ["#c099ff"] repoColor: ["#c099ff"]
springColor: ["#c3e88d"] # Spring Boot config 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
@@ -102,9 +87,8 @@ 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`. An editor `LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your
plugin can so add a generated theme file to your own config, the way config plus an editor-generated theme:
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
@@ -117,11 +101,10 @@ 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. It's for one); a note describes the item, so it shows under every account.
JSON so an editor plugin can read it with a built-in parser; several Several lazysecrets windows can save to it without losing each other's
lazysecrets windows can save to it without losing each other's changes. changes. One written by a newer lazysecrets is read but not saved to,
One written by a newer lazysecrets is read but not saved to, with a with a warning, until you update.
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,
@@ -157,8 +140,9 @@ 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 footer, `v0.1.1 ↑ v0.2.0`; `U` (or every server down, it says nothing. A newer release shows in the status
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases. pane, `↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what
changed since your version, across the last 10 releases.
Updating (`u` there, or `lazysecrets update`) downloads the release for 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
+3 -5
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 about 21 MB instead of 30 MB: A stripped binary is a third smaller:
```sh ```sh
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
@@ -35,10 +35,8 @@ 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. Brackets `feat!:` marks a breaking change, `feat(scope):` names a part.
and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all `[skip changelog]` in the description leaves a pull request out.
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
+6 -6
View File
@@ -34,7 +34,7 @@ tab-separated:
| 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 | | status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty | | note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` | | 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 What's cached prints at once, then what loads follows as it arrives: read
@@ -47,7 +47,7 @@ so `^prod` finds names starting with prod.
| Flag | | | Flag | |
|---|---| |---|---|
| `--format json` | an object per line instead, for editor plugins (below) | | `--format json` | an object per line instead, for editor plugins (below) |
| `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded | | `--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 | | `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again | | `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read | | `--errors=false` | leave out the vaults and sources it couldn't read |
@@ -77,12 +77,11 @@ may be added:
| `expires` | when a secret, key or certificate expires, if it does (Azure) | | `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) | | `enabled` | `false` for a disabled one; only where the provider says (Azure) |
`lazysecrets open --action go --account <account> <link>` opens one in [`lazysecrets open --action go --account <account> <link>`](sharing.md#opening-a-link)
lazysecrets without asking what to do or which account, ending in the opens one in lazysecrets without asking what to do or which account.
secrets list, like Go to.
### An fzf picker
### Example usage with fzf
```sh ```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \ lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \ --header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
@@ -90,5 +89,6 @@ lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --
--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 {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) ![fzf-example](assets/fzf-example.png)
+17 -20
View File
@@ -49,11 +49,11 @@ 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; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field | | `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 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 or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) | | `e` | open the selected SOPS, `.env` or Spring 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](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.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) |
@@ -63,10 +63,8 @@ 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 exactly as stored. It hides again after a while, and when the terminal
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
@@ -75,8 +73,7 @@ when the terminal loses focus (`values.hideOnBlur`); see [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 |
A star belongs to the account you starred the item under; a note shows Both are kept in the [state file](config.md#state).
under every account. Both are kept in the [state file](config.md#state).
## Go to ## Go to
@@ -124,15 +121,16 @@ 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 (when there's room): the version and updates (`U`), each account logo; `y` copies any row:
(`L` on one that needs it signs in), what's expiring (`enter` goes to
it), this session's errors, newest first (what a pane showed, or the - the version and updates (`U`)
footer flashed before you moved on; not kept after quitting), the config, - each account (`L` on one that needs it signs in)
state and cache files (`e` opens one in your editor), and links to the - what's expiring (`enter` goes to it)
docs, source, issues, releases and changelog (`o` opens one; on the - this session's errors, newest first (not kept after quitting)
version or update, its release), then the changelog of every release up - the config, state and cache files (`e` opens one in your editor)
to yours, built in so it needs no network: `/` filters it (`/fixed`, - links to the docs, source, issues, releases and changelog (`o` opens one)
`/search`). `y` copies any row. - the changelog of every release up to yours, built in: `/` filters it
(`/fixed`, `/search`)
| Key | Action | | Key | Action |
|---|---| |---|---|
@@ -141,9 +139,8 @@ to yours, built in so it needs no network: `/` filters it (`/fixed`,
| `+` / `_` | 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). macOS uses The divider and pane heights are remembered (zoom isn't). `<>{}` are for
ctrl+arrows for Mission Control and switching desktops by default, hence macOS, where ctrl+arrows switch desktops.
the `<` `>` `{` `}` alternatives.
## Rebinding keys ## Rebinding keys
+43 -14
View File
@@ -1,9 +1,9 @@
# SOPS and .env files in git repos # SOPS, .env and Spring 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) and plain env files as vaults, and the keys in them `.env`, INI), plain env files and [Spring Boot config](#spring-boot-config)
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
escaped, `app\.name`, so it doesn't read as nesting. escaped, `app\.name`, so it doesn't read as nesting.
## Which repos ## Which repos
@@ -46,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 files too. `include`/`exclude` apply to env and Spring files too.
```yaml ```yaml
providers: providers:
@@ -65,6 +65,43 @@ 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:
- **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's details count the keys left out, and `e` opens it whole. A
`${DB_PASSWORD}` value is marked as a reference: the secret is set
elsewhere. A tracked file is marked in red only when a listed key has its
value in plain text.
Each list of regexps replaces its default; `keys: [".*"]` lists every key:
```yaml
providers:
repos:
spring:
keys: ["(?i)(password|secret|token|key)$", "bearer$"] # matched against the key's name
values: ["^ENC\\(.+\\)$"] # and against its value
```
A repo's `.lazysecrets.yaml` can set `spring` too, for everyone, and wins
over the config, one list at a time.
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.
## Decrypting ## Decrypting
Listing never decrypts: SOPS encrypts values only, so keys and metadata Listing never decrypts: SOPS encrypts values only, so keys and metadata
@@ -100,13 +137,5 @@ sops 3.8 or later.
## Links ## Links
`S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files). Repo links name the repo by its git remote, so they open in a colleague's
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 [sharing](sharing.md).
+9 -5
View File
@@ -7,10 +7,13 @@ 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
``` ```
@@ -102,8 +105,8 @@ it now and then: new links use the newest version.
## Opening a link ## Opening a link
- 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, and fills in any reference it finds there. Key Vault URLs, Azure portal
`@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too. links, `@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.
@@ -143,9 +146,10 @@ 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` and config tmux; `--terminal NAME` picks another. It records your `PATH`, config
locations, since apps start without your shell's environment (and so locations and XDG directories, since apps start without your shell's
without `az`): run it again after moving lazysecrets or changing those. environment (and so without `az`): run it again after moving lazysecrets
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
-3
View File
@@ -42,9 +42,6 @@ func New() Clipboard {
} }
} }
// Native reports whether the system clipboard is used rather than OSC52.
func (c Clipboard) Native() bool { return c.native }
// Write puts s on the clipboard. // Write puts s on the clipboard.
func (c Clipboard) Write(s string) tea.Cmd { func (c Clipboard) Write(s string) tea.Cmd {
if !c.native { if !c.native {
+2 -4
View File
@@ -16,6 +16,7 @@
package commands package commands
import ( import (
"cmp"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@@ -60,10 +61,7 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config. // Validate checks a command from the config.
func (c Command) Validate() error { func (c Command) Validate() error {
name := c.Description name := cmp.Or(c.Description, c.Command)
if name == "" {
name = c.Command
}
switch { switch {
case c.Command == "": case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description) return fmt.Errorf("custom command %q has no command", c.Description)
+12 -11
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -14,6 +15,7 @@ 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"
) )
@@ -47,7 +49,8 @@ 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"; ` + "`" + ` toggles it. // SourcesView is "grouped" (default) or "flat"; the backtick key
// 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.
@@ -151,6 +154,8 @@ 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"`
// Spring says which keys of Spring Boot config files are secrets.
Spring repo.SpringPatterns `yaml:"spring"`
} }
type Azure struct { type Azure struct {
@@ -203,17 +208,12 @@ func xdgDir(env, fallback string) (string, error) {
return filepath.Join(base, "lazysecrets"), nil return filepath.Join(base, "lazysecrets"), nil
} }
// Load reads path. A missing file is an empty config. Unknown keys are an
// error, so typos don't silently do nothing.
func Load(path string) (Config, error) {
return LoadAll([]string{path})
}
// LoadAll reads several config files in order, each on top of the ones // LoadAll reads several config files in order, each on top of the ones
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the // before, like lazygit's LG_CONFIG_FILE: a later file overrides the
// settings it sets, and theme roles merge one by one. That lets an editor // settings it sets, and theme roles merge one by one. That lets an editor
// plugin add a generated theme file to the user's own config. Missing // plugin add a generated theme file to the user's own config. Missing
// files are skipped. // files are skipped. Unknown keys are an error, so typos don't silently do
// nothing.
func LoadAll(paths []string) (Config, error) { func LoadAll(paths []string) (Config, error) {
var c Config var c Config
keybinding := map[string]map[string][]string{} keybinding := map[string]map[string][]string{}
@@ -228,9 +228,7 @@ func LoadAll(paths []string) (Config, error) {
if keybinding[ctx] == nil { if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{} keybinding[ctx] = map[string][]string{}
} }
for name, keys := range actions { maps.Copy(keybinding[ctx], actions)
keybinding[ctx][name] = keys
}
} }
} }
c.Keybinding = keybinding c.Keybinding = keybinding
@@ -297,6 +295,9 @@ 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 := c.Providers.Repos.Spring.Check(); err != nil {
return fmt.Errorf("%s: providers.repos.%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)
+8 -7
View File
@@ -18,7 +18,7 @@ func write(t *testing.T, content string) string {
} }
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
c, err := Load(write(t, ` c, err := LoadAll([]string{write(t, `
ui: ui:
icons: false icons: false
sourcesView: flat sourcesView: flat
@@ -36,7 +36,7 @@ providers:
databricks: databricks:
enabled: false enabled: false
hideProfiles: [test] hideProfiles: [test]
`)) `)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"missing": filepath.Join(t.TempDir(), "nope.yaml"), "missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""), "empty": write(t, ""),
} { } {
c, err := Load(path) c, err := LoadAll([]string{path})
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil { if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err) t.Errorf("%s: %+v, %v", name, c, err)
} }
@@ -84,8 +84,9 @@ 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 spring:\n keys: [\"(pass\"]\n",
} { } {
if _, err := Load(write(t, content)); err == nil { if _, err := LoadAll([]string{write(t, content)}); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") { } else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err) t.Errorf("%s: error doesn't name the file: %v", name, err)
@@ -132,18 +133,18 @@ theme:
} }
func TestUpdates(t *testing.T) { func TestUpdates(t *testing.T) {
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")) c, err := LoadAll([]string{write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")})
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 { if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
t.Errorf("updates = %+v, %v", c.Updates, err) t.Errorf("updates = %+v, %v", c.Updates, err)
} }
} }
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n")) c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond { if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err) t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
} }
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil { if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
t.Error("a minute's delay accepted") t.Error("a minute's delay accepted")
} }
} }
+1 -2
View File
@@ -9,7 +9,6 @@
package instance package instance
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -117,7 +116,7 @@ func (s *Server) handle(c net.Conn) {
defer c.Close() defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second)) _ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request var req request
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil { if err := json.NewDecoder(c).Decode(&req); err != nil {
return return
} }
var resp response var resp response
+3 -7
View File
@@ -114,19 +114,15 @@ 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 "" // installed before the key was recorded: assume it works return "" // can't tell: assume it works
} }
if exe := strings.TrimSpace(string(out)); !exists(exe) { exe := strings.TrimSpace(string(out))
if _, err := os.Stat(exe); err != nil {
return "lazysecrets:// links run " + exe + ", which no longer exists" return "lazysecrets:// links run " + exe + ", which no longer exists"
} }
return "" return ""
} }
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration. // Uninstall removes the applet and its registration.
func Uninstall() (string, error) { func Uninstall() (string, error) {
app, err := AppPath() app, err := AppPath()
+5 -4
View File
@@ -140,11 +140,10 @@ func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
} }
// Launch opens a new terminal window running inner, without waiting for // Launch opens a new terminal window running argv, 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
} }
@@ -166,9 +165,11 @@ 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 config locations. // without Homebrew's PATH (so no az) or the user's XDG directories, so
// the window would use another cache and miss running instances' sockets.
var passEnv = []string{ var passEnv = []string{
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS",
"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",
} }
-3
View File
@@ -107,9 +107,6 @@ func parseTerm(tok string) (term, bool) {
return t, true return t, true
} }
// Empty reports whether the query has no terms: everything matches.
func (q Query) Empty() bool { return len(q.groups) == 0 }
// Item is a row to match: Text is everything searched, and runes // Item is a row to match: Text is everything searched, and runes
// NameStart up to NameEnd of it are the row's name (the rest is context, // NameStart up to NameEnd of it are the row's name (the rest is context,
// like a path or a note). Without a name, nothing gets the name bonus. // like a path or a note). Without a name, nothing gets the name bonus.
-6
View File
@@ -99,9 +99,3 @@ func TestAnchorsOnTheName(t *testing.T) {
t.Errorf("password$: %v", got) t.Errorf("password$: %v", got)
} }
} }
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+97 -119
View File
@@ -16,6 +16,7 @@ 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"
@@ -99,13 +100,7 @@ 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) {
var out []provider.Source out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []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:
@@ -118,7 +113,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()
out = append(out, provider.Source{ srcs = append(srcs, provider.Source{
Kind: provider.KindAzure, Kind: provider.KindAzure,
ID: id, ID: id,
Name: deref(s.DisplayName), Name: deref(s.DisplayName),
@@ -126,6 +121,10 @@ 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 {
@@ -230,22 +229,29 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
if err != nil { if err != nil {
return nil, err return nil, err
} }
var out []provider.Container return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) {
pager := client.NewListBySubscriptionPager(nil) for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
vaults = append(vaults, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
return vaults
})
}
// all lists every page, items picking out what to keep of each.
func all[P, T any](ctx context.Context, pager *runtime.Pager[P], items func(P) []T) ([]T, error) {
var out []T
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)
} }
for _, v := range page.Value { out = append(out, items(page)...)
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
} }
@@ -288,51 +294,40 @@ 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:
pager := vc.secrets.NewListSecretPropertiesPager(nil) tenant := p.tenant(c.Source.ID)
for pager.More() { out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) {
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) {
continue objs = append(objs, secretObject(c, s, tenant))
} }
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
} }
} return objs
})
case provider.TypeKey: case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesPager(nil) out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) {
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) {
continue objs = append(objs, keyObject(c, k))
} }
out = append(out, keyObject(c, k))
} }
} return objs
})
case provider.TypeCertificate: case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesPager(nil) out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) {
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 {
continue objs = append(objs, certObject(c, cp))
} }
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
} }
@@ -353,7 +348,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), "Key", string(*resp.Key.KID)) o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID))
} }
o.Attrs = keyAttrs(resp.Key) o.Attrs = keyAttrs(resp.Key)
} }
@@ -364,7 +359,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), "Certificate", string(*resp.ID)) o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, 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)
@@ -381,56 +376,46 @@ 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:
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil) out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (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 {
if s.ID != nil { if s.ID != nil {
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID))) objs = append(objs, secretObject(o.Container, s, tenant))
} }
} }
} return objs
})
case provider.TypeKey: case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil) out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) {
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 {
out = append(out, keyObject(o.Container, k)) objs = append(objs, keyObject(o.Container, k))
} }
} }
} return objs
})
case provider.TypeCertificate: case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil) out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) {
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 {
out = append(out, certObject(o.Container, cp)) objs = append(objs, certObject(o.Container, cp))
} }
} }
} return objs
})
default: default:
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
tenant := p.tenant(o.Container.Source.ID) if err != nil {
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
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type] out[i].URL = portalAssetURL(tenant, o.Type, out[i].ID+"/"+out[i].Version) // the version's page
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) })
@@ -452,56 +437,49 @@ 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) v, _, err := p.VersionedValue(ctx, o)
if err != nil { return v, err
return provider.SecretValue{}, err
}
switch o.Type {
case provider.TypeSecret:
v, _, err := p.VersionedValue(ctx, o)
return v, err
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
pemKey, err := publicKeyPEM(resp.Key)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
}
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
}
return provider.SecretValue{}, provider.ErrUnsupported
} }
// VersionedValue is Value, with the version read. // 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
} }
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil) var text string
if err != nil { switch o.Type {
return provider.SecretValue{}, "", cleanError(err) case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
version := o.Version
if resp.ID != nil {
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if text, err = publicKeyPEM(resp.Key); err != nil {
return provider.SecretValue{}, "", err
}
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, "", errors.New("certificate has no public certificate yet (pending issuance?)")
}
text = certPEM(resp.CER)
default:
return provider.SecretValue{}, "", provider.ErrUnsupported
} }
version := o.Version return provider.NewSecretValue([]byte(text)), o.Version, nil
if resp.ID != nil {
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
} }
var ( var (
+9 -15
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, kind, objectID string) string { func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID
} }
func tenantPrefix(tenant string) string { func tenantPrefix(tenant string) string {
@@ -73,19 +73,13 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
if !ok { if !ok {
return "" return ""
} }
switch t { return base + "/" + t.Collection()
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, collection, name string) string { func objectURL(c provider.Container, t provider.ObjectType, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name return strings.TrimSuffix(c.Detail, "/") + "/" + t.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
@@ -93,7 +87,7 @@ func objectURL(c provider.Container, collection, 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, "secrets", name) url := objectURL(c, provider.TypeSecret, name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeSecret, Type: provider.TypeSecret,
@@ -103,7 +97,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, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")), URL: portalAssetURL(tenant, provider.TypeSecret, 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)
@@ -113,7 +107,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, "keys", name) url := objectURL(c, provider.TypeKey, name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeKey, Type: provider.TypeKey,
@@ -132,7 +126,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, "certificates", name) url := objectURL(c, provider.TypeCertificate, name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeCertificate, Type: provider.TypeCertificate,
+13 -13
View File
@@ -2,6 +2,7 @@ package azure
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"strings" "strings"
@@ -70,13 +71,10 @@ 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: map[string]string{}} info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)}
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
@@ -122,7 +120,11 @@ func (v vaultInfo) attrs() []provider.Attr {
} else { } else {
add("soft delete", "off") add("soft delete", "off")
} }
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)]) if 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")
@@ -131,15 +133,13 @@ func (v vaultInfo) attrs() []provider.Attr {
default: default:
add("network", "all networks") add("network", "all networks")
} }
if v.endpoints > 0 { switch {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1])) case v.endpoints == 1:
add("private", "1 endpoint")
case v.endpoints > 1:
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
} }
keys := make([]string, 0, len(v.tags)) for i, k := range slices.Sorted(maps.Keys(v.tags)) {
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := "" label := ""
if i == 0 { if i == 0 {
label = "tags" label = "tags"
+14 -11
View File
@@ -203,17 +203,20 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
return out, nil return out, nil
} }
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls // fallbackVault is the backing vault of an AKV-backed scope, to read it
// back to the scope's Detail, so a scope remembered from an earlier session // from when Databricks fails, or "" when there's none to fall back to. It
// (a favourite) works before its workspace's scopes are listed. // falls back to the scope's Detail, so a scope remembered from an earlier
func (p *Provider) vaultURL(c provider.Container) string { // session (a favourite) works before its workspace's scopes are listed.
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
if p.keyVault == nil || ctx.Err() != nil {
return ""
}
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 == "" {
url, _ = strings.CutPrefix(c.Detail, akvDetail) if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok {
if url == c.Detail { url = detail
url = ""
} }
} }
return url return url
@@ -264,8 +267,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.vaultURL(c) url := p.fallbackVault(ctx, c)
if url == "" || p.keyVault == nil || ctx.Err() != nil { if url == "" {
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)
@@ -302,8 +305,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
} }
dbxErr := classify(pr, err) dbxErr := classify(pr, err)
url := p.vaultURL(o.Container) url := p.fallbackVault(ctx, o.Container)
if url == "" || p.keyVault == nil || ctx.Err() != nil { if url == "" {
return provider.SecretValue{}, dbxErr return provider.SecretValue{}, dbxErr
} }
v, kvErr := p.keyVault.Value(ctx, provider.Object{ v, kvErr := p.keyVault.Value(ctx, provider.Object{
+30 -49
View File
@@ -48,19 +48,8 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
func (p *Provider) Kind() provider.Kind { return p.kind } func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string { func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
if p.id != "" { func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
return p.id
}
return string(p.kind)
}
func (p *Provider) Label() string {
if p.label != "" {
return p.label
}
return string(p.kind)
}
// As renames the provider, to simulate several accounts of one kind. // As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider { func (p *Provider) As(id, label string) *Provider {
@@ -236,9 +225,7 @@ func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.O
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 + map[provider.ObjectType]string{ o.URL = base + "/" + t.Collection()
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)
@@ -252,11 +239,7 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
} }
} }
var ( var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
yes = true
no = false
)
// ErrForbidden mimics a data-plane permission error from a backend. // ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets") var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
@@ -271,12 +254,13 @@ 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)
} }
@@ -284,32 +268,32 @@ func Azure(delay time.Duration) *Provider {
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...) kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{ dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base, Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain", Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"), Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple") }, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{ p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"}, {Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)}, {Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)}, {Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
} }
p.values[dbPassword.ID+"@91d3e0"] = "previous-password" p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password" p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0), Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"), Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000") }, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"), Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated") }, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0), Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"), Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0), Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"}, {Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"}, {Label: "ops", Value: "sign, verify"},
@@ -317,7 +301,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "keys", "signing-key"), Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0), Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"}, {Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"}, {Label: "ops", Value: "wrapKey, unwrapKey"},
@@ -326,7 +310,7 @@ func Azure(delay time.Duration) *Provider {
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file", Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"}, {Label: "subject", Value: "CN=app.example.com"},
@@ -338,7 +322,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "certificates", "app-tls"), Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert) }, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12", Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"}, {Label: "subject", Value: "CN=old.example.com"},
@@ -351,10 +335,10 @@ func Azure(delay time.Duration) *Provider {
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as sharing.keyRules wants. // A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"), Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=") }, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey) p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
@@ -364,19 +348,19 @@ func Azure(delay time.Duration) *Provider {
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com") dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...) kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"), Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-password") }, "dev-password")
// Left behind, as dev vaults are: expired, disabled, or both. // Left behind, as dev vaults are: expired, disabled, or both.
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"), Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
}, "whsec_demo_expired") }, "whsec_demo_expired")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0), Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0),
Reference: secretRef("kv-app-dev", "test-user-password"), Reference: secretRef("kv-app-dev", "test-user-password"),
}, "disabled-demo-password") }, "disabled-demo-password")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0), Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"), Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled") }, "expired-and-disabled")
@@ -388,24 +372,21 @@ 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: &yes, Updated: base.AddDate(0, -4, 0), Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0),
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"), Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password") }, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
// Unlike the first account, ops may read keys here. // Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{ p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0), Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}}, Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption", Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey) }, demoPublicKey)
@@ -413,12 +394,12 @@ func AzureOps(delay time.Duration) *Provider {
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com") sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...) kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"), Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token") }, "pd-demo-token")
// Expiring soon whenever the demo runs: the other dates are fixed, // Expiring soon whenever the demo runs: the other dates are fixed,
// and would have passed. Tests use the first account only. // and would have passed. Tests use the first account only.
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0), Name: "grafana-api-key", Enabled: new(true), Updated: base.AddDate(0, -11, 0),
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"), Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
}, "glsa_demo_expiring") }, "glsa_demo_expiring")
+14
View File
@@ -84,6 +84,20 @@ 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
+8 -3
View File
@@ -22,7 +22,12 @@ 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)
if isSops { // A plain file's values are at risk in git; a Spring file's only when
// one of its secrets is in plain text.
plain := !isSops
if !isSops && IsSpring(c.ID) {
out, plain = p.springDetails(c)
} 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 := ""
@@ -43,7 +48,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 && !isSops: case tracked && plain:
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"})
@@ -51,7 +56,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: !isSops}) out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: plain})
} }
return out, nil return out, nil
} }
+93 -88
View File
@@ -1,6 +1,7 @@
// 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. SOPS files are the one type so far. // their keys the secrets. Secret files are SOPS files, plain env files and
// Spring Boot config (spring.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
@@ -49,6 +50,9 @@ 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
// Spring says which keys of Spring Boot config files are secrets, for
// repos whose .lazysecrets.yaml doesn't.
Spring SpringPatterns
// Sops is the sops binary; "sops" (on PATH) when empty. // Sops is the sops binary; "sops" (on PATH) when empty.
Sops string Sops string
} }
@@ -106,8 +110,6 @@ 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
@@ -137,7 +139,6 @@ 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)
@@ -206,13 +207,11 @@ func parallel(n int, f func(i int)) {
var wg sync.WaitGroup var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU())) sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n { for i := range n {
wg.Add(1) wg.Go(func() {
go func() {
defer wg.Done()
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
f(i) f(i)
}() })
} }
wg.Wait() wg.Wait()
} }
@@ -325,6 +324,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
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 IsEnv(f): case IsEnv(f):
c.Detail = provider.DetailPlainEnv c.Detail = provider.DetailPlainEnv
case IsSpring(f):
c.Detail = "spring · " + springFormat(f)
default: default:
return return
} }
@@ -361,30 +362,26 @@ 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 := readMeta(c.ID) meta, isSops := parseMeta(c.ID, b)
if !isSops && IsSpring(c.ID) {
return p.springObjects(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)
} }
if !isSops { // a plain env file attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
out := make([]provider.Object, 0, len(keys)) if isSops {
for _, k := range keys { attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}
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, ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
} }
if !k.encrypted { if 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)"})
} }
out = append(out, o) out = append(out, o)
} }
@@ -403,15 +400,20 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
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 = o.Container.ID file = filePath(o.Container)
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 {
if IsSpring(file) {
return springValue(file, o.Name)
}
return plainValue(file, o.Name) return plainValue(file, o.Name)
} }
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
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -434,12 +436,18 @@ 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 := c.ID path := filePath(c)
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
@@ -447,6 +455,12 @@ 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 f.Sops == "" && IsSpring(path) {
if k, err := springKeyNamed(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
@@ -598,8 +612,9 @@ func normalizeRemote(remote string) (string, string) {
// lazysecretsFile is a repo's own .lazysecrets.yaml. // lazysecretsFile is a repo's own .lazysecrets.yaml.
type lazysecretsFile struct { type lazysecretsFile struct {
Include []string `yaml:"include"` Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"` Exclude []string `yaml:"exclude"`
Spring SpringPatterns `yaml:"spring"`
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).
@@ -609,33 +624,38 @@ type lazysecretsFile struct {
// 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 "", nil return f, nil
} }
if err != nil { if err != nil {
return "", err return f, err
} }
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil { if err := yaml.Unmarshal(b, &f); err != nil {
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err) return f, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
} }
return f.Sharing.Key, nil return f, 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 { if len(f.Include) > 0 || len(f.Exclude) > 0 {
var f lazysecretsFile include, exclude = f.Include, f.Exclude
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude
}
} }
inc, err := globs(include) inc, err := globs(include)
if err != nil { if err != nil {
@@ -907,12 +927,21 @@ 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 || !bytes.Contains(b, []byte("sops")) { if err != nil {
return sopsMeta{}, false
}
return parseMeta(file, b)
}
// parseMeta is readMeta, of the file's contents b.
func parseMeta(file string, b []byte) (sopsMeta, bool) {
format := formatOf(file)
if len(b) > maxFileSize || format == "" || !bytes.Contains(b, []byte("sops")) {
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 formatOf(file) { switch format {
case "yaml", "json": case "yaml", "json":
var doc struct { var doc struct {
Sops map[string]any `yaml:"sops"` Sops map[string]any `yaml:"sops"`
@@ -1040,7 +1069,8 @@ func parseKeys(file string, b []byte) ([]key, error) {
if !ok || strings.HasPrefix(k, "sops_") { if !ok || strings.HasPrefix(k, "sops_") {
continue continue
} }
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
} }
return out, nil return out, nil
case "ini": case "ini":
@@ -1105,48 +1135,23 @@ func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k) return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
} }
// extractFor rebuilds the extract path from a key name, for objects made // extractOf is the sops --extract path of the key named name, for objects
// from a link (db.password, list[0], app\.name). A dotenv file is flat: // made from a link.
// its names are the keys as they are. func extractOf(file, name string) (string, error) {
func extractFor(file, name string) string { b, err := os.ReadFile(file)
if formatOf(file) == "dotenv" { if err != nil {
return quote(name) return "", err
} }
var b, part strings.Builder keys, err := parseKeys(file, b)
flush := func() { if err != nil {
if part.Len() > 0 { return "", err
b.WriteString(quote(part.String())) }
part.Reset() for _, k := range keys {
if k.name == name {
return k.extract, nil
} }
} }
for i := 0; i < len(name); i++ { return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
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 {
+31 -5
View File
@@ -237,6 +237,25 @@ 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") {
@@ -554,15 +573,22 @@ 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"]` {
t.Errorf("dotenv: %s", e) keys, err = parseKeys(".env", []byte("spring.url=a\nexport TOKEN=b\nSPACED = c\n"))
if err != nil {
t.Fatal(err)
}
got = nil
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
}
want = `spring.url ["spring.url"]|TOKEN ["export TOKEN"]|SPACED ["SPACED "]`
if strings.Join(got, "|") != want {
t.Errorf("dotenv keys = %s\nwant %s", strings.Join(got, "|"), want)
} }
} }
+414
View File
@@ -0,0 +1,414 @@
package repo
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode"
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Spring Boot config files (application*.properties/.yml, bootstrap*) hold
// all of an app's config, most of it not secret: of their keys, only those
// named like a secret or with a value that looks like one are listed. The
// patterns are configurable, and the file's details count the rest, so a
// secret the patterns miss isn't hidden without a trace.
// DefaultSpringKeys and DefaultSpringValues are the patterns used when
// neither the config nor the repo's .lazysecrets.yaml sets them. The key
// pattern is anchored at the end of the name: spring.datasource.password,
// client-secret and checkoutKey, not the oauth2.*, keycloak.* and
// token-uri keys every Spring app has. passord is Norwegian. The file's
// details count the keys left out.
var (
DefaultSpringKeys = []string{
`(?i)(pass(word|wd|phrase|ord)?|pwd|secret|token|key|credentials?|salt|connection-?string)$`,
}
DefaultSpringValues = []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
}
)
// SpringPatterns are regexps matched against a Spring config key's name
// and its value: a key is listed when either matches. A list that isn't
// set is DefaultSpringKeys or DefaultSpringValues.
type SpringPatterns struct {
Keys *[]string `yaml:"keys"`
Values *[]string `yaml:"values"`
}
// Check says which pattern doesn't compile.
func (p SpringPatterns) Check() error {
_, err := compileSpring(p)
return err
}
type springFilter struct{ keys, values []*regexp.Regexp }
func compileSpring(p SpringPatterns) (springFilter, error) {
var f springFilter
var errs []error
add := func(field string, patterns *[]string, defaults []string, out *[]*regexp.Regexp) {
list := defaults
if patterns != nil {
list = *patterns
}
for i, s := range list {
re, err := regexp.Compile(s)
if err != nil {
errs = append(errs, fmt.Errorf("spring.%s[%d]: %w", field, i, err))
continue
}
*out = append(*out, re)
}
}
add("keys", p.Keys, DefaultSpringKeys, &f.keys)
add("values", p.Values, DefaultSpringValues, &f.values)
return f, errors.Join(errs...)
}
func (f springFilter) secret(k springKey) bool {
return anyMatch(f.keys, k.key) || anyMatch(f.values, k.value)
}
// springFilterFor is the repo's patterns: its .lazysecrets.yaml's, else the
// config's, each list on its own.
func (p *Provider) springFilterFor(root string) (springFilter, error) {
f, err := readLazysecretsFile(root)
if err != nil {
return springFilter{}, err
}
patterns := p.s.Spring
if f.Spring.Keys != nil {
patterns.Keys = f.Spring.Keys
}
if f.Spring.Values != nil {
patterns.Values = f.Spring.Values
}
filter, err := compileSpring(patterns)
if err != nil && (f.Spring.Keys != nil || f.Spring.Values != nil) {
err = fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return filter, err
}
// 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"
}
type springKey struct {
key string // spring.datasource.password
name string // key, with (profile) after it in a later document
value string
line int // 1-based
}
// reference reports whether the value is only a placeholder, ${DB_PASSWORD}:
// the secret is elsewhere. One with a default, ${DB_PASSWORD:hunter2}, has
// the default in the file.
func (k springKey) reference() bool {
return strings.HasPrefix(k.value, "${") && strings.HasSuffix(k.value, "}") &&
!strings.ContainsAny(k.value[2:len(k.value)-1], ":${}")
}
func (k springKey) jasypt() bool {
return strings.HasPrefix(k.value, "ENC(") && strings.HasSuffix(k.value, ")")
}
// plain reports whether the file holds the value itself, readable.
func (k springKey) plain() bool {
return k.value != "" && !k.reference() && !k.jasypt()
}
// readSpring lists a Spring config file's keys, every one, with values.
func readSpring(file string) ([]springKey, error) {
b, err := os.ReadFile(file)
if err != nil {
return nil, err
}
return parseSpring(file, b)
}
func parseSpring(file string, b []byte) ([]springKey, error) {
if springFormat(file) == "properties" {
return parseProperties(b), nil
}
return parseSpringYAML(b)
}
// 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 [][]springKey) []springKey {
var out []springKey
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) ([]springKey, 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 [][]springKey
for {
var doc yaml.Node
err := dec.Decode(&doc)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
var keys []springKey
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 *[]springKey) {
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, springKey{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) []springKey {
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
var docs [][]springKey
var doc []springKey
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, springKey{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()
}
// springObjects lists the file's secret keys.
func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Object, error) {
keys, err := parseSpring(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
filter, err := p.springFilterFor(c.Source.ID)
if err != nil {
return nil, err
}
format := provider.Attr{Label: "format", Value: "spring " + springFormat(c.ID)}
var out []provider.Object
for _, k := range keys {
if !filter.secret(k) {
continue
}
state := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}
switch {
case k.reference():
state = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}
case k.jasypt():
state = provider.Attr{Label: "encrypted", Value: "jasypt"}
}
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name,
Attrs: []provider.Attr{format, state},
})
}
return out, nil
}
func springValue(file, name string) (provider.SecretValue, error) {
k, err := springKeyNamed(file, name)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(k.value)), nil
}
func springKeyNamed(file, name string) (springKey, error) {
keys, err := readSpring(file)
if err != nil {
return springKey{}, err
}
for _, k := range keys {
if k.name == name {
return k, nil
}
}
return springKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
}
// springDetails says how many of the file's keys aren't listed, and
// whether any listed one is in plain text.
func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, plain bool) {
keys, err := readSpring(c.ID)
if err != nil {
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
}
filter, err := p.springFilterFor(c.Source.ID)
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) {
secrets++
plain = plain || k.plain()
}
if k.jasypt() {
jasypt++
}
}
encryption := "none: plain text"
if jasypt > 0 {
encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, len(keys))
}
return []provider.Attr{
{Label: "encryption", Value: encryption},
{Label: "other keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
}, plain
}
+222
View File
@@ -0,0 +1,222 @@
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 []springKey) 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 := compileSpring(SpringPatterns{})
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(springKey{key: key, value: 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(springKey{key: key, value: 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(abc==)
`,
"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]
keys, _ := p.Objects(ctx, yml, provider.TypeSecret)
var names []string
for _, k := range keys {
names = append(names, k.Name)
}
if want := []string{"spring.datasource.password", "jasypt.encryptor.password", "app.api-key"}; !slices.Equal(names, want) {
t.Errorf("keys %v, want %v", names, want)
}
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "ENC(abc==)" {
t.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["other 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, Spring: SpringPatterns{Keys: &all}})
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 5 {
t.Errorf("every key: %d", len(keys))
}
lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"url$\"]\n values: []\n"), 0o644)
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 1 || keys[0].Name != "spring.datasource.url" {
t.Errorf("repo's patterns: %v", keys)
}
os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"(\"]\n"), 0o644)
if _, err := p.Objects(ctx, yml, provider.TypeSecret); err == nil || !strings.Contains(err.Error(), ".lazysecrets.yaml: spring.keys[0]") {
t.Errorf("bad pattern: %v", err)
}
}
-2
View File
@@ -21,8 +21,6 @@ func NewSecretValue(b []byte) SecretValue {
// Reveal returns the plaintext. Call it only to display or copy the value. // Reveal returns the plaintext. Call it only to display or copy the value.
func (v SecretValue) Reveal() string { return string(v.b) } func (v SecretValue) Reveal() string { return string(v.b) }
func (v SecretValue) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked } func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked } func (v SecretValue) GoString() string { return masked }
+7 -13
View File
@@ -92,15 +92,9 @@ 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, c := range collections { for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
if strings.EqualFold(c, collection) { if strings.EqualFold(t.Collection(), collection) {
return t, true return t, true
} }
} }
@@ -118,7 +112,7 @@ func (r Ref) IdentifierURL() string {
if r.Type == "" { if r.Type == "" {
return u + "/" return u + "/"
} }
for _, seg := range []string{collections[r.Type], r.Name, r.Version} { for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} {
if seg != "" { if seg != "" {
u += "/" + url.PathEscape(seg) u += "/" + url.PathEscape(seg)
} }
@@ -140,7 +134,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, collections[r.Type]) segs = append(segs, r.Type.Collection())
} }
segs = append(segs, r.Name, r.Version) segs = append(segs, r.Name, r.Version)
} }
@@ -273,7 +267,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 strings.ToLower(u.Host) { switch host := strings.ToLower(u.Host); host {
case "azure": case "azure":
r.Kind = provider.KindAzure r.Kind = provider.KindAzure
if r.Host == "subscriptions" { if r.Host == "subscriptions" {
@@ -300,11 +294,11 @@ func parseLink(s string) (Ref, error) {
} }
case "databricks", "repo": case "databricks", "repo":
r.Kind = provider.KindDatabricks r.Kind = provider.KindDatabricks
if strings.EqualFold(u.Host, "repo") { if 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", strings.ToLower(u.Host)) return Ref{}, fmt.Errorf("%s link has too many parts", host)
} }
if len(rest) > 0 { if len(rest) > 0 {
r.Scope = rest[0] r.Scope = rest[0]
+7 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8"
) )
// Rules are what a key's value must be to share with it: random enough // Rules are what a key's value must be to share with it: random enough
@@ -40,14 +41,18 @@ func (r Rules) Check(key []byte) error {
kinds++ kinds++
} }
} }
n := len([]rune(s)) n := utf8.RuneCountInString(s)
var has, needs []string var has, needs []string
if n < r.MinLength { if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n)) has = append(has, fmt.Sprintf("%d characters", n))
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 {
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1])) kind := "kinds"
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 || n > uint64(len(b)) { if size <= 0 {
return Sealed{}, bad return Sealed{}, bad
} }
rest := b[1+size:] rest := b[1+size:]
+20 -29
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"
@@ -654,7 +654,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case detailsMsg: case detailsMsg:
if msg.req == m.reqID[slotDetails] { if msg.req == m.reqID[slotDetails] {
m.done(slotDetails) m.cancelReq(slotDetails)
} }
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) { if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again delete(m.details, msg.key) // tried again when shown again
@@ -685,7 +685,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotContainers] { if msg.req != m.reqID[slotContainers] {
break break
} }
m.done(slotContainers) m.cancelReq(slotContainers)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok { if src, ok := m.selectedSource(); ok {
@@ -703,7 +703,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotObjects] { if msg.req != m.reqID[slotObjects] {
break break
} }
m.done(slotObjects) m.cancelReq(slotObjects)
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil { if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind)) m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
m.closeVersions() m.closeVersions()
@@ -724,7 +724,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.starredFirst(msg.items)) m.secrets.SetItems(m.starredObjectsFirst(msg.items))
cmds = append(cmds, m.refreshShown(before)) cmds = append(cmds, m.refreshShown(before))
} }
} }
@@ -733,7 +733,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotValue] { if msg.req != m.reqID[slotValue] {
break break
} }
m.done(slotValue) m.cancelReq(slotValue)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -748,7 +748,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotVersions] { if msg.req != m.reqID[slotVersions] {
break break
} }
m.done(slotVersions) m.cancelReq(slotVersions)
// Errors and unsupported providers just leave the count out. // Errors and unsupported providers just leave the count out.
if msg.err == nil { if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
@@ -760,7 +760,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotDescribe] { if msg.req != m.reqID[slotDescribe] {
break break
} }
m.done(slotDescribe) m.cancelReq(slotDescribe)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -775,7 +775,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotCopy] { if msg.req != m.reqID[slotCopy] {
break break
} }
m.done(slotCopy) m.cancelReq(slotCopy)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true} m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
@@ -887,7 +887,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Quit): case key.Matches(msg, k.Quit):
return nil, true return nil, true
case key.Matches(msg, k.Help): case key.Matches(msg, k.Help):
return m.showKeybindings(), false return m.showKeybindingsFor(""), false
case key.Matches(msg, k.Up): case key.Matches(msg, k.Up):
m.move(-1) m.move(-1)
case key.Matches(msg, k.Down): case key.Matches(msg, k.Down):
@@ -1167,14 +1167,11 @@ func (m *Model) syncSelection() tea.Cmd {
if m.onLinks() { if m.onLinks() {
m.showLinkedContainers() m.showLinkedContainers()
} else if src, ok := m.selectedSource(); ok { } else if src, ok := m.selectedSource(); ok {
m.containers.Title = "[2] Vaults"
m.containers.Empty = "no vaults found" m.containers.Empty = "no vaults found"
switch src.Kind { switch src.Kind {
case provider.KindDatabricks: case provider.KindDatabricks:
m.containers.Title = "[2] Scopes"
m.containers.Empty = "no secret scopes" m.containers.Empty = "no secret scopes"
case provider.KindRepo: case provider.KindRepo:
m.containers.Title = "[2] Files"
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)" m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
} }
cmds = append(cmds, m.showContainers(src, id)) cmds = append(cmds, m.showContainers(src, id))
@@ -1259,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.starredFirst(e.value)) m.secrets.SetItems(m.starredObjectsFirst(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
@@ -1319,8 +1316,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
}) })
} }
// closeVersions returns the Secrets pane to the object list, with the // closeVersions returns the Secrets pane to the object list.
// cursor on the object whose versions were shown.
func (m *Model) closeVersions() { func (m *Model) closeVersions() {
m.versionsOf = nil m.versionsOf = nil
} }
@@ -1517,9 +1513,6 @@ func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
} }
} }
// done releases the context of a completed request.
func (m *Model) done(p slot) { m.cancelReq(p) }
// loadSources starts a new generation of source loads, superseding any in // loadSources starts a new generation of source loads, superseding any in
// flight. // flight.
func (m *Model) loadSources() tea.Cmd { func (m *Model) loadSources() tea.Cmd {
@@ -1639,7 +1632,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd { func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() { if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok { if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false) return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
} }
return nil return nil
} }
@@ -1682,12 +1675,8 @@ 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(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} }) return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} })
} }
func (m *Model) cancelClipClear() { func (m *Model) cancelClipClear() {
@@ -1794,13 +1783,15 @@ 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 {
revealed := m.detail.State() == panes.ValueShown reveal := "show"
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) if m.detail.State() == panes.ValueShown {
reveal = "hide"
}
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), reveal: reveal}.ShortHelp(), 2)
} }
var lines []string var lines []string
if !m.clipDeadline.IsZero() { if !m.clipDeadline.IsZero() {
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, secondsLeft(m.clipDeadline, m.now())))))
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))
+4 -4
View File
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") { if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
t.Errorf("detail hint doesn't follow the binding:\n%s", scr) t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
} }
if !strings.Contains(lastLine(scr), "z show") { if !strings.Contains(lastLines(scr, 1), "z show") {
t.Errorf("help bar: %q", lastLine(scr)) t.Errorf("help bar: %q", lastLines(scr, 1))
} }
d.keys("z") d.keys("z")
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar) t.Errorf("help bar with the value shown: %q", bar)
} }
d.keys("z") d.keys("z")
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
if !strings.Contains(scr, "Alice Example") { if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr) t.Errorf("account not shown:\n%s", scr)
} }
bar := lastLine(scr) bar := lastLines(scr, 1)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar) t.Errorf("help bar: %q", bar)
} }
+1 -1
View File
@@ -66,7 +66,7 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
if i < 0 || sameListing(before[i], s) { if i < 0 || sameListing(before[i], s) {
return nil return nil
} }
m.detail.Refresh(s) m.detail.SetDescribed(s)
uncache(m, m.cache.details, detailKey(s)) uncache(m, m.cache.details, detailKey(s))
return m.describe(s) return m.describe(s)
} }
+10 -20
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -84,10 +85,7 @@ func (m *Model) showCommands() tea.Cmd {
} }
var opts []option var opts []option
for _, c := range cmds { for _, c := range cmds {
label := c.Description label := cmp.Or(c.Description, c.Command)
if label == "" {
label = c.Command
}
if c.Key != "" { if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
} }
@@ -122,7 +120,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
in.SetValue(p.Default) in.SetValue(p.Default)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
title: cmpOr(c.Description, "Command"), input: &in, title: cmp.Or(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd { submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text) it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1) return m.askPrompt(c, it, obj, i+1)
@@ -151,7 +149,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
case strings.HasPrefix(c.Value, "env:"): case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".") lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
} }
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{ m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }}, {key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
}) })
return nil return nil
@@ -177,7 +175,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
// execCommand runs the command as its output mode says. // execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd { func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh") shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) { setValue := func(cmd *exec.Cmd) {
switch { switch {
case c.Value == "stdin": case c.Value == "stdin":
@@ -195,7 +193,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
m.flash = flash{} m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} }) return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
} }
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"} m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel() defer cancel()
@@ -219,7 +217,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
const maxOutputLines = 30 const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) { func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmpOr(msg.cmd.Description, "command") name := cmp.Or(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil { if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch { switch {
case msg.err != nil: case msg.err != nil:
@@ -298,11 +296,7 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
if r, err := ref.ForObject(o, version); err == nil { if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String()) it.Link = commands.Q(r.String())
} }
if version != "" { o.Version = version // "": the current value, like copying it
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true return it, &o, true
} }
@@ -313,9 +307,5 @@ func newTextInput(prompt string, width int) textinput.Model {
return in return in
} }
func cmpOr(a, b string) string { // pathInputWidth fits a link or a folder path in a prompt, in the window.
if a != "" { func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
return a
}
return b
}
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"github.com/charmbracelet/x/ansi"
) )
func commandDriver(t *testing.T, cmds ...commands.Command) *driver { func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
@@ -34,7 +35,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
// temporary path's length. // temporary path's length.
shown := "" shown := ""
if d.m.prompt != nil { if d.m.prompt != nil {
shown = ansiStrip(strings.Join(d.m.prompt.lines, "")) shown = ansi.Strip(strings.Join(d.m.prompt.lines, ""))
} }
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") { if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr) t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
+1 -1
View File
@@ -242,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
return tea.QuitMsg{} return tea.QuitMsg{}
} }
} }
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" { if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w"). // Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...) args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{} m.flash = flash{}
+5 -4
View File
@@ -6,6 +6,7 @@ import (
"slices" "slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -30,7 +31,7 @@ func (m Model) expiringItems() []expiringItem {
for _, c := range m.cache.containers[row].value { for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != notExpiring { if state, _ := m.expiry(o); state != expiry.None {
out = append(out, expiringItem{o, row}) out = append(out, expiringItem{o, row})
} }
} }
@@ -51,11 +52,11 @@ func (m Model) expiringFields() []panes.Field {
var out []panes.Field var out []panes.Field
for _, it := range items { for _, it := range items {
state, left := m.expiry(it.o) state, left := m.expiry(it.o)
mark := "⚠ " + daysLeft(left) mark := "⚠ " + expiry.Left(left)
if state == expired { if state == expiry.Expired {
mark = "✗ expired" mark = "✗ expired"
} }
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row} f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil { if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String() f.Link = r.String()
} }
+3 -15
View File
@@ -4,7 +4,6 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry" "git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -12,30 +11,19 @@ import (
// list once they have expired (✗ expired) or will within the configured // list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them. // warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState = expiry.State
const (
notExpiring = expiry.None
expiresSoon = expiry.Soon
expired = expiry.Expired
)
// expiry tells whether o has expired or will within the warning period, // expiry tells whether o has expired or will within the warning period,
// and how long it has left. // and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) { func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning) return expiry.Of(o.Expires, m.now(), m.expiryWarning)
} }
// expiryWord is matched by the filter: "expired" or "expiring". // expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string { func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state { switch state, _ := m.expiry(o); state {
case expired: case expiry.Expired:
return "expired" return "expired"
case expiresSoon: case expiry.Soon:
return "expiring" return "expiring"
} }
return "" return ""
} }
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { return expiry.Left(d) }
+39 -23
View File
@@ -8,6 +8,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -100,11 +101,14 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
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: a lock for an encrypted one, a file in the // A secret file: a lock for an encrypted one, a leaf
// warning colour for plain text, and its type after it // for Spring config, a file in the warning colour for plain
// ("sops · age", "env · plain text"). // text, and its type after it ("sops · age", "env · plain text").
icon, style := iconSet.Encrypted, sty.KindRepo icon, style := iconSet.Encrypted, sty.KindRepo
if !strings.HasPrefix(c.Detail, "sops") { switch {
case strings.HasPrefix(c.Detail, "spring"):
icon, style = iconSet.Spring, sty.Spring
case !strings.HasPrefix(c.Detail, "sops"):
icon, style = iconSet.PlainFile, sty.Warning icon, style = iconSet.PlainFile, sty.Warning
} }
if icon != "" { if icon != "" {
@@ -115,7 +119,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
if withSource { if withSource {
src := c.Source.Name src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok { if account := accountOf(c.Source); account != "" {
src = account + " / " + src src = account + " / " + src
} }
line += sty.Muted.Render(" · " + src) line += sty.Muted.Render(" · " + src)
@@ -134,33 +138,45 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
} }
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
switch state, left := m.expiry(o); state { switch state, left := m.expiry(o); state {
case expired: case expiry.Expired:
line += " " + sty.Error.Render("✗ expired") line += " " + sty.Error.Render("✗ expired")
case expiresSoon: case expiry.Soon:
line += " " + sty.Warning.Render("⚠ "+daysLeft(left)) line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
} }
return withNote(sty, line, n.Note, width) return withNote(sty, line, n.Note, width)
} }
} }
// starredContainersFirst moves starred vaults and scopes to the front, // starredFirst moves starred items to the front, keeping the order
// keeping the order otherwise. It doesn't modify items, which may be cached. // otherwise. It doesn't modify items, which may be cached.
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container { 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 provider.Container) int { slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
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
} }
// starredFirst moves starred objects to the front, keeping the order func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
// otherwise. It doesn't modify items, which may be cached. return starredFirst(sources, func(s provider.Source) bool {
func (m Model) starredFirst(items []provider.Object) []provider.Object { return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
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.
@@ -198,7 +214,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.starredFirst(m.secrets.Items())) m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
} }
} }
@@ -244,7 +260,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.starredFirst(m.secrets.Items())) m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
} }
} }
switch { switch {
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
// jump opens the favourites list, filters it and jumps to the first match. // jump opens the favourites list, filters it and jumps to the first match.
@@ -429,7 +430,7 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("F") d.keys("F")
var labels []string var labels []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
labels = append(labels, ansiStrip(o.label)) labels = append(labels, ansi.Strip(o.label))
} }
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") { if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels) t.Errorf("favourites should list alice's star only: %q", labels)
+5 -4
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -61,7 +62,7 @@ func (m *Model) syncFields() tea.Cmd {
case !ok: case !ok:
m.fields.Show("", nil) m.fields.Show("", nil)
case m.selectedProblem() != "": case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem())) m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup: case r.kind == rowGroup:
m.showAccount(r) m.showAccount(r)
case r.kind == rowSource: case r.kind == rowSource:
@@ -134,7 +135,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
add("web", src.URL) add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok { if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind] label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value))) add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
} }
return out return out
} }
@@ -158,7 +159,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
case provider.KindRepo: case provider.KindRepo:
add("format", c.Detail) add("format", c.Detail)
} }
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL) add("web", c.URL)
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok { if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
@@ -221,5 +222,5 @@ func (m Model) problemFields(problem string) []panes.Field {
// fieldName names a copied field: its label, or a row of several under // fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value. // one label (a tag), its value.
func fieldName(f panes.Field) string { func fieldName(f panes.Field) string {
return cmpOr(f.Label, f.Value) return cmp.Or(f.Label, f.Value)
} }
+9 -18
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
@@ -16,7 +17,7 @@ import (
) )
// The help bar shows the keys that matter in the focused pane; ? lists all // The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindings). // of them in a menu (showKeybindingsFor).
// newHelp styles the help bar to read clearly: keys like labels elsewhere, // newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The // descriptions in the normal text colour, only the separators dimmed. The
@@ -38,10 +39,9 @@ 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
// revealed, when set, says whether the selected value is shown, so // reveal, when set, is what the reveal key will do, "show" or "hide":
// the reveal key says just what it'll do: show or hide, the same // the same width so the bar doesn't shift.
// width so the bar doesn't shift. reveal string
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.revealed != nil { if 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])) reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal))
} }
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,11 +84,6 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"} var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
// and group. Enter runs the selected one.
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with // showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the // what it does, filterable by key, description and group; enter runs the
// selected one. Opened from the panes, it lists the focused pane's keys // selected one. Opened from the panes, it lists the focused pane's keys
@@ -154,7 +149,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 { if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true}) opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds { for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom") label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom")
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }}) opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
} }
} }
@@ -263,11 +258,7 @@ 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 {
u, d := keys.Name(up), keys.Name(down) return keyHint{joinKeys(up, down), "move"}
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
+1 -12
View File
@@ -199,18 +199,7 @@ 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
} }
var choices []choice choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" })
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)
+3 -8
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) { func TestContextHelpBar(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
t.Errorf("Sources help bar: %q", bar) t.Errorf("Sources help bar: %q", bar)
} }
d.keys("3") d.keys("3")
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar) t.Errorf("Secrets help bar: %q", bar)
} }
} }
@@ -225,11 +225,6 @@ func TestKeyPress(t *testing.T) {
} }
} }
func lastLine(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
return lines[len(lines)-1]
}
func TestNoHighlightWhileTerminalBlurred(t *testing.T) { func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
@@ -264,7 +259,7 @@ func TestVersionInStatus(t *testing.T) {
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") { if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen()) t.Errorf("no version in the status pane:\n%s", d.screen())
} }
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") { if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar) t.Errorf("help line %q", bar)
} }
} }
+24 -19
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,18 +209,7 @@ 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) {
var choices []choice choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) })
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}}
} }
@@ -242,6 +231,24 @@ 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
@@ -433,7 +440,7 @@ func (m *Model) handleResolved(msg resolvedMsg) {
// (ref.Ref.Account), if it's one of them. // (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) { func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices { for _, c := range choices {
if r.Account != "" && providerOf(c.sourceRow) == r.Account { if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account {
return c.sourceRow, true return c.sourceRow, true
} }
} }
@@ -686,7 +693,7 @@ func (m *Model) share(menu bool) tea.Cmd {
share := func(k string) func(*Model) tea.Cmd { share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) } return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
} }
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}} opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret { if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")}) opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
} }
@@ -714,7 +721,7 @@ func (m *Model) share(menu bool) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) } return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
} }
opts := []option{ opts := []option{
option{key: "o", label: "Lazysecrets link", run: share("o")}, {key: "o", label: "Lazysecrets link", run: share("o")},
} }
if o.Container.Source.Kind == provider.KindAzure { if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")}) opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
@@ -830,10 +837,8 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
} }
func (m *Model) startOpenPrompt() tea.Cmd { func (m *Model) startOpenPrompt() tea.Cmd {
in := textinput.New() in := newTextInput("open: ", m.pathInputWidth())
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")},
+1 -3
View File
@@ -75,10 +75,8 @@ 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 := textinput.New() in := newTextInput("/", 40)
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{}
+1 -4
View File
@@ -3,7 +3,6 @@ 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"
@@ -58,11 +57,9 @@ 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 := textinput.New() in := newTextInput("note: ", 48)
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{
+5 -7
View File
@@ -77,17 +77,15 @@ func TestNotesOnRows(t *testing.T) {
func TestWithNoteGivesWay(t *testing.T) { func TestWithNoteGivesWay(t *testing.T) {
t.Parallel() t.Parallel()
st := styles.Default() st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 || if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 ||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") { !strings.HasSuffix(ansi.Strip(got), "prod SQL admin") {
t.Errorf("fits: %q", stripANSI(got)) t.Errorf("fits: %q", ansi.Strip(got))
} }
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") || if got := ansi.Strip(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
!strings.HasSuffix(got, "…") { !strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", got) t.Errorf("tight: %q", got)
} }
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" { if got := ansi.Strip(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
t.Errorf("no room: %q", got) t.Errorf("no room: %q", got)
} }
} }
func stripANSI(s string) string { return ansi.Strip(s) }
+18 -15
View File
@@ -29,41 +29,43 @@ type persistTickMsg struct{}
type persistedMsg struct{ err error } type persistedMsg struct{ err error }
// keepCached reports whether a cache key's account still exists and // keepers report whether a provider ID, or a cache key's, is an account
// isn't a repo: every key starts with the provider ID. // that still exists and isn't a repo.
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) } func (m *Model) keepers() (keep, keepKey func(string) bool) {
keep = cachefile.Keep(m.providers)
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) } return keep, func(key string) bool { return keep(cachefile.ProviderOf(key)) }
}
// 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 m.keepProvider(id) && recent(e.At) { if keep(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 m.keepCached(k) && recent(e.At) { if keepKey(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 m.keepCached(k) && recent(e.At) { if keepKey(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 m.keepCached(k) && recent(e.At) { if keepKey(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 m.keepCached(k) { if keepKey(k) {
m.cache.cursor[k] = v m.cache.cursor[k] = v
} }
} }
@@ -72,29 +74,30 @@ 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 f.Dropped, m.cacheDropped = m.cacheDropped, nil
for id, r := range m.sourceResults { for id, r := range m.sourceResults {
if r.err == nil && m.keepProvider(id) { if r.err == nil && keep(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 m.keepCached(k) { if keepKey(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 m.keepCached(k) { if keepKey(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 m.keepCached(k) { if keepKey(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 m.keepCached(k) { if keepKey(k) {
f.Cursor[k] = v f.Cursor[k] = v
} }
} }
+3 -2
View File
@@ -16,6 +16,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
const sopsFile = `db: const sopsFile = `db:
@@ -225,9 +226,9 @@ func TestSearchAllRepos(t *testing.T) {
var got []string var got []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo { if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansiStrip(o.label)) t.Errorf("not a repo: %s", ansi.Strip(o.label))
} }
got = append(got, ansiStrip(o.label)) got = append(got, ansi.Strip(o.label))
} }
if len(got) != 2 { if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got) t.Errorf("want db.password in both repos: %v", got)
+16 -7
View File
@@ -28,12 +28,22 @@ 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 {
wait := m.revealDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.revealToken token := m.revealToken
return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} }) return tea.Tick(untilNextSecond(m.revealDeadline, m.now()), func(time.Time) tea.Msg { return revealTickMsg{token} })
}
// untilNextSecond is how long until a countdown to deadline shows its
// next whole second.
func untilNextSecond(deadline, now time.Time) time.Duration {
if wait := deadline.Sub(now) % time.Second; wait > 0 {
return wait
}
return time.Second
}
// secondsLeft is a countdown to deadline, in whole seconds rounded up.
func secondsLeft(deadline, now time.Time) int {
return max(0, int(math.Ceil(deadline.Sub(now).Seconds())))
} }
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd { func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
@@ -49,8 +59,7 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
} }
func (m *Model) showHidesIn() { func (m *Model) showHidesIn() {
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds()))) m.detail.SetHidesIn(fmt.Sprintf("%ds", secondsLeft(m.revealDeadline, m.now())))
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.
+21 -31
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -46,10 +45,6 @@ type crawlMsg struct {
err error err error
} }
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of // searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with // each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group. // its source's row key as group.
@@ -67,7 +62,7 @@ func (m *Model) searchEntries() (sources, items []option) {
sort := strings.ToLower(strings.Join(path, "/")) + " " + account sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort, es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key, option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)), starAccount: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt return &es[len(es)-1].opt
} }
@@ -134,8 +129,6 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
} }
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// providerFilter picks the providers a search or a load covers; nil is // providerFilter picks the providers a search or a load covers; nil is
// all of them. // all of them.
type providerFilter func(provider.Provider) bool type providerFilter func(provider.Provider) bool
@@ -148,7 +141,7 @@ func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id } return func(p provider.Provider) bool { return p.ID() == id }
} }
// coverageOf is searchCoverage for the providers in. // coverageOf is the coverage of the providers in.
func (m *Model) coverageOf(in providerFilter) coverage { func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage var c coverage
for _, p := range m.providers { for _, p := range m.providers {
@@ -183,7 +176,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
// searchLines says what the search covers. // searchLines says what the search covers.
func (m *Model) searchLines() []string { func (m *Model) searchLines() []string {
c := m.searchCoverage() c := m.coverageOf(nil)
switch { switch {
case m.crawl.running: case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.", return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
@@ -210,11 +203,8 @@ func plural(n int, word string) string {
return fmt.Sprintf("%d %ss", n, word) return fmt.Sprintf("%d %ss", n, word)
} }
// startCrawl loads every unlisted subscription's vaults and every vault's // startCrawlOf loads, for the providers in, every unlisted subscription's
// secrets not loaded yet. // vaults and every vault's secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd { func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if in != nil && !in(p) { if in != nil && !in(p) {
@@ -301,7 +291,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
src := *job.src src := *job.src
timeout := m.timeout.of(src.Kind) timeout := m.timeout.of(src.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
items, err := p.Containers(ctx, src) items, err := p.Containers(ctx, src)
for i := range items { for i := range items {
@@ -314,7 +304,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
p := m.providerFor(c.Source) p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind) timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret) items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items { for i := range items {
@@ -351,6 +341,19 @@ 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 {
@@ -364,14 +367,7 @@ func (m *Model) sourceMark(row string) string {
if !ok { if !ok {
return m.st.Muted.Render("○") return m.st.Muted.Render("○")
} }
loaded, loading := 0, false loaded, loading := m.vaultsLoaded(e.value)
for _, c := range e.value {
key := cachefile.ObjectsKey(c, 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("◌")
@@ -396,9 +392,3 @@ func (m *Model) vaultMark(c provider.Container) string {
} }
return " " return " "
} }
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+19 -20
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
@@ -115,19 +116,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Fatal("platform-dev not in sources") t.Fatal("platform-dev not in sources")
} }
} }
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") { if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "○") {
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark) t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
if len(d.m.search.shown) != 0 { if len(d.m.search.shown) != 0 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown)) t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
} }
d.send(ctrl('r')) d.send(ctrl('r'))
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") { if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "●") {
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark) t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if !strings.Contains(ansiStrip(o.label), "platform-dev") { if !strings.Contains(ansi.Strip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansiStrip(o.label)) t.Errorf("result from elsewhere: %q", ansi.Strip(o.label))
} }
} }
if len(d.m.search.shown) == 0 { if len(d.m.search.shown) == 0 {
@@ -177,19 +178,17 @@ func TestStarFromSearch(t *testing.T) {
} }
} }
func ansiStrip(s string) string { return ansi.Strip(s) }
func TestSearchStartsWhereYouAre(t *testing.T) { func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch() d.openSearch()
v := d.m.search v := d.m.search
scope := func() string { return ansiStrip(v.scopes[v.src].label) } scope := func() string { return ansi.Strip(v.scopes[v.src].label) }
if scope() != "All" { if scope() != "All" {
t.Errorf("opened on %q, want All", scope()) t.Errorf("opened on %q, want All", scope())
} }
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") { if v.res >= len(v.shown) || !strings.Contains(ansi.Strip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen()) t.Errorf("vault not under the cursor:\n%s", d.screen())
} }
d.send(ctrl('s')) // narrows to the source, and back d.send(ctrl('s')) // narrows to the source, and back
@@ -233,13 +232,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
t.Errorf("no results in the account:\n%s", d.screen()) t.Errorf("no results in the account:\n%s", d.screen())
} }
for _, o := range v.shown { for _, o := range v.shown {
if providerOf(o.group) != header.provider { if cachefile.ProviderOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label)) t.Errorf("result from another account: %q", ansi.Strip(o.label))
} }
} }
// The account's sources are under it, indented. // The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") { if next := v.scopes[v.src+1]; next.group == "" || cachefile.ProviderOf(next.group) != header.provider || !strings.HasPrefix(ansi.Strip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label)) t.Errorf("after the account: %q", ansi.Strip(next.label))
} }
} }
@@ -327,7 +326,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
t.Fatal("no results") t.Fatal("no results")
} }
o := d.m.search.shown[0] o := d.m.search.shown[0]
plain := []rune(ansiStrip(o.label)) plain := []rune(ansi.Strip(o.label))
var got string var got string
for _, h := range o.hits { for _, h := range o.hits {
got += string(plain[h]) got += string(plain[h])
@@ -348,14 +347,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("F") d.keys("F")
bar := lastLine(d.screen()) bar := lastLines(d.screen(), 1)
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} { for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) { if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want) t.Errorf("browsing: help bar %q lacks %q", bar, want)
} }
} }
d.keys("/") d.keys("/")
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
t.Errorf("typing: help bar %q", bar) t.Errorf("typing: help bar %q", bar)
} }
} }
@@ -375,8 +374,8 @@ func TestHelpFromPopups(t *testing.T) {
if o.header { if o.header {
sections = append(sections, o.label) sections = append(sections, o.label)
} }
if strings.Contains(ansiStrip(o.label), "copy value") { if strings.Contains(ansi.Strip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label)) t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label))
} }
} }
if strings.Join(sections, ",") != "Go to,While typing, after /" { if strings.Join(sections, ",") != "Go to,While typing, after /" {
@@ -428,7 +427,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc") d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLine(d.screen())) bar := strings.TrimSpace(lastLines(d.screen(), 1))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") { if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar) t.Errorf("help bar %q", bar)
} }
@@ -443,11 +442,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
for d.m.search.src > 0 { for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) d.send(tea.KeyPressMsg{Code: tea.KeyUp})
} }
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ { for i := 0; !strings.Contains(ansi.Strip(d.m.search.scope().label), "platform-dev"); i++ {
if i > len(d.m.search.scopes) { if i > len(d.m.search.scopes) {
var labels []string var labels []string
for _, o := range d.m.search.scopes { for _, o := range d.m.search.scopes {
labels = append(labels, ansiStrip(o.label)) labels = append(labels, ansi.Strip(o.label))
} }
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results) t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
} }
+7 -31
View File
@@ -95,9 +95,7 @@ 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 := textinput.New() in := newTextInput("/", searchWidth(m.w)-6) // as wide as the popup
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
@@ -203,7 +201,7 @@ func (m *Model) fillSearch(keep bool) {
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())), id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
}) })
for _, o := range sources { for _, o := range sources {
if providerOf(o.group) == p.ID() { if cachefile.ProviderOf(o.group) == p.ID() {
o.label = " " + o.label o.label = " " + o.label
scopes = append(scopes, o) scopes = append(scopes, o)
} }
@@ -267,7 +265,7 @@ func (v *searchView) match() []option {
default: default:
account, ofAccount := v.scopeAccount() account, ofAccount := v.scopeAccount()
for _, o := range v.items { for _, o := range v.items {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group { if sc.id == allSources || ofAccount && cachefile.ProviderOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o) cand = append(cand, o)
} }
} }
@@ -523,7 +521,7 @@ func (m *Model) searchLoads() tea.Cmd {
return m.startCrawlOf(v.scopeFilter()) return m.startCrawlOf(v.scopeFilter())
} }
if !v.onSource() { if !v.onSource() {
return m.startCrawl() return m.startCrawlOf(nil)
} }
providerID, sourceID := splitSourceRow(sc.group) providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources { for _, src := range m.sourceResults[providerID].sources {
@@ -616,13 +614,7 @@ 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 := 0 loaded, _ := m.vaultsLoaded(e.value)
for _, c := range e.value {
key := cachefile.ObjectsKey(c, 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 ""
} }
@@ -770,7 +762,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, "", " "+m.goToTabs(ts)) body = append(body, "", " "+panes.Tabs(m.st, ts, tabNames[:], m.search.tab))
} }
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") {
@@ -803,7 +795,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+" ") + m.goToTabs(ts) head := ts.Render(goToTitle+" ") + panes.Tabs(m.st, ts, tabNames[:], m.search.tab)
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.
@@ -856,19 +848,3 @@ 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
}
+2 -5
View File
@@ -10,7 +10,6 @@ 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"
@@ -304,7 +303,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) {
@@ -338,9 +337,7 @@ 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 := textinput.New() in := newTextInput("folder: ", m.pathInputWidth())
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()
+1 -22
View File
@@ -134,10 +134,7 @@ func (m *Model) rebuildSources() {
} }
for _, p := range m.providers { for _, p := range m.providers {
kind, id := p.Kind(), p.ID() kind, id := p.Kind(), p.ID()
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ") account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id] r, done := m.sourceResults[id]
sources := r.sources sources := r.sources
if !flat { if !flat {
@@ -215,17 +212,6 @@ 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 {
@@ -239,13 +225,6 @@ 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()
+3 -2
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -83,7 +84,7 @@ func (m Model) statusFields() []panes.Field {
} }
} }
// A release's rows open its release page. // A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")} version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") { if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
} }
@@ -159,7 +160,7 @@ func (m Model) accountStatus(p provider.Provider) string {
return r.err.Error() return r.err.Error()
} }
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()] noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmpOr(noun, "source")) return plural(len(r.sources), cmp.Or(noun, "source"))
} }
// ago is a duration as people say it: "just now", "5m ago", "2h ago". // ago is a duration as people say it: "just now", "5m ago", "2h ago".
+1 -11
View File
@@ -2,13 +2,11 @@ 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"
) )
@@ -130,15 +128,7 @@ 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 {
switch err := m.openURL(r.URL); { return m.openPage("the release page", r.URL, func() {})
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)
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
func TestRecentScope(t *testing.T) { func TestRecentScope(t *testing.T) {
@@ -21,7 +22,7 @@ func TestRecentScope(t *testing.T) {
d.keys("R") d.keys("R")
var recent []string var recent []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
recent = append(recent, ansiStrip(o.label)) recent = append(recent, ansi.Strip(o.label))
} }
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") { if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
t.Fatalf("want newest first: %q", recent) t.Fatalf("want newest first: %q", recent)
@@ -31,7 +32,7 @@ func TestRecentScope(t *testing.T) {
t.Errorf("jumped to %q", s) t.Errorf("jumped to %q", s)
} }
d.keys("F") d.keys("F")
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") { if len(d.m.search.shown) != 1 || !strings.Contains(ansi.Strip(d.m.search.shown[0].label), "kv-shared-prod") {
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen()) t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
} }
} }
+2
View File
@@ -11,6 +11,7 @@ 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
Note string // has a note (lazysecrets list) Note string // has a note (lazysecrets list)
} }
@@ -25,6 +26,7 @@ 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
Note: "\U000F09ED", Note: "\U000F09ED",
} }
+5 -14
View File
@@ -15,8 +15,8 @@ package keys
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"sort"
"strings" "strings"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
@@ -266,11 +266,11 @@ func (m *Map) Apply(cfg map[string]map[string][]string) error {
byName[a.Context+"."+a.Name] = a byName[a.Context+"."+a.Name] = a
contexts[a.Context] = true contexts[a.Context] = true
} }
for _, ctx := range sortedKeys(cfg) { for _, ctx := range slices.Sorted(maps.Keys(cfg)) {
if !contexts[ctx] { if !contexts[ctx] {
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", ")) return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(slices.Sorted(maps.Keys(contexts)), ", "))
} }
for _, name := range sortedKeys(cfg[ctx]) { for _, name := range slices.Sorted(maps.Keys(cfg[ctx])) {
a, ok := byName[ctx+"."+name] a, ok := byName[ctx+"."+name]
if !ok { if !ok {
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", ")) return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
@@ -289,16 +289,7 @@ func (m *Map) names(ctx string) []string {
out = append(out, a.Name) out = append(out, a.Name)
} }
} }
sort.Strings(out) slices.Sort(out)
return out
}
func sortedKeys[V any](set map[string]V) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out return out
} }
+17
View File
@@ -69,3 +69,20 @@ 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
}
+27 -31
View File
@@ -60,16 +60,6 @@ func (d *Detail) ShowSecret(s provider.Object) {
d.container = &s.Container d.container = &s.Container
} }
// Refresh shows s's newer metadata if s is the secret shown, keeping
// its account and any value shown or loading.
func (d *Detail) Refresh(s provider.Object) {
if d.secret == nil || d.secret.ID != s.ID {
return
}
s.Container = d.secret.Container
d.secret = &s
}
// ShowContainer shows a container summary when no secret is selected. // ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) { func (d *Detail) ShowContainer(c provider.Container) {
d.Clear() d.Clear()
@@ -198,20 +188,31 @@ 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, prefix+wrapped[0]) out = append(out, fieldLabel(st, label)+wrapped[0])
for _, w := range wrapped[1:] { for _, w := range wrapped[1:] {
out = append(out, indent+w) out = append(out, valueIndent+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.")}
@@ -221,30 +222,27 @@ 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)
if d.note != "" { note()
field("note", st.Favourite.Render(d.note))
}
if c.Detail != "" { if c.Detail != "" {
field("", muted(c.Detail)) field("", muted(c.Detail))
} }
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) field("source", source)
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)
if d.note != "" { 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", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) field("source", source)
if s.Version != "" { if s.Version != "" {
field("version", s.Version) field("version", s.Version)
} }
@@ -308,14 +306,12 @@ 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, indent+hints(reveal)) out = append(out, valueIndent+hints(reveal))
} else { } else {
field(label, hints(reveal)) field(label, hints(reveal))
} }
@@ -332,9 +328,9 @@ 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, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w)) out = append(out, fieldLabel(st, label)+st.Error.Render(w))
} else { } else {
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w)) out = append(out, valueIndent+st.Error.Render(w))
} }
} }
} }
@@ -345,10 +341,10 @@ func (d Detail) lines(st styles.Styles) []string {
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, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w)) out = append(out, fieldLabel(st, label)+st.Value.Render(w))
first = false first = false
} else { } else {
out = append(out, indent+st.Value.Render(w)) out = append(out, valueIndent+st.Value.Render(w))
} }
} }
} }
@@ -362,7 +358,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, indent+hints("hide", more...)) out = append(out, valueIndent+hints("hide", more...))
} }
return append(out, d.versionLines(st)...) return append(out, d.versionLines(st)...)
} }
@@ -383,7 +379,7 @@ func (d Detail) versionLines(st styles.Styles) []string {
head += muted(" · " + d.VersionsKey + " to browse") head += muted(" · " + d.VersionsKey + " to browse")
} }
} }
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head} out := []string{"", fieldLabel(st, "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 {
-1
View File
@@ -9,7 +9,6 @@ import (
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
// sgr matches SGR escape sequences.
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m") var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
// Highlight draws a cursor row: line, padded to width, on style's // Highlight draws a cursor row: line, padded to width, on style's
+3 -35
View File
@@ -301,15 +301,6 @@ func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Rang
return out return out
} }
// RuneIndexes converts byte offsets in s to rune offsets.
func RuneIndexes(s string, bytes []int) []int {
out := make([]int, 0, len(bytes))
for _, b := range bytes {
out = append(out, utf8.RuneCountInString(s[:b]))
}
return out
}
// highlight returns the rune indexes to highlight in a row's plain text: // highlight returns the rune indexes to highlight in a row's plain text:
// the filter hits, shifted to where the Filter text appears in the row. // the filter hits, shifted to where the Filter text appears in the row.
// It returns nil if the row doesn't contain the Filter text verbatim. // It returns nil if the row doesn't contain the Filter text verbatim.
@@ -338,17 +329,7 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
ts := TitleStyle(st, focused) ts := TitleStyle(st, focused)
title := ts.Render(l.Title) title := ts.Render(l.Title)
if len(l.Tabs) > 1 { if len(l.Tabs) > 1 {
for i, tab := range l.Tabs { title += ts.Render(" ") + Tabs(st, ts, l.Tabs, l.Tab)
sep := " - "
if i == 0 {
sep = " "
}
if i == l.Tab {
title += ts.Render(sep) + ts.Underline(true).Render(tab)
} else {
title += ts.Render(sep) + st.Muted.Render(tab)
}
}
} }
if q := l.input.Value(); q != "" && !l.filtering { if q := l.input.Value(); q != "" && !l.filtering {
title += ts.Render(" /" + q) title += ts.Render(" /" + q)
@@ -379,26 +360,13 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
default: default:
for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ { for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ {
line := l.item.Render(st, l.items[l.visible[i]], inner-1) line := l.item.Render(st, l.items[l.visible[i]], inner-1)
plain := ansi.Strip(line) line = " " + MarkMatches(line, l.highlight(i, ansi.Strip(line)), st.Match)
hits := l.highlight(i, plain)
if i == l.cursor { if i == l.cursor {
base := st.SelectedBlurred base := st.SelectedBlurred
if focused { if focused {
base = st.Selected base = st.Selected
} }
text := " " + line line = Highlight(ansi.Truncate(line, inner, "…"), base, inner)
if len(hits) > 0 {
for j := range hits {
hits[j]++ // leading space
}
text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...)
}
line = Highlight(ansi.Truncate(text, inner, "…"), base, inner)
} else if len(hits) > 0 {
// Style just the matches, keeping the row's own colours.
line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...)
} else {
line = " " + line
} }
body = append(body, line) body = append(body, line)
} }
+2
View File
@@ -34,6 +34,7 @@ type Styles struct {
KindAzure lipgloss.Style KindAzure lipgloss.Style
KindDatabricks lipgloss.Style KindDatabricks lipgloss.Style
KindRepo lipgloss.Style KindRepo lipgloss.Style
Spring lipgloss.Style // Spring Boot config files
// themed marks the selection colours as set by the theme, so // themed marks the selection colours as set by the theme, so
// OnBackground leaves them. // OnBackground leaves them.
@@ -70,6 +71,7 @@ func Default() Styles {
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue), KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red), KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta), KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
Spring: lipgloss.NewStyle().Foreground(lipgloss.Green),
} }
} }
+5 -8
View File
@@ -3,8 +3,8 @@ package styles
import ( import (
"fmt" "fmt"
"image/color" "image/color"
"maps"
"slices" "slices"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -42,6 +42,7 @@ var Roles = map[string]string{
"azureColor": "Azure sources and vaults", "azureColor": "Azure sources and vaults",
"databricksColor": "Databricks workspaces and scopes", "databricksColor": "Databricks workspaces and scopes",
"repoColor": "repos and their secret files (SOPS)", "repoColor": "repos and their secret files (SOPS)",
"springColor": "Spring Boot config files",
} }
// FromTheme returns the default styles with the theme's roles applied, and // FromTheme returns the default styles with the theme's roles applied, and
@@ -51,12 +52,7 @@ var Roles = map[string]string{
func FromTheme(theme map[string][]string) (Styles, color.Color, error) { func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default() st := Default()
var fg color.Color var fg color.Color
roles := make([]string, 0, len(theme)) for _, role := range slices.Sorted(maps.Keys(theme)) {
for r := range theme {
roles = append(roles, r)
}
sort.Strings(roles)
for _, role := range roles {
if _, ok := Roles[role]; !ok { if _, ok := Roles[role]; !ok {
continue continue
} }
@@ -104,6 +100,8 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st.KindDatabricks = v.fg(lipgloss.NewStyle()) st.KindDatabricks = v.fg(lipgloss.NewStyle())
case "repoColor": case "repoColor":
st.KindRepo = v.fg(lipgloss.NewStyle()) st.KindRepo = v.fg(lipgloss.NewStyle())
case "springColor":
st.Spring = v.fg(lipgloss.NewStyle())
} }
} }
return st, fg, nil return st, fg, nil
@@ -131,7 +129,6 @@ type value struct {
bold, italic, underline, reverse, faint bool bold, italic, underline, reverse, faint bool
} }
// fg styles s with the value as foreground, bg as background.
func (v value) fg(s lipgloss.Style) lipgloss.Style { func (v value) fg(s lipgloss.Style) lipgloss.Style {
if v.color != nil { if v.color != nil {
s = s.Foreground(v.color) s = s.Foreground(v.color)
+3 -5
View File
@@ -89,14 +89,9 @@ func (r Release) Notes(current string) string {
return PlainLinks(strings.TrimSpace(notes)) return PlainLinks(strings.TrimSpace(notes))
} }
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
// PlainLinks shows Markdown links as their text, for a terminal. // PlainLinks shows Markdown links as their text, for a terminal.
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") } func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
// and a terminal shows them as their text.
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`) var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
// Client talks to the release servers. // Client talks to the release servers.
@@ -148,6 +143,9 @@ func (c *Client) Latest(ctx context.Context) (Release, error) {
return Release{}, errors.Join(errs...) return Release{}, errors.Join(errs...)
} }
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
func (c *Client) latest(ctx context.Context, repo string) (Release, error) { func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
api, err := API(repo) api, err := API(repo)
if err != nil { if err != nil {