1 Commits
Author SHA1 Message Date
keb 386af66565 refactor: tidy the command's setup, the update command and instance reach
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 2s
- 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.
2026-10-15 08:51:56 +02:00
92 changed files with 1070 additions and 2801 deletions

No files matched your search

+28 -8
View File
@@ -1,13 +1,33 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Build output
/lazysecrets
# Release builds (GoReleaser, see .goreleaser.yaml)
/dist/
# go test -c, coverage profiles
*.test
*.out
go.work
go.work.sum
.env
-22
View File
@@ -2,28 +2,6 @@
Every release of lazysecrets, newest first.
## v0.3.0 – 2026-10-15
### Added
- Spring Boot config files in repos ([#79](https://git.kebcloud.no/keb/lazysecrets/pulls/79)) ([96395cb](https://git.kebcloud.no/keb/lazysecrets/commit/96395cb6a27a94293e35401790296a894821d075))
- Decrypt Jasypt values in .env and Spring files ([#80](https://git.kebcloud.no/keb/lazysecrets/pulls/80)) ([17da9ea](https://git.kebcloud.no/keb/lazysecrets/commit/17da9eaa3271dfa315a517ad0092ab5cda0b0597))
- H shows what's hidden; Spring keys that don't look secret are hidden ([#81](https://git.kebcloud.no/keb/lazysecrets/pulls/81)) ([913b3db](https://git.kebcloud.no/keb/lazysecrets/commit/913b3db6a938d21c6d8c9285a3111418495c7345))
- Secret patterns for all of a repo's config files, with per-type overrides ([#82](https://git.kebcloud.no/keb/lazysecrets/pulls/82)) ([4bac06a](https://git.kebcloud.no/keb/lazysecrets/commit/4bac06ae5ba6fcb8c96d46198f01235391e29f1a))
- Example env files are listed hidden, not skipped ([#83](https://git.kebcloud.no/keb/lazysecrets/pulls/83)) ([580c7ef](https://git.kebcloud.no/keb/lazysecrets/commit/580c7ef985599b98277d80be38e39abe169b2dc4))
- Docker Compose files in repos ([#84](https://git.kebcloud.no/keb/lazysecrets/pulls/84)) ([0f5248d](https://git.kebcloud.no/keb/lazysecrets/commit/0f5248d8f6d27e3f95f8b9cbc28310ee7b0581f7))
- Go to shows a repo file's icon before its name ([#85](https://git.kebcloud.no/keb/lazysecrets/pulls/85)) ([e2f289d](https://git.kebcloud.no/keb/lazysecrets/commit/e2f289da5c54dab869e7ba10ca35bdc1ab74bf28))
### Fixed
- Decrypt SOPS .env keys written with export or spaces ([#78](https://git.kebcloud.no/keb/lazysecrets/pulls/78)) ([39cbac9](https://git.kebcloud.no/keb/lazysecrets/commit/39cbac9aabcea11e9915363e4adae5873f9724de))
## v0.2.1 – 2026-10-15
### Fixed
- A link opens lazysecrets with your cache and next to your running instances ([#71](https://git.kebcloud.no/keb/lazysecrets/pulls/71)) ([bb41767](https://git.kebcloud.no/keb/lazysecrets/commit/bb41767cbad00bab4f1824f4e0754cc7abd98c59))
## v0.2.0 – 2026-10-14
### Added
+23 -11
View File
@@ -7,23 +7,24 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
That's by design and may stay that way.
- **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor
pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
for Neovim.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pipes.
- **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane.
- **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env`, Spring Boot and Docker Compose files in your git repos.
- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
@@ -44,8 +45,9 @@ Both approaches will install the latest release from [GitHub](https://github.com
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else.
`U` (or `lazysecrets update`) updates in place when there's a newer
release.
lazysecrets looks for a newer release once a day: the footer then shows
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
`lazysecrets update`).
Or build it yourself:
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
@@ -68,26 +70,36 @@ use most:
| `l` / `h` | the details of what's selected / back |
| `/` | filter the list you're in |
| `space` | reveal / hide the value |
| `y` | copy the value |
| `y` | copy the value (cleared from the clipboard after 30s) |
| `ctrl+s` | Go to: search everything and jump there |
| `f` / `F` | star / your favourites |
| `s` | copy a link to share |
| `?` | every key, searchable |
| `q` | quit |
## Integration
`lazysecrets list`, `get` and `open` plug lazysecrets into fzf, editor
pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
## Integration
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
Here is an example of implementing a picker in `fzf`:
```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](docs/assets/fzf-example.png)
More defails in the [Integration](docs/integration.md) docs
## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
- [SOPS, .env, Spring and compose files](docs/repos.md): which repos and files, decrypting
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
opening links, also from the browser and chat
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
+10 -4
View File
@@ -18,7 +18,7 @@ import (
// It ends with a newline only when printing to a terminal, so command
// substitution and pipes get the value exactly.
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
v, _, err := readKey(ctx, providers, st, r)
v, err := value(ctx, providers, st, r)
if err != nil {
return err
}
@@ -30,9 +30,15 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err
}
// readKey reads what r points at (ref.Read), and the version read. The
// account asked for (--account) goes first, else the one that last read
// it.
// value reads what r points at (ref.Read), with the account that last
// read it first.
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
v, _, err := readKey(ctx, providers, st, r)
return v, err
}
// readKey is value, also returning the version read. 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) {
key := r
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",
} {
r, _ := ref.Parse(link)
v, _, err := readKey(context.Background(), providers, st, r)
v, err := value(context.Background(), providers, st, r)
if err != nil || v.Reveal() != want {
t.Errorf("%s: %q, %v", link, v.Reveal(), err)
}
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
"{{secrets/app/storage-key}}": "names no workspace",
} {
r, _ := ref.Parse(link)
if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Errorf("%s: %v, want %q", link, err, wantErr)
}
}
+1 -2
View File
@@ -57,7 +57,6 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: Spring and compose keys that don't look secret, example files, and files and repos with only those (json: \"hidden\")")
if err := fs.Parse(args); err != nil {
return err
}
@@ -130,7 +129,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
f = cachefile.Read(cachePath)
}
f, loaded := listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, Hidden: *hidden,
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
Mark: func(link, account string) listing.Marks {
n := st.Node(link)
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
+9 -4
View File
@@ -68,20 +68,27 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
if c.Key == "" {
continue
}
name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil {
return err
}
for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
}
}
}
return nil
}
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column) and,
// for open, what to do with it rather than asking (--action).
@@ -143,7 +150,6 @@ func run() error {
for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false]
[--stars=false] [--notes=false] [--recent] [--expiry=false]
print every source, vault and secret it can
find, one per line, for fzf and pickers:
what's cached at once, then what loads
@@ -347,7 +353,6 @@ Flags:
repos := repo.New(repo.Settings{
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
SecretPatterns: pc.Repos.SecretPatterns, Types: map[string]repo.FileType{"spring": pc.Repos.Spring, "compose": pc.Repos.Compose},
})
// The repo it started in (shown on top as Repo) and the
// 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
var version string
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
// maybe +dirty): its commit.
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
func versionString() string {
if version != "" {
return version
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 258 KiB

+4 -3
View File
@@ -16,7 +16,7 @@ A service principal is used instead when `AZURE_CLIENT_ID`,
On an account's group row, the fourth pane shows who it signs in as: name,
user, tenant, object ID, whether the session used MFA, directory roles
(built-in ones by name) and its subscriptions, read from az's account list
and the token lazysecrets already has. `y` on
and the token lazysecrets already has: no requests of their own. `y` on
the row copies the object ID, the one access policies and role
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
field, `/` filters them (`/tenant`) and `y` copies the selected one.
@@ -67,8 +67,9 @@ fetched. `v` lists an item's versions. Items expiring within
The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
on one goes to it. Expiry dates come with the lists. It says how many
vaults it covers; `ctrl+a` in search loads the rest.
on one goes to it. That makes no requests of its own: expiry dates come
with the lists. It says how many vaults it covers; `ctrl+a` in search
loads the rest.
On a vault, the right pane shows its settings: resource group, location,
SKU, whether access is by Azure roles or access policies, soft delete and
+38 -23
View File
@@ -1,11 +1,10 @@
# Config
Optional, at `~/.config/lazysecrets/config.yaml` (or
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags (noted
below) override it. Unknown keys are an error, so typos don't go
unnoticed. `e` on the status pane (`0`) opens it in your editor, as in
lazygit (making it if it isn't there yet); changes apply when lazysecrets
starts again.
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the
status pane (`0`) opens it in your editor, as in lazygit (making it if
it isn't there yet); changes apply when lazysecrets starts again.
```yaml
ui:
@@ -18,7 +17,7 @@ ui:
values:
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables; also --clipboard-clear
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
storage:
cache: true # keep what was loaded (never values) for the next start
history: true # count uses of items for Recent (R)
@@ -29,15 +28,32 @@ updates:
- https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets
providers:
timeout: 30s # per backend request; a provider's own timeout overrides it; also --timeout
azure: # likewise databricks and repos; azure covers every az account
enabled: true # default
timeout: 30s # per backend request; a provider's own timeout overrides it
azure: # applies to every az account
enabled: true # default
timeout: 30s
hideSubscriptions: [platform-mgmt] # by name or ID
vaults: # listed under "Pinned vaults", for vaults
- https://kv-foo.vault.azure.net/ # you can read but not discover
databricks:
enabled: true
timeout: 30s
hideProfiles: [test]
repos:
enabled: true
timeout: 30s
roots: [~/git]
paths: [~/work/infra]
include: ["secrets/**"]
exclude: ["**/testdata/**"]
sharing: # sharing env files as encrypted links
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
keyRules: # what a key's value must be
minLength: 32 # characters, 16 at least
minClasses: 2 # kinds: lowercase, uppercase, digits, symbols
```
More, each on its own page: [Azure](azure.md#vaults-you-cant-discover)
(`providers.azure`), [Databricks](databricks.md) (`providers.databricks`),
[repos](repos.md#which-files) (`providers.repos`),
More, each on its own page: [repos](repos.md) (`providers.repos`),
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
@@ -74,8 +90,6 @@ theme:
azureColor: ["#82aaff"]
databricksColor: ["#ff757f"]
repoColor: ["#c099ff"]
springColor: ["#c3e88d"] # Spring Boot config files
composeColor: ["#82aaff"] # Docker Compose files
```
A role lazysecrets doesn't know, a typo or one from a newer version, is
@@ -88,8 +102,9 @@ as boxes, turn them off with `ui.icons: false`.
Several config files can be layered, later ones winning (theme roles and
keybindings merge one by one), with a comma-separated `--config` or
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your
config plus an editor-generated theme:
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor
plugin can so add a generated theme file to your own config, the way
snacks.nvim themes lazygit from Neovim's highlight groups:
```sh
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
@@ -102,10 +117,11 @@ Stars, notes, usage and the layout are kept in
by you: one record per source, vault or secret, keyed by its
`lazysecrets://` link, never with values. A star belongs to the account you
starred the item under (a vault two of your accounts can see can be starred
for one); a note describes the item, so it shows under every account.
Several lazysecrets windows can save to it without losing each other's
changes. One written by a newer lazysecrets is read but not saved to,
with a warning, until you update.
for one); a note describes the item, so it shows under every account. It's
JSON so an editor plugin can read it with a built-in parser; several
lazysecrets windows can save to it without losing each other's changes.
One written by a newer lazysecrets is read but not saved to, with a
warning, until you update.
Revealing, copying, opening in the browser, sharing and jumping to an item
count as using it: its record keeps when it was last used and how often,
@@ -141,9 +157,8 @@ until then it reuses the answer from the state file. Releases are
published on GitHub and on git.kebcloud.no, and it asks them in that
order, the next when one doesn't answer within 5 seconds;
`updates.sources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the status
pane, `↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what
changed since your version, across the last 10 releases.
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against
+7 -9
View File
@@ -12,7 +12,7 @@ repo, pick `kv-shared-prod / env-share` (the second account's) as the
key, then paste the link back. The demo repos aren't on disk, so opening
asks where to save, as for a repo you haven't cloned.
A stripped binary is a third smaller:
A stripped binary is about 21 MB instead of 30 MB:
```sh
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
@@ -35,8 +35,10 @@ subject, checked by `pr-title.yml`:
| `fix` | a bug fix | Fixed |
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no |
`feat!:` marks a breaking change, `feat(scope):` names a part.
`[skip changelog]` in the description leaves a pull request out.
`feat!:` marks a breaking change, `feat(scope):` names a part. Brackets
and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all
become `feat: x` in the changelog. `[skip changelog]` in the description
leaves a pull request out.
CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each
platform on pull requests into `main`. `main` is protected: a pull request
@@ -49,14 +51,10 @@ what its pull request tested, and CI doesn't run again there.
Merge as many pull requests as you like, then release `main`:
```sh
scripts/release.sh --dry-run minor # the tag and notes the release would have
scripts/release.sh minor # CHANGELOG.md, commit, tag, push
scripts/release.sh --dry-run v0.2.0 # the notes the release would have
scripts/release.sh v0.2.0 # CHANGELOG.md, commit, tag, push
```
`patch`, `minor` and `major` bump the latest release (`v0.2.1` → `v0.2.2`,
`v0.3.0`, `v1.0.0`; pre-releases don't count); a tag (`v0.3.0`,
`v0.3.0-rc.1`) is taken as it is.
It refuses unless you're on a clean `main` that matches `origin/main`.
[git-cliff](https://git-cliff.org) (`cliff.toml`) writes `CHANGELOG.md`
from the pull request titles. The tag runs the release workflow
+6 -7
View File
@@ -34,7 +34,7 @@ tab-separated:
| name | `platform-prod / kv-app-prod / db-password` |
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
| account | what it's listed through: `Azure` (`Azure:<user>/<tenant>` with several az accounts), `Databricks` or `Repos` |
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
| link | its `lazysecrets://` link |
What's cached prints at once, then what loads follows as it arrives: read
@@ -47,11 +47,10 @@ so `^prod` finds names starting with prod.
| Flag | |
|---|---|
| `--format json` | an object per line instead, for editor plugins (below) |
| `--kind secret,vault` | only these kinds: `source`, `vault` (also scopes and repo files), `secret`, `key`, `certificate`; lists they don't need aren't loaded |
| `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded |
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read |
| `--hidden` | list what's [hidden](repos.md#spring-boot-config) too, as `H` shows it (json: `"hidden": true`) |
| `--color never` | no colours, for scripts |
| `--icons=false` | no provider icons |
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
@@ -78,11 +77,12 @@ may be added:
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
[`lazysecrets open --action go --account <account> <link>`](sharing.md#opening-a-link)
opens one in lazysecrets without asking what to do or which account.
`lazysecrets open --action go --account <account> <link>` opens one in
lazysecrets without asking what to do or which account, ending in the
secrets list, like Go to.
### An fzf picker
### Example usage with fzf
```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
@@ -90,6 +90,5 @@ 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-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](assets/fzf-example.png)
+20 -18
View File
@@ -20,7 +20,6 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
| `H` | show / hide what's hidden: [Spring and compose keys](repos.md#spring-boot-config) that don't look secret, example files, and files and repos with only those; shown dimmed |
| `r` | refresh the focused pane |
| `q`, `ctrl+c` | quit |
@@ -50,11 +49,11 @@ match case exactly.
| Key | Action |
|---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) |
| `y` | copy the value without revealing it. On an Azure account's row: its object ID; in its pane, the selected field |
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) |
| `e` | open the selected SOPS, `.env`, Spring or compose file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
| `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) |
@@ -64,8 +63,10 @@ match case exactly.
A revealed value is shown as it reads best: JSON indented, and PEM
certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored. It hides again after a while, and when the terminal
loses focus ([config](config.md)).
it exactly as stored.
A revealed value hides again after `values.revealTimeout` (60s) and
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
## Stars and notes
@@ -74,7 +75,8 @@ loses focus ([config](config.md)).
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
| `n` | write a short note on it, shown dimmed on its row and in the detail pane |
Both are kept in the [state file](config.md#state).
A star belongs to the account you starred the item under; a note shows
under every account. Both are kept in the [state file](config.md#state).
## Go to
@@ -122,16 +124,15 @@ it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how
your accounts are doing; `e` there opens your [config](config.md) in your
editor, as in lazygit. Focused, the right pane lists more under the
logo; `y` copies any row:
- the version and updates (`U`)
- each account (`L` on one that needs it signs in)
- what's expiring (`enter` goes to it)
- this session's errors, newest first (not kept after quitting)
- the config, state and cache files (`e` opens one in your editor)
- links to the docs, source, issues, releases and changelog (`o` opens one)
- the changelog of every release up to yours, built in: `/` filters it
(`/fixed`, `/search`)
logo (when there's room): the version and updates (`U`), each account
(`L` on one that needs it signs in), what's expiring (`enter` goes to
it), this session's errors, newest first (what a pane showed, or the
footer flashed before you moved on; not kept after quitting), the config,
state and cache files (`e` opens one in your editor), and links to the
docs, source, issues, releases and changelog (`o` opens one; on the
version or update, its release), then the changelog of every release up
to yours, built in so it needs no network: `/` filters it (`/fixed`,
`/search`). `y` copies any row.
| Key | Action |
|---|---|
@@ -140,8 +141,9 @@ logo; `y` copies any row:
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
| `=` | reset the layout (divider, heights, zoom) |
The divider and pane heights are remembered (zoom isn't). `<>{}` are for
macOS, where ctrl+arrows switch desktops.
The divider and pane heights are remembered (zoom isn't). macOS uses
ctrl+arrows for Mission Control and switching desktops by default, hence
the `<` `>` `{` `}` alternatives.
## Rebinding keys
+21 -100
View File
@@ -1,9 +1,9 @@
# SOPS, .env, Spring and compose files in git repos
# SOPS and .env files in git repos
lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI), plain env files, [Spring Boot config](#spring-boot-config)
and [Docker Compose files](#docker-compose) as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
`.env`, INI) and plain env files as vaults, and the keys in them
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
escaped, `app\.name`, so it doesn't read as nesting.
## Which repos
@@ -29,9 +29,8 @@ anything by hand.
## Which files
**Env files**: `.env`, `.env.local`, `prod.env` and the like, also when
git-ignored (as they usually are), in the folders git tracks files in.
`.env.example`, `.sample`, `.template` and the like are listed hidden
(`H` shows them): someone may have put a real value in one. They're read
git-ignored (as they usually are), in the folders git tracks files in;
not `.env.example`, `.sample`, `.template` and the like. They're read
directly; a file with SOPS metadata is always treated as SOPS, whatever
its name.
@@ -47,7 +46,7 @@ Folders of dependencies and caches are never looked in, tracked or not:
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
4. the whole repo.
`include`/`exclude` apply to env, Spring and compose files too.
`include`/`exclude` apply to env files too.
```yaml
providers:
@@ -66,81 +65,13 @@ sharing:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
```
## Spring Boot config
`application.yml`, `application-prod.properties`, `bootstrap.yaml` and the
like, tracked by git (not those in a Helm chart's `templates/`). Most of
their keys aren't secret, so only those that look like one are listed; the
rest are hidden:
- **keys** named like a secret: ending in `password`, `pwd`, `secret`,
`token`, `key`, `credentials`, `salt`, `connection-string` and the
like (`spring.datasource.password`, `client-secret`, `checkoutKey`),
- **values** that look like one: Jasypt's `ENC(...)`, a URL with a
password (`jdbc:postgresql://app:pw@db/app`), or a connection string
with `AccountKey=`, `SharedAccessKey=` or `Password=`.
A file with none is hidden too, and a repo with only such files. `H` shows
what's hidden, dimmed, in the panes and in Go to (`lazysecrets list
--hidden` lists it); a pane's counter says how many it hides. A file's
details count its hidden keys, and `e` opens it whole. A `${DB_PASSWORD}`
value is marked as a reference: the secret is set elsewhere. A tracked
file is marked in red only when a listed key has its value in plain text.
The patterns are regexps, for all of a repo's config files and per type of
file; each list replaces the one it falls back to, and `keys: [".*"]`
lists every key:
```yaml
providers:
repos:
secretPatterns: # all config files
keys: ["(?i)(password|secret|token|key)$"] # matched against the key's name
values: ["^ENC\\(.+\\)$"] # and against its value
spring:
secretPatterns: # Spring's: only what differs
keys: ["(?i)(password|secret)$", "bearer$"]
compose:
secretPatterns: # and compose's
values: []
```
A repo's `.lazysecrets.yaml` can set them too, the same way
(`secretPatterns:`, `spring: secretPatterns:`, `compose:
secretPatterns:`), for everyone. Each list
comes from the first that sets it: the repo's for the type, the repo's,
the config's for the type, the config's, the defaults.
Keys are named as Spring names them, `spring.datasource.password`; in a
file with several documents (`---`, or `#---` in `.properties`), a later
document's keys get its profile, `spring.datasource.password (prod)`.
Values Maven fills in at build time (`@project.version@`) are read as
they are.
## Docker Compose
`compose.yaml`, `docker-compose.yml` and their variants
(`docker-compose.test.yml`), tracked by git; `docker-compose.example.yml`
and the like are hidden. Their secrets are the services' `environment`
variables, as a map (`DB_PASSWORD: x`) or a list (`- DB_PASSWORD=x`),
named `service.VAR` (`api.DB_PASSWORD`); a `<<: *anchor` shared between
services is read too. The same [secret patterns](#spring-boot-config)
pick them, the rest hidden, with `compose: secretPatterns:` for compose
files' own.
`${DB_PASSWORD}`, `$DB_PASSWORD` and `${DB_PASSWORD:?…}`, and a variable
without a value (`DB_PASSWORD:`, `- DB_PASSWORD`), taken from where
compose runs, are marked as references; `${DB_PASSWORD:-x}` has its
default in the file.
## Decrypting
Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and
metadata (encryption, last modified) are read straight from the files.
Revealing, copying or `lazysecrets get` decrypts one value; SOPS's with
your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault
through az, AWS/GCP KMS, PGP). When sops can't decrypt, its whole error
message is shown.
Listing never decrypts: SOPS encrypts values only, so keys and metadata
(encryption, last modified) are read straight from the files. Revealing,
copying or `lazysecrets get` decrypts one value with your `sops` binary, so
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
KMS, PGP). When sops can't decrypt, its whole error message is shown.
On a file, the right pane shows how it's encrypted and the keys it's
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
@@ -149,24 +80,6 @@ tracked, ignored or neither, and its last commit. A plain `.env` file that
git tracks, or that isn't ignored, is marked in red: its values are in the
repo's history, or easily could be.
### Jasypt
A value like `ENC(...)` in a plain `.env`, Spring or compose file is
[Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied
with jasypt-spring-boot's default algorithm (3.0+:
`PBEWITHHMACSHA512ANDAES_256`). The password is, as in Spring:
1. `$JASYPT_ENCRYPTOR_PASSWORD`,
2. the file's own: `jasypt.encryptor.password` in a Spring file, the
service's `JASYPT_ENCRYPTOR_PASSWORD` in a compose file,
`JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` in it (with a
default: `${VAR:default}` in Spring, `${VAR:-default}` elsewhere) is
read from the environment.
Environment variables are lazysecrets' own: a link opened from the browser
starts it without your shell's. A `.env` whose values are all Jasypt's
isn't marked in red.
## Opening files
`e` opens the selected file, or on a secret, its file at that key's line:
@@ -187,5 +100,13 @@ sops 3.8 or later.
## Links
Repo links name the repo by its git remote, so they open in a colleague's
clone wherever it is ([sharing](sharing.md)).
`S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files).
Links name the repo by its git remote, so they open in a colleague's clone
wherever it is:
```
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
See [sharing](sharing.md).
+5 -9
View File
@@ -7,13 +7,10 @@ permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself.
```
lazysecrets://azure/subscriptions/<subscription-id> subscription
lazysecrets://azure/kv-app-prod.vault.azure.net vault
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net workspace
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app scope
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
@@ -105,8 +102,8 @@ it now and then: new links use the newest version.
## Opening a link
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
and fills in any reference it finds there. Key Vault URLs, Azure portal
links, `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
and fills in any reference it finds there. Key Vault URLs,
`@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
set up.
@@ -146,10 +143,9 @@ turns the question off too.
link then opens a new window of the terminal you ran it in, with
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
tmux; `--terminal NAME` picks another. It records your `PATH`, config
locations and XDG directories, since apps start without your shell's
environment (and so without `az`): run it again after moving lazysecrets
or changing those.
tmux; `--terminal NAME` picks another. It records your `PATH` and config
locations, since apps start without your shell's environment (and so
without `az`): run it again after moving lazysecrets or changing those.
- macOS: a small background app, `~/Applications/Lazysecrets Links.app`
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
+3
View File
@@ -42,6 +42,9 @@ func New() Clipboard {
}
}
// Native reports whether the system clipboard is used rather than OSC52.
func (c Clipboard) Native() bool { return c.native }
// Write puts s on the clipboard.
func (c Clipboard) Write(s string) tea.Cmd {
if !c.native {
+4 -2
View File
@@ -16,7 +16,6 @@
package commands
import (
"cmp"
"fmt"
"regexp"
"strings"
@@ -61,7 +60,10 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config.
func (c Command) Validate() error {
name := cmp.Or(c.Description, c.Command)
name := c.Description
if name == "" {
name = c.Command
}
switch {
case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description)
+11 -17
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"strings"
@@ -15,7 +14,6 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
@@ -49,8 +47,7 @@ type UI struct {
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
// and in lazysecrets list. Defaults to false.
RecentIcon *bool `yaml:"recentIcon"`
// SourcesView is "grouped" (default) or "flat"; the backtick key
// toggles it.
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
@@ -154,11 +151,6 @@ type Repos struct {
// a repo's .sops.yaml creation_rules say where to look.
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
// SecretPatterns say which keys of config files are secrets; Spring
// and Compose override them for their files, list by list.
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
Spring repo.FileType `yaml:"spring"`
Compose repo.FileType `yaml:"compose"`
}
type Azure struct {
@@ -211,12 +203,17 @@ func xdgDir(env, fallback string) (string, error) {
return filepath.Join(base, "lazysecrets"), nil
}
// Load reads path. A missing file is an empty config. Unknown keys are an
// error, so typos don't silently do nothing.
func Load(path string) (Config, error) {
return LoadAll([]string{path})
}
// LoadAll reads several config files in order, each on top of the ones
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
// settings it sets, and theme roles merge one by one. That lets an editor
// plugin add a generated theme file to the user's own config. Missing
// files are skipped. Unknown keys are an error, so typos don't silently do
// nothing.
// files are skipped.
func LoadAll(paths []string) (Config, error) {
var c Config
keybinding := map[string]map[string][]string{}
@@ -231,7 +228,9 @@ func LoadAll(paths []string) (Config, error) {
if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{}
}
maps.Copy(keybinding[ctx], actions)
for name, keys := range actions {
keybinding[ctx][name] = keys
}
}
}
c.Keybinding = keybinding
@@ -298,11 +297,6 @@ func (c Config) validate(path string) error {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
}
}
if err := errors.Join(c.Providers.Repos.SecretPatterns.Check("providers.repos.secretPatterns"),
c.Providers.Repos.Spring.SecretPatterns.Check("providers.repos.spring.secretPatterns"),
c.Providers.Repos.Compose.SecretPatterns.Check("providers.repos.compose.secretPatterns")); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
+7 -9
View File
@@ -18,7 +18,7 @@ func write(t *testing.T, content string) string {
}
func TestLoad(t *testing.T) {
c, err := LoadAll([]string{write(t, `
c, err := Load(write(t, `
ui:
icons: false
sourcesView: flat
@@ -36,7 +36,7 @@ providers:
databricks:
enabled: false
hideProfiles: [test]
`)})
`))
if err != nil {
t.Fatal(err)
}
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""),
} {
c, err := LoadAll([]string{path})
c, err := Load(path)
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err)
}
@@ -84,10 +84,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
"regexp": "providers:\n repos:\n secretPatterns:\n keys: [\"(pass\"]\n",
"spring re": "providers:\n repos:\n spring:\n secretPatterns:\n values: [\"(\"]\n",
} {
if _, err := LoadAll([]string{write(t, content)}); err == nil {
if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err)
@@ -134,18 +132,18 @@ theme:
}
func TestUpdates(t *testing.T) {
c, err := LoadAll([]string{write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")})
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n"))
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
t.Errorf("updates = %+v, %v", c.Updates, err)
}
}
func TestLoadDelay(t *testing.T) {
c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
}
if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted")
}
}
+2 -1
View File
@@ -9,6 +9,7 @@
package instance
import (
"bufio"
"context"
"encoding/json"
"errors"
@@ -116,7 +117,7 @@ func (s *Server) handle(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request
if err := json.NewDecoder(c).Decode(&req); err != nil {
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
return
}
var resp response
-67
View File
@@ -1,67 +0,0 @@
// Package jasypt decrypts values encrypted with Jasypt the way
// jasypt-spring-boot 3.0 and later does by default: ENC(...) around the
// base64 of a random salt, a random IV and the ciphertext, with
// PBEWITHHMACSHA512ANDAES_256 (PBKDF2-HMAC-SHA512, 1000 iterations,
// AES-256-CBC).
package jasypt
import (
"crypto/aes"
"crypto/cipher"
"crypto/pbkdf2"
"crypto/sha512"
"encoding/base64"
"errors"
"strings"
"unicode/utf8"
)
const iterations = 1000
// ErrDecrypt is a value that doesn't decrypt with the password: a wrong
// password, or one encrypted with another algorithm.
var ErrDecrypt = errors.New("doesn't decrypt: a wrong password, or not Jasypt's default algorithm (PBEWITHHMACSHA512ANDAES_256)")
// Is reports whether v is a Jasypt value, ENC(...).
func Is(v string) bool {
return len(v) > len("ENC()") && strings.HasPrefix(v, "ENC(") && strings.HasSuffix(v, ")")
}
// Decrypt decrypts an ENC(...) value.
func Decrypt(v, password string) (string, error) {
b, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(v, "ENC("), ")"))
if err != nil {
return "", errors.New("not Jasypt: ENC(...) isn't base64")
}
const size = aes.BlockSize // the salt's and the IV's too
if len(b) < 3*size || len(b)%size != 0 {
return "", ErrDecrypt
}
salt, iv, data := b[:size], b[size:2*size], b[2*size:]
key, err := pbkdf2.Key(sha512.New, password, salt, iterations, 32)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plain := make([]byte, len(data))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data)
// A wrong password decrypts to noise: its padding is almost never
// valid, and the rest rarely UTF-8.
n := int(plain[len(plain)-1])
if n == 0 || n > size {
return "", ErrDecrypt
}
for _, c := range plain[len(plain)-n:] {
if int(c) != n {
return "", ErrDecrypt
}
}
plain = plain[:len(plain)-n]
if !utf8.Valid(plain) {
return "", ErrDecrypt
}
return string(plain), nil
}
-37
View File
@@ -1,37 +0,0 @@
package jasypt
import (
"errors"
"testing"
)
// Encrypted by Jasypt 1.9.3 itself, set up as jasypt-spring-boot's default
// encryptor, with the password "test-password".
var vectors = map[string]string{
"ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)": "hunter2",
"ENC(w4pm2JVZcbG5R+Ud9uKHg8VhJ/0aAK7sFXtNBv+Vo450SAchTQtfhw0TnIJ9gN+D0VFbwqJzfXU369coTJz2rw==)": "blåbærsyltetøy ✓",
}
func TestDecrypt(t *testing.T) {
for enc, want := range vectors {
if !Is(enc) {
t.Errorf("%s: not Jasypt", enc)
}
if got, err := Decrypt(enc, "test-password"); err != nil || got != want {
t.Errorf("%s: %q %v, want %q", enc, got, err, want)
}
if _, err := Decrypt(enc, "wrong"); !errors.Is(err, ErrDecrypt) {
t.Errorf("%s with a wrong password: %v", enc, err)
}
}
for _, v := range []string{"ENC(not base64!)", "ENC(aGVsbG8=)"} {
if _, err := Decrypt(v, "test-password"); err == nil {
t.Errorf("%s: no error", v)
}
}
for _, v := range []string{"ENC()", "hunter2", "ENC[AES256_GCM,data:x]", "${DB_PASSWORD}"} {
if Is(v) {
t.Errorf("%s: Jasypt", v)
}
}
}
+7 -3
View File
@@ -114,15 +114,19 @@ func Check() string {
}
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
if err != nil {
return "" // can't tell: assume it works
return "" // installed before the key was recorded: assume it works
}
exe := strings.TrimSpace(string(out))
if _, err := os.Stat(exe); err != nil {
if exe := strings.TrimSpace(string(out)); !exists(exe) {
return "lazysecrets:// links run " + exe + ", which no longer exists"
}
return ""
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration.
func Uninstall() (string, error) {
app, err := AppPath()
+4 -5
View File
@@ -140,10 +140,11 @@ func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
// Launch opens a new terminal window running argv, without waiting for
// Launch opens a new terminal window running inner, without waiting for
// it.
func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil {
return err
}
@@ -165,11 +166,9 @@ func Executable() (string, error) {
// passEnv lists the variables carried from the install's environment to
// lazysecrets in the new window: apps start with a minimal environment,
// without Homebrew's PATH (so no az) or the user's XDG directories, so
// the window would use another cache and miss running instances' sockets.
// without Homebrew's PATH (so no az) or the user's config locations.
var passEnv = []string{
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS",
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
}
+4 -15
View File
@@ -56,9 +56,6 @@ type Entry struct {
// web page (portal, workspace), if any.
Reference string `json:"reference,omitempty"`
URL string `json:"url,omitempty"`
// Hidden is left out by its provider's rules (a Spring file's key
// that doesn't look secret), listed only with Options.Hidden.
Hidden bool `json:"hidden,omitempty"`
// Error says why a source's vaults or a vault's secrets couldn't be
// listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link.
@@ -73,8 +70,6 @@ type Options struct {
MaxAge time.Duration
// Cached lists only what's cached (and repos): nothing is loaded.
Cached bool
// Hidden lists what providers hide too, as H does in the TUI.
Hidden bool
// Kinds are the kinds of entry to list (Kinds' names); none is all.
// Lists only needed for other kinds aren't loaded: sources and vaults
// alone don't load any secrets. Lists that fail are always listed.
@@ -185,9 +180,6 @@ func (l *lister) provider(p provider.Provider) {
// source emits src, and what's cached under it, and queues what's not.
func (l *lister) source(p provider.Provider, src provider.Source) {
if src.Hidden && !l.o.Hidden {
return
}
src.Provider, src.ProviderLabel = p.ID(), p.Label()
l.put(p, sourceEntry(src))
row := cachefile.Row(p.ID(), src.ID)
@@ -204,9 +196,6 @@ func (l *lister) source(p provider.Provider, src provider.Source) {
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
if c.Hidden && !l.o.Hidden {
continue
}
c.Source = src
l.put(p, containerEntry(c))
for _, t := range c.ObjectTypes() {
@@ -232,17 +221,17 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
o.Container = c
r, err := ref.ForObject(o, "")
if err != nil || (o.Hidden && !l.o.Hidden) {
if err != nil {
continue
}
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL, Hidden: o.Hidden})
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL})
}
}
func sourceEntry(src provider.Source) Entry {
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL, Hidden: src.Hidden}
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
if r, err := ref.ForSource(src); err == nil {
e.Link = r.String()
}
@@ -250,7 +239,7 @@ func sourceEntry(src provider.Source) Entry {
}
func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL, Hidden: c.Hidden}
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String()
}
+3
View File
@@ -107,6 +107,9 @@ func parseTerm(tok string) (term, bool) {
return t, true
}
// Empty reports whether the query has no terms: everything matches.
func (q Query) Empty() bool { return len(q.groups) == 0 }
// Item is a row to match: Text is everything searched, and runes
// NameStart up to NameEnd of it are the row's name (the rest is context,
// like a path or a note). Without a name, nothing gets the name bonus.
+6
View File
@@ -99,3 +99,9 @@ func TestAnchorsOnTheName(t *testing.T) {
t.Errorf("password$: %v", got)
}
}
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+121 -99
View File
@@ -16,7 +16,6 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
@@ -100,7 +99,13 @@ func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindA
// Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []provider.Source) {
var out []provider.Source
pager := p.subs.NewListPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
@@ -113,7 +118,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.mu.Lock()
p.tenants[id] = tenant
p.mu.Unlock()
srcs = append(srcs, provider.Source{
out = append(out, provider.Source{
Kind: provider.KindAzure,
ID: id,
Name: deref(s.DisplayName),
@@ -121,10 +126,6 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
})
}
return srcs
})
if err != nil {
return nil, err
}
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
if len(p.opts.Vaults) > 0 {
@@ -229,29 +230,22 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
if err != nil {
return nil, err
}
return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) {
for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
vaults = append(vaults, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
return vaults
})
}
// all lists every page, items picking out what to keep of each.
func all[P, T any](ctx context.Context, pager *runtime.Pager[P], items func(P) []T) ([]T, error) {
var out []T
var out []provider.Container
pager := client.NewListBySubscriptionPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
out = append(out, items(page)...)
for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
out = append(out, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
}
return out, nil
}
@@ -294,40 +288,51 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
var out []provider.Object
switch t {
case provider.TypeSecret:
tenant := p.tenant(c.Source.ID)
out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) {
pager := vc.secrets.NewListSecretPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates.
if s.ID != nil && !deref(s.Managed) {
objs = append(objs, secretObject(c, s, tenant))
if s.ID == nil || deref(s.Managed) {
continue
}
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
}
return objs
})
}
case provider.TypeKey:
out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) {
pager := vc.keys.NewListKeyPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID != nil && !deref(k.Managed) {
objs = append(objs, keyObject(c, k))
if k.KID == nil || deref(k.Managed) {
continue
}
out = append(out, keyObject(c, k))
}
return objs
})
}
case provider.TypeCertificate:
out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) {
for _, cp := range page.Value {
if cp.ID != nil {
objs = append(objs, certObject(c, cp))
}
pager := vc.certs.NewListCertificatePropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
return objs
})
for _, cp := range page.Value {
if cp.ID == nil {
continue
}
out = append(out, certObject(c, cp))
}
}
default:
return nil, provider.ErrUnsupported
}
if err != nil {
return nil, err
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil
}
@@ -348,7 +353,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
if resp.Key != nil {
if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID))
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
}
o.Attrs = keyAttrs(resp.Key)
}
@@ -359,7 +364,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
}
if resp.ID != nil {
o.Version = resp.ID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.ID))
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
}
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType)
@@ -376,46 +381,56 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err != nil {
return nil, err
}
tenant := p.tenant(o.Container.Source.ID)
var out []provider.Object
switch o.Type {
case provider.TypeSecret:
out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (objs []provider.Object) {
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
if s.ID != nil {
objs = append(objs, secretObject(o.Container, s, tenant))
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID)))
}
}
return objs
})
}
case provider.TypeKey:
out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) {
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID != nil {
objs = append(objs, keyObject(o.Container, k))
out = append(out, keyObject(o.Container, k))
}
}
return objs
})
}
case provider.TypeCertificate:
out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) {
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID != nil {
objs = append(objs, certObject(o.Container, cp))
out = append(out, certObject(o.Container, cp))
}
}
return objs
})
}
default:
return nil, provider.ErrUnsupported
}
if err != nil {
return nil, err
}
tenant := p.tenant(o.Container.Source.ID)
for i := range out {
out[i].Reference = versionedReference(out[i])
if o.Type != provider.TypeSecret { // secretObject's is the version's already
out[i].URL = portalAssetURL(tenant, o.Type, out[i].ID+"/"+out[i].Version) // the version's page
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type]
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
}
}
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
@@ -437,49 +452,56 @@ func versionedReference(o provider.Object) string {
// Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty).
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
v, _, err := p.VersionedValue(ctx, o)
return v, err
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, err
}
switch o.Type {
case provider.TypeSecret:
v, _, err := p.VersionedValue(ctx, o)
return v, err
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
pemKey, err := publicKeyPEM(resp.Key)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
}
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
}
return provider.SecretValue{}, provider.ErrUnsupported
}
// VersionedValue is Value, with the version read.
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
if o.Type != provider.TypeSecret {
v, err := p.Value(ctx, o)
return v, o.Version, err
}
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, "", err
}
var text string
switch o.Type {
case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
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
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
return provider.NewSecretValue([]byte(text)), o.Version, nil
version := o.Version
if resp.ID != nil {
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
}
var (
+15 -9
View File
@@ -55,8 +55,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
// portalAssetURL links to a Key Vault object's page: a version's with
// one in objectID; a secret's own (its versions) without.
func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID
func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
}
func tenantPrefix(tenant string) string {
@@ -73,13 +73,19 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
if !ok {
return ""
}
return base + "/" + t.Collection()
switch t {
case provider.TypeKey:
return base + "/keys"
case provider.TypeCertificate:
return base + "/certificates"
}
return base + "/secrets"
}
// objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, t provider.ObjectType, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name
func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
}
// secretObject is a secret, or a version of one, from a listing. Its URL
@@ -87,7 +93,7 @@ func objectURL(c provider.Container, t provider.ObjectType, name string) string
// a version, that version's.
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
name := p.ID.Name()
url := objectURL(c, provider.TypeSecret, name)
url := objectURL(c, "secrets", name)
o := provider.Object{
Container: c,
Type: provider.TypeSecret,
@@ -97,7 +103,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
ContentType: deref(p.ContentType),
Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: portalAssetURL(tenant, provider.TypeSecret, strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
@@ -107,7 +113,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name()
url := objectURL(c, provider.TypeKey, name)
url := objectURL(c, "keys", name)
o := provider.Object{
Container: c,
Type: provider.TypeKey,
@@ -126,7 +132,7 @@ func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, provider.TypeCertificate, name)
url := objectURL(c, "certificates", name)
o := provider.Object{
Container: c,
Type: provider.TypeCertificate,
+13 -13
View File
@@ -2,7 +2,6 @@ package azure
import (
"fmt"
"maps"
"slices"
"strings"
@@ -71,10 +70,13 @@ func graphInfo(m map[string]any) vaultInfo {
// armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo {
info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)}
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
info.group = id.ResourceGroupName
}
for k, t := range v.Tags {
info.tags[k] = deref(t)
}
pr := v.Properties
if pr == nil {
return info
@@ -120,11 +122,7 @@ func (v vaultInfo) attrs() []provider.Attr {
} else {
add("soft delete", "off")
}
if on(v.purge, false) {
add("purge prot.", "on")
} else {
add("purge prot.", "off")
}
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
switch {
case strings.EqualFold(v.publicAccess, "disabled"):
add("network", "private endpoints only")
@@ -133,13 +131,15 @@ func (v vaultInfo) attrs() []provider.Attr {
default:
add("network", "all networks")
}
switch {
case v.endpoints == 1:
add("private", "1 endpoint")
case v.endpoints > 1:
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
if v.endpoints > 0 {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
}
for i, k := range slices.Sorted(maps.Keys(v.tags)) {
keys := make([]string, 0, len(v.tags))
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := ""
if i == 0 {
label = "tags"
+11 -14
View File
@@ -203,20 +203,17 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
return out, nil
}
// fallbackVault is the backing vault of an AKV-backed scope, to read it
// from when Databricks fails, or "" when there's none to fall back to. It
// falls back to the scope's Detail, so a scope remembered from an earlier
// session (a favourite) works before its workspace's scopes are listed.
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
if p.keyVault == nil || ctx.Err() != nil {
return ""
}
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
// back to the scope's Detail, so a scope remembered from an earlier session
// (a favourite) works before its workspace's scopes are listed.
func (p *Provider) vaultURL(c provider.Container) string {
p.mu.Lock()
url := p.vaults[c.ID]
p.mu.Unlock()
if url == "" {
if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok {
url = detail
url, _ = strings.CutPrefix(c.Detail, akvDetail)
if url == c.Detail {
url = ""
}
}
return url
@@ -267,8 +264,8 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
url := p.fallbackVault(ctx, c)
if url == "" {
url := p.vaultURL(c)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return nil, errNoFallback
}
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
@@ -305,8 +302,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
}
dbxErr := classify(pr, err)
url := p.fallbackVault(ctx, o.Container)
if url == "" {
url := p.vaultURL(o.Container)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return provider.SecretValue{}, dbxErr
}
v, kvErr := p.keyVault.Value(ctx, provider.Object{
+49 -30
View File
@@ -48,8 +48,19 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
func (p *Provider) ID() string {
if p.id != "" {
return p.id
}
return string(p.kind)
}
func (p *Provider) Label() string {
if p.label != "" {
return p.label
}
return string(p.kind)
}
// As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider {
@@ -225,7 +236,9 @@ func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.O
o.Type = t
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
o.URL = base + "/" + t.Collection()
o.URL = base + map[provider.ObjectType]string{
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
}[t]
}
k := listKey{c.ID, t}
p.objects[k] = append(p.objects[k], o)
@@ -239,7 +252,11 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
}
}
var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
var (
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
yes = true
no = false
)
// ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
@@ -254,13 +271,12 @@ const (
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
func secretRef(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
// Azure returns a fake Azure provider with two subscriptions.
func Azure(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
objectURL := func(vault, kind, name string) string {
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
}
@@ -268,32 +284,32 @@ func Azure(delay time.Duration) *Provider {
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
}
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0),
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"},
@@ -301,7 +317,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"},
@@ -310,7 +326,7 @@ func Azure(delay time.Duration) *Provider {
}, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"},
@@ -322,7 +338,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"},
@@ -335,10 +351,10 @@ func Azure(delay time.Duration) *Provider {
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
@@ -348,19 +364,19 @@ func Azure(delay time.Duration) *Provider {
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-password")
// Left behind, as dev vaults are: expired, disabled, or both.
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
}, "whsec_demo_expired")
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0),
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0),
Reference: secretRef("kv-app-dev", "test-user-password"),
}, "disabled-demo-password")
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0),
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled")
@@ -372,21 +388,24 @@ func Azure(delay time.Duration) *Provider {
// its own, like an admin account next to a personal one.
func AzureOps(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0),
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
// Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey)
@@ -394,12 +413,12 @@ func AzureOps(delay time.Duration) *Provider {
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token")
// Expiring soon whenever the demo runs: the other dates are fixed,
// and would have passed. Tests use the first account only.
p.add(kv, provider.TypeSecret, provider.Object{
Name: "grafana-api-key", Enabled: new(true), Updated: base.AddDate(0, -11, 0),
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0),
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
}, "glsa_demo_expiring")
+2 -98
View File
@@ -2,7 +2,6 @@ package fake
import (
"context"
"fmt"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -19,9 +18,8 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
return attrs, ctx.Err()
}
// Repos returns a fake repo provider: four git repos with SOPS, plain
// .env, Spring and compose files, the first one the repo lazysecrets was
// started in.
// Repos returns a fake repo provider: three git repos with SOPS and plain
// .env files, the first one the repo lazysecrets was started in.
func Repos() *Provider {
p := newProvider(provider.KindRepo, 0)
p.id, p.label = "Repos", "Repos"
@@ -115,90 +113,6 @@ func Repos() *Provider {
"AZURE_CLIENT_SECRET", "azure-demo-client-secret",
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000")
// config adds a Spring or compose file (kind) and its keys as name,
// value and how it's listed: "" a secret, "hidden", "reference" (the
// value is a placeholder) or "jasypt" (the value is the decrypted one).
config := func(src provider.Source, path, kind, git, commit string, kv ...string) {
form := "yaml"
if strings.HasSuffix(path, ".properties") {
form = "properties"
}
c := p.addContainer(src, path, kind+" · "+form, provider.TypeSecret)
format := provider.Attr{Label: "format", Value: kind + " " + form}
secrets, hidden, jasypt, plain := 0, 0, 0, false
for i := 0; i+2 < len(kv); i += 3 {
o := provider.Object{Name: kv[i], Attrs: []provider.Attr{format, {Label: "encrypted", Value: "no: plain text in the file"}}}
switch kv[i+2] {
case "hidden":
o.Hidden = true
hidden++
continue
case "reference":
o.Detail, o.Attrs[1] = "reference", provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}
case "jasypt":
o.Detail, o.Attrs[1] = "jasypt", provider.Attr{Label: "encrypted", Value: "jasypt"}
jasypt++
default:
plain = plain || kv[i+1] != ""
}
secrets++
p.add(c, provider.TypeSecret, o, kv[i+1])
}
// Hidden keys after the rest, so the list opens on a secret.
for i := 0; i+2 < len(kv); i += 3 {
if kv[i+2] == "hidden" {
p.add(c, provider.TypeSecret, provider.Object{Name: kv[i], Hidden: true, Attrs: []provider.Attr{format}}, kv[i+1])
}
}
if secrets == 0 {
p.hide(src, path)
}
encryption := "none: plain text"
if jasypt > 0 {
encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, secrets+hidden)
}
d := []provider.Attr{
{Label: "encryption", Value: encryption},
{Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", hidden)},
}
if git == "tracked" && plain {
d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
} else {
d = append(d, provider.Attr{Label: "git", Value: git})
}
p.details[c.ID] = append(d, provider.Attr{Label: "commit", Value: commit})
}
orders := repo("orders-service", false)
config(orders, "src/main/resources/application.yml", "spring", "tracked", "5be2c07 · 6 days ago",
"spring.application.name", "orders-service", "hidden",
"server.port", "8080", "hidden",
"spring.datasource.url", "jdbc:postgresql://orders-db:5432/orders", "hidden",
"spring.datasource.password", "${DB_PASSWORD}", "reference",
"spring.security.oauth2.client.provider.idp.token-uri", "https://idp.acme.example/token", "hidden",
"spring.security.oauth2.client.registration.orders.client-secret", "orders-demo-client-secret", "jasypt",
"jasypt.encryptor.password", "${JASYPT_ENCRYPTOR_PASSWORD}", "reference",
"app.payment.api-key", "pay_live_demo_7Hq2", "jasypt")
config(orders, "src/main/resources/application-local.properties", "spring", "tracked", "5be2c07 · 6 days ago",
"spring.datasource.url", "jdbc:postgresql://localhost:5432/orders", "hidden",
"spring.datasource.password", "local-orders-pw", "",
"logging.level.com.acme", "DEBUG", "hidden")
config(orders, "src/test/resources/application-test.yml", "spring", "tracked", "d81a3f9 · 3 weeks ago",
"spring.datasource.url", "jdbc:h2:mem:orders", "hidden",
"spring.jpa.hibernate.ddl-auto", "create-drop", "hidden")
config(orders, "docker-compose.yml", "compose", "tracked", "5be2c07 · 6 days ago",
"api.SPRING_PROFILES_ACTIVE", "local", "hidden",
"api.DB_PASSWORD", "${DB_PASSWORD}", "reference",
"db.POSTGRES_USER", "orders", "hidden",
"db.POSTGRES_PASSWORD", "orders-db-demo-pw", "")
config(orders, "docker-compose.example.yml", "compose", "tracked", "5be2c07 · 6 days ago",
"db.POSTGRES_PASSWORD", "changeme", "")
p.hide(orders, "docker-compose.example.yml")
file(orders, ".env.example", "", "tracked", "5be2c07 · 6 days ago",
"DB_PASSWORD", "changeme",
"JASYPT_ENCRYPTOR_PASSWORD", "changeme")
p.hide(orders, ".env.example")
web := repo("web-frontend", false)
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
"SENTRY_AUTH_TOKEN", "sntrys_demo_token",
@@ -235,13 +149,3 @@ func (p *Provider) Contents(ctx context.Context, c provider.Container) ([]byte,
}
return []byte(b.String()), ctx.Err()
}
// hide marks a file hidden, as the repo provider does an example file or
// a config file without secrets.
func (p *Provider) hide(src provider.Source, name string) {
for i, c := range p.containers[src.ID] {
if c.Name == name {
p.containers[src.ID][i].Hidden = true
}
}
}
+1 -22
View File
@@ -73,10 +73,6 @@ type Source struct {
// Here marks the source lazysecrets was started in (the current git
// repo): listed first, starred or not, and labelled.
Here bool
// Hidden marks a source, container or object the provider's rules
// left out (a Spring file with no secret-looking keys): listed only
// while the app shows hidden ones.
Hidden bool `json:",omitempty"`
}
// ObjectType is the kind of object stored in a container.
@@ -88,20 +84,6 @@ const (
TypeCertificate ObjectType = "Certificate"
)
// Collection is the type's path segment in Key Vault URLs and links, e.g.
// "secrets".
func (t ObjectType) Collection() string {
switch t {
case TypeSecret:
return "secrets"
case TypeKey:
return "keys"
case TypeCertificate:
return "certificates"
}
return ""
}
// Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct {
Source Source
@@ -113,8 +95,7 @@ type Container struct {
Types []ObjectType
URL string // web page for the container, if any
// Attrs describe it further, from the listing (a vault's settings).
Attrs []Attr `json:",omitempty"`
Hidden bool `json:",omitempty"` // see Source.Hidden
Attrs []Attr `json:",omitempty"`
}
// ObjectTypes returns the container's object types, defaulting to secrets.
@@ -138,8 +119,6 @@ type Object struct {
Type ObjectType
ID string
Name string
Detail string // shown after the name, e.g. "jasypt" for how its value is kept
Hidden bool `json:",omitempty"` // see Source.Hidden
Version string
Enabled *bool
Updated time.Time
-141
View File
@@ -1,141 +0,0 @@
package repo
import (
"path"
"regexp"
"strings"
"go.yaml.in/yaml/v3"
)
// Docker Compose files: compose.yaml, docker-compose.yml and their
// variants (docker-compose.test.yml). Their keys are the services'
// environment variables, named service.VAR.
var composeType = configType{
name: "compose", is: IsCompose, format: func(string) string { return "yaml" }, parse: parseCompose,
jasyptSetting: func(name string) string {
service, _, _ := strings.Cut(name, ".")
return service + "." + jasyptEnv
},
placeholder: composePlaceholder,
}
var composeName = regexp.MustCompile(`^(docker-)?compose([.-][^/]+)?\.ya?ml$`)
// IsCompose reports whether f is named like a Docker Compose file.
func IsCompose(f string) bool {
return composeName.MatchString(strings.ToLower(path.Base(f)))
}
// composePlaceholderRe is compose's ${VAR}, ${VAR:-default}, ${VAR-default},
// ${VAR:?error}, ${VAR?error} or $VAR.
var composePlaceholderRe = regexp.MustCompile(`^\$(?:\{(\w+)(?:(:?-)([^}]*)|:?\?[^}]*)?\}|(\w+))$`)
func composePlaceholder(v string) (name, def string, hasDef, ok bool) {
m := composePlaceholderRe.FindStringSubmatch(v)
if m == nil {
return "", "", false, false
}
return m[1] + m[4], m[3], m[2] != "", true
}
func parseCompose(_ string, b []byte) ([]configKey, error) {
var doc yaml.Node
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
if len(doc.Content) == 0 {
return nil, nil
}
var out []configKey
for _, service := range pairs(field(doc.Content[0], "services")) {
for _, k := range environment(field(service.value, "environment")) {
k.name = service.key.Value + "." + k.key
out = append(out, k)
}
}
return out, nil
}
// environment reads a service's environment, a map (KEY: value) or a list
// (- KEY=value). A variable without a value (KEY:, - KEY) is taken from
// where compose runs: a reference.
func environment(n *yaml.Node) []configKey {
var out []configKey
switch n = deref(n); {
case n == nil:
case n.Kind == yaml.MappingNode:
for _, p := range pairs(n) {
v := deref(p.value)
ref := v.Tag == "!!null" || reference(composePlaceholder, v.Value)
out = append(out, configKey{key: p.key.Value, value: v.Value, ref: ref, line: p.key.Line})
}
case n.Kind == yaml.SequenceNode:
for _, item := range n.Content {
item = deref(item)
k, v, ok := strings.Cut(item.Value, "=")
out = append(out, configKey{key: k, value: v, ref: !ok || reference(composePlaceholder, v), line: item.Line})
}
}
return out
}
type pair struct{ key, value *yaml.Node }
// pairs are a mapping's entries, with those merged in by << (anchors
// shared between services) first and its own over them.
func pairs(n *yaml.Node) []pair {
n = deref(n)
if n == nil || n.Kind != yaml.MappingNode {
return nil
}
var merged, own []pair
for i := 0; i+1 < len(n.Content); i += 2 {
k, v := n.Content[i], n.Content[i+1]
if k.Value != "<<" {
own = append(own, pair{k, v})
continue
}
from := []*yaml.Node{v}
if v.Kind == yaml.SequenceNode {
from = v.Content
}
for _, m := range from {
merged = append(merged, pairs(m)...)
}
}
var out []pair
for _, p := range append(merged, own...) {
if i := indexOf(out, p.key.Value); i >= 0 {
out[i] = p
} else {
out = append(out, p)
}
}
return out
}
func indexOf(ps []pair, key string) int {
for i, p := range ps {
if p.key.Value == key {
return i
}
}
return -1
}
func field(n *yaml.Node, key string) *yaml.Node {
ps := pairs(n)
if i := indexOf(ps, key); i >= 0 {
return ps[i].value
}
return nil
}
func deref(n *yaml.Node) *yaml.Node {
for n != nil && n.Kind == yaml.AliasNode {
n = n.Alias
}
return n
}
-140
View File
@@ -1,140 +0,0 @@
package repo
import (
"context"
"maps"
"os"
"path/filepath"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestIsCompose(t *testing.T) {
for f, want := range map[string]bool{
"docker-compose.yml": true,
"deploy/compose.yaml": true,
"docker-compose.test.yml": true,
"docker-compose-dev.yaml": true,
"docker-compose.example.yml": true, // listed hidden
"applications.yaml": false,
"composer.json": false,
"compose.yml.bak": false,
} {
if IsCompose(f) != want {
t.Errorf("%s: %t", f, !want)
}
}
}
func TestComposePlaceholder(t *testing.T) {
for v, want := range map[string]bool{
"${DB_PASSWORD}": true, "$DB_PASSWORD": true, "${DB_PASSWORD:?set it}": true, "${DB_PASSWORD?}": true,
"${DB_PASSWORD:-hunter2}": false, "${DB_PASSWORD-hunter2}": false, // the default is in the file
"hunter2": false, "pre-${DB_PASSWORD}": false, "$": false,
} {
if got := reference(composePlaceholder, v); got != want {
t.Errorf("%s: reference %t", v, got)
}
}
if name, def, hasDef, _ := composePlaceholder("${PW:-x}"); name != "PW" || def != "x" || !hasDef {
t.Errorf("default: %s %s %t", name, def, hasDef)
}
}
func TestParseCompose(t *testing.T) {
keys, err := parseCompose("", []byte(`x-db: &db
DB_USER: app
DB_PASSWORD: shared
services:
api:
image: api
environment:
<<: *db
DB_PASSWORD: own
API_KEY: ${API_KEY}
FROM_HOST:
worker:
environment:
- QUEUE_PASSWORD=hunter2
- TOKEN
- EMPTY=
web:
image: web
`))
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, k := range keys {
ref := ""
if k.ref {
ref = " (ref)"
}
got[k.name] = k.value + ref
}
want := map[string]string{
"api.DB_USER": "app", "api.DB_PASSWORD": "own", "api.API_KEY": "${API_KEY} (ref)", "api.FROM_HOST": " (ref)",
"worker.QUEUE_PASSWORD": "hunter2", "worker.TOKEN": " (ref)", "worker.EMPTY": "",
}
if !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
for _, k := range keys {
if k.name == "worker.QUEUE_PASSWORD" && (k.line != 14 || k.key != "QUEUE_PASSWORD") {
t.Errorf("%s: line %d, key %s", k.name, k.line, k.key)
}
}
}
func TestComposeFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"docker-compose.yml": `services:
api:
environment:
PORT: "8080"
DB_PASSWORD: ${DB_PASSWORD}
JASYPT_ENCRYPTOR_PASSWORD: test-password
API_TOKEN: ` + jasyptHunter2 + `
`,
"docker-compose.example.yml": "services:\n api:\n environment:\n DB_PASSWORD: changeme\n",
})
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, err := p.Containers(ctx, srcs[0])
if err != nil || len(cs) != 2 {
t.Fatalf("files: %+v %v", cs, err)
}
for _, c := range cs {
if c.Detail != "compose · yaml" || c.Hidden != (c.Name == "docker-compose.example.yml") {
t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden)
}
}
compose := cs[1] // after docker-compose.example.yml
keys, _ := p.Objects(ctx, compose, provider.TypeSecret)
got := map[string]string{}
for _, k := range shown(keys) {
got[k.Name] = k.Detail
}
if want := map[string]string{"api.DB_PASSWORD": "reference", "api.JASYPT_ENCRYPTOR_PASSWORD": "", "api.API_TOKEN": "jasypt"}; !maps.Equal(got, want) {
t.Errorf("keys %v, want %v", got, want)
}
// The service's JASYPT_ENCRYPTOR_PASSWORD decrypts its values.
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "docker-compose.yml"}, Name: "api.API_TOKEN"}
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "hunter2" {
t.Errorf("jasypt: %q %v", v.Reveal(), err)
}
if f, _ := p.File(compose, &link); f.Line != 7 {
t.Errorf("line %d", f.Line)
}
// compose.secretPatterns over the shared ones.
os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte("secretPatterns:\n keys: [\"^nothing$\"]\ncompose:\n secretPatterns:\n keys: [\"^PORT$\"]\n values: []\n"), 0o644)
keys, _ = p.Objects(ctx, compose, provider.TypeSecret)
if s := shown(keys); len(s) != 1 || s[0].Name != "api.PORT" {
t.Errorf("compose's patterns: %v", s)
}
}
-170
View File
@@ -1,170 +0,0 @@
package repo
import (
"fmt"
"os"
"path/filepath"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Config files (Spring's, compose's) are read directly, like env files,
// but most of their keys aren't secret: those the secret patterns
// (patterns.go) don't match are hidden, and a file with none of them is.
// configType is a type of config file: what it's called (in settings, as
// spring:, and on the file's row) and how its keys are read.
type configType struct {
name string
is func(file string) bool
format func(file string) string // yaml, properties
parse func(file string, b []byte) ([]configKey, error)
// jasyptSetting is the key that holds the Jasypt password for the
// key named name.
jasyptSetting func(name string) string
// placeholder reads a value that only refers to another,
// ${DB_PASSWORD}: what it names, and its default if it has one.
placeholder func(v string) (name, def string, hasDef, ok bool)
}
var configTypes = []configType{springType, composeType}
func isConfig(f string) bool {
_, ok := configTypeOf(f)
return ok
}
// configTypeOf is the type of config file f is, if it's one.
func configTypeOf(f string) (configType, bool) {
for _, t := range configTypes {
if t.is(f) {
return t, true
}
}
return configType{}, false
}
// detail is the file's row: "spring · yaml".
func (t configType) detail(file string) string { return t.name + " · " + t.format(file) }
// configKey is a key of a config file, with its value.
type configKey struct {
key string // as the app sees it: spring.datasource.password, DB_PASSWORD
name string // one key in the file: spring.datasource.password (prod), api.DB_PASSWORD
value string
ref bool // the value only refers to the secret, set elsewhere: ${DB_PASSWORD}
line int // 1-based
}
func (k configKey) jasypt() bool { return jasypt.Is(k.value) }
// plain reports whether the file holds the value itself, readable.
func (k configKey) plain() bool { return k.value != "" && !k.ref && !k.jasypt() }
// reference reports whether v is only a placeholder without a default:
// one with a default has the default in the file.
func reference(placeholder func(string) (string, string, bool, bool), v string) bool {
_, _, hasDef, ok := placeholder(v)
return ok && !hasDef
}
func (t configType) read(file string) ([]configKey, error) {
b, err := os.ReadFile(file)
if err != nil {
return nil, err
}
return t.parse(file, b)
}
func (t configType) keyNamed(file, name string) (configKey, error) {
keys, err := t.read(file)
if err != nil {
return configKey{}, err
}
for _, k := range keys {
if k.name == name {
return k, nil
}
}
return configKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
}
func (t configType) value(file, name string) (provider.SecretValue, error) {
k, err := t.keyNamed(file, name)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(k.value)), nil
}
// configHidden reports whether a config file has no secret keys. One that
// can't be read isn't hidden: its error is worth seeing.
func (p *Provider) configHidden(t configType, root, file string) bool {
keys, err := t.read(file)
if err != nil {
return false
}
filter, err := p.filterFor(root, t.name)
if err != nil {
return false
}
return !slices.ContainsFunc(keys, func(k configKey) bool { return filter.secret(k.key, k.value) })
}
// configObjects lists the file's keys, those that don't look like secrets
// hidden.
func (p *Provider) configObjects(t configType, c provider.Container, b []byte) ([]provider.Object, error) {
keys, err := t.parse(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
filter, err := p.filterFor(c.Source.ID, t.name)
if err != nil {
return nil, err
}
format := provider.Attr{Label: "format", Value: t.name + " " + t.format(c.ID)}
var out []provider.Object
for _, k := range keys {
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
switch {
case k.ref:
state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference"
case k.jasypt():
state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt"
}
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail,
Hidden: !filter.secret(k.key, k.value), Attrs: []provider.Attr{format, state},
})
}
return out, nil
}
// configDetails says how many of the file's keys aren't listed, and
// whether any listed one is in plain text.
func (p *Provider) configDetails(t configType, c provider.Container) (attrs []provider.Attr, plain bool) {
keys, err := t.read(c.ID)
if err != nil {
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
}
filter, err := p.filterFor(c.Source.ID, t.name)
if err != nil {
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
}
secrets, jasypt := 0, 0
for _, k := range keys {
if filter.secret(k.key, k.value) {
secrets++
plain = plain || k.plain()
}
if k.jasypt() {
jasypt++
}
}
return []provider.Attr{
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
{Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
}, plain
}
+4 -36
View File
@@ -2,8 +2,6 @@ package repo
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -24,12 +22,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
}
var out []provider.Attr
meta, isSops := readMeta(c.ID)
// A plain file's values are at risk in git, unless they're all Jasypt;
// a config file's only when one of its secrets is in plain text.
plain := !isSops
if t, ok := configTypeOf(c.ID); ok && !isSops {
out, plain = p.configDetails(t, c)
} else if isSops {
if isSops {
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
for i, k := range meta.recipients {
label := ""
@@ -42,7 +35,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
}
} else {
out, plain = envDetails(c.ID)
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
}
git := func(args ...string) (string, bool) {
@@ -50,7 +43,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
return strings.TrimSpace(string(b)), err == nil
}
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
case tracked && plain:
case tracked && !isSops:
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
case tracked:
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
@@ -58,7 +51,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
} else {
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: plain})
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
}
return out, nil
}
@@ -67,28 +60,3 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
}
return out, nil
}
// envDetails says how a plain env file's values are encrypted, and whether
// any is in plain text.
func envDetails(file string) (attrs []provider.Attr, plain bool) {
b, err := os.ReadFile(file)
if err != nil {
return []provider.Attr{{Label: "encryption", Value: err.Error(), Warn: true}}, true
}
keys, _ := parseKeys(file, b)
n := 0
for _, k := range keys {
if k.jasypt {
n++
}
}
return []provider.Attr{{Label: "encryption", Value: jasyptShare(n, len(keys))}}, n < len(keys)
}
// jasyptShare is how many of a plain file's values are Jasypt's.
func jasyptShare(n, of int) string {
if n == 0 {
return "none: plain text"
}
return fmt.Sprintf("jasypt: %d of %d values", n, of)
}
-66
View File
@@ -1,66 +0,0 @@
package repo
import (
"errors"
"fmt"
"os"
"path/filepath"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// jasyptEnv is where Spring looks for the password first, before the
// config's jasypt.encryptor.password, and so does lazysecrets.
const jasyptEnv = "JASYPT_ENCRYPTOR_PASSWORD"
// jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain
// file; other values are as they are.
func jasyptValue(file, name string, v provider.SecretValue) (provider.SecretValue, error) {
s := v.Reveal()
if !jasypt.Is(s) {
return v, nil
}
password, from, err := jasyptPassword(file, name)
if err != nil {
return provider.SecretValue{}, err
}
plain, err := jasypt.Decrypt(s, password)
if errors.Is(err, jasypt.ErrDecrypt) {
return provider.SecretValue{}, fmt.Errorf("jasypt: with the password from %s: %w", from, err)
}
if err != nil {
return provider.SecretValue{}, fmt.Errorf("jasypt: %w", err)
}
return provider.NewSecretValue([]byte(plain)), nil
}
// jasyptPassword is the password for the Jasypt value of the key named
// name, and where it's from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's
// own setting (jasypt.encryptor.password in a Spring file; the service's
// JASYPT_ENCRYPTOR_PASSWORD in a compose file, the file's in an env file),
// with a ${VAR} in it read from the environment.
func jasyptPassword(file, name string) (password, from string, err error) {
if v := os.Getenv(jasyptEnv); v != "" {
return v, "$" + jasyptEnv, nil
}
setting, read, placeholder := jasyptEnv, plainValue, composePlaceholder // env files interpolate like compose
if t, ok := configTypeOf(file); ok {
setting, read, placeholder = t.jasyptSetting(name), t.value, t.placeholder
}
v, err := read(file, setting)
if err != nil {
return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file))
}
from = setting + " in " + filepath.Base(file)
env, def, hasDef, ok := placeholder(v.Reveal())
switch {
case !ok:
return v.Reveal(), from, nil
case os.Getenv(env) != "":
return os.Getenv(env), "$" + env + " (" + from + ")", nil
case hasDef:
return def, "the default in " + from, nil
}
return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v.Reveal(), env)
}
-102
View File
@@ -1,102 +0,0 @@
package repo
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// hunter2, encrypted by Jasypt with the password "test-password".
const jasyptHunter2 = "ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)"
func TestJasyptPassword(t *testing.T) {
dir := t.TempDir()
value := func(name, body string) (string, error) {
file := filepath.Join(dir, name)
if err := os.WriteFile(file, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
key := "app.token"
if !IsSpring(name) {
key = "TOKEN"
}
v, err := (&Provider{}).Value(context.Background(), provider.Object{ID: file + "\x00" + key, Name: key})
return v.Reveal(), err
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
t.Setenv("MY_PASSWORD", "")
spring := func(password string) string {
return "jasypt.encryptor.password: " + password + "\napp.token: " + jasyptHunter2 + "\n"
}
for _, c := range []struct{ name, file, body, env, want string }{
{"the file's own", "application.yml", spring("test-password"), "", "hunter2"},
{"a ${VAR}", "application.yml", spring("${MY_PASSWORD}"), "test-password", "hunter2"},
{"a ${VAR}'s default", "application.yml", spring("${MY_PASSWORD:test-password}"), "", "hunter2"},
{"properties", "application-local.properties", "jasypt.encryptor.password=test-password\napp.token=" + jasyptHunter2 + "\n", "", "hunter2"},
{"env file", ".env", "JASYPT_ENCRYPTOR_PASSWORD=test-password\nTOKEN=" + jasyptHunter2 + "\n", "", "hunter2"},
{"not jasypt", ".env", "TOKEN=hunter2\n", "", "hunter2"},
} {
t.Setenv("MY_PASSWORD", c.env)
if got, err := value(c.file, c.body); err != nil || got != c.want {
t.Errorf("%s: %q %v", c.name, got, err)
}
}
// $JASYPT_ENCRYPTOR_PASSWORD wins over the file's, as in Spring.
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
if got, err := value("application.yml", spring("wrong")); err != nil || got != "hunter2" {
t.Errorf("environment first: %q %v", got, err)
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "wrong")
if _, err := value("application.yml", spring("test-password")); err == nil || !strings.Contains(err.Error(), "the password from $JASYPT_ENCRYPTOR_PASSWORD") {
t.Errorf("a wrong one says where it's from: %v", err)
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
t.Setenv("MY_PASSWORD", "")
for body, want := range map[string]string{
spring("${MY_PASSWORD}"): "$MY_PASSWORD isn't set",
"app.token: " + jasyptHunter2: "set $JASYPT_ENCRYPTOR_PASSWORD, or jasypt.encryptor.password in application.yml",
} {
if _, err := value("application.yml", body); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("no password: %v, want %q", err, want)
}
}
}
func TestJasyptEnvFile(t *testing.T) {
root := gitRepo(t, map[string]string{
"jasypt.env": "TOKEN=" + jasyptHunter2 + "\n",
"mixed.env": "TOKEN=" + jasyptHunter2 + "\nURL=https://example.com\n",
})
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, _ := p.Containers(ctx, srcs[0])
for _, c := range cs {
attrs, _ := p.Details(ctx, c)
got := map[string]provider.Attr{}
for _, a := range attrs {
got[a.Label] = a
}
switch c.Name {
case "jasypt.env":
if got["encryption"].Value != "jasypt: 1 of 1 values" || got["git"].Warn {
t.Errorf("all jasypt: %+v", got)
}
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
if len(keys) != 1 || keys[0].Attrs[1].Value != "jasypt" || keys[0].Detail != "jasypt" {
t.Errorf("keys: %+v", keys)
}
case "mixed.env":
if got["encryption"].Value != "jasypt: 1 of 2 values" || !got["git"].Warn {
t.Errorf("mixed: %+v", got)
}
}
}
}
-113
View File
@@ -1,113 +0,0 @@
package repo
import (
"errors"
"fmt"
"path/filepath"
"regexp"
)
// Config files (Spring's) hold all of an app's config, most of it not
// secret: of their keys, those named like a secret or with a value that
// looks like one are listed, the rest hidden (H shows them). The patterns
// can be set for all of a repo's files and per file type, in the config
// and in a repo's .lazysecrets.yaml.
// DefaultSecretKeys and DefaultSecretValues are the patterns used when
// nothing sets them. The key pattern is anchored at the end of the name:
// spring.datasource.password, client-secret, DB_PASSWORD and checkoutKey,
// not the oauth2.*, keycloak.* and token-uri keys every Spring app has.
// passord is Norwegian. They err on listing too much: a row too many is
// cheap, a secret missed isn't.
var (
DefaultSecretKeys = []string{
`(?i)(pass(word|wd|phrase|ord)?|pwd|secret|token|key|credentials?|salt|connection-?string)$`,
}
DefaultSecretValues = []string{
`^ENC\(.+\)$`, // Jasypt
`://[^/\s:@]*:[^/\s@]+@`, // a URL with a password: jdbc:postgresql://user:pass@host
`(?i)(AccountKey|SharedAccessKey|Password|Pwd)=`, // a connection string with one
}
)
// SecretPatterns are regexps matched against a key's name and its value:
// a key is a secret when either matches. A list that isn't set is taken
// from the next place that sets it (see filterFor).
type SecretPatterns struct {
Keys *[]string `yaml:"keys"`
Values *[]string `yaml:"values"`
}
// FileType holds the settings of one type of file (spring:), for now its
// own secret patterns.
type FileType struct {
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
}
// Check says which pattern doesn't compile; field is where they're set
// (spring.secretPatterns), for the message.
func (p SecretPatterns) Check(field string) error {
var errs []error
for name, list := range map[string]*[]string{"keys": p.Keys, "values": p.Values} {
if list == nil {
continue
}
for i, s := range *list {
if _, err := regexp.Compile(s); err != nil {
errs = append(errs, fmt.Errorf("%s.%s[%d]: %w", field, name, i, err))
}
}
}
return errors.Join(errs...)
}
type secretFilter struct{ keys, values []*regexp.Regexp }
func (f secretFilter) secret(key, value string) bool {
return anyMatch(f.keys, key) || anyMatch(f.values, value)
}
// filterFor is the patterns for a repo's files of one type (spring), each
// list from the first that sets it: the repo's .lazysecrets.yaml for the
// type, then for all its files, the config for the type, then for all
// repos, then the defaults.
func (p *Provider) filterFor(root, fileType string) (secretFilter, error) {
f, err := readLazysecretsFile(root)
if err != nil {
return secretFilter{}, err
}
own := filepath.Join(root, ".lazysecrets.yaml") + ": "
type source struct {
field string
patterns SecretPatterns
}
sources := []source{
{own + fileType + ".secretPatterns", f.types()[fileType].SecretPatterns},
{own + "secretPatterns", f.SecretPatterns},
{"providers.repos." + fileType + ".secretPatterns", p.s.Types[fileType].SecretPatterns},
{"providers.repos.secretPatterns", p.s.SecretPatterns},
}
pick := func(name string, list func(SecretPatterns) *[]string, defaults []string) ([]*regexp.Regexp, error) {
for _, s := range sources {
if l := list(s.patterns); l != nil {
return compileAll(*l, s.field+"."+name)
}
}
return compileAll(defaults, "default "+name)
}
keys, kerr := pick("keys", func(p SecretPatterns) *[]string { return p.Keys }, DefaultSecretKeys)
values, verr := pick("values", func(p SecretPatterns) *[]string { return p.Values }, DefaultSecretValues)
return secretFilter{keys, values}, errors.Join(kerr, verr)
}
func compileAll(patterns []string, field string) ([]*regexp.Regexp, error) {
out := make([]*regexp.Regexp, 0, len(patterns))
for i, s := range patterns {
re, err := regexp.Compile(s)
if err != nil {
return nil, fmt.Errorf("%s[%d]: %w", field, i, err)
}
out = append(out, re)
}
return out, nil
}
-49
View File
@@ -1,49 +0,0 @@
package repo
import (
"os"
"path/filepath"
"testing"
)
// Each list comes from the first that sets it: the repo's spring, the
// repo's, the config's spring, the config's, the defaults.
func TestSecretPatternsOrder(t *testing.T) {
list := func(s ...string) *[]string { return &s }
root := t.TempDir()
write := func(body string) {
if err := os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
p := New(Settings{
SecretPatterns: SecretPatterns{Keys: list("^config$"), Values: list("^config$")},
Types: map[string]FileType{"spring": {SecretPatterns: SecretPatterns{Keys: list("^config-spring$")}}},
})
secret := func(key, value string) bool {
t.Helper()
f, err := p.filterFor(root, "spring")
if err != nil {
t.Fatal(err)
}
return f.secret(key, value)
}
if !secret("config-spring", "") || secret("config", "") || !secret("", "config") {
t.Error("the config's spring keys, its shared values")
}
write("secretPatterns:\n keys: [\"^repo$\"]\n")
if !secret("repo", "") || secret("config-spring", "") || !secret("", "config") {
t.Error("the repo's shared keys over the config's")
}
write("secretPatterns:\n keys: [\"^repo$\"]\nspring:\n secretPatterns:\n keys: [\"^repo-spring$\"]\n values: []\n")
if !secret("repo-spring", "") || secret("repo", "") || secret("", "config") {
t.Error("the repo's spring over everything; an empty list matches nothing")
}
p = New(Settings{})
write("")
if !secret("spring.datasource.password", "") || secret("server.port", "") {
t.Error("the defaults")
}
}
+103 -151
View File
@@ -1,7 +1,6 @@
// Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and
// their keys the secrets. Secret files are SOPS files, plain env files and
// config files: Spring Boot's and Docker Compose's (configfile.go).
// their keys the secrets. SOPS files are the one type so far.
//
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
// metadata are read directly. A value is decrypted when it's revealed or
@@ -32,7 +31,6 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -51,11 +49,6 @@ type Settings struct {
// Include and Exclude are globs relative to a repo's root (** matches
// any depth), for repos without their own .lazysecrets.yaml.
Include, Exclude []string
// SecretPatterns say which keys of config files are secrets, Types
// per type of file (spring), for repos whose .lazysecrets.yaml
// doesn't.
SecretPatterns SecretPatterns
Types map[string]FileType
// Sops is the sops binary; "sops" (on PATH) when empty.
Sops string
}
@@ -113,6 +106,8 @@ func (p *Provider) load() {
roots = append(roots, root)
}
}
// The repo lazysecrets started in, if any: marked, so the UI can
// put it on top, with its files under Dir first.
here, inRepo := p.current()
if inRepo {
seen[here.root] = true
@@ -142,6 +137,7 @@ func (p *Provider) load() {
add(root)
}
}
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil {
add(root)
@@ -192,19 +188,12 @@ func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
return provider.Source{}, false
}
src := p.source(r)
files, err := p.Containers(ctx, src)
if err == nil && len(files) == 0 {
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 {
return provider.Source{}, false
}
src.Hidden = err == nil && allHidden(files)
return src, true
}
// allHidden reports whether a repo's files are all hidden: the repo is too.
func allHidden(files []provider.Container) bool {
return !slices.ContainsFunc(files, func(c provider.Container) bool { return !c.Hidden })
}
func (p *Provider) source(r repoInfo) provider.Source {
return provider.Source{
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
@@ -217,11 +206,13 @@ func parallel(n int, f func(i int)) {
var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
f(i)
})
}()
}
wg.Wait()
}
@@ -273,7 +264,6 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
parallel(len(srcs), func(i int) {
files, err := p.Containers(ctx, srcs[i])
keep[i] = err != nil || len(files) > 0
srcs[i].Hidden = err == nil && allHidden(files)
})
var out []provider.Source
for i, src := range srcs {
@@ -333,13 +323,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
case isSops:
return // a SOPS file where .sops.yaml says SOPS files don't go
case isEnvNamed(f):
case IsEnv(f):
c.Detail = provider.DetailPlainEnv
c.Hidden = isExample(f)
case isConfig(f):
t, _ := configTypeOf(f)
c.Detail = t.detail(f)
c.Hidden = isExample(f) || p.configHidden(t, r.root, path)
default:
return
}
@@ -376,29 +361,30 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
if err != nil {
return nil, err
}
meta, isSops := parseMeta(c.ID, b)
if t, ok := configTypeOf(c.ID); ok && !isSops {
return p.configObjects(t, c, b)
}
meta, isSops := readMeta(c.ID)
keys, err := parseKeys(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
if isSops {
attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}
if !isSops { // a plain env file
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
})
}
return out, nil
}
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
o := provider.Object{
Container: c, Type: provider.TypeSecret,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
}
switch {
case isSops && !k.encrypted:
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
case !isSops && k.jasypt:
o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt"
if !k.encrypted {
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
}
out = append(out, o)
}
@@ -413,29 +399,18 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
return nil, provider.ErrUnsupported
}
// Value reads one key: from a plain file, decrypting a Jasypt value; from
// a SOPS file, with sops.
// Value decrypts one key with sops.
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key
file = filePath(o.Container)
file = o.Container.ID
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
extract = extractFor(file, o.Name)
}
if _, isSops := readMeta(file); !isSops {
read := plainValue
if t, ok := configTypeOf(file); ok {
read = t.value
}
v, err := read(file, o.Name)
if err != nil {
return v, err
}
return jasyptValue(file, o.Name, v)
}
if !ok {
var err error
if extract, err = extractOf(file, o.Name); err != nil {
return provider.SecretValue{}, err
}
return plainValue(file, o.Name)
}
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
@@ -459,18 +434,12 @@ func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, er
return os.ReadFile(f.Path)
}
// filePath is the file c is. A container made from a link has no ID: the
// file is its path in the repo.
func filePath(c provider.Container) string {
if c.ID != "" {
return c.ID
}
return filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
// File is the file c is, and the line o's key is on.
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := filePath(c)
path := c.ID
if path == "" { // made from a link
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
f := provider.File{Path: path}
if _, isSops := readMeta(path); isSops {
f.Sops = p.s.Sops
@@ -478,12 +447,6 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File
if o == nil {
return f, nil
}
if t, ok := configTypeOf(path); ok && f.Sops == "" {
if k, err := t.keyNamed(path, o.Name); err == nil {
f.Line = k.line
}
return f, nil
}
b, err := os.ReadFile(path)
if err != nil {
return f, err
@@ -635,56 +598,44 @@ func normalizeRemote(remote string) (string, string) {
// lazysecretsFile is a repo's own .lazysecrets.yaml.
type lazysecretsFile struct {
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
Spring FileType `yaml:"spring"`
Compose FileType `yaml:"compose"`
Sharing struct {
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
Sharing struct {
// Key is the link to the secret that encrypts the repo's
// shared env files (package share).
Key string `yaml:"key"`
} `yaml:"sharing"`
}
func (f lazysecretsFile) types() map[string]FileType {
return map[string]FileType{"spring": f.Spring, "compose": f.Compose}
}
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) {
f, err := readLazysecretsFile(root)
return f.Sharing.Key, err
}
// readLazysecretsFile reads the repo's .lazysecrets.yaml; without one, it's
// empty.
func readLazysecretsFile(root string) (lazysecretsFile, error) {
var f lazysecretsFile
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) {
return f, nil
return "", nil
}
if err != nil {
return f, err
return "", err
}
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return f, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return f, nil
return f.Sharing.Key, nil
}
// selector says which files to look at: user (the include/exclude of the
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
f, err := readLazysecretsFile(root)
if err != nil {
return nil, nil, err
}
include, exclude := p.s.Include, p.s.Exclude
if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude
}
}
inc, err := globs(include)
if err != nil {
@@ -720,7 +671,7 @@ func envFiles(root string, tracked []string) []string {
dirs := map[string]bool{".": true}
for _, f := range tracked {
isTracked[f] = true
if isEnvNamed(f) {
if IsEnv(f) {
out = append(out, f)
}
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
@@ -734,7 +685,7 @@ func envFiles(root string, tracked []string) []string {
}
for _, e := range entries {
f := path.Join(d, e.Name())
if e.Type().IsRegular() && !isTracked[f] && isEnvNamed(f) {
if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) {
out = append(out, f)
}
}
@@ -769,29 +720,16 @@ func inPackages(f string) bool {
// .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies.
func IsEnv(f string) bool {
return isEnvNamed(f) && !isExample(f)
}
// isEnvNamed reports whether a file outside dependencies is named like an
// env file, examples too.
func isEnvNamed(f string) bool {
if inPackages(f) {
return false
}
base := strings.ToLower(path.Base(f))
return !inPackages(f) && (base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env"))
}
var exampleWords = []string{"example", "sample", "template", "dist", "tmpl"}
// isExample reports whether a file is an example or template of another:
// .env.example, docker-compose.example.yml. They're listed hidden: someone
// may have put a real value in one.
func isExample(f string) bool {
base := strings.ToLower(path.Base(f))
for _, w := range exampleWords {
if strings.HasSuffix(base, "."+w) || strings.Contains(base, "."+w+".") {
return true
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} {
if strings.HasSuffix(base, ext) {
return false
}
}
return false
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
}
// plainValue reads a key from a plain env file.
@@ -969,21 +907,12 @@ func readMeta(file string) (sopsMeta, bool) {
return sopsMeta{}, false // not a format sops writes: skip reading
}
b, err := os.ReadFile(file)
if err != nil {
return sopsMeta{}, false
}
return parseMeta(file, b)
}
// parseMeta is readMeta, of the file's contents b.
func parseMeta(file string, b []byte) (sopsMeta, bool) {
format := formatOf(file)
if len(b) > maxFileSize || format == "" || !bytes.Contains(b, []byte("sops")) {
if err != nil || !bytes.Contains(b, []byte("sops")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
}
var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch format {
switch formatOf(file) {
case "yaml", "json":
var doc struct {
Sops map[string]any `yaml:"sops"`
@@ -1087,8 +1016,7 @@ type key struct {
name string // db.password, list[0], with . [ ] \ in keys escaped
extract string // sops --extract: ["db"]["password"]
encrypted bool
jasypt bool // a plain env file's ENC(...) value
line int // 1-based
line int // 1-based
}
// parseKeys lists a sops file's keys without decrypting anything.
@@ -1112,8 +1040,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
if !ok || strings.HasPrefix(k, "sops_") {
continue
}
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), jasypt: jasypt.Is(v), line: i + 1})
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
}
return out, nil
case "ini":
@@ -1178,23 +1105,48 @@ func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractOf is the sops --extract path of the key named name, for objects
// made from a link.
func extractOf(file, name string) (string, error) {
b, err := os.ReadFile(file)
if err != nil {
return "", err
// extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
// its names are the keys as they are.
func extractFor(file, name string) string {
if formatOf(file) == "dotenv" {
return quote(name)
}
keys, err := parseKeys(file, b)
if err != nil {
return "", err
}
for _, k := range keys {
if k.name == name {
return k.extract, nil
var b, part strings.Builder
flush := func() {
if part.Len() > 0 {
b.WriteString(quote(part.String()))
part.Reset()
}
}
return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
for i := 0; i < len(name); i++ {
switch c := name[i]; c {
case '\\':
if i+1 < len(name) {
i++
}
part.WriteByte(name[i])
case '.':
flush()
case '[':
end := strings.IndexByte(name[i:], ']')
if end < 0 || !digits(name[i+1:i+end]) {
part.WriteByte(c)
continue
}
flush()
b.WriteString(name[i : i+end+1])
i += end
default:
part.WriteByte(c)
}
}
flush()
return b.String()
}
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
}
func absPath(p string) string {
+8 -37
View File
@@ -237,25 +237,6 @@ func TestDecrypt(t *testing.T) {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
// A dotenv file's export lines: sops keeps export in the key.
os.WriteFile(plain, []byte("export TOKEN=s3cret\n"), 0o600)
enc, err = exec.Command("sops", "encrypt", "--age", recipient, "--input-type", "dotenv", "--output-type", "dotenv", plain).Output()
if err != nil {
t.Fatal(err)
}
envFile := filepath.Join(root, "prod.env")
os.WriteFile(envFile, enc, 0o600)
envC := provider.Container{Source: srcs[0], ID: envFile, Name: "prod.env"}
envKeys, _ := p.Objects(ctx, envC, provider.TypeSecret)
if len(envKeys) != 1 {
t.Fatalf("env keys: %v", envKeys)
}
for _, o := range []provider.Object{envKeys[0], {Container: provider.Container{Source: srcs[0], Name: "prod.env"}, Name: "TOKEN"}} {
if v, err := p.Value(ctx, o); err != nil || v.Reveal() != "s3cret" {
t.Errorf("export line (link: %t): %q %v", o.ID == "", v.Reveal(), err)
}
}
// Without the key: sops' own error.
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
@@ -292,12 +273,9 @@ func TestPlainEnvFiles(t *testing.T) {
got := map[string]string{}
for _, c := range files {
got[c.Name] = c.Detail
if c.Hidden != (c.Name == ".env.example") {
t.Errorf("%s: hidden %t", c.Name, c.Hidden)
}
}
if len(got) != 3 || got[".env"] != "env · plain text" || got[".env.example"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
t.Fatalf("files %v (want .env, the example, hidden, and the sops file; not node_modules)", got)
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
}
var env provider.Container
@@ -322,7 +300,7 @@ func TestPlainEnvFiles(t *testing.T) {
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env", ".env.example"}) {
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
t.Errorf("with rules elsewhere: %v", names)
}
}
@@ -576,22 +554,15 @@ func TestKeyNamesEscaped(t *testing.T) {
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
keys, err = parseKeys(".env", []byte("spring.url=a\nexport TOKEN=b\nSPACED = c\n"))
if err != nil {
t.Fatal(err)
}
got = nil
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
}
want = `spring.url ["spring.url"]|TOKEN ["export TOKEN"]|SPACED ["SPACED "]`
if strings.Join(got, "|") != want {
t.Errorf("dotenv keys = %s\nwant %s", strings.Join(got, "|"), want)
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
-236
View File
@@ -1,236 +0,0 @@
package repo
import (
"bytes"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode"
"go.yaml.in/yaml/v3"
)
// Spring Boot config files: application*.properties/.yml and bootstrap*,
// their keys filtered by the secret patterns (patterns.go).
// springName is application.yml, application-prod.properties, bootstrap.yaml
// and the like; not applications.yaml (Argo CD's) or application.yml.example.
var springName = regexp.MustCompile(`^(application|bootstrap)(-[^.]+)?\.(properties|ya?ml)$`)
// IsSpring reports whether f is named like a Spring Boot config file, and
// isn't a Helm chart's template of one.
func IsSpring(f string) bool {
return springName.MatchString(strings.ToLower(path.Base(f))) && path.Base(path.Dir(f)) != "templates"
}
func springFormat(f string) string {
if strings.ToLower(filepath.Ext(f)) == ".properties" {
return "properties"
}
return "yaml"
}
var springType = configType{
name: "spring", is: IsSpring, format: springFormat, parse: parseSpring,
jasyptSetting: func(string) string { return "jasypt.encryptor.password" },
placeholder: springPlaceholder,
}
// springPlaceholderRe is Spring's ${name} or ${name:default}.
var springPlaceholderRe = regexp.MustCompile(`^\$\{([^:}]+)(:([^}]*))?\}$`)
func springPlaceholder(v string) (name, def string, hasDef, ok bool) {
m := springPlaceholderRe.FindStringSubmatch(v)
if m == nil {
return "", "", false, false
}
return m[1], m[3], m[2] != "", true
}
func parseSpring(file string, b []byte) ([]configKey, error) {
var keys []configKey
var err error
if springFormat(file) == "properties" {
keys = parseProperties(b)
} else {
keys, err = parseSpringYAML(b)
}
for i := range keys {
keys[i].ref = reference(springPlaceholder, keys[i].value)
}
return keys, err
}
// springDocs names the keys of a multi-document file's later documents
// after the profile they're for, so each name is one key:
// spring.datasource.password (prod).
func springDocs(docs [][]configKey) []configKey {
var out []configKey
for i, doc := range docs {
suffix := ""
if i > 0 {
suffix = fmt.Sprintf(" (document %d)", i+1)
for _, k := range doc {
if k.key == "spring.config.activate.on-profile" || k.key == "spring.profiles" {
suffix = " (" + k.value + ")"
}
}
}
for _, k := range doc {
k.name = k.key + suffix
out = append(out, k)
}
}
return out
}
// mavenFiltered is a value Maven fills in at build time, @project.version@:
// not YAML until it's quoted.
var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`)
func parseSpringYAML(b []byte) ([]configKey, error) {
b = mavenFiltered.ReplaceAllFunc(b, func(m []byte) []byte {
sub := mavenFiltered.FindSubmatch(m)
return fmt.Appendf(nil, "%s'%s'", sub[1], bytes.ReplaceAll(sub[2], []byte("'"), []byte("''")))
})
dec := yaml.NewDecoder(bytes.NewReader(b))
var docs [][]configKey
for {
var doc yaml.Node
err := dec.Decode(&doc)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
var keys []configKey
if len(doc.Content) > 0 {
springWalk(doc.Content[0], "", &keys)
}
docs = append(docs, keys)
}
return springDocs(docs), nil
}
// springWalk names keys the way Spring does: nested keys joined by dots,
// list items by index (servers[0]).
func springWalk(n *yaml.Node, name string, out *[]configKey) {
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
child := n.Content[i].Value
if name != "" {
child = name + "." + child
}
springWalk(n.Content[i+1], child, out)
}
case yaml.SequenceNode:
for i, c := range n.Content {
springWalk(c, fmt.Sprintf("%s[%d]", name, i), out)
}
case yaml.AliasNode:
if n.Alias != nil {
springWalk(n.Alias, name, out)
}
case yaml.ScalarNode:
if name != "" {
*out = append(*out, configKey{key: name, value: n.Value, line: n.Line})
}
}
}
// parseProperties reads a Java .properties file: key=value, key: value or
// key value, lines continued with a trailing backslash, # and ! comments,
// and #--- between documents (Spring Boot 2.4+).
func parseProperties(b []byte) []configKey {
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
var docs [][]configKey
var doc []configKey
for i := 0; i < len(lines); i++ {
start := i + 1
line := strings.TrimLeftFunc(lines[i], unicode.IsSpace)
if line == "#---" || line == "!---" {
docs, doc = append(docs, doc), nil
continue
}
if line == "" || line[0] == '#' || line[0] == '!' {
continue
}
for continued(line) && i+1 < len(lines) {
i++
line = line[:len(line)-1] + strings.TrimLeftFunc(lines[i], unicode.IsSpace)
}
k, v := splitProperty(line)
doc = append(doc, configKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start})
}
return springDocs(append(docs, doc))
}
// continued reports whether the line ends in an odd number of backslashes.
func continued(line string) bool {
n := len(line) - len(strings.TrimRight(line, `\`))
return n%2 == 1
}
// splitProperty cuts a logical line at its first unescaped =, : or
// whitespace; whitespace around the separator isn't part of either side.
func splitProperty(line string) (key, value string) {
const space = " \t\f"
for i := 0; i < len(line); i++ {
switch c := line[i]; {
case c == '\\':
i++
case c == '=' || c == ':':
return line[:i], strings.TrimLeft(line[i+1:], space)
case strings.IndexByte(space, c) >= 0:
rest := strings.TrimLeft(line[i:], space)
if rest != "" && (rest[0] == '=' || rest[0] == ':') {
rest = strings.TrimLeft(rest[1:], space)
}
return line[:i], rest
}
}
return line, ""
}
func unescapeProperty(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '\\' || i+1 == len(s) {
b.WriteByte(s[i])
continue
}
i++
switch s[i] {
case 't':
b.WriteByte('\t')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 'f':
b.WriteByte('\f')
case 'u':
if i+5 <= len(s) {
if r, err := strconv.ParseUint(s[i+1:i+5], 16, 32); err == nil {
b.WriteRune(rune(r))
i += 4
continue
}
}
b.WriteByte('u')
default:
b.WriteByte(s[i])
}
}
return b.String()
}
-255
View File
@@ -1,255 +0,0 @@
package repo
import (
"context"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestIsSpring(t *testing.T) {
for f, want := range map[string]bool{
"src/main/resources/application.yml": true,
"application-prod.properties": true,
"config/application-local.yaml": true,
"bootstrap.yml": true,
"applicationset/dev/applications.yaml": false, // Argo CD's
"chart/templates/application.yaml": false, // Helm's
"application.yml.example": false,
"application.json": false,
"src/main/resources/applicationContext.xml": false,
} {
if IsSpring(f) != want {
t.Errorf("%s: %t", f, !want)
}
}
}
func springNames(keys []configKey) map[string]string {
out := map[string]string{}
for _, k := range keys {
out[k.name] = k.value
}
return out
}
func TestParseProperties(t *testing.T) {
got := springNames(parseProperties([]byte(`# a comment
! another
a=1
b : 2
c 3
d\:e=4
long=one, \
two
unicode=café
empty=
#---
spring.config.activate.on-profile=prod
a=prod-1
`)))
want := map[string]string{
"a": "1", "b": "2", "c": "3", "d:e": "4", "long": "one, two", "unicode": "café", "empty": "",
"spring.config.activate.on-profile (prod)": "prod", "a (prod)": "prod-1",
}
if !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestParseSpringYAML(t *testing.T) {
keys, err := parseSpringYAML([]byte(`info.version: @project.version@
artifacts:
- @project.artifactId@
spring:
datasource:
password: ${DB_PASSWORD}
servers:
- name: a
secret: x
logging.level:
com.example: DEBUG
---
spring:
config.activate.on-profile: prod
datasource:
password: hunter2
---
other: y
`))
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"info.version": "@project.version@", "artifacts[0]": "@project.artifactId@",
"spring.datasource.password": "${DB_PASSWORD}", "servers[0].name": "a", "servers[0].secret": "x",
"logging.level.com.example": "DEBUG",
"spring.config.activate.on-profile (prod)": "prod", "spring.datasource.password (prod)": "hunter2",
"other (document 3)": "y",
}
if got := springNames(keys); !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestSpringDefaults(t *testing.T) {
f, err := (&Provider{}).filterFor(t.TempDir(), "spring")
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]string{
"spring.datasource.password": "x",
"spring.security.oauth2.client.registration.a.client-secret": "x",
"profapp.payment.checkoutKey": "x",
"app.clients.sgig.sgig-passord": "x",
"jasypt.encryptor.password": "${JASYPT_ENCRYPTOR_PASSWORD}",
"spring.datasource.url": "jdbc:postgresql://app:hunter2@db:5432/app",
"app.servicebus.connection": "Endpoint=sb://x/;SharedAccessKeyName=a;SharedAccessKey=b",
"app.name": "ENC(abc==)",
} {
if !f.secret(key, value) {
t.Errorf("%s=%s isn't listed", key, value)
}
}
for key, value := range map[string]string{
"spring.security.oauth2.client.provider.a.token-uri": "https://idp/token",
"spring.security.oauth2.client.registration.a.authorization-grant-type": "client_credentials",
"app.frontend.keycloak.url": "https://keycloak",
"server.port": "8080",
"spring.datasource.url": "jdbc:postgresql://db:5432/app",
} {
if f.secret(key, value) {
t.Errorf("%s=%s is listed", key, value)
}
}
}
func TestSpringFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"src/main/resources/application.yml": `server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://db/app
password: ${DB_PASSWORD}
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD}
app:
api-key: ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)
`,
"src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n",
"applicationset/applications.yaml": "kind: ApplicationSet\n",
})
ctx := context.Background()
details := func(p *Provider, c provider.Container) map[string]provider.Attr {
attrs, err := p.Details(ctx, c)
if err != nil {
t.Fatal(err)
}
out := map[string]provider.Attr{}
for _, a := range attrs {
out[a.Label] = a
}
return out
}
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, err := p.Containers(ctx, srcs[0])
if err != nil {
t.Fatal(err)
}
var files []string
for _, c := range cs {
files = append(files, c.Name+" · "+c.Detail)
}
if want := []string{
"src/main/resources/application-local.properties · spring · properties",
"src/main/resources/application.yml · spring · yaml",
}; !slices.Equal(files, want) {
t.Fatalf("files %v\nwant %v", files, want)
}
local, yml := cs[0], cs[1]
listed, _ := p.Objects(ctx, yml, provider.TypeSecret)
keys := shown(listed)
var names []string
for _, k := range keys {
names = append(names, strings.TrimSpace(k.Name+" "+k.Detail))
}
if len(listed) != 5 || !listed[0].Hidden || listed[0].Name != "server.port" {
t.Errorf("hidden keys: %+v", listed)
}
if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) {
t.Errorf("keys %v, want %v", names, want)
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "hunter2" {
t.Errorf("value: %q %v", v.Reveal(), err)
}
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"}
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "${DB_PASSWORD}" {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
if f, _ := p.File(yml, &keys[1]); f.Line != 9 || f.Sops != "" {
t.Errorf("file: %+v", f)
}
// References and Jasypt aren't plain text in git; a password is.
if d := details(p, yml); d["hidden keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" {
t.Errorf("yml: %+v", d)
}
if d := details(p, local); !d["git"].Warn {
t.Errorf("local: %+v", d)
}
// The config's patterns, and the repo's over them.
all := []string{".*"}
p = New(Settings{Dir: root, SecretPatterns: SecretPatterns{Keys: &all}})
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 5 {
t.Errorf("every key: %d", len(shown(keys)))
}
lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"url$\"]\n values: []\n"), 0o644)
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 1 || shown(keys)[0].Name != "spring.datasource.url" {
t.Errorf("repo's patterns: %v", keys)
}
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"(\"]\n"), 0o644)
if _, err := p.Objects(ctx, yml, provider.TypeSecret); err == nil || !strings.Contains(err.Error(), ".lazysecrets.yaml: spring.secretPatterns.keys[0]") {
t.Errorf("bad pattern: %v", err)
}
}
func shown(objects []provider.Object) []provider.Object {
return slices.DeleteFunc(slices.Clone(objects), func(o provider.Object) bool { return o.Hidden })
}
func TestHiddenFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"a/application.yml": "server:\n port: 8080\n",
"b/application.yml": "spring.datasource.password: x\n",
})
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
if len(srcs) != 1 || srcs[0].Hidden {
t.Fatalf("sources: %+v", srcs)
}
cs, _ := p.Containers(ctx, srcs[0])
if len(cs) != 2 || !cs[0].Hidden || cs[1].Hidden {
t.Errorf("files: %+v", cs)
}
// A repo with only hidden files is hidden too.
root = gitRepo(t, map[string]string{"application.yml": "server:\n port: 8080\n"})
srcs, _ = New(Settings{Dir: root}).Sources(ctx)
if len(srcs) != 1 || !srcs[0].Hidden {
t.Errorf("hidden repo: %+v", srcs)
}
}
+2
View File
@@ -21,6 +21,8 @@ func NewSecretValue(b []byte) SecretValue {
// Reveal returns the plaintext. Call it only to display or copy the value.
func (v SecretValue) Reveal() string { return string(v.b) }
func (v SecretValue) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked }
+13 -7
View File
@@ -92,9 +92,15 @@ func (r Ref) ContainerName() string {
// Container.Detail.
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
var collections = map[provider.ObjectType]string{
provider.TypeSecret: "secrets",
provider.TypeKey: "keys",
provider.TypeCertificate: "certificates",
}
func typeOf(collection string) (provider.ObjectType, bool) {
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
if strings.EqualFold(t.Collection(), collection) {
for t, c := range collections {
if strings.EqualFold(c, collection) {
return t, true
}
}
@@ -112,7 +118,7 @@ func (r Ref) IdentifierURL() string {
if r.Type == "" {
return u + "/"
}
for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} {
for _, seg := range []string{collections[r.Type], r.Name, r.Version} {
if seg != "" {
u += "/" + url.PathEscape(seg)
}
@@ -134,7 +140,7 @@ func (r Ref) String() string {
segs = append(segs, r.Scope, r.Name)
default:
if r.Type != "" {
segs = append(segs, r.Type.Collection())
segs = append(segs, collections[r.Type])
}
segs = append(segs, r.Name, r.Version)
}
@@ -267,7 +273,7 @@ func parseLink(s string) (Ref, error) {
r.Host = strings.ToLower(segs[0])
rest := segs[1:]
switch host := strings.ToLower(u.Host); host {
switch strings.ToLower(u.Host) {
case "azure":
r.Kind = provider.KindAzure
if r.Host == "subscriptions" {
@@ -294,11 +300,11 @@ func parseLink(s string) (Ref, error) {
}
case "databricks", "repo":
r.Kind = provider.KindDatabricks
if host == "repo" {
if strings.EqualFold(u.Host, "repo") {
r.Kind = provider.KindRepo
}
if len(rest) > 2 {
return Ref{}, fmt.Errorf("%s link has too many parts", host)
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host))
}
if len(rest) > 0 {
r.Scope = rest[0]
+2 -7
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// Rules are what a key's value must be to share with it: random enough
@@ -41,18 +40,14 @@ func (r Rules) Check(key []byte) error {
kinds++
}
}
n := utf8.RuneCountInString(s)
n := len([]rune(s))
var has, needs []string
if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n))
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
}
if kinds < r.MinClasses {
kind := "kinds"
if kinds == 1 {
kind = "kind"
}
has = append(has, fmt.Sprintf("%d %s of character", kinds, kind))
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
}
if len(has) == 0 {
+1 -1
View File
@@ -174,7 +174,7 @@ func Read(r ref.Ref) (Sealed, error) {
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
}
n, size := binary.Uvarint(b[1:])
if size <= 0 {
if size <= 0 || n > uint64(len(b)) {
return Sealed{}, bad
}
rest := b[1+size:]
+29 -25
View File
@@ -3,11 +3,11 @@
package app
import (
"cmp"
"context"
"errors"
"fmt"
"image/color"
"math"
"os"
"os/exec"
"strings"
@@ -232,7 +232,6 @@ type Model struct {
// provider finds it (provider.Current), before Sources lists them all.
current *provider.Source
flatSources bool // Sources pane without provider groups
showHidden bool // H: what providers hide is listed too, dimmed
sourcesFavs bool // Sources pane on its Favourites tab
history bool // count uses of items (usage.go)
onActive func()
@@ -470,7 +469,6 @@ func New(providers []provider.Provider, opts Options) Model {
Filter: func(c provider.Container) string { return c.Name },
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
Render: m.containerRender(false),
Hidden: func(c provider.Container) bool { return c.Hidden },
})
m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{
@@ -480,7 +478,6 @@ func New(providers []provider.Provider, opts Options) Model {
// Typing "expir" finds what has expired or soon will.
FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
Render: m.objectRender(),
Hidden: func(o provider.Object) bool { return o.Hidden },
})
m.secrets.Empty = "no secrets"
m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret
@@ -657,7 +654,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case detailsMsg:
if msg.req == m.reqID[slotDetails] {
m.cancelReq(slotDetails)
m.done(slotDetails)
}
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again
@@ -688,7 +685,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotContainers] {
break
}
m.cancelReq(slotContainers)
m.done(slotContainers)
if msg.err != nil {
m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok {
@@ -706,7 +703,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotObjects] {
break
}
m.cancelReq(slotObjects)
m.done(slotObjects)
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
m.closeVersions()
@@ -727,7 +724,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.versionsOf != nil {
m.setVersions(msg.items)
} else {
m.secrets.SetItems(m.starredObjectsFirst(msg.items))
m.secrets.SetItems(m.starredFirst(msg.items))
cmds = append(cmds, m.refreshShown(before))
}
}
@@ -736,7 +733,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotValue] {
break
}
m.cancelReq(slotValue)
m.done(slotValue)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -751,7 +748,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotVersions] {
break
}
m.cancelReq(slotVersions)
m.done(slotVersions)
// Errors and unsupported providers just leave the count out.
if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
@@ -763,7 +760,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotDescribe] {
break
}
m.cancelReq(slotDescribe)
m.done(slotDescribe)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -778,7 +775,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotCopy] {
break
}
m.cancelReq(slotCopy)
m.done(slotCopy)
if msg.err != nil {
m.noteErr(msg.err)
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
@@ -890,7 +887,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Quit):
return nil, true
case key.Matches(msg, k.Help):
return m.showKeybindingsFor(""), false
return m.showKeybindings(), false
case key.Matches(msg, k.Up):
m.move(-1)
case key.Matches(msg, k.Down):
@@ -982,8 +979,6 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.cycleZoom(-1)
case key.Matches(msg, k.SourcesView):
m.toggleSourcesView()
case key.Matches(msg, k.ToggleHidden):
m.toggleHidden()
case key.Matches(msg, k.Versions):
if m.versionsOf != nil {
m.closeVersions()
@@ -1172,11 +1167,14 @@ func (m *Model) syncSelection() tea.Cmd {
if m.onLinks() {
m.showLinkedContainers()
} else if src, ok := m.selectedSource(); ok {
m.containers.Title = "[2] Vaults"
m.containers.Empty = "no vaults found"
switch src.Kind {
case provider.KindDatabricks:
m.containers.Title = "[2] Scopes"
m.containers.Empty = "no secret scopes"
case provider.KindRepo:
m.containers.Title = "[2] Files"
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
}
cmds = append(cmds, m.showContainers(src, id))
@@ -1261,7 +1259,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
m.secrets.SetLoading()
return m.loadObjects(c, m.objType, key)
}
m.secrets.SetItems(m.starredObjectsFirst(e.value))
m.secrets.SetItems(m.starredFirst(e.value))
m.secrets.Select(m.cache.cursor[key])
if e.fresh(m.now()) {
return nil
@@ -1321,7 +1319,8 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
})
}
// closeVersions returns the Secrets pane to the object list.
// closeVersions returns the Secrets pane to the object list, with the
// cursor on the object whose versions were shown.
func (m *Model) closeVersions() {
m.versionsOf = nil
}
@@ -1518,6 +1517,9 @@ func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
}
}
// done releases the context of a completed request.
func (m *Model) done(p slot) { m.cancelReq(p) }
// loadSources starts a new generation of source loads, superseding any in
// flight.
func (m *Model) loadSources() tea.Cmd {
@@ -1637,7 +1639,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
}
return nil
}
@@ -1680,8 +1682,12 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
// clipTick fires when the countdown's displayed second next changes.
func (m *Model) clipTick() tea.Cmd {
wait := m.clipDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.clipToken
return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} })
return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} })
}
func (m *Model) cancelClipClear() {
@@ -1788,15 +1794,13 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else {
reveal := "show"
if m.detail.State() == panes.ValueShown {
reveal = "hide"
}
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), reveal: reveal}.ShortHelp(), 2)
revealed := m.detail.State() == panes.ValueShown
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
}
var lines []string
if !m.clipDeadline.IsZero() {
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, secondsLeft(m.clipDeadline, m.now())))))
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
}
if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash))
+5 -36
View File
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
}
if !strings.Contains(lastLines(scr, 1), "z show") {
t.Errorf("help bar: %q", lastLines(scr, 1))
if !strings.Contains(lastLine(scr), "z show") {
t.Errorf("help bar: %q", lastLine(scr))
}
d.keys("z")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar)
}
d.keys("z")
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr)
}
bar := lastLines(scr, 1)
bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar)
}
@@ -1188,7 +1188,7 @@ func TestStatusLineCountsRepos(t *testing.T) {
t.Parallel()
d := newDriver(t, fake.Azure(0), fake.Repos())
// The status line, not the pane, which an expired secret's ⚠ fills.
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 4 repos") {
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 3 repos") {
t.Errorf("status line: %q", line)
}
d = newDriver(t, fake.Azure(0))
@@ -1344,34 +1344,3 @@ func TestErrorLog(t *testing.T) {
t.Errorf("log %+v", d.m.errLog)
}
}
// hiding marks the secret db-password hidden, as the repo provider does
// a Spring key that doesn't look secret.
type hiding struct{ provider.Provider }
func (h hiding) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
objs, err := h.Provider.Objects(ctx, c, t)
for i := range objs {
objs[i].Hidden = objs[i].Name == "db-password"
}
return objs, err
}
func TestToggleHidden(t *testing.T) {
t.Parallel()
d := newDriver(t, hiding{fake.Azure(0)})
if _, _, s := d.selected(); s == "db-password" || s == "" {
t.Fatalf("selected %q: the hidden one, or none", s)
}
if strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "1 hidden") {
t.Fatalf("hidden one listed:\n%s", d.screen())
}
d.keys("H")
if !strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "showing hidden") {
t.Fatalf("H doesn't show it:\n%s", d.screen())
}
d.keys("H")
if strings.Contains(d.screen(), "db-password") {
t.Fatalf("H again doesn't hide it:\n%s", d.screen())
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
if i < 0 || sameListing(before[i], s) {
return nil
}
m.detail.SetDescribed(s)
m.detail.Refresh(s)
uncache(m, m.cache.details, detailKey(s))
return m.describe(s)
}
+20 -10
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"context"
"fmt"
"os"
@@ -85,7 +84,10 @@ func (m *Model) showCommands() tea.Cmd {
}
var opts []option
for _, c := range cmds {
label := cmp.Or(c.Description, c.Command)
label := c.Description
if label == "" {
label = c.Command
}
if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
}
@@ -120,7 +122,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
in.SetValue(p.Default)
in.CursorEnd()
m.prompt = &modal{
title: cmp.Or(c.Description, "Command"), input: &in,
title: cmpOr(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1)
@@ -149,7 +151,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
}
m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
})
return nil
@@ -175,7 +177,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
// execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) {
switch {
case c.Value == "stdin":
@@ -193,7 +195,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
}
m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
@@ -217,7 +219,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmp.Or(msg.cmd.Description, "command")
name := cmpOr(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch {
case msg.err != nil:
@@ -296,7 +298,11 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String())
}
o.Version = version // "": the current value, like copying it
if version != "" {
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true
}
@@ -307,5 +313,9 @@ func newTextInput(prompt string, width int) textinput.Model {
return in
}
// pathInputWidth fits a link or a folder path in a prompt, in the window.
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
+1 -2
View File
@@ -8,7 +8,6 @@ import (
"time"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"github.com/charmbracelet/x/ansi"
)
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
@@ -35,7 +34,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
// temporary path's length.
shown := ""
if d.m.prompt != nil {
shown = ansi.Strip(strings.Join(d.m.prompt.lines, ""))
shown = ansiStrip(strings.Join(d.m.prompt.lines, ""))
}
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
+1 -1
View File
@@ -242,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
return tea.QuitMsg{}
}
}
if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{}
+4 -5
View File
@@ -6,7 +6,6 @@ import (
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -31,7 +30,7 @@ func (m Model) expiringItems() []expiringItem {
for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != expiry.None {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
}
@@ -52,11 +51,11 @@ func (m Model) expiringFields() []panes.Field {
var out []panes.Field
for _, it := range items {
state, left := m.expiry(it.o)
mark := "⚠ " + expiry.Left(left)
if state == expiry.Expired {
mark := "⚠ " + daysLeft(left)
if state == expired {
mark = "✗ expired"
}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String()
}
+15 -3
View File
@@ -4,6 +4,7 @@ import (
"time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -11,19 +12,30 @@ import (
// list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState = expiry.State
const (
notExpiring = expiry.None
expiresSoon = expiry.Soon
expired = expiry.Expired
)
// expiry tells whether o has expired or will within the warning period,
// and how long it has left.
func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
}
// expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state {
case expiry.Expired:
case expired:
return "expired"
case expiry.Soon:
case expiresSoon:
return "expiring"
}
return ""
}
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { return expiry.Left(d) }
+30 -74
View File
@@ -6,10 +6,8 @@ import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -97,22 +95,27 @@ func (m *Model) refreshContext(key, name string, path []string, src provider.Sou
// containerRender draws a vault or scope, starred or not. In the
// Opened links list it also names the source, since they come from many.
func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Container, int) string {
st := m.store
st, iconSet := m.store, m.icons
return func(sty styles.Styles, c provider.Container, width int) string {
n := st.Node(storeKey(c))
line := c.Name
if c.Source.Kind == provider.KindRepo {
// A secret file: its icon, and its type after it ("sops ·
// age", "env · plain text").
if icon := m.fileIcon(sty, c); icon != "" {
line = icon + " " + line
// A secret file: a lock for an encrypted one, a file in the
// warning colour for plain text, and its type after it
// ("sops · age", "env · plain text").
icon, style := iconSet.Encrypted, sty.KindRepo
if !strings.HasPrefix(c.Detail, "sops") {
icon, style = iconSet.PlainFile, sty.Warning
}
if icon != "" {
line = style.Render(icon) + " " + line
}
line += sty.Muted.Render(" " + c.Detail)
}
line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line)
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
if withSource {
src := c.Source.Name
if account := accountOf(c.Source); account != "" {
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
src = account + " / " + src
}
line += sty.Muted.Render(" · " + src)
@@ -130,81 +133,34 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
line = sty.Muted.Render(o.Name + " (disabled)")
}
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
if o.Detail != "" {
line += sty.Muted.Render(" " + o.Detail)
}
line = dimIf(sty, o.Hidden, line)
switch state, left := m.expiry(o); state {
case expiry.Expired:
case expired:
line += " " + sty.Error.Render("✗ expired")
case expiry.Soon:
line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
case expiresSoon:
line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
}
return withNote(sty, line, n.Note, width)
}
}
// fileIcon is a repo file's icon, in its colour: a lock for an encrypted
// one, a leaf for Spring config, a whale for compose, a file in the
// warning colour for plain text; "" for other vaults, or without icons.
func (m Model) fileIcon(sty styles.Styles, c provider.Container) string {
if c.Source.Kind != provider.KindRepo {
return ""
}
icon, style := m.icons.Encrypted, sty.KindRepo
switch {
case strings.HasPrefix(c.Detail, "spring"):
icon, style = m.icons.Spring, sty.Spring
case strings.HasPrefix(c.Detail, "compose"):
icon, style = m.icons.Compose, sty.Compose
case !strings.HasPrefix(c.Detail, "sops"):
icon, style = m.icons.PlainFile, sty.Warning
}
if icon == "" {
return ""
}
return style.Render(icon)
}
// dimIf draws a hidden item's row (shown with H) in the muted colour,
// whole, so it stands apart from what's listed by the provider's rules.
func dimIf(sty styles.Styles, hidden bool, line string) string {
if !hidden {
return line
}
return sty.Muted.Render(ansi.Strip(line))
}
// starredFirst moves starred items to the front, keeping the order
// otherwise. It doesn't modify items, which may be cached.
func starredFirst[T any](items []T, starred func(T) bool) []T {
rank := func(x T) int {
if starred(x) {
return 0
}
return 1
}
// starredContainersFirst moves starred vaults and scopes to the front,
// keeping the order otherwise. It doesn't modify items, which may be cached.
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
slices.SortStableFunc(out, func(a, b provider.Container) int {
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
})
return out
}
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
return starredFirst(sources, func(s provider.Source) bool {
return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
})
}
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
return starredFirst(items, func(c provider.Container) bool {
return m.store.Node(storeKey(c)).StarredFor(c.Source.Provider)
})
}
func (m Model) starredObjectsFirst(items []provider.Object) []provider.Object {
return starredFirst(items, func(o provider.Object) bool {
return m.store.Node(objectKey(o)).StarredFor(o.Container.Source.Provider)
// starredFirst moves starred objects to the front, keeping the order
// otherwise. It doesn't modify items, which may be cached.
func (m Model) starredFirst(items []provider.Object) []provider.Object {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Object) int {
return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider))
})
return out
}
// showLinkedContainers lists the vaults opened from links.
@@ -242,7 +198,7 @@ func (m *Model) resortStarred() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
}
if m.versionsOf == nil {
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
@@ -288,7 +244,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
switch {
+1 -2
View File
@@ -14,7 +14,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
)
// jump opens the favourites list, filters it and jumps to the first match.
@@ -430,7 +429,7 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("F")
var labels []string
for _, o := range d.m.search.shown {
labels = append(labels, ansi.Strip(o.label))
labels = append(labels, ansiStrip(o.label))
}
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels)
+4 -5
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"errors"
"fmt"
"strings"
@@ -62,7 +61,7 @@ func (m *Model) syncFields() tea.Cmd {
case !ok:
m.fields.Show("", nil)
case m.selectedProblem() != "":
m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup:
m.showAccount(r)
case r.kind == rowSource:
@@ -135,7 +134,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
}
return out
}
@@ -159,7 +158,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
case provider.KindRepo:
add("format", c.Detail)
}
add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL)
for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
@@ -222,5 +221,5 @@ func (m Model) problemFields(problem string) []panes.Field {
// fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value.
func fieldName(f panes.Field) string {
return cmp.Or(f.Label, f.Value)
return cmpOr(f.Label, f.Value)
}
+18 -9
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"fmt"
"slices"
"strings"
@@ -17,7 +16,7 @@ import (
)
// The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindingsFor).
// of them in a menu (showKeybindings).
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The
@@ -39,9 +38,10 @@ type contextHelp struct {
commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes
// reveal, when set, is what the reveal key will do, "show" or "hide":
// the same width so the bar doesn't shift.
reveal string
// revealed, when set, says whether the selected value is shown, so
// the reveal key says just what it'll do: show or hide, the same
// width so the bar doesn't shift.
revealed *bool
}
func (c contextHelp) ShortHelp() []key.Binding {
@@ -69,8 +69,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default:
reveal := k.Reveal
if c.reveal != "" {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal))
if c.revealed != nil {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
}
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
}
@@ -84,6 +84,11 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
// and group. Enter runs the selected one.
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the
// selected one. Opened from the panes, it lists the focused pane's keys
@@ -149,7 +154,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom")
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
}
}
@@ -258,7 +263,11 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des
// move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint {
return keyHint{joinKeys(up, down), "move"}
u, d := keys.Name(up), keys.Name(down)
if u == "" || d == "" {
return keyHint{u + d, "move"}
}
return keyHint{u + "/" + d, "move"}
}
// withKey fills a hint's format with the binding's key ("· %s loads
+20 -42
View File
@@ -38,16 +38,14 @@ func (m *Model) storeEntries() (favs, recent []option) {
return nil
}
var out []entry
detail := m.objectDetail(key)
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
account := ""
if p, ok := m.byID[acct]; ok {
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
}
account = strings.Trim(detail+" · "+account, " ·")
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
out = append(out, entry{depth, sort, option{
label: m.pathLabel(r.Kind, path, account, m.refFileIcon(r)), note: n.Note, mark: " ",
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
plain: m.inPlainEnv(r),
run: func(m *Model) tea.Cmd {
@@ -128,15 +126,12 @@ const recentMax = 20
// pathLabel draws a jump list entry: the provider's icon and the path in
// its colour, then the account.
func (m Model) pathLabel(kind provider.Kind, path []string, account, fileIcon string) string {
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string {
style := m.st.Kind(kind)
parts := make([]string, len(path))
for i, p := range path {
parts[i] = style.Render(p)
}
if fileIcon != "" && len(parts) > 1 {
parts[1] = fileIcon + " " + parts[1]
}
label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
if account != "" {
label += m.st.Muted.Render(" · " + account)
@@ -147,53 +142,25 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account, fileIcon st
// inPlainEnv reports whether r is a key in a plain env file, as far as
// the files loaded tell.
func (m Model) inPlainEnv(r ref.Ref) bool {
c, ok := m.repoFile(r)
return ok && r.Name != "" && c.Detail == provider.DetailPlainEnv
}
// repoFile is the repo file r is or is in, as far as the files loaded
// tell.
func (m Model) repoFile(r ref.Ref) (provider.Container, bool) {
if r.Kind != provider.KindRepo || r.IsSource() {
return provider.Container{}, false
if r.Kind != provider.KindRepo || r.Name == "" {
return false
}
file := r
file.Name, file.Type = "", ""
for _, e := range m.cache.containers {
for _, c := range e.value {
if storeKey(c) == file.String() {
return c, true
if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
return true
}
}
}
return provider.Container{}, false
}
// refFileIcon is the icon of the repo file r is or is in, if it's loaded.
func (m Model) refFileIcon(r ref.Ref) string {
if c, ok := m.repoFile(r); ok {
return m.fileIcon(m.st, c)
}
return ""
}
// objectDetail is the Detail of the secret with the store key key, as far
// as the lists loaded tell.
func (m Model) objectDetail(key string) string {
for _, e := range m.cache.objects {
for _, o := range e.value {
if o.Detail != "" && objectKey(o) == key {
return o.Detail
}
}
}
return ""
return false
}
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
// icon and colour, "app / apps/api/.env".
func (m Model) repoLabel(repo string, file ...string) string {
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "", "")
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "")
}
// sourceMatches reports whether src is the subscription or workspace r
@@ -232,7 +199,18 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil
}
choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" })
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if src.ID == n.Source && n.Source != "" {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) {
case 0:
return m.openRef(r, actGo)
+8 -3
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) {
t.Parallel()
d := newDriver(t)
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
t.Errorf("Sources help bar: %q", bar)
}
d.keys("3")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar)
}
}
@@ -225,6 +225,11 @@ func TestKeyPress(t *testing.T) {
}
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
return lines[len(lines)-1]
}
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel()
d := newDriver(t)
@@ -259,7 +264,7 @@ func TestVersionInStatus(t *testing.T) {
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen())
}
if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar)
}
}
+20 -25
View File
@@ -8,9 +8,9 @@ import (
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -209,7 +209,18 @@ const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) {
choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) })
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
if row, ok := preferredRow(r, choices); ok {
choices = []choice{{sourceRow: row}}
}
@@ -231,24 +242,6 @@ func (m *Model) openSource(r ref.Ref) {
}
}
// openWith offers each loaded source of kind that matches, one per
// account that lists it.
func (m Model) openWith(kind provider.Kind, matches func(provider.Source) bool) []choice {
var choices []choice
for _, p := range m.providers {
if p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if matches(src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
return choices
}
// refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The
@@ -269,7 +262,7 @@ func (m Model) refLabel(r ref.Ref) string {
default:
path = append(path, r.ContainerName(), r.Name)
}
label := m.pathLabel(r.Kind, path, "", m.refFileIcon(r))
label := m.pathLabel(r.Kind, path, "")
var extra []string
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
if r.IsContainer() {
@@ -440,7 +433,7 @@ func (m *Model) handleResolved(msg resolvedMsg) {
// (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices {
if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account {
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
return c.sourceRow, true
}
}
@@ -693,7 +686,7 @@ func (m *Model) share(menu bool) tea.Cmd {
share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
}
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
}
@@ -721,7 +714,7 @@ func (m *Model) share(menu bool) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
}
opts := []option{
{key: "o", label: "Lazysecrets link", run: share("o")},
option{key: "o", label: "Lazysecrets link", run: share("o")},
}
if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
@@ -837,8 +830,10 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
}
func (m *Model) startOpenPrompt() tea.Cmd {
in := newTextInput("open: ", m.pathInputWidth())
in := textinput.New()
in.Prompt = "open: "
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{
title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
+3 -1
View File
@@ -75,8 +75,10 @@ type modal struct {
// showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := newTextInput("/", 40)
in := textinput.New()
in.Prompt = "/"
in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0)
m.flash = flash{}
+4 -1
View File
@@ -3,6 +3,7 @@ package app
import (
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
@@ -57,9 +58,11 @@ func (m *Model) editNote() tea.Cmd {
c := o.Container
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source
}
in := newTextInput("note: ", 48)
in := textinput.New()
in.Prompt = "note: "
in.Placeholder = "e.g. prod SQL admin"
in.CharLimit = 80
in.SetWidth(48)
in.SetValue(m.store.Node(key).Note)
in.CursorEnd()
m.prompt = &modal{
+7 -5
View File
@@ -77,15 +77,17 @@ func TestNotesOnRows(t *testing.T) {
func TestWithNoteGivesWay(t *testing.T) {
t.Parallel()
st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 ||
!strings.HasSuffix(ansi.Strip(got), "prod SQL admin") {
t.Errorf("fits: %q", ansi.Strip(got))
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
t.Errorf("fits: %q", stripANSI(got))
}
if got := ansi.Strip(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
!strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", got)
}
if got := ansi.Strip(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
t.Errorf("no room: %q", got)
}
}
func stripANSI(s string) string { return ansi.Strip(s) }
+15 -18
View File
@@ -29,43 +29,41 @@ type persistTickMsg struct{}
type persistedMsg struct{ err error }
// keepers report whether a provider ID, or a cache key's, is an account
// that still exists and isn't a repo.
func (m *Model) keepers() (keep, keepKey func(string) bool) {
keep = cachefile.Keep(m.providers)
return keep, func(key string) bool { return keep(cachefile.ProviderOf(key)) }
}
// keepCached reports whether a cache key's account still exists and
// isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
// loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() {
f := cachefile.Read(m.cachePath)
keep, keepKey := m.keepers()
now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
for id, e := range f.Sources {
if keep(id) && recent(e.At) {
if m.keepProvider(id) && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At
}
}
for k, e := range f.Containers {
if keepKey(k) && recent(e.At) {
if m.keepCached(k) && recent(e.At) {
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
}
}
for k, e := range f.Objects {
if keepKey(k) && recent(e.At) {
if m.keepCached(k) && recent(e.At) {
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
}
}
for k, e := range f.Details {
if keepKey(k) && recent(e.At) {
if m.keepCached(k) && recent(e.At) {
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
}
}
for k, v := range f.Cursor {
if keepKey(k) {
if m.keepCached(k) {
m.cache.cursor[k] = v
}
}
@@ -74,30 +72,29 @@ func (m *Model) loadCacheFile() {
// cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New()
keep, keepKey := m.keepers()
f.Dropped, m.cacheDropped = m.cacheDropped, nil
for id, r := range m.sourceResults {
if r.err == nil && keep(id) {
if r.err == nil && m.keepProvider(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
}
}
for k, e := range m.cache.containers {
if keepKey(k) {
if m.keepCached(k) {
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
}
}
for k, e := range m.cache.objects {
if keepKey(k) {
if m.keepCached(k) {
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
}
}
for k, e := range m.cache.details {
if keepKey(k) {
if m.keepCached(k) {
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
}
}
for k, v := range m.cache.cursor {
if keepKey(k) {
if m.keepCached(k) {
f.Cursor[k] = v
}
}
+2 -3
View File
@@ -16,7 +16,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
)
const sopsFile = `db:
@@ -226,9 +225,9 @@ func TestSearchAllRepos(t *testing.T) {
var got []string
for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansi.Strip(o.label))
t.Errorf("not a repo: %s", ansiStrip(o.label))
}
got = append(got, ansi.Strip(o.label))
got = append(got, ansiStrip(o.label))
}
if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got)
+7 -16
View File
@@ -28,22 +28,12 @@ func (m *Model) startRevealTimer() tea.Cmd {
// revealTickCmd fires when the displayed second next changes.
func (m *Model) revealTickCmd() tea.Cmd {
token := m.revealToken
return tea.Tick(untilNextSecond(m.revealDeadline, m.now()), func(time.Time) tea.Msg { return revealTickMsg{token} })
}
// untilNextSecond is how long until a countdown to deadline shows its
// next whole second.
func untilNextSecond(deadline, now time.Time) time.Duration {
if wait := deadline.Sub(now) % time.Second; wait > 0 {
return wait
wait := m.revealDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
return time.Second
}
// secondsLeft is a countdown to deadline, in whole seconds rounded up.
func secondsLeft(deadline, now time.Time) int {
return max(0, int(math.Ceil(deadline.Sub(now).Seconds())))
token := m.revealToken
return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} })
}
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
@@ -59,7 +49,8 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
}
func (m *Model) showHidesIn() {
m.detail.SetHidesIn(fmt.Sprintf("%ds", secondsLeft(m.revealDeadline, m.now())))
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds())))
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
}
// hideValue hides the revealed value and stops its countdown.
+40 -45
View File
@@ -6,9 +6,9 @@ import (
"fmt"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -46,6 +46,10 @@ type crawlMsg struct {
err error
}
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group.
@@ -58,63 +62,49 @@ func (m *Model) searchEntries() (sources, items []option) {
var es []entry
// key is the store key, so ctrl+f stars the entry; mark shows how
// much is loaded, reload (ctrl+r) loads it again.
add := func(depth int, row string, kind provider.Kind, path []string, account, fileIcon, key, mark string, run, reload func(*Model) tea.Cmd) *option {
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) *option {
n := m.store.Node(key)
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account, fileIcon), note: n.Note, star: key,
starAccount: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)),
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt
}
// What providers hide is left out, unless H shows it, dimmed.
skip := func(hidden bool) bool { return hidden && !m.showHidden }
dim := func(hidden bool, e *option) {
if hidden {
e.label = m.st.Muted.Render(ansi.Strip(e.label))
}
}
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
if skip(src.Hidden) {
continue
}
src.Provider, src.ProviderLabel = p.ID(), p.Label()
row := sourceRowKey(p.ID(), src.ID)
dim(src.Hidden, add(0, row, src.Kind, []string{src.Name}, accountOf(src), "", sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil
}, func(m *Model) tea.Cmd { return m.loadSource(src) }))
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
for _, c := range m.cache.containers[row].value {
if skip(c.Hidden) {
continue
}
r, err := ref.ForContainer(c, provider.TypeSecret)
if err != nil {
continue
}
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
dim(c.Hidden, add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), m.fileIcon(m.st, c), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload))
}, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
r, err := ref.ForObject(o, "")
if err != nil || skip(o.Hidden) {
if err != nil {
continue
}
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·")
suffix := accountOf(src)
if t != provider.TypeSecret {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·")
}
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, m.fileIcon(m.st, c), objectKey(o), " ", func(m *Model) tea.Cmd {
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload)
e.plain = c.Detail == provider.DetailPlainEnv
dim(o.Hidden, e)
}
}
}
@@ -144,6 +134,8 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
}
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// providerFilter picks the providers a search or a load covers; nil is
// all of them.
type providerFilter func(provider.Provider) bool
@@ -156,7 +148,7 @@ func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id }
}
// coverageOf is the coverage of the providers in.
// coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage
for _, p := range m.providers {
@@ -191,7 +183,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
// searchLines says what the search covers.
func (m *Model) searchLines() []string {
c := m.coverageOf(nil)
c := m.searchCoverage()
switch {
case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
@@ -218,8 +210,11 @@ func plural(n int, word string) string {
return fmt.Sprintf("%d %ss", n, word)
}
// startCrawlOf loads, for the providers in, every unlisted subscription's
// vaults and every vault's secrets not loaded yet.
// startCrawl loads every unlisted subscription's vaults and every vault's
// secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers {
if in != nil && !in(p) {
@@ -306,7 +301,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Containers(ctx, src)
for i := range items {
@@ -319,7 +314,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items {
@@ -356,19 +351,6 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
return cmd
}
// vaultsLoaded counts the vaults whose secrets are cached or can't be
// read, and says whether any are loading.
func (m *Model) vaultsLoaded(vaults []provider.Container) (loaded int, loading bool) {
for _, c := range vaults {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
return loaded, loading
}
// sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string {
@@ -382,7 +364,14 @@ func (m *Model) sourceMark(row string) string {
if !ok {
return m.st.Muted.Render("○")
}
loaded, loading := m.vaultsLoaded(e.value)
loaded, loading := 0, false
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
switch {
case loading:
return m.st.Muted.Render("◌")
@@ -407,3 +396,9 @@ func (m *Model) vaultMark(c provider.Container) string {
}
return " "
}
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+20 -19
View File
@@ -6,7 +6,6 @@ import (
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi"
)
@@ -116,19 +115,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Fatal("platform-dev not in sources")
}
}
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "○") {
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") {
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
}
if len(d.m.search.shown) != 0 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
}
d.send(ctrl('r'))
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "●") {
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") {
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
}
for _, o := range d.m.search.shown {
if !strings.Contains(ansi.Strip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansi.Strip(o.label))
if !strings.Contains(ansiStrip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
}
}
if len(d.m.search.shown) == 0 {
@@ -178,17 +177,19 @@ func TestStarFromSearch(t *testing.T) {
}
}
func ansiStrip(s string) string { return ansi.Strip(s) }
func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch()
v := d.m.search
scope := func() string { return ansi.Strip(v.scopes[v.src].label) }
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
if scope() != "All" {
t.Errorf("opened on %q, want All", scope())
}
if v.res >= len(v.shown) || !strings.Contains(ansi.Strip(v.shown[v.res].label), "kv-shared-prod") {
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen())
}
d.send(ctrl('s')) // narrows to the source, and back
@@ -232,13 +233,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
t.Errorf("no results in the account:\n%s", d.screen())
}
for _, o := range v.shown {
if cachefile.ProviderOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansi.Strip(o.label))
if providerOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label))
}
}
// The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || cachefile.ProviderOf(next.group) != header.provider || !strings.HasPrefix(ansi.Strip(next.label), " ") {
t.Errorf("after the account: %q", ansi.Strip(next.label))
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label))
}
}
@@ -326,7 +327,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
t.Fatal("no results")
}
o := d.m.search.shown[0]
plain := []rune(ansi.Strip(o.label))
plain := []rune(ansiStrip(o.label))
var got string
for _, h := range o.hits {
got += string(plain[h])
@@ -347,14 +348,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("F")
bar := lastLines(d.screen(), 1)
bar := lastLine(d.screen())
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want)
}
}
d.keys("/")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
t.Errorf("typing: help bar %q", bar)
}
}
@@ -374,8 +375,8 @@ func TestHelpFromPopups(t *testing.T) {
if o.header {
sections = append(sections, o.label)
}
if strings.Contains(ansi.Strip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label))
if strings.Contains(ansiStrip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
}
}
if strings.Join(sections, ",") != "Go to,While typing, after /" {
@@ -427,7 +428,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLines(d.screen(), 1))
bar := strings.TrimSpace(lastLine(d.screen()))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar)
}
@@ -442,11 +443,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for i := 0; !strings.Contains(ansi.Strip(d.m.search.scope().label), "platform-dev"); i++ {
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
if i > len(d.m.search.scopes) {
var labels []string
for _, o := range d.m.search.scopes {
labels = append(labels, ansi.Strip(o.label))
labels = append(labels, ansiStrip(o.label))
}
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
}
+31 -7
View File
@@ -95,7 +95,9 @@ func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
in := newTextInput("/", searchWidth(m.w)-6) // as wide as the popup
in := textinput.New()
in.Prompt = "/"
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
v := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries()
m.search = v
@@ -201,7 +203,7 @@ func (m *Model) fillSearch(keep bool) {
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
})
for _, o := range sources {
if cachefile.ProviderOf(o.group) == p.ID() {
if providerOf(o.group) == p.ID() {
o.label = " " + o.label
scopes = append(scopes, o)
}
@@ -265,7 +267,7 @@ func (v *searchView) match() []option {
default:
account, ofAccount := v.scopeAccount()
for _, o := range v.items {
if sc.id == allSources || ofAccount && cachefile.ProviderOf(o.group) == account || sc.group != "" && o.group == sc.group {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o)
}
}
@@ -521,7 +523,7 @@ func (m *Model) searchLoads() tea.Cmd {
return m.startCrawlOf(v.scopeFilter())
}
if !v.onSource() {
return m.startCrawlOf(nil)
return m.startCrawl()
}
providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources {
@@ -614,7 +616,13 @@ func (m Model) loadHint() string {
}
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
}
loaded, _ := m.vaultsLoaded(e.value)
loaded := 0
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
if loaded == len(e.value) {
return ""
}
@@ -762,7 +770,7 @@ func (m Model) searchViewBox() string {
body = append(body, " "+m.st.Error.Render(l))
}
}
body = append(body, "", " "+panes.Tabs(m.st, ts, tabNames[:], m.search.tab))
body = append(body, "", " "+m.goToTabs(ts))
}
body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") {
@@ -795,7 +803,7 @@ func (m Model) searchViewBox() string {
// Tabs in the title, the current one underlined, like the panes; a
// picker has its own title, and the tabs over the input.
if v.picker == nil {
head := ts.Render(goToTitle+" ") + panes.Tabs(m.st, ts, tabNames[:], m.search.tab)
head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
}
// A picker: in its own border, under what it's for.
@@ -848,3 +856,19 @@ func pickable(o option) (ref.Ref, bool) {
}
return r, true
}
// goToTabs draws Go to's tabs, the current one underlined.
func (m Model) goToTabs(ts lipgloss.Style) string {
var out string
for i, name := range tabNames {
if i > 0 {
out += ts.Render(" - ")
}
if i == m.search.tab {
out += ts.Underline(true).Render(name)
} else {
out += m.st.Muted.Render(name)
}
}
return out
}
+6 -3
View File
@@ -10,6 +10,7 @@ import (
"slices"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
@@ -71,7 +72,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
return msg
}
for _, c := range all {
if c.Detail == provider.DetailPlainEnv && !c.Hidden { // not .env.example
if c.Detail == provider.DetailPlainEnv {
cs = append(cs, c)
}
}
@@ -303,7 +304,7 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
}
// openFailed says why a share link couldn't be decrypted, in a popup
// where its offer was: its first line (wrapped), or, with whole, all of it
// where its offer was: its first line (wrapped), or with whole all of it
// (Key Vault's refusals say what to check on later lines). From there,
// the key's secret can be gone to: its version, which may be disabled.
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
@@ -337,7 +338,9 @@ func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
// askShareDir asks where to save files from a repo that isn't here. It
// stays until they're saved, saying why when they can't be.
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
in := newTextInput("folder: ", m.pathInputWidth())
in := textinput.New()
in.Prompt = "folder: "
in.SetWidth(max(20, min(m.w-12, 64)))
dir := m.placeDir
if dir == "" {
dir, _ = os.Getwd()
+57 -56
View File
@@ -59,7 +59,6 @@ func sourceRowID(r sourceRow) string {
}
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
render := renderSourceRow(iconSet, mem, recent)
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID,
// Headers have no filter text, so filtering shows just sources.
@@ -78,48 +77,40 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
},
Render: func(st styles.Styles, r sourceRow, width int) string {
return dimIf(st, r.kind == rowSource && r.source.Hidden, render(st, r, width))
node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent, time.Now())
}
switch {
case r.kind == rowLinks:
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
case r.kind == rowSource && r.top:
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
case r.kind == rowSource && r.flat:
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
if r.account != "" {
line += st.Muted.Render(" · " + r.account)
}
return withNote(st, line+here(st, r.source), node.Note, width)
case r.kind == rowSource:
// The provider's colour, so long groups stay recognisable.
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
}
arrow := "▾"
if r.collapsed {
arrow = "▸"
}
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
if r.status == "!" {
status = st.Error.Render("(!)")
}
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
},
})
}
// renderSourceRow draws a source, a provider's header or the Opened links
// row.
func renderSourceRow(iconSet icons.Set, mem *store.Store, recent bool) func(styles.Styles, sourceRow, int) string {
return func(st styles.Styles, r sourceRow, width int) string {
node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent, time.Now())
}
switch {
case r.kind == rowLinks:
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
case r.kind == rowSource && r.top:
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
case r.kind == rowSource && r.flat:
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
if r.account != "" {
line += st.Muted.Render(" · " + r.account)
}
return withNote(st, line+here(st, r.source), node.Note, width)
case r.kind == rowSource:
// The provider's colour, so long groups stay recognisable.
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
}
arrow := "▾"
if r.collapsed {
arrow = "▸"
}
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
if r.status == "!" {
status = st.Error.Render("(!)")
}
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
}
}
// rebuildSources lays out the Sources pane from the per-provider results.
func (m *Model) rebuildSources() {
// While filtering, headers drop out, so rows show their provider and
@@ -138,16 +129,20 @@ func (m *Model) rebuildSources() {
}
// The repo lazysecrets started in gets a row of its own on top, so it
// opens on it; flat, it sorts first instead.
if src, ok := m.currentSource(); ok && !flat && (!src.Hidden || m.showHidden) {
if src, ok := m.currentSource(); ok && !flat {
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
}
for _, p := range m.providers {
kind, id := p.Kind(), p.ID()
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ")
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id]
sources := slices.DeleteFunc(slices.Clone(r.sources), func(s provider.Source) bool {
return (s.Here && !flat) || (s.Hidden && !m.showHidden)
})
sources := r.sources
if !flat {
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here })
}
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
continue // repos show once some are found to have secret files
}
@@ -220,6 +215,17 @@ func (m Model) currentSource() (provider.Source, bool) {
return provider.Source{}, false
}
// starredSourcesFirst moves starred sources to the front, keeping the
// order otherwise. It doesn't modify sources.
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
out := slices.Clone(sources)
rank := func(s provider.Source) int {
return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider))
}
slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) })
return out
}
// starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int {
switch {
@@ -233,6 +239,13 @@ func starRank(mem *store.Store, r sourceRow) int {
return 3
}
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected()
@@ -247,18 +260,6 @@ func (m Model) onLinks() bool {
return m.sources.SelectedID() == linksRowID
}
// toggleHidden lists what providers hide, dimmed, or leaves it out again.
func (m *Model) toggleHidden() {
m.showHidden = !m.showHidden
m.containers.SetShowHidden(m.showHidden)
m.secrets.SetShowHidden(m.showHidden)
m.rebuildSources()
m.flash = flash{text: "hidden items hidden"}
if m.showHidden {
m.flash = flash{text: "showing hidden items, dimmed"}
}
}
// toggleSourcesView switches between grouped and flat, keeping the cursor
// on the same source.
func (m *Model) toggleSourcesView() {
+2 -3
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"errors"
"fmt"
"strings"
@@ -84,7 +83,7 @@ func (m Model) statusFields() []panes.Field {
}
}
// A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")}
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
}
@@ -160,7 +159,7 @@ func (m Model) accountStatus(p provider.Provider) string {
return r.err.Error()
}
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmp.Or(noun, "source"))
return plural(len(r.sources), cmpOr(noun, "source"))
}
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
+11 -1
View File
@@ -2,11 +2,13 @@ package app
import (
"context"
"errors"
"strings"
"time"
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/update"
)
@@ -128,7 +130,15 @@ func (m *Model) showUpdate() {
run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" {
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
return m.openPage("the release page", r.URL, func() {})
switch err := m.openURL(r.URL); {
case errors.Is(err, browser.ErrRemote):
cmd := m.copyText("link to the release", r.URL, false)
m.flash.text += " (can't open a browser over SSH)"
return cmd
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
}
return nil
}})
}
m.showMenu("lazysecrets "+r.Tag, lines, opts)
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
)
func TestRecentScope(t *testing.T) {
@@ -22,7 +21,7 @@ func TestRecentScope(t *testing.T) {
d.keys("R")
var recent []string
for _, o := range d.m.search.shown {
recent = append(recent, ansi.Strip(o.label))
recent = append(recent, ansiStrip(o.label))
}
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
t.Fatalf("want newest first: %q", recent)
@@ -32,7 +31,7 @@ func TestRecentScope(t *testing.T) {
t.Errorf("jumped to %q", s)
}
d.keys("F")
if len(d.m.search.shown) != 1 || !strings.Contains(ansi.Strip(d.m.search.shown[0].label), "kv-shared-prod") {
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") {
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
}
}
-4
View File
@@ -11,8 +11,6 @@ type Set struct {
Repo string
Encrypted string // an encrypted file (SOPS)
PlainFile string // a plain text secrets file (.env)
Spring string // a Spring Boot config file
Compose string // a Docker Compose file
Note string // has a note (lazysecrets list)
}
@@ -27,8 +25,6 @@ var NerdFont = Set{
Repo: "\U000F02A2", // md-git
Encrypted: "\U000F033E", // md-lock
PlainFile: "\U000F0214", // md-file
Spring: "\U000F032A", // md-leaf
Compose: "\U000F0868", // md-docker
Note: "\U000F09ED",
}
+15 -8
View File
@@ -15,8 +15,8 @@ package keys
import (
"fmt"
"maps"
"slices"
"sort"
"strings"
"charm.land/bubbles/v2/key"
@@ -45,7 +45,6 @@ type Map struct {
Recent key.Binding
Note, Share, ShareMenu, OpenRef key.Binding
Search, SourcesView, Commands key.Binding
ToggleHidden key.Binding
SplitLeft, SplitRight, SplitReset key.Binding
Taller, Shorter, ZoomIn, ZoomOut key.Binding
Login, Refresh, Help, Quit key.Binding
@@ -103,7 +102,6 @@ func (m *Map) Actions() []Action {
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: Spring and compose keys that don't look like secrets, example files (.env.example), and files and repos with only those. Shown, they're dimmed; Go to finds them too."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
@@ -187,7 +185,7 @@ var defaults = map[string][]string{
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.open": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.toggleHidden": {"H"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"},
"lists.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
@@ -268,11 +266,11 @@ func (m *Map) Apply(cfg map[string]map[string][]string) error {
byName[a.Context+"."+a.Name] = a
contexts[a.Context] = true
}
for _, ctx := range slices.Sorted(maps.Keys(cfg)) {
for _, ctx := range sortedKeys(cfg) {
if !contexts[ctx] {
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(slices.Sorted(maps.Keys(contexts)), ", "))
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
}
for _, name := range slices.Sorted(maps.Keys(cfg[ctx])) {
for _, name := range sortedKeys(cfg[ctx]) {
a, ok := byName[ctx+"."+name]
if !ok {
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
@@ -291,7 +289,16 @@ func (m *Map) names(ctx string) []string {
out = append(out, a.Name)
}
}
slices.Sort(out)
sort.Strings(out)
return out
}
func sortedKeys[V any](set map[string]V) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
-17
View File
@@ -69,20 +69,3 @@ func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
}
return st.TitleBlurred
}
// Tabs draws tab names for a title in style ts, the current one
// underlined and the rest muted.
func Tabs(st styles.Styles, ts lipgloss.Style, names []string, current int) string {
var out string
for i, name := range names {
if i > 0 {
out += ts.Render(" - ")
}
if i == current {
out += ts.Underline(true).Render(name)
} else {
out += st.Muted.Render(name)
}
}
return out
}
+31 -33
View File
@@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) {
d.container = &s.Container
}
// 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.
func (d *Detail) ShowContainer(c provider.Container) {
d.Clear()
@@ -188,31 +198,20 @@ func carryStyle(lines []string) []string {
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.
func (d Detail) lines(st styles.Styles) []string {
inner := max(1, d.w-3)
var out []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"))
out = append(out, fieldLabel(st, label)+wrapped[0])
out = append(out, prefix+wrapped[0])
for _, w := range wrapped[1:] {
out = append(out, valueIndent+w)
out = append(out, indent+w)
}
}
muted := st.Muted.Render
note := func() {
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
}
if d.container == nil {
return []string{muted(" Select a vault or scope to browse its secrets.")}
@@ -222,27 +221,30 @@ func (d Detail) lines(st styles.Styles) []string {
if containerLabel == "" {
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 {
field(containerLabel, c.Name)
note()
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
if c.Detail != "" {
field("", muted(c.Detail))
}
field("source", source)
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
out = append(out, "", muted(" Select a secret to see its details."))
return out
}
s := *d.secret
field("name", s.Name)
note()
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
field(containerLabel, c.Name)
if c.Source.Kind != provider.KindAzure && c.Detail != "" {
field("", muted(c.Detail))
}
field("source", source)
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
if s.Version != "" {
field("version", s.Version)
}
@@ -306,12 +308,14 @@ func (d Detail) lines(st styles.Styles) []string {
case provider.TypeCertificate:
label, hidden, reveal = "cert", "", "show the certificate"
}
indent := strings.Repeat(" ", 12)
out = append(out, "")
switch d.state {
case ValueHidden:
if hidden != "" {
field(label, hidden)
out = append(out, valueIndent+hints(reveal))
out = append(out, indent+hints(reveal))
} else {
field(label, hints(reveal))
}
@@ -328,29 +332,23 @@ func (d Detail) lines(st styles.Styles) []string {
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
w = lead + w
if i == 0 && j == 0 {
out = append(out, fieldLabel(st, label)+st.Error.Render(w))
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w))
} else {
out = append(out, valueIndent+st.Error.Render(w))
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
}
}
}
case ValueShown:
if d.value.Reveal() == "" {
// Not in the value's colour, nor a word a value could be.
field(label, st.Muted.Italic(true).Render("∅ no value (an empty string)"))
out = append(out, valueIndent+hints("hide"))
break
}
// Where the dots were, continuing under itself; the keys below.
first := true
lines, note := valueView(d.value.Reveal())
for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first {
out = append(out, fieldLabel(st, label)+st.Value.Render(w))
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
first = false
} else {
out = append(out, valueIndent+st.Value.Render(w))
out = append(out, indent+st.Value.Render(w))
}
}
}
@@ -364,7 +362,7 @@ func (d Detail) lines(st styles.Styles) []string {
if d.hidesIn != "" {
more = append(more, "hides in "+d.hidesIn)
}
out = append(out, valueIndent+hints("hide", more...))
out = append(out, indent+hints("hide", more...))
}
return append(out, d.versionLines(st)...)
}
@@ -385,7 +383,7 @@ func (d Detail) versionLines(st styles.Styles) []string {
head += muted(" · " + d.VersionsKey + " to browse")
}
}
out := []string{"", fieldLabel(st, "versions") + head}
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
current := d.versions[0].Version
for i, v := range d.versions {
if i == maxVersionLines {
-20
View File
@@ -3,12 +3,6 @@ package panes
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
func TestWrappedValueKeepsItsStyle(t *testing.T) {
@@ -21,17 +15,3 @@ func TestWrappedValueKeepsItsStyle(t *testing.T) {
t.Errorf("the style went on past its reset: %q", got[2])
}
}
func TestEmptyValue(t *testing.T) {
d := NewDetail(icons.None)
d.SetSize(80, 20)
d.ShowSecret(provider.Object{Name: "spring.datasource.password", Type: provider.TypeSecret})
d.SetValue(provider.NewSecretValue(nil))
if view := ansi.Strip(d.View(styles.Default(), true, "")); !strings.Contains(view, "∅ no value (an empty string)") {
t.Errorf("empty value:\n%s", view)
}
d.SetValue(provider.NewSecretValue([]byte("empty")))
if view := ansi.Strip(d.View(styles.Default(), true, "")); strings.Contains(view, "∅") || !strings.Contains(view, "empty") {
t.Errorf("the word empty:\n%s", view)
}
}
+1
View File
@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/x/ansi"
)
// sgr matches SGR escape sequences.
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
// Highlight draws a cursor row: line, padded to width, on style's
+45 -37
View File
@@ -22,9 +22,6 @@ type Item[T any] struct {
// text the row doesn't show (e.g. the provider of a grouped source).
FilterExtra func(T) string
Render func(st styles.Styles, v T, width int) string
// Hidden, if set, says which items are listed only while the list
// shows hidden ones (SetShowHidden).
Hidden func(T) bool
}
// List is a scrollable, filterable list pane with loading and error states.
@@ -56,9 +53,6 @@ type List[T any] struct {
input textinput.Model
filtering bool
showHidden bool
hidden int // items left out for being hidden
}
func NewList[T any](title string, item Item[T]) List[T] {
@@ -83,12 +77,6 @@ func (l *List[T]) SetItems(items []T) {
l.applyFilter(prev)
}
// SetShowHidden lists hidden items too, or leaves them out again.
func (l *List[T]) SetShowHidden(on bool) {
l.showHidden = on
l.applyFilter(l.SelectedID())
}
// Reset clears items, error, filter and cursor.
func (l *List[T]) Reset() {
l.items, l.visible = nil, nil
@@ -249,28 +237,21 @@ func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd {
// applyFilter recomputes the visible items and puts the cursor back on the
// item with ID keep if it is still visible.
func (l *List[T]) applyFilter(keep string) {
l.visible, l.hits, l.hidden = l.visible[:0], nil, 0
shown := make([]int, 0, len(l.items))
for i, v := range l.items {
if !l.showHidden && l.item.Hidden != nil && l.item.Hidden(v) {
l.hidden++
continue
}
shown = append(shown, i)
}
l.visible, l.hits = l.visible[:0], nil
if q := l.input.Value(); q == "" {
l.visible = append(l.visible, shown...)
for i := range l.items {
l.visible = append(l.visible, i)
}
} else {
// The row's own text is the name; FilterExtra (a note, a provider)
// is context, matched too but ranked lower.
items := make([]match.Item, len(shown))
for j, i := range shown {
v := l.items[i]
items := make([]match.Item, len(l.items))
for i, v := range l.items {
text := l.item.Filter(v)
items[j] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
if l.item.FilterExtra != nil {
if extra := l.item.FilterExtra(v); extra != "" {
items[j].Text += " " + extra
items[i].Text += " " + extra
}
}
}
@@ -281,7 +262,7 @@ func (l *List[T]) applyFilter(keep string) {
hits = append(hits, p)
}
}
l.visible = append(l.visible, shown[h.Index])
l.visible = append(l.visible, h.Index)
l.hits = append(l.hits, hits)
}
}
@@ -320,6 +301,15 @@ func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Rang
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:
// 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.
@@ -348,7 +338,17 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
ts := TitleStyle(st, focused)
title := ts.Render(l.Title)
if len(l.Tabs) > 1 {
title += ts.Render(" ") + Tabs(st, ts, l.Tabs, l.Tab)
for i, tab := range l.Tabs {
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 {
title += ts.Render(" /" + q)
@@ -372,22 +372,33 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
for _, line := range wrapLines(l.Err.Error(), inner-1) {
body = append(body, st.Error.Render(" "+line))
}
case len(l.visible) == 0 && len(l.items) > l.hidden:
case len(l.visible) == 0 && len(l.items) > 0:
body = append(body, st.Muted.Render(" no matches"))
case len(l.visible) == 0 && l.hidden > 0:
body = append(body, st.Muted.Render(fmt.Sprintf(" %s (%d hidden)", l.Empty, l.hidden)))
case len(l.visible) == 0:
body = append(body, st.Muted.Render(" "+l.Empty))
default:
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 = " " + MarkMatches(line, l.highlight(i, ansi.Strip(line)), st.Match)
plain := ansi.Strip(line)
hits := l.highlight(i, plain)
if i == l.cursor {
base := st.SelectedBlurred
if focused {
base = st.Selected
}
line = Highlight(ansi.Truncate(line, inner, "…"), base, inner)
text := " " + line
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)
}
@@ -397,9 +408,6 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
if len(l.visible) > 0 {
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
}
if l.hidden > 0 {
counter = strings.Trim(counter+fmt.Sprintf(" · %d hidden", l.hidden), " ·")
}
if l.Loading && l.Age != "" && len(l.items) > 0 {
counter = "cached " + l.Age + " · " + counter
}
-38
View File
@@ -201,41 +201,3 @@ func TestListHeader(t *testing.T) {
t.Errorf("no room, the text:\n%s", v)
}
}
func TestListHidden(t *testing.T) {
l := NewList("Items", Item[string]{
ID: func(s string) string { return s },
Filter: func(s string) string { return s },
Render: func(_ styles.Styles, s string, _ int) string { return s },
Hidden: func(s string) bool { return strings.HasPrefix(s, "hidden") },
})
l.SetSize(40, 7)
l.SetItems([]string{"shown-a", "hidden-b", "shown-c"})
view := ansi.Strip(l.View(styles.Default(), true, ""))
if l.Len() != 2 || strings.Contains(view, "hidden-b") || !strings.Contains(view, "1 hidden") {
t.Fatalf("hidden listed:\n%s", view)
}
l.Move(1)
l.SetShowHidden(true)
if l.Len() != 3 || l.SelectedID() != "shown-c" {
t.Fatalf("shown: %d, selected %q", l.Len(), l.SelectedID())
}
l.StartFilter()
l.input.SetValue("hid")
l.applyFilter("")
if l.Len() != 1 || l.SelectedID() != "hidden-b" {
t.Fatalf("filtered with hidden shown: %d %q", l.Len(), l.SelectedID())
}
l = NewList("Items", Item[string]{
ID: func(s string) string { return s }, Filter: func(s string) string { return s },
Render: func(_ styles.Styles, s string, _ int) string { return s },
Hidden: func(string) bool { return true },
})
l.Empty = "no secrets"
l.SetSize(40, 7)
l.SetItems([]string{"a", "b"})
if view := ansi.Strip(l.View(styles.Default(), true, "")); !strings.Contains(view, "no secrets (2 hidden)") {
t.Fatalf("all hidden:\n%s", view)
}
}
-4
View File
@@ -34,8 +34,6 @@ type Styles struct {
KindAzure lipgloss.Style
KindDatabricks lipgloss.Style
KindRepo lipgloss.Style
Spring lipgloss.Style // Spring Boot config files
Compose lipgloss.Style // Docker Compose files
// themed marks the selection colours as set by the theme, so
// OnBackground leaves them.
@@ -72,8 +70,6 @@ func Default() Styles {
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
Spring: lipgloss.NewStyle().Foreground(lipgloss.Green),
Compose: lipgloss.NewStyle().Foreground(lipgloss.Blue),
}
}
+8 -8
View File
@@ -3,8 +3,8 @@ package styles
import (
"fmt"
"image/color"
"maps"
"slices"
"sort"
"strconv"
"strings"
@@ -42,8 +42,6 @@ var Roles = map[string]string{
"azureColor": "Azure sources and vaults",
"databricksColor": "Databricks workspaces and scopes",
"repoColor": "repos and their secret files (SOPS)",
"springColor": "Spring Boot config files",
"composeColor": "Docker Compose files",
}
// FromTheme returns the default styles with the theme's roles applied, and
@@ -53,7 +51,12 @@ var Roles = map[string]string{
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default()
var fg color.Color
for _, role := range slices.Sorted(maps.Keys(theme)) {
roles := make([]string, 0, len(theme))
for r := range theme {
roles = append(roles, r)
}
sort.Strings(roles)
for _, role := range roles {
if _, ok := Roles[role]; !ok {
continue
}
@@ -101,10 +104,6 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st.KindDatabricks = v.fg(lipgloss.NewStyle())
case "repoColor":
st.KindRepo = v.fg(lipgloss.NewStyle())
case "springColor":
st.Spring = v.fg(lipgloss.NewStyle())
case "composeColor":
st.Compose = v.fg(lipgloss.NewStyle())
}
}
return st, fg, nil
@@ -132,6 +131,7 @@ type value struct {
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 {
if v.color != nil {
s = s.Foreground(v.color)
+5 -3
View File
@@ -89,9 +89,14 @@ func (r Release) Notes(current string) string {
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.
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]+)\)`)
// Client talks to the release servers.
@@ -143,9 +148,6 @@ func (c *Client) Latest(ctx context.Context) (Release, error) {
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) {
api, err := API(repo)
if err != nil {
+8 -28
View File
@@ -3,9 +3,8 @@
# it, tags the commit and pushes both; the tag runs
# .gitea/workflows/release.yml, which builds and publishes the release.
#
# scripts/release.sh minor # or patch, major: after the latest release
# scripts/release.sh v0.2.0 # or v0.2.0-rc.1 for a pre-release
# scripts/release.sh --dry-run minor # print the tag and notes, change nothing
# scripts/release.sh --dry-run v0.2.0 # print the notes, change nothing
#
# A pre-release is tagged without touching CHANGELOG.md: its changes are
# listed under the release that follows.
@@ -16,34 +15,14 @@ if [ "${1:-}" = "--dry-run" ]; then
dry=true
shift
fi
tag="${1:?usage: $0 [--dry-run] <patch|minor|major, or a tag like v0.2.0>}"
tag="${1:?usage: $0 [--dry-run] <tag, e.g. v0.2.0>}"
if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "$tag is not vMAJOR.MINOR.PATCH (optionally -suffix)" >&2
exit 1
fi
command -v git-cliff >/dev/null || { echo "git-cliff is needed: brew install git-cliff" >&2; exit 1; }
cd "$(dirname "$0")/.."
git fetch -q origin main --tags
bumped=""
case "$tag" in
patch | minor | major)
# The latest release, pre-releases left out: v0.3.0-rc.1 isn't one.
tags=$(git tag -l 'v*' --sort=-v:refname)
last=$(grep -m1 -E '^v[0-9]+\.[0-9]+\.[0-9]+$' <<<"$tags" || true)
[ -n "$last" ] || { echo "No vMAJOR.MINOR.PATCH tag to bump from; give the tag." >&2; exit 1; }
IFS=. read -r major minor patch <<<"${last#v}"
case "$tag" in
major) next="v$((major + 1)).0.0" ;;
minor) next="v$major.$((minor + 1)).0" ;;
patch) next="v$major.$minor.$((patch + 1))" ;;
esac
bumped=" ($tag after $last)"
tag=$next
;;
esac
if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "$tag is not patch, minor, major or vMAJOR.MINOR.PATCH (optionally -suffix)" >&2
exit 1
fi
echo "$tag$bumped"
notes=$(git cliff --unreleased --tag "$tag" --strip all)
if ! grep -q '^- ' <<<"$notes"; then
@@ -61,6 +40,7 @@ if [ -n "$(git status --porcelain)" ]; then
echo "The working tree has changes; commit or stash them first." >&2
exit 1
fi
git fetch -q origin main --tags
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
echo "main isn't origin/main; pull (or push) first." >&2
exit 1
@@ -71,7 +51,7 @@ if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
fi
echo
read -r -p "Release $tag$bumped with these notes? [y/N] " ok
read -r -p "Release $tag with these notes? [y/N] " ok
[ "$ok" = y ] || [ "$ok" = Y ] || exit 1
if [[ "$tag" != *-* ]]; then