14 Commits
Author SHA1 Message Date
kebandClaude Opus 5.5 66faafb9b9 feat: the logo on top of the status pane's rows
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:26:06 +02:00
kebandClaude Opus 5.5 c129f22f5b feat: releases and changelog links in the status pane; the version opens its release
CI / Checks (pull_request) Successful in 25s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:09:49 +02:00
kebandClaude Opus 5.5 dc856677cc feat: the changelog in the status pane, built in
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 11:23:20 +02:00
keb 56d3d25c9c feat: link each changelog entry to its PR and commit (#11)
Like Immich's and RomM's release notes: every changelog entry now links to its pull request and its commit, in `CHANGELOG.md` and in the release notes on Gitea:

```
- Search with fzf's scoring and query syntax ([#11](…/pulls/11)) ([65dc2d7](…/commit/65dc2d7…))
```

- `cliff.toml`: the entry template adds the short commit hash as a link; a postprocessor turns the squash commit's `(#N)` into a link to the PR.
- The app shows release notes as text (`U`, and the status pane's changelog to come), so `Release.Notes()` turns Markdown links back into their text: `Search (#11) (65dc2d7)`.

Checked with `git cliff --unreleased` on main: every entry since v0.1.0 gets both links. Test for the link stripping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #11
2026-10-11 11:20:19 +02:00
keb 1c8d69d957 feat: the right pane describes what's selected in the pane you're in (#9)
The right pane kept showing the selected secret when you moved to Vaults or Sources. Now it follows focus:

| focus | right pane |
|---|---|
| Sources | the account (group row), or the subscription / workspace / repo: name, note, ID / profile / path, host or remote, account, web page, vault count |
| Vaults | the vault / scope / file: name, note, URI / backend / format, source, web page, counts of loaded secrets, keys, certificates |
| Secrets | the secret, as before |
| the right pane itself | what it described when you went in; `shift+tab` goes back there |

All but the secret use the `Fields` pane (#7), so `j`/`k`, `/` and `y` work on them; `Detail` loses the error message it got in #4, which is now a red "problem" row in `Fields`. A value revealed from a left pane stays in view until hidden, so `space` from any pane still shows it. `tab` from a subscription goes to Vaults as before; only an account row skips to its details.

This also changes the startup view: focus starts in Sources, so the right pane describes the first subscription instead of the first secret (golden output updated). Tests that read the secret pane focus Secrets first; a new test walks secret → vault → subscription → into the pane → back, and reveals from Sources.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #9
2026-10-11 10:59:40 +02:00
keb c8abc1dc95 feat: the value's keys on the line below it: reveal and copy (#8) 2026-10-11 10:38:00 +02:00
keb 71396f1e02 feat: a Fields pane for anything but a secret, first for Azure accounts: fields and groups to move through, filter and copy (#7) 2026-10-11 10:37:40 +02:00
keb f6c4f5f6f6 fix: show search instead of grouped/flat in the Sources help bar (#6)
The Sources pane's help bar listed `` ` `` (grouped/flat sources), a rarely used toggle, but not Go to (`ctrl+s`), the main way to find things. Search now takes its place, right after `enter`; the toggle is still in `?`.

Golden output updated: at 100 columns the bar now reads `enter open • ctrl+s search • s copy link • S share… • f star • F favourites • … • ? help • q quit` (`n note` moves behind the `…`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #6
2026-10-11 10:09:46 +02:00
keb 52b6257dd5 feat: show who an Azure account signs in as, in Details on its row (#5)
On an Azure account's group row, the Details pane now describes the account:

| field | from |
|---|---|
| name | token `name` |
| user (and "service principal") | az's account list |
| tenant: name · ID | az's account list (the name is borrowed from another entry in the same tenant when az left it out) |
| object ID | token `oid`; `y` on the row copies it |
| sign-in: MFA or not, and how | token `amr` |
| directory roles | token `wids`: built-in roles by name, others as `role 1a2b3c4d`, the default member role left out |
| subs: count · default | az's account list |
| cloud | az's account list, when not AzureCloud |

Nothing is fetched for it: the claims are the ones from the token lazysecrets already gets for the account's own tenant, read without verification. The token itself is never stored or shown. Before the first token, only the az-list fields show. A failing account shows its sign-in problem there instead (from #4).

New optional `provider.Accounts` interface; `azure.Provider` implements it. Tests: the fields from claims and az's list, sign-in methods, the UI showing it on the group row only, and `y` copying the object ID. Checked against a real signed-in account (name, MFA · password, default subscription) and an expired one (az-list fields only, since there's no token).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #5
2026-10-11 10:09:17 +02:00
keb a82f6bff27 fix: say a sign-in error once, then in Details for that account (#4)
A failing account's error ("sign-in required … press L to run az login --tenant …") stayed in the footer for the whole session.

- **Footer:** it's now an ordinary message, shown once when an account's error first appears (at start, or when a load fails), and gone on the next key press. It's said again only when the errors change, or for another account.
- **Details:** with that account's group header or one of its sources selected, the Details pane shows the error and the sign-in hint, in place of "Select a vault or scope…".
- **`L`** signs in to the selected account when it needs it, else to the last one that asked, as before. With two accounts, `L` used to run whichever sign-in had failed most recently.

Test: two accounts, one failing to list its subscriptions. The message is in the footer at start and gone after a key press, in Details on that account's group, and `L` targets it. It isn't shown on the other account's rows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #4
2026-10-11 10:06:28 +02:00
keb 87d98d038d fix: never show one az account's subscriptions under another (#3)
With two az accounts, signing in to one could make the other account's group list the first one's subscriptions.

**Why:** az can't be told which user to get a token for, so each account is pinned with `--subscription`, using one of its subscriptions. The pin was chosen once at start. But az's profile has one entry per subscription, tagged with the account that signed in to it last. Signing in to another account that also sees the pinned subscription moves it there, and from then on the first account's token requests silently returned the other account's token. That's what happened here: the in-app sign-in as one account re-tagged the other account's default subscription.

**Fix:**
- The pin is looked up in az's profile for every new token (only when the in-memory token cache misses), so it follows az's current mapping. An account with no subscriptions left in az gets "sign in as …".
- A token az issues for **another of the signed-in users** (by its `upn`/`unique_name`/`preferred_username`/`email` claim) is refused with "az gave a token for X, not Y: sign in as Y again", which shows as the usual sign-in prompt. Only a match against another known user is refused, so tokens without those claims (service principals) or with unusual guest names aren't turned away.

Tests cover the subscription moving between users, a refused token, a user gone from az, and the claim matching. I also checked against two real az accounts (one signed in, one expired): the signed-in account lists its subscriptions, and the expired one shows the sign-in error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #3
2026-10-11 09:55:43 +02:00
keb 10dc747f0f feat: keep what was loaded between runs, and start where you left off (#2)
Starting lazysecrets again now shows what you had loaded at once, and puts you back where you were.

- **Cache file** `~/.cache/lazysecrets/cache.json` (0600, written atomically): the Sources lists, vaults/scopes, secrets' metadata, Describe results and each list's cursor. **Never values**, and never repos (they're on disk anyway). Loaded on start; lists older than 2 minutes reload in the background as before. Saved every 30s when changed, and on quit.
- **Housekeeping:** entries not loaded for 30 days are dropped, as is everything from accounts no longer signed in (every cache key starts with the account ID). The file has a format version; another version is ignored and replaced. `cache: false` turns it off; `--demo` never uses it.
- **Sources are cached too:** Azure lists subscriptions over the network, so without this the Sources pane would still wait.
- **Go to** searches everything cached straight away, and its background loading now also refreshes lists from an earlier run instead of skipping them.
- **Where you left off:** the state file remembers the selected source, vault or secret per folder (the `--dir` or working directory, 100 most recent folders), and the next start in that folder goes back there quietly (no error if it's gone). Starting on a link skips it.

Docs: `cache:` in config.md, a Cache section, the place in State, and the README Privacy section now mentions the cache file.

Tests: cache round trip with a backend too slow to answer (lists and cursor come from the file), no value in the file, file mode 0600, old and signed-out entries dropped, back where it was left, not for another folder or a link.

This touches `app.go` near PR #1's changes (Model fields, the Update switch), so whichever merges second needs a small conflict fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #2
2026-10-11 09:53:32 +02:00
keb 17d2705e12 feat: e opens a repo's SOPS or .env file, in nvim, sops edit or your editor (#1)
`e` on a file in a repo opens it; on a secret, it opens the file at that key's line.

- **SOPS files**: `sops edit` in the terminal, then back to lazysecrets with the keys reloaded. Exit 200 (nothing changed) shows "unchanged".
- **Other files inside nvim** (`$NVIM` set): lazysecrets asks that nvim, over `--remote-expr`, to open the file once lazysecrets' terminal closes, then quits. The file lands in the window you came from, not the closing float. Started from a shell in a terminal, it opens in the previous window straight away. No change to the nvim plugin is needed.
- **Otherwise**: `$VISUAL`/`$EDITOR` in the terminal (with `+N`, `file:N` or `-g file:N` for editors that take a line), else the system default app.

The repo provider now records each key's line (YAML node lines, env/ini line numbers) behind a new optional `provider.Files` interface. Other providers show "only SOPS and .env files in repos can be opened".

The nvim handoff was tested against a headless nvim 0.12: a floating terminal closed on exit the way snacks does, and a shell in a split. A file name with a space and a quote opened at the right line in both. Unit tests cover lines per format, the nvim, sops and default-app paths, and the editor line arguments.

Note: `e` is a new default key, so a config that already binds `e` to something else now gets the "bound to both" error, until one of them is rebound.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #1
2026-10-11 09:34:05 +02:00
kebandClaude Opus 5.5 6fab41703e Hello world 👋
Release / Build and publish (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 23:58:25 +02:00
118 changed files with 1202 additions and 7685 deletions

No files matched your search

+3 -3
View File
@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v5
with:
go-version-file: go.mod
# The runner mounts Go, the module cache and the build cache as
@@ -40,7 +40,7 @@ jobs:
- name: staticcheck
# Pinned, so a new release can't fail CI on its own.
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 ./...
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
- name: go test
run: go test ./...
+5 -17
View File
@@ -4,9 +4,8 @@ name: Release
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea
# release with the archives, checksums and this release's section of the
# changelog (git-cliff, cliff.toml), then copies it to the GitHub
# mirror. Pushing the same tag again (after deleting it) replaces the
# release in both places.
# changelog (git-cliff, cliff.toml). Pushing the same tag again (after
# deleting it) replaces the release.
#
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
@@ -23,12 +22,12 @@ jobs:
contents: write
steps:
- name: Checkout (full history, for the changelog)
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v5
with:
go-version-file: go.mod
# The runner mounts Go, the module cache and the build cache as
@@ -75,21 +74,10 @@ jobs:
cat "$RUNNER_TEMP/notes.md"
- name: GoReleaser
uses: goreleaser/goreleaser-action@v7
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: v2.18.3
args: release --clean --release-notes ${{ runner.temp }}/notes.md
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Copy the release to GitHub
# The same notes and files on the GitHub mirror, which lazysecrets
# updates from first (internal/update). GH_RELEASE_TOKEN is a
# fine-grained token that can write the mirror's contents; without
# it the step does nothing.
run: node scripts/github-release.mjs
env:
TAG: ${{ github.ref_name }}
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-46
View File
@@ -1,46 +0,0 @@
name: Bug report
description: Something in lazysecrets doesn't work as it should.
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for reporting! Please leave out secret values, tokens and anything else private: names of vaults and secrets are enough.
- type: textarea
id: what
attributes:
label: What happened
description: What you did, what you expected, and what happened instead.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: If you can, try `lazysecrets --demo` first.
placeholder: |
1. lazysecrets --demo
2. ctrl+s, type …
- type: input
id: version
attributes:
label: Version
description: The status pane shows it, e.g. `v0.1.0`.
validations:
required: true
- type: dropdown
id: source
attributes:
label: Where
multiple: true
options:
- Azure Key Vault
- Databricks
- SOPS or .env files
- Links
- Everywhere / not sure
- type: input
id: platform
attributes:
label: OS and terminal
placeholder: macOS 26, kitty + tmux
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Docs
url: https://git.kebcloud.no/keb/lazysecrets/src/branch/main/docs
about: Keys, sources, links, config and more.
-20
View File
@@ -1,20 +0,0 @@
name: Feature request
description: An idea for lazysecrets, or something that could work better.
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
lazysecrets stays focused on finding, reading and sharing secrets, so not every idea will make it in, but they're all read.
- type: textarea
id: problem
attributes:
label: What you're trying to do
description: The problem or the situation, more than the solution.
validations:
required: true
- type: textarea
id: idea
attributes:
label: Your idea
description: How you'd like it to work, if you have one in mind.
-2
View File
@@ -1,2 +0,0 @@
<1+keb@noreply.git.kebcloud.no> <1+keb@noreply.localhost>
<3+renovate-bot@noreply.git.kebcloud.no> <3+renovate-bot@noreply.localhost>
+1 -33
View File
@@ -2,39 +2,7 @@
Every release of lazysecrets, newest first.
## v0.2.0 – 2026-10-14
### Added
- Lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers ([#70](https://git.kebcloud.no/keb/lazysecrets/pulls/70)) ([39454d4](https://git.kebcloud.no/keb/lazysecrets/commit/39454d47a864c8628c50b023aee9539d33be2f44))
### Fixed
- Lazysecrets starts from fzf's become, on a non-blocking terminal ([#67](https://git.kebcloud.no/keb/lazysecrets/pulls/67)) ([4480e8f](https://git.kebcloud.no/keb/lazysecrets/commit/4480e8fed00024691091eb7096abb9dc83b8044d))
- Local builds are dev-<commit> again, not a Go pseudo-version ([#68](https://git.kebcloud.no/keb/lazysecrets/pulls/68)) ([e825a51](https://git.kebcloud.no/keb/lazysecrets/commit/e825a51b30261b55c536af18fefde79dc6308377))
- A wrapped value in the details keeps its colour on every line ([#69](https://git.kebcloud.no/keb/lazysecrets/pulls/69)) ([dbb0eb2](https://git.kebcloud.no/keb/lazysecrets/commit/dbb0eb2bbdf6d695cf0d9ac15a1b513af8ba431d))
## v0.1.2 – 2026-10-13
### Added
- Look for repos in the usual folders, not just ~/git ([#57](https://git.kebcloud.no/keb/lazysecrets/pulls/57)) ([47ef862](https://git.kebcloud.no/keb/lazysecrets/commit/47ef86293d757f965069b1ea100a4d53d5a9b876))
- Config grouped by area, with per-provider timeouts and an update interval ([#60](https://git.kebcloud.no/keb/lazysecrets/pulls/60)) ([10d1fc6](https://git.kebcloud.no/keb/lazysecrets/commit/10d1fc63c75fbb91088119a2e81884e44c36c4a3))
- The update changelog covers every version you missed ([#61](https://git.kebcloud.no/keb/lazysecrets/pulls/61)) ([75bab25](https://git.kebcloud.no/keb/lazysecrets/commit/75bab2528677086219817ff9bc08a36755c96dcd))
### Fixed
- Help and errors name sharing.key and sharing.keyRules, not share.* ([#64](https://git.kebcloud.no/keb/lazysecrets/pulls/64)) ([6b3edd4](https://git.kebcloud.no/keb/lazysecrets/commit/6b3edd45d83b9b2af06a40f1f7e67f35c6861a69))
- Unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one ([#66](https://git.kebcloud.no/keb/lazysecrets/pulls/66)) ([a81fcaa](https://git.kebcloud.no/keb/lazysecrets/commit/a81fcaa5a6cef3ce0d07d6195f3e7bbb98808253))
## v0.1.1 – 2026-10-13
### Added
- `e` on the status pane edits the config ([#55](https://git.kebcloud.no/keb/lazysecrets/pulls/55)) ([2044341](https://git.kebcloud.no/keb/lazysecrets/commit/2044341b20d795fa7cadcabb9514631990993e0a))
- Share env files as encrypted links ([#54](https://git.kebcloud.no/keb/lazysecrets/pulls/54)) ([4377eb9](https://git.kebcloud.no/keb/lazysecrets/commit/4377eb98bf0dd523d5c4371bf84668412053290e))
## v0.1.0 – 2026-10-12
## v0.1.0 – 2026-10-10
Hello world 👋
+25 -56
View File
@@ -1,26 +1,29 @@
# lazysecrets
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic [lazygit](https://github.com/jesseduffield/lazygit).
![lazysecrets](docs/assets/lazysecrets.png)
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic lazygit.
## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
That's by design and may stay that way.
- **Read-only**: lazysecrets never creates, changes or deletes a secret, so
it's safe to point at production. That's by design and may stay that way.
(`e` opens a repo's SOPS or `.env` file in your editor, or `sops edit`;
any change is yours.)
- **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)
fzf-style matching (`'exact`, `^prefix`, `suffix$`, `!not`), and jumps
straight to the result.
- **Share links**: `s` copies a `lazysecrets://` link. A colleague opens it
with their own access, from the terminal or with a click in the browser
or chat.
- **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pipes.
- **Favourites**: star the secrets you use most for easy access.
- **Favourites**: star the secrets you use most and get back to them in one
key.
- **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane.
marked.
- **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.
@@ -31,20 +34,17 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Install
On macOS or Linux (arm64 or x86_64):
```sh
curl -fsSL https://raw.githubusercontent.com/karleinarb/lazysecrets/main/install.sh | sh
```
Or
```sh
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
```
Both approaches will install the latest release from [GitHub](https://github.com/karleinarb/lazysecrets/releases) with fallback to
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
`~/.local/bin` after checking it against the release's checksums.
It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases)
into `~/.local/bin` after checking it against the release's checksums.
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else.
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`).
@@ -60,14 +60,12 @@ lazysecrets # start lazysecrets
lazysecrets --demo # test with demo data
```
Pick a source on the left, then a vault, then a secret. With nothing
signed in yet, the right pane says how to get started. The keys you'll
Pick a source on the left, then a vault, then a secret. The keys you'll
use most:
| Key | Action |
|---|---|
| `j`/`k`, `enter`, `esc` | move, drill in, back |
| `l` / `h` | the details of what's selected / back |
| `j`/`k`, `h`/`l`, `enter`, `esc` | move, switch panes, drill in, back |
| `/` | filter the list you're in |
| `space` | reveal / hide the value |
| `y` | copy the value (cleared from the clipboard after 30s) |
@@ -77,59 +75,30 @@ use most:
| `?` | every key, searchable |
| `q` | quit |
## 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 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
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts,
and opening links from the browser and chat
- [Custom commands](docs/custom-commands.md): run your own commands on a
secret
- [Config](docs/config.md): every option, themes, layered configs, the
state file
- [Development](docs/development.md): building, tests, how changes land and
- [Development](docs/development.md): building, tests, pull requests and
releasing
## Privacy
lazysecrets never stores, caches or sends your secret values. It keeps the
names and metadata of what you've loaded in a cache file readable only by
you, to show it at once next time (`storage.cache: false` turns it off). It talks
you, to show it at once next time (`cache: false` turns it off). It talks
only to the services you use it with, and collects no usage data. The one
call of its own is a daily check for a new release, on GitHub or else
git.kebcloud.no, which `updates.autoCheck: false` turns off.
call of its own is a daily check for a new release on this server, which
`updateCheck: false` turns off.
## Repositories
The main repo of this codebase is at [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets).
The [GitHub mirror](https://github.com/karleinarb/lazysecrets) is for discoverability, issue-tracking and releases.
It also makes it possible to give a ⭐ if you like lazysecrets!
## Contributing
I don't take pull requests as I don't have time to review and test them.
But bug reports and ideas are very welcome!
Open an [issue on GitHub](https://github.com/karleinarb/lazysecrets/issues) and I'll have a look.
## AI disclosure
-11
View File
@@ -1,11 +0,0 @@
# Security
Please report a vulnerability privately, not in a public issue:
- on GitHub, with **Report a vulnerability** under the
[Security tab](https://github.com/karleinarb/lazysecrets/security), or
- by email to karl-einarb@outlook.com.
Say what's affected, how to reproduce it and the version
(`lazysecrets --version`). Fixes go into the next release, and only the
latest release gets them.
-2
View File
@@ -51,8 +51,6 @@ commit_preprocessors = [
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
# #11 was squash-merged with #10 (the status pane) in it: name both.
{ pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" },
# #55's title starts with a key, which capitalised reads as another.
{ pattern = "^feat: e on the status pane edits the config", replace = "feat: `e` on the status pane edits the config" },
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
# pr-title.yml); all become "feat: x".
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
+75 -12
View File
@@ -1,7 +1,10 @@
package main
import (
"cmp"
"context"
"errors"
"fmt"
"os"
"golang.org/x/term"
@@ -30,22 +33,82 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err
}
// value reads what r points at (ref.Read), with the account that last
// read it first.
// value reads what r points at without listing its vault: the vault and
// name are enough for Azure, tried with each account (the one that last
// read it first); for Databricks the workspace's profile is found by its
// host.
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
if r.IsSource() || r.IsContainer() {
return provider.SecretValue{}, fmt.Errorf("%s points at a %s, not a secret", r, map[bool]string{true: "subscription or workspace", false: "vault or scope"}[r.IsSource()])
}
if r.Kind != provider.KindAzure && r.Host == "" {
return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
}
// 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 = ""
node := st.Node(key.String())
account, source := node.Account, node.Source
if r.Account != "" && r.Account != account {
account, source = r.Account, ""
type try struct {
p provider.Provider
o provider.Object
}
return ref.Read(ctx, providers, r, account, source)
var tries []try
for _, p := range providers {
if p.Kind() != r.Kind {
continue
}
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
if r.Kind != provider.KindAzure { // by host: workspaces, repos
loc, ok := p.(provider.Locator)
if !ok {
continue
}
profile, found, err := loc.Locate(ctx, r.Host)
if err != nil || !found {
continue
}
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile}
o.Container = provider.Container{Source: src, Name: r.Scope}
// Listing the scopes tells a Key Vault-backed one, whose value
// can then be read from the vault if Databricks refuses.
if cs, err := p.Containers(ctx, src); err == nil {
for _, c := range cs {
if c.Name == r.Scope {
c.Source = src
o.Container = c
}
}
}
} else {
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
}
t := try{p, o}
if p.ID() == node.Account {
tries = append([]try{t}, tries...)
} else {
tries = append(tries, t)
}
}
if len(tries) == 0 {
switch r.Kind {
case provider.KindDatabricks:
return provider.SecretValue{}, fmt.Errorf("no Databricks profile for workspace %s", r.Host)
case provider.KindRepo:
return provider.SecretValue{}, fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host)
}
return provider.SecretValue{}, fmt.Errorf("no %s account to read %s with", r.Kind, r)
}
var errs []error
for _, t := range tries {
v, err := t.p.Value(ctx, t.o)
if err == nil {
return v, nil
}
if len(tries) == 1 {
return provider.SecretValue{}, err
}
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
}
return provider.SecretValue{}, errors.Join(errs...)
}
-269
View File
@@ -1,269 +0,0 @@
package main
import (
"cmp"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// listCmd prints every source, vault and secret lazysecrets can find, one
// per line, for fzf, editor pickers and scripts:
//
// lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1
//
// Each line is the kind, a mark (✗ or ⚠, ★, ◷ and the provider's icon),
// the name, a status (why it couldn't be read, or that it has expired or
// expires soon; else empty), its note (muted, or empty), the account and
// the link, tab-separated. The mark has a field of its own so a search can
// leave it out (fzf --nth), and ^prod finds names starting with prod. The
// name is coloured from the theme,
// with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS,
// ui.icons); the link never is.
//
// What's cached prints at once; what's missing or older than --max-age
// follows as it loads. A list that can't be read is a line too, as it
// fails: the vault or source, then ✗ and why in the theme's error colour.
// They're in the output rather than on stderr, which would draw over fzf.
func listCmd(providers []provider.Provider, st *store.Store, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons, withRecent bool, expiryWarn time.Duration, args []string, out io.Writer) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks")
maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again")
format := fs.String("format", "text", "text: kind, mark, name, status, note, account and link, tab-separated; json: an object per line")
color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi")
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)")
recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)")
notes := fs.Bool("notes", true, "start a note with a note icon (✎ without --icons) (text; the note is printed either way)")
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() > 0 {
return fmt.Errorf("usage: lazysecrets list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]")
}
var only []string
for k := range strings.SplitSeq(*kinds, ",") {
if k = strings.TrimSpace(k); k == "" {
continue
}
if !slices.Contains(listing.Kinds, k) {
return fmt.Errorf("--kind: %q is not one of %s", k, strings.Join(listing.Kinds, ", "))
}
only = append(only, k)
}
if *color != "always" && *color != "never" {
return fmt.Errorf("--color: %q is not always or never", *color)
}
now := time.Now()
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry,
color: *color == "always", warn: expiryWarn, now: now}
if *iconsOn {
m.icons = icons.NerdFont
}
var write func(listing.Entry) error
switch *format {
case "text":
write = func(e listing.Entry) error {
colored := *color == "always"
mark := m.of(e)
name := nameOf(e, theme, colored)
// Why it couldn't be read (vaults, sources), or when it
// expires (secrets, keys, certificates): never both.
status := errorOf(e, theme, colored)
if status == "" && *expiry {
status = expiryOf(e, theme, expiryWarn, now, colored)
}
note := noteOf(e, theme, m.icons, *notes, colored)
// A tab or line break in a name would break the line up.
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(status),
oneLine.Replace(note), e.Account, e.Link)
return err
}
case "json":
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
write = func(e listing.Entry) error { return enc.Encode(e) }
default:
return fmt.Errorf("--format: %q is not text or json", *format)
}
// When the reader goes away (a pick in fzf), writing fails instead of
// killing the process, so what loaded is still saved.
signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
var gone error
emit := func(e listing.Entry) error {
if e.Error != "" && !*showErrors {
return nil
}
if err := write(e); err != nil {
gone = err
return err
}
return nil
}
f := cachefile.New()
if cachePath != "" {
f = cachefile.Read(cachePath)
}
f, loaded := listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
Mark: func(link, account string) listing.Marks {
n := st.Node(link)
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
},
}, emit)
// Only what was loaded: list never moves a cursor, so the TUI's stay.
if cachePath != "" && loaded {
f.Cursor = nil
if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil {
return err
}
}
if gone != nil && !errors.Is(gone, syscall.EPIPE) {
return gone
}
return nil // or the reader has what it wanted
}
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
// noteOf is the entry's note, muted, after a note icon (✎ without Nerd
// Font icons) with icon; or "".
func noteOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, icon, color bool) string {
if e.Note == "" {
return ""
}
note := e.Note
if icon {
note = cmp.Or(iconSet.Note, "✎") + " " + note
}
return renderer(color)(theme.Muted, note)
}
// expiryOf says that a secret, key or certificate has expired, in red,
// or when it will if that's within warn (expires in 12d), in yellow, or
// is "". Its mark (✗, ⚠) is before the name.
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
render := renderer(color)
switch state, left := expiry.Of(e.Expires, now, warn); state {
case expiry.Expired:
return render(theme.Error, "expired")
case expiry.Soon:
return render(theme.Warning, "expires in "+expiry.Left(left))
}
return ""
}
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
// is before the name.
func errorOf(e listing.Entry, theme styles.Styles, color bool) string {
return renderer(color)(theme.Error, e.Error)
}
// nameOf is an entry's name for the text format: the path with the
// item's own name standing out, and the account.
func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
render := renderer(color)
var name string
if n := len(e.Path); n > 0 {
if n > 1 {
name = render(theme.Muted, strings.Join(e.Path[:n-1], " / ")+" / ")
}
name += e.Path[n-1]
}
if e.AccountLabel != "" {
name += render(theme.Muted, " · "+e.AccountLabel)
}
return name
}
// marks says what goes before the name, in a field of its own so a
// search leaves it out: ✗ in red when something's wrong (it couldn't be
// read, or has expired) or ⚠ when it expires within warn; ★ for a starred
// entry and ◷ for one used lately, as the TUI marks them; then the
// provider's icon. The marks are right-aligned in room for every kind
// turned on, so they sit by the icon and names line up.
type marks struct {
theme styles.Styles
icons icons.Set
stars, recent, expiry, color bool
warn time.Duration // ui.expiryWarningDays
now time.Time
}
func (m marks) of(e listing.Entry) string {
render := renderer(m.color)
var parts []string
room := 0 // marks turned on
mark := func(on bool, st lipgloss.Style, s string) {
room++
if on {
parts = append(parts, render(st, s))
}
}
// What's wrong, then what to watch: room for one of them.
state := expiry.None
if m.expiry {
state, _ = expiry.Of(e.Expires, m.now, m.warn)
}
switch {
case e.Error != "" || state == expiry.Expired:
mark(true, m.theme.Error, "✗")
case state == expiry.Soon:
mark(true, m.theme.Warning, "⚠")
default:
mark(false, m.theme.Error, "")
}
if m.stars {
mark(e.Starred, m.theme.Favourite, "★")
}
if m.recent {
mark(e.Recent, m.theme.Muted, "◷")
}
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
kind := provider.Kind("")
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
if strings.EqualFold(string(k), e.Provider) {
kind = k
}
}
if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" {
parts = append(parts, render(m.theme.Kind(kind), icon))
} else if len(parts) == 0 {
pad = strings.TrimSuffix(pad, " ") // no mark after the room
}
return pad + strings.Join(parts, " ")
}
func renderer(color bool) func(lipgloss.Style, string) string {
return func(st lipgloss.Style, s string) string {
if !color || s == "" {
return s
}
return st.Render(s)
}
}
-152
View File
@@ -1,152 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
func TestListNames(t *testing.T) {
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, AccountLabel: "alice"}
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-app-prod / db-password · alice" {
t.Errorf("plain: %q", got)
}
// The item's own name isn't muted with its path.
if got := nameOf(e, styles.Default(), true); !strings.Contains(got, "\x1b[mdb-password") {
t.Errorf("name not set apart: %q", got)
}
}
func TestListMarks(t *testing.T) {
az := icons.NerdFont.Azure
plain := marks{theme: styles.Default(), icons: icons.NerdFont, stars: true}
noIcons := plain
noIcons.icons = icons.None
colored := noIcons
colored.color = true
for _, c := range []struct {
m marks
e listing.Entry
want string
}{
// Room for the status mark and the star, so names line up.
{plain, listing.Entry{Provider: "azure", Starred: true}, " ★ " + az},
{plain, listing.Entry{Provider: "azure"}, " " + az},
{marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, " " + az},
{noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"},
{marks{}, listing.Entry{Provider: "azure", Starred: true}, " "},
{colored, listing.Entry{Provider: "azure", Starred: true}, " " + styles.Default().Favourite.Render("★")},
} {
if got := c.m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
}
}
func TestListRecentMarks(t *testing.T) {
az := icons.NerdFont.Azure
m := marks{icons: icons.NerdFont, stars: true, recent: true}
for _, c := range []struct {
e listing.Entry
want string
}{
// Right-aligned in room for three: by the icon, names lined up.
{listing.Entry{Provider: "azure", Starred: true, Recent: true}, " ★ ◷ " + az},
{listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az},
{listing.Entry{Provider: "azure"}, " " + az},
} {
if got := m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
}
}
func TestListExpiry(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
warn := 30 * 24 * time.Hour
for _, c := range []struct {
expires time.Time
want string
}{
{time.Time{}, ""},
{now.Add(-time.Hour), "expired"},
{now.Add(12*24*time.Hour + time.Hour), "expires in 12d"},
{now.Add(5 * time.Hour), "expires in 5h"},
{now.Add(60 * 24 * time.Hour), ""},
} {
if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want {
t.Errorf("expires %v: %q, want %q", c.expires, got, c.want)
}
}
if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("expired") {
t.Errorf("expired isn't red: %q", got)
}
}
func TestListNotes(t *testing.T) {
e := listing.Entry{Note: "rotate in May"}
if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" {
t.Errorf("icons: %q", got)
}
if got := noteOf(e, styles.Default(), icons.None, true, false); got != "✎ rotate in May" {
t.Errorf("no icons: %q", got)
}
if got := noteOf(e, styles.Default(), icons.NerdFont, false, false); got != "rotate in May" {
t.Errorf("--notes=false: %q", got)
}
if got := noteOf(listing.Entry{}, styles.Default(), icons.NerdFont, true, true); got != "" {
t.Errorf("no note: %q", got)
}
}
func TestListStatusMark(t *testing.T) {
az := icons.NerdFont.Azure
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
m := marks{icons: icons.NerdFont, stars: true, expiry: true, warn: 30 * 24 * time.Hour, now: now}
for _, c := range []struct {
e listing.Entry
want string
}{
// Right-aligned in room for two, as with ★ and ◷.
{listing.Entry{Provider: "azure", Error: "403 Forbidden"}, " ✗ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour), Starred: true}, "✗ ★ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(5 * 24 * time.Hour)}, " ⚠ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour), Starred: true}, " ★ " + az},
} {
if got := m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
}
m.expiry = false // --expiry=false: no ⚠ or ✗ for expiry; errors still
if got := m.of(listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}); got != " "+az {
t.Errorf("--expiry=false: %q", got)
}
m.expiry, m.color, m.theme = true, true, styles.Default()
if got := m.of(listing.Entry{Error: "403"}); !strings.Contains(got, styles.Default().Error.Render("✗")) {
t.Errorf("not a red ✗: %q", got)
}
if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) {
t.Errorf("not a yellow ⚠: %q", got)
}
}
func TestListErrors(t *testing.T) {
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"}
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-restricted" {
t.Errorf("the name has more: %q", got)
}
if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" {
t.Errorf("plain: %q", got)
}
if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("403 Forbidden") {
t.Errorf("why isn't red: %q", got)
}
if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" {
t.Errorf("no error: %q", got)
}
}
+35 -145
View File
@@ -3,9 +3,7 @@
package main
import (
"cmp"
"context"
"errors"
"flag"
"fmt"
"io"
@@ -89,32 +87,6 @@ func cmpOr(a, b string) string {
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).
func refArg(cmd string, args []string) (r ref.Ref, action string, err error) {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
account := fs.String("account", "", "the account to use when several could, as lazysecrets list prints it")
usage := "usage: lazysecrets get [--account <account>] <ref>"
act := new(string)
if cmd == "open" {
act = fs.String("action", "", "do this rather than ask: go (to it), copy (go, and copy its value) or show (go, and show it)")
usage = "usage: lazysecrets open [--account <account>] [--action go|copy|show] <ref>"
}
if err := fs.Parse(args); err != nil {
return r, "", err
}
if fs.NArg() != 1 {
return r, "", errors.New(usage)
}
if _, ok := app.OpenActions[*act]; *act != "" && !ok {
return r, "", fmt.Errorf("--action: %q is not go, copy or show", *act)
}
r, err = ref.Parse(fs.Arg(0))
r.Account = *account
return r, *act, err
}
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
// config directory.
func defaultConfigPath() string {
@@ -134,28 +106,18 @@ func run() error {
showVersion := flag.Bool("version", false, "print the version and exit")
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request, every provider's (overrides the config)")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
icons := flag.Bool("icons", true,
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets
lazysecrets [flags] [open [--account <account>] [--action go|copy|show]] <ref>
open a lazysecrets:// link (also a share link),
Key Vault URL, @Microsoft.KeyVault(...) or
{{secrets/scope/key}}
lazysecrets [flags] get [--account <account>] <ref>
print the value a lazysecrets:// link points at,
lazysecrets [flags] [open] <ref>
open a lazysecrets:// link, Key Vault URL,
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false]
print every source, vault and secret it can
find, one per line, for fzf and pickers:
what's cached at once, then what loads
lazysecrets [flags] share [--key <secret>] <env file>...
print a link to env files of a repo, encrypted
with a secret's value (default: sharing.key)
lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME]
@@ -178,21 +140,12 @@ Flags:
}
var open, getRef *ref.Ref
var openAction string
var shareArgs, listArgs []string
printKeys := false
switch args := flag.Args(); {
case len(args) > 0 && args[0] == "share":
shareArgs = args[1:]
if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how
}
case len(args) > 0 && args[0] == "list":
listArgs = append([]string{}, args[1:]...) // not nil: listing
case len(args) == 1 && args[0] == "keybindings":
printKeys = true
case len(args) > 0 && args[0] == "get":
r, _, err := refArg("get", args[1:])
case len(args) == 2 && args[0] == "get":
r, err := ref.Parse(args[1])
if err != nil {
return err
}
@@ -202,14 +155,14 @@ Flags:
case len(args) > 0 && args[0] == "launch":
return launch(*configPath, args[1:])
case len(args) > 0 && args[0] == "update":
return updateCmd(*configPath, args[1:])
return updateCmd(args[1:])
case len(args) == 0:
case args[0] == "open" && len(args) >= 2:
r, act, err := refArg("open", args[1:])
case args[0] == "open" && len(args) == 2:
r, err := ref.Parse(args[1])
if err != nil {
return err
}
open, openAction = &r, act
open = &r
case len(args) == 1 && looksLikeRef(args[0]):
// lazysecrets <link>, like lazysecrets open <link>
r, err := ref.Parse(args[0])
@@ -230,11 +183,6 @@ Flags:
if err != nil {
return err
}
// Warnings show when the window opens.
var warnings []string
if w := styles.Unknown(cfg.Theme); w != "" {
warnings = append(warnings, w)
}
keymap := keys.Default()
if err := keymap.Apply(cfg.Keybinding); err != nil {
return err
@@ -249,57 +197,33 @@ Flags:
// Precedence: flag > environment > config file > default.
set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if *timeout <= 0 {
return fmt.Errorf("--timeout: %s is not positive", *timeout)
if !set["timeout"] && cfg.Timeout > 0 {
*timeout = cfg.Timeout
}
// Each provider's own timeout, unless --timeout sets them all.
timeouts := map[provider.Kind]time.Duration{}
if !set["timeout"] {
if cfg.Providers.Timeout > 0 {
*timeout = cfg.Providers.Timeout
}
for k, d := range map[provider.Kind]time.Duration{
provider.KindAzure: cfg.Providers.Azure.Timeout,
provider.KindDatabricks: cfg.Providers.Databricks.Timeout,
provider.KindRepo: cfg.Providers.Repos.Timeout,
} {
if d > 0 {
timeouts[k] = d
}
}
}
// timeoutFor is how long a request to a provider of kind k may take.
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
*clipClear = *cfg.Values.ClipboardClear
if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
*clipClear = *cfg.ClipboardClear
}
expiryDays := 30
if cfg.UI.ExpiryWarningDays != nil {
expiryDays = *cfg.UI.ExpiryWarningDays
if cfg.ExpiryWarningDays != nil {
expiryDays = *cfg.ExpiryWarningDays
}
revealTimeout := 60 * time.Second
if cfg.Values.RevealTimeout != nil {
revealTimeout = *cfg.Values.RevealTimeout
if cfg.RevealTimeout != nil {
revealTimeout = *cfg.RevealTimeout
}
loadDelay := 150 * time.Millisecond
if cfg.UI.LoadDelay != nil {
loadDelay = *cfg.UI.LoadDelay
}
var updateEvery time.Duration // 0: the app's default, a day
if cfg.Updates.AutoCheckInterval != nil {
updateEvery = *cfg.Updates.AutoCheckInterval
if cfg.LoadDelay != nil {
loadDelay = *cfg.LoadDelay
}
if !set["icons"] {
switch {
case os.Getenv("LAZYSECRETS_ICONS") != "":
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
case cfg.UI.Icons != nil:
*icons = *cfg.UI.Icons
case cfg.Icons != nil:
*icons = *cfg.Icons
}
}
recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon
// Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file.
stateDir, err := config.StateDir()
@@ -311,25 +235,17 @@ Flags:
statePath = filepath.Join(stateDir, "state.json")
}
st, err := store.Load(statePath)
if errors.Is(err, store.ErrNewer) {
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
} else if err != nil {
if err != nil {
return err
}
var providers []provider.Provider
if *demo {
// Two Azure accounts, like a personal and an admin login in az.
alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
fake.Seed(st, alice, admin)
az := fake.Azure(400*time.Millisecond).As(alice, "Azure · alice")
// Due soon whenever the demo runs (the tests' dates are fixed).
az.Expire("kv-app-prod", "api-key", time.Now().AddDate(0, 0, 9).Truncate(24*time.Hour))
providers = []provider.Provider{
az,
fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"),
fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"),
fake.Databricks(600 * time.Millisecond),
fake.Repos(),
}
} else {
pc := cfg.Providers
@@ -360,7 +276,7 @@ Flags:
if start == "" {
start, _ = os.Getwd()
}
roots := repo.DefaultRoots
roots := []string{"~/git"}
if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots
}
@@ -383,28 +299,10 @@ Flags:
log.SetOutput(io.Discard)
if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
return get(ctx, providers, st, *getRef)
}
if listArgs != nil {
cachePath := ""
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cachePath = filepath.Join(d, "cache.json")
}
return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout)
}
if shareArgs != nil {
// The files from a repo, the key from whichever provider it's
// in: the longest timeout.
d := *timeout
for _, t := range timeouts {
d = max(d, t)
}
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel()
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
}
// Links opened elsewhere (lazysecrets launch) can come here.
var p *tea.Program
@@ -440,7 +338,7 @@ Flags:
// What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string
if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
@@ -454,40 +352,32 @@ Flags:
m := app.New(providers, app.Options{
Timeout: *timeout,
Timeouts: timeouts,
Icons: *icons,
ClipboardClear: *clipClear,
FlatSources: cfg.UI.SourcesView == "flat",
CollapseGroups: config.On(cfg.UI.CollapseGroups),
FlatSources: cfg.SourcesView == "flat",
CollapseGroups: config.On(cfg.CollapseGroups),
LoadDelay: loadDelay,
NoHistory: !config.On(cfg.Storage.History),
NoHistory: !config.On(cfg.History),
OnActive: onActive,
RevealTimeout: revealTimeout,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.Values.HideOnBlur),
HideOnBlur: config.On(cfg.HideOnBlur),
HandlerSetup: setup,
Updater: updater(cfg.Updates.Sources),
UpdateCheck: config.On(cfg.Updates.AutoCheck),
UpdateEvery: updateEvery,
Updater: updater(),
UpdateCheck: config.On(cfg.UpdateCheck),
Keys: &keymap,
Commands: cfg.CustomCommands,
Version: versionString(),
Store: st,
Warning: strings.Join(warnings, " · "),
CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath),
StatePath: statePath,
Changelog: lazysecrets.Changelog,
Dir: placeDir,
ShareKey: cfg.Sharing.Key,
ShareRules: shareRules(cfg),
Open: open,
OpenAction: openAction,
RecentIcon: recentIcon,
Styles: &theme,
DefaultFg: defaultFg,
})
blockingStdin()
p = tea.NewProgram(m)
final, err := p.Run()
if fm, ok := final.(app.Model); ok {
-93
View File
@@ -1,93 +0,0 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// shareFiles prints a share link to env files of one repo, encrypted with
// the --key secret's value, else the repo's or the config's sharing.key:
//
// lazysecrets share .env apps/api/.env
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error {
fs := flag.NewFlagSet("share", flag.ContinueOnError)
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: sharing.key)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() == 0 {
return errors.New("usage: lazysecrets share [--key <secret>] <env files>")
}
var root, id string
var files []share.File
for _, name := range fs.Args() {
abs, err := filepath.Abs(name)
if err != nil {
return err
}
r, i, err := repo.Root(filepath.Dir(abs))
if err != nil {
return err
}
if root == "" {
root, id = r, i
} else if r != root {
return fmt.Errorf("%s isn't in %s: share files of one repo at a time", name, root)
}
real, err := filepath.EvalSymlinks(abs)
if err != nil {
return err
}
rel, err := filepath.Rel(root, real)
if err != nil {
return err
}
f := share.File{Path: filepath.ToSlash(rel)}
if err := share.CheckPath(f.Path); err != nil {
return err
}
if f.Data, err = os.ReadFile(real); err != nil {
return err
}
files = append(files, f)
}
key, ok, err := share.DefaultKey(root, general)
if *keyLink != "" {
key, err = share.ParseKey(*keyLink)
ok = err == nil
}
if err != nil {
return err
}
if !ok {
return errors.New("which secret to encrypt with? --key <secret>, or sharing.key in the repo's .lazysecrets.yaml or the config")
}
v, version, err := readKey(ctx, providers, st, key)
if err != nil {
return fmt.Errorf("reading the key %s: %w", key, err)
}
link, err := share.Seal(share.Header{Key: share.Pin(key, version), Repo: id}, []byte(v.Reveal()), files, rules)
if err != nil {
return err
}
_, err = fmt.Fprintln(out, link)
return err
}
// shareRules are the config's sharing.keyRules.
func shareRules(cfg config.Config) share.Rules {
return share.Rules{MinLength: cfg.Sharing.KeyRules.MinLength, MinClasses: cfg.Sharing.KeyRules.MinClasses}
}
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// demoRules take the demo key, made up to be remembered.
var demoRules = share.Rules{MinLength: 16, MinClasses: 1}
func TestShareFiles(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir())
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", "git@git.example.com:team/app.git"}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Skip("git:", string(out))
}
}
os.WriteFile(filepath.Join(root, ".env"), []byte("A=1\n"), 0o600)
os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644)
providers := []provider.Provider{fake.Azure(0)}
st, _ := store.Load("")
ctx := context.Background()
key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password"
var out bytes.Buffer
if err := shareFiles(ctx, providers, st, key, demoRules, []string{filepath.Join(root, ".env")}, &out); err != nil {
t.Fatal(err)
}
r, err := ref.Parse(strings.TrimSpace(out.String()))
if err != nil {
t.Fatal(err)
}
s, err := share.Read(r)
if err != nil || s.Repo != "git.example.com/team/app" || s.Key.Name != "db-password" {
t.Fatalf("%+v, %v", s.Header, err)
}
files, err := s.Open([]byte("correct-horse-battery-staple"))
if err != nil || len(files) != 1 || files[0].Path != ".env" || string(files[0].Data) != "A=1\n" {
t.Errorf("%+v, %v", files, err)
}
for _, c := range []struct {
general string
args []string
want string
}{
{"", []string{filepath.Join(root, ".env")}, "which secret"},
{key, []string{filepath.Join(root, "main.go")}, "isn't an env file"},
{key, nil, "usage"},
{"", []string{"--key", "lazysecrets://azure/kv-app-prod.vault.azure.net", filepath.Join(root, ".env")}, "isn't a secret"},
} {
if err := shareFiles(ctx, providers, st, c.general, demoRules, c.args, &out); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%v: %v, want %q", c.args, err, c.want)
}
}
}
-5
View File
@@ -1,5 +0,0 @@
//go:build !unix
package main
func blockingStdin() {}
-20
View File
@@ -1,20 +0,0 @@
//go:build unix
package main
import (
"os"
"syscall"
"golang.org/x/term"
)
// blockingStdin makes a terminal on stdin blocking again. fzf's become
// hands on its /dev/tty non-blocking; Go can't poll /dev/tty on macOS,
// so it reads it directly, and the TUI's first read would fail with
// EAGAIN ("resource temporarily unavailable").
func blockingStdin() {
if fd := int(os.Stdin.Fd()); term.IsTerminal(fd) {
_ = syscall.SetNonblock(fd, false)
}
}
+6 -12
View File
@@ -7,16 +7,14 @@ import (
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// updater is the app's way to find and install releases, replacing the
// binary that's running, from the servers (config releases) or the
// default ones.
func updater(servers []string) *app.Updater {
c := &update.Client{Servers: servers}
// binary that's running.
func updater() *app.Updater {
c := &update.Client{}
return &app.Updater{
Latest: c.Latest,
Install: func(ctx context.Context, r update.Release) error {
@@ -31,19 +29,15 @@ func updater(servers []string) *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it.
func updateCmd(configPath string, args []string) error {
func updateCmd(args []string) error {
check := len(args) == 1 && args[0] == "--check"
if len(args) > 0 && !check {
return errors.New("usage: lazysecrets update [--check]")
}
cfg, err := config.LoadAll(strings.Split(configPath, ","))
if err != nil {
return err
}
current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
c := &update.Client{Servers: cfg.Updates.Sources}
c := &update.Client{}
r, err := c.Latest(ctx)
if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
@@ -55,7 +49,7 @@ func updateCmd(configPath string, args []string) error {
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil
}
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes()))
if check {
fmt.Println("lazysecrets update installs it.")
return nil
+2 -20
View File
@@ -1,10 +1,6 @@
package main
import (
"regexp"
"runtime/debug"
"strings"
)
import "runtime/debug"
// version is set by release builds:
//
@@ -12,12 +8,7 @@ import (
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})(\+.*)?$`)
// recorded: the module version (go install …@v1.2.3), else the commit.
func versionString() string {
if version != "" {
return version
@@ -27,15 +18,6 @@ func versionString() string {
return "dev"
}
if v := bi.Main.Version; v != "" && v != "(devel)" {
// A commit, not a release: a local build (Go stamps those too) or
// go install …@<commit>.
if m := pseudo.FindStringSubmatch(v); m != nil {
dirty := ""
if strings.HasSuffix(v, "+dirty") {
dirty = "-dirty"
}
return "dev-" + m[1][:7] + dirty
}
return v
}
var rev, dirty string
-22
View File
@@ -1,22 +0,0 @@
package main
import "testing"
func TestPseudoVersions(t *testing.T) {
for v, want := range map[string]string{
"v0.1.3-0.20261014174204-1c31a75d0507": "1c31a75d0507",
"v0.1.3-0.20261014174204-1c31a75d0507+dirty": "1c31a75d0507",
"v0.0.0-20261014174204-1c31a75d0507": "1c31a75d0507",
"v0.2.0-rc.1.0.20261014174204-1c31a75d0507": "1c31a75d0507",
"v0.1.2": "",
"v0.2.0-rc.1": "",
} {
got := ""
if m := pseudo.FindStringSubmatch(v); m != nil {
got = m[1]
}
if got != want {
t.Errorf("%s: %q, want %q", v, got, want)
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

+9 -21
View File
@@ -16,10 +16,12 @@ 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: 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.
and the token lazysecrets already has. Below them are the groups the
account is in, nested ones too: those come from Microsoft Graph, once a
session, the first time the pane shows them. `y` on the row copies the
object ID, the one access policies and role assignments ask for; `tab`
goes into the pane, where `j`/`k` pick any field or group, `/` filters
them (`/tenant`, `/platform`) and `y` copies the selected one.
If an account's az login has expired, the status line says so once, and the
Details pane on that account's rows says it until you sign in: press `L`
@@ -38,10 +40,8 @@ vault resources (e.g. *Reader*). Key Vault data-plane access is separate
| read secret values | *Key Vault Secrets User* |
| read certificate details | *Key Vault Certificate User* |
A missing permission is shown in the pane, shortened to what was denied
(the action, or the access policy permission), with what to check: a
role or group to activate in PIM, access from the vault's owner, or, when
its firewall refused, the network you're on.
A missing permission is shown in the pane, including the action that was
denied.
## Vaults you can't discover
@@ -63,16 +63,4 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane
(`[`/`]`). `space` reveals a secret's value, or the public material of a
key (PEM public key) or certificate; private key material is never
fetched. `v` lists an item's versions. Items expiring within
`ui.expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
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. 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
purge protection, which networks can reach it, private endpoints and tags.
They come with the list of vaults, so they cost no extra requests and are
cached with it.
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
+23 -54
View File
@@ -2,60 +2,40 @@
Optional, at `~/.config/lazysecrets/config.yaml` (or
`$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.
it. Unknown keys are an error, so typos don't go unnoticed.
```yaml
ui:
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
recentIcon: false # mark items used lately (Recent, R) with ◷, here and in lazysecrets list
sourcesView: grouped # or flat; ` toggles it
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d; ⚠ in lazysecrets list); expired ones get ✗
values:
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
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
storage:
cache: true # keep what was loaded (never values) for the next start
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
timeout: 30s # per backend request
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
sourcesView: grouped # or flat; ` toggles it
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
history: true # count uses of items for Recent (R)
updates:
autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way)
autoCheckInterval: 24h # at most this often
sources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets
cache: true # keep what was loaded (never values) for the next start
providers:
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: [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`),
More, each on its own page: [repos](repos.md) (`providers.repos`), the
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`).
@@ -76,7 +56,7 @@ theme:
activeBorderColor: ["#ff966c", bold] # focused pane
inactiveBorderColor: ["#589ed7"]
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold)
inactiveViewSelectedLineBgColor: ["#2f334d"]
defaultFgColor: ["#c8d3f5"]
optionsTextColor: ["#82aaff"] # keys and labels
searchMatchColor: ["#ffc777", bold]
@@ -92,11 +72,8 @@ theme:
repoColor: ["#c099ff"]
```
A role lazysecrets doesn't know, a typo or one from a newer version, is
skipped with a warning.
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
as boxes, turn them off with `ui.icons: false`.
as boxes, turn them off with `icons: false`.
## Layering
@@ -120,13 +97,11 @@ starred the item under (a vault two of your accounts can see can be starred
for one); a note describes the item, so it shows under every account. It's
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,
for Recent (`R`). Records kept only for usage are dropped after 90 days
unused, and beyond the 100 most recent. `storage.history: false` stops counting.
unused, and beyond the 100 most recent. `history: false` stops counting.
It also keeps where you left lazysecrets in each folder (the selected
source, vault or secret), and goes back there the next time it starts in
@@ -139,30 +114,24 @@ What lazysecrets has loaded is kept in
you: the Sources lists, vaults and scopes, and secrets' names and metadata
(tags, dates, references), never values. On the next start they show at
once, and anything older than two minutes reloads in the background when
you go to it, or when Go to searches it. While it does, the pane's
bottom border says how old what it shows is (`cached 3h ago`). Revealing
and copying always fetch the value live. [`lazysecrets list`](integration.md#listing-for-fzf-and-pickers)
reads it and adds to it too.
you go to it, or when Go to searches it. Revealing and copying always
fetch the value live.
Lists not loaded for 30 days are dropped, and so is everything from an
account you're no longer signed in to. Repos aren't cached: their files
are read from disk anyway. Deleting the file is always safe, and
`storage.cache: false` stops using it.
`cache: false` stops using it.
## Updates
At startup, in the background, lazysecrets asks for the latest release
(`updates.autoCheck`), once a day or every `updates.autoCheckInterval`;
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 footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
Once a day (`updateCheck`), in the background, lazysecrets asks the
release server for the latest release; within the day it reuses the
answer from the state file. Offline, or with the server down, it says
nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows its changelog.
Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against
that server's `checksums.txt`, makes
your OS and CPU, checks it against the release's `checksums.txt`, makes
sure it runs, and only then replaces the binary, atomically: a
lazysecrets that's running keeps working, and the next start is the new
version. A binary a package manager owns (Homebrew, Nix, a distro
-4
View File
@@ -13,10 +13,6 @@ Secrets in Azure Key Vault-backed scopes are read through Databricks; if
Databricks refuses, lazysecrets reads the backing vault directly with your
az login (which then needs the [Key Vault roles](azure.md#permissions)).
When Databricks refuses, the pane says what to check: access to the
workspace and READ on the scope, or, when the workspace's IP access list
refused, the network you're on.
Hide profiles you don't want in the [config](config.md):
```yaml
+3 -12
View File
@@ -7,11 +7,6 @@ go test ./...
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
```
In `--demo`, sharing env files works end to end: `S` → `f` on a demo
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 about 21 MB instead of 30 MB:
```sh
@@ -22,7 +17,7 @@ Development builds show `dev-<commit>` as their version (`-dirty` with
uncommitted changes); `go install …@v0.2.0` shows the tag, releases the
version they were built for.
## How changes land
## Pull requests
Pull requests into `main` are squash-merged, so each is one commit titled
like the PR. The title is a [conventional commit](https://www.conventionalcommits.org)
@@ -61,12 +56,8 @@ from the pull request titles. The tag runs the release workflow
(`.gitea/workflows/release.yml`): it tests, then
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
archives and publishes a Gitea release with them and the release's
changelog section. Then `scripts/github-release.mjs` copies that release,
notes and files, to the GitHub mirror, with the `GH_RELEASE_TOKEN`
secret (a fine-grained token that can write the mirror's contents);
without the secret it skips that. `goreleaser release --snapshot
--clean` builds the same archives locally, into `dist/`, without
publishing.
changelog section. `goreleaser release --snapshot --clean` builds the same
archives locally, into `dist/`, without publishing.
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
alone.
-94
View File
@@ -1,94 +0,0 @@
# Integration
lazysecrets works outside its own window too: `lazysecrets get` reads a
value for scripts, and `lazysecrets list` lists everything for fzf, editor
pickers and your own tools. Both take the [links](sharing.md) lazysecrets
copies and shares. For Neovim, there's
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
## Reading a value
`lazysecrets get <link>` prints the value, for scripts and pipes:
```sh
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
```
It reads the value directly, with your az accounts (the one that last read
it first), the Databricks profile for the workspace, or sops in your clone
of the repo; a version in the link reads that version. `--account` tries
that account first, as [`list`](#listing-for-fzf-and-pickers) prints it.
The value ends with a newline only on a terminal. When it can't read the
value, it says why on stderr and exits with status 1.
## Listing, for fzf and pickers
`lazysecrets list` prints every source, vault and secret, one per line,
tab-separated:
| Field | |
|---|---|
| kind | `source`, `vault`, `secret`, `key` or `certificate` |
| mark | ✗ when something's wrong (expired, or couldn't be read) or ⚠ when it expires soon; ★ when starred; ◷ when used lately (with `--recent`); and the provider's icon |
| name | `platform-prod / kv-app-prod / db-password` |
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
| account | what it's listed through: 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
it as a stream. Values are never listed. It stops quietly when the reader
goes, like fzf after a pick. Kind stays the first field, and account and
link the last two; new fields only ever go between. The mark has a field of
its own so a search can leave it out: in fzf, `--nth` searches the name,
so `^prod` finds names starting with prod.
| Flag | |
|---|---|
| `--format json` | an object per line instead, for editor plugins (below) |
| `--kind secret,vault` | only these kinds: `source`, `vault`, `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 |
| `--color never` | no colours, for scripts |
| `--icons=false` | no provider icons |
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
| `--notes=false` | no note icon before a note |
| `--recent` | ◷ before names used lately, in `mutedColor`; default `ui.recentIcon` |
| `--expiry=false` | no expiry: not in the status, and no ✗ or ⚠ for it |
With `--format json`, each line is an object with these fields; new ones
may be added:
| Field | |
|---|---|
| `link`, `kind`, `account` | as above |
| `error` | why it couldn't be read, only then |
| `provider` | `azure`, `databricks` or `repo` |
| `path` | the names: `["platform-prod", "kv-app-prod", "db-password"]` |
| `accountLabel` | the account's short name, when several az accounts are signed in |
| `starred` | starred for this account |
| `note` | your note on it, if any |
| `reference` | how config refers to a secret: `@Microsoft.KeyVault(SecretUri=…)` or `{{secrets/scope/key}}` (`Y` in lazysecrets) |
| `url` | its web page, in the Azure portal or the Databricks workspace (`o` in lazysecrets) |
| `recent` | used lately, as Recent (`R`) lists: in the last 90 days |
| `lastUsed`, `uses` | when it was last used, and how often; only when `recent`, to sort by |
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
`lazysecrets open --action go --account <account> <link>` opens one in
lazysecrets without asking what to do or which account, ending in the
secrets list, like Go to.
### 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' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](assets/fzf-example.png)
+24 -36
View File
@@ -13,8 +13,7 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
|---|---|
| `j`/`k`, `↑`/`↓` | move |
| `g`/`G` | top / bottom |
| `tab`, `shift+tab`/`h` | next / previous pane |
| `l` | the details of what's selected, on the right (`h` goes back) |
| `tab`/`l`, `shift+tab`/`h` | next / previous pane |
| `1`–`4` | jump to pane |
| `enter` / `esc`, `⌫` | drill in / back (also clears a filter or leaves the versions view) |
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
@@ -49,24 +48,19 @@ match case exactly.
| Key | Action |
|---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) |
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field or group |
| `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 or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
| `s` / `S` | copy a [link](links.md) to the selected item / share menu |
| `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) |
| `L` | sign in again (shown when a login has expired) |
| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one |
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.
A revealed value hides again after `values.revealTimeout` (60s) and
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
A revealed value hides again after `revealTimeout` (60s) and when the
terminal loses focus (`hideOnBlur`); see [config](config.md).
## Stars and notes
@@ -84,24 +78,20 @@ under every account. Both are kept in the [state file](config.md#state).
starred) and `R` on Recent (what you used last). `enter` goes to the
selected item.
Search has the scope on the left and what's in it on the right. The
scopes are All, then grouped like the Sources pane: each account (or
Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how
much of each is loaded. Repos searches every repo without asking Azure
or Databricks for anything. Go to opens on All; `ctrl+s` again narrows
it to the source or group selected in the Sources pane, and once more
goes back to All. Typing a search loads what it covers: everything on
All, else the account or source picked; results appear as lists
arrive. That's once each time Go to opens, and only what isn't loaded
yet; `ctrl+a` loads again, lists that failed included.
Search has the scope on the left (All; all of Azure, Databricks or repos;
or one source; with `○ ◐ ●` for how much of it is loaded) and what's in
it on the right. All repos searches every repo without asking Azure or
Databricks for anything. It opens on the
source you're in. Typing a search loads what it covers: everything on
All, else the source picked; results appear as lists arrive. That's once
each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads
again, lists that failed included.
Like a pane, Go to is either browsing or typing a filter:
| Browsing | Typing | Action |
|---|---|---|
| `j`/`k` | `ctrl+j`/`ctrl+k` | move |
| `g`/`G` | | top / bottom |
| `ctrl+s` | `ctrl+s` | only the source or group you're in / All |
| `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
| `[` / `]` | | previous / next tab |
| `f` | `ctrl+f` | star |
@@ -117,22 +107,20 @@ anything loads.
The pane on the right describes what's selected in the pane you're in: the
account or subscription in Sources, the vault in Vaults, the secret in
Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
`/` and `y` work on its rows, and `h` goes back. A value you reveal stays in view whichever pane you're in, until
Secrets. Going into it (`4`, or `tab` from an account's row) keeps it on
that; there `j`/`k`, `/` and `y` work on its rows, and `shift+tab` goes
back. A value you reveal stays in view whichever pane you're in, until
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 (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.
your accounts are doing. Focused, the right pane lists more under the
logo (when there's room): the version
and updates (`U`), each account (`L` on one that needs it signs in), 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 |
|---|---|
+25 -89
View File
@@ -1,10 +1,9 @@
# Sharing
# Links
Subscriptions, workspaces, vaults, scopes, repos, files and secrets are
shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
it lands on that item, reading it with their own account and
permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself.
Every subscription, workspace, vault, scope, repo, file and secret has a
`lazysecrets://` link. Share one in chat or a ticket, and whoever opens it
lands on that item, reading it with their own account and permissions:
links carry names, never values.
```
lazysecrets://azure/kv-app-prod.vault.azure.net vault
@@ -15,91 +14,18 @@ lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
## Sharing a link
## Sharing
`s` copies a link to the selected item (from the versions view, pinned to
that version). `S` opens the share menu:
- secrets: the link (`o`), the plain Key Vault URL (`u`), the config
reference (`y`) or the web page (`b`); in an env file, also the file as
an [encrypted link](#sharing-env-files) (`f`)
reference (`y`) or the web page (`b`)
- vaults and scopes: a link (`o`), a link that opens on the current tab
(`t`), the plain vault URL (`u`) or the web page (`b`)
- subscriptions and workspaces: a link (`o`) or the web page (`b`)
- repos and env files: also their env files as an
[encrypted link](#sharing-env-files) (`f`)
## Sharing env files
`.env` files usually live only on your machine. `S` → `f` shares them as
an encrypted link, on a repo (all its plain env files), an env file, or a
key in one (its file):
```
lazysecrets://share/AQ…
```
The files are encrypted with a secret's value, so whoever can read that
secret (in Key Vault, Databricks or a repo's SOPS file) can open the link,
and nobody else: who may open it is up to the vault's access, as for
everything else. Make a secret for it once, random:
```sh
az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl rand -base64 32)"
```
and name it in the repo's `.lazysecrets.yaml` (committed: the whole team
uses it) or your config (for repos that don't name one), by its
`lazysecrets://` link, Key Vault URL or `@Microsoft.KeyVault(...)`
reference:
```yaml
sharing:
key: https://kv-team.vault.azure.net/secrets/env-share
```
With neither, sharing asks which secret, in [Go to](keys.md#go-to): it
lists only secrets, and `enter` picks one (pasting a reference picks it
too).
Anyone with a link can try to guess the key, so sharing refuses one that
isn't long and varied enough, saying how (never showing it). By default
that's 32 characters of two kinds; your config can ask for more, or less:
```yaml
sharing:
keyRules:
minLength: 32 # characters; 16 at least
minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols
```
Opening a link doesn't check them.
On the command line, `lazysecrets share [--key <secret>] <env file>...`
prints the link.
Opening a share link says which repo it's from, how many files and which
secret it's encrypted with, and decrypts only when asked: lazysecrets
reads the secret with your access, then shows where each file goes in
your clone of the repo (or asks for a folder, if it isn't here), which
ones would replace a file there and which ones git doesn't ignore. It
saves them all, only the new ones, or none. New files are readable by you
only.
A link holds the files' paths and contents encrypted ([age](https://age-encryption.org),
the secret's value as passphrase); the repo, the secret and the number of
files are in the clear. Links stay under 16 KB, which fits chat and
tickets.
A link stays readable as long as its secret is. On Azure it's pinned to
the secret's version, so adding a new version (rotating) doesn't break
old links, and disabling the old version, or taking away someone's
access, shuts them out; Databricks secrets have no versions, so changing
the value breaks every old link. Whoever has read the secret's value
before can decrypt links made with it without asking the vault, so rotate
it now and then: new links use the newest version.
## Opening a link
## Opening
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
and fills in any reference it finds there. Key Vault URLs,
@@ -118,13 +44,23 @@ read the clipboard over SSH.
lazysecrets finds the vault in your accounts (one Resource Graph query per
account) and jumps to it, asking which account to use when several can see
it, ending on a secret's details as `enter` on it would. Links say what,
not what to do: opening a link to a secret asks whether to just go to it,
also copy or show its value, or (`y`) copy its value without going there.
`lazysecrets open --action go|copy|show <link>` does one without asking
and ends in the list, like Go to,
and `--account <account>` picks the account (as [`list`](integration.md#listing-for-fzf-and-pickers)
prints it) when several can see it.
it. Links say what, not what to do: opening a link to a secret asks whether
to just go to it, also copy or show its value, or (`y`) copy its value
without going there.
## In scripts
`lazysecrets get <link>` prints the value, for scripts and pipes:
```sh
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
```
It reads the value directly, with your az accounts (the one that last read
it first), the Databricks profile for the workspace, or sops in your clone
of the repo; a version in the link reads that version. The value ends with
a newline only on a terminal.
## From the browser and chat
+7 -26
View File
@@ -3,16 +3,14 @@
lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.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.
(`db.password`, `list[0]`) as secrets.
## Which repos
**Repos** lists every repo with secret files under the `roots` (by
default whichever of `~/git`, `~/repo`, `~/repos`, `~/projects`, `~/code`,
`~/source` and `~/src` exist; one or two levels down, `repo` or
`org/repo`), any listed in `paths`, and any you starred something in
(the repo, a file or a key), wherever it is. They're checked in the background.
**Repos** lists every repo with secret files under the `roots` (`~/git`
by default; one or two levels down, `repo` or `org/repo`), any listed in
`paths`, and any you starred something in (the repo, a file or a key),
wherever it is. They're checked in the background.
Started inside a repo, that repo gets a row of its own at the top,
`Repo · <name>`, and lazysecrets opens on it, with the files under the
@@ -51,20 +49,12 @@ Folders of dependencies and caches are never looked in, tracked or not:
```yaml
providers:
repos:
roots: [~/git] # folders of repos (replaces the defaults; [] turns it off)
roots: [~/git] # folders of repos (the default; [] turns it off)
paths: [~/work/infra] # single repos elsewhere
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
exclude: ["**/testdata/**"]
```
A repo's `.lazysecrets.yaml` can also name the secret its env files are
[shared](sharing.md#sharing-env-files) with:
```yaml
sharing:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
```
## Decrypting
Listing never decrypts: SOPS encrypts values only, so keys and metadata
@@ -73,13 +63,6 @@ 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
fingerprints), when it was last encrypted, and what git knows of it:
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.
## Opening files
`e` opens the selected file, or on a secret, its file at that key's line:
@@ -100,8 +83,6 @@ sops 3.8 or later.
## Links
`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:
@@ -109,4 +90,4 @@ wherever it is:
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
See [sharing](sharing.md).
See [links](links.md).
+37 -37
View File
@@ -1,43 +1,41 @@
module git.kebcloud.no/keb/lazysecrets
go 1.27.2
go 1.26.4
require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.10
charm.land/lipgloss/v2 v2.0.6
filippo.io/age v1.3.2
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/x/ansi v0.11.9
github.com/charmbracelet/x/ansi v0.11.8
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
github.com/databricks/databricks-sdk-go v0.188.0
github.com/junegunn/fzf v0.74.5
github.com/databricks/databricks-sdk-go v0.187.0
github.com/junegunn/fzf v0.74.4
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/mod v0.42.0
golang.org/x/term v0.47.0
gopkg.in/ini.v1 v1.67.3
golang.org/x/mod v0.38.0
golang.org/x/term v0.45.0
gopkg.in/ini.v1 v1.67.0
)
require (
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/auth v0.4.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -47,35 +45,37 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.264.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/api v0.182.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+164 -91
View File
@@ -1,41 +1,36 @@
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/auth v0.4.2 h1:sb0eyLkhRtpq5jA+a8KWw0W70YcdVca7KJ8TM0AFYDg=
cloud.google.com/go/auth v0.4.2/go.mod h1:Kqvlz1cf1sNA0D+sYJnkPQOP+JMHkuHeIgVmCRtZOLc=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1/go.mod h1:oXtinPO4OLj9d1DOTrqrL1oRwGhcqadvAmrl6wTeGlk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 h1:Terj1CMW4+NY5CpWjSRkqLoxh2DkYDdv/FH2z0897Uc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0/go.mod h1:7Ut2Qw9vcwpLW5Q4MBy43uSxXS7tK2yjs94OJ6j5thE=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.2.0 h1:+lnLQhKh3cgSOIOVH61UZ3s/l9d+bAZp5d/spt1+7UI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.2.0/go.mod h1:tStOHrivWUrcBolspvKV70Us1ckESYGYSHdG4LX8zyY=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0 h1:4dT2wTR+ViaGvwcAoMjqt0BvgwBkT6nXxg8flJhnLfw=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0/go.mod h1:7fgu6AtY2gkWhtZt9SEHK/MkTVUn2HPvAZjv6XsrjuI=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0 h1:+1fJwTilk/X7inNqwREnYEOgFCdg8ut7GULxARDbu34=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0/go.mod h1:EGwSLlGqrrfYQhtCi9JcIkPQKl9WxsL6ZPJd+63Vy1A=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v1.0.0 h1:67nFqWXpo0x5Nz0XEb1yI7s8D+EHy8NsTinYw9sZnLk=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v1.0.0/go.mod h1:fewgRjNVE84QVVh798sIMFb7gPXPp7NmnekGnboSnXk=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 h1:guyQA4b8XB2sbJZXzUnOF9mn0WDBv/ZT7me9wTipKtE=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1/go.mod h1:8h8yhzh9o+0HeSIhUxYny+rEQajScrfIpNktvgYG3Q8=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0 h1:TPaLKUUZw3XUaQy5aU+ApHXFKB2VnTWUA0xufkvFDE8=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0/go.mod h1:Rn/a97OKDAoOGvk5/OFjqObHHqWyoMMVSGtikqbKcAg=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0 h1:15MiSirdxWpIKFEHwfwfMWYieETCUnPTERPIlnytQeo=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0/go.mod h1:0AAf/fHAqhIsvD6XGKlCsDO8u+cCiYBBuro+lWS6os4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM=
@@ -48,18 +43,20 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY=
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -68,14 +65,20 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/databricks/databricks-sdk-go v0.188.0 h1:1nxBqlctGnPzlReyziG199abHs9cR/KNjsCnSM+oDGA=
github.com/databricks/databricks-sdk-go v0.188.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/databricks/databricks-sdk-go v0.187.0 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8=
github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -85,23 +88,44 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/junegunn/fzf v0.74.5 h1:mpJlCdgGuddKerHrdjvV3cnezEBgyjZAVn8KCo/4OMc=
github.com/junegunn/fzf v0.74.5/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI=
github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso=
github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
@@ -112,79 +136,128 @@ github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBql
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.182.0 h1:if5fPvudRQ78GeRx3RayIoiuV7modtErPIZC/T2bIvE=
google.golang.org/api v0.182.0/go.mod h1:cGhjy4caqA5yXRzEhkHI8Y9mfyC2VLTlER2l08xaqtM=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+14 -40
View File
@@ -7,12 +7,10 @@
#
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
# and x86_64.
#
# Releases are on GitHub and git.kebcloud.no, tried in that order;
# LAZYSECRETS_RELEASES (repository addresses, space-separated) uses others.
set -eu
repos="${LAZYSECRETS_RELEASES:-https://github.com/karleinarb/lazysecrets https://git.kebcloud.no/keb/lazysecrets}"
repo=https://git.kebcloud.no/keb/lazysecrets
api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
fail() {
@@ -20,13 +18,12 @@ fail() {
exit 1
}
# Short timeouts, so a server that's down leaves time for the next.
if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; }
download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; }
fetch() { curl -fsSL "$1"; }
download() { curl -fsSL -o "$2" "$1"; }
elif command -v wget >/dev/null 2>&1; then
fetch() { wget -qO- -T 15 "$1"; }
download() { wget -qO "$2" -T 30 "$1"; }
fetch() { wget -qO- "$1"; }
download() { wget -qO "$2" "$1"; }
else
fail "needs curl or wget"
fi
@@ -42,26 +39,11 @@ arm64 | aarch64) arch=arm64 ;;
*) fail "no release for $(uname -m)" ;;
esac
# api is a repository's API: GitHub's, or Gitea's (and Forgejo's).
api() {
case "$1" in
https://github.com/*) echo "https://api.github.com/repos/${1#https://github.com/}" ;;
*)
host=${1#*://}
host=${host%%/*}
echo "${1%%://*}://$host/api/v1/repos/${1#*://"$host"/}"
;;
esac
}
tag="${1:-}"
if [ -z "$tag" ]; then
for repo in $repos; do
tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null |
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag=
[ -n "$tag" ] && break
done
[ -n "$tag" ] || fail "couldn't look up the latest release on $repos"
tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') ||
fail "couldn't look up the latest release"
[ -n "$tag" ] || fail "couldn't look up the latest release"
fi
case "$tag" in
v*) ;;
@@ -73,19 +55,11 @@ name="lazysecrets_${version}_${os}_${arch}"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# The archive and its checksums.txt from the first server that has both.
from=
for repo in $repos; do
base="$repo/releases/download/$tag"
if download "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then
echo "Downloading lazysecrets $tag for $os/$arch from $repo"
if download "$base/$name.tar.gz" "$tmp/$name.tar.gz"; then
from=$repo
break
fi
fi
done
[ -n "$from" ] || fail "couldn't download $tag for $os/$arch from $repos"
echo "Downloading lazysecrets $tag for $os/$arch"
download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" ||
fail "couldn't download $tag for $os/$arch: see $repo/releases"
download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" ||
fail "couldn't download $tag's checksums.txt"
want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/checksums.txt")
[ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt"
-172
View File
@@ -1,172 +0,0 @@
// Package cachefile is the cache of what lazysecrets has loaded
// (cache.json): the Sources lists, vaults, secrets' metadata and where
// the cursor was in each, never values. The TUI and lazysecrets list
// both read it and save to it; a save merges with what's on disk, so
// neither undoes what the other loaded.
package cachefile
import (
"encoding/json"
"maps"
"os"
"path/filepath"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Version is the file's format; a file of another version is ignored,
// and replaced on the next save.
const Version = 1
// MaxAge drops what hasn't been loaded again for this long.
const MaxAge = 30 * 24 * time.Hour
// File is the cache. Every key starts with the provider ID: Sources are
// keyed by it, the rest by Row, Objects' keys and the like.
type File struct {
Version int `json:"version"`
Sources map[string]Stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
Containers map[string]Stamped[[]provider.Container] `json:"containers,omitempty"`
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"`
// Dropped are keys deleted since the file was read (a list that
// failed to load): Save deletes them from the file too, rather than
// merging the old entry back. Not saved.
Dropped map[string]bool `json:"-"`
}
// Stamped is a list or detail with when it was loaded.
type Stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
// New is an empty cache.
func New() File {
return File{
Version: Version,
Sources: map[string]Stamped[[]provider.Source]{},
Containers: map[string]Stamped[[]provider.Container]{},
Objects: map[string]Stamped[[]provider.Object]{},
Details: map[string]Stamped[provider.Object]{},
Cursor: map[string]string{},
}
}
// Row keys a source's vaults or scopes.
func Row(providerID, sourceID string) string { return providerID + "|" + sourceID }
// ContainerKey keys what's cached about a vault or scope.
func ContainerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
// ObjectsKey keys a vault's secrets, keys or certificates.
func ObjectsKey(c provider.Container, t provider.ObjectType) string {
return ContainerKey(c) + "|" + string(t)
}
// ProviderOf is the provider ID a key starts with.
func ProviderOf(key string) string {
id, _, _ := strings.Cut(key, "|")
return id
}
// Keep is Save's keep for these providers: their lists are kept, but not
// repos', whose files are read from disk anyway, nor other accounts'.
func Keep(providers []provider.Provider) func(providerID string) bool {
keep := map[string]bool{}
for _, p := range providers {
keep[p.ID()] = p.Kind() != provider.KindRepo
}
return func(id string) bool { return keep[id] }
}
// Read reads path; a missing, unreadable or other version's file is an
// empty cache.
func Read(path string) File {
f := New()
b, err := os.ReadFile(path)
if err != nil {
return f
}
var d File
if json.Unmarshal(b, &d) != nil || d.Version != Version {
return f
}
maps.Copy(f.Sources, d.Sources)
maps.Copy(f.Containers, d.Containers)
maps.Copy(f.Objects, d.Objects)
maps.Copy(f.Details, d.Details)
maps.Copy(f.Cursor, d.Cursor)
return f
}
// Save writes f to path, merged with the file there: of a list in both,
// the one loaded last wins, and f's cursors win (a nil Cursor keeps the
// file's). Entries older than MaxAge, those of providers keep says no to
// (accounts signed out of), and f.Dropped are dropped, from the file's
// side as well. Saves from several lazysecrets take turns (a lock file),
// so none undoes another's.
func Save(path string, f File, keep func(providerID string) bool, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
unlock, err := lock(path + ".lock")
if err != nil {
return err
}
defer unlock()
out := New()
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
disk := Read(path)
merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped)
merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped)
merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped)
merge(out.Details, disk.Details, f.Details, ok, f.Dropped)
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
for k, v := range m {
if keep(ProviderOf(k)) {
out.Cursor[k] = v
}
}
}
return write(path, out)
}
// merge puts disk's entries in out, but not those dropped, then ours
// where they're newer.
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool, dropped map[string]bool) {
for k, e := range disk {
if ok(k, e.At) && !dropped[k] {
out[k] = e
}
}
for k, e := range ours {
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
out[k] = e
}
}
}
// write writes f atomically, readable by the user only.
func write(path string, f File) error {
b, err := json.Marshal(f)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
-122
View File
@@ -1,122 +0,0 @@
package cachefile
import (
"path/filepath"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func list(at time.Time, names ...string) Stamped[[]provider.Container] {
var cs []provider.Container
for _, n := range names {
cs = append(cs, provider.Container{Name: n})
}
return Stamped[[]provider.Container]{At: at, Value: cs}
}
func TestSaveMerges(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
all := func(string) bool { return true }
// The TUI saved two lists.
tui := New()
tui.Containers["az|a"] = list(now.Add(-time.Hour), "kv-a-old")
tui.Containers["az|b"] = list(now.Add(-time.Minute), "kv-b")
tui.Cursor["az|b"] = "kv-b"
if err := Save(path, tui, all, now); err != nil {
t.Fatal(err)
}
// lazysecrets list loads a again, and c.
listed := Read(path)
listed.Containers["az|a"] = list(now, "kv-a-new")
listed.Containers["az|c"] = list(now, "kv-c")
if err := Save(path, listed, all, now); err != nil {
t.Fatal(err)
}
// The TUI saves what it has, older: list's lists stay.
if err := Save(path, tui, all, now); err != nil {
t.Fatal(err)
}
f := Read(path)
for key, want := range map[string]string{"az|a": "kv-a-new", "az|b": "kv-b", "az|c": "kv-c"} {
if got := f.Containers[key].Value; len(got) != 1 || got[0].Name != want {
t.Errorf("%s = %v, want %s", key, got, want)
}
}
if f.Cursor["az|b"] != "kv-b" {
t.Error("cursor lost")
}
}
func TestSaveDrops(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
f := New()
f.Containers["az|old"] = list(now.Add(-MaxAge-time.Hour), "kv-old")
f.Containers["gone|x"] = list(now, "kv-gone")
f.Containers["az|x"] = list(now, "kv-x")
if err := Save(path, f, func(id string) bool { return true }, now); err != nil {
t.Fatal(err)
}
// Signed out of "gone" since: dropped from the file too.
if err := Save(path, New(), func(id string) bool { return id == "az" }, now); err != nil {
t.Fatal(err)
}
got := Read(path).Containers
if _, ok := got["az|old"]; ok {
t.Error("kept a list older than MaxAge")
}
if _, ok := got["gone|x"]; ok {
t.Error("kept a signed-out account's list")
}
if _, ok := got["az|x"]; !ok {
t.Error("dropped a list on disk the save didn't have")
}
}
func TestReadOtherVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
f := New()
f.Version = Version + 1
f.Containers["az|x"] = list(time.Now(), "kv-x")
if err := write(path, f); err != nil {
t.Fatal(err)
}
if got := Read(path); len(got.Containers) != 0 || got.Version != Version {
t.Errorf("read another version's file: %+v", got)
}
}
func TestSaveDroppedAndCursors(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
all := func(string) bool { return true }
f := New()
f.Containers["az|bad"] = list(now, "kv-old")
f.Cursor["az|x"] = "kv-tui"
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
// The TUI fails to load it again and drops it: it stays gone.
f = New()
f.Dropped = map[string]bool{"az|bad": true}
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
if _, ok := Read(path).Containers["az|bad"]; ok {
t.Error("a dropped list came back")
}
// lazysecrets list saves without cursors: the TUI's stay.
listed := New()
listed.Cursor = nil
listed.Containers["az|y"] = list(now, "kv-y")
if err := Save(path, listed, all, now); err != nil {
t.Fatal(err)
}
if Read(path).Cursor["az|x"] != "kv-tui" {
t.Error("the TUI's cursor was lost")
}
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !unix
package cachefile
// lock does nothing here: saves can then race, losing lists that load
// again.
func lock(string) (func(), error) { return func() {}, nil }
-25
View File
@@ -1,25 +0,0 @@
//go:build unix
package cachefile
import (
"os"
"syscall"
)
// lock takes path as a lock file, waiting while another lazysecrets has
// it, and returns how to let go.
func lock(path string) (unlock func(), err error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
f.Close()
return nil, err
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
+37 -123
View File
@@ -14,23 +14,41 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
type Config struct {
UI UI `yaml:"ui"`
// Values keeps revealed and copied values from lingering.
Values Values `yaml:"values"`
// Storage is what's kept between runs.
Storage Storage `yaml:"storage"`
// Updates sets how lazysecrets looks for newer releases.
Updates Updates `yaml:"updates"`
Icons *bool `yaml:"icons"`
ClipboardClear *time.Duration `yaml:"clipboardClear"`
// RevealTimeout hides a revealed value after this long; 0s never.
// Defaults to 60s.
RevealTimeout *time.Duration `yaml:"revealTimeout"`
// ExpiryWarningDays marks secrets, keys and certificates expiring
// within this many days; 0 marks only expired ones. Defaults to 30.
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
Timeout time.Duration `yaml:"timeout"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
// UpdateCheck looks for a newer release once a day, in the
// background; defaults to true. U and lazysecrets update work either
// way.
UpdateCheck *bool `yaml:"updateCheck"`
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
Providers Providers `yaml:"providers"`
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
LinkHandler LinkHandler `yaml:"linkHandler"`
// Sharing sets how env files are shared; see package share.
Sharing Sharing `yaml:"sharing"`
// CustomCommands run shell commands on the selected item; see
// package commands.
CustomCommands []commands.Command `yaml:"customCommands"`
@@ -42,55 +60,6 @@ type Config struct {
Theme map[string][]string `yaml:"theme"`
}
type UI struct {
Icons *bool `yaml:"icons"`
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
// and in lazysecrets list. Defaults to false.
RecentIcon *bool `yaml:"recentIcon"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// ExpiryWarningDays marks secrets, keys and certificates expiring
// within this many days; 0 marks only expired ones. Defaults to 30.
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
}
type Values struct {
// RevealTimeout hides a revealed value after this long; 0s never.
// Defaults to 60s.
RevealTimeout *time.Duration `yaml:"revealTimeout"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
ClipboardClear *time.Duration `yaml:"clipboardClear"`
}
type Storage struct {
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
}
type Updates struct {
// AutoCheck looks for a newer release in the background at startup,
// at most once per AutoCheckInterval; defaults to true. U and
// lazysecrets update work either way.
AutoCheck *bool `yaml:"autoCheck"`
// AutoCheckInterval defaults to 24h.
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
// Sources are the repositories updates come from, tried in
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
// then git.kebcloud.no (update.DefaultServers).
Sources []string `yaml:"sources"`
}
type LinkHandler struct {
// Terminal to open links in, overriding the one handler install
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
@@ -108,27 +77,7 @@ type LinkHandler struct {
Prompt *bool `yaml:"prompt"`
}
type Sharing struct {
// Key is the secret whose value encrypts shared files (its
// lazysecrets:// link, Key Vault URL or reference), for repos whose
// .lazysecrets.yaml doesn't name one.
Key string `yaml:"key"`
// KeyRules are what a key's value must be to share with it.
KeyRules KeyRules `yaml:"keyRules"`
}
// KeyRules default to 32 characters of 2 kinds (share.DefaultRules).
type KeyRules struct {
MinLength int `yaml:"minLength"` // at least 16
// MinClasses is how many kinds of character: lowercase, uppercase,
// digits, symbols.
MinClasses int `yaml:"minClasses"`
}
type Providers struct {
// Timeout bounds each backend request; defaults to 30s. Each
// provider's own timeout overrides it for that provider.
Timeout time.Duration `yaml:"timeout"`
Azure Azure `yaml:"azure"`
Databricks Databricks `yaml:"databricks"`
Repos Repos `yaml:"repos"`
@@ -138,13 +87,11 @@ type Providers struct {
// that repo is the only one shown; elsewhere, the ones in Paths and under
// Roots.
type Repos struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
Paths []string `yaml:"paths"`
// Roots are folders of repos. Defaults to repo.DefaultRoots (~/git,
// ~/src, ~/code and the like); an empty list turns it off.
// Roots are folders of repos. Defaults to ~/git; an empty list
// turns it off.
Roots *[]string `yaml:"roots"`
// Include and Exclude are globs relative to a repo's root (** for any
// depth), for repos without their own .lazysecrets.yaml. Without them,
@@ -154,8 +101,6 @@ type Repos struct {
}
type Azure struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
// HideSubscriptions lists subscriptions to leave out, by name or ID.
@@ -166,8 +111,6 @@ type Azure struct {
}
type Databricks struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
HideProfiles []string `yaml:"hideProfiles"`
@@ -254,48 +197,19 @@ func decodeInto(c *Config, path string) error {
}
func (c Config) validate(path string) error {
if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" {
return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v)
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
}
for _, cmd := range c.CustomCommands {
if err := cmd.Validate(); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
}
if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 {
return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d)
}
if d := c.UI.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: ui.loadDelay: %s is not between 0s and 5s", path, *d)
}
if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
}
for i, r := range c.Updates.Sources {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: updates.sources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
}
}
if k := c.Sharing.Key; k != "" {
if _, err := share.ParseKey(k); err != nil {
return fmt.Errorf("%s: sharing.key: %w", path, err)
}
}
if n := c.Sharing.KeyRules.MinLength; n != 0 && n < 16 {
return fmt.Errorf("%s: sharing.keyRules.minLength: %d is under 16", path, n)
}
if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
}
for name, d := range map[string]time.Duration{
"providers.timeout": c.Providers.Timeout,
"providers.azure.timeout": c.Providers.Azure.Timeout,
"providers.databricks.timeout": c.Providers.Databricks.Timeout,
"providers.repos.timeout": c.Providers.Repos.Timeout,
} {
if d < 0 {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
if d := c.ExpiryWarningDays; d != nil && *d < 0 {
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d)
}
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
}
for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") {
+13 -38
View File
@@ -19,17 +19,12 @@ func write(t *testing.T, content string) string {
func TestLoad(t *testing.T) {
c, err := Load(write(t, `
ui:
icons: false
sourcesView: flat
values:
clipboardClear: 45s
storage:
history: false
providers:
timeout: 1m
providers:
azure:
timeout: 2m
hideSubscriptions: [platform-mgmt]
vaults:
- https://kv-foo.vault.azure.net/
@@ -40,12 +35,12 @@ providers:
if err != nil {
t.Fatal(err)
}
if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second ||
c.Providers.Timeout != time.Minute || c.Providers.Azure.Timeout != 2*time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second ||
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
t.Errorf("config = %+v", c)
}
if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) {
t.Errorf("ui = %+v, storage = %+v", c.UI, c.Storage)
if c.SourcesView != "flat" {
t.Errorf("sourcesView = %q", c.SourcesView)
}
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
t.Error("enabled flags wrong")
@@ -62,7 +57,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"empty": write(t, ""),
} {
c, err := Load(path)
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
if err != nil || c.Icons != nil || c.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err)
}
}
@@ -73,17 +68,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
"typo": "icon: true\n",
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"flat keys": "icons: false\n",
"duration": "providers:\n timeout: soon\n",
"negative": "providers:\n repos:\n timeout: -1s\n",
"view": "ui:\n sourcesView: tree\n",
"sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n",
"http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n",
"interval": "updates:\n autoCheckInterval: 0s\n",
"share key": "sharing:\n key: https://kv.vault.azure.net/\n",
"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",
"duration": "timeout: soon\n",
"view": "sourcesView: tree\n",
} {
if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name)
@@ -95,7 +81,6 @@ func TestLoadRejectsMistakes(t *testing.T) {
func TestLoadAllLayers(t *testing.T) {
user := write(t, `
ui:
icons: false
theme:
azureColor: [blue]
@@ -105,8 +90,6 @@ providers:
hideSubscriptions: [a]
`)
theme := write(t, `
ui:
sourcesView: flat
theme:
azureColor: ["#82aaff"]
activeBorderColor: ["#ff966c", bold]
@@ -115,8 +98,7 @@ theme:
if err != nil {
t.Fatal(err)
}
// A group set in both files merges: icons from one, sourcesView the other.
if c.UI.Icons == nil || *c.UI.Icons || c.UI.SourcesView != "flat" || len(c.Providers.Azure.HideSubscriptions) != 1 {
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 {
t.Errorf("user settings lost: %+v", c)
}
want := map[string][]string{
@@ -131,19 +113,12 @@ theme:
}
}
func TestUpdates(t *testing.T) {
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n"))
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 := 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)
c, err := Load(write(t, "loadDelay: 300ms\n"))
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.LoadDelay, err)
}
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted")
}
}
-41
View File
@@ -1,41 +0,0 @@
// Package expiry says whether a secret, key or certificate has expired
// or will soon, and how long it has left, the same way everywhere: the
// TUI's lists and status pane, and lazysecrets list.
package expiry
import (
"fmt"
"time"
)
type State int
const (
None State = iota // no expiry, or not within the warning period
Soon // within the warning period
Expired // already
)
// Of tells whether something expiring at expires (zero: never) has
// expired by now or will within warn, and how long it has left.
func Of(expires, now time.Time, warn time.Duration) (State, time.Duration) {
if expires.IsZero() {
return None, 0
}
left := expires.Sub(now)
switch {
case left <= 0:
return Expired, 0
case left <= warn:
return Soon, left
}
return None, left
}
// Left is a short "12d", or "5h" on the last day.
func Left(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
-27
View File
@@ -1,27 +0,0 @@
package expiry
import (
"testing"
"time"
)
func TestOf(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
warn := 30 * 24 * time.Hour
for _, c := range []struct {
expires time.Time
want State
left string
}{
{time.Time{}, None, ""},
{now.Add(-time.Hour), Expired, ""},
{now.Add(12*24*time.Hour + time.Hour), Soon, "12d"},
{now.Add(5 * time.Hour), Soon, "5h"},
{now.Add(60 * 24 * time.Hour), None, ""},
} {
state, left := Of(c.expires, now, warn)
if state != c.want || (c.left != "" && Left(left) != c.left) {
t.Errorf("%v: %v %s, want %v %s", c.expires, state, Left(left), c.want, c.left)
}
}
}
-399
View File
@@ -1,399 +0,0 @@
// Package listing lists what lazysecrets can find, for lazysecrets list:
// what Go to searches, without the TUI. What's cached comes first, at
// once; then lists that are missing or older than MaxAge load in the
// background, a few at a time, and what's new in them follows as they
// arrive. Repos are always read, from disk. Values are never listed.
package listing
import (
"cmp"
"context"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// Workers is how many lists load at once, as in Go to.
const Workers = 4
// Kinds are the kinds of entry, from sources down.
var Kinds = []string{"source", "vault", "secret", "key", "certificate"}
// Entry is a source, vault or item, by its lazysecrets:// link.
type Entry struct {
Link string `json:"link"`
// Kind is source (a subscription, workspace or repo), vault (a Key
// Vault, secret scope or repo file), secret, key or certificate.
Kind string `json:"kind"`
// Provider is azure, databricks or repo, as in links.
Provider string `json:"provider"`
// Path names it: its source's, vault's and own names.
Path []string `json:"path"`
// Account is the provider it's listed through: an az account
// (Azure:<user>/<tenant>), Databricks or Repos. lazysecrets open and
// get take it (--account) to use that account when several could.
Account string `json:"account"`
// AccountLabel names the az account, when there are several.
AccountLabel string `json:"accountLabel,omitempty"`
// Starred is starred for this account; Recent is used lately (Recent,
// R, in the TUI): LastUsed is when, Uses how often.
Starred bool `json:"starred"`
Recent bool `json:"recent"`
LastUsed time.Time `json:"lastUsed,omitzero"`
Uses int `json:"uses,omitempty"`
// Expires is when a secret, key or certificate expires, if it does;
// Enabled whether it's enabled, where the provider says (Azure).
Expires time.Time `json:"expires,omitzero"`
Enabled *bool `json:"enabled,omitempty"`
// Note is the user's note on it, if any.
Note string `json:"note,omitempty"`
// Reference is how config refers to a secret, where the provider has
// a way (@Microsoft.KeyVault(...), {{secrets/scope/key}}); URL its
// web page (portal, workspace), if any.
Reference string `json:"reference,omitempty"`
URL string `json:"url,omitempty"`
// Error says why a source's vaults or a vault's secrets couldn't be
// listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link.
Error string `json:"error,omitempty"`
}
type Options struct {
Providers []provider.Provider
// Cache is what was loaded before; Run adds what it loads.
Cache cachefile.File
// MaxAge: cached lists loaded longer ago are loaded again.
MaxAge time.Duration
// Cached lists only what's cached (and repos): nothing is loaded.
Cached bool
// Kinds are the kinds of entry to list (Kinds' names); none is all.
// Lists only needed for other kinds aren't loaded: sources and vaults
// alone don't load any secrets. Lists that fail are always listed.
Kinds []string
// Mark tells what's remembered about the entry with link, seen
// through account; nil marks nothing.
Mark func(link, account string) Marks
Timeout func(provider.Kind) time.Duration // nil: 30s
Now func() time.Time
}
// Marks are what's remembered about an entry: starred for the account,
// used lately (Recent) and when and how often, and its note.
type Marks struct {
Starred, Recent bool
LastUsed time.Time
Uses int
Note string
}
// Run lists everything, calling emit for each entry once, from this
// goroutine, and for each list that can't be loaded, as it fails. An
// error from emit (the reader went away) stops it. It returns the cache
// with what was loaded, and whether anything was.
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
results: make(chan result)}
if l.o.Now == nil {
l.o.Now = time.Now
}
if l.o.Timeout == nil {
l.o.Timeout = func(provider.Kind) time.Duration { return 30 * time.Second }
}
for _, p := range o.Providers {
if l.stopped != nil {
break
}
l.provider(p)
}
l.start(ctx)
for l.active > 0 {
r := <-l.results
l.active--
if l.stopped == nil {
l.handle(r)
l.start(ctx)
}
if l.stopped != nil {
cancel()
}
}
return l.o.Cache, l.loaded
}
type job struct {
p provider.Provider
src *provider.Source // list its vaults
c *provider.Container // list its secrets, keys or certificates (t)
t provider.ObjectType
key string // where the list is cached
}
type result struct {
job
sources []provider.Source
containers []provider.Container
objects []provider.Object
err error
}
type lister struct {
o Options
emit func(Entry) error
seen map[string]bool // provider ID + link of entries emitted
pending map[string]bool // cache keys queued or loading
failed map[string]bool // cache keys that couldn't be loaded: not again
loaded bool // something was cached
queue []job
active int
results chan result
stopped error // emit's error
}
func (l *lister) cacheable(p provider.Provider) bool { return p.Kind() != provider.KindRepo }
// load reports whether to load a list: a repo's always (from disk), else
// unless Cached, when it isn't cached or is older than MaxAge.
func (l *lister) load(p provider.Provider, cached bool, at time.Time) bool {
if !l.cacheable(p) {
return true
}
return !l.o.Cached && (!cached || l.o.Now().Sub(at) >= l.o.MaxAge)
}
func (l *lister) provider(p provider.Provider) {
e, ok := l.o.Cache.Sources[p.ID()]
if l.cacheable(p) && ok {
for _, src := range sorted(e.Value, func(s provider.Source) string { return s.Name }) {
l.source(p, src)
}
}
if l.load(p, ok, e.At) {
l.enqueue(job{p: p, key: p.ID()})
}
}
// source emits src, and what's cached under it, and queues what's not.
func (l *lister) source(p provider.Provider, src provider.Source) {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
l.put(p, sourceEntry(src))
row := cachefile.Row(p.ID(), src.ID)
e, ok := l.o.Cache.Containers[row]
if l.cacheable(p) && ok {
l.containers(p, src, e.Value)
}
if l.load(p, ok, e.At) {
if l.wants("vault", "secret", "key", "certificate") {
l.enqueue(job{p: p, src: &src, key: row})
}
}
}
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
c.Source = src
l.put(p, containerEntry(c))
for _, t := range c.ObjectTypes() {
key := cachefile.ObjectsKey(c, t)
e, ok := l.o.Cache.Objects[key]
if l.cacheable(p) {
l.objects(p, c, e.Value)
}
// Keys and certificates load only when asked for; else,
// like Go to, only as far as the TUI opened them.
asked := l.wants(strings.ToLower(string(t)))
if t != provider.TypeSecret {
asked = len(l.o.Kinds) > 0 && asked
}
if asked && l.load(p, ok, e.At) {
l.enqueue(job{p: p, c: &c, t: t, key: key})
}
}
}
}
func (l *lister) objects(p provider.Provider, c provider.Container, objs []provider.Object) {
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
o.Container = c
r, err := ref.ForObject(o, "")
if err != nil {
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})
}
}
func sourceEntry(src provider.Source) Entry {
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()
}
return e
}
func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String()
}
return e
}
// put emits e, unless it was already (or emitting has failed). Entries
// without a link (one couldn't be made) are left out, but not errors.
func (l *lister) put(p provider.Provider, e Entry) {
if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
return
}
e.Account = p.ID()
if l.o.Mark != nil && e.Link != "" {
m := l.o.Mark(e.Link, e.Account)
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
if m.Recent {
e.LastUsed, e.Uses = m.LastUsed, m.Uses
}
}
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] {
return
}
l.seen[id] = true
l.stopped = l.emit(e)
}
// wants reports whether any of kinds is listed.
func (l *lister) wants(kinds ...string) bool {
if len(l.o.Kinds) == 0 {
return true
}
for _, k := range kinds {
if slices.Contains(l.o.Kinds, k) {
return true
}
}
return false
}
func (l *lister) enqueue(j job) {
if l.pending[j.key] || l.failed[j.key] {
return
}
l.pending[j.key] = true
l.queue = append(l.queue, j)
}
func (l *lister) start(ctx context.Context) {
for l.stopped == nil && l.active < Workers && len(l.queue) > 0 {
j := l.queue[0]
l.queue = l.queue[1:]
l.active++
go func() { l.results <- l.fetch(ctx, j) }()
}
}
func (l *lister) fetch(ctx context.Context, j job) result {
kind := j.p.Kind()
ctx, cancel := context.WithTimeout(ctx, l.o.Timeout(kind))
defer cancel()
r := result{job: j}
switch {
case j.c != nil:
r.objects, r.err = j.p.Objects(ctx, *j.c, j.t)
case j.src != nil:
r.containers, r.err = j.p.Containers(ctx, *j.src)
default:
r.sources, r.err = j.p.Sources(ctx)
}
return r
}
func (l *lister) handle(r result) {
delete(l.pending, r.key)
now := l.o.Now()
if r.err != nil {
l.failed[r.key] = true
l.put(r.p, r.failed())
return
}
l.loaded = l.loaded || l.cacheable(r.p)
cache := l.cacheable(r.p)
switch {
case r.c != nil:
if cache {
l.o.Cache.Objects[r.key] = cachefile.Stamped[[]provider.Object]{At: now, Value: r.objects}
}
l.objects(r.p, *r.c, r.objects)
case r.src != nil:
r.containers = slices.Clone(r.containers) // the provider's may be shared
for i := range r.containers {
r.containers[i].Source = *r.src
}
if cache {
l.o.Cache.Containers[r.key] = cachefile.Stamped[[]provider.Container]{At: now, Value: r.containers}
}
l.containers(r.p, *r.src, r.containers)
default:
if cache {
l.o.Cache.Sources[r.key] = cachefile.Stamped[[]provider.Source]{At: now, Value: r.sources}
}
for _, src := range r.sources {
l.source(r.p, src)
}
}
}
// failed is the entry for a list that couldn't be loaded.
func (r result) failed() Entry {
var e Entry
switch {
case r.c != nil:
e = containerEntry(*r.c)
case r.src != nil:
src := *r.src
src.Provider, src.ProviderLabel = r.p.ID(), r.p.Label()
e = sourceEntry(src)
default:
e = Entry{Kind: "source", Provider: kindOf(r.p.Kind()), Path: []string{r.p.Label()}}
}
e.Error = firstLine(r.err.Error())
return e
}
// firstLine is an error's first line, the summary (Azure's go on with
// the caller's IDs and more): an entry is one line. Tabs, which would
// split it into columns, are spaces.
func firstLine(s string) string {
for line := range strings.Lines(s) {
if line = strings.TrimSpace(line); line != "" {
return strings.ReplaceAll(line, "\t", " ")
}
}
return "unreadable"
}
func kindOf(k provider.Kind) string { return strings.ToLower(string(k)) }
// accountOf is the account part of a source's provider label ("alice" in
// "Azure · alice"), or "" when there's a single account.
func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok {
return ""
}
return account
}
func sorted[T any](s []T, name func(T) string) []T {
s = slices.Clone(s)
slices.SortStableFunc(s, func(a, b T) int {
return cmp.Compare(strings.ToLower(name(a)), strings.ToLower(name(b)))
})
return s
}
-297
View File
@@ -1,297 +0,0 @@
package listing
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// counting counts a provider's requests.
type counting struct {
provider.Provider
calls atomic.Int32
}
func (c *counting) Sources(ctx context.Context) ([]provider.Source, error) {
c.calls.Add(1)
return c.Provider.Sources(ctx)
}
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
c.calls.Add(1)
return c.Provider.Containers(ctx, src)
}
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
c.calls.Add(1)
return c.Provider.Objects(ctx, ct, t)
}
// run lists, returning the entries and, apart, the lists that failed.
func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
t.Helper()
if o.Cache.Version == 0 {
o.Cache = cachefile.New()
}
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
var got, failed []Entry
f, _ := Run(context.Background(), o, func(e Entry) error {
if e.Error != "" {
failed = append(failed, e)
} else {
got = append(got, e)
}
return nil
})
return got, f, failed
}
func has(es []Entry, kind, path string) bool {
for _, e := range es {
if e.Kind == kind && strings.Join(e.Path, "/") == path {
return true
}
}
return false
}
func TestListLoadsAndCaches(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
got, f, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, MaxAge: time.Minute})
for _, want := range [][2]string{
{"source", "platform-prod"}, {"vault", "platform-prod/kv-app-prod"}, {"secret", "platform-prod/kv-app-prod/db-password"},
{"source", "lazysecrets-demo"}, {"secret", "lazysecrets-demo/.env/DATABASE_URL"},
} {
if !has(got, want[0], want[1]) {
t.Errorf("no %s %s", want[0], want[1])
}
}
if len(errs) != 1 || errs[0].Kind != "vault" || errs[0].Path[1] != "kv-restricted" || !strings.Contains(errs[0].Error, "403") || errs[0].Link == "" {
t.Errorf("failed = %+v", errs)
}
seen := map[string]bool{}
for _, e := range got {
if seen[e.Link] {
t.Errorf("%s listed twice", e.Link)
}
seen[e.Link] = true
if e.Provider != "azure" && e.Provider != "repo" {
t.Errorf("provider %q", e.Provider)
}
}
for _, e := range got {
if e.Kind == "secret" && e.Provider == "azure" && (!strings.HasPrefix(e.Reference, "@Microsoft.KeyVault(") || e.URL == "") {
t.Errorf("%s: reference %q, url %q", e.Link, e.Reference, e.URL)
}
}
if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 {
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
}
for k := range f.Containers {
if cachefile.ProviderOf(k) != az.ID() {
t.Errorf("cached a repo: %s", k)
}
}
// Again with that cache: the same, without asking Azure.
before := az.calls.Load()
again, _, _ := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, MaxAge: time.Minute})
if len(again) != len(got) {
t.Errorf("%d entries from the cache, %d loaded", len(again), len(got))
}
// The vault that couldn't be read wasn't cached: it's asked again.
if n := az.calls.Load() - before; n != 1 {
t.Errorf("%d requests with a fresh cache, want 1 (kv-restricted)", n)
}
// Stale: loaded again, but each entry is still listed once.
before = az.calls.Load()
stale, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if az.calls.Load()-before < 3 {
t.Error("stale lists weren't loaded again")
}
links := map[string]bool{}
for _, e := range stale {
if links[e.Link] {
t.Errorf("%s listed twice", e.Link)
}
links[e.Link] = true
}
}
func TestListCachedOnly(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
before := az.calls.Load()
got, _, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, Cached: true,
Now: func() time.Time { return time.Now().Add(24 * time.Hour) }})
if az.calls.Load() != before || len(errs) != 0 {
t.Errorf("--cached asked Azure: %d requests, %v", az.calls.Load()-before, errs)
}
if !has(got, "secret", "platform-prod/kv-app-prod/db-password") || !has(got, "secret", "lazysecrets-demo/.env/DATABASE_URL") {
t.Error("cached secrets or repos missing")
}
}
func TestListCachedFirst(t *testing.T) {
t.Parallel()
az := fake.Azure(0)
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Forget one vault's secrets: they load, after everything cached.
var dropped string
for k := range f.Objects {
if strings.Contains(k, "kv-app-prod") && strings.HasSuffix(k, "|Secret") {
dropped = k
delete(f.Objects, k)
break
}
}
if dropped == "" {
t.Fatalf("no kv-app-prod in %v", f.Objects)
}
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute})
last := got[len(got)-1]
if last.Kind != "secret" || last.Path[1] != "kv-app-prod" {
t.Errorf("last = %+v, want a kv-app-prod secret, loaded after the cache", last)
}
}
func TestListStopsWhenTheReaderGoes(t *testing.T) {
t.Parallel()
gone := errors.New("broken pipe")
n := 0
var errs []Entry
Run(context.Background(), Options{
Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute,
Timeout: func(provider.Kind) time.Duration { return time.Second },
}, func(e Entry) error {
if e.Error != "" {
errs = append(errs, e)
}
n++
if n == 2 {
return gone
}
return nil
})
if n != 2 || len(errs) != 0 {
t.Errorf("emitted %d after the reader went, errs %v", n, errs)
}
}
func TestListKinds(t *testing.T) {
t.Parallel()
// Sources only: nothing under them is asked for.
az := &counting{Provider: fake.Azure(0)}
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"source"}})
if len(got) == 0 || az.calls.Load() != 1 {
t.Errorf("%d sources, %d requests, want just the sources'", len(got), az.calls.Load())
}
for _, e := range got {
if e.Kind != "source" {
t.Errorf("listed a %s", e.Kind)
}
}
// Keys, asked for: loaded, though Go to wouldn't; no secrets.
got, _, failed := run(t, Options{Providers: []provider.Provider{fake.Azure(0)}, MaxAge: time.Minute, Kinds: []string{"key"}})
if !has(got, "key", "platform-prod/kv-app-prod/signing-key") {
t.Errorf("no keys: %+v", got)
}
for _, e := range got {
if e.Kind != "key" {
t.Errorf("listed a %s", e.Kind)
}
}
if len(failed) == 0 {
t.Error("a list that failed wasn't listed")
}
}
func TestErrorsAreOneLine(t *testing.T) {
for in, want := range map[string]string{
"403 Forbidden: Caller is not authorized\nCaller: appid=x;oid=y\nVault: kv;location=z": "403 Forbidden: Caller is not authorized",
"\n timeout\tafter 30s\n": "timeout after 30s",
"": "unreadable",
} {
if got := firstLine(in); got != want {
t.Errorf("firstLine(%q) = %q, want %q", in, got, want)
}
}
}
func TestListMarks(t *testing.T) {
t.Parallel()
az := fake.Azure(0)
var starredLink string
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
Mark: func(link, account string) Marks {
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
starredLink = link
}
if strings.HasSuffix(link, "/api-key") {
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
}
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
}
return Marks{Starred: link == starredLink && account == az.ID()}
}})
var starred, recent int
for _, e := range got {
if e.Starred {
starred++
if e.Link != starredLink {
t.Errorf("%s starred", e.Link)
}
}
if (e.Note != "") != strings.HasSuffix(e.Link, "/api-key") {
t.Errorf("%s: note %q", e.Link, e.Note)
}
if e.Recent {
recent++
if e.Uses != 3 || e.LastUsed.IsZero() {
t.Errorf("%s: %d uses, last %v", e.Link, e.Uses, e.LastUsed)
}
} else if e.Uses != 0 || !e.LastUsed.IsZero() {
t.Errorf("%s not recent, but %d uses, last %v", e.Link, e.Uses, e.LastUsed)
}
}
if starred != 1 || recent == 0 {
t.Errorf("%d starred, %d recent", starred, recent)
}
}
func TestFailedListNotRetried(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Everything stale: sources reload, and their vaults are queued again
// as each list arrives. kv-restricted fails once, and isn't asked again.
before := az.calls.Load()
_, _, failed := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if len(failed) != 1 {
t.Errorf("failed: %+v", failed)
}
fresh := &counting{Provider: fake.Azure(0)}
run(t, Options{Providers: []provider.Provider{fresh}, MaxAge: time.Minute})
if n := az.calls.Load() - before; n > fresh.calls.Load() {
t.Errorf("%d requests with a stale cache, more than %d from scratch: something was asked twice", n, fresh.calls.Load())
}
}
func TestRunWithoutTimeout(t *testing.T) {
t.Parallel()
Run(context.Background(), Options{Providers: []provider.Provider{fake.Repos()}, Cache: cachefile.New()},
func(Entry) error { return nil }) // no Timeout: a default, not a panic
}
+70
View File
@@ -1,10 +1,15 @@
package azure
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -155,3 +160,68 @@ func directoryRoles(wids any) string {
slices.Sort(names)
return strings.Join(names, ", ")
}
// graphGroups lists the signed-in user's groups, nested ones too, from
// Microsoft Graph with az's token for it.
const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999"
var _ provider.AccountGroups = (*Provider)(nil)
// Groups lists the groups the account is a member of, by name.
func (p *Provider) Groups(ctx context.Context) ([]string, error) {
if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") {
return nil, provider.ErrUnsupported
}
tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}})
if err != nil {
return nil, err
}
var names []string
for next := graphGroups; next != ""; {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok.Token)
resp, err := p.http().Do(req)
if err != nil {
return nil, err
}
var page struct {
Value []struct {
Type string `json:"@odata.type"`
Name string `json:"displayName"`
} `json:"value"`
Next string `json:"@odata.nextLink"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
err = json.NewDecoder(resp.Body).Decode(&page)
resp.Body.Close()
switch {
case err != nil:
return nil, fmt.Errorf("listing groups from Microsoft Graph: %w", err)
case page.Error != nil:
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", page.Error.Message)
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", resp.Status)
}
for _, v := range page.Value {
if v.Type == "#microsoft.graph.group" && v.Name != "" {
names = append(names, v.Name)
}
}
next = page.Next
}
slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })
return names, nil
}
// http is the client for Microsoft Graph; tests replace it.
func (p *Provider) http() *http.Client {
if p.graphClient != nil {
return p.graphClient
}
return http.DefaultClient
}
+47
View File
@@ -1,7 +1,17 @@
package azure
import (
"context"
"errors"
"io"
"net/http"
"slices"
"strings"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -65,3 +75,40 @@ func TestSignInMethods(t *testing.T) {
}
}
}
type staticToken struct{}
func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil
}
type graphPages map[string]string // by URL
func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) {
body, ok := g[r.URL.String()]
if !ok || r.Header.Get("Authorization") != "Bearer tok" {
return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil
}
return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
}
func TestGroups(t *testing.T) {
next := "https://graph.microsoft.com/v1.0/next"
p := &Provider{
cred: staticToken{},
account: &accountDetails{account: account{user: "alice@contoso.com"}},
graphClient: &http.Client{Transport: graphPages{
graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"},
{"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`,
next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`,
}},
}
got, err := p.Groups(context.Background())
if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) {
t.Errorf("groups %q, %v", got, err)
}
p.account.cloud = "AzureChinaCloud"
if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) {
t.Errorf("other cloud: %v", err)
}
}
+18 -38
View File
@@ -9,6 +9,7 @@ import (
"crypto/x509"
"errors"
"fmt"
"net/http"
"regexp"
"slices"
"strings"
@@ -17,9 +18,9 @@ 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/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
@@ -39,6 +40,7 @@ type Provider struct {
cred azcore.TokenCredential
opts options
account *accountDetails // nil for a service principal from AZURE_*
graphClient *http.Client // for Groups; nil for the default
subs *armsubscriptions.Client
graph *armresourcegraph.Client
@@ -183,6 +185,10 @@ func (p *Provider) tenant(subscriptionID string) string {
return p.tenants[subscriptionID]
}
const vaultQuery = `resources
| where type =~ 'microsoft.keyvault/vaults'
| project id, name, uri = tostring(properties.vaultUri)`
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
var out []provider.Container
req := armresourcegraph.QueryRequest{
@@ -222,7 +228,7 @@ func graphVault(src provider.Source, row any) (provider.Container, bool) {
if id == "" || name == "" || uri == "" {
return provider.Container{}, false
}
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes, Attrs: graphInfo(m).attrs()}, true
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true
}
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
@@ -243,7 +249,7 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
}
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(),
Detail: *v.Properties.VaultURI, Types: objectTypes,
})
}
}
@@ -299,7 +305,7 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
if s.ID == nil || deref(s.Managed) {
continue
}
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
out = append(out, secretObject(c, s))
}
}
case provider.TypeKey:
@@ -392,7 +398,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
}
for _, s := range page.Value {
if s.ID != nil {
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID)))
out = append(out, secretObject(o.Container, s))
}
}
}
@@ -425,13 +431,8 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
default:
return nil, provider.ErrUnsupported
}
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
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) })
return out, nil
@@ -458,8 +459,11 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
}
switch o.Type {
case provider.TypeSecret:
v, _, err := p.VersionedValue(ctx, o)
return v, err
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
@@ -483,31 +487,7 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
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
}
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
}
var (
_ provider.Locator = (*Provider)(nil)
_ provider.Versioned = (*Provider)(nil)
)
var _ provider.Locator = (*Provider)(nil)
// validHost guards the Resource Graph query against odd input.
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
+2 -75
View File
@@ -19,7 +19,6 @@ import (
"net/http"
"net/http/httptest"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -30,7 +29,6 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -176,11 +174,8 @@ func TestForbiddenIsShortened(t *testing.T) {
if err == nil {
t.Fatal("expected error")
}
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\n\nCheck:\n• your role or group is activated in PIM\n• "; !strings.HasPrefix(got, want) {
t.Errorf("error = %q, want %q…", got, want)
}
if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") {
t.Errorf("firewall checks = %q", checks)
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want {
t.Errorf("error = %q, want %q", got, want)
}
var re *azcore.ResponseError
if !errors.As(err, &re) || re.StatusCode != 403 {
@@ -188,13 +183,6 @@ func TestForbiddenIsShortened(t *testing.T) {
}
}
func TestShortenPolicyDenied(t *testing.T) {
msg := "The user, group or application 'appid=04b07795;oid=1234;numgroups=83;iss=https://sts.windows.net/t/' does not have secrets list permission on key vault 'kv-app;location=norwayeast'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287"
if got := shortenDenied(msg); got != "no secrets list permission in the vault's access policies" {
t.Errorf("shortenDenied = %q", got)
}
}
func TestShortenDenied(t *testing.T) {
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
@@ -335,21 +323,6 @@ func TestPortalURLs(t *testing.T) {
}
}
func TestSecretPages(t *testing.T) {
c := provider.Container{Detail: "https://kv-app.vault.azure.net/"}
base := "https://portal.azure.com/#@contoso.onmicrosoft.com/asset/Microsoft_Azure_KeyVault/Secret/https://kv-app.vault.azure.net/secrets/db"
for id, want := range map[string]string{
"https://kv-app.vault.azure.net/secrets/db": base, // listed: the secret's page
"https://kv-app.vault.azure.net/secrets/db/06140fa2b9a64869a96ef040f6d4dcab": base + "/06140fa2b9a64869a96ef040f6d4dcab", // a version's
} {
sid := azsecrets.ID(id)
o := secretObject(c, &azsecrets.SecretProperties{ID: &sid}, "contoso.onmicrosoft.com")
if o.URL != want {
t.Errorf("%s: %q, want %q", id, o.URL, want)
}
}
}
func TestVersionedReference(t *testing.T) {
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
@@ -444,49 +417,3 @@ func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
size := (len(b) - 1) / 2
return b[1 : 1+size], b[1+size:]
}
func TestVaultSettings(t *testing.T) {
want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)",
"soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)",
"private=1 endpoint", "tags=env: prod", "=team: platform"}
check := func(name string, attrs []provider.Attr) {
t.Helper()
var got []string
for _, a := range attrs {
got = append(got, a.Label+"="+a.Value)
}
if !slices.Equal(got, want) {
t.Errorf("%s\n got %q\nwant %q", name, got, want)
}
}
// Resource Graph: the query's columns, as JSON gives them.
c, ok := graphVault(provider.Source{ID: "s"}, map[string]any{
"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/", "location": "norwayeast",
"resourceGroup": "rg-sec", "tags": map[string]any{"team": "platform", "env": "prod"},
"sku": "premium", "rbac": true, "softDelete": nil, "retention": 30.0, "purge": true,
"publicAccess": "", "defaultAction": "Deny", "ipRules": 1.0, "subnets": 0.0, "endpoints": 1.0, "policies": 0.0,
})
if !ok {
t.Fatal("row not read")
}
check("resource graph", c.Attrs)
// The management API, when Resource Graph fails.
id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv"
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": id, "name": "kv", "location": "norwayeast",
"tags": map[string]any{"team": "platform", "env": "prod"},
"properties": map[string]any{
"tenantId": "t", "vaultUri": "https://kv.vault.azure.net/", "sku": map[string]any{"family": "A", "name": "premium"},
"enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30,
"networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}},
"privateEndpointConnections": []any{map[string]any{"id": "pe1"}},
}}}})
})
vaults, err := p.armVaults(context.Background(), provider.Source{ID: "s"})
if err != nil || len(vaults) != 1 {
t.Fatalf("arm: %v %v", vaults, err)
}
check("management API", vaults[0].Attrs)
}
+5 -36
View File
@@ -11,7 +11,6 @@ import (
"fmt"
"math/big"
"net/http"
"regexp"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
@@ -53,8 +52,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
}
// portalAssetURL links to a Key Vault object's page: a version's with
// one in objectID; a secret's own (its versions) without.
// portalAssetURL links to a Key Vault object's page; objectID must include
// the version.
func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
}
@@ -88,10 +87,7 @@ 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
// is its page in the portal: the secret's (with its versions), or with
// a version, that version's.
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, "secrets", name)
o := provider.Object{
@@ -103,7 +99,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, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
URL: ObjectListURL(c, provider.TypeSecret),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
@@ -283,24 +279,13 @@ func cleanError(err error) error {
}
}
}
se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
if se.status == http.StatusForbidden {
return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)}
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
}
return se
}
var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`)
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
// sentences plus caller and resource IDs, to its first sentence and the
// missing action.
func shortenDenied(msg string) string {
// Access policies: "The user, group or application 'appid=…;oid=…'
// does not have secrets list permission on key vault 'kv;location=…'".
if m := policyDenied.FindStringSubmatch(msg); m != nil {
return "no " + m[1] + " permission in the vault's access policies"
}
const marker = "Action: '"
i := strings.Index(msg, marker)
if i < 0 {
@@ -332,20 +317,4 @@ func (e *serviceError) Error() string {
return s
}
// deniedChecks are what to check when Key Vault refuses: the network,
// for its firewall's refusals, else access.
func deniedChecks(msg string) []string {
if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") ||
strings.Contains(msg, "private link") {
return []string{
"you're on a network the vault lets in (VPN, office)",
"the vault's owner has let your network in",
}
}
return []string{
"your role or group is activated in PIM",
"the vault's owner has given you access: a Key Vault role, or its access policies",
}
}
func (e *serviceError) Unwrap() error { return e.err }
-158
View File
@@ -1,158 +0,0 @@
package azure
import (
"fmt"
"slices"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// A vault's settings come with the listing, from Resource Graph or the
// Key Vault management API, with no requests of their own: where it is,
// how access to it works, and how it's protected.
type vaultInfo struct {
group, location, sku string
rbac, softDelete, purge *bool
retention *int32
publicAccess, defaultAction string
ipRules, subnets, endpoints int
policies int
tags map[string]string
}
// vaultQuery lists vaults with just the settings shown, not their whole
// properties (access policies can be long).
const vaultQuery = `resources
| where type =~ 'microsoft.keyvault/vaults'
| project id, name, uri = tostring(properties.vaultUri), location, resourceGroup, tags,
sku = tostring(properties.sku.name), rbac = properties.enableRbacAuthorization,
softDelete = properties.enableSoftDelete, retention = properties.softDeleteRetentionInDays,
purge = properties.enablePurgeProtection, publicAccess = tostring(properties.publicNetworkAccess),
defaultAction = tostring(properties.networkAcls.defaultAction),
ipRules = array_length(properties.networkAcls.ipRules),
subnets = array_length(properties.networkAcls.virtualNetworkRules),
endpoints = array_length(properties.privateEndpointConnections),
policies = array_length(properties.accessPolicies)`
// graphInfo reads a vaultQuery row's settings.
func graphInfo(m map[string]any) vaultInfo {
str := func(k string) string { s, _ := m[k].(string); return s }
num := func(k string) int { f, _ := m[k].(float64); return int(f) }
flag := func(k string) *bool {
if b, ok := m[k].(bool); ok {
return &b
}
return nil
}
v := vaultInfo{
group: str("resourceGroup"), location: str("location"), sku: str("sku"),
rbac: flag("rbac"), softDelete: flag("softDelete"), purge: flag("purge"),
publicAccess: str("publicAccess"), defaultAction: str("defaultAction"),
ipRules: num("ipRules"), subnets: num("subnets"), endpoints: num("endpoints"), policies: num("policies"),
}
if f, ok := m["retention"].(float64); ok {
r := int32(f)
v.retention = &r
}
if tags, ok := m["tags"].(map[string]any); ok {
v.tags = map[string]string{}
for k, t := range tags {
v.tags[k] = fmt.Sprint(t)
}
}
return v
}
// armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo {
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
}
if pr.SKU != nil && pr.SKU.Name != nil {
info.sku = string(*pr.SKU.Name)
}
info.rbac, info.softDelete, info.purge = pr.EnableRbacAuthorization, pr.EnableSoftDelete, pr.EnablePurgeProtection
info.retention, info.publicAccess = pr.SoftDeleteRetentionInDays, deref(pr.PublicNetworkAccess)
if acl := pr.NetworkACLs; acl != nil {
if acl.DefaultAction != nil {
info.defaultAction = string(*acl.DefaultAction)
}
info.ipRules, info.subnets = len(acl.IPRules), len(acl.VirtualNetworkRules)
}
info.endpoints, info.policies = len(pr.PrivateEndpointConnections), len(pr.AccessPolicies)
return info
}
func (v vaultInfo) attrs() []provider.Attr {
var out []provider.Attr
add := func(label, value string) {
if value != "" {
out = append(out, provider.Attr{Label: label, Value: value})
}
}
add("group", v.group)
add("location", v.location)
add("SKU", v.sku)
// Who may read secrets is decided by Azure roles or by the vault's
// own access policies, never both.
if on(v.rbac, false) {
add("access", "Azure roles (RBAC)")
} else {
add("access", fmt.Sprintf("access policies (%d)", v.policies))
}
if on(v.softDelete, true) {
days := int32(90)
if v.retention != nil {
days = *v.retention
}
add("soft delete", fmt.Sprintf("on, %d days", days))
} else {
add("soft delete", "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")
case strings.EqualFold(v.defaultAction, "deny"):
add("network", fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", v.ipRules, v.subnets))
default:
add("network", "all networks")
}
if v.endpoints > 0 {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
}
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"
}
out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]})
}
return out
}
// on is *b, or def when it isn't set.
func on(b *bool, def bool) bool {
if b == nil {
return def
}
return *b
}
+3 -43
View File
@@ -317,52 +317,15 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return v, nil
}
// deniedChecks are what to check when Databricks refuses: the network,
// when the workspace's IP access list refused, else access, and for a
// profile signed in through az, which account az is on.
func deniedChecks(pr profile, msg string) []string {
if strings.Contains(msg, "IP ACL") || strings.Contains(msg, "IP access list") {
return []string{
"you're on a network the workspace lets in (VPN, office)",
"the workspace's admin has let your network in",
}
}
var checks []string
if pr.authType == "azure-cli" {
checks = append(checks, "az is signed in as the account with access: profile "+pr.name+" uses your az login (az account show)")
}
return append(checks,
"you have access to the workspace",
"the scope's manager has given you READ on it, or a group you're in",
)
}
var errNoFallback = errors.New("no Key Vault fallback")
// combine reports both the Databricks and the Key Vault failure, with
// both their checks after. An auth error from either stays reachable
// with errors.As.
// combine reports both the Databricks and the Key Vault failure. An auth
// error from either stays reachable with errors.As.
func combine(dbxErr, kvErr error) error {
if errors.Is(kvErr, errNoFallback) {
return dbxErr
}
var checks []string
strip := func(err error) error {
if d, ok := err.(*provider.Denied); ok {
for _, c := range d.Checks {
if !slices.Contains(checks, c) {
checks = append(checks, c)
}
}
return d.Err
}
return err
}
err := fmt.Errorf("%w; Key Vault fallback: %w", strip(dbxErr), strip(kvErr))
if len(checks) > 0 {
return &provider.Denied{Err: err, Checks: checks}
}
return err
return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr)
}
// classify turns authentication failures into *provider.AuthError: a 401
@@ -373,9 +336,6 @@ func classify(pr profile, err error) error {
}
var api *apierr.APIError
if errors.As(err, &api) {
if api.StatusCode == 403 {
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
}
if api.StatusCode != 401 {
return err
}
@@ -140,8 +140,6 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server {
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
map[string]any{"key": "client-secret"},
}})
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked":
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"})
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
write(w, 200, map[string]any{"key": q.Get("key"),
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
@@ -293,27 +291,3 @@ func TestClassify(t *testing.T) {
t.Error("cancellation classified as auth")
}
}
func TestDeniedSaysWhatToCheck(t *testing.T) {
p := newTestProvider(t, workspaceAPI(t, 0), nil)
_, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret)
var d *provider.Denied
// The test profile signs in through az: that's the first check.
if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• az is signed in as the account with access: profile prod uses your az login") ||
!strings.Contains(err.Error(), "\n• you have access to the workspace") {
t.Errorf("err %q", err)
}
if got := deniedChecks(profile{authType: "pat"}, "denied"); strings.Contains(got[0], "az") {
t.Errorf("a token profile's checks %q", got)
}
if got := deniedChecks(profile{}, "Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") {
t.Errorf("IP ACL checks %q", got)
}
// Both refused: one message, then both lists of checks.
err = combine(&provider.Denied{Err: errors.New("dbx"), Checks: []string{"a", "b"}},
&provider.Denied{Err: errors.New("kv"), Checks: []string{"b", "c"}})
if got := err.Error(); got != "dbx; Key Vault fallback: kv\n\nCheck:\n• a\n• b\n• c" {
t.Errorf("combined %q", got)
}
}
+6 -49
View File
@@ -24,7 +24,6 @@ type Provider struct {
errs map[listKey]error
values map[string]string // by object ID
versions map[string][]provider.Object // by object ID
details map[string][]provider.Attr // by container ID
}
type listKey struct {
@@ -102,7 +101,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err := p.wait(ctx); err != nil {
return nil, err
}
if p.kind != provider.KindAzure { // only Key Vault keeps versions
if p.kind == provider.KindDatabricks {
return nil, provider.ErrUnsupported
}
vs := p.versions[o.ID]
@@ -134,14 +133,13 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.NewSecretValue([]byte(v)), nil
}
// Locate finds the source holding the vault (by host), the workspace or
// the repo (by its remote).
// Locate finds the source holding the vault (by host) or the workspace.
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
if err := p.wait(ctx); err != nil {
return "", false, err
}
for _, src := range p.sources {
if (p.kind == provider.KindDatabricks || p.kind == provider.KindRepo) && strings.EqualFold(src.Detail, host) {
if p.kind == provider.KindDatabricks && src.Detail == host {
return src.ID, true, nil
}
for _, c := range p.containers[src.ID] {
@@ -211,26 +209,13 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
if p.kind == provider.KindDatabricks {
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
}
if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) {
if p.kind == provider.KindAzure || vault != detail {
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
}
p.containers[src.ID] = append(p.containers[src.ID], c)
return c
}
// Expire sets when the object name in vault expires, for --demo: a date
// relative to today in the test data would change the tests' screenshots
// from day to day.
func (p *Provider) Expire(vault, name string, at time.Time) {
for _, objs := range p.objects {
for i := range objs {
if objs[i].Container.Name == vault && objs[i].Name == name {
objs[i].Expires = at
}
}
}
}
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
o.Container = c
o.Type = t
@@ -300,13 +285,9 @@ func Azure(delay time.Duration) *Provider {
Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{
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"),
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: &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: &yes, Updated: base.AddDate(0, -3, 0),
@@ -349,10 +330,6 @@ func Azure(delay time.Duration) *Provider {
}, demoCert)
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: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
@@ -366,19 +343,6 @@ func Azure(delay time.Duration) *Provider {
p.add(kvDev, provider.TypeSecret, provider.Object{
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: &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: &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: &no, Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled")
return p
}
@@ -396,7 +360,6 @@ func AzureOps(delay time.Duration) *Provider {
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{
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...)
@@ -415,12 +378,6 @@ func AzureOps(delay time.Duration) *Provider {
p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "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: &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")
return p
}
-151
View File
@@ -1,151 +0,0 @@
package fake
import (
"context"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
var _ provider.ContainerDetails = (*Provider)(nil)
// Details returns a file's details, like the repo provider's.
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
attrs, ok := p.details[c.ID]
if !ok {
return nil, provider.ErrUnsupported
}
return attrs, ctx.Err()
}
// 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"
p.details = map[string][]provider.Attr{}
repo := func(name string, here bool) provider.Source {
s := p.addSource("/home/dev/git/"+name, name, "github.com/acme/"+name)
s.Here = here
p.sources[len(p.sources)-1] = s
return s
}
// file adds a file and its keys, alternating name and value. git and
// commit are what git says of it: "tracked", "ignored" or "untracked".
file := func(src provider.Source, path, method, git, commit string, kv ...string) {
sops := method != ""
detail := provider.DetailPlainEnv
if sops {
detail = "sops · " + method
}
c := p.addContainer(src, path, detail, provider.TypeSecret)
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
if sops {
attrs = []provider.Attr{{Label: "format", Value: "sops " + format(path)}, {Label: "encryption", Value: method}}
}
for i := 0; i+1 < len(kv); i += 2 {
o := provider.Object{Name: kv[i], Attrs: attrs}
if sops {
o.Updated = base
}
p.add(c, provider.TypeSecret, o, kv[i+1])
}
d := []provider.Attr{{Label: "encryption", Value: "none: plain text"}}
if sops {
d = []provider.Attr{{Label: "encryption", Value: detail}}
keys := map[string]string{
"age": "age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p",
"azure_kv": "azure_kv https://kv-sops-prod.vault.azure.net/keys/sops/1f2e3d4c",
}
for i, m := range strings.Split(method, ", ") {
label := ""
if i == 0 {
label = "keys" // on the first only, like the repo provider
}
d = append(d, provider.Attr{Label: label, Value: keys[m]})
}
d = append(d, provider.Attr{Label: "encrypted", Value: base.Local().Format("2006-01-02 15:04")})
}
switch {
case git == "tracked" && !sops:
d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
case git == "untracked":
d = append(d, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !sops})
default:
d = append(d, provider.Attr{Label: "git", Value: git})
}
if commit != "" {
d = append(d, provider.Attr{Label: "commit", Value: commit})
}
p.details[c.ID] = d
}
api := repo("lazysecrets-demo", true)
file(api, "deploy/prod/secrets.yaml", "age, azure_kv", "tracked", "4f9c2ab · 3 days ago",
"database.password", "pr0d-Pg-7f3k9QwZ",
"database.url", "postgres://app:pr0d-Pg-7f3k9QwZ@pg-demo-prod:5432/app",
"stripe.secret_key", "sk_live_demo_51NxDemoKey0000",
"jwt.signing_key", "c2lnbmluZy1rZXktZm9yLXRoZS1kZW1v",
"gcp.service_account", `{"type":"service_account","project_id":"lazysecrets-demo","client_email":"app@lazysecrets-demo.iam.gserviceaccount.com","private_key_id":"demo1234"}`)
file(api, "deploy/staging/secrets.yaml", "age", "tracked", "a71e05d · 2 weeks ago",
"database.password", "stg-Pg-2b8d1L",
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago",
"github.client_secret", "8f3c2a9d7e1b6f4a0c5d2e9b8a7f6c3d1e0b4a5f",
"google.client_secret", "GOCSPX-demo-000000000000")
file(api, ".env", "", "ignored", "",
"DATABASE_URL", "postgres://app:local@localhost:5432/app",
"STRIPE_SECRET_KEY", "sk_test_demo_local",
"LOG_LEVEL", "debug")
infra := repo("platform-infra", false)
file(infra, "terraform/prod/secrets.enc.yaml", "azure_kv", "tracked", "9d40c3e · yesterday",
"postgres.admin_password", "infra-Pg-admin-0Xq",
"grafana.admin_password", "grafana-demo-admin",
"acr.pull_token", "acr-demo-pull-token")
file(infra, "k8s/monitoring/alertmanager.enc.yaml", "age", "tracked", "1c58e7a · 5 days ago",
"slack.webhook_url", "https://hooks.slack.com/services/T000/B000/demo",
"pagerduty.routing_key", "pd-demo-routing-key")
file(infra, "scripts/.env", "", "tracked", "77b2d90 · 8 months ago",
"AZURE_CLIENT_SECRET", "azure-demo-client-secret",
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000")
web := repo("web-frontend", false)
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
"SENTRY_AUTH_TOKEN", "sntrys_demo_token",
"NEXT_PUBLIC_API_URL", "https://api.acme.example")
file(web, ".env.local", "", "untracked", "",
"NEXT_PUBLIC_API_URL", "http://localhost:8080",
"ANALYTICS_KEY", "demo-analytics-key")
return p
}
// format is a SOPS file's format, by its name.
func format(path string) string {
switch {
case strings.HasSuffix(path, ".json"):
return "json"
case strings.HasSuffix(path, ".yaml"):
return "yaml"
}
return "dotenv"
}
var _ provider.Contents = (*Provider)(nil)
// Contents is a plain env file as KEY=value lines, from its keys, so the
// demo repos' env files can be shared.
func (p *Provider) Contents(ctx context.Context, c provider.Container) ([]byte, error) {
if c.Detail != provider.DetailPlainEnv {
return nil, provider.ErrUnsupported
}
var b strings.Builder
for _, o := range p.objects[listKey{c.ID, provider.TypeSecret}] {
b.WriteString(o.Name + "=" + p.values[o.ID] + "\n")
}
return []byte(b.String()), ctx.Err()
}
-65
View File
@@ -1,65 +0,0 @@
package fake
import (
"cmp"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// Seed stars, notes and uses a few of the demo's secrets, so --demo
// shows Favourites (F), Recent (R), notes, and stars in lazysecrets list. alice and
// admin are the two Azure accounts' provider IDs.
func Seed(st *store.Store, alice, admin string) {
type item struct {
link, account, source string
name string // else the link's last part
note string
path []string
star bool
uses int
ago time.Duration
}
const azure, dbx = "lazysecrets://azure/", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/"
for _, it := range []item{
{link: azure + "kv-app-prod.vault.azure.net/secrets/db-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, star: true, uses: 12, ago: 20 * time.Minute,
note: "rotated by the data team each quarter"},
{link: azure + "kv-app-prod.vault.azure.net/secrets/api-key", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, uses: 4, ago: 2 * time.Hour},
{link: azure + "kv-breakglass.vault.azure.net/secrets/emergency-admin", account: admin, source: "sub-prod",
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"},
{link: azure + "kv-shared-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-shared-prod"}, uses: 2, ago: 26 * time.Hour},
{link: azure + "kv-app-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, note: "migrated to Microsoft Graph sendMail; delete after Q4"},
{link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
// A subscription, for both accounts, and a repo's file.
{link: azure + "subscriptions/sub-prod", account: alice, source: "sub-prod",
name: "platform-prod", star: true},
{link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
name: "platform-prod", star: true},
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
source: "/home/dev/git/lazysecrets-demo", name: "deploy/prod/secrets.yaml",
path: []string{"lazysecrets-demo"}, star: true},
} {
context := func(n *store.Node) {
n.Name = cmp.Or(it.name, it.link[strings.LastIndex(it.link, "/")+1:])
n.Path, n.Source, n.Account = it.path, it.source, it.account
}
if it.star || it.note != "" {
_ = st.Update(it.link, func(n *store.Node) {
context(n)
if it.star {
n.ToggleStar(it.account)
}
n.Note = it.note
})
}
for range it.uses {
_ = st.Use(it.link, time.Now().Add(-it.ago), context)
}
}
}
+5 -43
View File
@@ -12,7 +12,6 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
)
@@ -94,8 +93,6 @@ type Container struct {
// secrets only.
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"`
}
// ObjectTypes returns the container's object types, defaulting to secrets.
@@ -110,7 +107,6 @@ func (c Container) ObjectTypes() []ObjectType {
type Attr struct {
Label string
Value string
Warn bool // worth a look (a plain .env tracked by git): shown in red
}
// Object describes a secret, key or certificate without its value.
@@ -167,13 +163,6 @@ type Current interface {
Current(ctx context.Context) (Source, bool)
}
// Versioned is implemented by providers whose secrets have versions: it
// is Value, also returning the version read (the current one when
// o.Version is empty), so a link can pin it.
type Versioned interface {
VersionedValue(ctx context.Context, o Object) (SecretValue, string, error)
}
// Locator is implemented by providers that can find where a container lives,
// to open links. address is a vault host (kv.vault.azure.net) or a
// workspace host. It returns the ID of the source holding it.
@@ -189,12 +178,6 @@ type Files interface {
File(c Container, o *Object) (File, error)
}
// Contents is implemented by providers whose containers are files, so
// they can be shared (package share): the file as it is.
type Contents interface {
Contents(ctx context.Context, c Container) ([]byte, error)
}
// File is a container's file, to open.
type File struct {
Path string
@@ -216,30 +199,9 @@ type Account struct {
Copy, CopyLabel string
}
// Denied is a refusal with what to check, from what the service said: a
// role to activate in PIM, access to ask for, the network. Error is the
// refusal, then the checks as a list.
type Denied struct {
Err error
Checks []string
}
func (d *Denied) Error() string {
if len(d.Checks) == 0 {
return d.Err.Error()
}
return d.Err.Error() + "\n\nCheck:\n• " + strings.Join(d.Checks, "\n• ")
}
func (d *Denied) Unwrap() error { return d.Err }
// DetailPlainEnv is the Detail of a container that is a plain (not
// encrypted) env file in a repo.
const DetailPlainEnv = "env · plain text"
// ContainerDetails is implemented by providers that can say more about a
// container than its listing does: how a file is encrypted and what git
// knows of it. ErrUnsupported means there's nothing more for c.
type ContainerDetails interface {
Details(ctx context.Context, c Container) ([]Attr, error)
// AccountGroups is implemented by providers that can list the groups
// their account is a member of, directly or through other groups. It
// goes over the network.
type AccountGroups interface {
Groups(ctx context.Context) ([]string, error)
}
-62
View File
@@ -1,62 +0,0 @@
package repo
import (
"context"
"os/exec"
"path/filepath"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Details are how a file is encrypted and for whom, and what git knows of
// it: a plain .env file that git tracks, or could, is marked.
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
r, ok := p.repo(c.Source.ID)
if !ok {
return nil, provider.ErrUnsupported
}
rel, err := filepath.Rel(r.root, c.ID)
if err != nil {
return nil, err
}
var out []provider.Attr
meta, isSops := readMeta(c.ID)
if isSops {
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
for i, k := range meta.recipients {
label := ""
if i == 0 {
label = "keys"
}
out = append(out, provider.Attr{Label: label, Value: k})
}
if !meta.modified.IsZero() {
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
}
} else {
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
}
git := func(args ...string) (string, bool) {
b, err := exec.CommandContext(ctx, "git", append([]string{"-C", r.root}, args...)...).Output()
return strings.TrimSpace(string(b)), err == nil
}
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
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"})
default:
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: !isSops})
}
return out, nil
}
if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" {
out = append(out, provider.Attr{Label: "last commit", Value: last})
}
return out, nil
}
+24 -171
View File
@@ -80,10 +80,6 @@ type repoInfo struct {
here bool // the repo lazysecrets started in
}
// DefaultRoots are the usual folders of repos, used when the config
// doesn't set roots. Ones that don't exist cost a stat each.
var DefaultRoots = []string{"~/git", "~/repo", "~/repos", "~/projects", "~/code", "~/source", "~/src"}
func New(s Settings) *Provider {
if s.Sops == "" {
s.Sops = "sops"
@@ -120,18 +116,10 @@ func (p *Provider) load() {
}
add(root)
}
// Missing roots are skipped (most of the defaults are), and one
// that's a symlink to another is scanned once.
scanned := map[string]bool{}
for _, dir := range p.s.Roots {
base, err := filepath.EvalSymlinks(expandHome(dir))
if err != nil || scanned[base] {
continue
}
scanned[base] = true
for _, root := range reposIn(base, 2) {
// A repo under two roots keeps the first one's name.
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") && names[root] == "" {
base, _ := filepath.EvalSymlinks(expandHome(dir))
for _, root := range reposIn(expandHome(dir), 2) {
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
names[root] = filepath.ToSlash(name)
}
add(root)
@@ -323,8 +311,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 IsEnv(f):
c.Detail = provider.DetailPlainEnv
case isEnv(f):
c.Detail = "env · plain text"
default:
return
}
@@ -403,11 +391,10 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
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 = o.Container.ID
file, extract = o.Container.ID, extractFor(o.Name)
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
extract = extractFor(file, o.Name)
}
if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name)
@@ -425,15 +412,6 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.NewSecretValue(stdout.Bytes()), nil
}
// Contents reads the file c is.
func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, error) {
f, err := p.File(c, nil)
if err != nil {
return nil, err
}
return os.ReadFile(f.Path)
}
// 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 := c.ID
@@ -486,16 +464,6 @@ func (p *Provider) Locate(_ context.Context, host string) (string, bool, error)
return "", false, nil
}
// Root is the root of the git repo dir is in, and the repo's ID: its
// remote without scheme or .git (a repo link's host), else the root.
func Root(dir string) (root, id string, err error) {
root, err = gitRoot(dir)
if err != nil {
return "", "", fmt.Errorf("%s isn't in a git repository", dir)
}
return root, info(root, "").id, nil
}
// --- git ---
func gitRoot(dir string) (string, error) {
@@ -600,27 +568,6 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile 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"`
}
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) {
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return f.Sharing.Key, nil
}
// selector says which files to look at: user (the include/exclude of the
@@ -671,7 +618,7 @@ func envFiles(root string, tracked []string) []string {
dirs := map[string]bool{".": true}
for _, f := range tracked {
isTracked[f] = true
if IsEnv(f) {
if isEnv(f) {
out = append(out, f)
}
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
@@ -685,7 +632,7 @@ func envFiles(root string, tracked []string) []string {
}
for _, e := range entries {
f := path.Join(d, e.Name())
if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) {
if e.Type().IsRegular() && !isTracked[f] && isEnv(f) {
out = append(out, f)
}
}
@@ -716,10 +663,10 @@ func inPackages(f string) bool {
return false
}
// IsEnv reports whether a file is named like an env file with real values:
// isEnv reports whether a file is named like an env file with real values:
// .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies.
func IsEnv(f string) bool {
func isEnv(f string) bool {
if inPackages(f) {
return false
}
@@ -869,31 +816,9 @@ func anyMatch(res []*regexp.Regexp, f string) bool {
type sopsMeta struct {
methods []string // age, azure_kv, …
recipients []string // "age age1…", "azure_kv https://kv…/keys/sops"
modified time.Time
}
// recipient names a key a file is encrypted for, from its sops metadata:
// public, never a secret.
func recipient(method string, f map[string]string) string {
var v string
switch method {
case "age":
v = f["recipient"]
case "pgp":
v = f["fp"]
case "kms":
v = f["arn"]
case "gcp_kms":
v = f["resource_id"]
case "azure_kv":
v = strings.TrimSuffix(f["vault_url"], "/") + "/keys/" + f["name"]
case "hc_vault":
v = strings.TrimSuffix(f["vault_address"], "/") + " " + f["engine_path"] + "/" + f["key_name"]
}
return method + " " + v
}
const maxFileSize = 4 << 20
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
@@ -923,15 +848,6 @@ func readMeta(file string) (sopsMeta, bool) {
for _, k := range known {
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
m.methods = append(m.methods, k)
for _, e := range v {
fields := map[string]string{}
if em, ok := e.(map[string]any); ok {
for fk, fv := range em {
fields[fk] = fmt.Sprint(fv)
}
}
m.recipients = append(m.recipients, recipient(k, fields))
}
}
}
if s, ok := doc.Sops["lastmodified"].(string); ok {
@@ -948,7 +864,6 @@ func readMeta(file string) (sopsMeta, bool) {
for _, k := range known {
if strings.Contains(text, "sops_"+k+"__") {
m.methods = append(m.methods, k)
m.recipients = append(m.recipients, envRecipients(text, k)...)
}
}
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
@@ -966,35 +881,6 @@ func readMeta(file string) (sopsMeta, bool) {
return m, true
}
// envRecipients are method's keys in a dotenv or ini sops file, whose
// metadata is flattened: sops_age__list_0__map_recipient=age1….
func envRecipients(text, method string) []string {
prefix := "sops_" + method + "__list_"
var order []string
keys := map[string]map[string]string{}
for line := range strings.Lines(text) {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
rest, found := strings.CutPrefix(strings.TrimSpace(k), prefix)
if !ok || !found {
continue
}
n, field, ok := strings.Cut(rest, "__map_")
if !ok {
continue
}
if keys[n] == nil {
keys[n] = map[string]string{}
order = append(order, n)
}
keys[n][field] = strings.TrimSpace(v)
}
out := make([]string, len(order))
for i, n := range order {
out[i] = recipient(method, keys[n])
}
return out
}
func formatOf(file string) string {
switch strings.ToLower(filepath.Ext(file)) {
case ".yaml", ".yml":
@@ -1013,7 +899,7 @@ func formatOf(file string) string {
}
type key struct {
name string // db.password, list[0], with . [ ] \ in keys escaped
name string // db.password, list[0]
extract string // sops --extract: ["db"]["password"]
encrypted bool
line int // 1-based
@@ -1058,7 +944,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
}
}
return out, nil
@@ -1075,9 +961,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
if top && k == "sops" {
continue
}
child := escape(k)
child := k
if name != "" {
child = name + "." + child
child = name + "." + k
}
walk(n.Content[i+1], child, extract+quote(k), false, out)
}
@@ -1099,56 +985,23 @@ func quote(k string) string {
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
}
// escape keeps a key's dots and brackets from reading as nesting in a
// name: app\.name is one key, app.name two.
func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// 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)
// from a link (db.password, list[0]). Names with dots in keys can't be
// told apart from nesting: those come from the listing instead.
func extractFor(name string) string {
var b strings.Builder
for _, part := range strings.Split(name, ".") {
base, idx, _ := strings.Cut(part, "[")
if base != "" {
b.WriteString(quote(base))
}
var b, part strings.Builder
flush := func() {
if part.Len() > 0 {
b.WriteString(quote(part.String()))
part.Reset()
if idx != "" {
b.WriteString("[" + idx)
}
}
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 {
if a, err := filepath.Abs(p); err == nil {
return a
-89
View File
@@ -336,30 +336,6 @@ func TestRoots(t *testing.T) {
}
}
func TestRootsDeduped(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir())
os.Rename(gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}), filepath.Join(root, "infra"))
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
os.Rename(gitRepo(t, map[string]string{"prod.env": sopsEnv}), filepath.Join(root, "acme", "billing"))
// ~/src → ~/git, and a root reaching one repo at another depth.
links := t.TempDir()
alias := filepath.Join(links, "src")
os.Symlink(root, alias)
missing := filepath.Join(links, "nope")
acme := filepath.Join(root, "acme")
srcs, err := New(Settings{Dir: links, Roots: []string{missing, root, alias, acme}}).Sources(context.Background())
if err != nil {
t.Fatal(err)
}
got := names(srcs, func(s provider.Source) string { return s.Name })
slices.Sort(got)
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
t.Errorf("repos: %v, want each once, named from the first root", got)
}
}
func TestStarredReposAlongside(t *testing.T) {
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
@@ -501,68 +477,3 @@ func TestFile(t *testing.T) {
t.Errorf("lines %v\nwant %v", lines, want)
}
}
func TestFileDetails(t *testing.T) {
root := gitRepo(t, map[string]string{
".gitignore": ".env\n",
"secrets/app.yaml": sopsYAML,
"prod.env": sopsEnv,
"tracked.env": "TOKEN=x\n", // a plain .env in git: worth a look
})
os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=x\n"), 0o600)
os.WriteFile(filepath.Join(root, "local.env"), []byte("TOKEN=x\n"), 0o600)
p := New(Settings{Dir: root})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
details := func(name string) map[string]provider.Attr {
t.Helper()
attrs, err := p.Details(ctx, provider.Container{Source: srcs[0], ID: filepath.Join(root, name), Name: name})
if err != nil {
t.Fatal(err)
}
out := map[string]provider.Attr{}
for _, a := range attrs {
out[a.Label] = a
}
return out
}
d := details("secrets/app.yaml")
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
!strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" {
t.Errorf("sops yaml: %+v", d)
}
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
t.Errorf("sops env: %+v", d)
}
if d := details("tracked.env"); d["encryption"].Value != "none: plain text" || !d["git"].Warn {
t.Errorf("tracked plain env: %+v", d)
}
if d := details(".env"); d["git"].Value != "ignored" || d["git"].Warn {
t.Errorf("ignored env: %+v", d)
}
if d := details("local.env"); !d["git"].Warn || !strings.Contains(d["git"].Value, "not ignored") {
t.Errorf("untracked env: %+v", d)
}
}
func TestKeyNamesEscaped(t *testing.T) {
keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n"))
if err != nil {
t.Fatal(err)
}
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)
}
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
-103
View File
@@ -1,103 +0,0 @@
package ref
import (
"cmp"
"context"
"errors"
"fmt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Read reads what r points at without listing its vault: the vault and
// name are enough for Azure, tried with each account (account, the one
// that last read it, first, in source); for Databricks and repos the
// profile or repo is found by its host. It also returns the version read,
// when the provider tells (provider.Versioned).
func Read(ctx context.Context, providers []provider.Provider, r Ref, account, source string) (provider.SecretValue, string, error) {
if r.IsShare() || r.IsSource() || r.IsContainer() {
what := "vault or scope"
switch {
case r.IsShare():
what = "share link"
case r.IsSource():
what = "subscription or workspace"
}
return provider.SecretValue{}, "", fmt.Errorf("%s points at a %s, not a secret", r, what)
}
if r.Kind != provider.KindAzure && r.Host == "" {
return provider.SecretValue{}, "", errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
}
type try struct {
p provider.Provider
o provider.Object
}
var tries []try
for _, p := range providers {
if p.Kind() != r.Kind {
continue
}
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
if r.Kind != provider.KindAzure { // by host: workspaces, repos
loc, ok := p.(provider.Locator)
if !ok {
continue
}
profile, found, err := loc.Locate(ctx, r.Host)
if err != nil || !found {
continue
}
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile}
o.Container = provider.Container{Source: src, Name: r.Scope}
// Listing the scopes tells a Key Vault-backed one, whose value
// can then be read from the vault if Databricks refuses.
if cs, err := p.Containers(ctx, src); err == nil {
for _, c := range cs {
if c.Name == r.Scope {
c.Source = src
o.Container = c
}
}
}
} else {
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: source}
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
}
t := try{p, o}
if p.ID() == account {
tries = append([]try{t}, tries...)
} else {
tries = append(tries, t)
}
}
if len(tries) == 0 {
switch r.Kind {
case provider.KindDatabricks:
return provider.SecretValue{}, "", fmt.Errorf("no Databricks profile for workspace %s", r.Host)
case provider.KindRepo:
return provider.SecretValue{}, "", fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host)
}
return provider.SecretValue{}, "", fmt.Errorf("no %s account to read %s with", r.Kind, r)
}
var errs []error
for _, t := range tries {
v, version, err := value(ctx, t.p, t.o)
if err == nil {
return v, version, nil
}
if len(tries) == 1 || ctx.Err() != nil {
return provider.SecretValue{}, "", err
}
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
}
return provider.SecretValue{}, "", errors.Join(errs...)
}
func value(ctx context.Context, p provider.Provider, o provider.Object) (provider.SecretValue, string, error) {
if vp, ok := p.(provider.Versioned); ok {
return vp.VersionedValue(ctx, o)
}
v, err := p.Value(ctx, o)
return v, o.Version, err
}
+2 -30
View File
@@ -10,15 +10,11 @@
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
// lazysecrets://repo/<repo>[/<file>[/<key>]]
// lazysecrets://share/<sealed files>
//
// A repo is its git remote without scheme or .git
// (git.kebcloud.no/keb/infra), the file its path in the repo and the key
// its path in the file (db.password); slashes inside them are escaped.
//
// A share link carries files, encrypted (package share), instead of
// naming something.
//
// Vault hosts always contain dots, so "subscriptions" can't be one.
//
// Every path segment is percent-encoded, so names may hold any character.
@@ -53,23 +49,10 @@ type Ref struct {
Type provider.ObjectType
Name string // object name; empty for a container link
Version string
// Share is a share link's sealed files, as in the link; the other
// fields are empty. Package share reads it.
Share string
// Account, when set, is the provider (its ID) to open or read it
// with when several could, from lazysecrets list. It isn't part of
// the link.
Account string
}
// IsShare reports whether r is a share link.
func (r Ref) IsShare() bool { return r.Share != "" }
// IsSource reports whether r points at a subscription or workspace.
func (r Ref) IsSource() bool {
if r.IsShare() {
return false
}
if r.Kind != provider.KindAzure {
return r.Scope == ""
}
@@ -77,7 +60,7 @@ func (r Ref) IsSource() bool {
}
// IsContainer reports whether r points at a vault or scope.
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() && !r.IsShare() }
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() }
// ContainerName is the vault name (from its host) or the scope.
func (r Ref) ContainerName() string {
@@ -128,9 +111,6 @@ func (r Ref) IdentifierURL() string {
// String is the lazysecrets:// link.
func (r Ref) String() string {
if r.IsShare() {
return Scheme + "://share/" + r.Share
}
if r.Kind == provider.KindAzure && r.Subscription != "" {
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
}
@@ -228,8 +208,6 @@ var (
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
// a share link's sealed files: unpadded base64url
sealed = regexp.MustCompile(`^[A-Za-z0-9_-]{16,}$`)
)
// Parse reads any supported reference.
@@ -260,12 +238,6 @@ func parseLink(s string) (Ref, error) {
if err != nil {
return Ref{}, err
}
if strings.EqualFold(u.Host, "share") {
if len(segs) != 1 || !sealed.MatchString(segs[0]) {
return Ref{}, errors.New("share link is cut short or garbled: copy all of it")
}
return Ref{Share: segs[0]}, nil
}
var r Ref
if len(segs) == 0 || segs[0] == "" {
return Ref{}, errors.New("link has no vault or workspace")
@@ -314,7 +286,7 @@ func parseLink(s string) (Ref, error) {
}
default:
return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host)
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host)
}
return r, nil
}
-69
View File
@@ -1,69 +0,0 @@
package share
import (
"fmt"
"strings"
"unicode"
)
// Rules are what a key's value must be to share with it: random enough
// that guessing it is out of reach, since anyone with a link can try.
type Rules struct {
MinLength int // characters
MinClasses int // kinds of character: lowercase, uppercase, digits, symbols
}
// DefaultRules take a random base64 or hex value (openssl rand -base64
// 32, or -hex 32) and refuse passwords made up to be remembered.
var DefaultRules = Rules{MinLength: 32, MinClasses: 2}
// Check reports what key lacks, without showing it.
func (r Rules) Check(key []byte) error {
r = r.withDefaults()
s := string(key)
var lower, upper, digit, symbol bool
for _, c := range s {
switch {
case unicode.IsLower(c):
lower = true
case unicode.IsUpper(c):
upper = true
case unicode.IsDigit(c):
digit = true
default:
symbol = true
}
}
kinds := 0
for _, has := range []bool{lower, upper, digit, symbol} {
if has {
kinds++
}
}
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 {
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 {
return nil
}
return fmt.Errorf("its value has %s; sharing.keyRules needs %s",
strings.Join(has, " and "), strings.Join(needs, " and "))
}
func (r Rules) withDefaults() Rules {
if r.MinLength == 0 {
r.MinLength = DefaultRules.MinLength
}
if r.MinClasses == 0 {
r.MinClasses = DefaultRules.MinClasses
}
r.MinLength = max(r.MinLength, MinKey)
return r
}
-308
View File
@@ -1,308 +0,0 @@
// Package share seals env files into a lazysecrets://share link, so they
// can be passed on in chat or a ticket. They're encrypted with the value of
// a secret (in Key Vault, Databricks or a repo's SOPS file): whoever can
// read that secret can open the link, nobody else.
//
// A link says in the clear which secret it's encrypted with, the repo the
// files are from and how many there are, which is what's needed to open
// it. The files' paths and contents are encrypted, with age
// (https://age-encryption.org), the secret's value as its passphrase.
//
// The link's payload is unpadded base64url of:
//
// version (1) | uvarint length of the header | header (JSON) | age file
//
// The age file holds the files as deflated JSON.
//
// A change to the format gets the next version, and Read keeps reading
// every earlier one: links stay in chat and tickets for years. A
// version it doesn't know is from a newer lazysecrets.
package share
import (
"bytes"
"compress/flate"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"strings"
"unicode/utf8"
"filippo.io/age"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
const (
// version is the format's, written first; see the package doc.
version = 1
// MaxLink keeps a link short enough for chat and tickets (Teams takes
// about 28 KB) and for passing it to a terminal window.
MaxLink = 16 << 10
// MinKey is the shortest key the rules can ask for.
MinKey = 16
// maxUnpacked caps the bytes opening a link unpacks.
maxUnpacked = 1 << 20
)
// workFactor is the scrypt work factor for sealing: 2^15, about 40 ms
// each way, instead of age's 2^18 (a third of a second). scrypt makes
// guessing a weak passphrase slow; a key keeping to the rules is random
// and long enough not to need that. maxWorkFactor is the most opening
// accepts, so a link can't make it work for minutes.
var workFactor, maxWorkFactor = 15, 20
// File is a shared file.
type File struct {
Path string // relative to the repo's root, with slashes
Data []byte
}
// Header is what a link says in the clear.
type Header struct {
// Key is the secret whose value the files are encrypted with.
Key ref.Ref
// Repo is the repo the files are from: its remote without scheme or
// .git (a repo link's host).
Repo string
// Files is how many files the link holds.
Files int
}
// Sealed is a share link read, its files still encrypted.
type Sealed struct {
Header
box []byte
}
type header struct {
Key string `json:"key"`
Repo string `json:"repo,omitempty"`
Files int `json:"files"`
}
type file struct {
Path string `json:"path"`
Data string `json:"data"`
}
// Seal encrypts files with key, the value of h.Key, into a share link,
// if key keeps to rules (*KeyError if not).
func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) {
if _, err := ParseKey(h.Key.String()); err != nil {
return ref.Ref{}, fmt.Errorf("the key: %w", err)
}
if err := rules.Check(key); err != nil {
return ref.Ref{}, &KeyError{Name: h.Key.Name, Err: err}
}
if len(files) == 0 {
return ref.Ref{}, errors.New("no files to share")
}
plain := make([]file, len(files))
for i, f := range files {
if err := CheckPath(f.Path); err != nil {
return ref.Ref{}, err
}
if !utf8.Valid(f.Data) {
return ref.Ref{}, fmt.Errorf("%s isn't text", f.Path)
}
plain[i] = file{Path: f.Path, Data: string(f.Data)}
}
b, err := json.Marshal(plain)
if err != nil {
return ref.Ref{}, err
}
rcpt, err := age.NewScryptRecipient(string(key))
if err != nil {
return ref.Ref{}, err
}
rcpt.SetWorkFactor(workFactor)
var box bytes.Buffer
enc, err := age.Encrypt(&box, rcpt)
if err != nil {
return ref.Ref{}, err
}
z, _ := flate.NewWriter(enc, flate.BestCompression)
if _, err := z.Write(b); err != nil {
return ref.Ref{}, err
}
if err := z.Close(); err != nil {
return ref.Ref{}, err
}
if err := enc.Close(); err != nil {
return ref.Ref{}, err
}
hb, err := json.Marshal(header{Key: h.Key.String(), Repo: h.Repo, Files: len(files)})
if err != nil {
return ref.Ref{}, err
}
payload := binary.AppendUvarint([]byte{version}, uint64(len(hb)))
payload = append(append(payload, hb...), box.Bytes()...)
r := ref.Ref{Share: base64.RawURLEncoding.EncodeToString(payload)}
if n := len(r.String()); n > MaxLink {
return ref.Ref{}, fmt.Errorf("the link would be %d KB, more than the %d KB it can be: share fewer files", n>>10, MaxLink>>10)
}
return r, nil
}
// KeyError is a key that can't be used: it can't be read, or doesn't
// keep to the rules.
type KeyError struct {
Name string // the secret's
Err error
}
func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() }
func (e *KeyError) Unwrap() error { return e.Err }
// Read reads a share link's header.
func Read(r ref.Ref) (Sealed, error) {
bad := errors.New("share link is cut short or garbled: copy all of it")
b, err := base64.RawURLEncoding.DecodeString(r.Share)
if err != nil || len(b) < 2 {
return Sealed{}, bad
}
if b[0] != version {
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
}
n, size := binary.Uvarint(b[1:])
if size <= 0 || n > uint64(len(b)) {
return Sealed{}, bad
}
rest := b[1+size:]
if n > uint64(len(rest)) {
return Sealed{}, bad
}
var h header
if err := json.Unmarshal(rest[:n], &h); err != nil {
return Sealed{}, bad
}
key, err := ParseKey(h.Key)
if err != nil {
return Sealed{}, fmt.Errorf("share link's key: %w", err)
}
return Sealed{Header: Header{Key: key, Repo: h.Repo, Files: h.Files}, box: rest[n:]}, nil
}
// Open decrypts the files with key, the value of s.Key.
func (s Sealed) Open(key []byte) ([]File, error) {
id, err := age.NewScryptIdentity(string(key))
if err != nil {
return nil, err
}
id.SetMaxWorkFactor(maxWorkFactor)
dec, err := age.Decrypt(bytes.NewReader(s.box), id)
if err != nil {
var wrong *age.NoIdentityMatchError
if errors.As(err, &wrong) {
return nil, s.wrongKey()
}
return nil, fmt.Errorf("can't decrypt the link: %w", err)
}
b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxUnpacked+1))
if err != nil {
return nil, fmt.Errorf("can't decrypt the link: %w", err)
}
if len(b) > maxUnpacked {
return nil, errors.New("the link holds more than lazysecrets unpacks")
}
var plain []file
if err := json.Unmarshal(b, &plain); err != nil {
return nil, fmt.Errorf("can't read the link's files: %w", err)
}
out := make([]File, len(plain))
for i, f := range plain {
if err := CheckPath(f.Path); err != nil {
return nil, err
}
out[i] = File{Path: f.Path, Data: []byte(f.Data)}
}
return out, nil
}
// wrongKey says why the key, read fine, doesn't decrypt the link: its
// value isn't the one the link was made with.
func (s Sealed) wrongKey() error {
name := s.Key.Name
switch {
case s.Key.Kind == provider.KindAzure && s.Key.Version != "":
// A version's value never changes.
return fmt.Errorf("%s at version %s doesn't decrypt it: the link may be damaged", name, s.Key.Version)
case s.Key.Kind == provider.KindAzure:
return fmt.Errorf("%s's current version isn't the one the link was made with, so it can't decrypt it: ask for a new link", name)
}
// Databricks secrets and SOPS values are changed in place.
return fmt.Errorf("%s has changed since the link was made, so it can't decrypt it: ask for a new link", name)
}
// CheckPath accepts a path relative to a repo's root, with slashes, of an
// env file (.env, .env.local, prod.env) outside .git: what a link may
// write.
func CheckPath(p string) error {
switch {
case p == "" || path.IsAbs(p) || strings.ContainsAny(p, "\\\x00") || path.Clean(p) != p ||
p == ".." || strings.HasPrefix(p, "../"):
return fmt.Errorf("%q isn't a path inside a repo", p)
case p == ".git" || strings.HasPrefix(p, ".git/") || strings.Contains(p, "/.git/"):
return fmt.Errorf("%q is inside .git", p)
case !repo.IsEnv(p):
return fmt.Errorf("%q isn't an env file: only those can be shared", p)
}
return nil
}
// DefaultKey is the key for a repo's files: sharing.key in the repo's
// .lazysecrets.yaml, else general (sharing.key in the config). ok is false
// when neither names one.
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
link, err := repo.ShareKey(root)
if err != nil {
return ref.Ref{}, false, err
}
where := root + "/.lazysecrets.yaml"
if link == "" {
link, where = general, "the config"
}
if link == "" {
return ref.Ref{}, false, nil
}
key, err = ParseKey(link)
if err != nil {
return ref.Ref{}, false, fmt.Errorf("sharing.key in %s: %w", where, err)
}
return key, true, nil
}
// ParseKey reads which secret is the key: its lazysecrets:// link, Key
// Vault URL or @Microsoft.KeyVault(...) reference.
func ParseKey(s string) (ref.Ref, error) {
key, err := ref.Parse(s)
if err != nil {
return ref.Ref{}, err
}
switch {
case key.IsShare() || key.IsSource() || key.IsContainer() || (key.Type != "" && key.Type != provider.TypeSecret):
return ref.Ref{}, fmt.Errorf("%s isn't a secret", s)
case key.Kind == provider.KindDatabricks && key.Host == "":
return ref.Ref{}, fmt.Errorf("%s names no workspace: use its lazysecrets:// link", s)
}
return key, nil
}
// Pin is key at version, the version its value was read at, for Azure,
// whose old versions stay readable: a link keeps working when the key
// is rotated, until that version is disabled.
func Pin(key ref.Ref, version string) ref.Ref {
if key.Kind == provider.KindAzure && key.Version == "" {
key.Version = version
}
return key
}
-258
View File
@@ -1,258 +0,0 @@
package share
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
func init() { workFactor = 10 } // fast; opening accepts any up to maxWorkFactor
var (
key = ref.Ref{Kind: provider.KindAzure, Host: "kv-team.vault.azure.net", Type: provider.TypeSecret, Name: "env-share", Version: "91d3e0"}
pass = []byte("0123456789abcdef0123456789abcdef")
)
func TestSealOpen(t *testing.T) {
files := []File{
{Path: ".env", Data: []byte("DB_PASSWORD=hunter2\n")},
{Path: "apps/api/.env.local", Data: []byte("TOKEN=\"a b\" # quoted\n")},
}
link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files, Rules{})
if err != nil {
t.Fatal(err)
}
if strings.Contains(link.String(), "hunter2") || strings.Contains(link.String(), "apps") {
t.Errorf("link shows what it shares: %s", link)
}
// It goes through ref.Parse like any link.
r, err := ref.Parse(link.String())
if err != nil || !r.IsShare() {
t.Fatalf("parse: %v, %+v", err, r)
}
s, err := Read(r)
if err != nil {
t.Fatal(err)
}
if s.Key != key || s.Repo != "git.example.com/team/app" || s.Files != 2 {
t.Errorf("header %+v", s.Header)
}
got, err := s.Open(pass)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) {
t.Errorf("opened %+v", got)
}
if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "env-share at version 91d3e0 doesn't decrypt it") {
t.Errorf("wrong key: %v", err)
}
}
func TestWrongKeySaysWhy(t *testing.T) {
files := []File{{Path: ".env", Data: []byte("A=1\n")}}
for link, want := range map[string]string{
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share": "env-share has changed since the link was made",
"lazysecrets://repo/git.example.com%2Fteam%2Fapp/secrets.yaml/share": "share has changed since the link was made",
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share": "env-share's current version isn't the one the link was made with",
} {
k, err := ParseKey(link)
if err != nil {
t.Fatal(err)
}
r, err := Seal(Header{Key: k}, pass, files, Rules{})
if err != nil {
t.Fatal(err)
}
s, _ := Read(r)
if _, err := s.Open([]byte("rotated since, and long enough to be a key")); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", link, err, want)
}
}
}
func TestSealRefuses(t *testing.T) {
ok := []File{{Path: ".env", Data: []byte("A=1")}}
for name, c := range map[string]struct {
h Header
pass []byte
files []File
want string
}{
"short key": {Header{Key: key}, []byte("short"), ok, "needs 32 (minLength)"},
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"},
"no files": {Header{Key: key}, pass, nil, "no files"},
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
"binary": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte{0xff, 0xfe}}}, "isn't text"},
"too big": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte(random(2 * MaxLink))}}, "more than the 16 KB"},
} {
if _, err := Seal(c.h, c.pass, c.files, Rules{}); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want %q", name, err, c.want)
}
}
}
// random is text that doesn't compress.
func random(n int) string {
var b strings.Builder
x := uint32(1)
for b.Len() < n {
x ^= x << 13
x ^= x >> 17
x ^= x << 5
b.WriteByte("abcdefghijklmnopqrstuvwxyz0123456789"[x%36])
}
return b.String()
}
func TestRules(t *testing.T) {
for _, c := range []struct {
r Rules
key string
pass bool
}{
{Rules{}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", true}, // base64-like, 32
{Rules{}, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", true}, // hex
{Rules{}, "correct-horse-battery-staple", false}, // 28: short
{Rules{}, strings.Repeat("a", 40), false}, // one kind
{Rules{MinLength: 16, MinClasses: 1}, strings.Repeat("a", 16), true},
{Rules{MinLength: 4}, "abc123", false}, // never under MinKey
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", false},
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5U+", true},
} {
if err := c.r.Check([]byte(c.key)); (err == nil) != c.pass {
t.Errorf("%+v %q: %v", c.r, c.key, err)
} else if err != nil && strings.Contains(err.Error(), c.key) {
t.Errorf("the error shows the key: %v", err)
}
}
}
func TestReadGarbled(t *testing.T) {
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}}, Rules{})
if err != nil {
t.Fatal(err)
}
cut := ref.Ref{Share: link.Share[:len(link.Share)/2]}
s, err := Read(cut)
if err == nil {
_, err = s.Open(pass)
}
if err == nil {
t.Error("a link cut in half opened")
}
if _, err := Read(ref.Ref{Share: "AgAAAAAAAAAAAAAAAAAAAA"}); err == nil || !strings.Contains(err.Error(), "newer lazysecrets") {
t.Errorf("version 2: %v", err)
}
}
func TestCheckPath(t *testing.T) {
for _, p := range []string{".env", "apps/api/.env.local", "prod.env"} {
if err := CheckPath(p); err != nil {
t.Errorf("%s: %v", p, err)
}
}
for _, p := range []string{"", "/etc/.env", "../.env", "a/../.env", ".git/.env", "a/.git/x.env", `a\.env`, ".env.example", "node_modules/x/.env", "Makefile"} {
if err := CheckPath(p); err == nil {
t.Errorf("%q accepted", p)
}
}
}
func TestPlanWrite(t *testing.T) {
dir := t.TempDir()
if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil {
t.Skip("git:", string(out))
}
os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".env\n"), 0o644)
os.WriteFile(filepath.Join(dir, ".env"), []byte("OLD=1\n"), 0o644)
targets, err := Plan(dir, []File{
{Path: ".env", Data: []byte("NEW=1\n")},
{Path: "apps/api/.env.local", Data: []byte("B=2\n")},
})
if err != nil {
t.Fatal(err)
}
if !targets[0].Exists || targets[0].Exposed {
t.Errorf(".env: %+v", targets[0])
}
if targets[1].Exists || !targets[1].Exposed {
t.Errorf("apps/api/.env.local: %+v", targets[1])
}
for _, tg := range targets {
if err := tg.Write(); err != nil {
t.Fatal(err)
}
}
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "NEW=1\n" {
t.Errorf(".env is %q", b)
}
info, err := os.Stat(filepath.Join(dir, "apps", "api", ".env.local"))
if err != nil || info.Mode().Perm() != 0o600 {
t.Errorf("new file: %v, %v", info.Mode(), err)
}
}
func TestPlanSymlinks(t *testing.T) {
dir, outside := t.TempDir(), t.TempDir()
if err := os.Symlink(outside, filepath.Join(dir, "apps")); err != nil {
t.Skip(err)
}
if _, err := Plan(dir, []File{{Path: "apps/.env"}}); err == nil || !strings.Contains(err.Error(), "out of the folder") {
t.Errorf("through a linked folder: %v", err)
}
os.Symlink(filepath.Join(outside, "x"), filepath.Join(dir, ".env"))
if _, err := Plan(dir, []File{{Path: ".env"}}); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Errorf("onto a symlink: %v", err)
}
}
func TestParseKey(t *testing.T) {
for _, s := range []string{
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share",
"https://kv-team.vault.azure.net/secrets/env-share",
"@Microsoft.KeyVault(VaultName=kv-team;SecretName=env-share)",
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share",
} {
if k, err := ParseKey(s); err != nil || k.Name != "env-share" {
t.Errorf("%s: %+v, %v", s, k, err)
}
}
for s, want := range map[string]string{
"{{secrets/team/env-share}}": "names no workspace",
"https://kv-team.vault.azure.net/keys/sops": "isn't a secret",
"https://kv-team.vault.azure.net/": "isn't a secret",
"lazysecrets://azure/subscriptions/0000-1111": "isn't a secret",
} {
if _, err := ParseKey(s); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", s, err, want)
}
}
}
func TestDefaultKey(t *testing.T) {
dir := t.TempDir()
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
k, ok, err := DefaultKey(dir, general)
if err != nil || !ok || k.Name != "env-share" {
t.Errorf("general: %+v %v %v", k, ok, err)
}
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("sharing:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
t.Errorf("the repo's should win: %+v", k)
}
if _, ok, _ := DefaultKey(t.TempDir(), ""); ok {
t.Error("a key with none set")
}
unpinned := key
unpinned.Version = ""
if p := Pin(unpinned, "abc"); p.Version != "abc" {
t.Errorf("pin: %+v", p)
}
}
-87
View File
@@ -1,87 +0,0 @@
package share
import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Target is where an opened file goes.
type Target struct {
File
Abs string
Exists bool
// Exposed is set when dir is a git repo that doesn't ignore the file:
// its values could end up in a commit.
Exposed bool
}
// Plan says where files go under dir, without writing anything. A path
// that leads out of dir through a symlink, or is one, is an error.
func Plan(dir string, files []File) ([]Target, error) {
root, err := filepath.EvalSymlinks(dir)
if err != nil {
return nil, err
}
out := make([]Target, len(files))
for i, f := range files {
if err := CheckPath(f.Path); err != nil {
return nil, err
}
t := Target{File: f, Abs: filepath.Join(root, filepath.FromSlash(f.Path))}
if err := inside(root, filepath.Dir(t.Abs)); err != nil {
return nil, fmt.Errorf("%s: %w", f.Path, err)
}
switch info, err := os.Lstat(t.Abs); {
case err == nil && info.Mode()&fs.ModeSymlink != 0:
return nil, fmt.Errorf("%s is a symlink: not writing through it", f.Path)
case err == nil && !info.Mode().IsRegular():
return nil, fmt.Errorf("%s isn't a file", f.Path)
case err == nil:
t.Exists = true
case !errors.Is(err, fs.ErrNotExist):
return nil, err
}
t.Exposed = exposed(root, f.Path)
out[i] = t
}
return out, nil
}
// inside checks that dir, or the part of it that exists, is under root
// once symlinks are followed.
func inside(root, dir string) error {
for {
real, err := filepath.EvalSymlinks(dir)
if errors.Is(err, fs.ErrNotExist) && dir != root {
dir = filepath.Dir(dir) // created when writing
continue
}
if err != nil {
return err
}
if real != root && !strings.HasPrefix(real, root+string(filepath.Separator)) {
return errors.New("leads out of the folder through a symlink")
}
return nil
}
}
// exposed reports whether root is in a git repo that doesn't ignore p.
func exposed(root, p string) bool {
err := exec.Command("git", "-C", root, "check-ignore", "-q", "--", p).Run()
var exit *exec.ExitError
return errors.As(err, &exit) && exit.ExitCode() == 1 // 0: ignored; 128: no repo
}
// Write writes the file, readable by the user only when it's new.
func (t Target) Write() error {
if err := os.MkdirAll(filepath.Dir(t.Abs), 0o755); err != nil {
return err
}
return os.WriteFile(t.Abs, t.Data, 0o600)
}
+6 -20
View File
@@ -43,13 +43,6 @@ type Node struct {
// alone isn't.
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
// UsedLately reports whether the item was used within the last 90 days
// (what Recent lists). Starred and noted records are kept longer, so a
// use alone doesn't say.
func (n Node) UsedLately(now time.Time) bool {
return !n.LastUsed.IsZero() && now.Sub(n.LastUsed) <= usageMaxAge
}
// StarredFor reports whether the item is starred for account.
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
@@ -152,23 +145,16 @@ type Store struct {
data file
}
// ErrNewer is a state file written by a newer lazysecrets.
var ErrNewer = errors.New("written by a newer lazysecrets")
// Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo. A file from a newer lazysecrets
// is read but never saved, so nothing it keeps is lost: Load returns the
// store with an ErrNewer error.
// that never saves, for tests and --demo.
func Load(path string) (*Store, error) {
s := &Store{path: path}
d, err := read(path)
if errors.Is(err, ErrNewer) {
s.path = ""
} else if err != nil {
if err != nil {
return nil, err
}
s.data = d
return s, err
return s, nil
}
func read(path string) (file, error) {
@@ -186,12 +172,12 @@ func read(path string) (file, error) {
if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err)
}
if d.Version > version {
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
}
if d.Nodes == nil {
d.Nodes = map[string]Node{}
}
if d.Version > version {
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
}
return d, nil
}
+4 -23
View File
@@ -1,7 +1,6 @@
package store
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -66,17 +65,11 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
}
}
func TestNewerVersionReadOnly(t *testing.T) {
func TestNewerVersionRefused(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
_ = os.WriteFile(path, []byte(newer), 0o600)
s, err := Load(path)
if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" {
t.Fatalf("Load = %v, %v", s, err)
}
_ = s.SetFlag("x", true)
if b, _ := os.ReadFile(path); string(b) != newer {
t.Errorf("the newer file was overwritten: %s", b)
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
if _, err := Load(path); err == nil {
t.Error("a newer file was accepted")
}
}
@@ -119,15 +112,3 @@ func TestStarsPerAccount(t *testing.T) {
t.Errorf("unstar alice: %v", n.StarredBy)
}
}
func TestUsedLately(t *testing.T) {
now := time.Now()
for ago, want := range map[time.Duration]bool{time.Hour: true, 89 * 24 * time.Hour: true, 2 * 365 * 24 * time.Hour: false} {
if got := (Node{LastUsed: now.Add(-ago)}).UsedLately(now); got != want {
t.Errorf("used %v ago: %v", ago, got)
}
}
if (Node{}).UsedLately(now) {
t.Error("never used is lately")
}
}
+34 -175
View File
@@ -24,7 +24,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
@@ -57,7 +56,6 @@ const (
slotDescribe // extra metadata for the selected object
slotCopy // fetch a value straight to the clipboard
slotVersions // count the selected object's versions
slotDetails // a file's details
slotCount
)
@@ -65,8 +63,6 @@ const (
type Options struct {
// Timeout bounds every provider call. Defaults to 30s.
Timeout time.Duration
// Timeouts override Timeout for the providers of a kind.
Timeouts map[provider.Kind]time.Duration
// Icons shows Nerd Font glyphs next to backends.
Icons bool
// Clipboard defaults to the system clipboard, or OSC52 over SSH.
@@ -79,19 +75,11 @@ type Options struct {
// and keeps its state, for the status pane.
ConfigFiles []string
StatePath string
// Warning shows when the window opens: what lazysecrets skipped
// instead of failing to start.
Warning string
// Changelog is CHANGELOG.md, for the status pane.
Changelog string
// CacheFile keeps what was loaded between runs; "" keeps it for this
// run only.
CacheFile string
// ShareKey is the config's sharing.key: the secret that encrypts shared
// env files, for repos whose .lazysecrets.yaml names none.
ShareKey string
// ShareRules are what a key's value must be to share with it.
ShareRules share.Rules
// Dir is the folder lazysecrets runs in: it goes back to where it was
// left there, unless it was started on a link. "" doesn't.
Dir string
@@ -122,24 +110,15 @@ type Options struct {
// Keys, if set, replaces the default keymap (keys.Map.Apply).
Keys *keys.Map
// Updater, if set, finds and installs newer releases (U); with
// UpdateCheck, lazysecrets also looks on its own at startup, at most
// once per UpdateEvery (0 for a day).
// UpdateCheck, lazysecrets also looks once a day on its own.
Updater *Updater
UpdateCheck bool
UpdateEvery time.Duration
// HandlerSetup, if set, offers to set up the link handler at startup.
HandlerSetup *HandlerSetup
// NoHistory stops counting uses of items for the Recent list.
NoHistory bool
// RecentIcon marks items used lately with ◷ in the lists
// (ui.recentIcon).
RecentIcon bool
// Open is a reference to open at startup (lazysecrets open <ref>).
Open *ref.Ref
// OpenAction, if set, is what to do with Open, rather than asking:
// go to it, and maybe copy or show its value (OpenActions). It ends in
// the list, like Go to, rather than on the details.
OpenAction string
// Styles, if set, replaces the default theme (styles.FromTheme), and
// DefaultFg the terminal's default text colour.
Styles *styles.Styles
@@ -158,7 +137,7 @@ type Clipboard interface {
type Model struct {
providers []provider.Provider
byID map[string]provider.Provider
timeout timeouts
timeout time.Duration
keys keys.Map
st styles.Styles
@@ -192,15 +171,12 @@ type Model struct {
clip Clipboard
clipClear time.Duration
openURL func(string) error
// fields is the fourth pane for anything but a secret; lastLeft is
// the left pane it describes while the fourth has focus (fields.go).
// fields is the fourth pane for anything but a secret, with each
// account's groups as they load; lastLeft is the left pane it
// describes while the fourth has focus (fields.go).
fields panes.Fields
lastLeft pane
details map[string]detailsState // by containerKey
// errLog is this session's errors, newest first; loggedFlash the
// footer's text when last looked at (errlog.go).
errLog []loggedErr
loggedFlash string
groups map[string]groupsState
getenv func(string) string
nvimOpen func(server, path string, line int) error // edit.go
now func() time.Time
@@ -213,9 +189,6 @@ type Model struct {
statusW int // the status pane's width: the left column's, zoomed or not
changelog []panes.Field
cacheDirty bool
// cacheDropped are cache keys deleted since the last save, so saving
// deletes them from the file too.
cacheDropped map[string]bool
sourcesAt map[string]time.Time
placeDir string
savedPlace string // the link last saved as the place
@@ -253,7 +226,6 @@ type Model struct {
updater *Updater
updateCheck bool
updateEvery time.Duration
fetchOnStart bool // look for a newer release in Init
newer *update.Release // a newer release to offer
updated string // the release installed, waiting for a restart
@@ -293,11 +265,6 @@ type Model struct {
prompt *modal // popup that has the keyboard
linked []provider.Container // vaults opened from links, this session
open *ref.Ref // reference to open at startup
recentIcon bool // mark items used lately (◷)
openAction string // and what to do with it, or ask
shareKey string // the config's sharing.key
shareRules share.Rules // and sharing.keyRules
// A lazysecrets:// link found on the clipboard, offered in the footer.
dismissedLink string // the last link offered: not offered again
@@ -395,7 +362,7 @@ func New(providers []provider.Provider, opts Options) Model {
m := Model{
providers: providers,
byID: map[string]provider.Provider{},
timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
timeout: opts.Timeout,
keys: keys.Default(),
st: styles.Default(),
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
@@ -408,7 +375,7 @@ func New(providers []provider.Provider, opts Options) Model {
getenv: os.Getenv,
nvimOpen: nvimOpen,
cache: newCache(),
details: map[string]detailsState{},
groups: map[string]groupsState{},
fields: panes.NewFields(),
}
if m.clip == nil {
@@ -427,8 +394,7 @@ func New(providers []provider.Provider, opts Options) Model {
if m.store == nil {
m.store, _ = store.Load("")
}
m.recentIcon = opts.RecentIcon
m.sources = newSourcesList(iconSet, m.store, m.recentIcon)
m.sources = newSourcesList(iconSet, m.store)
m.sources.Empty = "no sources found"
m.collapsed = map[string]bool{}
m.flatSources = opts.FlatSources
@@ -448,10 +414,7 @@ func New(providers []provider.Provider, opts Options) Model {
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
m.expiryWarning = opts.ExpiryWarning
m.handlerSetup = opts.HandlerSetup
m.updater, m.updateCheck, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery
if m.updateEvery == 0 {
m.updateEvery = updateEvery
}
m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck
m.startUpdateCheck()
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
m.settle = opts.LoadDelay
@@ -460,7 +423,7 @@ func New(providers []provider.Provider, opts Options) Model {
}
m.defaultFg = opts.DefaultFg
m.help = newHelp(m.st)
m.open, m.openAction = opts.Open, opts.OpenAction
m.open = opts.Open
saved := m.store.Layout()
m.split, m.heights = saved.Split, saved.Heights
@@ -488,17 +451,11 @@ func New(providers []provider.Provider, opts Options) Model {
m.spinning = true
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if opts.Warning != "" {
m.flash = warning(opts.Warning)
}
if m.cachePath != "" {
m.loadCacheFile()
if len(m.sourceResults) > 0 {
m.rebuildSources()
m.sources.Loading = true // reloading what the file had
m.sources.Age = m.cachedAge(m.oldestSources())
}
}
if m.placeDir != "" && opts.Open == nil {
@@ -526,7 +483,7 @@ func (m *Model) setVersions(items []provider.Object) {
}
func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard(), tea.RequestBackgroundColor}
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()}
if m.cachePath != "" || m.placeDir != "" {
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
}
@@ -534,34 +491,23 @@ func (m Model) Init() tea.Cmd {
cmds = append(cmds, m.fetchLatest(false))
}
if m.open != nil {
msg := openRefMsg{ref: *m.open, action: m.openAction}
cmds = append(cmds, func() tea.Msg { return msg })
r := *m.open
cmds = append(cmds, func() tea.Msg { return openRefMsg{r} })
}
return tea.Batch(cmds...)
}
// openRefMsg starts opening a reference from outside the key handler:
// asking what to do with it, or doing action (OpenActions).
type openRefMsg struct {
ref ref.Ref
action string
}
// OpenActions are what lazysecrets open --action takes, the Open link
// menu's choices that go there.
var OpenActions = map[string]linkAction{"go": actGo, "copy": actCopy, "show": actShow}
// openRefMsg starts opening a reference from outside the key handler.
type openRefMsg struct{ ref ref.Ref }
// OpenLink is a message offering r in the Open link popup, for links
// handed over by the link handler (Program.Send).
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{ref: r} }
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.BackgroundColorMsg:
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
return m, nil
case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
@@ -610,24 +556,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case openRefMsg:
m.deferHandlerSetup() // the link first
if act, ok := OpenActions[msg.action]; ok && !msg.ref.IsShare() {
cmds = append(cmds, m.findRef(msg.ref, act))
} else {
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
}
case resolvedMsg:
m.handleResolved(msg)
case shareFilesMsg:
cmds = append(cmds, m.handleShareFiles(msg))
case sealedMsg:
cmds = append(cmds, m.handleSealed(msg))
case shareOpenedMsg:
cmds = append(cmds, m.handleShareOpened(msg))
case tea.PasteMsg:
cmds = append(cmds, m.handlePaste(msg))
@@ -652,15 +585,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case persistedMsg:
m.handlePersisted(msg)
case detailsMsg:
if msg.req == m.reqID[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
} else {
m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err}
}
case groupsMsg:
m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err}
case crawlMsg:
cmds = append(cmds, m.handleCrawl(msg))
@@ -688,10 +614,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotContainers)
if msg.err != nil {
m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err)
}
uncache(&m, m.cache.containers, msg.key)
delete(m.cache.containers, msg.key)
m.containers.SetError(msg.err)
} else {
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
@@ -709,23 +632,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.closeVersions()
} else if msg.err != nil {
m.noteErr(msg.err)
if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err)
}
uncache(&m, m.cache.objects, msg.key)
delete(m.cache.objects, msg.key)
m.secrets.SetError(msg.err)
} else {
if msg.bound != nil {
msg.key = m.rebind(*msg.bound)
}
before := m.cache.objects[msg.key].value
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
m.cacheDirty = true
if m.versionsOf != nil {
m.setVersions(msg.items)
} else {
m.secrets.SetItems(m.starredFirst(msg.items))
cmds = append(cmds, m.refreshShown(before))
}
}
@@ -736,7 +654,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotValue)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
m.detail.SetValueError(msg.err)
} else {
m.detail.SetValue(msg.value)
@@ -763,7 +680,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotDescribe)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
m.detail.SetDescribeError(msg.err)
} else {
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
@@ -834,7 +750,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.focus != paneDetail {
m.lastLeft = m.focus
}
m.logFlash()
cmds = append(cmds, m.syncFields())
if m.anyLoading() && !m.spinning {
m.spinning = true
@@ -906,8 +821,6 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup {
m.focus = paneDetail // an account's row: nothing between
}
case key.Matches(msg, k.Details):
m.focus = paneDetail // what's selected, on the right
case key.Matches(msg, k.PrevPane):
if m.focus == paneDetail {
m.focus = m.lastLeft // back to what it describes
@@ -928,12 +841,6 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.focus = paneDetail // its rows are on the right
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
// folded or unfolded a provider group
case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "":
r, err := ref.Parse(m.fieldLink())
if err == nil {
f, _ := m.fields.Selected()
m.startNav(r, actGo, f.Row)
}
case key.Matches(msg, k.Enter):
if m.focus < paneDetail {
m.focus++
@@ -1125,7 +1032,6 @@ func (m *Model) refresh() tea.Cmd {
case paneContainers:
if src, ok := m.selectedSource(); ok {
m.containers.Refresh()
m.details = map[string]detailsState{}
return m.loadContainers(src, m.selSource)
}
case paneSecrets:
@@ -1248,7 +1154,6 @@ func (m *Model) showContainers(src provider.Source, key string) tea.Cmd {
return nil
}
m.containers.Refresh()
m.containers.Age = m.cachedAge(e.at)
return m.loadContainers(src, key)
}
@@ -1265,7 +1170,6 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
return nil
}
m.secrets.Refresh()
m.secrets.Age = m.cachedAge(e.at)
return m.loadObjects(c, m.objType, key)
}
@@ -1279,7 +1183,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
}
m.secrets.SetLoading()
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
ctx, req := m.begin(slotObjects)
return func() tea.Msg {
items, err := p.Versions(ctx, o)
for i := range items {
@@ -1309,7 +1213,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
}
}
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotVersions, o.Container.Source.Kind)
ctx, req := m.begin(slotVersions)
return m.settled(ctx, func() tea.Msg {
items, err := p.Versions(ctx, o)
for i := range items {
@@ -1395,26 +1299,6 @@ func (m *Model) detailStale() bool {
return hasSecret || shown != m.containers.SelectedID()
}
// cachedAge is how old a list loaded at at is, if it's older than
// cacheTTL: the panes say it while it reloads.
func (m Model) cachedAge(at time.Time) string {
if d := m.now().Sub(at); d >= cacheTTL {
return ago(d)
}
return ""
}
// oldestSources is when the oldest of the cached Sources lists loaded.
func (m Model) oldestSources() time.Time {
var oldest time.Time
for _, at := range m.sourcesAt {
if oldest.IsZero() || at.Before(oldest) {
oldest = at
}
}
return oldest
}
func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
if msg.gen != m.sourcesGen {
return nil
@@ -1429,7 +1313,6 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
for _, p := range m.providers {
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
m.noteErr(r.err)
m.logErrOf(p.Label(), r.err)
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
}
}
@@ -1460,26 +1343,11 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
return m.crawlStart()
}
// timeouts bound each provider request: its kind's, else the default.
type timeouts struct {
def time.Duration
byKind map[provider.Kind]time.Duration
}
// of is how long a request to a provider of kind k may take.
func (t timeouts) of(k provider.Kind) time.Duration {
if d, ok := t.byKind[k]; ok {
return d
}
return t.def
}
// begin starts a cancellable request for pane p to a provider of kind k,
// cancelling any in flight.
func (m *Model) begin(p slot, k provider.Kind) (context.Context, int) {
// begin starts a cancellable request for pane p, cancelling any in flight.
func (m *Model) begin(p slot) (context.Context, int) {
m.cancelReq(p)
m.seq++
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k))
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
m.reqID[p], m.cancel[p] = m.seq, cancel
return ctx, m.seq
}
@@ -1533,7 +1401,7 @@ func (m Model) sourcesCmd() tea.Cmd {
gen := m.sourcesGen
var cmds []tea.Cmd
for _, p := range m.providers {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
cmds = append(cmds, func() tea.Msg {
defer cancel()
s, err := p.Sources(ctx)
@@ -1544,7 +1412,7 @@ func (m Model) sourcesCmd() tea.Cmd {
})
// The source it started in, quickly, on its own.
if c, ok := p.(provider.Current); ok {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
cmds = append(cmds, func() tea.Msg {
defer cancel()
src, ok := c.Current(ctx)
@@ -1561,7 +1429,7 @@ func (m Model) sourcesCmd() tea.Cmd {
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
p := m.providerFor(src)
ctx, req := m.begin(slotContainers, src.Kind)
ctx, req := m.begin(slotContainers)
return m.settled(ctx, func() tea.Msg {
items, err := p.Containers(ctx, src)
// Carry the source as given, with its provider ID, so later
@@ -1580,7 +1448,7 @@ func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
cands := m.candidates(c.Source)
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
ctx, req := m.begin(slotObjects, c.Source.Kind)
ctx, req := m.begin(slotObjects)
return m.settled(ctx, func() tea.Msg {
var errs []error
for _, p := range cands {
@@ -1608,7 +1476,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
func (m *Model) fetchValue(s provider.Object) tea.Cmd {
p := m.providerFor(s.Container.Source)
ctx, req := m.begin(slotValue, s.Container.Source.Kind)
ctx, req := m.begin(slotValue)
m.detail.SetLoading()
return func() tea.Msg {
v, err := p.Value(ctx, s)
@@ -1627,7 +1495,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
m.detail.SetDescribing()
}
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotDescribe, o.Container.Source.Kind)
ctx, req := m.begin(slotDescribe)
return m.settled(ctx, func() tea.Msg {
obj, err := p.Describe(ctx, o)
return describeMsg{req: req, key: key, obj: obj, err: err}
@@ -1655,7 +1523,7 @@ func (m *Model) copyValue() tea.Cmd {
return m.copyText(o.Name, v.Reveal(), true)
}
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotCopy, o.Container.Source.Kind)
ctx, req := m.begin(slotCopy)
m.flash = flash{text: "copying " + o.Name + "…"}
return func() tea.Msg {
v, err := p.Value(ctx, o)
@@ -1794,8 +1662,7 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else {
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)
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2)
}
var lines []string
if !m.clipDeadline.IsZero() {
@@ -1871,14 +1738,6 @@ func (m Model) problemText(providerID, err string) string {
return err
}
// fieldLink is the link of the selected Fields row, if it has one.
func (m Model) fieldLink() string {
if f, ok := m.fields.Selected(); ok && m.fields.Shown() {
return f.Link
}
return ""
}
// loginFor is the sign-in L runs: the selected account's, if it needs
// one, else the last one asked for.
func (m Model) loginFor() *provider.AuthError {
+41 -144
View File
@@ -494,7 +494,7 @@ func TestVersionHistoryInDetail(t *testing.T) {
screen := d.screen()
for _, want := range []string{
"versions 3 · v to browse",
" 3f9c2a 2026-09-01 current",
"▸ 3f9c2a 2026-09-01 current", // the version shown above
" 91d3e0 2026-03-01 expired",
" 0a77c4 2025-09-01 disabled",
} {
@@ -502,9 +502,6 @@ func TestVersionHistoryInDetail(t *testing.T) {
t.Errorf("missing %q:\n%s", want, screen)
}
}
if strings.Contains(screen, "▸ 3f9c2a") {
t.Errorf("a cursor on a list that can't be moved in:\n%s", screen)
}
d.keys("3", "j") // api-key: only its current version
if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") {
t.Errorf("wrong history for api-key:\n%s", d.screen())
@@ -536,7 +533,7 @@ func TestBackspaceGoesBack(t *testing.T) {
}
// While typing a filter, backspace deletes a character instead.
d.keys("/")
d.typeText("api-x")
d.typeText("apx")
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 {
t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len())
@@ -666,7 +663,7 @@ func TestFilter(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.typeText("api-k")
d.typeText("api")
if d.m.secrets.Len() != 1 {
t.Fatalf("visible = %d, want 1", d.m.secrets.Len())
}
@@ -684,9 +681,9 @@ func TestFilter(t *testing.T) {
}
d.keys("esc") // leave filter mode, clearing the query
d.keys("/")
d.typeText("api-k")
d.typeText("api")
d.keys("enter", "esc") // apply, then esc clears the applied filter
if d.m.secrets.Len() != 4 {
if d.m.secrets.Len() != 3 {
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
}
if _, _, s := d.selected(); s != "api-key" {
@@ -698,13 +695,13 @@ func TestMoveWhileFiltering(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.typeText("a") // all four secrets contain an a
if d.m.secrets.Len() != 4 {
d.typeText("a") // all three secrets contain an a
if d.m.secrets.Len() != 3 {
t.Fatalf("visible = %d", d.m.secrets.Len())
}
_, _, initial := d.selected() // kept from before filtering: the middle row
var seen []string
for _, k := range []rune{'j', 'j', 'j', 'k'} {
for _, k := range []rune{'j', 'j', 'k'} {
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
_, _, s := d.selected()
seen = append(seen, s)
@@ -712,9 +709,9 @@ func TestMoveWhileFiltering(t *testing.T) {
if !d.m.secrets.Filtering() {
t.Fatal("ctrl+j/k left filter mode")
}
// down to the bottom, stay there, back up one
if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
// down to the bottom, stay there, back up to where it started
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial {
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen)
}
if !strings.Contains(d.screen(), "ctrl+j/n down") {
t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
@@ -761,9 +758,7 @@ func TestFocusKeys(t *testing.T) {
}{
{"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail},
{"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers},
{"4", paneDetail}, {"tab", paneSources}, {"shift+tab", paneDetail},
// l: what's selected, on the right; h: back to it.
{"2", paneContainers}, {"l", paneDetail}, {"h", paneContainers}, {"h", paneSources}, {"l", paneDetail},
{"4", paneDetail}, {"l", paneSources}, {"h", paneDetail},
} {
d.keys(tc.key)
if d.m.focus != tc.want {
@@ -848,7 +843,7 @@ func TestCopyAndRevealWhileFiltering(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.typeText("api-k")
d.typeText("api")
d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
if d.clip().content != "sk_live_51HxDEMO0000000000000000" {
t.Errorf("ctrl+y copied %q", d.clip().content)
@@ -991,14 +986,9 @@ 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(lastLine(scr), "z show") {
if !strings.Contains(lastLine(scr), "z reveal/hide") {
t.Errorf("help bar: %q", lastLine(scr))
}
d.keys("z")
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar)
}
d.keys("z")
if strings.Contains(scr, "to browse") {
t.Errorf("hint for unbound versions:\n%s", scr)
}
@@ -1114,16 +1104,25 @@ func TestAccountInDetails(t *testing.T) {
}
}
// grouped is an account in two groups.
type grouped struct{ described }
func (grouped) Groups(context.Context) ([]string, error) {
return []string{"Platform team", "Vault readers"}, nil
}
func TestAccountPane(t *testing.T) {
t.Parallel()
d := newDriver(t, described{fake.Azure(0)})
d.keys("g", "l") // the account's row, then its pane
d := newDriver(t, grouped{described{fake.Azure(0)}})
d.keys("g", "tab") // the account's row, then straight to its pane
if d.m.focus != paneDetail {
t.Fatalf("focus %v, want the fourth pane", d.m.focus)
}
scr := d.screen()
if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr)
for _, want := range []string{"Alice Example", "groups 2", "Platform team", "Vault readers"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
@@ -1133,20 +1132,28 @@ func TestAccountPane(t *testing.T) {
if got := d.clip().content; got != "oid-123" {
t.Errorf("y on the object ID copied %q", got)
}
d.keys("G", "y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y on a group copied %q", got)
}
// / filters it, labels too.
d.keys("/")
d.typeText("object")
d.typeText("vault")
d.keys("enter")
if scr := d.screen(); !strings.Contains(scr, "oid-123") || strings.Contains(scr, "Alice Example") {
if scr := d.screen(); !strings.Contains(scr, "Vault readers") || strings.Contains(scr, "Platform team") {
t.Errorf("filtered:\n%s", scr)
}
d.keys("y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y in the filtered pane copied %q", got)
}
d.keys("esc")
if !strings.Contains(d.screen(), "Alice Example") {
if !strings.Contains(d.screen(), "Platform team") {
t.Errorf("filter not cleared:\n%s", d.screen())
}
d.keys("h")
d.keys("shift+tab")
if d.m.focus != paneSources {
t.Errorf("back to %v, want Sources", d.m.focus)
}
@@ -1184,27 +1191,14 @@ func TestDetailsFollowFocus(t *testing.T) {
}
}
func TestStatusLineCountsRepos(t *testing.T) {
t.Parallel()
d := newDriver(t, fake.Azure(0), fake.Repos())
// The status line, not the pane, which an expired secret's ⚠ fills.
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 3 repos") {
t.Errorf("status line: %q", line)
}
d = newDriver(t, fake.Azure(0))
if line := strings.Split(d.screen(), "\n")[1]; strings.Contains(line, "repo") {
t.Errorf("repos counted without any: %q", line)
}
}
func TestStatusPane(t *testing.T) {
t.Parallel()
var opened []string
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
OpenURL: func(u string) error { opened = append(opened, u); return nil }},
fake.Azure(0), signedOut{fake.Azure(0)})
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "2 accounts · 1 need sign-in") {
t.Errorf("status line: %q", line)
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") {
t.Errorf("status line:\n%s", d.screen())
}
d.keys("0")
if d.m.focus != paneStatus {
@@ -1247,100 +1241,3 @@ func TestStatusPane(t *testing.T) {
t.Errorf("back to %v", d.m.focus)
}
}
// empty is an account with nothing in it, like Databricks without profiles.
type empty struct{ provider.Provider }
func (empty) Sources(context.Context) ([]provider.Source, error) { return nil, nil }
func TestGetStarted(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, signedOut{fake.Azure(0)}, empty{fake.Databricks(0)})
scr := d.screen()
for _, want := range []string{"get started", "Azure az login", "L runs it here",
"databricks auth login --host", "lazysecrets --demo"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
d.keys("l", "j")
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" {
t.Errorf("L signs in to %+v", a)
}
d.keys("y")
if got := d.clip().content; got != "az login" {
t.Errorf("y copied %q", got)
}
// Once an account has something, an empty one says what it needs.
d = newDriverOpts(t, Options{}, fake.Azure(0), empty{fake.Databricks(0)})
d.m.sources.SelectWhere(func(r sourceRow) bool { return r.kind == rowGroup && r.provider == fake.Databricks(0).ID() })
d.send(nil)
if scr := d.screen(); strings.Contains(scr, "get started") || !strings.Contains(scr, "databricks auth login") {
t.Errorf("empty Databricks:\n%s", scr)
}
}
// detailed is an account whose vaults have details.
type detailed struct{ provider.Provider }
func (detailed) Details(_ context.Context, c provider.Container) ([]provider.Attr, error) {
return []provider.Attr{{Label: "access", Value: "Azure roles (RBAC)"}, {Label: "git", Value: "tracked", Warn: true}}, nil
}
func TestContainerDetails(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, detailed{fake.Azure(0)})
d.keys("2")
if scr := d.screen(); !strings.Contains(scr, "access Azure roles (RBAC)") || !strings.Contains(scr, "URI") {
t.Errorf("details:\n%s", scr)
}
if st := d.m.details[d.m.containers.SelectedID()]; st.loading || len(st.attrs) != 2 {
t.Errorf("state %+v", st)
}
}
func TestExpiringInStatus(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
d.keys("3", "]", "]") // load kv-app-prod's certificates
// old-tls and legacy-token.
if !strings.Contains(d.screen(), "⚠ 2 expiring") {
t.Errorf("status line:\n%s", d.screen())
}
d.keys("0")
scr := d.screen()
for _, want := range []string{"expiring ✗ expired old-tls · kv-app-prod", "in 1 of 3 vaults (ctrl+a in search loads all)"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
d.keys("3", "[", "[", "0", "enter") // elsewhere, then to it from the status pane
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "expiring" })
d.keys("enter")
if _, v, o := d.selected(); v != "kv-app-prod" || o != "old-tls" {
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
}
}
func TestErrorLog(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, fake.Azure(0))
d.keys("2", "j", "j", "3") // kv-restricted: its secrets can't be listed
d.m.flash = flash{text: "copy failed: no clipboard", isErr: true}
d.send(nil)
d.send(nil) // still showing: logged once
d.keys("0")
scr := d.screen()
if !strings.Contains(scr, "errors") || !strings.Contains(scr, "copy failed: no clipboard") || !strings.Contains(scr, "kv-restricted: 403 Forbidden") {
t.Errorf("errors not listed:\n%s", scr)
}
if n := len(d.m.errLog); n != 2 || !strings.HasPrefix(d.m.errLog[0].text, "copy failed") {
t.Errorf("log %+v", d.m.errLog)
}
d.m.logErr("403 Forbidden: again") // a new one, and an old one again: on top
d.m.logErr(d.m.errLog[2].text)
if len(d.m.errLog) != 3 || d.m.errLog[0].text == "403 Forbidden: again" {
t.Errorf("log %+v", d.m.errLog)
}
}
-45
View File
@@ -1,12 +1,8 @@
package app
import (
"maps"
"slices"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -52,44 +48,3 @@ func (c cache) rememberCursor(parent, child string) {
c.cursor[parent] = child
}
}
// refreshShown shows the selected secret's newer metadata when its list
// reloaded with it changed from before (a cached list refreshed in the
// background), and describes it afresh: its cached details came from
// the old listing.
func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
s, ok := m.secrets.Selected()
if !ok || m.secrets.SelectedID() != m.selSecret {
return nil // a new selection is shown as usual
}
i := slices.IndexFunc(before, func(o provider.Object) bool { return o.ID == s.ID })
if i < 0 || sameListing(before[i], s) {
return nil
}
m.detail.Refresh(s)
uncache(m, m.cache.details, detailKey(s))
return m.describe(s)
}
// uncache deletes key from a cache, and, at the next save, from the file.
func uncache[T any](m *Model, c map[string]entry[T], key string) {
if _, ok := c[key]; !ok {
return
}
delete(c, key)
if m.cacheDropped == nil {
m.cacheDropped = map[string]bool{}
}
m.cacheDropped[key] = true
m.cacheDirty = true
}
// sameListing reports whether a and b list the same: what a list
// says of an object, not what describing it adds.
func sameListing(a, b provider.Object) bool {
enabled := func(e *bool) bool { return e == nil || *e }
return a.Name == b.Name && a.Version == b.Version && enabled(a.Enabled) == enabled(b.Enabled) &&
a.Updated.Equal(b.Updated) && a.Expires.Equal(b.Expires) && a.ContentType == b.ContentType &&
maps.Equal(a.Tags, b.Tags) && a.Reference == b.Reference && a.URL == b.URL &&
slices.Equal(a.Attrs, b.Attrs)
}
-31
View File
@@ -2,7 +2,6 @@ package app
import (
"context"
"slices"
"testing"
"time"
@@ -160,33 +159,3 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) {
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
}
}
func TestReloadedListRefreshesShownSecret(t *testing.T) {
t.Parallel()
d, _, clock := newCachingDriver(t)
d.keys("3") // kv-app-prod's secrets, db-password selected
shown, _ := d.m.detail.Secret()
// What an older lazysecrets listed and cached for it.
const old = "https://portal.example/old"
key := d.m.selContainer
e := d.m.cache.objects[key]
items := slices.Clone(e.value)
for i := range items {
if items[i].ID == shown.ID {
items[i].URL = old
}
}
d.m.cache.objects[key] = entry[[]provider.Object]{items, e.at}
stale := shown
stale.URL = old
d.m.cache.details[detailKey(shown)] = entry[provider.Object]{stale, *clock}
// Away and back once the list is stale: it's shown from the cache,
// then reloaded, and the details pane follows.
d.keys("1", "j")
*clock = clock.Add(cacheTTL + time.Second)
d.keys("k", "3")
if got, _ := d.m.detail.Secret(); got.ID != shown.ID || got.URL == old {
t.Errorf("shown %s with %q after the reload", got.Name, got.URL)
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
o := *obj
p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout.of(o.Container.Source.Kind)
timeout := m.timeout
m.usedObject(o)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
+1 -37
View File
@@ -3,7 +3,6 @@ package app
import (
"cmp"
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
@@ -14,10 +13,9 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// Opening a repo's file (e), or on the status pane the config: SOPS files with sops edit, which decrypts
// Opening a repo's file (e): SOPS files with sops edit, which decrypts
// them for the editor and encrypts them again after. Other files, inside
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or
// $EDITOR, coming back after; else in the system's default app. On a
@@ -26,7 +24,6 @@ import (
type editDoneMsg struct {
c provider.Container
name string
config bool // the config file: read at startup
err error
}
@@ -47,8 +44,6 @@ func (m *Model) edit() tea.Cmd {
var c provider.Container
var o *provider.Object
switch m.focus {
case paneStatus: // like lazygit's status panel
return m.editConfig()
case paneSources:
m.flash = warning("select a file to open it")
return nil
@@ -98,33 +93,6 @@ func (m *Model) edit() tea.Cmd {
return m.openInEditor(f, finished)
}
// configHeader starts a config file made to be edited.
const configHeader = "# lazysecrets config: " + update.DefaultRepo + "/src/branch/main/docs/config.md\n"
// editConfig opens the user's config file (the first of them), as e
// opens a file, making it if it isn't there yet: e on the status pane.
func (m *Model) editConfig() tea.Cmd {
if len(m.configFiles) == 0 {
m.flash = warning("no config file")
return nil
}
path := m.configFiles[0]
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
err := os.MkdirAll(filepath.Dir(path), 0o755)
if err == nil {
err = os.WriteFile(path, []byte(configHeader), 0o644)
}
if err != nil {
m.flash = flash{text: "edit config: " + err.Error(), isErr: true}
return nil
}
}
name := filepath.Base(path)
return m.openInEditor(provider.File{Path: path}, func(err error) tea.Msg {
return editDoneMsg{name: name, config: true, err: err}
})
}
// plusLine are editors that open at a line with +N before the file.
var plusLine = map[string]bool{
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
@@ -174,10 +142,6 @@ func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
return nil
}
if msg.config {
m.flash = done("closed " + msg.name + ": restart lazysecrets to use it")
return nil
}
m.flash = done("closed " + msg.name)
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
m.hideValue()
-64
View File
@@ -1,64 +0,0 @@
package app
import (
"errors"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The status pane lists this session's errors, newest first: what a pane
// showed (a list that couldn't load) and what the footer flashed (a copy
// that failed), which go when you move on. Only the first line of each,
// and never kept after quitting.
// maxErrors is how many errors are kept.
const maxErrors = 20
type loggedErr struct {
at time.Time
text string
}
// logErr records text; an error already there moves to the top, with
// the time it happened again.
func (m *Model) logErr(text string) {
text, _, _ = strings.Cut(strings.TrimSpace(text), "\n")
if text == "" {
return
}
m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text })
m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...)
}
// logErrOf records err, about what, except a sign-in, which the footer
// and the account's details say.
func (m *Model) logErrOf(what string, err error) {
var auth *provider.AuthError
if !errors.As(err, &auth) {
m.logErr(what + ": " + err.Error())
}
}
// logFlash records an error the footer is showing, once.
func (m *Model) logFlash() {
if m.flash.isErr && m.flash.text != m.loggedFlash {
m.logErr(m.flash.text)
}
m.loggedFlash = m.flash.text
}
// errorFields are the status pane's rows for them.
func (m Model) errorFields() []panes.Field {
out := make([]panes.Field, len(m.errLog))
for i, e := range m.errLog {
out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true}
}
if len(out) > 0 {
out[0].Label = "errors"
}
return out
}
-76
View File
@@ -1,76 +0,0 @@
package app
import (
"cmp"
"fmt"
"slices"
"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"
)
// The status pane lists what has expired or will soon, across every vault
// loaded (opened, searched, or from the cache): expiry dates come with
// the lists, so this makes no requests. enter on one goes to it.
type expiringItem struct {
o provider.Object
row string // its source's row key, to go to it
}
// expiringItems are the loaded objects that have expired or will within
// the warning period, soonest first.
func (m Model) expiringItems() []expiringItem {
var out []expiringItem
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
}
}
}
}
}
slices.SortFunc(out, func(a, b expiringItem) int {
return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name))
})
return out
}
// expiringFields are the status pane's rows for them, and how much of
// everything they cover.
func (m Model) expiringFields() []panes.Field {
items := m.expiringItems()
var out []panes.Field
for _, it := range items {
state, left := m.expiry(it.o)
mark := "⚠ " + daysLeft(left)
if state == expired {
mark = "✗ expired"
}
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()
}
out = append(out, f)
}
cov := m.coverageOf(ofKind(provider.KindAzure))
switch {
case cov.vaults == 0 && len(items) == 0:
return nil
case !cov.complete():
out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) +
withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true})
case len(items) == 0:
out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true})
}
out[0].Label = "expiring"
return out
}
+22 -8
View File
@@ -1,10 +1,9 @@
package app
import (
"fmt"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -12,18 +11,28 @@ 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
type expiryState int
const (
notExpiring = expiry.None
expiresSoon = expiry.Soon
expired = expiry.Expired
notExpiring expiryState = iota
expiresSoon
expired
)
// expiry tells whether o has expired or will within the warning period,
// and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
if o.Expires.IsZero() {
return notExpiring, 0
}
left := o.Expires.Sub(m.now())
switch {
case left <= 0:
return expired, 0
case left <= m.expiryWarning:
return expiresSoon, left
}
return notExpiring, left
}
// expiryWord is matched by the filter: "expired" or "expiring".
@@ -38,4 +47,9 @@ func (m Model) expiryWord(o provider.Object) string {
}
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { return expiry.Left(d) }
func daysLeft(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
+7 -21
View File
@@ -3,11 +3,9 @@ package app
import (
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -21,22 +19,6 @@ import (
const star = "★ "
// recentMark marks items used lately (Recent), with ui.recentIcon.
const recentMark = "◷ "
// marks is what goes before a list row's name: ★ when it's starred for
// account and, with recent, ◷ when it was used lately.
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
var s string
if n.StarredFor(account) {
s = st.Favourite.Render(star)
}
if recent && n.UsedLately(now) {
s += st.Muted.Render(recentMark)
}
return s
}
func sourceKey(s provider.Source) string {
if r, err := ref.ForSource(s); err == nil {
return r.String()
@@ -112,7 +94,9 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
}
line += sty.Muted.Render(" " + c.Detail)
}
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
if n.StarredFor(c.Source.Provider) {
line = sty.Favourite.Render(star) + line
}
if withSource {
src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
@@ -132,7 +116,9 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Enabled != nil && !*o.Enabled {
line = sty.Muted.Render(o.Name + " (disabled)")
}
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
if n.StarredFor(o.Container.Source.Provider) {
line = sty.Favourite.Render(star) + line
}
switch state, left := m.expiry(o); state {
case expired:
line += " " + sty.Error.Render("✗ expired")
@@ -181,7 +167,7 @@ func (m *Model) rebind(c provider.Container) string {
m.linked[i].Source = c.Source
}
}
key := cachefile.ObjectsKey(c, m.objType)
key := containerKey(c) + "|" + string(m.objType)
if m.onLinks() {
m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c))
+1 -19
View File
@@ -6,7 +6,6 @@ import (
"slices"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
@@ -395,7 +394,7 @@ func TestStarWhileFiltering(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
for _, r := range "api-k" {
for _, r := range "api" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
@@ -435,20 +434,3 @@ func TestStarIsPerAccount(t *testing.T) {
t.Errorf("favourites should list alice's star only: %q", labels)
}
}
func TestRecentIcon(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
_ = mem.Use("lazysecrets://azure/"+kv+"/secrets/api-key", time.Now(), func(*store.Node) {})
d := newDriverOpts(t, Options{Store: mem, RecentIcon: true})
d.keys("3")
scr := d.screen()
if !strings.Contains(scr, "◷ api-key") || strings.Contains(scr, "◷ db-password") {
t.Errorf("want ◷ on api-key only:\n%s", scr)
}
off := newDriverOpts(t, Options{Store: mem})
off.keys("3")
if strings.Contains(off.screen(), "◷") {
t.Errorf("◷ without ui.recentIcon:\n%s", off.screen())
}
}
+57 -66
View File
@@ -1,6 +1,7 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
@@ -8,27 +9,23 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The fourth pane shows Fields for anything but a secret. On a provider's
// row: its account, from what the provider knows locally.
// row: its account, from what the provider knows locally, then the
// groups the account is in, loaded once a session when first shown.
// A file's details beyond its listing load once a
// session, when the cursor rests on it, and again with refresh. They're
// extra: moving on cancels the load, and search never waits for them.
type detailsState struct {
attrs []provider.Attr
type groupsState struct {
names []string
err error
loading bool
}
type detailsMsg struct {
req int
key string
attrs []provider.Attr
type groupsMsg struct {
provider string
names []string
err error
}
@@ -44,6 +41,7 @@ func (m Model) detailOf() pane {
// syncFields shows the Fields pane for what the focused pane has selected,
// unless that's a secret (or a vault with the Secrets pane in focus), which
// the secret pane shows, as it does a revealed value whatever the focus.
// An account's groups load the first time.
func (m *Model) syncFields() tea.Cmd {
if m.detail.State() != panes.ValueHidden {
// A value revealed (or being fetched) from any pane stays in
@@ -51,32 +49,28 @@ func (m *Model) syncFields() tea.Cmd {
m.fields.Hide()
return nil
}
m.fields.Header, m.fields.HeaderShort = nil, ""
m.fields.Header = nil
switch m.detailOf() {
case paneSources:
r, ok := m.sources.Selected()
switch {
case m.nothingFound():
m.fields.Show("get started", m.getStartedFields())
case !ok:
m.fields.Show("", nil)
case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup:
m.showAccount(r)
return m.showAccount(r)
case r.kind == rowSource:
m.fields.Show(r.source.Name, m.sourceFields(r.source))
default:
m.fields.Show("", nil)
}
case paneStatus:
m.fields.Header, m.fields.HeaderShort = logo, strings.TrimSpace("lazysecrets "+m.versionLabel())
m.fields.Header = logo
m.fields.Show("lazysecrets", m.statusFields())
case paneContainers:
if c, ok := m.containers.Selected(); ok {
cmd := m.loadDetails(c)
m.fields.Show(c.Name, m.containerFields(c))
return cmd
} else {
m.fields.Show("", nil)
}
@@ -86,8 +80,9 @@ func (m *Model) syncFields() tea.Cmd {
return nil
}
// showAccount shows a provider's account.
func (m *Model) showAccount(r sourceRow) {
// showAccount shows a provider's account, loading its groups the first
// time.
func (m *Model) showAccount(r sourceRow) tea.Cmd {
p := m.byID[r.provider]
var fields []panes.Field
if a, ok := p.(provider.Accounts); ok {
@@ -97,11 +92,24 @@ func (m *Model) showAccount(r sourceRow) {
}
}
}
if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 {
hint := emptyHint(p.Kind())
fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL})
var cmd tea.Cmd
if g, ok := p.(provider.AccountGroups); ok {
st, seen := m.groups[r.provider]
if !seen {
st = groupsState{loading: true}
m.groups[r.provider] = st
id, timeout := r.provider, m.timeout
cmd = func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
names, err := g.Groups(ctx)
return groupsMsg{provider: id, names: names, err: err}
}
}
fields = append(fields, groupFields(st)...)
}
m.fields.Show(p.Label(), fields)
return cmd
}
// sourceFields describe a subscription, workspace or repo.
@@ -161,52 +169,13 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL)
for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
}
}
for _, a := range c.Attrs {
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
}
switch st := m.details[containerKey(c)]; {
case st.loading:
out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true})
case errors.Is(st.err, provider.ErrUnsupported):
case st.err != nil:
out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true})
}
for _, a := range m.details[containerKey(c)].attrs {
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
}
return out
}
// loadDetails loads c's details, if its provider has them and they
// aren't loaded yet.
func (m *Model) loadDetails(c provider.Container) tea.Cmd {
d, ok := m.providerFor(c.Source).(provider.ContainerDetails)
key := containerKey(c)
if _, seen := m.details[key]; !ok || seen {
return nil
}
// One at a time: the previous one, if it's still loading, is
// cancelled and forgotten, to load when it's shown again.
for k, st := range m.details {
if st.loading {
delete(m.details, k)
}
}
m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails, c.Source.Kind)
return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c)
if ctx.Err() != nil {
err = ctx.Err()
}
return detailsMsg{req: req, key: key, attrs: attrs, err: err}
})
}
// problemFields show why an account has nothing to show, wrapped to the
// pane, in red.
func (m Model) problemFields(problem string) []panes.Field {
@@ -218,8 +187,30 @@ func (m Model) problemFields(problem string) []panes.Field {
return out
}
// fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value.
// fieldName names a copied field: its label, or for a group, the group.
func fieldName(f panes.Field) string {
return cmpOr(f.Label, f.Value)
if f.Label == "" || strings.HasPrefix(f.Label, "groups") {
return "group " + f.Value
}
return f.Label
}
// groupFields are the groups' rows: labelled on the first.
func groupFields(st groupsState) []panes.Field {
switch {
case st.loading:
return []panes.Field{{Label: "groups", Value: "loading…", Info: true}}
case errors.Is(st.err, provider.ErrUnsupported):
return nil
case st.err != nil:
return []panes.Field{{Label: "groups", Value: "couldn't list them: " + st.err.Error(), Info: true}}
case len(st.names) == 0:
return []panes.Field{{Label: "groups", Value: "none", Info: true}}
}
out := make([]panes.Field, len(st.names))
for i, n := range st.names {
out[i] = panes.Field{Value: n}
}
out[0].Label = fmt.Sprintf("groups %d", len(st.names))
return out
}
-66
View File
@@ -1,66 +0,0 @@
package app
import (
"errors"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// When no account has anything to show, as on a first run, the right
// pane says how to get started: the sign-in for each kind of account
// (y copies it, L runs az login), and the demo.
// nothingFound reports whether every account has loaded, with nothing.
func (m Model) nothingFound() bool {
if m.sourcesPending > 0 {
return false
}
for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 {
return false
}
}
return true
}
// getStartedFields are the rows saying how to get started.
func (m Model) getStartedFields() []panes.Field {
out := []panes.Field{{Value: "Nothing to show yet: sign in where your secrets are.", Info: true}}
seen := map[provider.Kind]bool{}
for _, p := range m.providers {
if seen[p.Kind()] {
continue
}
seen[p.Kind()] = true
f := emptyHint(p.Kind())
var auth *provider.AuthError
switch err := m.sourceResults[p.ID()].err; {
case errors.As(err, &auth) && len(auth.Login) > 0:
f.Provider = p.ID() // L signs in
out = append(out, f, panes.Field{Value: withKey(m.keys.Login, "%s runs it here"), Info: true})
case err != nil:
out = append(out, f, panes.Field{Value: err.Error(), Info: true, Err: true})
default:
out = append(out, f)
}
}
return append(out,
panes.Field{Label: "try", Value: "lazysecrets --demo"},
panes.Field{Value: "built-in sample data, to look around", Info: true},
panes.Field{Label: "docs", Value: "getting started", URL: update.DefaultRepo + "#get-started"},
)
}
// emptyHint is how an account of kind gets something to show.
func emptyHint(kind provider.Kind) panes.Field {
switch kind {
case provider.KindDatabricks:
return panes.Field{Label: "Databricks", Value: "databricks auth login --host https://<workspace>"}
case provider.KindRepo:
return panes.Field{Label: "repos", Value: "SOPS and .env files in your git repos",
URL: update.DefaultRepo + "/src/branch/main/docs/repos.md"}
}
return panes.Field{Label: string(kind), Value: "az login"}
}
+4 -13
View File
@@ -38,10 +38,6 @@ 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
// 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 {
@@ -54,8 +50,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
switch {
case c.focus == paneStatus:
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details"))
config := key.NewBinding(key.WithKeys(k.Edit.Keys()...), key.WithHelp(k.Edit.Help().Key, "edit config"))
return append(b, details, config, k.Update, k.Help, k.Quit)
return append(b, details, k.Update, k.Help, k.Quit)
case c.focus == paneDetail && c.account && c.of == paneStatus:
open := key.NewBinding(key.WithKeys(k.Open.Keys()...), key.WithHelp(k.Open.Help().Key, "open link"))
return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
@@ -64,15 +59,11 @@ func (c contextHelp) ShortHelp() []key.Binding {
}
switch c.focus {
case paneSources:
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter)
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter)
case paneContainers:
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter)
default:
reveal := k.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)
b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter)
}
if c.commands {
b = append(b, k.Commands)
+4 -29
View File
@@ -47,7 +47,6 @@ func (m *Model) storeEntries() (favs, recent []option) {
out = append(out, entry{depth, sort, option{
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 {
m.used(key)
return run(m)
@@ -107,7 +106,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
if m.history {
var used []string
for _, k := range keys {
if nodes[k].UsedLately(m.now()) {
if !nodes[k].LastUsed.IsZero() {
used = append(used, k)
}
}
@@ -139,30 +138,6 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
return label
}
// 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 {
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 c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
return true
}
}
}
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...), "")
}
// sourceMatches reports whether src is the subscription or workspace r
// links to.
func sourceMatches(r ref.Ref, src provider.Source) bool {
@@ -172,8 +147,8 @@ func sourceMatches(r ref.Ref, src provider.Source) bool {
return src.ID == r.Subscription
}
// accountOf is the account part of a source's provider label ("alice" in
// "Azure · alice"), or "" when there's a single account.
// accountOf is the account part of a source's provider label ("dev" in
// "Azure · dev"), or "" when there's a single account.
func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok {
@@ -217,7 +192,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
case 1:
m.startNav(r, actGo, choices[0].sourceRow)
default:
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices, false)
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
}
return nil
}
+2 -2
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) {
t.Parallel()
d := newDriver(t)
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share…") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space reveal") {
t.Errorf("Sources help bar: %q", bar)
}
d.keys("3")
if bar := lastLine(d.screen()); !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)
}
}
+6 -86
View File
@@ -46,9 +46,6 @@ type nav struct {
// quiet gives up without a word: going back to where lazysecrets
// was left, which may be gone.
quiet bool
// link ends on the object's details, as enter on it would: opening
// a link to it opens it, rather than just finding it in its list.
link bool
}
type resolvedMsg struct {
@@ -56,9 +53,6 @@ type resolvedMsg struct {
act linkAction
sourceRow string
others []string // other accounts' rows that also found the vault
// find ends in the list, like Go to, not on the details: opened by a
// tool that found it (lazysecrets open --action, from fzf).
find bool
choices []choice // several candidates: ask
linked *provider.Container
err error
@@ -84,9 +78,6 @@ const (
// just open it.
// With ask false, vault and scope links open straight away.
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
if r.IsShare() {
return m.offerShare(r)
}
if r.IsContainer() || r.IsSource() {
if !ask {
return m.openRef(r, actGo)
@@ -161,7 +152,7 @@ func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
return nil
}
ctx, req := m.begin(slotCopy, r.Kind)
ctx, req := m.begin(slotCopy)
m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg {
var errs []error
@@ -221,9 +212,6 @@ func (m *Model) openSource(r ref.Ref) {
}
}
}
if row, ok := preferredRow(r, choices); ok {
choices = []choice{{sourceRow: row}}
}
switch len(choices) {
case 0:
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
@@ -410,71 +398,23 @@ func (m *Model) handleResolved(msg resolvedMsg) {
for i := range msg.choices {
msg.choices[i].label = "Open in " + msg.choices[i].label
}
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, !msg.find)
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices)
case len(msg.others) > 0:
var choices []choice
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
if row, ok := preferredRow(msg.ref, choices); ok {
m.navResolved(msg, row)
return
}
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, !msg.find)
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices)
case msg.linked != nil:
m.addLinked(*msg.linked)
m.navResolved(msg, linksRowID)
m.startNav(msg.ref, msg.act, linksRowID)
default:
m.navResolved(msg, msg.sourceRow)
m.startNav(msg.ref, msg.act, msg.sourceRow)
}
}
// preferredRow is the choice under the account the reference asks for
// (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices {
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
return c.sourceRow, true
}
}
return "", false
}
// navResolved goes to what a resolved link points at, under row.
func (m *Model) navResolved(msg resolvedMsg, row string) {
if msg.find {
m.startNav(msg.ref, msg.act, row)
return
}
m.startLinkNav(msg.ref, msg.act, row)
}
// findRef is openRef ending in the list, like Go to: for a link a tool
// found (lazysecrets open --action).
func (m *Model) findRef(r ref.Ref, act linkAction) tea.Cmd {
cmd := m.openRef(r, act)
if cmd == nil {
return nil
}
return func() tea.Msg {
msg := cmd()
if rm, ok := msg.(resolvedMsg); ok {
rm.find = true
return rm
}
return msg
}
}
// startLinkNav opens a link: like startNav, ending on its object's
// details.
func (m *Model) startLinkNav(r ref.Ref, act linkAction, sourceRow string) {
m.startNav(r, act, sourceRow)
m.nav.link = true
}
// chooseSource asks where to open r.
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice, link bool) {
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) {
var opts []option
for i, c := range choices {
key := ""
@@ -484,7 +424,6 @@ func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []
row := c.sourceRow
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
m.startNav(r, act, row)
m.nav.link = link
return nil
}})
}
@@ -635,9 +574,6 @@ func (m *Model) advanceNav() bool {
m.pendingCmd = m.toggleReveal()
}
}
if n.link {
m.focus = paneDetail // after: copying acts on the focused pane
}
return true
}
return false
@@ -671,9 +607,6 @@ func (m *Model) share(menu bool) tea.Cmd {
if s.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
}
if m.canShareFiles(s) {
opts = append(opts, option{key: "f", label: "Encrypted link to its env files", run: func(m *Model) tea.Cmd { return m.shareFiles(s, nil) }})
}
m.showMenu("Share "+s.Name, nil, opts)
case paneContainers:
c, ok := m.containers.Selected()
@@ -696,11 +629,6 @@ func (m *Model) share(menu bool) tea.Cmd {
if c.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
}
if c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
opts = append(opts, option{key: "f", label: "Encrypted link to the file", run: func(m *Model) tea.Cmd {
return m.shareFiles(c.Source, []provider.Container{c})
}})
}
m.showMenu("Share "+c.Name, nil, opts)
default:
o, ok := m.detail.Secret()
@@ -725,11 +653,6 @@ func (m *Model) share(menu bool) tea.Cmd {
if o.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
}
if c := o.Container; c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
opts = append(opts, option{key: "f", label: "Encrypted link to its file", run: func(m *Model) tea.Cmd {
return m.shareFiles(c.Source, []provider.Container{c})
}})
}
m.showMenu("Share "+o.Name, nil, opts)
}
return nil
@@ -879,9 +802,6 @@ func (m *Model) openText(s string) tea.Cmd {
// handlePaste opens a pasted reference, unless a text input has focus.
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
if m.search != nil && m.search.picker != nil {
return m.pickPasted(msg.Content) // a reference to the secret
}
if p := m.prompt; p != nil && p.input != nil {
in, cmd := p.input.Update(msg)
p.input = &in
+4 -32
View File
@@ -40,8 +40,7 @@ func TestOpenSecretLink(t *testing.T) {
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Fatalf("opened %s/%s/%s", src, c, s)
}
// Opened: on its details, as enter on it would; its value hidden.
if d.m.focus != paneDetail || d.m.detail.State() != panes.ValueHidden {
if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden {
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
}
}
@@ -181,7 +180,7 @@ func TestPasteWhileFilteringFilters(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.send(tea.PasteMsg{Content: "api-k"})
d.send(tea.PasteMsg{Content: "api"})
if d.m.secrets.Len() != 1 || d.m.nav != nil {
t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
}
@@ -203,21 +202,8 @@ func TestOpenPromptAndStartupOpen(t *testing.T) {
t.Fatal("open at startup didn't ask what to do")
}
d2.keys("o")
if _, _, s := d2.selected(); s != "api-key" || d2.m.focus != paneDetail {
t.Errorf("open at startup: selected %q, focus %v, want the details", s, d2.m.focus)
}
// --action: no question, it does that.
d3 := newDriverOpts(t, Options{Open: &r, OpenAction: "go"})
if d3.m.prompt != nil {
t.Fatalf("--action go asked: %q", d3.m.prompt.title)
}
if _, _, s := d3.selected(); s != "api-key" || d3.m.focus != paneSecrets {
t.Errorf("--action go: selected %q, focus %v, want the secrets list, like Go to", s, d3.m.focus)
}
d4 := newDriverOpts(t, Options{Open: &r, OpenAction: "copy"})
if _, _, s := d4.selected(); s != "api-key" || d4.clip().content == "" {
t.Errorf("--action copy: selected %q, copied %q", s, d4.clip().content)
if _, _, s := d2.selected(); s != "api-key" {
t.Errorf("open at startup: selected %q", s)
}
}
@@ -318,20 +304,6 @@ func TestOpenAsksWhichAccount(t *testing.T) {
t.Errorf("j enter picked %q", c.Source.Provider)
}
// lazysecrets open --account: no question, that account.
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
r.Account = "Azure:bob@example.com/t1"
d.send(OpenLink(r))
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
d.keys("o")
}
if strings.Contains(d.screen(), "Several accounts can see") {
t.Fatalf("asked, with an account given:\n%s", d.screen())
}
if c, _ := d.m.containers.Selected(); c.Source.Provider != r.Account {
t.Errorf("--account opened via %q", c.Source.Provider)
}
// Opening into a folded account unfolds it.
d.keys("1", "g") // alice's header
d.keys("enter") // fold it
+2 -18
View File
@@ -1,11 +1,9 @@
package app
import (
"cmp"
"fmt"
"slices"
"strings"
"unicode"
"unicode/utf8"
"charm.land/bubbles/v2/key"
@@ -42,7 +40,6 @@ type option struct {
group string // the source row it's in (search)
kind provider.Kind // the kind of provider it's from (search)
hits []int // characters of the label the filter matched (Go to)
plain bool // a key in a plain env file: no key to encrypt with (Go to)
run func(m *Model) tea.Cmd
// header makes this a section title: not selectable, and hidden
// while a filter narrows the list.
@@ -56,13 +53,9 @@ type modal struct {
options []option
cursor int
// A text input instead of options; submit runs on enter (named
// submitLabel in the footer; "open" if empty). err says why the last
// one wasn't taken, under the input.
// A text input instead of options; submit runs on enter.
input *textinput.Model
submit func(m *Model, text string) tea.Cmd
submitLabel string
err string
// A filter over the options: typing narrows them (all holds them
// all), so moving uses arrows or ctrl+j/k.
@@ -266,10 +259,7 @@ func (m Model) modalView() string {
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
if p.input != nil {
body = append(body, " "+p.input.View())
if p.err != "" {
body = append(body, "", " "+m.st.Error.Render("✗ "+capitalize(p.err)))
}
foot = footer(hint(km.PopupSelect, cmp.Or(p.submitLabel, "open")), hint(km.PopupClose, "cancel"))
foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel"))
}
// Size from all options, not the ones shown or the cursor's, so the
@@ -416,9 +406,3 @@ func (m Model) overlay(base string) string {
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
).Render()
}
// capitalize upper-cases the first letter of a message.
func capitalize(s string) string {
r, n := utf8.DecodeRuneInString(s)
return string(unicode.ToUpper(r)) + s[n:]
}
+80 -24
View File
@@ -1,12 +1,15 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -21,28 +24,58 @@ import (
// And where lazysecrets was left, per folder it ran in, goes in the store,
// to go back there on the next start.
// cacheFileVersion is the file's format; a file of another version is
// ignored, and replaced on the next save.
const cacheFileVersion = 1
// cacheMaxAge drops what hasn't been loaded again for this long.
const cacheMaxAge = 30 * 24 * time.Hour
// persistEvery is how often a changed cache is saved while running, so
// little is lost if lazysecrets is killed rather than quit.
const persistEvery = 30 * time.Second
type cacheFile struct {
Version int `json:"version"`
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"`
}
type stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
type persistTickMsg struct{}
type persistedMsg struct{ err error }
// 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) }
func (m *Model) keepCached(key string) bool {
id, _ := splitSourceRow(key)
p, ok := m.byID[id]
return ok && p.Kind() != provider.KindRepo
}
// loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() {
f := cachefile.Read(m.cachePath)
b, err := os.ReadFile(m.cachePath)
if err != nil {
return
}
var f cacheFile
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
return
}
now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
for id, e := range f.Sources {
if m.keepProvider(id) && recent(e.At) {
if m.keepCached(id+"|") && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At
}
@@ -70,27 +103,33 @@ func (m *Model) loadCacheFile() {
}
// cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New()
f.Dropped, m.cacheDropped = m.cacheDropped, nil
func (m *Model) cacheSnapshot() cacheFile {
f := cacheFile{
Version: cacheFileVersion,
Sources: map[string]stamped[[]provider.Source]{},
Containers: map[string]stamped[[]provider.Container]{},
Objects: map[string]stamped[[]provider.Object]{},
Details: map[string]stamped[provider.Object]{},
Cursor: map[string]string{},
}
for id, r := range m.sourceResults {
if r.err == nil && m.keepProvider(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
if r.err == nil && m.keepCached(id+"|") {
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
}
}
for k, e := range m.cache.containers {
if m.keepCached(k) {
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
}
}
for k, e := range m.cache.objects {
if m.keepCached(k) {
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
}
}
for k, e := range m.cache.details {
if m.keepCached(k) {
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
f.Details[k] = stamped[provider.Object]{e.at, e.value}
}
}
for k, v := range m.cache.cursor {
@@ -101,11 +140,28 @@ func (m *Model) cacheSnapshot() cachefile.File {
return f
}
// saveCache saves f, merged with the file (lazysecrets list may have
// loaded more since), from any goroutine.
func (m *Model) saveCache(f cachefile.File) func() error {
path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
return func() error { return cachefile.Save(path, f, keep, now) }
// writeCacheFile writes f atomically, readable by the user only.
func writeCacheFile(path string, f cacheFile) error {
b, err := json.Marshal(f)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// persistTick saves the cache, and where lazysecrets is, in the
@@ -114,8 +170,8 @@ func (m *Model) persistTick() tea.Cmd {
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
if m.cacheDirty && m.cachePath != "" {
m.cacheDirty = false
save := m.saveCache(m.cacheSnapshot())
cmds = append(cmds, func() tea.Msg { return persistedMsg{save()} })
f, path := m.cacheSnapshot(), m.cachePath
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
}
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
m.savedPlace = p.Link
@@ -130,7 +186,7 @@ func (m *Model) persistTick() tea.Cmd {
func (m Model) Persist() error {
var errs []error
if m.cachePath != "" {
errs = append(errs, m.saveCache(m.cacheSnapshot())())
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
}
if m.placeDir != "" {
if p, ok := m.place(); ok {
@@ -188,6 +244,6 @@ func (m *Model) restorePlace() {
func (m *Model) handlePersisted(msg persistedMsg) {
if msg.err != nil {
m.logErrOf("saving the cache or place", msg.err)
m.noteErr(fmt.Errorf("saving the cache or place: %w", msg.err))
}
}
+8 -37
View File
@@ -1,14 +1,12 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
@@ -41,9 +39,6 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
// Next run, with a backend too slow to answer during the test: the
// lists show from the file, and the cursor is where it was.
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
if strings.Contains(d2.screen(), "cached") {
t.Errorf("a fresh cache said its age:\n%s", d2.screen())
}
d2.keys("2")
if _, v, _ := d2.selected(); v != vault {
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
@@ -54,44 +49,20 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
}
}
// Lists from an older cache say how old they are while they reload.
func TestCacheFileSaysItsAge(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
d.keys("3")
at := time.Now().Add(-3 * time.Hour)
for k, e := range d.m.cache.containers {
d.m.cache.containers[k] = entry[[]provider.Container]{e.value, at}
}
for id := range d.m.sourcesAt {
d.m.sourcesAt[id] = at
}
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
d2.keys("2")
if n := strings.Count(d2.screen(), "cached 3h ago"); n != 2 {
t.Errorf("Sources and Vaults should say they're from 3h ago (%d do):\n%s", n, d2.screen())
}
}
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
az := fake.Azure(0)
now := time.Now()
f := cachefile.File{
Version: cachefile.Version,
Containers: map[string]cachefile.Stamped[[]provider.Container]{
az.ID() + "|recent": {At: now.Add(-time.Hour), Value: []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {At: now.Add(-cachefile.MaxAge - time.Hour), Value: []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {At: now, Value: []provider.Container{{Name: "kv-gone"}}},
f := cacheFile{
Version: cacheFileVersion,
Containers: map[string]stamped[[]provider.Container]{
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
},
}
b, _ := json.Marshal(f) // as an older lazysecrets left it
if err := os.WriteFile(file, b, 0o600); err != nil {
if err := writeCacheFile(file, f); err != nil {
t.Fatal(err)
}
d := newDriverOpts(t, Options{CacheFile: file}, az)
@@ -100,7 +71,7 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Error("dropped a recent entry")
}
if _, ok := c[az.ID()+"|old"]; ok {
t.Error("kept an entry older than cachefile.MaxAge")
t.Error("kept an entry older than cacheMaxAge")
}
if _, ok := c["Azure:gone@x/t|recent"]; ok {
t.Error("kept an entry of an account that isn't signed in")
+3 -53
View File
@@ -206,9 +206,9 @@ func TestSearchAllRepos(t *testing.T) {
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for d.m.search.scope().id != accountScope+"Repos" {
for d.m.search.scope().id != kindScope+string(provider.KindRepo) {
if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatalf("no Repos scope: %v", d.m.search.scopes)
t.Fatalf("no All repos scope: %v", d.m.search.scopes)
}
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
@@ -235,7 +235,7 @@ func TestSearchAllRepos(t *testing.T) {
if azure() != azureBefore || d.m.crawl.running {
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
}
if !strings.Contains(d.screen(), "Results in Repos") {
if !strings.Contains(d.screen(), "Results in all repos") {
t.Errorf("title:\n%s", d.screen())
}
}
@@ -342,53 +342,3 @@ func TestEditorAtLine(t *testing.T) {
}
}
}
func TestEditConfig(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "lazysecrets", "config.yaml")
var opened []string
d := newDriverOpts(t, Options{ConfigFiles: []string{path}, OpenURL: func(u string) error { opened = append(opened, u); return nil }})
d.m.getenv = func(string) string { return "" }
// e on the status pane. No editor: the system's default app, on a
// config made for it.
d.keys("0", "e")
if !slices.Equal(opened, []string{path}) {
t.Errorf("opened %q", opened)
}
if b, err := os.ReadFile(path); err != nil || !strings.HasPrefix(string(b), "# lazysecrets config") {
t.Errorf("config made: %q, %v", b, err)
}
// Inside nvim: in that nvim; an existing config is left as it is.
os.WriteFile(path, []byte("icons: false\n"), 0o644)
d.m.getenv = func(k string) string { return map[string]string{"NVIM": "/tmp/nvim.sock"}[k] }
var nvim []string
d.m.nvimOpen = func(server, path string, line int) error {
nvim = append(nvim, server+" "+path)
return nil
}
if msg := d.m.editConfig()(); msg != (tea.QuitMsg{}) {
t.Errorf("didn't quit for nvim: %v", msg)
}
if want := []string{"/tmp/nvim.sock " + path}; !slices.Equal(nvim, want) {
t.Errorf("nvim got %q", nvim)
}
if b, _ := os.ReadFile(path); string(b) != "icons: false\n" {
t.Errorf("config changed: %q", b)
}
d.send(editDoneMsg{name: "config.yaml", config: true})
if !strings.Contains(d.m.flash.text, "restart") {
t.Errorf("flash %q", d.m.flash.text)
}
}
func TestStatusFooterEditsConfig(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("0")
if !strings.Contains(d.screen(), "e edit config") {
t.Errorf("no edit config in the footer:\n%s", d.screen())
}
}
+1 -2
View File
@@ -5,14 +5,13 @@ import (
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// containerKey identifies a container in the UI and caches. The same vault
// seen through two accounts must not share cached lists, since each
// account may see different things.
func containerKey(c provider.Container) string { return cachefile.ContainerKey(c) }
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
// providerFor finds the provider instance a source came from. Sources
// remembered from an earlier run (favourites) may name an account that
+17 -33
View File
@@ -10,7 +10,6 @@ import (
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"
)
@@ -62,14 +61,13 @@ 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, 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) {
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), 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
}
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
@@ -90,7 +88,7 @@ func (m *Model) searchEntries() (sources, items []option) {
return nil
}, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
r, err := ref.ForObject(o, "")
if err != nil {
continue
@@ -100,11 +98,10 @@ func (m *Model) searchEntries() (sources, items []option) {
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, objectKey(o), " ", func(m *Model) tea.Cmd {
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
}
}
}
@@ -134,25 +131,13 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
}
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
func (m *Model) searchCoverage() coverage { return m.coverageOf("") }
// providerFilter picks the providers a search or a load covers; nil is
// all of them.
type providerFilter func(provider.Provider) bool
func ofKind(k provider.Kind) providerFilter {
return func(p provider.Provider) bool { return p.Kind() == k }
}
func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id }
}
// coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage {
// coverageOf is searchCoverage for one kind of provider ("" for all).
func (m *Model) coverageOf(kind provider.Kind) coverage {
var c coverage
for _, p := range m.providers {
if in != nil && !in(p) {
if kind != "" && p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
@@ -167,7 +152,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
}
for _, v := range e.value {
c.vaults++
key := cachefile.ObjectsKey(v, provider.TypeSecret)
key := containerKey(v) + "|" + string(provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++
c.secrets += len(objs.value)
@@ -212,12 +197,12 @@ func plural(n int, word string) string {
// 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) }
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
// startCrawlOf is startCrawl for one kind of provider ("" for all).
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd {
for _, p := range m.providers {
if in != nil && !in(p) {
if kind != "" && p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
@@ -264,7 +249,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
// queueContainer queues loading c's secrets, unless loaded lately and not
// force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return
}
@@ -296,10 +281,10 @@ func (m *Model) crawlNext() tea.Cmd {
}
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
timeout := m.timeout
if job.src != nil {
p := m.providerFor(*job.src)
src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
@@ -312,7 +297,6 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
}
c := *job.container
p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
@@ -328,7 +312,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active--
key := msg.job.row
if msg.job.container != nil {
key = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
}
delete(m.crawl.pending, key)
switch {
@@ -366,7 +350,7 @@ func (m *Model) sourceMark(row string) string {
}
loaded, loading := 0, false
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
@@ -387,7 +371,7 @@ func (m *Model) sourceMark(row string) string {
// (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
switch {
case m.crawl.pending[key]:
return m.st.Muted.Render("◌")
+14 -76
View File
@@ -42,15 +42,11 @@ func TestSearchLoaded(t *testing.T) {
t.Fatalf("missing %q:\n%s", want, d.screen())
}
}
d.typeText("api-key") // on All: found at once
if !strings.Contains(d.screen(), "ctrl+s only platform-dev") {
t.Errorf("no hint to narrow to the source:\n%s", d.screen())
}
d.send(ctrl('s'))
if !strings.Contains(d.screen(), "no matches here · All searches everything") || !strings.Contains(d.screen(), "ctrl+s searches all") {
d.typeText("api-key")
if !strings.Contains(d.screen(), "no matches here · All searches everything") {
t.Errorf("no hint to search everything:\n%s", d.screen())
}
d.send(ctrl('s'))
d.searchAll()
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
t.Fatalf("loaded secret not found:\n%s", d.screen())
}
@@ -66,8 +62,7 @@ func TestSearchLoaded(t *testing.T) {
func TestSearchLoadsAsYouType(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.send(ctrl('s')) // only the source the cursor is on: platform-prod
d.openSearch() // on the source the cursor is on: platform-prod
// Typing loads the scope: that source, without ctrl+a.
d.typeText("smtp")
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
@@ -81,7 +76,10 @@ func TestSearchLoadsAsYouType(t *testing.T) {
}
// On All: everything.
d.send(ctrl('s'))
d.keys("tab") // to the scopes
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
d.typeText("-")
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
@@ -185,61 +183,19 @@ func TestSearchStartsWhereYouAre(t *testing.T) {
d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch()
v := d.m.search
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
if scope() != "All" {
t.Errorf("opened on %q, want All", scope())
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") {
t.Errorf("source not preselected: %q", got)
}
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
if !strings.Contains(scope(), "platform-prod") {
t.Errorf("ctrl+s narrowed to %q", scope())
}
d.send(ctrl('s'))
if scope() != "All" {
t.Errorf("second ctrl+s on %q, want All", scope())
}
// From Favourites: to Search, on All.
d.keys("esc", "]", "]") // stop typing; Favourites
d.send(ctrl('s'))
if v.tab != tabSearch || scope() != "All" {
t.Errorf("ctrl+s from another tab: tab %d, %q", v.tab, scope())
}
// From the Sources pane: the source.
d.keys("esc", "1", "j") // close; platform-dev
// From the Sources pane: just the source.
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev
d.openSearch()
v = d.m.search
d.send(ctrl('s'))
if !strings.Contains(scope(), "platform-dev") {
t.Errorf("ctrl+s narrowed to %q", scope())
}
}
func TestSearchNarrowsToAccount(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("1", "g") // the first group header
header, _ := d.m.sources.Selected()
d.openSearch()
d.send(ctrl('s'))
v := d.m.search
if v.scope().id != accountScope+header.provider {
t.Fatalf("ctrl+s on %q narrowed to %q", header.label, v.scope().id)
}
if len(v.shown) == 0 || !strings.Contains(d.screen(), "Results in "+header.label) {
t.Errorf("no results in the account:\n%s", d.screen())
}
for _, o := range v.shown {
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 == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label))
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") {
t.Errorf("source not preselected: %q", got)
}
}
@@ -283,24 +239,6 @@ func TestGoToTabsAndModes(t *testing.T) {
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
}
// g and G: top and bottom of the list with the cursor.
d.keys("G")
if v.res != len(v.shown)-1 || v.res == 0 {
t.Errorf("G on results: %d of %d", v.res, len(v.shown))
}
d.keys("g")
if v.res != 0 {
t.Errorf("g on results: %d", v.res)
}
d.keys("h", "G")
if v.src != len(v.scopes)-1 {
t.Errorf("G on scopes: %d of %d", v.src, len(v.scopes))
}
d.keys("g", "l")
if v.src != 0 {
t.Errorf("g on scopes: %d", v.src)
}
d.keys("/")
d.typeText("shared")
if !v.typing || len(v.shown) == 0 {
+76 -249
View File
@@ -12,27 +12,24 @@ import (
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// Go to is one popup for finding a place and going there. Tabs at the
// top: Search has two lists side by side, the scope on the left (All, or
// one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it
// on the right; Favourites and Recent (from the store, so they work
// before anything loads) are one list each.
// top: Favourites and Recent (from the store, so they work before
// anything loads) are one list each; Search has two lists side by side,
// the scope on the left (All, or one source with how much of it is
// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right.
//
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
// star, / to type) or typing a filter (ctrl+ letters). esc while typing
// clears the text and goes back to browsing; esc while browsing closes.
// F opens it on Favourites and R on Recent, browsing; search on Search,
// typing, on All. search again narrows to the source you were in, and
// back to All.
// typing.
//
// The results don't move by themselves: as lists load, rows already shown
// keep their order and new ones come after them, with the cursor on the
@@ -42,7 +39,7 @@ import (
type searchView struct {
input textinput.Model
typing bool // the text field has the keyboard
tab int // tabSearch, tabFavs or tabRecent
tab int // tabFavs, tabRecent or tabSearch
results bool // in Search, the results have the cursor (else the scopes)
scopes []option // All, then the sources (Search)
@@ -52,10 +49,6 @@ type searchView struct {
shown []option // the tab's (or scope's) entries matching the input
src int // cursor in scopes
res int // cursor in shown
here string // the scope of the source Go to was opened from, if any
// picker, if set, makes Go to pick a secret instead of going to it.
picker *picker
// loaded are the scopes a search has loaded since the popup opened:
// typing loads the scope once, so lists that fail aren't asked for
@@ -64,25 +57,25 @@ type searchView struct {
}
const (
tabSearch = iota
tabFavs
tabFavs = iota
tabRecent
tabSearch
tabCount
)
var tabNames = [tabCount]string{"Search", "Favourites", "Recent"}
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"}
const (
scopeFavs = "#favs"
scopeRecent = "#recent"
allSources = "#all"
accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos
kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos
)
const goToTitle = "Go to"
// showSearch opens Go to on Search, typing, on All, with the selected
// vault under the cursor.
// showSearch opens Go to on Search, typing, in the source you're in
// (else All), with the selected vault under the cursor.
func (m *Model) showSearch() tea.Cmd {
cmd := m.openGoTo(tabSearch, true)
m.preselect()
@@ -138,20 +131,17 @@ func (v *searchView) scope() option {
// onSource reports whether the results come from one source.
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
// preselect starts where the user is: the selected source is the one
// search narrows to, and the selected vault or scope is under the cursor.
// preselect starts where the user is: the selected source picked on the
// left, and the selected vault or scope under the cursor on the right.
func (m *Model) preselect() {
v := m.search
row := m.sources.SelectedID()
account := ""
if id, ok := strings.CutPrefix(row, "group:"); ok { // a group header
account = accountScope + id
}
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == account || o.group != "" && o.group == row })
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row })
if i < 0 {
return
}
v.here = v.scopes[i].id
v.src = i
m.fillSearch(false)
if m.focus == paneSources {
return
}
@@ -181,36 +171,25 @@ func (m *Model) fillSearch(keep bool) {
}
sources, items := m.searchEntries()
all := option{label: "All", id: allSources, mark: m.coverageMark(nil)}
// Grouped like the Sources pane: each account (or Databricks, or the
// repos) is a scope too, its sources under it, so a search can stay on
// one, like the repos (local, quick), without asking Azure for every
// vault. With only one, it's the same as All.
var groups []provider.Provider
for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 {
groups = append(groups, p)
}
}
all := option{label: "All", id: allSources, mark: m.coverageMark("")}
scopes := []option{all}
if len(groups) < 2 {
scopes = append(scopes, sources...)
} else {
for _, p := range groups {
k := p.Kind()
// All of one kind, so a search can stay on repos (local, quick)
// without asking Azure for every vault; only with several kinds.
var kinds []provider.Kind
for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) {
kinds = append(kinds, p.Kind())
}
}
if len(kinds) > 1 {
for _, k := range kinds {
scopes = append(scopes, option{
label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()),
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)),
id: kindScope + string(k), kind: k, mark: m.coverageMark(k),
})
for _, o := range sources {
if providerOf(o.group) == p.ID() {
o.label = " " + o.label
scopes = append(scopes, o)
}
}
}
}
v.scopes, v.items = scopes, items
v.scopes, v.items = append(scopes, sources...), items
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
v.shown = v.match()
@@ -223,9 +202,17 @@ func (m *Model) fillSearch(keep bool) {
v.res = max(0, min(v.res, len(v.shown)-1))
}
// coverageMark is ○ ◐ ● for how much of the providers in is loaded.
func (m *Model) coverageMark(in providerFilter) string {
switch c := m.coverageOf(in); {
// kindScopeLabel names the scope of all of a kind's sources.
func kindScopeLabel(k provider.Kind) string {
if k == provider.KindRepo {
return "All repos"
}
return "All " + string(k)
}
// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded.
func (m *Model) coverageMark(kind provider.Kind) string {
switch c := m.coverageOf(kind); {
case c.complete():
return m.st.Success.Render("●")
case c.vaultsLoaded > 0:
@@ -234,28 +221,15 @@ func (m *Model) coverageMark(in providerFilter) string {
return m.st.Muted.Render("○")
}
// scopeAccount is the provider whose sources the scope covers, if it's
// an account's.
func (v *searchView) scopeAccount() (string, bool) {
id, ok := strings.CutPrefix(v.scope().id, accountScope)
// scopeKind is the kind a scope covers all of, if it's one of those.
func (v *searchView) scopeKind() (provider.Kind, bool) {
k, ok := strings.CutPrefix(v.scope().id, kindScope)
if !ok || v.tab != tabSearch {
return "", false
}
return id, true
return provider.Kind(k), true
}
// scopeFilter is what loading everything in the scope covers: an
// account's, else all of them.
func (v *searchView) scopeFilter() providerFilter {
if id, ok := v.scopeAccount(); ok {
return ofProvider(id)
}
return nil
}
// scopeName is the scope's label without colours or indent.
func scopeName(o option) string { return strings.TrimSpace(ansi.Strip(o.label)) }
// match lists the scope's entries matching the input, best first.
func (v *searchView) match() []option {
var cand []option
@@ -265,16 +239,13 @@ func (v *searchView) match() []option {
case scopeRecent:
cand = v.recent
default:
account, ofAccount := v.scopeAccount()
kind, ofKind := v.scopeKind()
for _, o := range v.items {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group {
cand = append(cand, o)
}
}
}
if v.picker != nil {
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
}
q := v.input.Value()
if q == "" {
return slices.Clone(cand)
@@ -349,9 +320,6 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
// The actions shared by both modes, by their keys in the mode.
type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding }
k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect}
if key.Matches(msg, km.Search) {
return m.narrow()
}
if v.typing {
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
if key.Matches(msg, km.PopupClose) {
@@ -370,22 +338,6 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil
case key.Matches(msg, km.GoFilter):
return m.setTyping(true)
case key.Matches(msg, km.GoTop), key.Matches(msg, km.GoBottom):
n := len(v.shown)
if !v.results {
n = len(v.scopes)
}
i := 0
if key.Matches(msg, km.GoBottom) {
i = max(0, n-1)
}
if v.results {
v.res = i
} else if v.src != i {
v.src, v.res = i, 0
m.fillSearch(false)
}
return nil
case key.Matches(msg, km.Help):
return m.showKeybindingsFor(keys.GoTo)
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
@@ -431,14 +383,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil
case key.Matches(msg, k.sel):
switch {
case v.results && v.res < len(v.shown) && v.picker != nil:
r, _ := pickable(v.shown[v.res])
return v.picker.choose(m, r)
case v.results && v.res < len(v.shown):
m.search = nil
return v.shown[v.res].run(m)
case !v.results && v.picker != nil:
v.results = true // a picker only narrows to the scope
case !v.results && v.onSource():
m.search = nil
return v.scope().run(m)
@@ -454,9 +401,11 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
if v.onSource() {
return v.scope().reload(m)
}
return m.startCrawlOf(v.scopeFilter())
kind, _ := v.scopeKind()
return m.startCrawlOf(kind)
case inSearch && key.Matches(msg, k.loadAll):
return m.startCrawlOf(v.scopeFilter())
kind, _ := v.scopeKind()
return m.startCrawlOf(kind)
case key.Matches(msg, k.star):
if o, ok := v.selected(); ok && o.star != "" {
m.toggleStarKey(o.star, o.starAccount)
@@ -487,22 +436,6 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return cmd
}
// narrow switches Search between All and the source Go to was opened
// from; from another tab, or another scope, it goes to Search on All.
func (m *Model) narrow() tea.Cmd {
v := m.search
src := 0
if v.tab == tabSearch && v.scope().id == allSources {
src = slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
if src < 0 {
return nil
}
}
v.tab, v.src, v.res, v.results = tabSearch, src, 0, true
m.fillSearch(false)
return m.searchLoads()
}
// searchLoads loads what a search in the Search tab covers, like the
// load-all key: everything on All, else the source picked. Only what
// isn't loaded yet, once a popup; repos load by themselves anyway.
@@ -519,8 +452,8 @@ func (m *Model) searchLoads() tea.Cmd {
v.loaded = map[string]bool{}
}
v.loaded[sc.id] = true
if _, ok := v.scopeAccount(); ok {
return m.startCrawlOf(v.scopeFilter())
if kind, ok := v.scopeKind(); ok {
return m.startCrawlOf(kind)
}
if !v.onSource() {
return m.startCrawl()
@@ -564,35 +497,6 @@ func (m *Model) toggleStarKey(key, account string) {
// searchHint tops the results when what they come from isn't all
// loaded, saying how to load it.
func (m Model) searchHint() string {
load, narrow := m.loadHint(), m.narrowHint()
switch {
case narrow == "":
return load
case load != "":
narrow = " · " + narrow
}
return load + m.st.Muted.Render(narrow)
}
// narrowHint names the key for the other of All and the source Go to was
// opened from.
func (m Model) narrowHint() string {
v := m.search
if v.tab != tabSearch || m.crawl.running {
return ""
}
if v.scope().id == v.here && v.here != "" {
return withKey(m.keys.Search, "%s searches all")
}
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
if i < 0 || v.scope().id != allSources {
return ""
}
return withKey(m.keys.Search, "%s only "+scopeName(v.scopes[i]))
}
// loadHint says how much of the scope is loaded, and the key to load it.
func (m Model) loadHint() string {
v := m.search
if m.crawl.running {
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
@@ -600,8 +504,8 @@ func (m Model) loadHint() string {
if v.tab != tabSearch {
return "" // from the store: nothing to load
}
if _, ok := v.scopeAccount(); ok || v.scope().id == allSources {
c := m.coverageOf(v.scopeFilter())
if kind, ok := v.scopeKind(); ok || v.scope().id == allSources {
c := m.coverageOf(kind)
if c.complete() {
return ""
}
@@ -618,7 +522,7 @@ func (m Model) loadHint() string {
}
loaded := 0
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
@@ -654,16 +558,7 @@ func (m Model) searchViewBox() string {
v := m.search
focused := !m.termBlurred
w := searchWidth(m.w)
popupW := w
if v.picker != nil {
w -= 4 // inside the picker's border, in the popup
}
listH := max(5, min(m.h-10, 14))
if v.picker != nil {
// What it's for, the help, a line or two on the key picked, the
// tabs and a border more.
listH = max(5, min(m.h-10-len(v.picker.lines)-len(v.picker.help)-8, 14))
}
srcW := 10
for _, o := range v.scopes {
@@ -742,8 +637,14 @@ func (m Model) searchViewBox() string {
}
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
if _, ok := v.scopeAccount(); ok || v.onSource() {
resultsTitle = "Results in " + scopeName(v.scope())
if v.onSource() {
resultsTitle = "Results in " + ansi.Strip(v.scope().label)
}
if kind, ok := v.scopeKind(); ok {
resultsTitle = "Results in " + string(kind)
if kind == provider.KindRepo {
resultsTitle = "Results in all repos"
}
}
var lists string
if v.tab == tabSearch {
@@ -755,24 +656,7 @@ func (m Model) searchViewBox() string {
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
}
ts := panes.TitleStyle(m.st, focused)
var body []string
if p := v.picker; p != nil {
for _, l := range p.help {
body = append(body, " "+l)
}
switch {
case p.busy != "":
body = append(body, "", " "+m.st.Notice.Render(p.busy))
case p.err != "":
body = append(body, "")
for _, l := range strings.Split(ansi.Wordwrap(p.err, w-4, " "), "\n") {
body = append(body, " "+m.st.Error.Render(l))
}
}
body = append(body, "", " "+m.goToTabs(ts))
}
body = append(body, " "+v.input.View(), "")
body := []string{" " + v.input.View(), ""}
for _, l := range strings.Split(lists, "\n") {
body = append(body, " "+l)
}
@@ -792,83 +676,26 @@ func (m Model) searchViewBox() string {
if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
}
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, v.selectLabel()), hint(km.PopupClose, "stop typing"))...)
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...)
default:
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
}
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, v.selectLabel()), hint(km.GoClose, "close"))...)
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...)
}
// 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+" ") + 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.
var outer []string
for _, l := range v.picker.lines {
outer = append(outer, " "+l)
}
outer = append(outer, "")
for _, l := range strings.Split(panes.Box(m.st, ts.Render(v.picker.heading), "", body, w, len(body)+2, focused), "\n") {
outer = append(outer, " "+l)
}
return panes.Box(m.st, ts.Render(v.picker.title), foot, outer, popupW, len(outer)+2, focused)
}
// picker is Go to picking a secret for something (the key to share
// with): it lists secrets only, and enter hands the selected one's link
// to pick, which closes Go to when it's done with it.
type picker struct {
pick func(m *Model, r ref.Ref) tea.Cmd
title string // the popup's
lines []string // what picking is for, above the picker
heading string // the picker's title, on its border
help []string // inside it, above the tabs
busy string // what the pick is doing (picks wait meanwhile)
err string // why the secret picked can't be used
}
// choose hands r to the picker, unless it's still busy with one.
func (p *picker) choose(m *Model, r ref.Ref) tea.Cmd {
if p.busy != "" {
return nil
}
return p.pick(m, r)
}
// selectLabel names what enter does with the selected entry.
func (v *searchView) selectLabel() string {
if v.picker != nil {
return "pick"
}
return "go"
}
// pickable is the link of a Go to entry that a picker can pick: a
// secret (not a key or certificate, nor a key in a plain env file).
func pickable(o option) (ref.Ref, bool) {
r, err := ref.Parse(o.star)
if err != nil || o.plain || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
return ref.Ref{}, false
}
return r, true
}
// goToTabs draws Go to's tabs, the current one underlined.
func (m Model) goToTabs(ts lipgloss.Style) string {
var out string
// Tabs in the title, the current one underlined, like the panes.
ts := panes.TitleStyle(m.st, focused)
head := ts.Render(goToTitle + " ")
for i, name := range tabNames {
if i > 0 {
out += ts.Render(" - ")
head += ts.Render(" - ")
}
if i == m.search.tab {
out += ts.Underline(true).Render(name)
if i == v.tab {
head += ts.Underline(true).Render(name)
} else {
out += m.st.Muted.Render(name)
head += m.st.Muted.Render(name)
}
}
return out
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
}
-471
View File
@@ -1,471 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"slices"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/share"
)
// Sharing env files as an encrypted lazysecrets://share link (package
// share), and opening one: the files are encrypted with a secret's value,
// so whoever can read that secret can open the link.
// sharing is a share being made: the files read, and the key to use.
type sharing struct {
repo provider.Source
files []share.File
key ref.Ref // zero until chosen
}
type shareFilesMsg struct {
sharing
err error
}
type sealedMsg struct {
link ref.Ref
n int
// from is the picker the key was picked in; nil for sharing.key's.
from *picker
err error
}
// canShareFiles reports whether src's env files can be shared: it's a
// repo whose files can be read.
func (m Model) canShareFiles(src provider.Source) bool {
_, ok := m.providerFor(src).(provider.Contents)
return src.Kind == provider.KindRepo && ok
}
// shareFiles shares env files of a repo: cs, or with cs nil all its
// plain ones.
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
p := m.providerFor(src)
files, ok := p.(provider.Contents)
if !ok {
m.flash = warning("these files can't be shared")
return nil
}
general, timeout := m.shareKey, m.timeout.of(src.Kind)
m.flash = flash{text: "reading the files…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
msg := shareFilesMsg{sharing: sharing{repo: src}}
if cs == nil {
all, err := p.Containers(ctx, src)
if err != nil {
msg.err = err
return msg
}
for _, c := range all {
if c.Detail == provider.DetailPlainEnv {
cs = append(cs, c)
}
}
if len(cs) == 0 {
msg.err = errors.New(src.Name + " has no plain env files")
return msg
}
}
for _, c := range cs {
b, err := files.Contents(ctx, c)
if err != nil {
msg.err = err
return msg
}
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
}
key, ok, err := share.DefaultKey(src.ID, general)
if ok {
msg.key = key
}
msg.err = err
return msg
}
}
func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
switch {
case msg.err != nil:
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
case msg.key == (ref.Ref{}):
return m.pickShareKey(msg.sharing)
default:
m.confirmShare(msg.sharing)
}
return nil
}
// shareTitle names the action: "Share .env", "Share 2 env files".
func shareTitle(s sharing) string {
if len(s.files) == 1 {
return "Share " + path.Base(s.files[0].Path)
}
return "Share " + plural(len(s.files), "env file")
}
// sharedFiles lists the files being shared, under what's happening.
func (m *Model) sharedFiles(s sharing) []string {
lines := []string{"Sharing " + plural(len(s.files), "env file") + " as an encrypted link:"}
for _, f := range s.files {
lines = append(lines, " "+m.repoLabel(s.repo.Name, f.Path))
}
return lines
}
// confirmShare says what the link will hold before making it.
func (m *Model) confirmShare(s sharing) {
lines := append(m.sharedFiles(s), "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
m.showMenu(shareTitle(s), lines, []option{
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s, nil) }},
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }},
})
}
// pickShareKey asks which secret to encrypt with, in Go to, then shares
// with it: the picker showed what's shared, so there's no asking again.
// It stays open until the link is made, to say why a secret can't be
// used.
func (m *Model) pickShareKey(s sharing) tea.Cmd {
cmd := m.openGoTo(tabSearch, true)
p := &picker{
title: shareTitle(s),
lines: m.sharedFiles(s),
heading: "Pick a secret to encrypt with",
help: []string{
m.st.Muted.Render("The recipient needs read access to it to decrypt the files."),
m.st.Muted.Render("Its value isn't shown or put in the link."),
},
}
p.pick = func(m *Model, key ref.Ref) tea.Cmd {
s.key = key
p.err, p.busy = "", "encrypting with "+key.Name+"…"
cmd := m.seal(s, p)
m.flash = flash{}
return cmd
}
m.search.picker = p
m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border
m.fillSearch(false)
return tea.Batch(cmd, m.searchLoads())
}
// pickPasted picks a pasted reference to a secret, while picking one.
func (m *Model) pickPasted(text string) tea.Cmd {
key, err := share.ParseKey(strings.TrimSpace(text))
if err != nil {
m.search.picker.err = "✗ " + capitalize(err.Error())
return nil
}
return m.search.picker.choose(m, key)
}
// seal reads the key and encrypts the files with it, for the picker
// from (nil: the key wasn't picked).
func (m *Model) seal(s sharing, from *picker) tea.Cmd {
read, rules := m.keyReader(), m.shareRules
m.flash = flash{text: "encrypting with " + s.key.Name + "…"}
return func() tea.Msg {
v, version, err := read(s.key)
if err != nil {
return sealedMsg{from: from, err: &share.KeyError{Name: s.key.Name, Err: err}}
}
link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files, rules)
return sealedMsg{link: link, n: len(s.files), from: from, err: err}
}
}
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
if p := msg.from; p != nil {
if m.search == nil || m.search.picker != p {
return nil // the picker was closed: cancelled
}
p.busy = ""
var bad *share.KeyError
if errors.As(msg.err, &bad) {
p.err = "✗ " + capitalize(bad.Error()) // to pick another
return nil
}
m.search = nil
}
if msg.err != nil {
m.noteErr(msg.err)
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
return nil
}
m.dismissedLink = msg.link.String() // not offered back on focus
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
}
// keyReader reads a key's value with the providers, the account that
// last read it first, off the UI goroutine.
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
providers, st, timeout := m.providers, m.store, m.timeout
return func(key ref.Ref) (provider.SecretValue, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout.of(key.Kind))
defer cancel()
k := key
k.Version = ""
node := st.Node(k.String())
return ref.Read(ctx, providers, key, node.Account, node.Source)
}
}
// --- opening ---
type shareOpenedMsg struct {
sealed share.Sealed
files []share.File
dir string // the repo's clone; "" when it isn't here
err error
}
// offerShare says what a share link holds before decrypting it: links
// can come from anywhere, so nothing is read or written unasked.
func (m *Model) offerShare(r ref.Ref) tea.Cmd {
s, err := share.Read(r)
if err != nil {
m.showMenu("Share link can't be read", []string{m.st.Error.Render(err.Error())}, nil)
return nil
}
from := ""
if s.Repo != "" {
from = m.st.Muted.Render(" from ") + m.repoLabel(s.Repo)
}
m.showMenu("Shared env files", []string{
m.st.Muted.Render(plural(s.Files, "env file")) + from,
m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key),
}, []option{
{key: "o", label: "Decrypt…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }},
})
return nil
}
// openShare reads the key, decrypts the files and finds the repo's clone.
func (m *Model) openShare(s share.Sealed) tea.Cmd {
read := m.keyReader()
providers, timeout := m.providers, m.timeout.of(provider.KindRepo)
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
return func() tea.Msg {
msg := shareOpenedMsg{sealed: s}
v, _, err := read(s.Key)
if err != nil {
msg.err = &share.KeyError{Name: s.Key.Name, Err: err}
return msg
}
if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil {
return msg
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, p := range providers {
if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" {
// Its clone, if it's on disk (the demo's aren't).
if root, found, _ := loc.Locate(ctx, s.Repo); found && isDir(root) {
msg.dir = root
break
}
}
}
return msg
}
}
func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
switch {
case msg.err != nil:
m.noteErr(msg.err)
m.openFailed(msg.sealed, msg.err, false)
case msg.dir == "":
return m.askShareDir(msg)
default:
targets, err := planSave(msg.files, msg.dir)
if err != nil {
m.flash = flash{text: "save: " + err.Error(), isErr: true}
return nil
}
m.offerSave(targets, msg.dir)
}
return nil
}
// 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
// (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) {
width := max(30, min(m.w-12, 76))
text := capitalize(err.Error())
first, rest, _ := strings.Cut(text, "\n")
cut := !whole && rest != "" // more than the reason: what to check
if !whole {
text = first
}
var lines []string
for i, l := range strings.Split(ansi.Wordwrap(text, width-2, " "), "\n") {
if i == 0 {
l = "✗ " + l
} else if l != "" {
l = " " + l
}
lines = append(lines, m.st.Error.Render(l))
}
var opts []option
if cut {
opts = append(opts, option{key: "r", label: "Show the whole message", run: func(m *Model) tea.Cmd {
m.openFailed(s, err, true)
return nil
}})
}
opts = append(opts, option{key: "g", label: "Go to " + s.Key.Name, run: func(m *Model) tea.Cmd { return m.openRef(s.Key, actGo) }})
m.showMenu("Can't decrypt the shared files", lines, opts)
}
// 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 := textinput.New()
in.Prompt = "folder: "
in.SetWidth(max(20, min(m.w-12, 64)))
dir := m.placeDir
if dir == "" {
dir, _ = os.Getwd()
}
in.SetValue(dir)
in.CursorEnd()
lines := []string{m.st.Muted.Render("Paths are from the repo's root.")}
if msg.sealed.Repo != "" {
lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...)
}
p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"}
p.submit = func(m *Model, text string) tea.Cmd {
dir := expandHome(strings.TrimSpace(text))
targets, err := planSave(msg.files, dir)
switch {
case err == nil && slices.ContainsFunc(targets, func(t share.Target) bool { return t.Exists }):
m.offerSave(targets, dir) // ask before replacing
return nil
case err == nil:
// Chosen, with nothing to replace: saved at once.
if err = m.writeShared(targets, dir, false); err == nil {
return nil
}
}
p.err = err.Error()
m.prompt = p // again, to fix the folder
return p.input.Focus()
}
m.prompt = p
return p.input.Focus()
}
// planSave says where each file goes in dir, which must be a folder.
func planSave(files []share.File, dir string) ([]share.Target, error) {
if !isDir(dir) {
return nil, fmt.Errorf("no folder %s", dir)
}
return share.Plan(dir, files)
}
// offerSave shows where each file goes, then saves them as chosen:
// files there already are replaced only when asked.
func (m *Model) offerSave(targets []share.Target, dir string) {
var lines []string
exist := 0
for _, t := range targets {
file := " " + m.repoLabel(filepath.Base(dir), t.Path)
line := file + m.st.Muted.Render(" · new")
if t.Exists {
exist++
line = file + m.st.Muted.Render(" · replaces the one there")
}
if t.Exposed {
line += m.st.Error.Render(" · not ignored by git")
}
lines = append(lines, line)
}
lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...)
write := func(onlyNew bool) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd {
if err := m.writeShared(targets, dir, onlyNew); err != nil {
m.flash = flash{text: "save: " + err.Error(), isErr: true}
}
return nil
}
}
var opts []option
switch {
case exist == 0:
opts = append(opts, option{key: "w", label: "Save", run: write(false)})
case exist == len(targets):
opts = append(opts, option{key: "w", label: "Replace", run: write(false)})
default:
opts = append(opts,
option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)},
option{key: "n", label: onlyNew(len(targets) - exist), run: write(true)},
)
}
m.showMenu("Save the shared files", lines, opts)
}
// writeShared writes the files (with onlyNew, those not there yet) and
// says where, and which ones git doesn't ignore.
func (m *Model) writeShared(targets []share.Target, dir string, onlyNew bool) error {
n := 0
var exposed []string
for _, t := range targets {
if onlyNew && t.Exists {
continue
}
if err := t.Write(); err != nil {
return fmt.Errorf("%s: %w", t.Path, err)
}
n++
if t.Exposed {
exposed = append(exposed, t.Path)
}
}
m.flash = done("saved " + plural(n, "file") + " in " + dir)
if len(exposed) > 0 {
m.flash = warning("saved " + plural(n, "file") + " in " + dir + "; git doesn't ignore " + strings.Join(exposed, ", "))
}
return nil
}
// isDir reports whether dir is a folder on disk.
func isDir(dir string) bool {
info, err := os.Stat(dir)
return err == nil && info.IsDir()
}
// expandHome expands a leading ~/ to the home folder.
func expandHome(dir string) string {
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, rest)
}
}
return dir
}
// onlyNew labels saving only the n files not there yet.
func onlyNew(n int) string {
if n == 1 {
return "Save only the new one"
}
return fmt.Sprintf("Save only the %d new ones", n)
}
-355
View File
@@ -1,355 +0,0 @@
package app
import (
"context"
"errors"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// scryptWait is how long the driver waits for encrypting and decrypting:
// tens of milliseconds, more than its usual wait, and more under load.
const scryptWait = 20 * time.Second
// demoRules take the demo key, made up to be remembered.
var demoRules = share.Rules{MinLength: 16, MinClasses: 1}
// cloneWith makes a clone of git.example.com/team/app holding files.
func cloneWith(t *testing.T, files map[string]string) string {
t.Helper()
root := gitRepoWith(t, "https://git.example.com/team/app.git")
os.WriteFile(filepath.Join(root, ".gitignore"), []byte(".env\n"), 0o644)
for name, data := range files {
os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0o755)
os.WriteFile(filepath.Join(root, name), []byte(data), 0o600)
}
return root
}
// shareFrom shares a repo's env files from lazysecrets started in it,
// returning the link copied.
func shareFrom(t *testing.T, root string, paste bool) string {
t.Helper()
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
d.wait = scryptWait
if src, ok := d.m.selectedSource(); !ok || src.ID != root {
t.Fatalf("selected %v, want the repo", src.ID)
}
d.keys("S", "f")
// No sharing.key set: it asks which secret, in Go to, which lists
// only secrets.
v := d.m.search
if v == nil || v.picker == nil {
t.Fatalf("no secret picker: %s", d.m.flash.text)
}
for _, o := range v.shown {
if o.plain || strings.Contains(o.label, ".env") {
t.Errorf("a plain env value offered as the key: %s", o.label)
}
}
if !strings.Contains(d.screen(), "as an encrypted link") {
t.Errorf("doesn't say what picking does:\n%s", d.screen())
}
if paste {
// A reference pasted is picked as it is.
d.send(tea.PasteMsg{Content: "https://" + kv + "/secrets/db-password"})
} else {
for _, r := range "app-prod db-password" {
d.keys(string(r))
}
if len(v.shown) == 0 {
t.Fatalf("no secrets to pick:\n%s", d.screen())
}
for _, o := range v.shown {
if _, ok := pickable(o); !ok {
t.Errorf("not a secret: %s", o.label)
}
}
if !strings.Contains(d.screen(), "Share 2 env files") && !strings.Contains(d.screen(), "Share .env") {
t.Errorf("not titled as a picker:\n%s", d.screen())
}
d.keys("enter")
}
// Picking it shares: the picker said what.
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
}
return clip.content
}
func TestShareFilesRoundTrip(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
link := shareFrom(t, cloneWith(t, map[string]string{
".env": "DB_PASSWORD=hunter2\n",
"prod.env": "TOKEN=abc\n",
}), false)
// A colleague's clone: .env is there already, prod.env isn't.
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: theirs}))
d.wait = scryptWait
d.send(tea.PasteMsg{Content: link})
if d.m.prompt == nil || d.m.prompt.title != "Shared env files" || !strings.Contains(d.screen(), "2 env files from git.example.com/team/app") {
t.Fatalf("not offered:\n%s", d.screen())
}
d.keys("o")
if d.m.prompt == nil || d.m.prompt.title != "Save the shared files" {
t.Fatalf("no save menu (%s)", d.m.flash.text)
}
if s := d.screen(); !strings.Contains(s, "replaces the one there") || !strings.Contains(s, "not ignored by git") {
t.Errorf("save menu doesn't say what happens:\n%s", s)
}
d.keys("n") // only the new one
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=old\n" {
t.Errorf(".env replaced: %q", b)
}
if b, _ := os.ReadFile(filepath.Join(theirs, "prod.env")); string(b) != "TOKEN=abc\n" {
t.Errorf("prod.env: %q", b)
}
// Again, replacing.
d.send(tea.PasteMsg{Content: link})
d.keys("o", "w")
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=hunter2\n" {
t.Errorf(".env not replaced: %q", b)
}
}
func TestShareWithShareKeyConfirms(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := cloneWith(t, map[string]string{
".env": "A=1\n",
".lazysecrets.yaml": "sharing:\n key: https://" + kv + "/secrets/db-password\n",
})
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
d.wait = scryptWait
d.keys("S", "f")
// The key wasn't picked: it shows which, before sharing.
if d.m.search != nil || d.m.prompt == nil || d.m.prompt.title != "Share .env" || !strings.Contains(d.screen(), "db-password") {
t.Fatalf("no confirmation:\n%s", d.screen())
}
d.keys("c")
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
t.Errorf("copied %q (%s)", clip.content, d.m.flash.text)
}
}
func TestShareLinkElsewhereAsksWhere(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}), true)
dir, _ := filepath.EvalSymlinks(t.TempDir())
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
d.wait = scryptWait
d.send(tea.PasteMsg{Content: link})
d.keys("o")
if d.m.prompt == nil || d.m.prompt.input == nil || d.m.prompt.input.Value() != dir {
t.Fatalf("doesn't ask where, starting from where it runs:\n%s", d.screen())
}
// A folder that isn't there: the prompt stays, saying so.
d.m.prompt.input.SetValue(filepath.Join(dir, "nope"))
d.keys("enter")
if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "✗ No folder") {
t.Fatalf("a missing folder isn't said in the prompt:\n%s", d.screen())
}
if !strings.Contains(d.screen(), "enter save") {
t.Errorf("the footer doesn't say enter saves:\n%s", d.screen())
}
d.m.prompt.input.SetValue(dir)
d.keys("enter") // saved at once: nothing there to replace
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" {
t.Errorf(".env: %q (%s)", b, d.m.flash.text)
}
if d.m.prompt != nil || !strings.Contains(d.m.flash.text, "saved 1 file in "+dir) {
t.Errorf("not closed with where it saved: %q", d.m.flash.text)
}
}
func TestShareLinkGarbled(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.send(tea.PasteMsg{Content: "lazysecrets://share/AQ" + strings.Repeat("A", 30)})
if d.m.prompt == nil || !strings.Contains(d.screen(), "garbled") {
t.Errorf("not reported:\n%s", d.screen())
}
}
// TestShareInDemo shares a demo repo's env files with the demo key and
// opens the link, as lazysecrets --demo does: the demo repos aren't on
// disk, so it asks where to save.
func TestShareInDemo(t *testing.T) {
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip},
fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos())
d.wait = scryptWait
var src provider.Source
for _, s := range d.m.sourceResults["Repos"].sources {
if s.Here {
src = s
}
}
if src.ID == "" {
t.Fatal("no demo repo")
}
src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil))
if d.m.search == nil || d.m.search.picker == nil {
t.Fatalf("no picker: %s", d.m.flash.text)
}
// Too short a key: the picker stays, saying why.
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"})
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; sharing.keyRules needs 32 (minLength)") {
t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
}
if !strings.Contains(d.screen(), "28 characters") {
t.Errorf("not shown:\n%s", d.screen())
}
d.send(tea.PasteMsg{Content: "https://kv-shared-prod.vault.azure.net/secrets/env-share"})
if !strings.HasPrefix(clip.content, "lazysecrets://share/") || d.m.search != nil {
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
}
link := clip.content
// A value in a demo repo's SOPS file can be the key too, found by
// the repo's remote.
clip.content = ""
d.run(d.m.shareFiles(src, nil))
d.send(tea.PasteMsg{Content: "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/config%2Foauth.enc.json/github.client_secret"})
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
t.Errorf("with the demo repo's key: copied %q (%s)", clip.content, d.m.flash.text)
}
dir, _ := filepath.EvalSymlinks(t.TempDir())
d.m.placeDir = dir
d.send(tea.PasteMsg{Content: link})
d.keys("o")
if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "isn't among your repos") {
t.Fatalf("doesn't ask where:\n%s", d.screen())
}
d.keys("enter") // saved at once: nothing there to replace
entries, _ := os.ReadDir(dir)
if len(entries) == 0 {
t.Fatalf("nothing saved (%s)", d.m.flash.text)
}
}
func TestPickerCancelAndBusy(t *testing.T) {
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip},
fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos())
var src provider.Source
for _, s := range d.m.sourceResults["Repos"].sources {
if s.Here {
src = s
}
}
src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil))
p := d.m.search.picker
key, _ := share.ParseKey("https://kv-shared-prod.vault.azure.net/secrets/env-share")
// Picking starts sealing; another pick meanwhile is ignored.
cmd := p.choose(&d.m, key)
if cmd == nil || p.busy == "" {
t.Fatal("picking didn't start")
}
if again := p.choose(&d.m, key); again != nil {
t.Error("a second pick while busy wasn't ignored")
}
// Closed before the link is made: cancelled, nothing copied.
d.m.search = nil
d.wait = scryptWait
d.run(cmd)
if clip.content != "" {
t.Errorf("copied after the picker was closed: %q", clip.content)
}
}
func TestPlainEnvNotPickableFromFavourites(t *testing.T) {
mem, _ := store.Load("")
d := newDriverOpts(t, Options{Store: mem}, fake.Azure(0), fake.Repos())
var src provider.Source
for _, s := range d.m.sourceResults["Repos"].sources {
if s.Here {
src = s
}
}
// A key in the demo repo's plain .env, starred.
key := "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/.env/DATABASE_URL"
mem.Update(key, func(n *store.Node) {
n.StarredBy = []string{"Repos"}
n.Path = []string{src.Name, ".env"}
n.Source = src.ID
})
src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil))
favs, _ := d.m.storeEntries()
for _, o := range favs {
if o.star == key {
if _, ok := pickable(o); ok {
t.Error("a plain .env value is offered as a key in Favourites")
}
return
}
}
t.Fatalf("the starred key isn't among the favourites: %d", len(favs))
}
// refusing is a provider whose values can't be read, like a disabled
// version in Key Vault.
type refusing struct{ provider.Provider }
func (refusing) Value(context.Context, provider.Object) (provider.SecretValue, error) {
return provider.SecretValue{}, &provider.Denied{
Err: errors.New("Operation get is not allowed on a disabled secret."),
Checks: []string{"the secret's version is enabled"},
}
}
func TestOpenFailsInAPopup(t *testing.T) {
key, _ := share.ParseKey("https://" + kv + "/secrets/db-password")
link, err := share.Seal(share.Header{Key: key, Repo: "git.example.com/team/app"},
[]byte("correct-horse-battery-staple"), []share.File{{Path: ".env", Data: []byte("A=1\n")}}, demoRules)
if err != nil {
t.Fatal(err)
}
d := newDriverOpts(t, Options{}, refusing{fake.Azure(0)})
d.wait = scryptWait
d.send(tea.PasteMsg{Content: link.String()})
d.keys("o")
p := d.m.prompt
if p == nil || p.title != "Can't decrypt the shared files" || !strings.Contains(d.screen(), "Can't use db-password: Operation get is not allowed") {
t.Fatalf("not in a popup:\n%s", d.screen())
}
if strings.Contains(d.screen(), "version is enabled") {
t.Error("the whole message is shown before asking")
}
d.keys("r")
if !strings.Contains(d.screen(), "version is enabled") {
t.Errorf("r doesn't show the whole message:\n%s", d.screen())
}
d.keys("g")
if _, _, sel := d.selected(); d.m.prompt != nil || (sel != "db-password" && (d.m.nav == nil || d.m.nav.ref.Name != "db-password")) {
t.Errorf("g doesn't go to the key: %q, %q\n%s", sel, d.m.flash.text, d.screen())
}
}
+4 -6
View File
@@ -5,9 +5,7 @@ import (
"fmt"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
@@ -41,7 +39,7 @@ type sourceRow struct {
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
// either; provider IDs contain "/" (Azure:user/tenant).
func sourceRowKey(providerID, sourceID string) string { return cachefile.Row(providerID, sourceID) }
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID }
func splitSourceRow(row string) (providerID, sourceID string) {
providerID, sourceID, _ = strings.Cut(row, "|")
@@ -58,7 +56,7 @@ func sourceRowID(r sourceRow) string {
return sourceRowKey(r.provider, r.source.ID)
}
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID,
// Headers have no filter text, so filtering shows just sources.
@@ -80,8 +78,8 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent, time.Now())
if r.kind == rowSource && node.StarredFor(r.source.Provider) {
starred = st.Favourite.Render(star)
}
switch {
case r.kind == rowLinks:
+2 -14
View File
@@ -38,10 +38,9 @@ func (m Model) statusLine() string {
case m.newer != nil:
parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s")))
}
accounts, failing, repos := 0, 0, 0
accounts, failing := 0, 0
for _, p := range m.providers {
if p.Kind() == provider.KindRepo {
repos += len(m.sourceResults[p.ID()].sources) // those with secret files
continue
}
accounts++
@@ -49,9 +48,6 @@ func (m Model) statusLine() string {
failing++
}
}
if n := len(m.expiringItems()); n > 0 {
parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n)))
}
if accounts > 0 {
s := plural(accounts, "account")
if failing > 0 {
@@ -59,9 +55,6 @@ func (m Model) statusLine() string {
}
parts = append(parts, s)
}
if repos > 0 {
parts = append(parts, plural(repos, "repo"))
}
return " " + strings.Join(parts, m.st.Muted.Render(" · "))
}
@@ -105,9 +98,6 @@ func (m Model) statusFields() []panes.Field {
add(f)
}
out = append(out, m.expiringFields()...)
out = append(out, m.errorFields()...)
for i, path := range m.configFiles {
label := ""
if i == 0 {
@@ -122,9 +112,7 @@ func (m Model) statusFields() []panes.Field {
repo := strings.TrimPrefix(update.DefaultRepo, "https://")
add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"})
add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo})
// Issues are on the GitHub mirror.
github := strings.TrimPrefix(update.GitHubRepo, "https://")
add(panes.Field{Label: "issues", Value: github + "/issues", URL: update.GitHubRepo + "/issues"})
add(panes.Field{Label: "issues", Value: repo + "/issues", URL: update.DefaultRepo + "/issues"})
add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"})
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
return append(out, m.changelog...)
+5 -5
View File
@@ -1,5 +1,5 @@
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
│ ⚠ 1 expiring · 2 accounts ││ name platform-prod │
│ 2 accounts ││ name platform-prod │
╰───────────────────────────────╯│ ID sub-prod │
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
│ ▾ Azure (2) ││ vaults 3 │
@@ -20,8 +20,7 @@
╭─ [3] Secrets - Keys - Certs ──╮│ │
│ db-password ││ │
│ api-key ││ │
│ legacy-token ✗ expired ││ │
│ smtp-password (disabled) ││ │
│ legacy-token (disabled) ││ │
│ ││ │
│ ││ │
│ ││ │
@@ -29,5 +28,6 @@
│ ││ │
│ ││ │
│ ││ │
╰────────────────────── 1 of 4 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
│ ││ │
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share… • f star • F favourites • … • ? help • q quit
-25
View File
@@ -1,25 +0,0 @@
package app
import (
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// A provider's own timeout applies to it alone.
func TestTimeoutPerProvider(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Timeout: 5 * time.Second, Timeouts: map[provider.Kind]time.Duration{provider.KindAzure: 10 * time.Millisecond}},
fake.Azure(300*time.Millisecond), fake.Databricks(300*time.Millisecond))
scr := d.screen()
if !strings.Contains(scr, "✗ Azure: context deadline exceeded") || !strings.Contains(scr, "Databricks (…)") ||
strings.Contains(scr, "Databricks: context deadline") {
t.Errorf("want Azure timed out and Databricks still loading:\n%s", scr)
}
if got := (timeouts{def: time.Minute}).of(provider.KindRepo); got != time.Minute {
t.Errorf("no override: %s, want the default", got)
}
}
+4 -12
View File
@@ -20,8 +20,7 @@ type Updater struct {
}
// updateEvery is how often lazysecrets looks for a newer release on its
// own unless the config says otherwise; within that, the last answer (in
// the state file) is used.
// own; within that, the last answer (in the state file) is used.
const updateEvery = 24 * time.Hour
type updateMsg struct {
@@ -36,14 +35,14 @@ type installedMsg struct {
}
// startUpdateCheck sets up the startup look for a newer release: the
// state file's answer when it looked within updateEvery, else a quiet
// state file's answer when it looked less than a day ago, else a quiet
// look at the server (fetchOnStart, for Init).
func (m *Model) startUpdateCheck() {
if m.updater == nil || !m.updateCheck {
return
}
last := m.store.UpdateCheck()
if m.now().Sub(last.At) < m.updateEvery {
if m.now().Sub(last.At) < updateEvery {
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
return
}
@@ -110,22 +109,15 @@ func (m *Model) update() tea.Cmd {
func (m *Model) showUpdate() {
r := *m.newer
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
if notes := r.Notes(m.version); notes != "" {
if notes := r.Notes(); notes != "" {
lines = append(lines, "")
for _, l := range strings.Split(notes, "\n") {
if h, ok := strings.CutPrefix(l, "### "); ok {
l = m.st.Label.Render(h)
} else if h, ok := strings.CutPrefix(l, "## "); ok { // a release, when several are new
l = m.st.TitleFocused.Render(h)
}
lines = append(lines, l)
}
}
// A popup doesn't scroll: several releases behind, the rest is on
// the release pages.
if most := max(6, m.h-12); len(lines) > most {
lines = append(lines[:most-1], m.st.Muted.Render("… and more on the release pages"))
}
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" {
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more