Compare commits
3
Commits
v0.1.2
..
9787f78dd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9787f78dd8 | ||
|
|
d6c4b33d1d | ||
|
|
6fab41703e |
No files matched your search
@@ -16,10 +16,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
# The runner mounts Go, the module cache and the build cache as
|
# The runner mounts Go, the module cache and the build cache as
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
- name: staticcheck
|
- name: staticcheck
|
||||||
# Pinned, so a new release can't fail CI on its own.
|
# 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
|
- name: go test
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ name: Release
|
|||||||
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
|
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
|
||||||
# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea
|
# 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
|
# release with the archives, checksums and this release's section of the
|
||||||
# changelog (git-cliff, cliff.toml), then copies it to the GitHub
|
# changelog (git-cliff, cliff.toml). Pushing the same tag again (after
|
||||||
# mirror. Pushing the same tag again (after deleting it) replaces the
|
# deleting it) replaces the release.
|
||||||
# release in both places.
|
|
||||||
#
|
#
|
||||||
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
|
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
|
||||||
|
|
||||||
@@ -23,12 +22,12 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (full history, for the changelog)
|
- name: Checkout (full history, for the changelog)
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
# The runner mounts Go, the module cache and the build cache as
|
# The runner mounts Go, the module cache and the build cache as
|
||||||
@@ -75,21 +74,10 @@ jobs:
|
|||||||
cat "$RUNNER_TEMP/notes.md"
|
cat "$RUNNER_TEMP/notes.md"
|
||||||
|
|
||||||
- name: GoReleaser
|
- name: GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: v2.18.3
|
version: v2.18.3
|
||||||
args: release --clean --release-notes ${{ runner.temp }}/notes.md
|
args: release --clean --release-notes ${{ runner.temp }}/notes.md
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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 }}
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
-21
@@ -2,27 +2,7 @@
|
|||||||
|
|
||||||
Every release of lazysecrets, newest first.
|
Every release of lazysecrets, newest first.
|
||||||
|
|
||||||
## v0.1.2 – 2026-10-13
|
## v0.1.0 – 2026-10-10
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
Hello world 👋
|
Hello world 👋
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
# lazysecrets
|
# lazysecrets
|
||||||
|
|
||||||
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic [lazygit](https://github.com/jesseduffield/lazygit).
|
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic lazygit.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Main features
|
## Main features
|
||||||
|
|
||||||
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
|
- **Read-only**: lazysecrets never creates, changes or deletes a secret, so
|
||||||
That's by design and may stay that way.
|
it's safe to point at production. That's by design and may stay that way.
|
||||||
- **Zero setup**: uses the logins you already have (`az`, the Databricks
|
- **Zero setup**: uses the logins you already have (`az`, the Databricks
|
||||||
CLI, `sops`, `git`). No config file needed.
|
CLI, `sops`, `git`). No config file needed.
|
||||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||||
fzf-style matching.
|
fzf-style matching (`'exact`, `^prefix`, `suffix$`, `!not`), and jumps
|
||||||
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
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
|
with their own access, from the terminal or with a click in the browser
|
||||||
or chat. `.env` files can be shared too, as a link encrypted with a
|
or chat.
|
||||||
secret your team can read.
|
|
||||||
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
|
||||||
- **Careful with values**: values stay hidden until you reveal them, and
|
- **Careful with values**: values stay hidden until you reveal them, and
|
||||||
copied values are cleared from the clipboard after 30 seconds.
|
copied values are cleared from the clipboard after 30 seconds.
|
||||||
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
||||||
pipes.
|
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
|
- **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
|
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||||
on a secret.
|
on a secret.
|
||||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
||||||
@@ -34,20 +32,17 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
On macOS or Linux (arm64 or x86_64):
|
On macOS or Linux (arm64 or x86_64):
|
||||||
```sh
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/karleinarb/lazysecrets/main/install.sh | sh
|
|
||||||
```
|
|
||||||
Or
|
|
||||||
```sh
|
```sh
|
||||||
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | 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
|
It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases)
|
||||||
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
|
into `~/.local/bin` after checking it against the release's checksums.
|
||||||
`~/.local/bin` after checking it against the release's checksums.
|
|
||||||
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
|
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
|
||||||
somewhere else.
|
somewhere else.
|
||||||
|
|
||||||
|
|
||||||
lazysecrets looks for a newer release once a day: the footer then shows
|
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
|
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
|
||||||
`lazysecrets update`).
|
`lazysecrets update`).
|
||||||
@@ -63,14 +58,12 @@ lazysecrets # start lazysecrets
|
|||||||
lazysecrets --demo # test with demo data
|
lazysecrets --demo # test with demo data
|
||||||
```
|
```
|
||||||
|
|
||||||
Pick a source on the left, then a vault, then a secret. With nothing
|
Pick a source on the left, then a vault, then a secret. The keys you'll
|
||||||
signed in yet, the right pane says how to get started. The keys you'll
|
|
||||||
use most:
|
use most:
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `j`/`k`, `enter`, `esc` | move, drill in, back |
|
| `j`/`k`, `h`/`l`, `enter`, `esc` | move, switch panes, drill in, back |
|
||||||
| `l` / `h` | the details of what's selected / back |
|
|
||||||
| `/` | filter the list you're in |
|
| `/` | filter the list you're in |
|
||||||
| `space` | reveal / hide the value |
|
| `space` | reveal / hide the value |
|
||||||
| `y` | copy the value (cleared from the clipboard after 30s) |
|
| `y` | copy the value (cleared from the clipboard after 30s) |
|
||||||
@@ -92,29 +85,18 @@ use most:
|
|||||||
secret
|
secret
|
||||||
- [Config](docs/config.md): every option, themes, layered configs, the
|
- [Config](docs/config.md): every option, themes, layered configs, the
|
||||||
state file
|
state file
|
||||||
- [Development](docs/development.md): building, tests, how changes land and
|
- [Development](docs/development.md): building, tests, pull requests and
|
||||||
releasing
|
releasing
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
lazysecrets never stores, caches or sends your secret values. It keeps the
|
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
|
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
|
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
|
call of its own is a daily check for a new release on this server, which
|
||||||
git.kebcloud.no, which `updates.autoCheck: false` turns off.
|
`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
|
## AI disclosure
|
||||||
|
|
||||||
|
|||||||
-11
@@ -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.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// Package lazysecrets holds what the module's root has for the app: the
|
|
||||||
// changelog, embedded so the status pane can show it offline.
|
|
||||||
package lazysecrets
|
|
||||||
|
|
||||||
import _ "embed"
|
|
||||||
|
|
||||||
// Changelog is CHANGELOG.md as of the build: every release up to it.
|
|
||||||
//
|
|
||||||
//go:embed CHANGELOG.md
|
|
||||||
var Changelog string
|
|
||||||
+1
-8
@@ -24,8 +24,7 @@ Hello world 👋
|
|||||||
{% for group, commits in commits | group_by(attribute="group") %}
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
### {{ group | striptags | trim }}
|
### {{ group | striptags | trim }}
|
||||||
{% for commit in commits %}
|
{% for commit in commits %}
|
||||||
- {% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | split(pat="\n") | first | upper_first | trim }} \
|
- {% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | split(pat="\n") | first | upper_first | trim }}\
|
||||||
([{{ commit.id | truncate(length=7, end="") }}](https://git.kebcloud.no/keb/lazysecrets/commit/{{ commit.id }}))\
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}\n
|
{% endfor %}\n
|
||||||
"""
|
"""
|
||||||
@@ -34,8 +33,6 @@ trim = true
|
|||||||
# Runs on each part (header, every release) separately.
|
# Runs on each part (header, every release) separately.
|
||||||
postprocessors = [
|
postprocessors = [
|
||||||
{ pattern = "\n{3,}", replace = "\n\n" },
|
{ pattern = "\n{3,}", replace = "\n\n" },
|
||||||
# Squash commits end in the PR's number: link it.
|
|
||||||
{ pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://git.kebcloud.no/keb/lazysecrets/pulls/${1}))" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[git]
|
[git]
|
||||||
@@ -49,10 +46,6 @@ commit_preprocessors = [
|
|||||||
# A merge commit (not a squash) says "Merge pull request 'feat: …' (#4)
|
# A merge commit (not a squash) says "Merge pull request 'feat: …' (#4)
|
||||||
# from branch into main": keep the title and number.
|
# from branch into main": keep the title and number.
|
||||||
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
|
{ 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
|
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
|
||||||
# pr-title.yml); all become "feat: x".
|
# 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}: " },
|
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
|
||||||
|
|||||||
+77
-9
@@ -1,7 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
@@ -30,17 +33,82 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// value reads what r points at (ref.Read), with the account that last
|
// value reads what r points at without listing its vault: the vault and
|
||||||
// read it first.
|
// 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) {
|
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
|
||||||
v, _, err := readKey(ctx, providers, st, r)
|
if r.IsSource() || r.IsContainer() {
|
||||||
return v, err
|
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 == "" {
|
||||||
// readKey is value, also returning the version read.
|
return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
|
||||||
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
}
|
||||||
key := r
|
key := r
|
||||||
key.Version = ""
|
key.Version = ""
|
||||||
node := st.Node(key.String())
|
node := st.Node(key.String())
|
||||||
return ref.Read(ctx, providers, r, node.Account, node.Source)
|
|
||||||
|
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: 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...)
|
||||||
}
|
}
|
||||||
+28
-109
@@ -3,9 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -18,7 +16,6 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/instance"
|
"git.kebcloud.no/keb/lazysecrets/internal/instance"
|
||||||
@@ -108,7 +105,7 @@ func run() error {
|
|||||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
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)")
|
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")
|
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,
|
icons := flag.Bool("icons", true,
|
||||||
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
|
"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)")
|
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
|
||||||
@@ -116,14 +113,10 @@ func run() error {
|
|||||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||||
lazysecrets [flags] browse secrets
|
lazysecrets [flags] browse secrets
|
||||||
lazysecrets [flags] [open] <ref>
|
lazysecrets [flags] [open] <ref>
|
||||||
open a lazysecrets:// link (also a share link),
|
open a lazysecrets:// link, Key Vault URL,
|
||||||
Key Vault URL, @Microsoft.KeyVault(...) or
|
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
||||||
{{secrets/scope/key}}
|
|
||||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||||
for scripts: VAR=$(lazysecrets get <ref>)
|
for scripts: VAR=$(lazysecrets get <ref>)
|
||||||
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
|
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||||
config applied), as config to copy and edit
|
config applied), as config to copy and edit
|
||||||
lazysecrets handler install [--terminal NAME]
|
lazysecrets handler install [--terminal NAME]
|
||||||
@@ -146,14 +139,8 @@ Flags:
|
|||||||
}
|
}
|
||||||
|
|
||||||
var open, getRef *ref.Ref
|
var open, getRef *ref.Ref
|
||||||
var shareArgs []string
|
|
||||||
printKeys := false
|
printKeys := false
|
||||||
switch args := flag.Args(); {
|
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) == 1 && args[0] == "keybindings":
|
case len(args) == 1 && args[0] == "keybindings":
|
||||||
printKeys = true
|
printKeys = true
|
||||||
case len(args) == 2 && args[0] == "get":
|
case len(args) == 2 && args[0] == "get":
|
||||||
@@ -167,7 +154,7 @@ Flags:
|
|||||||
case len(args) > 0 && args[0] == "launch":
|
case len(args) > 0 && args[0] == "launch":
|
||||||
return launch(*configPath, args[1:])
|
return launch(*configPath, args[1:])
|
||||||
case len(args) > 0 && args[0] == "update":
|
case len(args) > 0 && args[0] == "update":
|
||||||
return updateCmd(*configPath, args[1:])
|
return updateCmd(args[1:])
|
||||||
case len(args) == 0:
|
case len(args) == 0:
|
||||||
case args[0] == "open" && len(args) == 2:
|
case args[0] == "open" && len(args) == 2:
|
||||||
r, err := ref.Parse(args[1])
|
r, err := ref.Parse(args[1])
|
||||||
@@ -195,11 +182,6 @@ Flags:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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()
|
keymap := keys.Default()
|
||||||
if err := keymap.Apply(cfg.Keybinding); err != nil {
|
if err := keymap.Apply(cfg.Keybinding); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -214,52 +196,30 @@ Flags:
|
|||||||
// Precedence: flag > environment > config file > default.
|
// Precedence: flag > environment > config file > default.
|
||||||
set := map[string]bool{}
|
set := map[string]bool{}
|
||||||
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
|
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
|
||||||
if *timeout <= 0 {
|
if !set["timeout"] && cfg.Timeout > 0 {
|
||||||
return fmt.Errorf("--timeout: %s is not positive", *timeout)
|
*timeout = cfg.Timeout
|
||||||
}
|
}
|
||||||
// Each provider's own timeout, unless --timeout sets them all.
|
if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
|
||||||
timeouts := map[provider.Kind]time.Duration{}
|
*clipClear = *cfg.ClipboardClear
|
||||||
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
|
|
||||||
}
|
}
|
||||||
expiryDays := 30
|
expiryDays := 30
|
||||||
if cfg.UI.ExpiryWarningDays != nil {
|
if cfg.ExpiryWarningDays != nil {
|
||||||
expiryDays = *cfg.UI.ExpiryWarningDays
|
expiryDays = *cfg.ExpiryWarningDays
|
||||||
}
|
}
|
||||||
revealTimeout := 60 * time.Second
|
revealTimeout := 60 * time.Second
|
||||||
if cfg.Values.RevealTimeout != nil {
|
if cfg.RevealTimeout != nil {
|
||||||
revealTimeout = *cfg.Values.RevealTimeout
|
revealTimeout = *cfg.RevealTimeout
|
||||||
}
|
}
|
||||||
loadDelay := 150 * time.Millisecond
|
loadDelay := 150 * time.Millisecond
|
||||||
if cfg.UI.LoadDelay != nil {
|
if cfg.LoadDelay != nil {
|
||||||
loadDelay = *cfg.UI.LoadDelay
|
loadDelay = *cfg.LoadDelay
|
||||||
}
|
|
||||||
var updateEvery time.Duration // 0: the app's default, a day
|
|
||||||
if cfg.Updates.AutoCheckInterval != nil {
|
|
||||||
updateEvery = *cfg.Updates.AutoCheckInterval
|
|
||||||
}
|
}
|
||||||
if !set["icons"] {
|
if !set["icons"] {
|
||||||
switch {
|
switch {
|
||||||
case os.Getenv("LAZYSECRETS_ICONS") != "":
|
case os.Getenv("LAZYSECRETS_ICONS") != "":
|
||||||
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
|
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
|
||||||
case cfg.UI.Icons != nil:
|
case cfg.Icons != nil:
|
||||||
*icons = *cfg.UI.Icons
|
*icons = *cfg.Icons
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,9 +234,7 @@ Flags:
|
|||||||
statePath = filepath.Join(stateDir, "state.json")
|
statePath = filepath.Join(stateDir, "state.json")
|
||||||
}
|
}
|
||||||
st, err := store.Load(statePath)
|
st, err := store.Load(statePath)
|
||||||
if errors.Is(err, store.ErrNewer) {
|
if err != nil {
|
||||||
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,10 +242,9 @@ Flags:
|
|||||||
if *demo {
|
if *demo {
|
||||||
// Two Azure accounts, like a personal and an admin login in az.
|
// Two Azure accounts, like a personal and an admin login in az.
|
||||||
providers = []provider.Provider{
|
providers = []provider.Provider{
|
||||||
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
|
fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"),
|
||||||
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
|
fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"),
|
||||||
fake.Databricks(600 * time.Millisecond),
|
fake.Databricks(600 * time.Millisecond),
|
||||||
fake.Repos(),
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pc := cfg.Providers
|
pc := cfg.Providers
|
||||||
@@ -318,7 +275,7 @@ Flags:
|
|||||||
if start == "" {
|
if start == "" {
|
||||||
start, _ = os.Getwd()
|
start, _ = os.Getwd()
|
||||||
}
|
}
|
||||||
roots := repo.DefaultRoots
|
roots := []string{"~/git"}
|
||||||
if pc.Repos.Roots != nil {
|
if pc.Repos.Roots != nil {
|
||||||
roots = *pc.Repos.Roots
|
roots = *pc.Repos.Roots
|
||||||
}
|
}
|
||||||
@@ -341,21 +298,10 @@ Flags:
|
|||||||
log.SetOutput(io.Discard)
|
log.SetOutput(io.Discard)
|
||||||
|
|
||||||
if getRef != nil {
|
if getRef != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return get(ctx, providers, st, *getRef)
|
return get(ctx, providers, st, *getRef)
|
||||||
}
|
}
|
||||||
if shareArgs != nil {
|
|
||||||
// The files from a repo, the key from whichever provider it's
|
|
||||||
// in: the longest timeout.
|
|
||||||
d := *timeout
|
|
||||||
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.
|
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||||
var p *tea.Program
|
var p *tea.Program
|
||||||
@@ -391,7 +337,7 @@ Flags:
|
|||||||
// What was loaded last time, and where it was left in this folder.
|
// What was loaded last time, and where it was left in this folder.
|
||||||
var cacheFile, placeDir string
|
var cacheFile, placeDir string
|
||||||
if !*demo {
|
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")
|
cacheFile = filepath.Join(d, "cache.json")
|
||||||
}
|
}
|
||||||
placeDir = *dir
|
placeDir = *dir
|
||||||
@@ -405,33 +351,25 @@ Flags:
|
|||||||
|
|
||||||
m := app.New(providers, app.Options{
|
m := app.New(providers, app.Options{
|
||||||
Timeout: *timeout,
|
Timeout: *timeout,
|
||||||
Timeouts: timeouts,
|
|
||||||
Icons: *icons,
|
Icons: *icons,
|
||||||
ClipboardClear: *clipClear,
|
ClipboardClear: *clipClear,
|
||||||
FlatSources: cfg.UI.SourcesView == "flat",
|
FlatSources: cfg.SourcesView == "flat",
|
||||||
CollapseGroups: config.On(cfg.UI.CollapseGroups),
|
CollapseGroups: config.On(cfg.CollapseGroups),
|
||||||
LoadDelay: loadDelay,
|
LoadDelay: loadDelay,
|
||||||
NoHistory: !config.On(cfg.Storage.History),
|
NoHistory: !config.On(cfg.History),
|
||||||
OnActive: onActive,
|
OnActive: onActive,
|
||||||
RevealTimeout: revealTimeout,
|
RevealTimeout: revealTimeout,
|
||||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||||
HideOnBlur: config.On(cfg.Values.HideOnBlur),
|
HideOnBlur: config.On(cfg.HideOnBlur),
|
||||||
HandlerSetup: setup,
|
HandlerSetup: setup,
|
||||||
Updater: updater(cfg.Updates.Sources),
|
Updater: updater(),
|
||||||
UpdateCheck: config.On(cfg.Updates.AutoCheck),
|
UpdateCheck: config.On(cfg.UpdateCheck),
|
||||||
UpdateEvery: updateEvery,
|
|
||||||
Keys: &keymap,
|
Keys: &keymap,
|
||||||
Commands: cfg.CustomCommands,
|
Commands: cfg.CustomCommands,
|
||||||
Version: versionString(),
|
Version: versionString(),
|
||||||
Store: st,
|
Store: st,
|
||||||
Warning: strings.Join(warnings, " · "),
|
|
||||||
CacheFile: cacheFile,
|
CacheFile: cacheFile,
|
||||||
ConfigFiles: configFiles(*configPath),
|
|
||||||
StatePath: statePath,
|
|
||||||
Changelog: lazysecrets.Changelog,
|
|
||||||
Dir: placeDir,
|
Dir: placeDir,
|
||||||
ShareKey: cfg.Sharing.Key,
|
|
||||||
ShareRules: shareRules(cfg),
|
|
||||||
Open: open,
|
Open: open,
|
||||||
Styles: &theme,
|
Styles: &theme,
|
||||||
DefaultFg: defaultFg,
|
DefaultFg: defaultFg,
|
||||||
@@ -443,22 +381,3 @@ Flags:
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// configFiles are the config files given, for the status pane: those
|
|
||||||
// that exist, or the first if none does (where to create one).
|
|
||||||
func configFiles(list string) []string {
|
|
||||||
var all, found []string
|
|
||||||
for _, f := range strings.Split(list, ",") {
|
|
||||||
if f = strings.TrimSpace(f); f == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
all = append(all, f)
|
|
||||||
if _, err := os.Stat(f); err == nil {
|
|
||||||
found = append(found, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(found) == 0 && len(all) > 0 {
|
|
||||||
return all[:1]
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,16 +7,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
// updater is the app's way to find and install releases, replacing the
|
// updater is the app's way to find and install releases, replacing the
|
||||||
// binary that's running, from the servers (config releases) or the
|
// binary that's running.
|
||||||
// default ones.
|
func updater() *app.Updater {
|
||||||
func updater(servers []string) *app.Updater {
|
c := &update.Client{}
|
||||||
c := &update.Client{Servers: servers}
|
|
||||||
return &app.Updater{
|
return &app.Updater{
|
||||||
Latest: c.Latest,
|
Latest: c.Latest,
|
||||||
Install: func(ctx context.Context, r update.Release) error {
|
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
|
// updateCmd is lazysecrets update [--check]: the newer release's
|
||||||
// changelog, then (unless --check) install it.
|
// changelog, then (unless --check) install it.
|
||||||
func updateCmd(configPath string, args []string) error {
|
func updateCmd(args []string) error {
|
||||||
check := len(args) == 1 && args[0] == "--check"
|
check := len(args) == 1 && args[0] == "--check"
|
||||||
if len(args) > 0 && !check {
|
if len(args) > 0 && !check {
|
||||||
return errors.New("usage: lazysecrets update [--check]")
|
return errors.New("usage: lazysecrets update [--check]")
|
||||||
}
|
}
|
||||||
cfg, err := config.LoadAll(strings.Split(configPath, ","))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
current := versionString()
|
current := versionString()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
c := &update.Client{Servers: cfg.Updates.Sources}
|
c := &update.Client{}
|
||||||
r, err := c.Latest(ctx)
|
r, err := c.Latest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
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)
|
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
|
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes()))
|
||||||
if check {
|
if check {
|
||||||
fmt.Println("lazysecrets update installs it.")
|
fmt.Println("lazysecrets update installs it.")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 232 KiB |
+5
-29
@@ -13,18 +13,8 @@ is listed under each, since their permissions can differ.
|
|||||||
A service principal is used instead when `AZURE_CLIENT_ID`,
|
A service principal is used instead when `AZURE_CLIENT_ID`,
|
||||||
`AZURE_TENANT_ID` and a secret or certificate are set in the environment.
|
`AZURE_TENANT_ID` and a secret or certificate are set in the environment.
|
||||||
|
|
||||||
On an account's group row, the fourth pane shows who it signs in as: name,
|
If your az login expires mid-session, the status line says so: press `L` to
|
||||||
user, tenant, object ID, whether the session used MFA, directory roles
|
run `az login` without leaving lazysecrets, and everything reloads.
|
||||||
(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.
|
|
||||||
|
|
||||||
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`
|
|
||||||
there to run `az login` for it without leaving lazysecrets, and everything
|
|
||||||
reloads.
|
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
@@ -38,10 +28,8 @@ vault resources (e.g. *Reader*). Key Vault data-plane access is separate
|
|||||||
| read secret values | *Key Vault Secrets User* |
|
| read secret values | *Key Vault Secrets User* |
|
||||||
| read certificate details | *Key Vault Certificate User* |
|
| read certificate details | *Key Vault Certificate User* |
|
||||||
|
|
||||||
A missing permission is shown in the pane, shortened to what was denied
|
A missing permission is shown in the pane, including the action that was
|
||||||
(the action, or the access policy permission), with what to check: a
|
denied.
|
||||||
role or group to activate in PIM, access from the vault's owner, or, when
|
|
||||||
its firewall refused, the network you're on.
|
|
||||||
|
|
||||||
## Vaults you can't discover
|
## Vaults you can't discover
|
||||||
|
|
||||||
@@ -63,16 +51,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
|
(`[`/`]`). `space` reveals a secret's value, or the public material of a
|
||||||
key (PEM public key) or certificate; private key material is never
|
key (PEM public key) or certificate; private key material is never
|
||||||
fetched. `v` lists an item's versions. Items expiring within
|
fetched. `v` lists an item's versions. Items expiring within
|
||||||
`ui.expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
`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.
|
|
||||||
+26
-55
@@ -2,58 +2,39 @@
|
|||||||
|
|
||||||
Optional, at `~/.config/lazysecrets/config.yaml` (or
|
Optional, at `~/.config/lazysecrets/config.yaml` (or
|
||||||
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
|
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
|
||||||
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the
|
it. Unknown keys are an error, so typos don't go unnoticed.
|
||||||
status pane (`0`) opens it in your editor, as in lazygit (making it if
|
|
||||||
it isn't there yet); changes apply when lazysecrets starts again.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ui:
|
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
||||||
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
|
||||||
sourcesView: grouped # or flat; ` toggles it
|
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
||||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
||||||
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); expired ones get ✗
|
||||||
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
|
timeout: 30s # per backend request
|
||||||
values:
|
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
|
||||||
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
sourcesView: grouped # or flat; ` toggles it
|
||||||
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
||||||
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
|
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
|
||||||
storage:
|
history: true # count uses of items for Recent (R)
|
||||||
cache: true # keep what was loaded (never values) for the next start
|
cache: true # keep what was loaded (never values) for the next start
|
||||||
history: true # count uses of items for Recent (R)
|
|
||||||
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
|
|
||||||
providers:
|
providers:
|
||||||
timeout: 30s # per backend request; a provider's own timeout overrides it
|
|
||||||
azure: # applies to every az account
|
azure: # applies to every az account
|
||||||
enabled: true # default
|
enabled: true # default
|
||||||
timeout: 30s
|
|
||||||
hideSubscriptions: [platform-mgmt] # by name or ID
|
hideSubscriptions: [platform-mgmt] # by name or ID
|
||||||
vaults: # listed under "Pinned vaults", for vaults
|
vaults: # listed under "Pinned vaults", for vaults
|
||||||
- https://kv-foo.vault.azure.net/ # you can read but not discover
|
- https://kv-foo.vault.azure.net/ # you can read but not discover
|
||||||
databricks:
|
databricks:
|
||||||
enabled: true
|
enabled: true
|
||||||
timeout: 30s
|
|
||||||
hideProfiles: [test]
|
hideProfiles: [test]
|
||||||
repos:
|
repos:
|
||||||
enabled: true
|
enabled: true
|
||||||
timeout: 30s
|
|
||||||
roots: [~/git]
|
roots: [~/git]
|
||||||
paths: [~/work/infra]
|
paths: [~/work/infra]
|
||||||
include: ["secrets/**"]
|
include: ["secrets/**"]
|
||||||
exclude: ["**/testdata/**"]
|
exclude: ["**/testdata/**"]
|
||||||
sharing: # sharing env files as encrypted links
|
|
||||||
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
|
|
||||||
keyRules: # what a key's value must be
|
|
||||||
minLength: 32 # characters, 16 at least
|
|
||||||
minClasses: 2 # kinds: lowercase, uppercase, digits, symbols
|
|
||||||
```
|
```
|
||||||
|
|
||||||
More, each on its own page: [repos](repos.md) (`providers.repos`),
|
More, each on its own page: [repos](repos.md) (`providers.repos`), the
|
||||||
[sharing env files](links.md#sharing-env-files) (`sharing`), the
|
|
||||||
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
||||||
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
||||||
[custom commands](custom-commands.md) (`customCommands`).
|
[custom commands](custom-commands.md) (`customCommands`).
|
||||||
@@ -75,7 +56,7 @@ theme:
|
|||||||
activeBorderColor: ["#ff966c", bold] # focused pane
|
activeBorderColor: ["#ff966c", bold] # focused pane
|
||||||
inactiveBorderColor: ["#589ed7"]
|
inactiveBorderColor: ["#589ed7"]
|
||||||
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
|
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
|
||||||
inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold)
|
inactiveViewSelectedLineBgColor: ["#2f334d"]
|
||||||
defaultFgColor: ["#c8d3f5"]
|
defaultFgColor: ["#c8d3f5"]
|
||||||
optionsTextColor: ["#82aaff"] # keys and labels
|
optionsTextColor: ["#82aaff"] # keys and labels
|
||||||
searchMatchColor: ["#ffc777", bold]
|
searchMatchColor: ["#ffc777", bold]
|
||||||
@@ -91,11 +72,8 @@ theme:
|
|||||||
repoColor: ["#c099ff"]
|
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
|
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
|
## Layering
|
||||||
|
|
||||||
@@ -119,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
|
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
|
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.
|
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
|
Revealing, copying, opening in the browser, sharing and jumping to an item
|
||||||
count as using it: its record keeps when it was last used and how often,
|
count as using it: its record keeps when it was last used and how often,
|
||||||
for Recent (`R`). Records kept only for usage are dropped after 90 days
|
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
|
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
|
source, vault or secret), and goes back there the next time it starts in
|
||||||
@@ -138,29 +114,24 @@ What lazysecrets has loaded is kept in
|
|||||||
you: the Sources lists, vaults and scopes, and secrets' names and metadata
|
you: the Sources lists, vaults and scopes, and secrets' names and metadata
|
||||||
(tags, dates, references), never values. On the next start they show at
|
(tags, dates, references), never values. On the next start they show at
|
||||||
once, and anything older than two minutes reloads in the background when
|
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
|
you go to it, or when Go to searches it. Revealing and copying always
|
||||||
bottom border says how old what it shows is (`cached 3h ago`). Revealing
|
fetch the value live.
|
||||||
and copying always fetch the value live.
|
|
||||||
|
|
||||||
Lists not loaded for 30 days are dropped, and so is everything from an
|
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
|
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
|
are read from disk anyway. Deleting the file is always safe, and
|
||||||
`storage.cache: false` stops using it.
|
`cache: false` stops using it.
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
At startup, in the background, lazysecrets asks for the latest release
|
Once a day (`updateCheck`), in the background, lazysecrets asks the
|
||||||
(`updates.autoCheck`), once a day or every `updates.autoCheckInterval`;
|
release server for the latest release; within the day it reuses the
|
||||||
until then it reuses the answer from the state file. Releases are
|
answer from the state file. Offline, or with the server down, it says
|
||||||
published on GitHub and on git.kebcloud.no, and it asks them in that
|
nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
|
||||||
order, the next when one doesn't answer within 5 seconds;
|
`lazysecrets update --check`) shows its changelog.
|
||||||
`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.
|
|
||||||
|
|
||||||
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
||||||
your OS and CPU from the first server that has it, checks it against
|
your OS and CPU, checks it against the release's `checksums.txt`, makes
|
||||||
that server's `checksums.txt`, makes
|
|
||||||
sure it runs, and only then replaces the binary, atomically: a
|
sure it runs, and only then replaces the binary, atomically: a
|
||||||
lazysecrets that's running keeps working, and the next start is the new
|
lazysecrets that's running keeps working, and the next start is the new
|
||||||
version. A binary a package manager owns (Homebrew, Nix, a distro
|
version. A binary a package manager owns (Homebrew, Nix, a distro
|
||||||
|
|||||||
@@ -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
|
Databricks refuses, lazysecrets reads the backing vault directly with your
|
||||||
az login (which then needs the [Key Vault roles](azure.md#permissions)).
|
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):
|
Hide profiles you don't want in the [config](config.md):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+3
-12
@@ -7,11 +7,6 @@ go test ./...
|
|||||||
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
|
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:
|
A stripped binary is about 21 MB instead of 30 MB:
|
||||||
|
|
||||||
```sh
|
```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
|
uncommitted changes); `go install …@v0.2.0` shows the tag, releases the
|
||||||
version they were built for.
|
version they were built for.
|
||||||
|
|
||||||
## How changes land
|
## Pull requests
|
||||||
|
|
||||||
Pull requests into `main` are squash-merged, so each is one commit titled
|
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)
|
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
|
(`.gitea/workflows/release.yml`): it tests, then
|
||||||
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
|
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
|
||||||
archives and publishes a Gitea release with them and the release's
|
archives and publishes a Gitea release with them and the release's
|
||||||
changelog section. Then `scripts/github-release.mjs` copies that release,
|
changelog section. `goreleaser release --snapshot --clean` builds the same
|
||||||
notes and files, to the GitHub mirror, with the `GH_RELEASE_TOKEN`
|
archives locally, into `dist/`, without publishing.
|
||||||
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.
|
|
||||||
|
|
||||||
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
|
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
|
||||||
alone.
|
alone.
|
||||||
+13
-43
@@ -13,8 +13,7 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `j`/`k`, `↑`/`↓` | move |
|
| `j`/`k`, `↑`/`↓` | move |
|
||||||
| `g`/`G` | top / bottom |
|
| `g`/`G` | top / bottom |
|
||||||
| `tab`, `shift+tab`/`h` | next / previous pane |
|
| `tab`/`l`, `shift+tab`/`h` | next / previous pane |
|
||||||
| `l` | the details of what's selected, on the right (`h` goes back) |
|
|
||||||
| `1`–`4` | jump to pane |
|
| `1`–`4` | jump to pane |
|
||||||
| `enter` / `esc`, `⌫` | drill in / back (also clears a filter or leaves the versions view) |
|
| `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 |
|
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
|
||||||
@@ -49,24 +48,18 @@ match case exactly.
|
|||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
||||||
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
|
| `y` | copy the value without revealing it; cleared from the clipboard after 30s |
|
||||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
||||||
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
||||||
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
||||||
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
| `s` / `S` | copy a [link](links.md) to the selected item / share menu |
|
||||||
| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-files)) |
|
|
||||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||||
| `x` | [custom commands](custom-commands.md) |
|
| `x` | [custom commands](custom-commands.md) |
|
||||||
| `L` | sign in again (shown when a login has expired) |
|
| `L` | sign in again (shown when a login has expired) |
|
||||||
| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one |
|
| `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
|
A revealed value hides again after `revealTimeout` (60s) and when the
|
||||||
certificates as their subject, issuer, dates, names and thumbprint, with
|
terminal loses focus (`hideOnBlur`); see [config](config.md).
|
||||||
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).
|
|
||||||
|
|
||||||
## Stars and notes
|
## Stars and notes
|
||||||
|
|
||||||
@@ -84,24 +77,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
|
starred) and `R` on Recent (what you used last). `enter` goes to the
|
||||||
selected item.
|
selected item.
|
||||||
|
|
||||||
Search has the scope on the left and what's in it on the right. The
|
Search has the scope on the left (All; all of Azure, Databricks or repos;
|
||||||
scopes are All, then grouped like the Sources pane: each account (or
|
or one source; with `○ ◐ ●` for how much of it is loaded) and what's in
|
||||||
Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how
|
it on the right. All repos searches every repo without asking Azure or
|
||||||
much of each is loaded. Repos searches every repo without asking Azure
|
Databricks for anything. It opens on the
|
||||||
or Databricks for anything. Go to opens on All; `ctrl+s` again narrows
|
source you're in. Typing a search loads what it covers: everything on
|
||||||
it to the source or group selected in the Sources pane, and once more
|
All, else the source picked; results appear as lists arrive. That's once
|
||||||
goes back to All. Typing a search loads what it covers: everything on
|
each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads
|
||||||
All, else the account or source picked; results appear as lists
|
again, lists that failed included.
|
||||||
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:
|
Like a pane, Go to is either browsing or typing a filter:
|
||||||
|
|
||||||
| Browsing | Typing | Action |
|
| Browsing | Typing | Action |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `j`/`k` | `ctrl+j`/`ctrl+k` | move |
|
| `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 |
|
| `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
|
||||||
| `[` / `]` | | previous / next tab |
|
| `[` / `]` | | previous / next tab |
|
||||||
| `f` | `ctrl+f` | star |
|
| `f` | `ctrl+f` | star |
|
||||||
@@ -115,25 +104,6 @@ anything loads.
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
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
|
|
||||||
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.
|
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane |
|
| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane |
|
||||||
|
|||||||
+8
-82
@@ -1,10 +1,9 @@
|
|||||||
# Links
|
# Links
|
||||||
|
|
||||||
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
|
Every subscription, workspace, vault, scope, repo, file and secret has a
|
||||||
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
|
`lazysecrets://` link. Share one in chat or a ticket, and whoever opens it
|
||||||
it lands on that item, reading it with their own account and
|
lands on that item, reading it with their own account and permissions:
|
||||||
permissions: the link names the item, never its value. Env files can
|
links carry names, never values.
|
||||||
also be [shared encrypted](#sharing-env-files), in the link itself.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
lazysecrets://azure/kv-app-prod.vault.azure.net vault
|
lazysecrets://azure/kv-app-prod.vault.azure.net vault
|
||||||
@@ -21,83 +20,10 @@ lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
|||||||
that version). `S` opens the share menu:
|
that version). `S` opens the share menu:
|
||||||
|
|
||||||
- secrets: the link (`o`), the plain Key Vault URL (`u`), the config
|
- 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
|
reference (`y`) or the web page (`b`)
|
||||||
an [encrypted link](#sharing-env-files) (`f`)
|
|
||||||
- vaults and scopes: a link (`o`), a link that opens on the current tab
|
- 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`)
|
(`t`), the plain vault URL (`u`) or the web page (`b`)
|
||||||
- subscriptions and workspaces: a link (`o`) 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
|
## Opening
|
||||||
|
|
||||||
@@ -118,9 +44,9 @@ read the clipboard over SSH.
|
|||||||
|
|
||||||
lazysecrets finds the vault in your accounts (one Resource Graph query per
|
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
|
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,
|
it. Links say what, not what to do: opening a link to a secret asks whether
|
||||||
not what to do: opening a link to a secret asks whether to just go to it,
|
to just go to it, also copy or show its value, or (`y`) copy its value
|
||||||
also copy or show its value, or (`y`) copy its value without going there.
|
without going there.
|
||||||
|
|
||||||
## In scripts
|
## In scripts
|
||||||
|
|
||||||
|
|||||||
+6
-43
@@ -3,16 +3,14 @@
|
|||||||
lazysecrets shows git repositories under **Repos**: their
|
lazysecrets shows git repositories under **Repos**: their
|
||||||
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
||||||
`.env`, INI) and plain env files as vaults, and the keys in them
|
`.env`, INI) 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
|
(`db.password`, `list[0]`) as secrets.
|
||||||
escaped, `app\.name`, so it doesn't read as nesting.
|
|
||||||
|
|
||||||
## Which repos
|
## Which repos
|
||||||
|
|
||||||
**Repos** lists every repo with secret files under the `roots` (by
|
**Repos** lists every repo with secret files under the `roots` (`~/git`
|
||||||
default whichever of `~/git`, `~/repo`, `~/repos`, `~/projects`, `~/code`,
|
by default; one or two levels down, `repo` or `org/repo`), any listed in
|
||||||
`~/source` and `~/src` exist; one or two levels down, `repo` or
|
`paths`, and any you starred something in (the repo, a file or a key),
|
||||||
`org/repo`), any listed in `paths`, and any you starred something in
|
wherever it is. They're checked in the background.
|
||||||
(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,
|
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
|
`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
|
```yaml
|
||||||
providers:
|
providers:
|
||||||
repos:
|
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
|
paths: [~/work/infra] # single repos elsewhere
|
||||||
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
|
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
|
||||||
exclude: ["**/testdata/**"]
|
exclude: ["**/testdata/**"]
|
||||||
```
|
```
|
||||||
|
|
||||||
A repo's `.lazysecrets.yaml` can also name the secret its env files are
|
|
||||||
[shared](links.md#sharing-env-files) with:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
sharing:
|
|
||||||
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
|
||||||
```
|
|
||||||
|
|
||||||
## Decrypting
|
## Decrypting
|
||||||
|
|
||||||
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
||||||
@@ -73,35 +63,8 @@ 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
|
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.
|
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:
|
|
||||||
|
|
||||||
- **SOPS files** open with `sops edit`, which decrypts them into your
|
|
||||||
editor (`$SOPS_EDITOR` or `$EDITOR`) and encrypts them again when you
|
|
||||||
save. lazysecrets comes back after, with the file's keys reloaded.
|
|
||||||
- **Other files**, when lazysecrets runs inside nvim (its plugin, or
|
|
||||||
`:terminal`): lazysecrets quits and the file opens in that nvim, in the
|
|
||||||
window you came from.
|
|
||||||
- Otherwise in `$VISUAL` or `$EDITOR` in the terminal, coming back after;
|
|
||||||
without either, in the system's default app for the file.
|
|
||||||
|
|
||||||
Editors that take a line (vim, nvim, nano, emacs, micro, helix, VS Code,
|
|
||||||
Zed, Sublime and others) start on the key. With `sops edit` that works for
|
|
||||||
editors taking `+N` (vim, nvim, nano, emacs, micro and others), and needs
|
|
||||||
sops 3.8 or later.
|
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files).
|
|
||||||
|
|
||||||
Links name the repo by its git remote, so they open in a colleague's clone
|
Links name the repo by its git remote, so they open in a colleague's clone
|
||||||
wherever it is:
|
wherever it is:
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,41 @@
|
|||||||
module git.kebcloud.no/keb/lazysecrets
|
module git.kebcloud.no/keb/lazysecrets
|
||||||
|
|
||||||
go 1.27.2
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.2.1
|
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
|
charm.land/lipgloss/v2 v2.0.6
|
||||||
filippo.io/age v1.3.2
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3
|
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.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/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/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/azkeys v1.5.0
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets 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/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/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
|
||||||
github.com/databricks/databricks-sdk-go v0.188.0
|
github.com/databricks/databricks-sdk-go v0.187.0
|
||||||
github.com/junegunn/fzf v0.74.5
|
github.com/junegunn/fzf v0.74.4
|
||||||
go.yaml.in/yaml/v3 v3.0.5
|
go.yaml.in/yaml/v3 v3.0.5
|
||||||
golang.org/x/mod v0.42.0
|
golang.org/x/mod v0.38.0
|
||||||
golang.org/x/term v0.47.0
|
golang.org/x/term v0.45.0
|
||||||
gopkg.in/ini.v1 v1.67.3
|
gopkg.in/ini.v1 v1.67.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/auth v0.18.2 // indirect
|
cloud.google.com/go/auth v0.4.2 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 // 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.12.0 // indirect
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect
|
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.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/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
|
||||||
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // 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/term v0.2.2 // indirect
|
||||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/windows v0.2.2 // 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/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // 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/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/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
|
||||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
|
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // 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/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // 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/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
golang.org/x/crypto v0.57.0 // indirect
|
golang.org/x/crypto v0.55.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
|
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
|
||||||
golang.org/x/net v0.60.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.49.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.5.0 // indirect
|
||||||
google.golang.org/api v0.264.0 // indirect
|
google.golang.org/api v0.182.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/grpc v1.83.2 // indirect
|
google.golang.org/grpc v1.79.3 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
)
|
)
|
||||||
@@ -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 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
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.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
|
||||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
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 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
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 v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
cloud.google.com/go/auth v0.4.2 h1:sb0eyLkhRtpq5jA+a8KWw0W70YcdVca7KJ8TM0AFYDg=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
cloud.google.com/go/auth v0.4.2/go.mod h1:Kqvlz1cf1sNA0D+sYJnkPQOP+JMHkuHeIgVmCRtZOLc=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
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 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||||
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo=
|
||||||
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
|
||||||
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/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
|
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 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 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/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.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0/go.mod h1:7Ut2Qw9vcwpLW5Q4MBy43uSxXS7tK2yjs94OJ6j5thE=
|
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/v3 v3.2.0 h1:+lnLQhKh3cgSOIOVH61UZ3s/l9d+bAZp5d/spt1+7UI=
|
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/v3 v3.2.0/go.mod h1:tStOHrivWUrcBolspvKV70Us1ckESYGYSHdG4LX8zyY=
|
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/keyvault/armkeyvault/v2 v2.1.0 h1:4dT2wTR+ViaGvwcAoMjqt0BvgwBkT6nXxg8flJhnLfw=
|
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/keyvault/armkeyvault/v2 v2.1.0/go.mod h1:7fgu6AtY2gkWhtZt9SEHK/MkTVUn2HPvAZjv6XsrjuI=
|
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 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/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/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
|
||||||
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 v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
|
||||||
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/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY=
|
||||||
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 v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo=
|
||||||
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/security/keyvault/azcertificates v1.5.0 h1:15MiSirdxWpIKFEHwfwfMWYieETCUnPTERPIlnytQeo=
|
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/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=
|
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-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 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI=
|
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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
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 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
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-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
|
||||||
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||||
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
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 h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw=
|
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=
|
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/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 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
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 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
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/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/databricks/databricks-sdk-go v0.188.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
|
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.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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
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=
|
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/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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
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.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
|
||||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI=
|
||||||
github.com/junegunn/fzf v0.74.5 h1:mpJlCdgGuddKerHrdjvV3cnezEBgyjZAVn8KCo/4OMc=
|
github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso=
|
||||||
github.com/junegunn/fzf v0.74.5/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
|
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 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
|
||||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
|
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=
|
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/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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
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.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
|
||||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
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/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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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.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.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.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.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.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.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
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 v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
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.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
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.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
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 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
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 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
|
||||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
|
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/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
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=
|
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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
|
google.golang.org/api v0.182.0 h1:if5fPvudRQ78GeRx3RayIoiuV7modtErPIZC/T2bIvE=
|
||||||
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
|
google.golang.org/api v0.182.0/go.mod h1:cGhjy4caqA5yXRzEhkHI8Y9mfyC2VLTlER2l08xaqtM=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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/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.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1/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
@@ -7,12 +7,10 @@
|
|||||||
#
|
#
|
||||||
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
|
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
|
||||||
# and x86_64.
|
# 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
|
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}"
|
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
@@ -20,13 +18,12 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Short timeouts, so a server that's down leaves time for the next.
|
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; }
|
fetch() { curl -fsSL "$1"; }
|
||||||
download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; }
|
download() { curl -fsSL -o "$2" "$1"; }
|
||||||
elif command -v wget >/dev/null 2>&1; then
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
fetch() { wget -qO- -T 15 "$1"; }
|
fetch() { wget -qO- "$1"; }
|
||||||
download() { wget -qO "$2" -T 30 "$1"; }
|
download() { wget -qO "$2" "$1"; }
|
||||||
else
|
else
|
||||||
fail "needs curl or wget"
|
fail "needs curl or wget"
|
||||||
fi
|
fi
|
||||||
@@ -42,26 +39,11 @@ arm64 | aarch64) arch=arm64 ;;
|
|||||||
*) fail "no release for $(uname -m)" ;;
|
*) fail "no release for $(uname -m)" ;;
|
||||||
esac
|
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:-}"
|
tag="${1:-}"
|
||||||
if [ -z "$tag" ]; then
|
if [ -z "$tag" ]; then
|
||||||
for repo in $repos; do
|
tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') ||
|
||||||
tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null |
|
fail "couldn't look up the latest release"
|
||||||
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag=
|
[ -n "$tag" ] || fail "couldn't look up the latest release"
|
||||||
[ -n "$tag" ] && break
|
|
||||||
done
|
|
||||||
[ -n "$tag" ] || fail "couldn't look up the latest release on $repos"
|
|
||||||
fi
|
fi
|
||||||
case "$tag" in
|
case "$tag" in
|
||||||
v*) ;;
|
v*) ;;
|
||||||
@@ -73,19 +55,11 @@ name="lazysecrets_${version}_${os}_${arch}"
|
|||||||
tmp=$(mktemp -d)
|
tmp=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
# The archive and its checksums.txt from the first server that has both.
|
echo "Downloading lazysecrets $tag for $os/$arch"
|
||||||
from=
|
download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" ||
|
||||||
for repo in $repos; do
|
fail "couldn't download $tag for $os/$arch: see $repo/releases"
|
||||||
base="$repo/releases/download/$tag"
|
download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" ||
|
||||||
if download "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then
|
fail "couldn't download $tag's checksums.txt"
|
||||||
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"
|
|
||||||
|
|
||||||
want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/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"
|
[ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt"
|
||||||
|
|||||||
+37
-120
@@ -14,23 +14,41 @@ import (
|
|||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
UI UI `yaml:"ui"`
|
Icons *bool `yaml:"icons"`
|
||||||
// Values keeps revealed and copied values from lingering.
|
ClipboardClear *time.Duration `yaml:"clipboardClear"`
|
||||||
Values Values `yaml:"values"`
|
// RevealTimeout hides a revealed value after this long; 0s never.
|
||||||
// Storage is what's kept between runs.
|
// Defaults to 60s.
|
||||||
Storage Storage `yaml:"storage"`
|
RevealTimeout *time.Duration `yaml:"revealTimeout"`
|
||||||
// Updates sets how lazysecrets looks for newer releases.
|
// ExpiryWarningDays marks secrets, keys and certificates expiring
|
||||||
Updates Updates `yaml:"updates"`
|
// 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"`
|
Providers Providers `yaml:"providers"`
|
||||||
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
||||||
LinkHandler LinkHandler `yaml:"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
|
// CustomCommands run shell commands on the selected item; see
|
||||||
// package commands.
|
// package commands.
|
||||||
CustomCommands []commands.Command `yaml:"customCommands"`
|
CustomCommands []commands.Command `yaml:"customCommands"`
|
||||||
@@ -42,52 +60,6 @@ type Config struct {
|
|||||||
Theme map[string][]string `yaml:"theme"`
|
Theme map[string][]string `yaml:"theme"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UI struct {
|
|
||||||
Icons *bool `yaml:"icons"`
|
|
||||||
// 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 {
|
type LinkHandler struct {
|
||||||
// Terminal to open links in, overriding the one handler install
|
// Terminal to open links in, overriding the one handler install
|
||||||
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
|
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
|
||||||
@@ -105,27 +77,7 @@ type LinkHandler struct {
|
|||||||
Prompt *bool `yaml:"prompt"`
|
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 {
|
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"`
|
Azure Azure `yaml:"azure"`
|
||||||
Databricks Databricks `yaml:"databricks"`
|
Databricks Databricks `yaml:"databricks"`
|
||||||
Repos Repos `yaml:"repos"`
|
Repos Repos `yaml:"repos"`
|
||||||
@@ -135,13 +87,11 @@ type Providers struct {
|
|||||||
// that repo is the only one shown; elsewhere, the ones in Paths and under
|
// that repo is the only one shown; elsewhere, the ones in Paths and under
|
||||||
// Roots.
|
// Roots.
|
||||||
type Repos struct {
|
type Repos struct {
|
||||||
// Timeout overrides providers.timeout for these requests.
|
|
||||||
Timeout time.Duration `yaml:"timeout"`
|
|
||||||
// Enabled defaults to true.
|
// Enabled defaults to true.
|
||||||
Enabled *bool `yaml:"enabled"`
|
Enabled *bool `yaml:"enabled"`
|
||||||
Paths []string `yaml:"paths"`
|
Paths []string `yaml:"paths"`
|
||||||
// Roots are folders of repos. Defaults to repo.DefaultRoots (~/git,
|
// Roots are folders of repos. Defaults to ~/git; an empty list
|
||||||
// ~/src, ~/code and the like); an empty list turns it off.
|
// turns it off.
|
||||||
Roots *[]string `yaml:"roots"`
|
Roots *[]string `yaml:"roots"`
|
||||||
// Include and Exclude are globs relative to a repo's root (** for any
|
// Include and Exclude are globs relative to a repo's root (** for any
|
||||||
// depth), for repos without their own .lazysecrets.yaml. Without them,
|
// depth), for repos without their own .lazysecrets.yaml. Without them,
|
||||||
@@ -151,8 +101,6 @@ type Repos struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Azure struct {
|
type Azure struct {
|
||||||
// Timeout overrides providers.timeout for these requests.
|
|
||||||
Timeout time.Duration `yaml:"timeout"`
|
|
||||||
// Enabled defaults to true.
|
// Enabled defaults to true.
|
||||||
Enabled *bool `yaml:"enabled"`
|
Enabled *bool `yaml:"enabled"`
|
||||||
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
||||||
@@ -163,8 +111,6 @@ type Azure struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Databricks struct {
|
type Databricks struct {
|
||||||
// Timeout overrides providers.timeout for these requests.
|
|
||||||
Timeout time.Duration `yaml:"timeout"`
|
|
||||||
// Enabled defaults to true.
|
// Enabled defaults to true.
|
||||||
Enabled *bool `yaml:"enabled"`
|
Enabled *bool `yaml:"enabled"`
|
||||||
HideProfiles []string `yaml:"hideProfiles"`
|
HideProfiles []string `yaml:"hideProfiles"`
|
||||||
@@ -251,48 +197,19 @@ func decodeInto(c *Config, path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) validate(path string) error {
|
func (c Config) validate(path string) error {
|
||||||
if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
||||||
return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v)
|
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
|
||||||
}
|
}
|
||||||
for _, cmd := range c.CustomCommands {
|
for _, cmd := range c.CustomCommands {
|
||||||
if err := cmd.Validate(); err != nil {
|
if err := cmd.Validate(); err != nil {
|
||||||
return fmt.Errorf("%s: %w", path, err)
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 {
|
if d := c.ExpiryWarningDays; d != nil && *d < 0 {
|
||||||
return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d)
|
return fmt.Errorf("%s: 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.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 {
|
for i, v := range c.Providers.Azure.Vaults {
|
||||||
if !strings.HasPrefix(v, "https://") {
|
if !strings.HasPrefix(v, "https://") {
|
||||||
|
|||||||
@@ -19,17 +19,12 @@ func write(t *testing.T, content string) string {
|
|||||||
|
|
||||||
func TestLoad(t *testing.T) {
|
func TestLoad(t *testing.T) {
|
||||||
c, err := Load(write(t, `
|
c, err := Load(write(t, `
|
||||||
ui:
|
icons: false
|
||||||
icons: false
|
sourcesView: flat
|
||||||
sourcesView: flat
|
clipboardClear: 45s
|
||||||
values:
|
timeout: 1m
|
||||||
clipboardClear: 45s
|
|
||||||
storage:
|
|
||||||
history: false
|
|
||||||
providers:
|
providers:
|
||||||
timeout: 1m
|
|
||||||
azure:
|
azure:
|
||||||
timeout: 2m
|
|
||||||
hideSubscriptions: [platform-mgmt]
|
hideSubscriptions: [platform-mgmt]
|
||||||
vaults:
|
vaults:
|
||||||
- https://kv-foo.vault.azure.net/
|
- https://kv-foo.vault.azure.net/
|
||||||
@@ -40,12 +35,12 @@ providers:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second ||
|
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.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" {
|
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
|
||||||
t.Errorf("config = %+v", c)
|
t.Errorf("config = %+v", c)
|
||||||
}
|
}
|
||||||
if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) {
|
if c.SourcesView != "flat" {
|
||||||
t.Errorf("ui = %+v, storage = %+v", c.UI, c.Storage)
|
t.Errorf("sourcesView = %q", c.SourcesView)
|
||||||
}
|
}
|
||||||
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
|
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
|
||||||
t.Error("enabled flags wrong")
|
t.Error("enabled flags wrong")
|
||||||
@@ -62,7 +57,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
|
|||||||
"empty": write(t, ""),
|
"empty": write(t, ""),
|
||||||
} {
|
} {
|
||||||
c, err := Load(path)
|
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)
|
t.Errorf("%s: %+v, %v", name, c, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,17 +68,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
|||||||
"typo": "icon: true\n",
|
"typo": "icon: true\n",
|
||||||
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
|
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
|
||||||
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
||||||
"flat keys": "icons: false\n",
|
"duration": "timeout: soon\n",
|
||||||
"duration": "providers:\n timeout: soon\n",
|
"view": "sourcesView: tree\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",
|
|
||||||
} {
|
} {
|
||||||
if _, err := Load(write(t, content)); err == nil {
|
if _, err := Load(write(t, content)); err == nil {
|
||||||
t.Errorf("%s: no error", name)
|
t.Errorf("%s: no error", name)
|
||||||
@@ -95,8 +81,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadAllLayers(t *testing.T) {
|
func TestLoadAllLayers(t *testing.T) {
|
||||||
user := write(t, `
|
user := write(t, `
|
||||||
ui:
|
icons: false
|
||||||
icons: false
|
|
||||||
theme:
|
theme:
|
||||||
azureColor: [blue]
|
azureColor: [blue]
|
||||||
errorColor: [red, bold]
|
errorColor: [red, bold]
|
||||||
@@ -105,8 +90,6 @@ providers:
|
|||||||
hideSubscriptions: [a]
|
hideSubscriptions: [a]
|
||||||
`)
|
`)
|
||||||
theme := write(t, `
|
theme := write(t, `
|
||||||
ui:
|
|
||||||
sourcesView: flat
|
|
||||||
theme:
|
theme:
|
||||||
azureColor: ["#82aaff"]
|
azureColor: ["#82aaff"]
|
||||||
activeBorderColor: ["#ff966c", bold]
|
activeBorderColor: ["#ff966c", bold]
|
||||||
@@ -115,8 +98,7 @@ theme:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// A group set in both files merges: icons from one, sourcesView the other.
|
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 {
|
||||||
if c.UI.Icons == nil || *c.UI.Icons || c.UI.SourcesView != "flat" || len(c.Providers.Azure.HideSubscriptions) != 1 {
|
|
||||||
t.Errorf("user settings lost: %+v", c)
|
t.Errorf("user settings lost: %+v", c)
|
||||||
}
|
}
|
||||||
want := map[string][]string{
|
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) {
|
func TestLoadDelay(t *testing.T) {
|
||||||
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
|
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
||||||
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
|
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
||||||
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
|
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")
|
t.Error("a minute's delay accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
package azure
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
// accountDetails describes an az account from az's account list and the
|
|
||||||
// claims of the latest token lazysecrets got for it: nothing is fetched
|
|
||||||
// for it.
|
|
||||||
type accountDetails struct {
|
|
||||||
account
|
|
||||||
claims func() map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ provider.Accounts = (*Provider)(nil)
|
|
||||||
|
|
||||||
// Account describes the az account, once a token has been fetched for it
|
|
||||||
// (the user's name and object ID come from the token).
|
|
||||||
func (p *Provider) Account() (provider.Account, bool) {
|
|
||||||
if p.account == nil {
|
|
||||||
return provider.Account{}, false
|
|
||||||
}
|
|
||||||
a := p.account
|
|
||||||
claims := a.claims()
|
|
||||||
str := func(k string) string { s, _ := claims[k].(string); return s }
|
|
||||||
|
|
||||||
var out provider.Account
|
|
||||||
add := func(label, value string) {
|
|
||||||
if value != "" {
|
|
||||||
out.Fields = append(out.Fields, provider.Attr{Label: label, Value: value})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add("name", str("name"))
|
|
||||||
user := a.user
|
|
||||||
if a.userType == "servicePrincipal" {
|
|
||||||
user += " (service principal)"
|
|
||||||
}
|
|
||||||
add("user", user)
|
|
||||||
add("tenant", a.tenantName)
|
|
||||||
add("tenant ID", a.tenant)
|
|
||||||
if oid := str("oid"); oid != "" {
|
|
||||||
add("object ID", oid)
|
|
||||||
out.Copy, out.CopyLabel = oid, "object ID"
|
|
||||||
}
|
|
||||||
if claims != nil {
|
|
||||||
add("sign-in", signInMethods(claims["amr"]))
|
|
||||||
add("roles", directoryRoles(claims["wids"]))
|
|
||||||
}
|
|
||||||
subs := fmt.Sprint(a.subscriptions)
|
|
||||||
if a.defaultSub != "" {
|
|
||||||
subs += " · default " + a.defaultSub
|
|
||||||
}
|
|
||||||
add("subs", subs)
|
|
||||||
if a.cloud != "" && a.cloud != "AzureCloud" {
|
|
||||||
add("cloud", a.cloud)
|
|
||||||
}
|
|
||||||
return out, len(out.Fields) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// signInMethods says how the session was signed in to, from the amr
|
|
||||||
// claim: with MFA or not, and by what.
|
|
||||||
func signInMethods(amr any) string {
|
|
||||||
list, _ := amr.([]any)
|
|
||||||
var methods []string
|
|
||||||
mfa := false
|
|
||||||
for _, m := range list {
|
|
||||||
s, _ := m.(string)
|
|
||||||
switch s {
|
|
||||||
case "mfa", "ngcmfa":
|
|
||||||
mfa = true
|
|
||||||
case "pwd":
|
|
||||||
methods = append(methods, "password")
|
|
||||||
case "rsa":
|
|
||||||
methods = append(methods, "Windows Hello or certificate")
|
|
||||||
case "fido":
|
|
||||||
methods = append(methods, "security key")
|
|
||||||
case "wia":
|
|
||||||
methods = append(methods, "Windows sign-in")
|
|
||||||
case "otp", "sms":
|
|
||||||
methods = append(methods, "one-time code")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(list) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
how := strings.Join(methods, ", ")
|
|
||||||
switch {
|
|
||||||
case mfa && how != "":
|
|
||||||
return "MFA · " + how
|
|
||||||
case mfa:
|
|
||||||
return "MFA"
|
|
||||||
case how != "":
|
|
||||||
return "no MFA · " + how
|
|
||||||
}
|
|
||||||
return "no MFA"
|
|
||||||
}
|
|
||||||
|
|
||||||
// builtinRoles names Entra ID's built-in directory roles by template ID,
|
|
||||||
// the IDs a token's wids claim lists.
|
|
||||||
var builtinRoles = map[string]string{
|
|
||||||
"62e90394-69f5-4237-9190-012177145e10": "Global Administrator",
|
|
||||||
"f2ef992c-3afb-46b9-b7cf-a126ee74c451": "Global Reader",
|
|
||||||
"e8611ab8-c189-46e8-94e1-60213ab1f814": "Privileged Role Administrator",
|
|
||||||
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13": "Privileged Authentication Administrator",
|
|
||||||
"194ae4cb-b126-40b2-bd5b-6091b380977d": "Security Administrator",
|
|
||||||
"5d6b6bb7-de71-4623-b4af-96380a352509": "Security Reader",
|
|
||||||
"5f2222b1-57c3-48ba-8ad5-d4759f1fde6f": "Security Operator",
|
|
||||||
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3": "Application Administrator",
|
|
||||||
"158c047a-c907-4556-b7ef-446551a6b5f7": "Cloud Application Administrator",
|
|
||||||
"cf1c38e5-3621-4004-a7cb-879624dced7c": "Application Developer",
|
|
||||||
"fe930be7-5e62-47db-91af-98c3a49a38b1": "User Administrator",
|
|
||||||
"fdd7a751-b60b-444a-984c-02652fe8fa1c": "Groups Administrator",
|
|
||||||
"729827e3-9c14-49f7-bb1b-9608f156bbb8": "Helpdesk Administrator",
|
|
||||||
"966707d0-3269-4727-9be2-8c3a10f19b9d": "Password Administrator",
|
|
||||||
"c4e39bd9-1100-46d3-8c65-fb160da0071f": "Authentication Administrator",
|
|
||||||
"0526716b-113d-4c15-b2c8-68e3c22b9f80": "Authentication Policy Administrator",
|
|
||||||
"8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2": "Hybrid Identity Administrator",
|
|
||||||
"b0f54661-2d74-4c50-afa3-1ec803f12efe": "Billing Administrator",
|
|
||||||
"4d6ac14f-3453-41d0-bef9-a3e0c569773a": "License Administrator",
|
|
||||||
"17315797-102d-40b4-93e0-432062caca18": "Compliance Administrator",
|
|
||||||
"29232cdf-9323-42fd-ade2-1d097af3e4de": "Exchange Administrator",
|
|
||||||
"f28a1f50-f6e7-4571-818b-6a12f2af6b6c": "SharePoint Administrator",
|
|
||||||
"69091246-20e8-4a56-aa4d-066075b2a7a8": "Teams Administrator",
|
|
||||||
"3a2c62db-5318-420d-8d74-23affee5d9d5": "Intune Administrator",
|
|
||||||
"11648597-926c-4cf3-9c36-bcebb0ba8dcc": "Power Platform Administrator",
|
|
||||||
"e3973bdf-4987-49ae-837a-ba8e231c7286": "Azure DevOps Administrator",
|
|
||||||
"9360feb5-f418-4baa-8175-e2a00bac4301": "Directory Writers",
|
|
||||||
"88d8e3e3-8f55-4a1e-953a-9b9898b8876b": "Directory Readers",
|
|
||||||
"95e79109-95c0-4d8e-aee3-d01accf2d47b": "Guest Inviter",
|
|
||||||
}
|
|
||||||
|
|
||||||
// memberRole is in every member's token: not worth listing.
|
|
||||||
const memberRole = "b79fbf4d-3ef9-4689-8143-76b194e85509"
|
|
||||||
|
|
||||||
// directoryRoles names the roles in a wids claim: built-in ones by name,
|
|
||||||
// others by their ID's first part.
|
|
||||||
func directoryRoles(wids any) string {
|
|
||||||
list, _ := wids.([]any)
|
|
||||||
var names []string
|
|
||||||
for _, w := range list {
|
|
||||||
id, _ := w.(string)
|
|
||||||
switch name, ok := builtinRoles[strings.ToLower(id)]; {
|
|
||||||
case id == "" || id == memberRole:
|
|
||||||
case ok:
|
|
||||||
names = append(names, name)
|
|
||||||
default:
|
|
||||||
short, _, _ := strings.Cut(id, "-")
|
|
||||||
names = append(names, "role "+short)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.Sort(names)
|
|
||||||
return strings.Join(names, ", ")
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package azure
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAccount(t *testing.T) {
|
|
||||||
claims := map[string]any{
|
|
||||||
"name": "Alice Example", "oid": "11111111-2222-3333-4444-555555555555",
|
|
||||||
"amr": []any{"pwd", "mfa"},
|
|
||||||
"wids": []any{"62e90394-69f5-4237-9190-012177145e10", memberRole, "aaaaaaaa-0000-0000-0000-000000000000"},
|
|
||||||
}
|
|
||||||
p := &Provider{account: &accountDetails{
|
|
||||||
account: account{user: "alice@contoso.com", tenant: "t1", tenantName: "Contoso", subscriptions: 3, defaultSub: "platform"},
|
|
||||||
claims: func() map[string]any { return claims },
|
|
||||||
}}
|
|
||||||
a, ok := p.Account()
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("no account")
|
|
||||||
}
|
|
||||||
got := map[string]string{}
|
|
||||||
for _, f := range a.Fields {
|
|
||||||
got[f.Label] = f.Value
|
|
||||||
}
|
|
||||||
want := map[string]string{
|
|
||||||
"name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso", "tenant ID": "t1",
|
|
||||||
"object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password",
|
|
||||||
"roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform",
|
|
||||||
}
|
|
||||||
for k, v := range want {
|
|
||||||
if got[k] != v {
|
|
||||||
t.Errorf("%s = %q, want %q", k, got[k], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Errorf("fields %v", got)
|
|
||||||
}
|
|
||||||
if a.Copy != claims["oid"] || a.CopyLabel != "object ID" {
|
|
||||||
t.Errorf("copy %q %q", a.Copy, a.CopyLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before any token: what az's list says.
|
|
||||||
claims = nil
|
|
||||||
a, _ = p.Account()
|
|
||||||
if len(a.Fields) != 4 || a.Copy != "" {
|
|
||||||
t.Errorf("without a token: %+v", a)
|
|
||||||
}
|
|
||||||
if _, ok := (&Provider{}).Account(); ok {
|
|
||||||
t.Error("a service principal from AZURE_* has no account details")
|
|
||||||
}
|
|
||||||
var _ provider.Accounts = p
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSignInMethods(t *testing.T) {
|
|
||||||
for want, amr := range map[string][]any{
|
|
||||||
"MFA · password": {"pwd", "mfa"},
|
|
||||||
"MFA · Windows Hello or certificate": {"rsa", "ngcmfa"},
|
|
||||||
"no MFA · password": {"pwd"},
|
|
||||||
"": nil,
|
|
||||||
} {
|
|
||||||
if got := signInMethods(amr); got != want {
|
|
||||||
t.Errorf("%v: %q, want %q", amr, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,10 +19,7 @@ import (
|
|||||||
// account is one az CLI identity in one tenant.
|
// account is one az CLI identity in one tenant.
|
||||||
type account struct {
|
type account struct {
|
||||||
user, tenant string
|
user, tenant string
|
||||||
userType string // "user" or "servicePrincipal"
|
|
||||||
tenantName string
|
tenantName string
|
||||||
cloud string // az's environment: AzureCloud, AzureChinaCloud, …
|
|
||||||
defaultSub string // az's default subscription's name, if this account's
|
|
||||||
anchor string // a subscription az maps to this account, to pin it
|
anchor string // a subscription az maps to this account, to pin it
|
||||||
anchorEnabled bool
|
anchorEnabled bool
|
||||||
isDefault bool
|
isDefault bool
|
||||||
@@ -55,15 +52,12 @@ func readAccounts(path string) ([]account, error) {
|
|||||||
var profile struct {
|
var profile struct {
|
||||||
Subscriptions []struct {
|
Subscriptions []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
IsDefault bool `json:"isDefault"`
|
IsDefault bool `json:"isDefault"`
|
||||||
TenantID string `json:"tenantId"`
|
TenantID string `json:"tenantId"`
|
||||||
TenantName string `json:"tenantDisplayName"`
|
TenantName string `json:"tenantDisplayName"`
|
||||||
Cloud string `json:"environmentName"`
|
|
||||||
User struct {
|
User struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
|
||||||
} `json:"user"`
|
} `json:"user"`
|
||||||
} `json:"subscriptions"`
|
} `json:"subscriptions"`
|
||||||
}
|
}
|
||||||
@@ -78,7 +72,7 @@ func readAccounts(path string) ([]account, error) {
|
|||||||
}
|
}
|
||||||
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
|
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName, userType: s.User.Type, cloud: s.Cloud})
|
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName})
|
||||||
i = len(out) - 1
|
i = len(out) - 1
|
||||||
}
|
}
|
||||||
a := &out[i]
|
a := &out[i]
|
||||||
@@ -88,21 +82,12 @@ func readAccounts(path string) ([]account, error) {
|
|||||||
enabled := s.State == "Enabled"
|
enabled := s.State == "Enabled"
|
||||||
switch {
|
switch {
|
||||||
case s.IsDefault:
|
case s.IsDefault:
|
||||||
a.anchor, a.anchorEnabled, a.isDefault, a.defaultSub = s.ID, enabled, true, s.Name
|
a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true
|
||||||
case a.isDefault:
|
case a.isDefault:
|
||||||
case a.anchor == "" || enabled && !a.anchorEnabled:
|
case a.anchor == "" || enabled && !a.anchorEnabled:
|
||||||
a.anchor, a.anchorEnabled = s.ID, enabled
|
a.anchor, a.anchorEnabled = s.ID, enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// az leaves the tenant's name out of some entries: take it from
|
|
||||||
// another in the same tenant.
|
|
||||||
for i := range out {
|
|
||||||
for _, b := range out {
|
|
||||||
if out[i].tenantName == "" && b.tenant == out[i].tenant {
|
|
||||||
out[i].tenantName = b.tenantName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.SortStableFunc(out, func(a, b account) int {
|
slices.SortStableFunc(out, func(a, b account) int {
|
||||||
if a.isDefault != b.isDefault {
|
if a.isDefault != b.isDefault {
|
||||||
if a.isDefault {
|
if a.isDefault {
|
||||||
@@ -166,7 +151,7 @@ func NewAccounts(s Settings) ([]*Provider, error) {
|
|||||||
if len(accounts) == 0 {
|
if len(accounts) == 0 {
|
||||||
// Not signed in yet: the first call fails with an AuthError and
|
// Not signed in yet: the first call fails with an AuthError and
|
||||||
// the UI offers az login.
|
// the UI offers az login.
|
||||||
cred := authCredential{cred: newCLICredential("", "", nil), login: []string{"az", "login"}}
|
cred := authCredential{cred: newCLICredential("", ""), login: []string{"az", "login"}}
|
||||||
p, err := newWithOptions(cred, options{Settings: s})
|
p, err := newWithOptions(cred, options{Settings: s})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -186,7 +171,7 @@ func NewAccounts(s Settings) ([]*Provider, error) {
|
|||||||
label = string(provider.KindAzure) + " · " + labels[i]
|
label = string(provider.KindAzure) + " · " + labels[i]
|
||||||
}
|
}
|
||||||
cred := authCredential{
|
cred := authCredential{
|
||||||
cred: newCLICredential(a.user, a.tenant, func() ([]account, error) { return readAccounts(path) }),
|
cred: newCLICredential(a.anchor, a.tenant),
|
||||||
login: []string{"az", "login", "--tenant", a.tenant},
|
login: []string{"az", "login", "--tenant", a.tenant},
|
||||||
account: label, // so "sign-in required" says which account
|
account: label, // so "sign-in required" says which account
|
||||||
}
|
}
|
||||||
@@ -194,7 +179,6 @@ func NewAccounts(s Settings) ([]*Provider, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.account = &accountDetails{account: a, claims: cred.cred.(*cliCredential).claims}
|
|
||||||
if label != "" {
|
if label != "" {
|
||||||
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
|
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
|
||||||
p.label = label
|
p.label = label
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package azure
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,7 +11,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,9 +67,7 @@ func TestAccountLabels(t *testing.T) {
|
|||||||
|
|
||||||
func TestCLICredentialPinsAccount(t *testing.T) {
|
func TestCLICredentialPinsAccount(t *testing.T) {
|
||||||
var calls [][]string
|
var calls [][]string
|
||||||
c := newCLICredential("alice@contoso.com", "t1", func() ([]account, error) {
|
c := newCLICredential("s-alice-2", "t1")
|
||||||
return []account{{user: "alice@contoso.com", tenant: "t1", anchor: "s-alice-2"}}, nil
|
|
||||||
})
|
|
||||||
c.run = func(_ context.Context, args ...string) ([]byte, error) {
|
c.run = func(_ context.Context, args ...string) ([]byte, error) {
|
||||||
calls = append(calls, args)
|
calls = append(calls, args)
|
||||||
exp := time.Now().Add(time.Hour).Unix()
|
exp := time.Now().Add(time.Hour).Unix()
|
||||||
@@ -106,7 +102,7 @@ func TestCLICredentialPinsAccount(t *testing.T) {
|
|||||||
|
|
||||||
func TestCLICredentialRenewsAndReportsErrors(t *testing.T) {
|
func TestCLICredentialRenewsAndReportsErrors(t *testing.T) {
|
||||||
n := 0
|
n := 0
|
||||||
c := newCLICredential("", "", nil)
|
c := newCLICredential("", "")
|
||||||
c.run = func(context.Context, ...string) ([]byte, error) {
|
c.run = func(context.Context, ...string) ([]byte, error) {
|
||||||
n++
|
n++
|
||||||
if n == 2 {
|
if n == 2 {
|
||||||
@@ -123,69 +119,3 @@ func TestCLICredentialRenewsAndReportsErrors(t *testing.T) {
|
|||||||
t.Errorf("err = %v", err)
|
t.Errorf("err = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// jwt is an unsigned token with claims, as az would print it.
|
|
||||||
func jwt(claims string) string {
|
|
||||||
enc := base64.RawURLEncoding.EncodeToString
|
|
||||||
return enc([]byte(`{"alg":"none"}`)) + "." + enc([]byte(claims)) + ".sig"
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLICredentialFollowsAzAndRefusesOtherUsers(t *testing.T) {
|
|
||||||
// az's profile has one entry per subscription: signing in as bob,
|
|
||||||
// who also sees s-shared, moved it from alice to bob.
|
|
||||||
profile := []account{
|
|
||||||
{user: "alice@contoso.com", tenant: "t1", anchor: "s-shared"},
|
|
||||||
{user: "bob@contoso.com", tenant: "t1", anchor: "s-bob"},
|
|
||||||
}
|
|
||||||
var args []string
|
|
||||||
issuedTo := "alice@contoso.com"
|
|
||||||
c := newCLICredential("alice@contoso.com", "t1", func() ([]account, error) { return profile, nil })
|
|
||||||
c.run = func(_ context.Context, a ...string) ([]byte, error) {
|
|
||||||
args = a
|
|
||||||
exp := time.Now().Add(time.Hour).Unix()
|
|
||||||
return []byte(fmt.Sprintf(`{"accessToken": %q, "expires_on": %d}`, jwt(`{"upn":"`+issuedTo+`"}`), exp)), nil
|
|
||||||
}
|
|
||||||
arm := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}}
|
|
||||||
if _, err := c.GetToken(context.Background(), arm); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(args, " "), "--subscription s-shared") {
|
|
||||||
t.Errorf("args %q", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now alice has another subscription, and az would hand out bob's
|
|
||||||
// token for the old one: the new pin is used, and bob's token refused.
|
|
||||||
profile[0].anchor = "s-alice"
|
|
||||||
c.cache = map[string]azcore.AccessToken{}
|
|
||||||
issuedTo = "bob@contoso.com"
|
|
||||||
_, err := c.GetToken(context.Background(), arm)
|
|
||||||
if !strings.Contains(strings.Join(args, " "), "--subscription s-alice") {
|
|
||||||
t.Errorf("args %q", args)
|
|
||||||
}
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "token for bob@contoso.com, not alice@contoso.com") {
|
|
||||||
t.Errorf("err = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alice gone from az entirely: sign in again.
|
|
||||||
profile = profile[1:]
|
|
||||||
if _, err := c.GetToken(context.Background(), arm); err == nil || !strings.Contains(err.Error(), "sign in as alice@contoso.com") {
|
|
||||||
t.Errorf("err = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTokenUser(t *testing.T) {
|
|
||||||
users := []string{"bob@contoso.com"}
|
|
||||||
for claims, want := range map[string]bool{
|
|
||||||
`{"upn":"Bob@Contoso.com"}`: true,
|
|
||||||
`{"unique_name":"live.com#bob@contoso.com"}`: true,
|
|
||||||
`{"upn":"alice@contoso.com"}`: false,
|
|
||||||
`{"appid":"00000000-0000-0000-0000-000000000000"}`: false,
|
|
||||||
} {
|
|
||||||
if _, got := tokenUser(tokenClaims(jwt(claims)), users); got != want {
|
|
||||||
t.Errorf("%s: %v", claims, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, got := tokenUser(tokenClaims("not-a-jwt"), users); got {
|
|
||||||
t.Error("not a JWT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,9 +17,9 @@ import (
|
|||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
|
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
|
"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/azcertificates"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
||||||
@@ -38,7 +38,6 @@ type Provider struct {
|
|||||||
|
|
||||||
cred azcore.TokenCredential
|
cred azcore.TokenCredential
|
||||||
opts options
|
opts options
|
||||||
account *accountDetails // nil for a service principal from AZURE_*
|
|
||||||
subs *armsubscriptions.Client
|
subs *armsubscriptions.Client
|
||||||
graph *armresourcegraph.Client
|
graph *armresourcegraph.Client
|
||||||
|
|
||||||
@@ -183,6 +182,10 @@ func (p *Provider) tenant(subscriptionID string) string {
|
|||||||
return p.tenants[subscriptionID]
|
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) {
|
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||||
var out []provider.Container
|
var out []provider.Container
|
||||||
req := armresourcegraph.QueryRequest{
|
req := armresourcegraph.QueryRequest{
|
||||||
@@ -222,7 +225,7 @@ func graphVault(src provider.Source, row any) (provider.Container, bool) {
|
|||||||
if id == "" || name == "" || uri == "" {
|
if id == "" || name == "" || uri == "" {
|
||||||
return provider.Container{}, false
|
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) {
|
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||||
@@ -243,7 +246,7 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
|
|||||||
}
|
}
|
||||||
out = append(out, provider.Container{
|
out = append(out, provider.Container{
|
||||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
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 +302,7 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
|
|||||||
if s.ID == nil || deref(s.Managed) {
|
if s.ID == nil || deref(s.Managed) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
|
out = append(out, secretObject(c, s))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case provider.TypeKey:
|
case provider.TypeKey:
|
||||||
@@ -392,7 +395,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
|||||||
}
|
}
|
||||||
for _, s := range page.Value {
|
for _, s := range page.Value {
|
||||||
if s.ID != nil {
|
if s.ID != nil {
|
||||||
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID)))
|
out = append(out, secretObject(o.Container, s))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,13 +428,8 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
|||||||
default:
|
default:
|
||||||
return nil, provider.ErrUnsupported
|
return nil, provider.ErrUnsupported
|
||||||
}
|
}
|
||||||
tenant := p.tenant(o.Container.Source.ID)
|
|
||||||
for i := range out {
|
for i := range out {
|
||||||
out[i].Reference = versionedReference(out[i])
|
out[i].Reference = versionedReference(out[i])
|
||||||
if o.Type != provider.TypeSecret { // secretObject's is the version's already
|
|
||||||
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type]
|
|
||||||
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
|
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -458,8 +456,11 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
}
|
}
|
||||||
switch o.Type {
|
switch o.Type {
|
||||||
case provider.TypeSecret:
|
case provider.TypeSecret:
|
||||||
v, _, err := p.VersionedValue(ctx, o)
|
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||||
return v, err
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, cleanError(err)
|
||||||
|
}
|
||||||
|
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
|
||||||
case provider.TypeKey:
|
case provider.TypeKey:
|
||||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -483,31 +484,7 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
return provider.SecretValue{}, provider.ErrUnsupported
|
return provider.SecretValue{}, provider.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// VersionedValue is Value, with the version read.
|
var _ provider.Locator = (*Provider)(nil)
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
// validHost guards the Resource Graph query against odd input.
|
// validHost guards the Resource Graph query against odd input.
|
||||||
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
|
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/policy"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
"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"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
@@ -176,11 +174,8 @@ func TestForbiddenIsShortened(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
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) {
|
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)
|
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)
|
|
||||||
}
|
}
|
||||||
var re *azcore.ResponseError
|
var re *azcore.ResponseError
|
||||||
if !errors.As(err, &re) || re.StatusCode != 403 {
|
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) {
|
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'"
|
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"
|
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) {
|
func TestVersionedReference(t *testing.T) {
|
||||||
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
|
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
|
||||||
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
|
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
|
size := (len(b) - 1) / 2
|
||||||
return b[1 : 1+size], b[1+size:]
|
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)
|
|
||||||
}
|
|
||||||
+12
-101
@@ -3,7 +3,6 @@ package azure
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -25,61 +24,19 @@ import (
|
|||||||
// every Key Vault request (the vault's auth challenge names a tenant), so
|
// every Key Vault request (the vault's auth challenge names a tenant), so
|
||||||
// this sends --tenant only for vaults in another tenant.
|
// this sends --tenant only for vaults in another tenant.
|
||||||
//
|
//
|
||||||
// Which account a subscription belongs to changes: az's profile has one
|
|
||||||
// entry per subscription, and signing in to another account that sees it
|
|
||||||
// moves it there. So the subscription is looked up for every new token,
|
|
||||||
// and a token az issues for another of the signed-in users is refused.
|
|
||||||
//
|
|
||||||
// Tokens are cached in memory until shortly before they expire, so the
|
// Tokens are cached in memory until shortly before they expire, so the
|
||||||
// many SDK clients don't each start an az process.
|
// many SDK clients don't each start an az process.
|
||||||
type cliCredential struct {
|
type cliCredential struct {
|
||||||
user string // the account's user; "" for az's default account
|
subscription string // pins the account; "" for az's default account
|
||||||
tenant string // the account's tenant, if known
|
tenant string // the account's tenant, if known
|
||||||
// accounts reads az's accounts, to pin the user; nil for the default
|
|
||||||
// account.
|
|
||||||
accounts func() ([]account, error)
|
|
||||||
run func(ctx context.Context, args ...string) ([]byte, error)
|
run func(ctx context.Context, args ...string) ([]byte, error)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cache map[string]azcore.AccessToken // by resource and tenant argument
|
cache map[string]azcore.AccessToken // by resource and tenant argument
|
||||||
// last is the claims of the latest token in the account's own
|
|
||||||
// tenant, to describe the account (Provider.Account).
|
|
||||||
last map[string]any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// claims are the latest token's claims, nil before the first.
|
func newCLICredential(subscription, tenant string) *cliCredential {
|
||||||
func (c *cliCredential) claims() map[string]any {
|
return &cliCredential{subscription: subscription, tenant: tenant, run: runAz, cache: map[string]azcore.AccessToken{}}
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
return c.last
|
|
||||||
}
|
|
||||||
|
|
||||||
func newCLICredential(user, tenant string, accounts func() ([]account, error)) *cliCredential {
|
|
||||||
return &cliCredential{user: user, tenant: tenant, accounts: accounts, run: runAz, cache: map[string]azcore.AccessToken{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pin is a subscription az maps to the user now, and the other users az
|
|
||||||
// holds.
|
|
||||||
func (c *cliCredential) pin() (subscription string, others []string, err error) {
|
|
||||||
if c.accounts == nil {
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
accts, err := c.accounts()
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
for _, a := range accts {
|
|
||||||
switch {
|
|
||||||
case a.user == c.user && a.tenant == c.tenant:
|
|
||||||
subscription = a.anchor
|
|
||||||
case !strings.EqualFold(a.user, c.user):
|
|
||||||
others = append(others, a.user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if subscription == "" {
|
|
||||||
return "", nil, fmt.Errorf("az has no subscriptions for %s any more: sign in as %s", c.user, c.user)
|
|
||||||
}
|
|
||||||
return subscription, others, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshMargin renews tokens this long before they expire.
|
// refreshMargin renews tokens this long before they expire.
|
||||||
@@ -93,25 +50,21 @@ func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOp
|
|||||||
return azcore.AccessToken{}, errors.New("az: this request needs multifactor authentication; run az login again")
|
return azcore.AccessToken{}, errors.New("az: this request needs multifactor authentication; run az login again")
|
||||||
}
|
}
|
||||||
resource := strings.TrimSuffix(opts.Scopes[0], "/.default")
|
resource := strings.TrimSuffix(opts.Scopes[0], "/.default")
|
||||||
otherTenant := opts.TenantID != "" && opts.TenantID != c.tenant
|
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
|
||||||
key := resource + " " + opts.TenantID
|
switch {
|
||||||
|
case opts.TenantID != "" && opts.TenantID != c.tenant:
|
||||||
|
// Another tenant: az picks the account itself.
|
||||||
|
args = append(args, "--tenant", opts.TenantID)
|
||||||
|
case c.subscription != "":
|
||||||
|
args = append(args, "--subscription", c.subscription)
|
||||||
|
}
|
||||||
|
key := strings.Join(args[5:], " ")
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
|
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
|
||||||
return tok, nil
|
return tok, nil
|
||||||
}
|
}
|
||||||
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
|
|
||||||
subscription, others, err := c.pin()
|
|
||||||
switch {
|
|
||||||
case otherTenant:
|
|
||||||
// Another tenant: az picks the account itself.
|
|
||||||
args = append(args, "--tenant", opts.TenantID)
|
|
||||||
case err != nil:
|
|
||||||
return azcore.AccessToken{}, err
|
|
||||||
case subscription != "":
|
|
||||||
args = append(args, "--subscription", subscription)
|
|
||||||
}
|
|
||||||
out, err := c.run(ctx, args...)
|
out, err := c.run(ctx, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return azcore.AccessToken{}, err
|
return azcore.AccessToken{}, err
|
||||||
@@ -120,52 +73,10 @@ func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOp
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return azcore.AccessToken{}, err
|
return azcore.AccessToken{}, err
|
||||||
}
|
}
|
||||||
claims := tokenClaims(tok.Token)
|
|
||||||
if u, ok := tokenUser(claims, others); ok {
|
|
||||||
return azcore.AccessToken{}, fmt.Errorf("az gave a token for %s, not %s: sign in as %s again", u, c.user, c.user)
|
|
||||||
}
|
|
||||||
if !otherTenant && claims != nil {
|
|
||||||
c.last = claims
|
|
||||||
}
|
|
||||||
c.cache[key] = tok
|
c.cache[key] = tok
|
||||||
return tok, nil
|
return tok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenClaims reads a JWT's claims, without verifying it: they only
|
|
||||||
// describe the account and catch az picking the wrong one. nil if it
|
|
||||||
// isn't a JWT.
|
|
||||||
func tokenClaims(token string) map[string]any {
|
|
||||||
parts := strings.Split(token, ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var claims map[string]any
|
|
||||||
if json.Unmarshal(b, &claims) != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return claims
|
|
||||||
}
|
|
||||||
|
|
||||||
// tokenUser reports which of users a token's claims are for, if any.
|
|
||||||
func tokenUser(claims map[string]any, users []string) (string, bool) {
|
|
||||||
for _, name := range []string{"upn", "unique_name", "preferred_username", "email"} {
|
|
||||||
v, _ := claims[name].(string)
|
|
||||||
if i := strings.LastIndexByte(v, '#'); i >= 0 {
|
|
||||||
v = v[i+1:] // live.com#user@example.com
|
|
||||||
}
|
|
||||||
for _, u := range users {
|
|
||||||
if v != "" && strings.EqualFold(v, u) {
|
|
||||||
return u, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCLIToken(b []byte) (azcore.AccessToken, error) {
|
func parseCLIToken(b []byte) (azcore.AccessToken, error) {
|
||||||
var t struct {
|
var t struct {
|
||||||
AccessToken string `json:"accessToken"`
|
AccessToken string `json:"accessToken"`
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
"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
|
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
|
||||||
}
|
}
|
||||||
|
|
||||||
// portalAssetURL links to a Key Vault object's page: a version's with
|
// portalAssetURL links to a Key Vault object's page; objectID must include
|
||||||
// one in objectID; a secret's own (its versions) without.
|
// the version.
|
||||||
func portalAssetURL(tenant, kind, objectID string) string {
|
func portalAssetURL(tenant, kind, objectID string) string {
|
||||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
|
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
|
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
|
||||||
}
|
}
|
||||||
|
|
||||||
// secretObject is a secret, or a version of one, from a listing. Its URL
|
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
|
||||||
// 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 {
|
|
||||||
name := p.ID.Name()
|
name := p.ID.Name()
|
||||||
url := objectURL(c, "secrets", name)
|
url := objectURL(c, "secrets", name)
|
||||||
o := provider.Object{
|
o := provider.Object{
|
||||||
@@ -103,7 +99,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
|
|||||||
ContentType: deref(p.ContentType),
|
ContentType: deref(p.ContentType),
|
||||||
Tags: tags(p.Tags),
|
Tags: tags(p.Tags),
|
||||||
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
|
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
|
||||||
URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
|
URL: ObjectListURL(c, provider.TypeSecret),
|
||||||
}
|
}
|
||||||
if a := p.Attributes; a != nil {
|
if a := p.Attributes; a != nil {
|
||||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||||
@@ -283,24 +279,13 @@ func cleanError(err error) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
||||||
if se.status == http.StatusForbidden {
|
|
||||||
return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)}
|
|
||||||
}
|
|
||||||
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
|
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
|
||||||
// sentences plus caller and resource IDs, to its first sentence and the
|
// sentences plus caller and resource IDs, to its first sentence and the
|
||||||
// missing action.
|
// missing action.
|
||||||
func shortenDenied(msg string) string {
|
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: '"
|
const marker = "Action: '"
|
||||||
i := strings.Index(msg, marker)
|
i := strings.Index(msg, marker)
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
@@ -332,20 +317,4 @@ func (e *serviceError) Error() string {
|
|||||||
return s
|
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 }
|
func (e *serviceError) Unwrap() error { return e.err }
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -317,52 +317,15 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
return v, nil
|
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")
|
var errNoFallback = errors.New("no Key Vault fallback")
|
||||||
|
|
||||||
// combine reports both the Databricks and the Key Vault failure, with
|
// combine reports both the Databricks and the Key Vault failure. An auth
|
||||||
// both their checks after. An auth error from either stays reachable
|
// error from either stays reachable with errors.As.
|
||||||
// with errors.As.
|
|
||||||
func combine(dbxErr, kvErr error) error {
|
func combine(dbxErr, kvErr error) error {
|
||||||
if errors.Is(kvErr, errNoFallback) {
|
if errors.Is(kvErr, errNoFallback) {
|
||||||
return dbxErr
|
return dbxErr
|
||||||
}
|
}
|
||||||
var checks []string
|
return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// classify turns authentication failures into *provider.AuthError: a 401
|
// classify turns authentication failures into *provider.AuthError: a 401
|
||||||
@@ -373,9 +336,6 @@ func classify(pr profile, err error) error {
|
|||||||
}
|
}
|
||||||
var api *apierr.APIError
|
var api *apierr.APIError
|
||||||
if errors.As(err, &api) {
|
if errors.As(err, &api) {
|
||||||
if api.StatusCode == 403 {
|
|
||||||
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
|
|
||||||
}
|
|
||||||
if api.StatusCode != 401 {
|
if api.StatusCode != 401 {
|
||||||
return err
|
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": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||||
map[string]any{"key": "client-secret"},
|
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":
|
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
||||||
write(w, 200, map[string]any{"key": q.Get("key"),
|
write(w, 200, map[string]any{"key": q.Get("key"),
|
||||||
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + 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")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ type Provider struct {
|
|||||||
errs map[listKey]error
|
errs map[listKey]error
|
||||||
values map[string]string // by object ID
|
values map[string]string // by object ID
|
||||||
versions map[string][]provider.Object // by object ID
|
versions map[string][]provider.Object // by object ID
|
||||||
details map[string][]provider.Attr // by container ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type listKey struct {
|
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 {
|
if err := p.wait(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if p.kind != provider.KindAzure { // only Key Vault keeps versions
|
if p.kind == provider.KindDatabricks {
|
||||||
return nil, provider.ErrUnsupported
|
return nil, provider.ErrUnsupported
|
||||||
}
|
}
|
||||||
vs := p.versions[o.ID]
|
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
|
return provider.NewSecretValue([]byte(v)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locate finds the source holding the vault (by host), the workspace or
|
// Locate finds the source holding the vault (by host) or the workspace.
|
||||||
// the repo (by its remote).
|
|
||||||
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
||||||
if err := p.wait(ctx); err != nil {
|
if err := p.wait(ctx); err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
for _, src := range p.sources {
|
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
|
return src.ID, true, nil
|
||||||
}
|
}
|
||||||
for _, c := range p.containers[src.ID] {
|
for _, c := range p.containers[src.ID] {
|
||||||
@@ -211,7 +209,7 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
|
|||||||
if p.kind == provider.KindDatabricks {
|
if p.kind == provider.KindDatabricks {
|
||||||
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
|
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"
|
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
|
||||||
}
|
}
|
||||||
p.containers[src.ID] = append(p.containers[src.ID], c)
|
p.containers[src.ID] = append(p.containers[src.ID], c)
|
||||||
@@ -332,10 +330,6 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
}, demoCert)
|
}, demoCert)
|
||||||
|
|
||||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||||
// A key to share env files with (S → f): random, as sharing.keyRules wants.
|
|
||||||
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{
|
p.add(shared, provider.TypeSecret, provider.Object{
|
||||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||||
}, "smtp-demo-password")
|
}, "smtp-demo-password")
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,8 +93,6 @@ type Container struct {
|
|||||||
// secrets only.
|
// secrets only.
|
||||||
Types []ObjectType
|
Types []ObjectType
|
||||||
URL string // web page for the container, if any
|
URL string // web page for the container, if any
|
||||||
// Attrs describe it further, from the listing (a vault's settings).
|
|
||||||
Attrs []Attr `json:",omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObjectTypes returns the container's object types, defaulting to secrets.
|
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||||
@@ -110,7 +107,6 @@ func (c Container) ObjectTypes() []ObjectType {
|
|||||||
type Attr struct {
|
type Attr struct {
|
||||||
Label string
|
Label string
|
||||||
Value 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.
|
// Object describes a secret, key or certificate without its value.
|
||||||
@@ -167,79 +163,9 @@ type Current interface {
|
|||||||
Current(ctx context.Context) (Source, bool)
|
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,
|
// 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
|
// 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.
|
// workspace host. It returns the ID of the source holding it.
|
||||||
type Locator interface {
|
type Locator interface {
|
||||||
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
|
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files is implemented by providers whose containers are files on disk
|
|
||||||
// (SOPS and .env files in repos), so they can be opened in an editor.
|
|
||||||
type Files interface {
|
|
||||||
// File is where c is, and for o (nil for the whole file) the line its
|
|
||||||
// key is on.
|
|
||||||
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
|
|
||||||
Line int // 1-based; 0 for the top
|
|
||||||
Sops string // the sops command to edit it with; empty for a plain file
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accounts is implemented by providers that can describe the identity
|
|
||||||
// they use, from what they have locally: for the Details pane on the
|
|
||||||
// provider's row. ok is false when there's nothing to say.
|
|
||||||
type Accounts interface {
|
|
||||||
Account() (Account, bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account describes a signed-in identity.
|
|
||||||
type Account struct {
|
|
||||||
Fields []Attr
|
|
||||||
// Copy is what y copies on the provider's row, named CopyLabel.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
+28
-207
@@ -80,10 +80,6 @@ type repoInfo struct {
|
|||||||
here bool // the repo lazysecrets started in
|
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 {
|
func New(s Settings) *Provider {
|
||||||
if s.Sops == "" {
|
if s.Sops == "" {
|
||||||
s.Sops = "sops"
|
s.Sops = "sops"
|
||||||
@@ -120,18 +116,10 @@ func (p *Provider) load() {
|
|||||||
}
|
}
|
||||||
add(root)
|
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 {
|
for _, dir := range p.s.Roots {
|
||||||
base, err := filepath.EvalSymlinks(expandHome(dir))
|
base, _ := filepath.EvalSymlinks(expandHome(dir))
|
||||||
if err != nil || scanned[base] {
|
for _, root := range reposIn(expandHome(dir), 2) {
|
||||||
continue
|
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
|
||||||
}
|
|
||||||
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] == "" {
|
|
||||||
names[root] = filepath.ToSlash(name)
|
names[root] = filepath.ToSlash(name)
|
||||||
}
|
}
|
||||||
add(root)
|
add(root)
|
||||||
@@ -323,8 +311,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
|
|||||||
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
|
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
|
||||||
case isSops:
|
case isSops:
|
||||||
return // a SOPS file where .sops.yaml says SOPS files don't go
|
return // a SOPS file where .sops.yaml says SOPS files don't go
|
||||||
case IsEnv(f):
|
case isEnv(f):
|
||||||
c.Detail = provider.DetailPlainEnv
|
c.Detail = "env · plain text"
|
||||||
default:
|
default:
|
||||||
return
|
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) {
|
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||||
file, extract, ok := strings.Cut(o.ID, "\x00")
|
file, extract, ok := strings.Cut(o.ID, "\x00")
|
||||||
if !ok { // made from a link: the repo's root, the file's path, the key
|
if !ok { // made from a link: the repo's root, the file's path, the key
|
||||||
file = o.Container.ID
|
file, extract = o.Container.ID, extractFor(o.Name)
|
||||||
if file == "" {
|
if file == "" {
|
||||||
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
|
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
|
||||||
}
|
}
|
||||||
extract = extractFor(file, o.Name)
|
|
||||||
}
|
}
|
||||||
if _, isSops := readMeta(file); !isSops {
|
if _, isSops := readMeta(file); !isSops {
|
||||||
return plainValue(file, o.Name)
|
return plainValue(file, o.Name)
|
||||||
@@ -425,46 +412,6 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
return provider.NewSecretValue(stdout.Bytes()), nil
|
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
|
|
||||||
if path == "" { // made from a link
|
|
||||||
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
|
|
||||||
}
|
|
||||||
f := provider.File{Path: path}
|
|
||||||
if _, isSops := readMeta(path); isSops {
|
|
||||||
f.Sops = p.s.Sops
|
|
||||||
}
|
|
||||||
if o == nil {
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
b, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return f, err
|
|
||||||
}
|
|
||||||
keys, err := parseKeys(path, b)
|
|
||||||
if err != nil {
|
|
||||||
return f, nil // still worth opening, at the top
|
|
||||||
}
|
|
||||||
_, extract, _ := strings.Cut(o.ID, "\x00")
|
|
||||||
for _, k := range keys {
|
|
||||||
if k.extract == extract || (extract == "" && k.name == o.Name) {
|
|
||||||
f.Line = k.line
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sopsError is sops' own message, whole: which keys it tried and why
|
// sopsError is sops' own message, whole: which keys it tried and why
|
||||||
// each failed can all matter.
|
// each failed can all matter.
|
||||||
func sopsError(stderr string, err error) string {
|
func sopsError(stderr string, err error) string {
|
||||||
@@ -486,16 +433,6 @@ func (p *Provider) Locate(_ context.Context, host string) (string, bool, error)
|
|||||||
return "", false, nil
|
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 ---
|
// --- git ---
|
||||||
|
|
||||||
func gitRoot(dir string) (string, error) {
|
func gitRoot(dir string) (string, error) {
|
||||||
@@ -600,27 +537,6 @@ func normalizeRemote(remote string) (string, string) {
|
|||||||
type lazysecretsFile struct {
|
type lazysecretsFile struct {
|
||||||
Include []string `yaml:"include"`
|
Include []string `yaml:"include"`
|
||||||
Exclude []string `yaml:"exclude"`
|
Exclude []string `yaml:"exclude"`
|
||||||
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
|
// selector says which files to look at: user (the include/exclude of the
|
||||||
@@ -671,7 +587,7 @@ func envFiles(root string, tracked []string) []string {
|
|||||||
dirs := map[string]bool{".": true}
|
dirs := map[string]bool{".": true}
|
||||||
for _, f := range tracked {
|
for _, f := range tracked {
|
||||||
isTracked[f] = true
|
isTracked[f] = true
|
||||||
if IsEnv(f) {
|
if isEnv(f) {
|
||||||
out = append(out, f)
|
out = append(out, f)
|
||||||
}
|
}
|
||||||
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
|
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
|
||||||
@@ -685,7 +601,7 @@ func envFiles(root string, tracked []string) []string {
|
|||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
f := path.Join(d, e.Name())
|
f := path.Join(d, e.Name())
|
||||||
if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) {
|
if e.Type().IsRegular() && !isTracked[f] && isEnv(f) {
|
||||||
out = append(out, f)
|
out = append(out, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,10 +632,10 @@ func inPackages(f string) bool {
|
|||||||
return false
|
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
|
// .env, .env.local, prod.env; not .env.example and the like, nor one in
|
||||||
// dependencies.
|
// dependencies.
|
||||||
func IsEnv(f string) bool {
|
func isEnv(f string) bool {
|
||||||
if inPackages(f) {
|
if inPackages(f) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -869,31 +785,9 @@ func anyMatch(res []*regexp.Regexp, f string) bool {
|
|||||||
|
|
||||||
type sopsMeta struct {
|
type sopsMeta struct {
|
||||||
methods []string // age, azure_kv, …
|
methods []string // age, azure_kv, …
|
||||||
recipients []string // "age age1…", "azure_kv https://kv…/keys/sops"
|
|
||||||
modified time.Time
|
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
|
const maxFileSize = 4 << 20
|
||||||
|
|
||||||
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
|
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
|
||||||
@@ -923,15 +817,6 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
for _, k := range known {
|
for _, k := range known {
|
||||||
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
|
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
|
||||||
m.methods = append(m.methods, k)
|
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 {
|
if s, ok := doc.Sops["lastmodified"].(string); ok {
|
||||||
@@ -948,7 +833,6 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
for _, k := range known {
|
for _, k := range known {
|
||||||
if strings.Contains(text, "sops_"+k+"__") {
|
if strings.Contains(text, "sops_"+k+"__") {
|
||||||
m.methods = append(m.methods, k)
|
m.methods = append(m.methods, k)
|
||||||
m.recipients = append(m.recipients, envRecipients(text, k)...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
|
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
|
||||||
@@ -966,35 +850,6 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
return m, true
|
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 {
|
func formatOf(file string) string {
|
||||||
switch strings.ToLower(filepath.Ext(file)) {
|
switch strings.ToLower(filepath.Ext(file)) {
|
||||||
case ".yaml", ".yml":
|
case ".yaml", ".yml":
|
||||||
@@ -1013,10 +868,9 @@ func formatOf(file string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type key struct {
|
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"]
|
extract string // sops --extract: ["db"]["password"]
|
||||||
encrypted bool
|
encrypted bool
|
||||||
line int // 1-based
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseKeys lists a sops file's keys without decrypting anything.
|
// parseKeys lists a sops file's keys without decrypting anything.
|
||||||
@@ -1035,18 +889,18 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
case "dotenv":
|
case "dotenv":
|
||||||
var out []key
|
var out []key
|
||||||
for i, line := range strings.Split(string(b), "\n") {
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
k, v, ok := envLine(line)
|
k, v, ok := envLine(line)
|
||||||
if !ok || strings.HasPrefix(k, "sops_") {
|
if !ok || strings.HasPrefix(k, "sops_") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC[")})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
case "ini":
|
case "ini":
|
||||||
var out []key
|
var out []key
|
||||||
section := ""
|
section := ""
|
||||||
for i, line := range strings.Split(string(b), "\n") {
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
switch {
|
switch {
|
||||||
case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"):
|
case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"):
|
||||||
@@ -1058,7 +912,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
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[")})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -1075,9 +929,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
|
|||||||
if top && k == "sops" {
|
if top && k == "sops" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
child := escape(k)
|
child := k
|
||||||
if name != "" {
|
if name != "" {
|
||||||
child = name + "." + child
|
child = name + "." + k
|
||||||
}
|
}
|
||||||
walk(n.Content[i+1], child, extract+quote(k), false, out)
|
walk(n.Content[i+1], child, extract+quote(k), false, out)
|
||||||
}
|
}
|
||||||
@@ -1090,7 +944,7 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
|
|||||||
walk(n.Alias, name, extract, false, out)
|
walk(n.Alias, name, extract, false, out)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
*out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC["), line: n.Line})
|
*out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC[")})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,56 +953,23 @@ func quote(k string) string {
|
|||||||
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
|
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
|
// 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:
|
// from a link (db.password, list[0]). Names with dots in keys can't be
|
||||||
// its names are the keys as they are.
|
// told apart from nesting: those come from the listing instead.
|
||||||
func extractFor(file, name string) string {
|
func extractFor(name string) string {
|
||||||
if formatOf(file) == "dotenv" {
|
var b strings.Builder
|
||||||
return quote(name)
|
for _, part := range strings.Split(name, ".") {
|
||||||
|
base, idx, _ := strings.Cut(part, "[")
|
||||||
|
if base != "" {
|
||||||
|
b.WriteString(quote(base))
|
||||||
}
|
}
|
||||||
var b, part strings.Builder
|
if idx != "" {
|
||||||
flush := func() {
|
b.WriteString("[" + idx)
|
||||||
if part.Len() > 0 {
|
|
||||||
b.WriteString(quote(part.String()))
|
|
||||||
part.Reset()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func digits(s string) bool {
|
|
||||||
return s != "" && strings.Trim(s, "0123456789") == ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func absPath(p string) string {
|
func absPath(p string) string {
|
||||||
if a, err := filepath.Abs(p); err == nil {
|
if a, err := filepath.Abs(p); err == nil {
|
||||||
return a
|
return a
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package repo
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"maps"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -336,30 +335,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) {
|
func TestStarredReposAlongside(t *testing.T) {
|
||||||
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
||||||
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
|
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
|
||||||
@@ -460,109 +435,3 @@ func TestEnvFilesSkipPackages(t *testing.T) {
|
|||||||
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
|
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFile(t *testing.T) {
|
|
||||||
root := gitRepo(t, map[string]string{
|
|
||||||
".gitignore": ".env\n",
|
|
||||||
"secrets/app.yaml": sopsYAML,
|
|
||||||
})
|
|
||||||
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nTOKEN=x\n\nURL=y\n"), 0o600)
|
|
||||||
p := New(Settings{Dir: root, Sops: "/opt/sops"})
|
|
||||||
ctx := context.Background()
|
|
||||||
srcs, _ := p.Sources(ctx)
|
|
||||||
files, err := p.Containers(ctx, srcs[0])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lines := map[string]int{}
|
|
||||||
for _, c := range files {
|
|
||||||
f, err := p.File(c, nil)
|
|
||||||
if err != nil || f.Path != filepath.Join(root, c.Name) || f.Line != 0 {
|
|
||||||
t.Errorf("%s: %+v, %v", c.Name, f, err)
|
|
||||||
}
|
|
||||||
if sops := f.Sops != ""; sops != (c.Name == "secrets/app.yaml") || (sops && f.Sops != "/opt/sops") {
|
|
||||||
t.Errorf("%s: sops %q", c.Name, f.Sops)
|
|
||||||
}
|
|
||||||
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
|
|
||||||
for _, k := range keys {
|
|
||||||
f, err := p.File(c, &k)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lines[c.Name+" "+k.Name] = f.Line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
want := map[string]int{
|
|
||||||
".env TOKEN": 2, ".env URL": 4,
|
|
||||||
"secrets/app.yaml db.password": 2, "secrets/app.yaml db.port": 3,
|
|
||||||
"secrets/app.yaml list[0]": 5, "secrets/app.yaml public_unencrypted": 6,
|
|
||||||
}
|
|
||||||
if !maps.Equal(lines, want) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
-26
@@ -10,15 +10,11 @@
|
|||||||
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
|
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
|
||||||
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
|
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
|
||||||
// lazysecrets://repo/<repo>[/<file>[/<key>]]
|
// lazysecrets://repo/<repo>[/<file>[/<key>]]
|
||||||
// lazysecrets://share/<sealed files>
|
|
||||||
//
|
//
|
||||||
// A repo is its git remote without scheme or .git
|
// 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
|
// (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.
|
// 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.
|
// Vault hosts always contain dots, so "subscriptions" can't be one.
|
||||||
//
|
//
|
||||||
// Every path segment is percent-encoded, so names may hold any character.
|
// Every path segment is percent-encoded, so names may hold any character.
|
||||||
@@ -53,19 +49,10 @@ type Ref struct {
|
|||||||
Type provider.ObjectType
|
Type provider.ObjectType
|
||||||
Name string // object name; empty for a container link
|
Name string // object name; empty for a container link
|
||||||
Version string
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
// IsSource reports whether r points at a subscription or workspace.
|
||||||
func (r Ref) IsSource() bool {
|
func (r Ref) IsSource() bool {
|
||||||
if r.IsShare() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if r.Kind != provider.KindAzure {
|
if r.Kind != provider.KindAzure {
|
||||||
return r.Scope == ""
|
return r.Scope == ""
|
||||||
}
|
}
|
||||||
@@ -73,7 +60,7 @@ func (r Ref) IsSource() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsContainer reports whether r points at a vault or scope.
|
// 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.
|
// ContainerName is the vault name (from its host) or the scope.
|
||||||
func (r Ref) ContainerName() string {
|
func (r Ref) ContainerName() string {
|
||||||
@@ -124,9 +111,6 @@ func (r Ref) IdentifierURL() string {
|
|||||||
|
|
||||||
// String is the lazysecrets:// link.
|
// String is the lazysecrets:// link.
|
||||||
func (r Ref) String() string {
|
func (r Ref) String() string {
|
||||||
if r.IsShare() {
|
|
||||||
return Scheme + "://share/" + r.Share
|
|
||||||
}
|
|
||||||
if r.Kind == provider.KindAzure && r.Subscription != "" {
|
if r.Kind == provider.KindAzure && r.Subscription != "" {
|
||||||
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
|
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
|
||||||
}
|
}
|
||||||
@@ -224,8 +208,6 @@ var (
|
|||||||
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
|
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
|
||||||
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
|
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
|
||||||
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
|
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.
|
// Parse reads any supported reference.
|
||||||
@@ -256,12 +238,6 @@ func parseLink(s string) (Ref, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Ref{}, err
|
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
|
var r Ref
|
||||||
if len(segs) == 0 || segs[0] == "" {
|
if len(segs) == 0 || segs[0] == "" {
|
||||||
return Ref{}, errors.New("link has no vault or workspace")
|
return Ref{}, errors.New("link has no vault or workspace")
|
||||||
@@ -310,7 +286,7 @@ func parseLink(s string) (Ref, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
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
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
-13
@@ -145,23 +145,16 @@ type Store struct {
|
|||||||
data file
|
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
|
// 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
|
// that never saves, for tests and --demo.
|
||||||
// is read but never saved, so nothing it keeps is lost: Load returns the
|
|
||||||
// store with an ErrNewer error.
|
|
||||||
func Load(path string) (*Store, error) {
|
func Load(path string) (*Store, error) {
|
||||||
s := &Store{path: path}
|
s := &Store{path: path}
|
||||||
d, err := read(path)
|
d, err := read(path)
|
||||||
if errors.Is(err, ErrNewer) {
|
if err != nil {
|
||||||
s.path = ""
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.data = d
|
s.data = d
|
||||||
return s, err
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func read(path string) (file, error) {
|
func read(path string) (file, error) {
|
||||||
@@ -179,12 +172,12 @@ func read(path string) (file, error) {
|
|||||||
if err := json.Unmarshal(b, &d); err != nil {
|
if err := json.Unmarshal(b, &d); err != nil {
|
||||||
return d, fmt.Errorf("%s: %w", path, err)
|
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 {
|
if d.Nodes == nil {
|
||||||
d.Nodes = map[string]Node{}
|
d.Nodes = map[string]Node{}
|
||||||
}
|
}
|
||||||
if d.Version > version {
|
|
||||||
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
|
|
||||||
}
|
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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")
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
|
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
|
||||||
_ = os.WriteFile(path, []byte(newer), 0o600)
|
if _, err := Load(path); err == nil {
|
||||||
s, err := Load(path)
|
t.Error("a newer file was accepted")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-325
@@ -8,7 +8,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,13 +17,13 @@ import (
|
|||||||
"charm.land/bubbles/v2/spinner"
|
"charm.land/bubbles/v2/spinner"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/clipboard"
|
"git.kebcloud.no/keb/lazysecrets/internal/clipboard"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||||
@@ -40,9 +39,6 @@ const (
|
|||||||
paneContainers
|
paneContainers
|
||||||
paneSecrets
|
paneSecrets
|
||||||
paneDetail
|
paneDetail
|
||||||
// paneStatus is on top of the left panes; after the right pane in
|
|
||||||
// the tab order (status.go).
|
|
||||||
paneStatus
|
|
||||||
paneCount
|
paneCount
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,7 +53,6 @@ const (
|
|||||||
slotDescribe // extra metadata for the selected object
|
slotDescribe // extra metadata for the selected object
|
||||||
slotCopy // fetch a value straight to the clipboard
|
slotCopy // fetch a value straight to the clipboard
|
||||||
slotVersions // count the selected object's versions
|
slotVersions // count the selected object's versions
|
||||||
slotDetails // a file's details
|
|
||||||
slotCount
|
slotCount
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,8 +60,6 @@ const (
|
|||||||
type Options struct {
|
type Options struct {
|
||||||
// Timeout bounds every provider call. Defaults to 30s.
|
// Timeout bounds every provider call. Defaults to 30s.
|
||||||
Timeout time.Duration
|
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 shows Nerd Font glyphs next to backends.
|
||||||
Icons bool
|
Icons bool
|
||||||
// Clipboard defaults to the system clipboard, or OSC52 over SSH.
|
// Clipboard defaults to the system clipboard, or OSC52 over SSH.
|
||||||
@@ -75,23 +68,9 @@ type Options struct {
|
|||||||
ClipboardClear time.Duration
|
ClipboardClear time.Duration
|
||||||
// OpenURL opens a web page; defaults to the system browser.
|
// OpenURL opens a web page; defaults to the system browser.
|
||||||
OpenURL func(url string) error
|
OpenURL func(url string) error
|
||||||
// ConfigFiles and StatePath are where lazysecrets reads its config
|
|
||||||
// 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
|
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
||||||
// run only.
|
// run only.
|
||||||
CacheFile string
|
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
|
// 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.
|
// left there, unless it was started on a link. "" doesn't.
|
||||||
Dir string
|
Dir string
|
||||||
@@ -122,11 +101,9 @@ type Options struct {
|
|||||||
// Keys, if set, replaces the default keymap (keys.Map.Apply).
|
// Keys, if set, replaces the default keymap (keys.Map.Apply).
|
||||||
Keys *keys.Map
|
Keys *keys.Map
|
||||||
// Updater, if set, finds and installs newer releases (U); with
|
// Updater, if set, finds and installs newer releases (U); with
|
||||||
// UpdateCheck, lazysecrets also looks on its own at startup, at most
|
// UpdateCheck, lazysecrets also looks once a day on its own.
|
||||||
// once per UpdateEvery (0 for a day).
|
|
||||||
Updater *Updater
|
Updater *Updater
|
||||||
UpdateCheck bool
|
UpdateCheck bool
|
||||||
UpdateEvery time.Duration
|
|
||||||
// HandlerSetup, if set, offers to set up the link handler at startup.
|
// HandlerSetup, if set, offers to set up the link handler at startup.
|
||||||
HandlerSetup *HandlerSetup
|
HandlerSetup *HandlerSetup
|
||||||
// NoHistory stops counting uses of items for the Recent list.
|
// NoHistory stops counting uses of items for the Recent list.
|
||||||
@@ -151,7 +128,7 @@ type Clipboard interface {
|
|||||||
type Model struct {
|
type Model struct {
|
||||||
providers []provider.Provider
|
providers []provider.Provider
|
||||||
byID map[string]provider.Provider
|
byID map[string]provider.Provider
|
||||||
timeout timeouts
|
timeout time.Duration
|
||||||
|
|
||||||
keys keys.Map
|
keys keys.Map
|
||||||
st styles.Styles
|
st styles.Styles
|
||||||
@@ -185,30 +162,14 @@ type Model struct {
|
|||||||
clip Clipboard
|
clip Clipboard
|
||||||
clipClear time.Duration
|
clipClear time.Duration
|
||||||
openURL func(string) error
|
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 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
|
|
||||||
getenv func(string) string
|
|
||||||
nvimOpen func(server, path string, line int) error // edit.go
|
|
||||||
now func() time.Time
|
|
||||||
// The cache file, whether the cache changed since it was saved, and
|
// The cache file, whether the cache changed since it was saved, and
|
||||||
// when each provider's Sources list was loaded (persist.go).
|
// when each provider's Sources list was loaded (persist.go).
|
||||||
cachePath string
|
cachePath string
|
||||||
// configFiles and statePath are for the status pane (status.go).
|
|
||||||
configFiles []string
|
|
||||||
statePath string
|
|
||||||
statusW int // the status pane's width: the left column's, zoomed or not
|
|
||||||
changelog []panes.Field
|
|
||||||
cacheDirty bool
|
cacheDirty bool
|
||||||
sourcesAt map[string]time.Time
|
sourcesAt map[string]time.Time
|
||||||
placeDir string
|
placeDir string
|
||||||
savedPlace string // the link last saved as the place
|
savedPlace string // the link last saved as the place
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
// A pending clear of a copied value, counted down in the footer.
|
// A pending clear of a copied value, counted down in the footer.
|
||||||
// clipToken identifies the latest copy so older ticks are ignored.
|
// clipToken identifies the latest copy so older ticks are ignored.
|
||||||
@@ -243,7 +204,6 @@ type Model struct {
|
|||||||
|
|
||||||
updater *Updater
|
updater *Updater
|
||||||
updateCheck bool
|
updateCheck bool
|
||||||
updateEvery time.Duration
|
|
||||||
fetchOnStart bool // look for a newer release in Init
|
fetchOnStart bool // look for a newer release in Init
|
||||||
newer *update.Release // a newer release to offer
|
newer *update.Release // a newer release to offer
|
||||||
updated string // the release installed, waiting for a restart
|
updated string // the release installed, waiting for a restart
|
||||||
@@ -284,9 +244,6 @@ type Model struct {
|
|||||||
linked []provider.Container // vaults opened from links, this session
|
linked []provider.Container // vaults opened from links, this session
|
||||||
open *ref.Ref // reference to open at startup
|
open *ref.Ref // reference to open at startup
|
||||||
|
|
||||||
shareKey string // the config's sharing.key
|
|
||||||
shareRules share.Rules // and sharing.keyRules
|
|
||||||
|
|
||||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
||||||
dismissedLink string // the last link offered: not offered again
|
dismissedLink string // the last link offered: not offered again
|
||||||
clipChecked time.Time
|
clipChecked time.Time
|
||||||
@@ -383,7 +340,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
m := Model{
|
m := Model{
|
||||||
providers: providers,
|
providers: providers,
|
||||||
byID: map[string]provider.Provider{},
|
byID: map[string]provider.Provider{},
|
||||||
timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
|
timeout: opts.Timeout,
|
||||||
keys: keys.Default(),
|
keys: keys.Default(),
|
||||||
st: styles.Default(),
|
st: styles.Default(),
|
||||||
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
|
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
|
||||||
@@ -393,11 +350,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
clip: opts.Clipboard,
|
clip: opts.Clipboard,
|
||||||
clipClear: opts.ClipboardClear,
|
clipClear: opts.ClipboardClear,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
getenv: os.Getenv,
|
|
||||||
nvimOpen: nvimOpen,
|
|
||||||
cache: newCache(),
|
cache: newCache(),
|
||||||
details: map[string]detailsState{},
|
|
||||||
fields: panes.NewFields(),
|
|
||||||
}
|
}
|
||||||
if m.clip == nil {
|
if m.clip == nil {
|
||||||
m.clip = clipboard.New()
|
m.clip = clipboard.New()
|
||||||
@@ -431,14 +384,11 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
if opts.Keys != nil {
|
if opts.Keys != nil {
|
||||||
m.keys = *opts.Keys
|
m.keys = *opts.Keys
|
||||||
}
|
}
|
||||||
m.detail.RevealKey, m.detail.VersionsKey, m.detail.CopyKey = keys.Name(m.keys.Reveal), keys.Name(m.keys.Versions), keys.Name(m.keys.Copy)
|
m.detail.RevealKey, m.detail.VersionsKey = keys.Name(m.keys.Reveal), keys.Name(m.keys.Versions)
|
||||||
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
|
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
|
||||||
m.expiryWarning = opts.ExpiryWarning
|
m.expiryWarning = opts.ExpiryWarning
|
||||||
m.handlerSetup = opts.HandlerSetup
|
m.handlerSetup = opts.HandlerSetup
|
||||||
m.updater, m.updateCheck, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery
|
m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck
|
||||||
if m.updateEvery == 0 {
|
|
||||||
m.updateEvery = updateEvery
|
|
||||||
}
|
|
||||||
m.startUpdateCheck()
|
m.startUpdateCheck()
|
||||||
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
|
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
|
||||||
m.settle = opts.LoadDelay
|
m.settle = opts.LoadDelay
|
||||||
@@ -475,17 +425,10 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
m.spinning = true
|
m.spinning = true
|
||||||
|
|
||||||
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
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 != "" {
|
if m.cachePath != "" {
|
||||||
m.loadCacheFile()
|
m.loadCacheFile()
|
||||||
if len(m.sourceResults) > 0 {
|
if len(m.sourceResults) > 0 {
|
||||||
m.rebuildSources()
|
m.rebuildSources()
|
||||||
m.sources.Loading = true // reloading what the file had
|
|
||||||
m.sources.Age = m.cachedAge(m.oldestSources())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.placeDir != "" && opts.Open == nil {
|
if m.placeDir != "" && opts.Open == nil {
|
||||||
@@ -513,7 +456,7 @@ func (m *Model) setVersions(items []provider.Object) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
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 != "" {
|
if m.cachePath != "" || m.placeDir != "" {
|
||||||
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
|
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
|
||||||
}
|
}
|
||||||
@@ -538,9 +481,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.BackgroundColorMsg:
|
|
||||||
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
|
|
||||||
return m, nil
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.w, m.h = msg.Width, msg.Height
|
m.w, m.h = msg.Width, msg.Height
|
||||||
|
|
||||||
@@ -594,15 +534,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case resolvedMsg:
|
case resolvedMsg:
|
||||||
m.handleResolved(msg)
|
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:
|
case tea.PasteMsg:
|
||||||
cmds = append(cmds, m.handlePaste(msg))
|
cmds = append(cmds, m.handlePaste(msg))
|
||||||
|
|
||||||
@@ -618,25 +549,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case commandDoneMsg:
|
case commandDoneMsg:
|
||||||
m.handleCommandDone(msg)
|
m.handleCommandDone(msg)
|
||||||
|
|
||||||
case editDoneMsg:
|
|
||||||
cmds = append(cmds, m.handleEditDone(msg))
|
|
||||||
|
|
||||||
case persistTickMsg:
|
case persistTickMsg:
|
||||||
cmds = append(cmds, m.persistTick())
|
cmds = append(cmds, m.persistTick())
|
||||||
|
|
||||||
case persistedMsg:
|
case persistedMsg:
|
||||||
m.handlePersisted(msg)
|
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 crawlMsg:
|
case crawlMsg:
|
||||||
cmds = append(cmds, m.handleCrawl(msg))
|
cmds = append(cmds, m.handleCrawl(msg))
|
||||||
|
|
||||||
@@ -663,9 +581,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.done(slotContainers)
|
m.done(slotContainers)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
if src, ok := m.selectedSource(); ok {
|
|
||||||
m.logErrOf(src.Name, msg.err)
|
|
||||||
}
|
|
||||||
delete(m.cache.containers, msg.key)
|
delete(m.cache.containers, msg.key)
|
||||||
m.containers.SetError(msg.err)
|
m.containers.SetError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
@@ -684,23 +599,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.closeVersions()
|
m.closeVersions()
|
||||||
} else if msg.err != nil {
|
} else if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
if c, ok := m.containers.Selected(); ok {
|
|
||||||
m.logErrOf(c.Name, msg.err)
|
|
||||||
}
|
|
||||||
delete(m.cache.objects, msg.key)
|
delete(m.cache.objects, msg.key)
|
||||||
m.secrets.SetError(msg.err)
|
m.secrets.SetError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
if msg.bound != nil {
|
if msg.bound != nil {
|
||||||
msg.key = m.rebind(*msg.bound)
|
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.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||||
m.cacheDirty = true
|
m.cacheDirty = true
|
||||||
if m.versionsOf != nil {
|
if m.versionsOf != nil {
|
||||||
m.setVersions(msg.items)
|
m.setVersions(msg.items)
|
||||||
} else {
|
} else {
|
||||||
m.secrets.SetItems(m.starredFirst(msg.items))
|
m.secrets.SetItems(m.starredFirst(msg.items))
|
||||||
cmds = append(cmds, m.refreshShown(before))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,7 +621,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.done(slotValue)
|
m.done(slotValue)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
|
||||||
m.detail.SetValueError(msg.err)
|
m.detail.SetValueError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
m.detail.SetValue(msg.value)
|
m.detail.SetValue(msg.value)
|
||||||
@@ -738,7 +647,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.done(slotDescribe)
|
m.done(slotDescribe)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
|
||||||
m.detail.SetDescribeError(msg.err)
|
m.detail.SetDescribeError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
|
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
|
||||||
@@ -805,12 +713,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.waiting = 0
|
m.waiting = 0
|
||||||
m.askHandlerSetup()
|
m.askHandlerSetup()
|
||||||
m.keys.Login.SetEnabled(m.loginFor() != nil)
|
m.keys.Login.SetEnabled(m.auth != nil && len(m.auth.Login) > 0)
|
||||||
if m.focus != paneDetail {
|
|
||||||
m.lastLeft = m.focus
|
|
||||||
}
|
|
||||||
m.logFlash()
|
|
||||||
cmds = append(cmds, m.syncFields())
|
|
||||||
if m.anyLoading() && !m.spinning {
|
if m.anyLoading() && !m.spinning {
|
||||||
m.spinning = true
|
m.spinning = true
|
||||||
cmds = append(cmds, m.spin.Tick)
|
cmds = append(cmds, m.spin.Tick)
|
||||||
@@ -876,19 +779,9 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
|||||||
case key.Matches(msg, k.Bottom):
|
case key.Matches(msg, k.Bottom):
|
||||||
m.jump(false)
|
m.jump(false)
|
||||||
case key.Matches(msg, k.NextPane):
|
case key.Matches(msg, k.NextPane):
|
||||||
// The four main panes, round; the status pane (0) leads into them.
|
m.focus = (m.focus + 1) % paneCount
|
||||||
m.focus = (m.focus + 1) % paneStatus
|
|
||||||
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):
|
case key.Matches(msg, k.PrevPane):
|
||||||
if m.focus == paneDetail {
|
m.focus = (m.focus + paneCount - 1) % paneCount
|
||||||
m.focus = m.lastLeft // back to what it describes
|
|
||||||
} else {
|
|
||||||
m.focus = (m.focus + paneStatus - 1) % paneStatus
|
|
||||||
}
|
|
||||||
case key.Matches(msg, k.Pane1):
|
case key.Matches(msg, k.Pane1):
|
||||||
m.focus = paneSources
|
m.focus = paneSources
|
||||||
case key.Matches(msg, k.Pane2):
|
case key.Matches(msg, k.Pane2):
|
||||||
@@ -897,18 +790,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
|||||||
m.focus = paneSecrets
|
m.focus = paneSecrets
|
||||||
case key.Matches(msg, k.Pane4):
|
case key.Matches(msg, k.Pane4):
|
||||||
m.focus = paneDetail
|
m.focus = paneDetail
|
||||||
case key.Matches(msg, k.Pane0):
|
|
||||||
m.focus = paneStatus
|
|
||||||
case m.focus == paneStatus && key.Matches(msg, k.Enter):
|
|
||||||
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():
|
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
|
||||||
// folded or unfolded a provider group
|
// 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):
|
case key.Matches(msg, k.Enter):
|
||||||
if m.focus < paneDetail {
|
if m.focus < paneDetail {
|
||||||
m.focus++
|
m.focus++
|
||||||
@@ -964,10 +847,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
|||||||
case key.Matches(msg, k.Back):
|
case key.Matches(msg, k.Back):
|
||||||
if f := m.focusedFilter(); f != nil && f.HasFilter() {
|
if f := m.focusedFilter(); f != nil && f.HasFilter() {
|
||||||
f.ClearFilter()
|
f.ClearFilter()
|
||||||
} else if m.versionsOf != nil && (m.focus == paneSecrets || m.focus == paneDetail) {
|
} else if m.versionsOf != nil && m.focus >= paneSecrets {
|
||||||
m.closeVersions()
|
m.closeVersions()
|
||||||
} else if m.focus == paneStatus {
|
|
||||||
m.focus = paneSources
|
|
||||||
} else if m.focus > paneSources {
|
} else if m.focus > paneSources {
|
||||||
m.focus--
|
m.focus--
|
||||||
}
|
}
|
||||||
@@ -989,11 +870,9 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
|||||||
}
|
}
|
||||||
case key.Matches(msg, k.Open):
|
case key.Matches(msg, k.Open):
|
||||||
return m.openSelected(), false
|
return m.openSelected(), false
|
||||||
case key.Matches(msg, k.Edit):
|
|
||||||
return m.edit(), false
|
|
||||||
case key.Matches(msg, k.Login):
|
case key.Matches(msg, k.Login):
|
||||||
if auth := m.loginFor(); auth != nil {
|
if m.auth != nil && len(m.auth.Login) > 0 {
|
||||||
c := exec.Command(auth.Login[0], auth.Login[1:]...)
|
c := exec.Command(m.auth.Login[0], m.auth.Login[1:]...)
|
||||||
return tea.ExecProcess(c, func(err error) tea.Msg { return loginDoneMsg{err: err} }), false
|
return tea.ExecProcess(c, func(err error) tea.Msg { return loginDoneMsg{err: err} }), false
|
||||||
}
|
}
|
||||||
case key.Matches(msg, k.Refresh):
|
case key.Matches(msg, k.Refresh):
|
||||||
@@ -1022,10 +901,6 @@ func (m *Model) focusedFilter() filterable {
|
|||||||
return &m.containers
|
return &m.containers
|
||||||
case paneSecrets:
|
case paneSecrets:
|
||||||
return &m.secrets
|
return &m.secrets
|
||||||
case paneDetail:
|
|
||||||
if m.fields.Shown() {
|
|
||||||
return &m.fields
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1040,12 +915,8 @@ func (m *Model) move(delta int) {
|
|||||||
case paneSecrets:
|
case paneSecrets:
|
||||||
m.secrets.Move(delta)
|
m.secrets.Move(delta)
|
||||||
case paneDetail:
|
case paneDetail:
|
||||||
if m.fields.Shown() {
|
|
||||||
m.fields.Move(delta)
|
|
||||||
} else {
|
|
||||||
m.detail.Scroll(delta)
|
m.detail.Scroll(delta)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) jump(top bool) {
|
func (m *Model) jump(top bool) {
|
||||||
@@ -1064,10 +935,6 @@ func (m *Model) jump(top bool) {
|
|||||||
m.secrets.Top()
|
m.secrets.Top()
|
||||||
case m.focus == paneSecrets:
|
case m.focus == paneSecrets:
|
||||||
m.secrets.Bottom()
|
m.secrets.Bottom()
|
||||||
case m.focus == paneDetail && m.fields.Shown() && top:
|
|
||||||
m.fields.Top()
|
|
||||||
case m.focus == paneDetail && m.fields.Shown():
|
|
||||||
m.fields.Bottom()
|
|
||||||
case m.focus == paneDetail && top:
|
case m.focus == paneDetail && top:
|
||||||
m.detail.ScrollTop()
|
m.detail.ScrollTop()
|
||||||
case m.focus == paneDetail:
|
case m.focus == paneDetail:
|
||||||
@@ -1100,7 +967,6 @@ func (m *Model) refresh() tea.Cmd {
|
|||||||
case paneContainers:
|
case paneContainers:
|
||||||
if src, ok := m.selectedSource(); ok {
|
if src, ok := m.selectedSource(); ok {
|
||||||
m.containers.Refresh()
|
m.containers.Refresh()
|
||||||
m.details = map[string]detailsState{}
|
|
||||||
return m.loadContainers(src, m.selSource)
|
return m.loadContainers(src, m.selSource)
|
||||||
}
|
}
|
||||||
case paneSecrets:
|
case paneSecrets:
|
||||||
@@ -1223,7 +1089,6 @@ func (m *Model) showContainers(src provider.Source, key string) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.containers.Refresh()
|
m.containers.Refresh()
|
||||||
m.containers.Age = m.cachedAge(e.at)
|
|
||||||
return m.loadContainers(src, key)
|
return m.loadContainers(src, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,7 +1105,6 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.secrets.Refresh()
|
m.secrets.Refresh()
|
||||||
m.secrets.Age = m.cachedAge(e.at)
|
|
||||||
return m.loadObjects(c, m.objType, key)
|
return m.loadObjects(c, m.objType, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1254,7 +1118,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
m.secrets.SetLoading()
|
m.secrets.SetLoading()
|
||||||
p := m.providerFor(o.Container.Source)
|
p := m.providerFor(o.Container.Source)
|
||||||
ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
|
ctx, req := m.begin(slotObjects)
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
items, err := p.Versions(ctx, o)
|
items, err := p.Versions(ctx, o)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
@@ -1284,7 +1148,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
p := m.providerFor(o.Container.Source)
|
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 {
|
return m.settled(ctx, func() tea.Msg {
|
||||||
items, err := p.Versions(ctx, o)
|
items, err := p.Versions(ctx, o)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
@@ -1370,26 +1234,6 @@ func (m *Model) detailStale() bool {
|
|||||||
return hasSecret || shown != m.containers.SelectedID()
|
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 {
|
func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
|
||||||
if msg.gen != m.sourcesGen {
|
if msg.gen != m.sourcesGen {
|
||||||
return nil
|
return nil
|
||||||
@@ -1404,18 +1248,10 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
|
|||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
|
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
|
||||||
m.noteErr(r.err)
|
m.noteErr(r.err)
|
||||||
m.logErrOf(p.Label(), r.err)
|
|
||||||
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
|
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Said once, when they change; after that the Details pane says it
|
m.status = strings.Join(errs, " · ")
|
||||||
// for the selected account.
|
|
||||||
if status := strings.Join(errs, " · "); status != m.status {
|
|
||||||
m.status = status
|
|
||||||
if status != "" {
|
|
||||||
m.flash = flash{text: m.problemText(msg.provider, status), isErr: true}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.rebuildSources()
|
m.rebuildSources()
|
||||||
m.sources.Loading = m.sourcesPending > 0
|
m.sources.Loading = m.sourcesPending > 0
|
||||||
return m.autoLoad(msg)
|
return m.autoLoad(msg)
|
||||||
@@ -1435,26 +1271,11 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
|
|||||||
return m.crawlStart()
|
return m.crawlStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeouts bound each provider request: its kind's, else the default.
|
// begin starts a cancellable request for pane p, cancelling any in flight.
|
||||||
type timeouts struct {
|
func (m *Model) begin(p slot) (context.Context, int) {
|
||||||
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) {
|
|
||||||
m.cancelReq(p)
|
m.cancelReq(p)
|
||||||
m.seq++
|
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
|
m.reqID[p], m.cancel[p] = m.seq, cancel
|
||||||
return ctx, m.seq
|
return ctx, m.seq
|
||||||
}
|
}
|
||||||
@@ -1508,7 +1329,7 @@ func (m Model) sourcesCmd() tea.Cmd {
|
|||||||
gen := m.sourcesGen
|
gen := m.sourcesGen
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
for _, p := range m.providers {
|
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 {
|
cmds = append(cmds, func() tea.Msg {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
s, err := p.Sources(ctx)
|
s, err := p.Sources(ctx)
|
||||||
@@ -1519,7 +1340,7 @@ func (m Model) sourcesCmd() tea.Cmd {
|
|||||||
})
|
})
|
||||||
// The source it started in, quickly, on its own.
|
// The source it started in, quickly, on its own.
|
||||||
if c, ok := p.(provider.Current); ok {
|
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 {
|
cmds = append(cmds, func() tea.Msg {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
src, ok := c.Current(ctx)
|
src, ok := c.Current(ctx)
|
||||||
@@ -1536,7 +1357,7 @@ func (m Model) sourcesCmd() tea.Cmd {
|
|||||||
|
|
||||||
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
|
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
|
||||||
p := m.providerFor(src)
|
p := m.providerFor(src)
|
||||||
ctx, req := m.begin(slotContainers, src.Kind)
|
ctx, req := m.begin(slotContainers)
|
||||||
return m.settled(ctx, func() tea.Msg {
|
return m.settled(ctx, func() tea.Msg {
|
||||||
items, err := p.Containers(ctx, src)
|
items, err := p.Containers(ctx, src)
|
||||||
// Carry the source as given, with its provider ID, so later
|
// Carry the source as given, with its provider ID, so later
|
||||||
@@ -1555,7 +1376,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 {
|
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
|
||||||
cands := m.candidates(c.Source)
|
cands := m.candidates(c.Source)
|
||||||
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
|
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 {
|
return m.settled(ctx, func() tea.Msg {
|
||||||
var errs []error
|
var errs []error
|
||||||
for _, p := range cands {
|
for _, p := range cands {
|
||||||
@@ -1583,7 +1404,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
|
|||||||
|
|
||||||
func (m *Model) fetchValue(s provider.Object) tea.Cmd {
|
func (m *Model) fetchValue(s provider.Object) tea.Cmd {
|
||||||
p := m.providerFor(s.Container.Source)
|
p := m.providerFor(s.Container.Source)
|
||||||
ctx, req := m.begin(slotValue, s.Container.Source.Kind)
|
ctx, req := m.begin(slotValue)
|
||||||
m.detail.SetLoading()
|
m.detail.SetLoading()
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
v, err := p.Value(ctx, s)
|
v, err := p.Value(ctx, s)
|
||||||
@@ -1602,7 +1423,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
|||||||
m.detail.SetDescribing()
|
m.detail.SetDescribing()
|
||||||
}
|
}
|
||||||
p := m.providerFor(o.Container.Source)
|
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 {
|
return m.settled(ctx, func() tea.Msg {
|
||||||
obj, err := p.Describe(ctx, o)
|
obj, err := p.Describe(ctx, o)
|
||||||
return describeMsg{req: req, key: key, obj: obj, err: err}
|
return describeMsg{req: req, key: key, obj: obj, err: err}
|
||||||
@@ -1612,15 +1433,6 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
|||||||
// copyValue copies the selected object's value, fetching it without
|
// copyValue copies the selected object's value, fetching it without
|
||||||
// revealing it if it isn't shown already.
|
// revealing it if it isn't shown already.
|
||||||
func (m *Model) copyValue() tea.Cmd {
|
func (m *Model) copyValue() tea.Cmd {
|
||||||
if m.focus == paneDetail && m.fields.Shown() {
|
|
||||||
if f, ok := m.fields.Selected(); ok {
|
|
||||||
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
|
|
||||||
return m.copyText(a.CopyLabel, a.Copy, false)
|
|
||||||
}
|
|
||||||
o, ok := m.detail.Secret()
|
o, ok := m.detail.Secret()
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -1630,7 +1442,7 @@ func (m *Model) copyValue() tea.Cmd {
|
|||||||
return m.copyText(o.Name, v.Reveal(), true)
|
return m.copyText(o.Name, v.Reveal(), true)
|
||||||
}
|
}
|
||||||
p := m.providerFor(o.Container.Source)
|
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 + "…"}
|
m.flash = flash{text: "copying " + o.Name + "…"}
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
v, err := p.Value(ctx, o)
|
v, err := p.Value(ctx, o)
|
||||||
@@ -1675,25 +1487,6 @@ func (m *Model) cancelClipClear() {
|
|||||||
func (m *Model) openSelected() tea.Cmd {
|
func (m *Model) openSelected() tea.Cmd {
|
||||||
var name, url string
|
var name, url string
|
||||||
var used func()
|
var used func()
|
||||||
switch {
|
|
||||||
case m.focus == paneDetail && m.fields.Shown():
|
|
||||||
f, _ := m.fields.Selected()
|
|
||||||
if f.URL == "" {
|
|
||||||
m.flash = warning("nothing to open in the browser here")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
name, url, used = f.Label, f.URL, func() {}
|
|
||||||
default:
|
|
||||||
name, url, used = m.openTarget()
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return m.openPage(name, url, used)
|
|
||||||
}
|
|
||||||
|
|
||||||
// openTarget is the focused pane's selected item, for the browser.
|
|
||||||
func (m *Model) openTarget() (name, url string, used func()) {
|
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
case paneSources:
|
case paneSources:
|
||||||
s, _ := m.selectedSource()
|
s, _ := m.selectedSource()
|
||||||
@@ -1705,11 +1498,9 @@ func (m *Model) openTarget() (name, url string, used func()) {
|
|||||||
o, _ := m.detail.Secret()
|
o, _ := m.detail.Secret()
|
||||||
name, url, used = o.Name, o.URL, func() { m.usedObject(o) }
|
name, url, used = o.Name, o.URL, func() { m.usedObject(o) }
|
||||||
}
|
}
|
||||||
return name, url, used
|
if name == "" {
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
// openPage opens url in the browser, or copies it over SSH.
|
|
||||||
func (m *Model) openPage(name, url string, used func()) tea.Cmd {
|
|
||||||
if url == "" {
|
if url == "" {
|
||||||
m.flash = warning("no web page for " + name)
|
m.flash = warning("no web page for " + name)
|
||||||
return nil
|
return nil
|
||||||
@@ -1731,27 +1522,14 @@ func (m *Model) openPage(name, url string, used func()) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// noteErr remembers authentication failures so the UI can offer sign-in,
|
// noteErr remembers authentication failures so the UI can offer sign-in.
|
||||||
// saying so once for each account.
|
|
||||||
func (m *Model) noteErr(err error) {
|
func (m *Model) noteErr(err error) {
|
||||||
var auth *provider.AuthError
|
var auth *provider.AuthError
|
||||||
if errors.As(err, &auth) {
|
if errors.As(err, &auth) {
|
||||||
if m.auth == nil || m.auth.Who() != auth.Who() {
|
|
||||||
m.flash = flash{text: m.authText(auth), isErr: true}
|
|
||||||
}
|
|
||||||
m.auth = auth
|
m.auth = auth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// authText says which account needs signing in, and how.
|
|
||||||
func (m Model) authText(auth *provider.AuthError) string {
|
|
||||||
if len(auth.Login) == 0 {
|
|
||||||
return fmt.Sprintf("%s: sign-in required: %v", auth.Who(), auth.Err)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s: sign-in required (not signed in or session expired)%s",
|
|
||||||
auth.Who(), withKey(m.keys.Login, " · press %s to run "+strings.Join(auth.Login, " ")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) anyLoading() bool {
|
func (m Model) anyLoading() bool {
|
||||||
return m.sources.Loading || m.containers.Loading || m.secrets.Loading ||
|
return m.sources.Loading || m.containers.Loading || m.secrets.Loading ||
|
||||||
m.detail.State() == panes.ValueLoading
|
m.detail.State() == panes.ValueLoading
|
||||||
@@ -1769,10 +1547,19 @@ func (m Model) footerView() string {
|
|||||||
} else if l != nil && l.Filtering() {
|
} else if l != nil && l.Filtering() {
|
||||||
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
|
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
|
||||||
} else {
|
} else {
|
||||||
revealed := m.detail.State() == panes.ValueShown
|
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}.ShortHelp(), 2)
|
||||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
|
|
||||||
}
|
}
|
||||||
var lines []string
|
var lines []string
|
||||||
|
failed := func(text string) { lines = append(lines, m.flashLine(flash{text: text, isErr: true})) }
|
||||||
|
switch {
|
||||||
|
case m.auth != nil && len(m.auth.Login) > 0:
|
||||||
|
failed(fmt.Sprintf("%s: sign-in required (not signed in or session expired)%s",
|
||||||
|
m.auth.Who(), withKey(m.keys.Login, " · press %s to run "+strings.Join(m.auth.Login, " "))))
|
||||||
|
case m.auth != nil:
|
||||||
|
failed(fmt.Sprintf("%s: sign-in required: %v", m.auth.Who(), m.auth.Err))
|
||||||
|
case m.status != "":
|
||||||
|
failed(m.status)
|
||||||
|
}
|
||||||
if !m.clipDeadline.IsZero() {
|
if !m.clipDeadline.IsZero() {
|
||||||
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
|
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
|
||||||
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
|
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
|
||||||
@@ -1780,6 +1567,12 @@ func (m Model) footerView() string {
|
|||||||
if m.flash.text != "" {
|
if m.flash.text != "" {
|
||||||
lines = append(lines, m.flashLine(m.flash))
|
lines = append(lines, m.flashLine(m.flash))
|
||||||
}
|
}
|
||||||
|
// The version at the right end, one column in, like lazygit.
|
||||||
|
if v := m.versionLabel(); v != "" {
|
||||||
|
up := m.updateLabel() // a newer release, in colour
|
||||||
|
gap := max(1, m.w-1-ansi.StringWidth(help)-ansi.StringWidth(v+up)-1)
|
||||||
|
help += strings.Repeat(" ", gap) + m.st.Muted.Render(v) + m.st.Notice.Render(up) + " "
|
||||||
|
}
|
||||||
return strings.Join(append(lines, " "+help), "\n")
|
return strings.Join(append(lines, " "+help), "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1821,73 +1614,3 @@ func (m Model) View() tea.View {
|
|||||||
v.WindowTitle = "lazysecrets"
|
v.WindowTitle = "lazysecrets"
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectedProblem is why the selected Sources row's account has nothing
|
|
||||||
// to show, if it failed: for the Details pane.
|
|
||||||
func (m Model) selectedProblem() string {
|
|
||||||
r, ok := m.sources.Selected()
|
|
||||||
if !ok || (r.kind != rowGroup && r.kind != rowSource) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
res, done := m.sourceResults[r.provider]
|
|
||||||
if !done || res.err == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return m.problemText(r.provider, res.err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// problemText says what went wrong for a provider: for a sign-in, which
|
|
||||||
// account and how to sign in again; else err as given.
|
|
||||||
func (m Model) problemText(providerID, err string) string {
|
|
||||||
var auth *provider.AuthError
|
|
||||||
if r, ok := m.sourceResults[providerID]; ok && errors.As(r.err, &auth) {
|
|
||||||
return m.authText(auth)
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() && f.Provider != "" {
|
|
||||||
var auth *provider.AuthError
|
|
||||||
if res := m.sourceResults[f.Provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
|
|
||||||
return auth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r, ok := m.sources.Selected(); ok {
|
|
||||||
var auth *provider.AuthError
|
|
||||||
if res := m.sourceResults[r.provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
|
|
||||||
return auth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.auth != nil && len(m.auth.Login) > 0 {
|
|
||||||
return m.auth
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectedAccount describes the account of the selected provider row, if
|
|
||||||
// its provider can.
|
|
||||||
func (m Model) selectedAccount() (provider.Account, bool) {
|
|
||||||
r, ok := m.sources.Selected()
|
|
||||||
if !ok || r.kind != rowGroup {
|
|
||||||
return provider.Account{}, false
|
|
||||||
}
|
|
||||||
if res := m.sourceResults[r.provider]; res.err != nil {
|
|
||||||
return provider.Account{}, false // the problem shows instead
|
|
||||||
}
|
|
||||||
a, ok := m.byID[r.provider].(provider.Accounts)
|
|
||||||
if !ok {
|
|
||||||
return provider.Account{}, false
|
|
||||||
}
|
|
||||||
return a.Account()
|
|
||||||
}
|
|
||||||
+12
-332
@@ -19,7 +19,6 @@ import (
|
|||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@@ -58,7 +57,7 @@ func newDriverOpts(t *testing.T, opts Options, providers ...provider.Provider) *
|
|||||||
m := New(providers, opts)
|
m := New(providers, opts)
|
||||||
m.detail.Now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) }
|
m.detail.Now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) }
|
||||||
d := &driver{t: t, m: m}
|
d := &driver{t: t, m: m}
|
||||||
d.send(tea.WindowSizeMsg{Width: 100, Height: 33}) // 30 for the main panes, 3 for status
|
d.send(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||||
d.run(m.Init())
|
d.run(m.Init())
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
@@ -186,9 +185,8 @@ func TestIcons(t *testing.T) {
|
|||||||
if !strings.Contains(screen, azure+" Azure") || !strings.Contains(screen, dbx+" Databricks") {
|
if !strings.Contains(screen, azure+" Azure") || !strings.Contains(screen, dbx+" Databricks") {
|
||||||
t.Errorf("icons missing:\n%s", screen)
|
t.Errorf("icons missing:\n%s", screen)
|
||||||
}
|
}
|
||||||
d.keys("3")
|
if !strings.Contains(screen, "source "+azure+" Azure / platform-prod") {
|
||||||
if !strings.Contains(d.screen(), "source "+azure+" Azure / platform-prod") {
|
t.Errorf("detail source line missing icon:\n%s", screen)
|
||||||
t.Errorf("detail source line missing icon:\n%s", d.screen())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +204,7 @@ func TestObjectTypeTabs(t *testing.T) {
|
|||||||
t.Fatalf("selected = %q", s)
|
t.Fatalf("selected = %q", s)
|
||||||
}
|
}
|
||||||
screen := d.screen()
|
screen := d.screen()
|
||||||
for _, want := range []string{"key type RSA 2048", "ops sign, verify", "space to show the public key"} {
|
for _, want := range []string{"key type RSA 2048", "ops sign, verify", "space to show public key"} {
|
||||||
if !strings.Contains(screen, want) {
|
if !strings.Contains(screen, want) {
|
||||||
t.Errorf("missing %q:\n%s", want, screen)
|
t.Errorf("missing %q:\n%s", want, screen)
|
||||||
}
|
}
|
||||||
@@ -402,7 +400,6 @@ func (describing) Describe(_ context.Context, o provider.Object) (provider.Objec
|
|||||||
func TestDescribeUpdatesDetail(t *testing.T) {
|
func TestDescribeUpdatesDetail(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t, describing{fake.Azure(0)})
|
d := newDriver(t, describing{fake.Azure(0)})
|
||||||
d.keys("3") // the fourth pane follows focus: the secret
|
|
||||||
if !strings.Contains(d.screen(), "described db-password") {
|
if !strings.Contains(d.screen(), "described db-password") {
|
||||||
t.Fatalf("describe result not shown:\n%s", d.screen())
|
t.Fatalf("describe result not shown:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
@@ -490,11 +487,10 @@ func TestVersions(t *testing.T) {
|
|||||||
func TestVersionHistoryInDetail(t *testing.T) {
|
func TestVersionHistoryInDetail(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("3") // the fourth pane follows focus: the secret
|
|
||||||
screen := d.screen()
|
screen := d.screen()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"versions 3 · v to browse",
|
"versions 3 · v to browse",
|
||||||
" 3f9c2a 2026-09-01 current",
|
"▸ 3f9c2a 2026-09-01 current", // the version shown above
|
||||||
" 91d3e0 2026-03-01 expired",
|
" 91d3e0 2026-03-01 expired",
|
||||||
" 0a77c4 2025-09-01 disabled",
|
" 0a77c4 2025-09-01 disabled",
|
||||||
} {
|
} {
|
||||||
@@ -502,9 +498,6 @@ func TestVersionHistoryInDetail(t *testing.T) {
|
|||||||
t.Errorf("missing %q:\n%s", want, screen)
|
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
|
d.keys("3", "j") // api-key: only its current version
|
||||||
if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") {
|
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())
|
t.Errorf("wrong history for api-key:\n%s", d.screen())
|
||||||
@@ -601,7 +594,7 @@ func TestPermissionErrorShownInPane(t *testing.T) {
|
|||||||
if !strings.Contains(d.screen(), "403 Forbidden") {
|
if !strings.Contains(d.screen(), "403 Forbidden") {
|
||||||
t.Error("error not rendered")
|
t.Error("error not rendered")
|
||||||
}
|
}
|
||||||
if !strings.Contains(d.screen(), "name kv-restricted") {
|
if !strings.Contains(d.screen(), "Select a secret") {
|
||||||
t.Error("detail should show the container summary")
|
t.Error("detail should show the container summary")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,9 +754,7 @@ func TestFocusKeys(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail},
|
{"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail},
|
||||||
{"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers},
|
{"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers},
|
||||||
{"4", paneDetail}, {"tab", paneSources}, {"shift+tab", paneDetail},
|
{"4", paneDetail}, {"l", paneSources}, {"h", paneDetail},
|
||||||
// l: what's selected, on the right; h: back to it.
|
|
||||||
{"2", paneContainers}, {"l", paneDetail}, {"h", paneContainers}, {"h", paneSources}, {"l", paneDetail},
|
|
||||||
} {
|
} {
|
||||||
d.keys(tc.key)
|
d.keys(tc.key)
|
||||||
if d.m.focus != tc.want {
|
if d.m.focus != tc.want {
|
||||||
@@ -982,23 +973,18 @@ func TestSourcesFavouritesTab(t *testing.T) {
|
|||||||
func TestRebindingShowsEverywhere(t *testing.T) {
|
func TestRebindingShowsEverywhere(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
km := keys.Default()
|
km := keys.Default()
|
||||||
if err := km.Apply(map[string]map[string][]string{"lists": {"reveal": {"z"}, "versions": {}}}); err != nil {
|
if err := km.Apply(map[string]map[string][]string{"lists": {"reveal": {"e"}, "versions": {}}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
d := newDriverOpts(t, Options{Keys: &km})
|
d := newDriverOpts(t, Options{Keys: &km})
|
||||||
d.keys("3")
|
d.keys("3")
|
||||||
scr := d.screen()
|
scr := d.screen()
|
||||||
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
|
if !strings.Contains(scr, "(e to reveal)") || strings.Contains(scr, "space to reveal") {
|
||||||
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
||||||
}
|
}
|
||||||
if !strings.Contains(lastLine(scr), "z show") {
|
if !strings.Contains(lastLine(scr), "e reveal/hide") {
|
||||||
t.Errorf("help bar: %q", lastLine(scr))
|
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") {
|
if strings.Contains(scr, "to browse") {
|
||||||
t.Errorf("hint for unbound versions:\n%s", scr)
|
t.Errorf("hint for unbound versions:\n%s", scr)
|
||||||
}
|
}
|
||||||
@@ -1006,9 +992,9 @@ func TestRebindingShowsEverywhere(t *testing.T) {
|
|||||||
if strings.Contains(d.screen(), "correct-horse") {
|
if strings.Contains(d.screen(), "correct-horse") {
|
||||||
t.Error("space still reveals")
|
t.Error("space still reveals")
|
||||||
}
|
}
|
||||||
d.keys("z")
|
d.keys("e")
|
||||||
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
|
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
|
||||||
t.Errorf("z doesn't reveal:\n%s", d.screen())
|
t.Errorf("e doesn't reveal:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,309 +1022,3 @@ func TestMultiLineErrorsShownWhole(t *testing.T) {
|
|||||||
t.Errorf("footer:\n%s", d.screen())
|
t.Errorf("footer:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// signedOut is a second Azure account whose sign-in has expired.
|
|
||||||
type signedOut struct{ provider.Provider }
|
|
||||||
|
|
||||||
func (signedOut) ID() string { return "Azure:admin@contoso.com/t1" }
|
|
||||||
func (signedOut) Label() string { return "Azure · admin" }
|
|
||||||
func (signedOut) Sources(context.Context) ([]provider.Source, error) {
|
|
||||||
return nil, &provider.AuthError{Kind: provider.KindAzure, Account: "Azure · admin",
|
|
||||||
Login: []string{"az", "login", "--tenant", "t1"}, Err: errors.New("AADSTS70043")}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSignInMessageOnceThenInDetails(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
d := newDriverOpts(t, Options{CollapseGroups: false}, fake.Azure(0), signedOut{fake.Azure(0)})
|
|
||||||
hint := "Azure · admin: sign-in required"
|
|
||||||
if !strings.Contains(lastLines(d.screen(), 2), hint) { // the footer: one line
|
|
||||||
t.Fatalf("not said at start:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
d.keys("j")
|
|
||||||
if strings.Contains(lastLines(d.screen(), 2), hint) {
|
|
||||||
t.Errorf("still in the footer after moving:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
|
|
||||||
// On the account's group, Details says it, and L signs in to it.
|
|
||||||
d.keys("G")
|
|
||||||
if r, _ := d.m.sources.Selected(); r.provider != "Azure:admin@contoso.com/t1" {
|
|
||||||
t.Fatalf("selected %+v", r)
|
|
||||||
}
|
|
||||||
if scr := d.screen(); !strings.Contains(scr, "problem") || !strings.Contains(scr, hint) || !strings.Contains(scr, "press L to run az login") {
|
|
||||||
t.Errorf("not in Details:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" {
|
|
||||||
t.Errorf("L signs in to %+v", a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Elsewhere Details is as usual.
|
|
||||||
d.keys("g")
|
|
||||||
if strings.Contains(d.screen(), hint) {
|
|
||||||
t.Errorf("shown for another account:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastLines is the bottom n lines of a screen.
|
|
||||||
func lastLines(scr string, n int) string {
|
|
||||||
lines := strings.Split(strings.TrimRight(scr, "\n"), "\n")
|
|
||||||
return strings.Join(lines[max(0, len(lines)-n):], "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// described is an account that can say who it is.
|
|
||||||
type described struct{ provider.Provider }
|
|
||||||
|
|
||||||
func (described) Account() (provider.Account, bool) {
|
|
||||||
return provider.Account{
|
|
||||||
Fields: []provider.Attr{{Label: "name", Value: "Alice Example"}, {Label: "object ID", Value: "oid-123"}},
|
|
||||||
Copy: "oid-123", CopyLabel: "object ID",
|
|
||||||
}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAccountInDetails(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
d := newDriver(t, described{fake.Azure(0)})
|
|
||||||
d.keys("g") // the group's row
|
|
||||||
if r, _ := d.m.sources.Selected(); r.kind != rowGroup {
|
|
||||||
t.Fatalf("not on the group: %+v", r)
|
|
||||||
}
|
|
||||||
if scr := d.screen(); !strings.Contains(scr, "Alice Example") || !strings.Contains(scr, "oid-123") {
|
|
||||||
t.Errorf("account not in Details:\n%s", scr)
|
|
||||||
}
|
|
||||||
d.keys("y")
|
|
||||||
if got := d.clip().content; got != "oid-123" {
|
|
||||||
t.Errorf("y copied %q", got)
|
|
||||||
}
|
|
||||||
d.keys("j")
|
|
||||||
if strings.Contains(d.screen(), "Alice Example") {
|
|
||||||
t.Errorf("still shown on a subscription:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
bar := lastLine(scr)
|
|
||||||
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
|
|
||||||
t.Errorf("help bar: %q", bar)
|
|
||||||
}
|
|
||||||
d.keys("j", "y")
|
|
||||||
if got := d.clip().content; got != "oid-123" {
|
|
||||||
t.Errorf("y on the object ID copied %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// / filters it, labels too.
|
|
||||||
d.keys("/")
|
|
||||||
d.typeText("object")
|
|
||||||
d.keys("enter")
|
|
||||||
if scr := d.screen(); !strings.Contains(scr, "oid-123") || strings.Contains(scr, "Alice Example") {
|
|
||||||
t.Errorf("filtered:\n%s", scr)
|
|
||||||
}
|
|
||||||
d.keys("esc")
|
|
||||||
if !strings.Contains(d.screen(), "Alice Example") {
|
|
||||||
t.Errorf("filter not cleared:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
|
|
||||||
d.keys("h")
|
|
||||||
if d.m.focus != paneSources {
|
|
||||||
t.Errorf("back to %v, want Sources", d.m.focus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetailsFollowFocus(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
d := newDriver(t)
|
|
||||||
d.keys("3") // a secret
|
|
||||||
if !strings.Contains(d.screen(), "version 3f9c2a") {
|
|
||||||
t.Fatalf("secret not shown:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
d.keys("2") // its vault
|
|
||||||
if scr := d.screen(); !strings.Contains(scr, "name kv-app-prod") || strings.Contains(scr, "version 3f9c2a") {
|
|
||||||
t.Errorf("vault not described:\n%s", scr)
|
|
||||||
}
|
|
||||||
d.keys("1") // its subscription
|
|
||||||
if scr := d.screen(); !strings.Contains(scr, "name platform-prod") || !strings.Contains(scr, "vaults 3") {
|
|
||||||
t.Errorf("subscription not described:\n%s", scr)
|
|
||||||
}
|
|
||||||
// Into the fourth pane: it keeps describing the subscription, and
|
|
||||||
// shift+tab goes back there.
|
|
||||||
d.keys("4")
|
|
||||||
if !strings.Contains(d.screen(), "name platform-prod") {
|
|
||||||
t.Errorf("lost the subscription:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
d.keys("shift+tab")
|
|
||||||
if d.m.focus != paneSources {
|
|
||||||
t.Errorf("back to %v", d.m.focus)
|
|
||||||
}
|
|
||||||
// A value revealed from a left pane stays in view until hidden.
|
|
||||||
d.keys("space")
|
|
||||||
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
|
|
||||||
t.Errorf("value not shown:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatusLineCountsRepos(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
d := newDriver(t, fake.Azure(0), fake.Repos())
|
|
||||||
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") {
|
|
||||||
t.Errorf("status line: %q", line)
|
|
||||||
}
|
|
||||||
d = newDriver(t, fake.Azure(0))
|
|
||||||
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 !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 {
|
|
||||||
t.Fatalf("focus %v", d.m.focus)
|
|
||||||
}
|
|
||||||
scr := d.screen()
|
|
||||||
for _, want := range []string{logo[2], "version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required",
|
|
||||||
"/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} {
|
|
||||||
if !strings.Contains(scr, want) {
|
|
||||||
t.Errorf("missing %q:\n%s", want, scr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Into its rows: L signs in to the failing account, y copies a path,
|
|
||||||
// o opens a link.
|
|
||||||
d.keys("enter")
|
|
||||||
if d.m.focus != paneDetail {
|
|
||||||
t.Fatalf("enter: focus %v", d.m.focus)
|
|
||||||
}
|
|
||||||
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Provider == "Azure:admin@contoso.com/t1" })
|
|
||||||
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" {
|
|
||||||
t.Errorf("L signs in to %+v", a)
|
|
||||||
}
|
|
||||||
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "config" })
|
|
||||||
d.keys("y")
|
|
||||||
if got := d.clip().content; got != "/cfg/config.yaml" {
|
|
||||||
t.Errorf("y copied %q", got)
|
|
||||||
}
|
|
||||||
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "docs" })
|
|
||||||
d.keys("o")
|
|
||||||
if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") {
|
|
||||||
t.Errorf("o opened %q", opened)
|
|
||||||
}
|
|
||||||
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "version" })
|
|
||||||
d.keys("o")
|
|
||||||
if len(opened) != 2 || !strings.HasSuffix(opened[1], "/releases/tag/v0.1.0") {
|
|
||||||
t.Errorf("o on the version opened %q", opened)
|
|
||||||
}
|
|
||||||
d.keys("shift+tab")
|
|
||||||
if d.m.focus != paneStatus {
|
|
||||||
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
|
|
||||||
if !strings.Contains(d.screen(), "⚠ 1 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
|
||||||
"slices"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,31 +48,3 @@ func (c cache) rememberCursor(parent, child string) {
|
|||||||
c.cursor[parent] = child
|
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)
|
|
||||||
delete(m.cache.details, detailKey(s))
|
|
||||||
return m.describe(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -160,33 +159,3 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) {
|
|||||||
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,9 +53,6 @@ func paneContext(p pane) string {
|
|||||||
|
|
||||||
// commandsHere are the custom commands for the focused pane.
|
// commandsHere are the custom commands for the focused pane.
|
||||||
func (m *Model) commandsHere() []commands.Command {
|
func (m *Model) commandsHere() []commands.Command {
|
||||||
if m.focus == paneStatus {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []commands.Command
|
var out []commands.Command
|
||||||
for _, c := range m.commands {
|
for _, c := range m.commands {
|
||||||
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
|
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
|
||||||
@@ -165,7 +162,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
|
|||||||
o := *obj
|
o := *obj
|
||||||
p := m.providerFor(o.Container.Source)
|
p := m.providerFor(o.Container.Source)
|
||||||
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
|
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
|
||||||
timeout := m.timeout.of(o.Container.Source.Kind)
|
timeout := m.timeout
|
||||||
m.usedObject(o)
|
m.usedObject(o)
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
|||||||
@@ -1,260 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"errors"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"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
|
|
||||||
// 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
|
|
||||||
// secret, editors that can open at a line start on its key.
|
|
||||||
|
|
||||||
type editDoneMsg struct {
|
|
||||||
c provider.Container
|
|
||||||
name string
|
|
||||||
config bool // the config file: read at startup
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// sopsUnchanged is sops edit's exit code when the file wasn't changed.
|
|
||||||
const sopsUnchanged = 200
|
|
||||||
|
|
||||||
// edit opens the selected file, or the file of the selected secret.
|
|
||||||
func (m *Model) edit() tea.Cmd {
|
|
||||||
if m.focus == paneDetail && m.fields.Shown() {
|
|
||||||
f, ok := m.fields.Selected()
|
|
||||||
if !ok || f.Path == "" {
|
|
||||||
m.flash = warning("nothing to open in an editor here")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
name := filepath.Base(f.Path)
|
|
||||||
return m.openInEditor(provider.File{Path: f.Path}, func(err error) tea.Msg { return editDoneMsg{name: name, err: err} })
|
|
||||||
}
|
|
||||||
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
|
|
||||||
case paneContainers:
|
|
||||||
sel, ok := m.containers.Selected()
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
c = sel
|
|
||||||
default:
|
|
||||||
sel, ok := m.detail.Secret()
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
c, o = sel.Container, &sel
|
|
||||||
}
|
|
||||||
files, ok := m.providerFor(c.Source).(provider.Files)
|
|
||||||
if !ok {
|
|
||||||
m.flash = warning("only SOPS and .env files in repos can be opened")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
f, err := files.File(c, o)
|
|
||||||
if err != nil {
|
|
||||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if o != nil {
|
|
||||||
m.usedObject(*o)
|
|
||||||
} else {
|
|
||||||
m.usedContainer(c)
|
|
||||||
}
|
|
||||||
name := filepath.Base(f.Path)
|
|
||||||
finished := func(err error) tea.Msg { return editDoneMsg{c: c, name: name, err: err} }
|
|
||||||
|
|
||||||
if f.Sops != "" {
|
|
||||||
cmd := exec.Command(f.Sops, "edit", f.Path)
|
|
||||||
cmd.Dir = filepath.Dir(f.Path) // sops looks for .sops.yaml from here
|
|
||||||
// sops starts SOPS_EDITOR (else EDITOR, else vim) on the decrypted
|
|
||||||
// copy, which keeps the file's lines: start it on the key.
|
|
||||||
editor := cmp.Or(m.getenv("SOPS_EDITOR"), m.getenv("EDITOR"), "vim")
|
|
||||||
if at, ok := editorAtLine(editor, f.Line); ok {
|
|
||||||
cmd.Env = append(os.Environ(), "SOPS_EDITOR="+at)
|
|
||||||
}
|
|
||||||
m.flash = flash{}
|
|
||||||
return tea.ExecProcess(cmd, finished)
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
"micro": true, "kak": true, "mg": true, "ne": true, "joe": true, "vis": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// editorAtLine is editor starting at line, for editors that take +N:
|
|
||||||
// sops adds the file after it.
|
|
||||||
func editorAtLine(editor string, line int) (string, bool) {
|
|
||||||
fields := strings.Fields(editor)
|
|
||||||
if line == 0 || len(fields) == 0 || !plusLine[filepath.Base(fields[0])] {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return editor + " +" + strconv.Itoa(line), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// editorArgs opens f at its line, for editors known to take one.
|
|
||||||
func editorArgs(editor string, f provider.File) []string {
|
|
||||||
fields := strings.Fields(editor)
|
|
||||||
if f.Line == 0 || len(fields) == 0 {
|
|
||||||
return []string{f.Path}
|
|
||||||
}
|
|
||||||
at := f.Path + ":" + strconv.Itoa(f.Line)
|
|
||||||
switch base := filepath.Base(fields[0]); {
|
|
||||||
case plusLine[base]:
|
|
||||||
return []string{"+" + strconv.Itoa(f.Line), f.Path}
|
|
||||||
case base == "hx" || base == "helix" || base == "subl" || base == "zed":
|
|
||||||
return []string{at}
|
|
||||||
case base == "code" || base == "codium" || base == "cursor" || base == "windsurf":
|
|
||||||
return []string{"-g", at}
|
|
||||||
}
|
|
||||||
return []string{f.Path}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleEditDone reports how editing went and reloads the file's secrets:
|
|
||||||
// their names, lines or values may have changed.
|
|
||||||
func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
|
|
||||||
var exit *exec.ExitError
|
|
||||||
switch {
|
|
||||||
case errors.As(msg.err, &exit) && exit.ExitCode() == sopsUnchanged:
|
|
||||||
m.flash = flash{text: msg.name + " unchanged"}
|
|
||||||
return nil
|
|
||||||
case errors.Is(msg.err, exec.ErrNotFound):
|
|
||||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error() + " (is sops installed?)", isErr: true}
|
|
||||||
return nil
|
|
||||||
case msg.err != nil:
|
|
||||||
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()
|
|
||||||
m.secrets.Refresh()
|
|
||||||
return m.loadObjects(c, m.objType, m.selContainer)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// nvimLua opens a file in the nvim lazysecrets runs in. If lazysecrets is
|
|
||||||
// that terminal's own process (the nvim plugin, or :terminal lazysecrets),
|
|
||||||
// it waits for the terminal to close, so the file opens in the window
|
|
||||||
// below rather than in one about to close. Otherwise (a shell in a
|
|
||||||
// terminal) it opens it in the previous window now. The autocmd isn't
|
|
||||||
// the buffer's own: snacks wipes the buffer when the terminal closes,
|
|
||||||
// which would delete it before it runs.
|
|
||||||
const nvimLua = `local path, line, pid = ...
|
|
||||||
local function open()
|
|
||||||
local ok, err = pcall(vim.cmd, "edit " .. vim.fn.fnameescape(path))
|
|
||||||
if not ok then vim.notify("lazysecrets: " .. err, vim.log.levels.ERROR) return end
|
|
||||||
if line > 0 then pcall(vim.api.nvim_win_set_cursor, 0, { line, 0 }) vim.cmd("normal! zz") end
|
|
||||||
end
|
|
||||||
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
|
||||||
if vim.bo[b].buftype == "terminal" and vim.b[b].terminal_job_pid == pid then
|
|
||||||
vim.api.nvim_create_autocmd("TermClose", { callback = function(ev)
|
|
||||||
if ev.buf ~= b then return end
|
|
||||||
vim.defer_fn(open, 50)
|
|
||||||
return true
|
|
||||||
end })
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
|
||||||
pcall(vim.cmd, "wincmd p")
|
|
||||||
open()`
|
|
||||||
|
|
||||||
// nvimOpen asks the nvim at server to open path at line.
|
|
||||||
func nvimOpen(server, path string, line int) error {
|
|
||||||
q := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
|
||||||
lua := strings.ReplaceAll(nvimLua, "\n", " ")
|
|
||||||
expr := "luaeval('load(_A[1])(_A[2], _A[3], _A[4])', [" + q(lua) + ", " + q(path) + ", " +
|
|
||||||
strconv.Itoa(line) + ", " + strconv.Itoa(os.Getpid()) + "])"
|
|
||||||
out, err := exec.Command("nvim", "--server", server, "--remote-expr", expr).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
|
||||||
return errors.New(msg)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// openInEditor opens f: in the nvim lazysecrets runs in, quitting; else
|
|
||||||
// in $VISUAL or $EDITOR, coming back after; else in the default app.
|
|
||||||
func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.Cmd {
|
|
||||||
name := filepath.Base(f.Path)
|
|
||||||
if server := m.getenv("NVIM"); server != "" {
|
|
||||||
open := m.nvimOpen
|
|
||||||
return func() tea.Msg {
|
|
||||||
if err := open(server, f.Path, f.Line); err != nil {
|
|
||||||
return finished(err)
|
|
||||||
}
|
|
||||||
return tea.QuitMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
|
||||||
// Through the shell, so EDITOR can carry arguments ("code -w").
|
|
||||||
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
|
||||||
m.flash = flash{}
|
|
||||||
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
|
|
||||||
}
|
|
||||||
switch err := m.openURL(f.Path); {
|
|
||||||
case errors.Is(err, browser.ErrRemote):
|
|
||||||
m.flash = warning("set $EDITOR to open files over SSH")
|
|
||||||
case err != nil:
|
|
||||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
|
||||||
default:
|
|
||||||
m.flash = done("opened " + name)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
"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[containerKey(c)+"|"+string(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
|
|
||||||
}
|
|
||||||
@@ -421,9 +421,8 @@ func TestStarIsPerAccount(t *testing.T) {
|
|||||||
d.keys("n")
|
d.keys("n")
|
||||||
d.typeText("shared note")
|
d.typeText("shared note")
|
||||||
d.keys("enter")
|
d.keys("enter")
|
||||||
// On both accounts' rows, and in the selected one's details.
|
if n := strings.Count(d.screen(), "shared note"); n != 2 {
|
||||||
if n := strings.Count(d.screen(), "shared note"); n != 3 {
|
t.Errorf("note shown %d times, want 2:\n%s", n, d.screen())
|
||||||
t.Errorf("note shown %d times, want 3:\n%s", n, d.screen())
|
|
||||||
}
|
}
|
||||||
d.keys("F")
|
d.keys("F")
|
||||||
var labels []string
|
var labels []string
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"github.com/charmbracelet/x/ansi"
|
|
||||||
|
|
||||||
"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.
|
|
||||||
|
|
||||||
// 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
|
|
||||||
err error
|
|
||||||
loading bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type detailsMsg struct {
|
|
||||||
req int
|
|
||||||
key string
|
|
||||||
attrs []provider.Attr
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// detailOf is the pane the fourth pane describes: the one in focus, or,
|
|
||||||
// with the fourth in focus, the one it was entered from.
|
|
||||||
func (m Model) detailOf() pane {
|
|
||||||
if m.focus != paneDetail {
|
|
||||||
return m.focus
|
|
||||||
}
|
|
||||||
return m.lastLeft
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
func (m *Model) syncFields() tea.Cmd {
|
|
||||||
if m.detail.State() != panes.ValueHidden {
|
|
||||||
// A value revealed (or being fetched) from any pane stays in
|
|
||||||
// view until it's hidden.
|
|
||||||
m.fields.Hide()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m.fields.Header, m.fields.HeaderShort = 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)
|
|
||||||
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.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)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
m.fields.Hide()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// showAccount shows a provider's account.
|
|
||||||
func (m *Model) showAccount(r sourceRow) {
|
|
||||||
p := m.byID[r.provider]
|
|
||||||
var fields []panes.Field
|
|
||||||
if a, ok := p.(provider.Accounts); ok {
|
|
||||||
if acct, ok := a.Account(); ok {
|
|
||||||
for _, f := range acct.Fields {
|
|
||||||
fields = append(fields, panes.Field{Label: f.Label, Value: f.Value})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
m.fields.Show(p.Label(), fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceFields describe a subscription, workspace or repo.
|
|
||||||
func (m Model) sourceFields(src provider.Source) []panes.Field {
|
|
||||||
var out []panes.Field
|
|
||||||
add := func(label, value string) {
|
|
||||||
if value != "" {
|
|
||||||
out = append(out, panes.Field{Label: label, Value: value})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Azure's detail is "subscription <ID>": the ID row says it.
|
|
||||||
id, detail := "ID", ""
|
|
||||||
switch src.Kind {
|
|
||||||
case provider.KindDatabricks:
|
|
||||||
id, detail = "profile", "host"
|
|
||||||
case provider.KindRepo:
|
|
||||||
id, detail = "path", "remote"
|
|
||||||
}
|
|
||||||
add("name", src.Name)
|
|
||||||
add("note", m.store.Node(sourceKey(src)).Note)
|
|
||||||
if src.ID != src.Name {
|
|
||||||
add(id, src.ID)
|
|
||||||
}
|
|
||||||
if detail != "" {
|
|
||||||
add(detail, src.Detail)
|
|
||||||
}
|
|
||||||
if src.ProviderLabel != string(src.Kind) {
|
|
||||||
add("account", src.ProviderLabel)
|
|
||||||
}
|
|
||||||
add("web", src.URL)
|
|
||||||
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
|
|
||||||
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
|
|
||||||
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// containerFields describe a vault, scope or file.
|
|
||||||
func (m Model) containerFields(c provider.Container) []panes.Field {
|
|
||||||
var out []panes.Field
|
|
||||||
add := func(label, value string) {
|
|
||||||
if value != "" {
|
|
||||||
out = append(out, panes.Field{Label: label, Value: value})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add("name", c.Name)
|
|
||||||
add("note", m.store.Node(storeKey(c)).Note)
|
|
||||||
// The vault's URI, the scope's backend, the file's format.
|
|
||||||
switch c.Source.Kind {
|
|
||||||
case provider.KindAzure:
|
|
||||||
add("URI", c.Detail)
|
|
||||||
case provider.KindDatabricks:
|
|
||||||
add("backend", c.Detail)
|
|
||||||
case provider.KindRepo:
|
|
||||||
add("format", c.Detail)
|
|
||||||
}
|
|
||||||
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[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 {
|
|
||||||
var out []panes.Field
|
|
||||||
for _, l := range strings.Split(ansi.Wrap(problem, max(20, m.fields.Width()-16), " "), "\n") {
|
|
||||||
out = append(out, panes.Field{Value: l, Info: true, Err: true})
|
|
||||||
}
|
|
||||||
out[0].Label = "problem"
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// fieldName names a copied field: its label, or a row of several under
|
|
||||||
// one label (a tag), its value.
|
|
||||||
func fieldName(f panes.Field) string {
|
|
||||||
return cmpOr(f.Label, f.Value)
|
|
||||||
}
|
|
||||||
@@ -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
-26
@@ -36,12 +36,6 @@ type contextHelp struct {
|
|||||||
k keys.Map
|
k keys.Map
|
||||||
focus pane
|
focus pane
|
||||||
commands bool // custom commands apply here
|
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 {
|
func (c contextHelp) ShortHelp() []key.Binding {
|
||||||
@@ -50,29 +44,13 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
|||||||
if k.Login.Enabled() {
|
if k.Login.Enabled() {
|
||||||
b = append(b, k.Login)
|
b = append(b, k.Login)
|
||||||
}
|
}
|
||||||
copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy"))
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
case c.focus == paneDetail && c.account:
|
|
||||||
return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit)
|
|
||||||
}
|
|
||||||
switch c.focus {
|
switch c.focus {
|
||||||
case paneSources:
|
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.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.SourcesView, k.Filter)
|
||||||
case paneContainers:
|
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:
|
default:
|
||||||
reveal := k.Reveal
|
b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
if c.commands {
|
if c.commands {
|
||||||
b = append(b, k.Commands)
|
b = append(b, k.Commands)
|
||||||
@@ -82,7 +60,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
|||||||
|
|
||||||
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
|
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
|
||||||
|
|
||||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail"}
|
||||||
|
|
||||||
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
||||||
// what it does, the focused pane's first, filterable by key, description
|
// what it does, the focused pane's first, filterable by key, description
|
||||||
|
|||||||
+3
-28
@@ -47,7 +47,6 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
|||||||
out = append(out, entry{depth, sort, option{
|
out = append(out, entry{depth, sort, option{
|
||||||
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
||||||
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
|
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
|
||||||
plain: m.inPlainEnv(r),
|
|
||||||
run: func(m *Model) tea.Cmd {
|
run: func(m *Model) tea.Cmd {
|
||||||
m.used(key)
|
m.used(key)
|
||||||
return run(m)
|
return run(m)
|
||||||
@@ -139,30 +138,6 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
|
|||||||
return label
|
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
|
// sourceMatches reports whether src is the subscription or workspace r
|
||||||
// links to.
|
// links to.
|
||||||
func sourceMatches(r ref.Ref, src provider.Source) bool {
|
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
|
return src.ID == r.Subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
// accountOf is the account part of a source's provider label ("alice" in
|
// accountOf is the account part of a source's provider label ("dev" in
|
||||||
// "Azure · alice"), or "" when there's a single account.
|
// "Azure · dev"), or "" when there's a single account.
|
||||||
func accountOf(src provider.Source) string {
|
func accountOf(src provider.Source) string {
|
||||||
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -217,7 +192,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
|
|||||||
case 1:
|
case 1:
|
||||||
m.startNav(r, actGo, choices[0].sourceRow)
|
m.startNav(r, actGo, choices[0].sourceRow)
|
||||||
default:
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ func (m *Model) resizeFocused(delta int) bool {
|
|||||||
if m.focus > paneSecrets {
|
if m.focus > paneSecrets {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
avail := m.listAvail()
|
avail := m.leftAvail()
|
||||||
h := m.leftHeights(avail)
|
h := m.leftHeights(avail)
|
||||||
i := int(m.focus)
|
i := int(m.focus)
|
||||||
j := i + 1
|
j := i + 1
|
||||||
@@ -128,9 +128,6 @@ func (m *Model) saveLayout() {
|
|||||||
|
|
||||||
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) }
|
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) }
|
||||||
|
|
||||||
// listAvail is the rows the three list panes share, under the status pane.
|
|
||||||
func (m Model) listAvail() int { return m.leftAvail() - statusH }
|
|
||||||
|
|
||||||
func (m *Model) cycleZoom(delta int) {
|
func (m *Model) cycleZoom(delta int) {
|
||||||
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
|
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
|
||||||
}
|
}
|
||||||
@@ -141,22 +138,22 @@ func (m *Model) layout() {
|
|||||||
}
|
}
|
||||||
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
|
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
|
||||||
avail := m.leftAvail()
|
avail := m.leftAvail()
|
||||||
leftW, h := m.leftWidth(), m.leftHeights(avail-statusH)
|
leftW, h := m.leftWidth(), m.leftHeights(avail)
|
||||||
|
|
||||||
switch m.zoom {
|
switch m.zoom {
|
||||||
case zoomHalf:
|
case zoomHalf:
|
||||||
if m.focus == paneDetail || m.focus == paneStatus {
|
if m.focus == paneDetail {
|
||||||
leftW = minLeftW
|
leftW = minLeftW
|
||||||
} else {
|
} else {
|
||||||
leftW = max(m.leftWidth(), m.w/2)
|
leftW = max(m.leftWidth(), m.w/2)
|
||||||
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
|
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
|
||||||
h[m.focus] = avail - statusH
|
h[m.focus] = avail
|
||||||
h = fitHeights(h, avail-statusH, tinyPaneH)
|
h = fitHeights(h, avail, tinyPaneH)
|
||||||
}
|
}
|
||||||
case zoomFull:
|
case zoomFull:
|
||||||
// Only the focused pane is drawn; it gets the whole screen.
|
// Only the focused pane is drawn; it gets the whole screen.
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
case paneDetail, paneStatus:
|
case paneDetail:
|
||||||
leftW = 0
|
leftW = 0
|
||||||
default:
|
default:
|
||||||
leftW = m.w
|
leftW = m.w
|
||||||
@@ -165,12 +162,10 @@ func (m *Model) layout() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m.statusW = leftW
|
|
||||||
m.sources.SetSize(leftW, h[0])
|
m.sources.SetSize(leftW, h[0])
|
||||||
m.containers.SetSize(leftW, h[1])
|
m.containers.SetSize(leftW, h[1])
|
||||||
m.secrets.SetSize(leftW, h[2])
|
m.secrets.SetSize(leftW, h[2])
|
||||||
m.detail.SetSize(m.w-leftW, avail)
|
m.detail.SetSize(m.w-leftW, avail)
|
||||||
m.fields.SetSize(m.w-leftW, avail)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// active reports whether p is drawn focused: it has focus and the terminal
|
// active reports whether p is drawn focused: it has focus and the terminal
|
||||||
@@ -180,23 +175,15 @@ func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred }
|
|||||||
|
|
||||||
func (m Model) panesView() string {
|
func (m Model) panesView() string {
|
||||||
spin := m.spin.View()
|
spin := m.spin.View()
|
||||||
views := [paneStatus]func() string{
|
views := [paneCount]func() string{
|
||||||
func() string { return m.sources.View(m.st, m.active(paneSources), spin) },
|
func() string { return m.sources.View(m.st, m.active(paneSources), spin) },
|
||||||
func() string { return m.containers.View(m.st, m.active(paneContainers), spin) },
|
func() string { return m.containers.View(m.st, m.active(paneContainers), spin) },
|
||||||
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
|
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
|
||||||
func() string {
|
func() string { return m.detail.View(m.st, m.active(paneDetail), spin) },
|
||||||
if m.fields.Shown() {
|
|
||||||
return m.fields.View(m.st, m.active(paneDetail), spin)
|
|
||||||
}
|
|
||||||
return m.detail.View(m.st, m.active(paneDetail), spin)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if m.zoom == zoomFull {
|
if m.zoom == zoomFull {
|
||||||
if m.focus == paneStatus {
|
|
||||||
return views[paneDetail]() // its rows
|
|
||||||
}
|
|
||||||
return views[m.focus]()
|
return views[m.focus]()
|
||||||
}
|
}
|
||||||
left := lipgloss.JoinVertical(lipgloss.Left, m.statusView(), views[0](), views[1](), views[2]())
|
left := lipgloss.JoinVertical(lipgloss.Left, views[0](), views[1](), views[2]())
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
|
||||||
}
|
}
|
||||||
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
|
|||||||
func TestContextHelpBar(t *testing.T) {
|
func TestContextHelpBar(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
if bar := 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)
|
t.Errorf("Sources help bar: %q", bar)
|
||||||
}
|
}
|
||||||
d.keys("3")
|
d.keys("3")
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share…") {
|
||||||
t.Errorf("Secrets help bar: %q", bar)
|
t.Errorf("Secrets help bar: %q", bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,13 +258,14 @@ func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVersionInStatus(t *testing.T) {
|
func TestVersionBottomRight(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriverOpts(t, Options{Version: "0.1.0"})
|
d := newDriverOpts(t, Options{Version: "0.1.0"})
|
||||||
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
bar := lastLine(d.screen())
|
||||||
t.Errorf("no version in the status pane:\n%s", d.screen())
|
if !strings.HasSuffix(bar, "v0.1.0 ") || ansi.StringWidth(bar) != 100 {
|
||||||
|
t.Errorf("help line %q (%d wide)", bar, ansi.StringWidth(bar))
|
||||||
}
|
}
|
||||||
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
if !strings.Contains(bar, "? help • q quit") {
|
||||||
t.Errorf("help line %q", bar)
|
t.Errorf("help squeezed out by the version: %q", bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,9 +46,6 @@ type nav struct {
|
|||||||
// quiet gives up without a word: going back to where lazysecrets
|
// quiet gives up without a word: going back to where lazysecrets
|
||||||
// was left, which may be gone.
|
// was left, which may be gone.
|
||||||
quiet bool
|
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 {
|
type resolvedMsg struct {
|
||||||
@@ -81,9 +78,6 @@ const (
|
|||||||
// just open it.
|
// just open it.
|
||||||
// With ask false, vault and scope links open straight away.
|
// With ask false, vault and scope links open straight away.
|
||||||
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
|
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 r.IsContainer() || r.IsSource() {
|
||||||
if !ask {
|
if !ask {
|
||||||
return m.openRef(r, actGo)
|
return m.openRef(r, actGo)
|
||||||
@@ -158,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}
|
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ctx, req := m.begin(slotCopy, r.Kind)
|
ctx, req := m.begin(slotCopy)
|
||||||
m.flash = flash{text: "copying " + r.Name + "…"}
|
m.flash = flash{text: "copying " + r.Name + "…"}
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
var errs []error
|
var errs []error
|
||||||
@@ -404,30 +398,23 @@ func (m *Model) handleResolved(msg resolvedMsg) {
|
|||||||
for i := range msg.choices {
|
for i := range msg.choices {
|
||||||
msg.choices[i].label = "Open in " + msg.choices[i].label
|
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, true)
|
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices)
|
||||||
case len(msg.others) > 0:
|
case len(msg.others) > 0:
|
||||||
var choices []choice
|
var choices []choice
|
||||||
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
||||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||||
}
|
}
|
||||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
|
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices)
|
||||||
case msg.linked != nil:
|
case msg.linked != nil:
|
||||||
m.addLinked(*msg.linked)
|
m.addLinked(*msg.linked)
|
||||||
m.startLinkNav(msg.ref, msg.act, linksRowID)
|
m.startNav(msg.ref, msg.act, linksRowID)
|
||||||
default:
|
default:
|
||||||
m.startLinkNav(msg.ref, msg.act, msg.sourceRow)
|
m.startNav(msg.ref, msg.act, msg.sourceRow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
// 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
|
var opts []option
|
||||||
for i, c := range choices {
|
for i, c := range choices {
|
||||||
key := ""
|
key := ""
|
||||||
@@ -437,7 +424,6 @@ func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []
|
|||||||
row := c.sourceRow
|
row := c.sourceRow
|
||||||
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
||||||
m.startNav(r, act, row)
|
m.startNav(r, act, row)
|
||||||
m.nav.link = link
|
|
||||||
return nil
|
return nil
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
@@ -588,9 +574,6 @@ func (m *Model) advanceNav() bool {
|
|||||||
m.pendingCmd = m.toggleReveal()
|
m.pendingCmd = m.toggleReveal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n.link {
|
|
||||||
m.focus = paneDetail // after: copying acts on the focused pane
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -624,9 +607,6 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
if s.URL != "" {
|
if s.URL != "" {
|
||||||
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
|
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)
|
m.showMenu("Share "+s.Name, nil, opts)
|
||||||
case paneContainers:
|
case paneContainers:
|
||||||
c, ok := m.containers.Selected()
|
c, ok := m.containers.Selected()
|
||||||
@@ -649,11 +629,6 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
if c.URL != "" {
|
if c.URL != "" {
|
||||||
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
|
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)
|
m.showMenu("Share "+c.Name, nil, opts)
|
||||||
default:
|
default:
|
||||||
o, ok := m.detail.Secret()
|
o, ok := m.detail.Secret()
|
||||||
@@ -678,11 +653,6 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
if o.URL != "" {
|
if o.URL != "" {
|
||||||
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
|
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)
|
m.showMenu("Share "+o.Name, nil, opts)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -832,9 +802,6 @@ func (m *Model) openText(s string) tea.Cmd {
|
|||||||
|
|
||||||
// handlePaste opens a pasted reference, unless a text input has focus.
|
// handlePaste opens a pasted reference, unless a text input has focus.
|
||||||
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
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 {
|
if p := m.prompt; p != nil && p.input != nil {
|
||||||
in, cmd := p.input.Update(msg)
|
in, cmd := p.input.Update(msg)
|
||||||
p.input = &in
|
p.input = &in
|
||||||
|
|||||||
@@ -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" {
|
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)
|
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 != paneSecrets || d.m.detail.State() != panes.ValueHidden {
|
||||||
if d.m.focus != paneDetail || d.m.detail.State() != panes.ValueHidden {
|
|
||||||
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
|
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
@@ -42,7 +40,6 @@ type option struct {
|
|||||||
group string // the source row it's in (search)
|
group string // the source row it's in (search)
|
||||||
kind provider.Kind // the kind of provider it's from (search)
|
kind provider.Kind // the kind of provider it's from (search)
|
||||||
hits []int // characters of the label the filter matched (Go to)
|
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
|
run func(m *Model) tea.Cmd
|
||||||
// header makes this a section title: not selectable, and hidden
|
// header makes this a section title: not selectable, and hidden
|
||||||
// while a filter narrows the list.
|
// while a filter narrows the list.
|
||||||
@@ -56,13 +53,9 @@ type modal struct {
|
|||||||
options []option
|
options []option
|
||||||
cursor int
|
cursor int
|
||||||
|
|
||||||
// A text input instead of options; submit runs on enter (named
|
// A text input instead of options; submit runs on enter.
|
||||||
// submitLabel in the footer; "open" if empty). err says why the last
|
|
||||||
// one wasn't taken, under the input.
|
|
||||||
input *textinput.Model
|
input *textinput.Model
|
||||||
submit func(m *Model, text string) tea.Cmd
|
submit func(m *Model, text string) tea.Cmd
|
||||||
submitLabel string
|
|
||||||
err string
|
|
||||||
|
|
||||||
// A filter over the options: typing narrows them (all holds them
|
// A filter over the options: typing narrows them (all holds them
|
||||||
// all), so moving uses arrows or ctrl+j/k.
|
// 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"))
|
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
|
||||||
if p.input != nil {
|
if p.input != nil {
|
||||||
body = append(body, " "+p.input.View())
|
body = append(body, " "+p.input.View())
|
||||||
if p.err != "" {
|
foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel"))
|
||||||
body = append(body, "", " "+m.st.Error.Render("✗ "+capitalize(p.err)))
|
|
||||||
}
|
|
||||||
foot = footer(hint(km.PopupSelect, cmp.Or(p.submitLabel, "open")), hint(km.PopupClose, "cancel"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size from all options, not the ones shown or the cursor's, so the
|
// 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),
|
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
||||||
).Render()
|
).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:]
|
|
||||||
}
|
|
||||||
@@ -42,7 +42,7 @@ func TestNotesOnRows(t *testing.T) {
|
|||||||
d.note("main prod")
|
d.note("main prod")
|
||||||
|
|
||||||
screen := d.screen()
|
screen := d.screen()
|
||||||
for _, want := range []string{"stripe live", "app secrets", "main prod", "note main prod"} {
|
for _, want := range []string{"stripe live", "app secrets", "main prod", "note stripe live"} {
|
||||||
if !strings.Contains(screen, want) {
|
if !strings.Contains(screen, want) {
|
||||||
t.Errorf("missing %q:\n%s", want, screen)
|
t.Errorf("missing %q:\n%s", want, screen)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -243,6 +244,6 @@ func (m *Model) restorePlace() {
|
|||||||
|
|
||||||
func (m *Model) handlePersisted(msg persistedMsg) {
|
func (m *Model) handlePersisted(msg persistedMsg) {
|
||||||
if msg.err != nil {
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,9 +39,6 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
|
|||||||
// Next run, with a backend too slow to answer during the test: the
|
// 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.
|
// lists show from the file, and the cursor is where it was.
|
||||||
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
|
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")
|
d2.keys("2")
|
||||||
if _, v, _ := d2.selected(); v != vault {
|
if _, v, _ := d2.selected(); v != vault {
|
||||||
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
|
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
|
||||||
@@ -52,29 +49,6 @@ 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) {
|
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
file := filepath.Join(t.TempDir(), "cache.json")
|
file := filepath.Join(t.TempDir(), "cache.json")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package app
|
|||||||
import (
|
import (
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -206,9 +205,9 @@ func TestSearchAllRepos(t *testing.T) {
|
|||||||
for d.m.search.src > 0 {
|
for d.m.search.src > 0 {
|
||||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||||
}
|
}
|
||||||
for 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 {
|
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})
|
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||||
}
|
}
|
||||||
@@ -235,160 +234,7 @@ func TestSearchAllRepos(t *testing.T) {
|
|||||||
if azure() != azureBefore || d.m.crawl.running {
|
if azure() != azureBefore || d.m.crawl.running {
|
||||||
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
|
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())
|
t.Errorf("title:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEditFile(t *testing.T) {
|
|
||||||
// Not parallel: git, run by the repo provider, is slow under load.
|
|
||||||
if _, err := exec.LookPath("git"); err != nil {
|
|
||||||
t.Skip("no git")
|
|
||||||
}
|
|
||||||
root := gitRepoWith(t, "https://git.example.com/team/x.git")
|
|
||||||
envPath := filepath.Join(root, "secrets", ".env")
|
|
||||||
os.WriteFile(envPath, []byte("# local\nTOKEN=x\n"), 0o600)
|
|
||||||
var opened []string
|
|
||||||
d := newDriverOpts(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }},
|
|
||||||
fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
|
||||||
env := map[string]string{"NVIM": "/tmp/nvim.sock"}
|
|
||||||
d.m.getenv = func(k string) string { return env[k] }
|
|
||||||
var nvim []string
|
|
||||||
d.m.nvimOpen = func(server, path string, line int) error {
|
|
||||||
nvim = append(nvim, fmt.Sprintf("%s %s:%d", server, path, line))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inside nvim: the file, then a secret at its line, and lazysecrets quits.
|
|
||||||
d.keys("2")
|
|
||||||
if _, c, _ := d.selected(); c != "secrets/.env" {
|
|
||||||
t.Fatalf("selected %q\n%s", c, d.screen())
|
|
||||||
}
|
|
||||||
d.keys("e")
|
|
||||||
d.keys("3", "e")
|
|
||||||
want := []string{"/tmp/nvim.sock " + envPath + ":0", "/tmp/nvim.sock " + envPath + ":2"}
|
|
||||||
if !slices.Equal(nvim, want) {
|
|
||||||
t.Errorf("nvim got %q\nwant %q", nvim, want)
|
|
||||||
}
|
|
||||||
if msg := d.m.edit()(); msg != (tea.QuitMsg{}) {
|
|
||||||
t.Errorf("after opening in nvim: %#v, want quit", msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SOPS files go to sops edit, in the terminal, even inside nvim.
|
|
||||||
nvim = nil
|
|
||||||
d.keys("2", "j")
|
|
||||||
if _, c, _ := d.selected(); c != "secrets/app.yaml" {
|
|
||||||
t.Fatalf("selected %q", c)
|
|
||||||
}
|
|
||||||
if cmd := d.m.edit(); cmd == nil || len(nvim) > 0 {
|
|
||||||
t.Errorf("sops file: cmd %v, nvim %q", cmd, nvim)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neither nvim nor an editor: the system's default app.
|
|
||||||
delete(env, "NVIM")
|
|
||||||
d.keys("k", "e")
|
|
||||||
if !slices.Equal(opened, []string{envPath}) || !strings.Contains(d.screen(), "opened .env") {
|
|
||||||
t.Errorf("opened %q\n%s", opened, d.screen())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Done editing: the file's secrets reload.
|
|
||||||
d.send(editDoneMsg{c: provider.Container{Source: provider.Source{Provider: "Repos"}, ID: envPath}, name: ".env"})
|
|
||||||
if !strings.Contains(d.screen(), "closed .env") {
|
|
||||||
t.Errorf("after editing:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other providers have no files.
|
|
||||||
az := newDriver(t, fake.Azure(0))
|
|
||||||
az.keys("2", "e")
|
|
||||||
if !strings.Contains(az.screen(), "only SOPS and .env files") {
|
|
||||||
t.Errorf("azure:\n%s", az.screen())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEditorArgs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
f := provider.File{Path: "/r/.env", Line: 7}
|
|
||||||
for editor, want := range map[string][]string{
|
|
||||||
"nvim": {"+7", "/r/.env"},
|
|
||||||
"/usr/bin/vim -p": {"+7", "/r/.env"},
|
|
||||||
"hx": {"/r/.env:7"},
|
|
||||||
"code --wait": {"-g", "/r/.env:7"},
|
|
||||||
"open -a TextEdit": {"/r/.env"},
|
|
||||||
} {
|
|
||||||
if got := editorArgs(editor, f); !slices.Equal(got, want) {
|
|
||||||
t.Errorf("%s: %q, want %q", editor, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := editorArgs("nvim", provider.File{Path: "/r/.env"}); !slices.Equal(got, []string{"/r/.env"}) {
|
|
||||||
t.Errorf("no line: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEditorAtLine(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
for _, c := range []struct {
|
|
||||||
editor string
|
|
||||||
line int
|
|
||||||
want string
|
|
||||||
ok bool
|
|
||||||
}{
|
|
||||||
{"nvim", 4, "nvim +4", true},
|
|
||||||
{"/usr/bin/vim -p", 4, "/usr/bin/vim -p +4", true},
|
|
||||||
{"code --wait", 4, "", false}, // sops adds the file last: no -g file:N
|
|
||||||
{"nvim", 0, "", false},
|
|
||||||
} {
|
|
||||||
if got, ok := editorAtLine(c.editor, c.line); got != c.want || ok != c.ok {
|
|
||||||
t.Errorf("%q at %d: %q %v", c.editor, c.line, got, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
-26
@@ -61,14 +61,13 @@ func (m *Model) searchEntries() (sources, items []option) {
|
|||||||
var es []entry
|
var es []entry
|
||||||
// key is the store key, so ctrl+f stars the entry; mark shows how
|
// key is the store key, so ctrl+f stars the entry; mark shows how
|
||||||
// much is loaded, reload (ctrl+r) loads it again.
|
// much is loaded, reload (ctrl+r) loads it again.
|
||||||
add := func(depth int, row string, kind provider.Kind, path []string, account, 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)
|
n := m.store.Node(key)
|
||||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||||
es = append(es, entry{depth, sort,
|
es = append(es, entry{depth, sort,
|
||||||
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
||||||
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
||||||
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
||||||
return &es[len(es)-1].opt
|
|
||||||
}
|
}
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
for _, src := range m.sourceResults[p.ID()].sources {
|
for _, src := range m.sourceResults[p.ID()].sources {
|
||||||
@@ -99,11 +98,10 @@ func (m *Model) searchEntries() (sources, items []option) {
|
|||||||
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
||||||
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
||||||
}
|
}
|
||||||
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, 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)
|
m.startNav(r, actGo, row)
|
||||||
return nil
|
return nil
|
||||||
}, reload)
|
}, reload)
|
||||||
e.plain = c.Detail == provider.DetailPlainEnv
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,25 +131,13 @@ func (c coverage) complete() bool {
|
|||||||
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
|
func (m *Model) searchCoverage() coverage { return m.coverageOf("") }
|
||||||
|
|
||||||
// providerFilter picks the providers a search or a load covers; nil is
|
// coverageOf is searchCoverage for one kind of provider ("" for all).
|
||||||
// all of them.
|
func (m *Model) coverageOf(kind provider.Kind) coverage {
|
||||||
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 {
|
|
||||||
var c coverage
|
var c coverage
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if in != nil && !in(p) {
|
if kind != "" && p.Kind() != kind {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, src := range m.sourceResults[p.ID()].sources {
|
for _, src := range m.sourceResults[p.ID()].sources {
|
||||||
@@ -211,12 +197,12 @@ func plural(n int, word string) string {
|
|||||||
|
|
||||||
// startCrawl loads every unlisted subscription's vaults and every vault's
|
// startCrawl loads every unlisted subscription's vaults and every vault's
|
||||||
// secrets not loaded yet.
|
// 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.
|
// startCrawlOf is startCrawl for one kind of provider ("" for all).
|
||||||
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd {
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if in != nil && !in(p) {
|
if kind != "" && p.Kind() != kind {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, src := range m.sourceResults[p.ID()].sources {
|
for _, src := range m.sourceResults[p.ID()].sources {
|
||||||
@@ -295,10 +281,10 @@ func (m *Model) crawlNext() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
||||||
|
timeout := m.timeout
|
||||||
if job.src != nil {
|
if job.src != nil {
|
||||||
p := m.providerFor(*job.src)
|
p := m.providerFor(*job.src)
|
||||||
src := *job.src
|
src := *job.src
|
||||||
timeout := m.timeout.of(src.Kind)
|
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := contextWithTimeout(timeout)
|
ctx, cancel := contextWithTimeout(timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -311,7 +297,6 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
|||||||
}
|
}
|
||||||
c := *job.container
|
c := *job.container
|
||||||
p := m.providerFor(c.Source)
|
p := m.providerFor(c.Source)
|
||||||
timeout := m.timeout.of(c.Source.Kind)
|
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := contextWithTimeout(timeout)
|
ctx, cancel := contextWithTimeout(timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -42,15 +42,11 @@ func TestSearchLoaded(t *testing.T) {
|
|||||||
t.Fatalf("missing %q:\n%s", want, d.screen())
|
t.Fatalf("missing %q:\n%s", want, d.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.typeText("api-key") // on All: found at once
|
d.typeText("api-key")
|
||||||
if !strings.Contains(d.screen(), "ctrl+s only platform-dev") {
|
if !strings.Contains(d.screen(), "no matches here · All searches everything") {
|
||||||
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") {
|
|
||||||
t.Errorf("no hint to search everything:\n%s", d.screen())
|
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") {
|
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
|
||||||
t.Fatalf("loaded secret not found:\n%s", d.screen())
|
t.Fatalf("loaded secret not found:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
@@ -66,8 +62,7 @@ func TestSearchLoaded(t *testing.T) {
|
|||||||
func TestSearchLoadsAsYouType(t *testing.T) {
|
func TestSearchLoadsAsYouType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.openSearch()
|
d.openSearch() // on the source the cursor is on: platform-prod
|
||||||
d.send(ctrl('s')) // only the source the cursor is on: platform-prod
|
|
||||||
// Typing loads the scope: that source, without ctrl+a.
|
// Typing loads the scope: that source, without ctrl+a.
|
||||||
d.typeText("smtp")
|
d.typeText("smtp")
|
||||||
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
|
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
|
||||||
@@ -81,7 +76,10 @@ func TestSearchLoadsAsYouType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// On All: everything.
|
// 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("-")
|
d.typeText("-")
|
||||||
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
|
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())
|
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.keys("2", "j") // platform-prod / kv-shared-prod
|
||||||
d.openSearch()
|
d.openSearch()
|
||||||
v := d.m.search
|
v := d.m.search
|
||||||
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
|
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") {
|
||||||
if scope() != "All" {
|
t.Errorf("source not preselected: %q", got)
|
||||||
t.Errorf("opened on %q, want All", scope())
|
|
||||||
}
|
}
|
||||||
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
|
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
|
||||||
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
d.send(ctrl('s')) // narrows to the source, and back
|
|
||||||
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.
|
// From the Sources pane: just the source.
|
||||||
d.keys("esc", "]", "]") // stop typing; Favourites
|
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev
|
||||||
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
|
|
||||||
d.openSearch()
|
d.openSearch()
|
||||||
v = d.m.search
|
v = d.m.search
|
||||||
d.send(ctrl('s'))
|
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") {
|
||||||
if !strings.Contains(scope(), "platform-dev") {
|
t.Errorf("source not preselected: %q", got)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,24 +239,6 @@ func TestGoToTabsAndModes(t *testing.T) {
|
|||||||
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
|
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.keys("/")
|
||||||
d.typeText("shared")
|
d.typeText("shared")
|
||||||
if !v.typing || len(v.shown) == 0 {
|
if !v.typing || len(v.shown) == 0 {
|
||||||
|
|||||||
+75
-247
@@ -14,24 +14,22 @@ import (
|
|||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Go to is one popup for finding a place and going there. Tabs at the
|
// 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
|
// top: Favourites and Recent (from the store, so they work before
|
||||||
// one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it
|
// anything loads) are one list each; Search has two lists side by side,
|
||||||
// on the right; Favourites and Recent (from the store, so they work
|
// the scope on the left (All, or one source with how much of it is
|
||||||
// before anything loads) are one list each.
|
// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right.
|
||||||
//
|
//
|
||||||
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
|
// 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
|
// star, / to type) or typing a filter (ctrl+ letters). esc while typing
|
||||||
// clears the text and goes back to browsing; esc while browsing closes.
|
// 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,
|
// 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
|
// typing.
|
||||||
// back to All.
|
|
||||||
//
|
//
|
||||||
// The results don't move by themselves: as lists load, rows already shown
|
// 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
|
// keep their order and new ones come after them, with the cursor on the
|
||||||
@@ -41,7 +39,7 @@ import (
|
|||||||
type searchView struct {
|
type searchView struct {
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
typing bool // the text field has the keyboard
|
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)
|
results bool // in Search, the results have the cursor (else the scopes)
|
||||||
|
|
||||||
scopes []option // All, then the sources (Search)
|
scopes []option // All, then the sources (Search)
|
||||||
@@ -51,10 +49,6 @@ type searchView struct {
|
|||||||
shown []option // the tab's (or scope's) entries matching the input
|
shown []option // the tab's (or scope's) entries matching the input
|
||||||
src int // cursor in scopes
|
src int // cursor in scopes
|
||||||
res int // cursor in shown
|
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:
|
// 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
|
// typing loads the scope once, so lists that fail aren't asked for
|
||||||
@@ -63,25 +57,25 @@ type searchView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tabSearch = iota
|
tabFavs = iota
|
||||||
tabFavs
|
|
||||||
tabRecent
|
tabRecent
|
||||||
|
tabSearch
|
||||||
tabCount
|
tabCount
|
||||||
)
|
)
|
||||||
|
|
||||||
var tabNames = [tabCount]string{"Search", "Favourites", "Recent"}
|
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
scopeFavs = "#favs"
|
scopeFavs = "#favs"
|
||||||
scopeRecent = "#recent"
|
scopeRecent = "#recent"
|
||||||
allSources = "#all"
|
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"
|
const goToTitle = "Go to"
|
||||||
|
|
||||||
// showSearch opens Go to on Search, typing, on All, with the selected
|
// showSearch opens Go to on Search, typing, in the source you're in
|
||||||
// vault under the cursor.
|
// (else All), with the selected vault under the cursor.
|
||||||
func (m *Model) showSearch() tea.Cmd {
|
func (m *Model) showSearch() tea.Cmd {
|
||||||
cmd := m.openGoTo(tabSearch, true)
|
cmd := m.openGoTo(tabSearch, true)
|
||||||
m.preselect()
|
m.preselect()
|
||||||
@@ -137,20 +131,17 @@ func (v *searchView) scope() option {
|
|||||||
// onSource reports whether the results come from one source.
|
// onSource reports whether the results come from one source.
|
||||||
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
|
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
|
||||||
|
|
||||||
// preselect starts where the user is: the selected source is the one
|
// preselect starts where the user is: the selected source picked on the
|
||||||
// search narrows to, and the selected vault or scope is under the cursor.
|
// left, and the selected vault or scope under the cursor on the right.
|
||||||
func (m *Model) preselect() {
|
func (m *Model) preselect() {
|
||||||
v := m.search
|
v := m.search
|
||||||
row := m.sources.SelectedID()
|
row := m.sources.SelectedID()
|
||||||
account := ""
|
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row })
|
||||||
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 })
|
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
v.here = v.scopes[i].id
|
v.src = i
|
||||||
|
m.fillSearch(false)
|
||||||
if m.focus == paneSources {
|
if m.focus == paneSources {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -180,36 +171,25 @@ func (m *Model) fillSearch(keep bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sources, items := m.searchEntries()
|
sources, items := m.searchEntries()
|
||||||
all := option{label: "All", id: allSources, mark: m.coverageMark(nil)}
|
all := option{label: "All", id: allSources, mark: m.coverageMark("")}
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scopes := []option{all}
|
scopes := []option{all}
|
||||||
if len(groups) < 2 {
|
// All of one kind, so a search can stay on repos (local, quick)
|
||||||
scopes = append(scopes, sources...)
|
// without asking Azure for every vault; only with several kinds.
|
||||||
} else {
|
var kinds []provider.Kind
|
||||||
for _, p := range groups {
|
for _, p := range m.providers {
|
||||||
k := p.Kind()
|
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{
|
scopes = append(scopes, option{
|
||||||
label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()),
|
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)),
|
||||||
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
|
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 = append(scopes, sources...), items
|
||||||
}
|
|
||||||
v.scopes, v.items = scopes, items
|
|
||||||
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
|
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
|
||||||
|
|
||||||
v.shown = v.match()
|
v.shown = v.match()
|
||||||
@@ -222,9 +202,17 @@ func (m *Model) fillSearch(keep bool) {
|
|||||||
v.res = max(0, min(v.res, len(v.shown)-1))
|
v.res = max(0, min(v.res, len(v.shown)-1))
|
||||||
}
|
}
|
||||||
|
|
||||||
// coverageMark is ○ ◐ ● for how much of the providers in is loaded.
|
// kindScopeLabel names the scope of all of a kind's sources.
|
||||||
func (m *Model) coverageMark(in providerFilter) string {
|
func kindScopeLabel(k provider.Kind) string {
|
||||||
switch c := m.coverageOf(in); {
|
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():
|
case c.complete():
|
||||||
return m.st.Success.Render("●")
|
return m.st.Success.Render("●")
|
||||||
case c.vaultsLoaded > 0:
|
case c.vaultsLoaded > 0:
|
||||||
@@ -233,28 +221,15 @@ func (m *Model) coverageMark(in providerFilter) string {
|
|||||||
return m.st.Muted.Render("○")
|
return m.st.Muted.Render("○")
|
||||||
}
|
}
|
||||||
|
|
||||||
// scopeAccount is the provider whose sources the scope covers, if it's
|
// scopeKind is the kind a scope covers all of, if it's one of those.
|
||||||
// an account's.
|
func (v *searchView) scopeKind() (provider.Kind, bool) {
|
||||||
func (v *searchView) scopeAccount() (string, bool) {
|
k, ok := strings.CutPrefix(v.scope().id, kindScope)
|
||||||
id, ok := strings.CutPrefix(v.scope().id, accountScope)
|
|
||||||
if !ok || v.tab != tabSearch {
|
if !ok || v.tab != tabSearch {
|
||||||
return "", false
|
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.
|
// match lists the scope's entries matching the input, best first.
|
||||||
func (v *searchView) match() []option {
|
func (v *searchView) match() []option {
|
||||||
var cand []option
|
var cand []option
|
||||||
@@ -264,16 +239,13 @@ func (v *searchView) match() []option {
|
|||||||
case scopeRecent:
|
case scopeRecent:
|
||||||
cand = v.recent
|
cand = v.recent
|
||||||
default:
|
default:
|
||||||
account, ofAccount := v.scopeAccount()
|
kind, ofKind := v.scopeKind()
|
||||||
for _, o := range v.items {
|
for _, o := range v.items {
|
||||||
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
|
if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group {
|
||||||
cand = append(cand, o)
|
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()
|
q := v.input.Value()
|
||||||
if q == "" {
|
if q == "" {
|
||||||
return slices.Clone(cand)
|
return slices.Clone(cand)
|
||||||
@@ -348,9 +320,6 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
// The actions shared by both modes, by their keys in the mode.
|
// 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 }
|
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}
|
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 {
|
if v.typing {
|
||||||
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
|
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) {
|
if key.Matches(msg, km.PopupClose) {
|
||||||
@@ -369,22 +338,6 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
case key.Matches(msg, km.GoFilter):
|
case key.Matches(msg, km.GoFilter):
|
||||||
return m.setTyping(true)
|
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):
|
case key.Matches(msg, km.Help):
|
||||||
return m.showKeybindingsFor(keys.GoTo)
|
return m.showKeybindingsFor(keys.GoTo)
|
||||||
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
|
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
|
||||||
@@ -430,14 +383,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
case key.Matches(msg, k.sel):
|
case key.Matches(msg, k.sel):
|
||||||
switch {
|
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):
|
case v.results && v.res < len(v.shown):
|
||||||
m.search = nil
|
m.search = nil
|
||||||
return v.shown[v.res].run(m)
|
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():
|
case !v.results && v.onSource():
|
||||||
m.search = nil
|
m.search = nil
|
||||||
return v.scope().run(m)
|
return v.scope().run(m)
|
||||||
@@ -453,9 +401,11 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
if v.onSource() {
|
if v.onSource() {
|
||||||
return v.scope().reload(m)
|
return v.scope().reload(m)
|
||||||
}
|
}
|
||||||
return m.startCrawlOf(v.scopeFilter())
|
kind, _ := v.scopeKind()
|
||||||
|
return m.startCrawlOf(kind)
|
||||||
case inSearch && key.Matches(msg, k.loadAll):
|
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):
|
case key.Matches(msg, k.star):
|
||||||
if o, ok := v.selected(); ok && o.star != "" {
|
if o, ok := v.selected(); ok && o.star != "" {
|
||||||
m.toggleStarKey(o.star, o.starAccount)
|
m.toggleStarKey(o.star, o.starAccount)
|
||||||
@@ -486,22 +436,6 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
return 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
|
// searchLoads loads what a search in the Search tab covers, like the
|
||||||
// load-all key: everything on All, else the source picked. Only what
|
// load-all key: everything on All, else the source picked. Only what
|
||||||
// isn't loaded yet, once a popup; repos load by themselves anyway.
|
// isn't loaded yet, once a popup; repos load by themselves anyway.
|
||||||
@@ -518,8 +452,8 @@ func (m *Model) searchLoads() tea.Cmd {
|
|||||||
v.loaded = map[string]bool{}
|
v.loaded = map[string]bool{}
|
||||||
}
|
}
|
||||||
v.loaded[sc.id] = true
|
v.loaded[sc.id] = true
|
||||||
if _, ok := v.scopeAccount(); ok {
|
if kind, ok := v.scopeKind(); ok {
|
||||||
return m.startCrawlOf(v.scopeFilter())
|
return m.startCrawlOf(kind)
|
||||||
}
|
}
|
||||||
if !v.onSource() {
|
if !v.onSource() {
|
||||||
return m.startCrawl()
|
return m.startCrawl()
|
||||||
@@ -563,35 +497,6 @@ func (m *Model) toggleStarKey(key, account string) {
|
|||||||
// searchHint tops the results when what they come from isn't all
|
// searchHint tops the results when what they come from isn't all
|
||||||
// loaded, saying how to load it.
|
// loaded, saying how to load it.
|
||||||
func (m Model) searchHint() string {
|
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
|
v := m.search
|
||||||
if m.crawl.running {
|
if m.crawl.running {
|
||||||
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
|
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
|
||||||
@@ -599,8 +504,8 @@ func (m Model) loadHint() string {
|
|||||||
if v.tab != tabSearch {
|
if v.tab != tabSearch {
|
||||||
return "" // from the store: nothing to load
|
return "" // from the store: nothing to load
|
||||||
}
|
}
|
||||||
if _, ok := v.scopeAccount(); ok || v.scope().id == allSources {
|
if kind, ok := v.scopeKind(); ok || v.scope().id == allSources {
|
||||||
c := m.coverageOf(v.scopeFilter())
|
c := m.coverageOf(kind)
|
||||||
if c.complete() {
|
if c.complete() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -653,16 +558,7 @@ func (m Model) searchViewBox() string {
|
|||||||
v := m.search
|
v := m.search
|
||||||
focused := !m.termBlurred
|
focused := !m.termBlurred
|
||||||
w := searchWidth(m.w)
|
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))
|
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
|
srcW := 10
|
||||||
for _, o := range v.scopes {
|
for _, o := range v.scopes {
|
||||||
@@ -741,8 +637,14 @@ func (m Model) searchViewBox() string {
|
|||||||
}
|
}
|
||||||
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
|
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]
|
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
|
||||||
if _, ok := v.scopeAccount(); ok || v.onSource() {
|
if v.onSource() {
|
||||||
resultsTitle = "Results in " + scopeName(v.scope())
|
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
|
var lists string
|
||||||
if v.tab == tabSearch {
|
if v.tab == tabSearch {
|
||||||
@@ -754,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)
|
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := panes.TitleStyle(m.st, focused)
|
body := []string{" " + v.input.View(), ""}
|
||||||
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(), "")
|
|
||||||
for _, l := range strings.Split(lists, "\n") {
|
for _, l := range strings.Split(lists, "\n") {
|
||||||
body = append(body, " "+l)
|
body = append(body, " "+l)
|
||||||
}
|
}
|
||||||
@@ -791,83 +676,26 @@ func (m Model) searchViewBox() string {
|
|||||||
if v.tab == tabSearch {
|
if v.tab == tabSearch {
|
||||||
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
|
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:
|
default:
|
||||||
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
|
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
|
||||||
if v.tab == tabSearch {
|
if v.tab == tabSearch {
|
||||||
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
|
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
|
// Tabs in the title, the current one underlined, like the panes.
|
||||||
// picker has its own title, and the tabs over the input.
|
ts := panes.TitleStyle(m.st, focused)
|
||||||
if v.picker == nil {
|
head := ts.Render(goToTitle + " ")
|
||||||
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
|
|
||||||
for i, name := range tabNames {
|
for i, name := range tabNames {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
out += ts.Render(" - ")
|
head += ts.Render(" - ")
|
||||||
}
|
}
|
||||||
if i == m.search.tab {
|
if i == v.tab {
|
||||||
out += ts.Underline(true).Render(name)
|
head += ts.Underline(true).Render(name)
|
||||||
} else {
|
} 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)
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The status pane, like lazygit's: a line on top of the left panes with
|
|
||||||
// the version, a newer release and how the accounts are doing. Focused
|
|
||||||
// (0), the right pane has the rest: updates, each account, the files
|
|
||||||
// lazysecrets uses, links, and the changelog.
|
|
||||||
|
|
||||||
// logo heads the status pane's rows.
|
|
||||||
var logo = []string{
|
|
||||||
` _ _`,
|
|
||||||
`| | __ _ _____ _ ___ ___ ___ _ __ ___| |_ ___`,
|
|
||||||
`| |/ _` + "`" + ` |_ / | | / __|/ _ \/ __| '__/ _ \ __/ __|`,
|
|
||||||
`| | (_| |/ /| |_| \__ \ __/ (__| | | __/ |_\__ \`,
|
|
||||||
`|_|\__,_/___|\__, |___/\___|\___|_| \___|\__|___/`,
|
|
||||||
` |___/`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusH is the status pane's height: one line in a box.
|
|
||||||
const statusH = 3
|
|
||||||
|
|
||||||
// statusLine is what the status pane says while it isn't focused.
|
|
||||||
func (m Model) statusLine() string {
|
|
||||||
var parts []string
|
|
||||||
switch {
|
|
||||||
case m.updated != "":
|
|
||||||
parts = append(parts, m.st.Notice.Render("→ "+m.updated+" (restart)"))
|
|
||||||
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
|
|
||||||
for _, p := range m.providers {
|
|
||||||
if p.Kind() == provider.KindRepo {
|
|
||||||
repos += len(m.sourceResults[p.ID()].sources) // those with secret files
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
accounts++
|
|
||||||
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
|
|
||||||
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 {
|
|
||||||
s += " · " + m.st.Error.Render(fmt.Sprintf("%d need sign-in", failing))
|
|
||||||
}
|
|
||||||
parts = append(parts, s)
|
|
||||||
}
|
|
||||||
if repos > 0 {
|
|
||||||
parts = append(parts, plural(repos, "repo"))
|
|
||||||
}
|
|
||||||
return " " + strings.Join(parts, m.st.Muted.Render(" · "))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) statusView() string {
|
|
||||||
title := "[0] lazysecrets"
|
|
||||||
if v := m.versionLabel(); v != "" {
|
|
||||||
title += " " + v
|
|
||||||
}
|
|
||||||
return panes.Box(m.st, panes.TitleStyle(m.st, m.active(paneStatus)).Render(title), "",
|
|
||||||
[]string{m.statusLine()}, m.statusW, statusH, m.active(paneStatus))
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusFields are the status pane's rows on the right.
|
|
||||||
func (m Model) statusFields() []panes.Field {
|
|
||||||
var out []panes.Field
|
|
||||||
add := func(f panes.Field) {
|
|
||||||
if f.Value != "" {
|
|
||||||
out = append(out, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A release's rows open its release page.
|
|
||||||
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
|
|
||||||
if strings.HasPrefix(version.Value, "v") {
|
|
||||||
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
|
|
||||||
}
|
|
||||||
add(version)
|
|
||||||
upd := panes.Field{Label: "update", Value: m.updateStatus(), Info: m.newer == nil}
|
|
||||||
if m.newer != nil {
|
|
||||||
upd.URL = update.DefaultRepo + "/releases/tag/" + m.newer.Tag
|
|
||||||
}
|
|
||||||
add(upd)
|
|
||||||
add(panes.Field{Label: "license", Value: "GPL-3.0-or-later · © 2026 Karl-Einar Bergstrøm", Info: true})
|
|
||||||
|
|
||||||
for i, p := range m.providers {
|
|
||||||
f := panes.Field{Value: p.Label() + " · " + m.accountStatus(p), Provider: p.ID()}
|
|
||||||
if i == 0 {
|
|
||||||
f.Label = "accounts"
|
|
||||||
}
|
|
||||||
var auth *provider.AuthError
|
|
||||||
f.Err = errors.As(m.sourceResults[p.ID()].err, &auth)
|
|
||||||
add(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, m.expiringFields()...)
|
|
||||||
out = append(out, m.errorFields()...)
|
|
||||||
|
|
||||||
for i, path := range m.configFiles {
|
|
||||||
label := ""
|
|
||||||
if i == 0 {
|
|
||||||
label = "config"
|
|
||||||
}
|
|
||||||
add(panes.Field{Label: label, Value: path, Path: path})
|
|
||||||
}
|
|
||||||
add(panes.Field{Label: "state", Value: m.statePath, Path: m.statePath})
|
|
||||||
add(panes.Field{Label: "cache", Value: m.cachePath, Path: m.cachePath})
|
|
||||||
|
|
||||||
// Short, without https://; y copies and o opens the whole URL.
|
|
||||||
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: "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...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateStatus says whether a newer release is out, and when it was
|
|
||||||
// last checked.
|
|
||||||
func (m Model) updateStatus() string {
|
|
||||||
switch {
|
|
||||||
case m.updated != "":
|
|
||||||
return "updated to " + m.updated + ": restart to use it"
|
|
||||||
case m.newer != nil:
|
|
||||||
return m.newer.Tag + " is out" + withKey(m.keys.Update, " · %s updates")
|
|
||||||
}
|
|
||||||
at := m.store.UpdateCheck().At
|
|
||||||
if at.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "up to date · checked " + ago(m.now().Sub(at))
|
|
||||||
}
|
|
||||||
|
|
||||||
// accountStatus says whether an account's sources loaded.
|
|
||||||
func (m Model) accountStatus(p provider.Provider) string {
|
|
||||||
r, done := m.sourceResults[p.ID()]
|
|
||||||
var auth *provider.AuthError
|
|
||||||
switch {
|
|
||||||
case !done:
|
|
||||||
return "loading…"
|
|
||||||
case errors.As(r.err, &auth) && len(auth.Login) > 0:
|
|
||||||
return "sign-in required" + withKey(m.keys.Login, " · %s signs in")
|
|
||||||
case r.err != nil:
|
|
||||||
return r.err.Error()
|
|
||||||
}
|
|
||||||
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
|
|
||||||
return plural(len(r.sources), cmpOr(noun, "source"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
|
|
||||||
func ago(d time.Duration) string {
|
|
||||||
switch {
|
|
||||||
case d < time.Minute:
|
|
||||||
return "just now"
|
|
||||||
case d < time.Hour:
|
|
||||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
||||||
case d < 48*time.Hour:
|
|
||||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
|
||||||
}
|
|
||||||
|
|
||||||
// changelogFields are CHANGELOG.md as rows: each release's version and
|
|
||||||
// date, then its entries, marked by what kind they are.
|
|
||||||
func changelogFields(md string) []panes.Field {
|
|
||||||
var out []panes.Field
|
|
||||||
kind := ""
|
|
||||||
for _, line := range strings.Split(md, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(line, "## "):
|
|
||||||
version, date, _ := strings.Cut(strings.TrimPrefix(line, "## "), " – ")
|
|
||||||
out = append(out, panes.Field{Label: version, Value: date, Info: true})
|
|
||||||
kind = ""
|
|
||||||
case strings.HasPrefix(line, "### "):
|
|
||||||
kind = strings.ToLower(strings.TrimPrefix(line, "### "))
|
|
||||||
case strings.HasPrefix(line, "- ") && len(out) > 0:
|
|
||||||
text := update.PlainLinks(strings.TrimPrefix(line, "- "))
|
|
||||||
if kind != "" {
|
|
||||||
text = kind + ": " + text
|
|
||||||
}
|
|
||||||
out = append(out, panes.Field{Value: text})
|
|
||||||
case line != "" && len(out) > 0 && !strings.HasPrefix(line, "<!--"):
|
|
||||||
// A release's own words ("Hello world 👋").
|
|
||||||
out = append(out, panes.Field{Value: update.PlainLinks(line)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestChangelogFields(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
md := "# Changelog\n\nEvery release of lazysecrets, newest first.\n\n## v0.2.0 – 2026-10-12\n\n### Added\n\n" +
|
|
||||||
"- Search ([#11](https://x/pulls/11)) ([65dc2d7](https://x/commit/65dc2d7))\n\n### Fixed\n\n- A fix (#3)\n\n" +
|
|
||||||
"## v0.1.0 – 2026-10-10\n\nHello world 👋\n\n<!-- generated by git-cliff (cliff.toml) -->\n"
|
|
||||||
got := changelogFields(md)
|
|
||||||
want := []panes.Field{
|
|
||||||
{Label: "v0.2.0", Value: "2026-10-12", Info: true},
|
|
||||||
{Value: "added: Search (#11) (65dc2d7)"},
|
|
||||||
{Value: "fixed: A fix (#3)"},
|
|
||||||
{Label: "v0.1.0", Value: "2026-10-10", Info: true},
|
|
||||||
{Value: "Hello world 👋"},
|
|
||||||
}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("got %+v", got)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Errorf("row %d: %+v, want %+v", i, got[i], want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-22
@@ -1,25 +1,22 @@
|
|||||||
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
|
╭─ [1] Sources - Favourites ────╮╭─ Secret ────────────────────────────────────────────────────────╮
|
||||||
│ 2 accounts ││ name platform-prod │
|
│ ▾ Azure (2) ││ name db-password │
|
||||||
╰───────────────────────────────╯│ ID sub-prod │
|
│ platform-prod ││ vault kv-app-prod │
|
||||||
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
|
│ platform-dev ││ source Azure / platform-prod │
|
||||||
│ ▾ Azure (2) ││ vaults 3 │
|
│ ▾ Databricks (2) ││ version 3f9c2a │
|
||||||
│ platform-prod ││ │
|
│ prod ││ enabled yes │
|
||||||
│ platform-dev ││ │
|
╰────────────────────── 2 of 6 ─╯│ updated 2026-09-01 09:30 │
|
||||||
│ ▾ Databricks (2) ││ │
|
╭─ [2] Vaults ──────────────────╮│ expires 2027-09-01 09:30 │
|
||||||
│ prod ││ │
|
│ kv-app-prod ││ content text/plain │
|
||||||
╰────────────────────── 2 of 6 ─╯│ │
|
│ kv-shared-prod ││ tags env=prod, owner=data │
|
||||||
╭─ [2] Vaults ──────────────────╮│ │
|
│ kv-restricted ││ ref @Microsoft.KeyVault(SecretUri=https://kv-app-prod.vau│
|
||||||
│ kv-app-prod ││ │
|
│ ││ lt.azure.net/secrets/db-password) │
|
||||||
│ kv-shared-prod ││ │
|
|
||||||
│ kv-restricted ││ │
|
|
||||||
│ ││ │
|
│ ││ │
|
||||||
|
│ ││ value •••••••• (space to reveal) │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
╰────────────────────── 1 of 3 ─╯│ versions 3 · v to browse │
|
||||||
│ ││ │
|
╭─ [3] Secrets - Keys - Certs ──╮│ ▸ 3f9c2a 2026-09-01 current │
|
||||||
╰────────────────────── 1 of 3 ─╯│ │
|
│ db-password ││ 91d3e0 2026-03-01 expired │
|
||||||
╭─ [3] Secrets - Keys - Certs ──╮│ │
|
│ api-key ││ 0a77c4 2025-09-01 disabled │
|
||||||
│ db-password ││ │
|
|
||||||
│ api-key ││ │
|
|
||||||
│ legacy-token (disabled) ││ │
|
│ legacy-token (disabled) ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
@@ -29,5 +26,5 @@
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
╰────────────────────── 1 of 3 ─╯╰─────────────────────────────────────────────────────────────────╯
|
||||||
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
|
enter open • s copy link • S share… • f star • F favourites • n note • … • ? help • q quit
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-12
@@ -20,8 +20,7 @@ type Updater struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updateEvery is how often lazysecrets looks for a newer release on its
|
// updateEvery is how often lazysecrets looks for a newer release on its
|
||||||
// own unless the config says otherwise; within that, the last answer (in
|
// own; within that, the last answer (in the state file) is used.
|
||||||
// the state file) is used.
|
|
||||||
const updateEvery = 24 * time.Hour
|
const updateEvery = 24 * time.Hour
|
||||||
|
|
||||||
type updateMsg struct {
|
type updateMsg struct {
|
||||||
@@ -36,14 +35,14 @@ type installedMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startUpdateCheck sets up the startup look for a newer release: the
|
// 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).
|
// look at the server (fetchOnStart, for Init).
|
||||||
func (m *Model) startUpdateCheck() {
|
func (m *Model) startUpdateCheck() {
|
||||||
if m.updater == nil || !m.updateCheck {
|
if m.updater == nil || !m.updateCheck {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
last := m.store.UpdateCheck()
|
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})
|
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,22 +109,15 @@ func (m *Model) update() tea.Cmd {
|
|||||||
func (m *Model) showUpdate() {
|
func (m *Model) showUpdate() {
|
||||||
r := *m.newer
|
r := *m.newer
|
||||||
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
|
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, "")
|
lines = append(lines, "")
|
||||||
for _, l := range strings.Split(notes, "\n") {
|
for _, l := range strings.Split(notes, "\n") {
|
||||||
if h, ok := strings.CutPrefix(l, "### "); ok {
|
if h, ok := strings.CutPrefix(l, "### "); ok {
|
||||||
l = m.st.Label.Render(h)
|
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)
|
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.",
|
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) }}}
|
run: func(m *Model) tea.Cmd { return m.install(r) }}}
|
||||||
if r.URL != "" {
|
if r.URL != "" {
|
||||||
@@ -163,3 +155,15 @@ func (m *Model) handleInstalled(msg installedMsg) {
|
|||||||
m.updated = msg.release.Tag
|
m.updated = msg.release.Tag
|
||||||
m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it")
|
m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateLabel is the version's suffix in the footer: a newer release to
|
||||||
|
// update to, or the one installed and waiting for a restart.
|
||||||
|
func (m Model) updateLabel() string {
|
||||||
|
switch {
|
||||||
|
case m.updated != "":
|
||||||
|
return " → " + m.updated + " (restart)"
|
||||||
|
case m.newer != nil:
|
||||||
|
return " ↑ " + m.newer.Tag + withKey(m.keys.Update, " %s")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -47,8 +47,8 @@ func TestUpdate(t *testing.T) {
|
|||||||
if f.asked != 1 {
|
if f.asked != 1 {
|
||||||
t.Fatalf("asked %d times at startup", f.asked)
|
t.Fatalf("asked %d times at startup", f.asked)
|
||||||
}
|
}
|
||||||
if scr := d.screen(); !strings.Contains(scr, "[0] lazysecrets v0.1.1") || !strings.Contains(scr, "↑ v0.2.0 U") {
|
if !strings.Contains(d.screen(), "v0.1.1 ↑ v0.2.0 U") {
|
||||||
t.Errorf("no update in the status pane:\n%s", scr)
|
t.Errorf("no update in the footer:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
|
|
||||||
// U: the changelog, without the install notes; u installs.
|
// U: the changelog, without the install notes; u installs.
|
||||||
@@ -83,12 +83,6 @@ func TestUpdate(t *testing.T) {
|
|||||||
if f2.asked != 1 {
|
if f2.asked != 1 {
|
||||||
t.Errorf("asked %d times a day later", f2.asked)
|
t.Errorf("asked %d times a day later", f2.asked)
|
||||||
}
|
}
|
||||||
// With autoCheckInterval: 1h, two hours on is long enough.
|
|
||||||
mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-2 * time.Hour), Tag: "v0.2.0"})
|
|
||||||
newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, UpdateEvery: time.Hour, Store: mem})
|
|
||||||
if f2.asked != 2 {
|
|
||||||
t.Errorf("asked %d times, want a look after the hour", f2.asked)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateAsked(t *testing.T) {
|
func TestUpdateAsked(t *testing.T) {
|
||||||
@@ -122,11 +116,3 @@ func TestUpdateAsked(t *testing.T) {
|
|||||||
t.Errorf("U on a dev build:\n%s", dev.screen())
|
t.Errorf("U on a dev build:\n%s", dev.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStartupWarning(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
d := newDriverOpts(t, Options{Warning: "theme: skipped unknown activeBorder"})
|
|
||||||
if !strings.Contains(d.screen(), "theme: skipped unknown activeBorder") {
|
|
||||||
t.Errorf("no warning:\n%s", d.screen())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
// Usage: revealing, copying, opening in the browser, sharing and jumping
|
// Usage: revealing, copying, opening in the browser, sharing and jumping
|
||||||
// to an item count as using it. The store keeps when each item was last
|
// to an item count as using it. The store keeps when each item was last
|
||||||
// used and how often, for the Recent section of the jump list. The config's
|
// used and how often, for the Recent section of the jump list. The config's
|
||||||
// storage.history: false turns this off.
|
// history: false turns this off.
|
||||||
|
|
||||||
// used counts a use of the remembered item key, keeping its context.
|
// used counts a use of the remembered item key, keeping its context.
|
||||||
func (m *Model) used(key string) {
|
func (m *Model) used(key string) {
|
||||||
|
|||||||
+17
-23
@@ -36,11 +36,11 @@ const (
|
|||||||
type Map struct {
|
type Map struct {
|
||||||
// lists
|
// lists
|
||||||
Up, Down, Top, Bottom key.Binding
|
Up, Down, Top, Bottom key.Binding
|
||||||
NextPane, PrevPane, Details key.Binding
|
NextPane, PrevPane key.Binding
|
||||||
Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding
|
Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||||
NextTab, PrevTab key.Binding
|
NextTab, PrevTab key.Binding
|
||||||
Enter, Back, Filter key.Binding
|
Enter, Back, Filter key.Binding
|
||||||
Reveal, Copy, CopyRef, Open, Edit key.Binding
|
Reveal, Copy, CopyRef, Open key.Binding
|
||||||
Versions, Favourite, Favourites key.Binding
|
Versions, Favourite, Favourites key.Binding
|
||||||
Recent key.Binding
|
Recent key.Binding
|
||||||
Note, Share, ShareMenu, OpenRef key.Binding
|
Note, Share, ShareMenu, OpenRef key.Binding
|
||||||
@@ -61,8 +61,7 @@ type Map struct {
|
|||||||
SearchSources, SearchResults, SearchSwitch key.Binding
|
SearchSources, SearchResults, SearchSwitch key.Binding
|
||||||
SearchLoad, SearchLoadAll key.Binding
|
SearchLoad, SearchLoadAll key.Binding
|
||||||
// goto
|
// goto
|
||||||
GoDown, GoUp, GoTop, GoBottom key.Binding
|
GoDown, GoUp, GoPrevTab, GoNextTab key.Binding
|
||||||
GoPrevTab, GoNextTab key.Binding
|
|
||||||
GoSources, GoResults, GoSwitch key.Binding
|
GoSources, GoResults, GoSwitch key.Binding
|
||||||
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
|
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
|
||||||
GoSelect, GoClose key.Binding
|
GoSelect, GoClose key.Binding
|
||||||
@@ -88,14 +87,12 @@ func (m *Map) Actions() []Action {
|
|||||||
a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."),
|
a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."),
|
||||||
a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."),
|
a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."),
|
||||||
a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."),
|
a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."),
|
||||||
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane; from the detail pane, the one it describes."),
|
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane."),
|
||||||
a(Lists, "details", &m.Details, "details", "move", "Focus the detail pane on the right, on what's selected: a source, vault, file or secret."),
|
|
||||||
a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."),
|
a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."),
|
||||||
a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
|
a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
|
||||||
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
|
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
|
||||||
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
|
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
|
||||||
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
|
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
|
||||||
a(Lists, "open", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
|
|
||||||
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
|
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
|
||||||
|
|
||||||
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
|
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
|
||||||
@@ -103,7 +100,7 @@ func (m *Map) Actions() []Action {
|
|||||||
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
|
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
|
||||||
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
|
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
|
||||||
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
|
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
|
||||||
a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
|
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one, starting in the source you're in. On the left, the scope: Favourites, Recent, All, or one source (○ ◐ ● for how much of it is loaded)."),
|
||||||
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
||||||
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
|
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
|
||||||
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
|
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
|
||||||
@@ -114,10 +111,9 @@ func (m *Map) Actions() []Action {
|
|||||||
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
|
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
|
||||||
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
|
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
|
||||||
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
|
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
|
||||||
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
|
|
||||||
|
|
||||||
a(Lists, "copyLink", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
||||||
a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."),
|
a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
|
||||||
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
|
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
|
||||||
|
|
||||||
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
|
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
|
||||||
@@ -161,9 +157,7 @@ func (m *Map) Actions() []Action {
|
|||||||
|
|
||||||
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
|
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
|
||||||
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
|
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
|
||||||
a(GoTo, "top", &m.GoTop, "top", "go to", "Jump to the top of the list in Go to while browsing."),
|
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."),
|
||||||
a(GoTo, "bottom", &m.GoBottom, "bottom", "go to", "Jump to the bottom of the list in Go to while browsing."),
|
|
||||||
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Search, Favourites, Recent."),
|
|
||||||
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
|
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
|
||||||
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
|
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
|
||||||
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
|
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
|
||||||
@@ -182,15 +176,15 @@ func (m *Map) Actions() []Action {
|
|||||||
var defaults = map[string][]string{
|
var defaults = map[string][]string{
|
||||||
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
|
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
|
||||||
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
|
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
|
||||||
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
|
"lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"},
|
||||||
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
|
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"},
|
||||||
"lists.open": {"enter"}, "lists.back": {"esc", "backspace"},
|
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
||||||
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"},
|
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
|
||||||
"lists.commands": {"x"},
|
"lists.commands": {"x"},
|
||||||
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
|
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
|
||||||
"lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"},
|
"lists.versions": {"v"}, "lists.openInBrowser": {"o"},
|
||||||
"lists.copyLink": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
|
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
|
||||||
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
|
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
|
||||||
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
|
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
|
||||||
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
|
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
|
||||||
@@ -209,7 +203,7 @@ var defaults = map[string][]string{
|
|||||||
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
|
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
|
||||||
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
|
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
|
||||||
|
|
||||||
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.top": {"g", "home"}, "goto.bottom": {"G", "end"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
|
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
|
||||||
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
|
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
|
||||||
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
|
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
|
||||||
"goto.select": {"enter"}, "goto.close": {"esc", "q"},
|
"goto.select": {"enter"}, "goto.close": {"esc", "q"},
|
||||||
|
|||||||
+27
-57
@@ -31,7 +31,7 @@ type Detail struct {
|
|||||||
Icons icons.Set
|
Icons icons.Set
|
||||||
// RevealKey and VersionsKey are the keys hints mention ("space to
|
// RevealKey and VersionsKey are the keys hints mention ("space to
|
||||||
// reveal"); a hint is left out when its key is "" (unbound).
|
// reveal"); a hint is left out when its key is "" (unbound).
|
||||||
RevealKey, VersionsKey, CopyKey string
|
RevealKey, VersionsKey string
|
||||||
|
|
||||||
secret *provider.Object
|
secret *provider.Object
|
||||||
container *provider.Container
|
container *provider.Container
|
||||||
@@ -60,16 +60,6 @@ func (d *Detail) ShowSecret(s provider.Object) {
|
|||||||
d.container = &s.Container
|
d.container = &s.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh shows s's newer metadata if s is the secret shown, keeping
|
|
||||||
// its account and any value shown or loading.
|
|
||||||
func (d *Detail) Refresh(s provider.Object) {
|
|
||||||
if d.secret == nil || d.secret.ID != s.ID {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.Container = d.secret.Container
|
|
||||||
d.secret = &s
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowContainer shows a container summary when no secret is selected.
|
// ShowContainer shows a container summary when no secret is selected.
|
||||||
func (d *Detail) ShowContainer(c provider.Container) {
|
func (d *Detail) ShowContainer(c provider.Container) {
|
||||||
d.Clear()
|
d.Clear()
|
||||||
@@ -78,7 +68,7 @@ func (d *Detail) ShowContainer(c provider.Container) {
|
|||||||
|
|
||||||
// Clear drops the selection and any fetched value.
|
// Clear drops the selection and any fetched value.
|
||||||
func (d *Detail) Clear() {
|
func (d *Detail) Clear() {
|
||||||
*d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, CopyKey: d.CopyKey, w: d.w, h: d.h} // drops the note too
|
*d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, w: d.w, h: d.h} // drops the note too
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDescribing marks extra metadata as being fetched.
|
// SetDescribing marks extra metadata as being fetched.
|
||||||
@@ -272,35 +262,24 @@ func (d Detail) lines(st styles.Styles) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Secrets are masked; keys and certificates only expose public material.
|
// Secrets are masked; keys and certificates only expose public material.
|
||||||
// What the keys do with the value, on the line under it.
|
keyHint := func(k, what string) string {
|
||||||
hints := func(reveal string, more ...string) string {
|
if k == "" {
|
||||||
var h []string
|
return ""
|
||||||
if d.RevealKey != "" {
|
|
||||||
h = append(h, d.RevealKey+" to "+reveal)
|
|
||||||
}
|
}
|
||||||
if d.CopyKey != "" {
|
return "(" + k + " to " + what + ")"
|
||||||
h = append(h, d.CopyKey+" to copy")
|
|
||||||
}
|
}
|
||||||
return muted(strings.Join(append(h, more...), " · "))
|
label, hidden := "value", st.Masked.Render("••••••••")+muted(" "+keyHint(d.RevealKey, "reveal"))
|
||||||
}
|
|
||||||
label, hidden, reveal := "value", st.Masked.Render("••••••••"), "reveal"
|
|
||||||
switch s.Type {
|
switch s.Type {
|
||||||
case provider.TypeKey:
|
case provider.TypeKey:
|
||||||
label, hidden, reveal = "public", "", "show the public key"
|
label, hidden = "public", muted(keyHint(d.RevealKey, "show public key"))
|
||||||
case provider.TypeCertificate:
|
case provider.TypeCertificate:
|
||||||
label, hidden, reveal = "cert", "", "show the certificate"
|
label, hidden = "cert", muted(keyHint(d.RevealKey, "show certificate"))
|
||||||
}
|
}
|
||||||
indent := strings.Repeat(" ", 12)
|
|
||||||
|
|
||||||
out = append(out, "")
|
out = append(out, "")
|
||||||
switch d.state {
|
switch d.state {
|
||||||
case ValueHidden:
|
case ValueHidden:
|
||||||
if hidden != "" {
|
|
||||||
field(label, hidden)
|
field(label, hidden)
|
||||||
out = append(out, indent+hints(reveal))
|
|
||||||
} else {
|
|
||||||
field(label, hints(reveal))
|
|
||||||
}
|
|
||||||
case ValueLoading:
|
case ValueLoading:
|
||||||
field(label, spinnerPlaceholder+" fetching…")
|
field(label, spinnerPlaceholder+" fetching…")
|
||||||
case ValueFailed:
|
case ValueFailed:
|
||||||
@@ -321,30 +300,23 @@ func (d Detail) lines(st styles.Styles) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ValueShown:
|
case ValueShown:
|
||||||
// Where the dots were, continuing under itself; the keys below.
|
var hints []string
|
||||||
first := true
|
if d.RevealKey != "" {
|
||||||
lines, note := valueView(d.value.Reveal())
|
hints = append(hints, d.RevealKey+" to hide")
|
||||||
for _, line := range lines {
|
|
||||||
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
|
|
||||||
if first {
|
|
||||||
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
|
|
||||||
first = false
|
|
||||||
} else {
|
|
||||||
out = append(out, indent+st.Value.Render(w))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var more []string
|
|
||||||
if note != "" && d.CopyKey != "" {
|
|
||||||
note += ": " + d.CopyKey + " copies it as stored"
|
|
||||||
}
|
|
||||||
if note != "" {
|
|
||||||
more = append(more, note)
|
|
||||||
}
|
}
|
||||||
if d.hidesIn != "" {
|
if d.hidesIn != "" {
|
||||||
more = append(more, "hides in "+d.hidesIn)
|
hints = append(hints, "hides in "+d.hidesIn)
|
||||||
|
}
|
||||||
|
line := " " + st.Label.Render(label)
|
||||||
|
if len(hints) > 0 {
|
||||||
|
line += muted(" (" + strings.Join(hints, " · ") + ")")
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
for _, line := range strings.Split(d.value.Reveal(), "\n") {
|
||||||
|
for _, w := range strings.Split(ansi.Hardwrap(line, inner, true), "\n") {
|
||||||
|
out = append(out, " "+st.Value.Render(w))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out = append(out, indent+hints("hide", more...))
|
|
||||||
}
|
}
|
||||||
return append(out, d.versionLines(st)...)
|
return append(out, d.versionLines(st)...)
|
||||||
}
|
}
|
||||||
@@ -372,13 +344,11 @@ func (d Detail) versionLines(st styles.Styles) []string {
|
|||||||
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
|
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// A list to read, not to move in (v browses them): no cursor. The
|
marker := " "
|
||||||
// version shown above, when it isn't the current one, is named.
|
if d.secret.Version != "" && v.Version == d.secret.Version {
|
||||||
line := " " + VersionLine(st, v, d.Now(), current)
|
marker = "▸ " // the version shown above
|
||||||
if d.secret.Version != "" && v.Version == d.secret.Version && v.Version != current {
|
|
||||||
line += muted(" · shown above")
|
|
||||||
}
|
}
|
||||||
out = append(out, line)
|
out = append(out, " "+marker+VersionLine(st, v, d.Now(), current))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package panes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/x/ansi"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fields is the fourth pane for anything but a secret (an account, and
|
|
||||||
// what describes vaults, scopes and files): labelled values, one per row,
|
|
||||||
// a label on the first of several values, to move through, filter and
|
|
||||||
// copy from. It's a List, so / filters it like the other panes. Secrets
|
|
||||||
// have Detail, for revealing and versions.
|
|
||||||
type Fields struct {
|
|
||||||
List[Field]
|
|
||||||
shown bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Field is a row: a value under a label. Info rows (loading, errors)
|
|
||||||
// can't be copied; Err ones are errors. A row can also be opened: URL in
|
|
||||||
// the browser (o), Path in the editor (e), Provider signed in to (L), or
|
|
||||||
// Link gone to (enter).
|
|
||||||
type Field struct {
|
|
||||||
Label, Value string
|
|
||||||
Info, Err bool
|
|
||||||
|
|
||||||
URL, Path, Provider string
|
|
||||||
// Link is a lazysecrets link enter goes to, in the source of row Row.
|
|
||||||
Link, Row string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFields() Fields {
|
|
||||||
l := NewList("", Item[Field]{
|
|
||||||
ID: func(f Field) string { return f.Label + "\x00" + f.Value },
|
|
||||||
Filter: func(f Field) string { return f.Value },
|
|
||||||
// "/tenant" finds the tenant's rows.
|
|
||||||
FilterExtra: func(f Field) string { return f.Label },
|
|
||||||
Render: func(st styles.Styles, f Field, width int) string {
|
|
||||||
value := f.Value
|
|
||||||
switch {
|
|
||||||
case f.Err:
|
|
||||||
value = st.Error.Render(value)
|
|
||||||
case f.Info:
|
|
||||||
value = st.Muted.Render(value)
|
|
||||||
}
|
|
||||||
return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
l.Empty = "nothing to show"
|
|
||||||
return Fields{List: l}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show shows fields under title, instead of the secret pane. Another
|
|
||||||
// title (another account or vault) starts over, without a filter.
|
|
||||||
func (s *Fields) Show(title string, fields []Field) {
|
|
||||||
if title != s.Title {
|
|
||||||
s.Reset()
|
|
||||||
s.Title = title
|
|
||||||
}
|
|
||||||
s.shown = true
|
|
||||||
s.SetItems(fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide gives the fourth pane back to the secret pane.
|
|
||||||
func (s *Fields) Hide() { s.shown = false }
|
|
||||||
|
|
||||||
// Shown reports whether the fourth pane shows Fields.
|
|
||||||
func (s Fields) Shown() bool { return s.shown }
|
|
||||||
|
|
||||||
// Selected is the field under the cursor, unless it's an info row.
|
|
||||||
func (s Fields) Selected() (Field, bool) {
|
|
||||||
f, ok := s.List.Selected()
|
|
||||||
if !ok || f.Info {
|
|
||||||
return Field{}, false
|
|
||||||
}
|
|
||||||
return f, true
|
|
||||||
}
|
|
||||||
@@ -34,14 +34,6 @@ type List[T any] struct {
|
|||||||
Empty string // shown when loaded with no items
|
Empty string // shown when loaded with no items
|
||||||
Loading bool
|
Loading bool
|
||||||
Err error
|
Err error
|
||||||
// Age says how old the items are ("2h ago") while they reload, by
|
|
||||||
// the counter.
|
|
||||||
Age string
|
|
||||||
// Header lines (a logo) go above the items while the cursor is in
|
|
||||||
// the rows under it, the pane has room and there's no filter; else
|
|
||||||
// HeaderShort, a line of plain text, does.
|
|
||||||
Header []string
|
|
||||||
HeaderShort string
|
|
||||||
|
|
||||||
item Item[T]
|
item Item[T]
|
||||||
items []T
|
items []T
|
||||||
@@ -96,7 +88,6 @@ func (l *List[T]) SetError(err error) {
|
|||||||
func (l *List[T]) Refresh() {
|
func (l *List[T]) Refresh() {
|
||||||
l.Loading = true
|
l.Loading = true
|
||||||
l.Err = nil
|
l.Err = nil
|
||||||
l.Age = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *List[T]) SetLoading() {
|
func (l *List[T]) SetLoading() {
|
||||||
@@ -168,36 +159,9 @@ func (l List[T]) rows() int {
|
|||||||
if l.filtering {
|
if l.filtering {
|
||||||
r--
|
r--
|
||||||
}
|
}
|
||||||
r -= len(l.header())
|
|
||||||
return max(0, r)
|
return max(0, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// headerMinRows is how many item rows Header leaves at least.
|
|
||||||
const headerMinRows = 5
|
|
||||||
|
|
||||||
// header is the lines above the items: Header and a blank line, if it
|
|
||||||
// fits, leaves headerMinRows and the cursor is in the rows under it;
|
|
||||||
// else HeaderShort.
|
|
||||||
func (l List[T]) header() []string {
|
|
||||||
short := []string{}
|
|
||||||
if l.HeaderShort != "" {
|
|
||||||
short = []string{l.HeaderShort}
|
|
||||||
}
|
|
||||||
if len(l.Header) == 0 || l.filtering || l.input.Value() != "" {
|
|
||||||
return short
|
|
||||||
}
|
|
||||||
for _, line := range l.Header {
|
|
||||||
if ansi.StringWidth(line) > l.w-3 {
|
|
||||||
return short
|
|
||||||
}
|
|
||||||
}
|
|
||||||
below := l.h - 2 - len(l.Header) - 1
|
|
||||||
if below < headerMinRows || l.cursor >= below {
|
|
||||||
return short
|
|
||||||
}
|
|
||||||
return append(l.Header[:len(l.Header):len(l.Header)], "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtering reports whether the filter input has focus.
|
// Filtering reports whether the filter input has focus.
|
||||||
func (l List[T]) Filtering() bool { return l.filtering }
|
func (l List[T]) Filtering() bool { return l.filtering }
|
||||||
|
|
||||||
@@ -361,15 +325,12 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
|||||||
if l.filtering {
|
if l.filtering {
|
||||||
body = append(body, l.input.View())
|
body = append(body, l.input.View())
|
||||||
}
|
}
|
||||||
for _, line := range l.header() {
|
|
||||||
body = append(body, st.Notice.Render(" "+line))
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case l.Loading && len(l.items) == 0:
|
case l.Loading && len(l.items) == 0:
|
||||||
body = append(body, " "+spin+" loading…")
|
body = append(body, " "+spin+" loading…")
|
||||||
case l.Err != nil:
|
case l.Err != nil:
|
||||||
for _, line := range wrapLines(l.Err.Error(), inner-1) {
|
for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") {
|
||||||
body = append(body, st.Error.Render(" "+line))
|
body = append(body, st.Error.Render(" "+line))
|
||||||
}
|
}
|
||||||
case len(l.visible) == 0 && len(l.items) > 0:
|
case len(l.visible) == 0 && len(l.items) > 0:
|
||||||
@@ -408,9 +369,6 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
|||||||
if len(l.visible) > 0 {
|
if len(l.visible) > 0 {
|
||||||
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
||||||
}
|
}
|
||||||
if l.Loading && l.Age != "" && len(l.items) > 0 {
|
|
||||||
counter = "cached " + l.Age + " · " + counter
|
|
||||||
}
|
|
||||||
return BoxCounted(st, title, "", counter, body, l.w, l.h, focused)
|
return BoxCounted(st, title, "", counter, body, l.w, l.h, focused)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,22 +386,3 @@ func (l *List[T]) scroll() {
|
|||||||
}
|
}
|
||||||
l.offset = max(0, min(l.offset, len(l.visible)-rows))
|
l.offset = max(0, min(l.offset, len(l.visible)-rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrapLines wraps text to width, line by line; a "• " item's further
|
|
||||||
// lines line up under its text.
|
|
||||||
func wrapLines(text string, width int) []string {
|
|
||||||
var out []string
|
|
||||||
for _, line := range strings.Split(text, "\n") {
|
|
||||||
indent := ""
|
|
||||||
if strings.HasPrefix(line, "• ") {
|
|
||||||
indent = " "
|
|
||||||
}
|
|
||||||
for i, l := range strings.Split(ansi.Wrap(line, max(1, width-len(indent)), " -/"), "\n") {
|
|
||||||
if i > 0 {
|
|
||||||
l = indent + l
|
|
||||||
}
|
|
||||||
out = append(out, l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -177,27 +177,3 @@ func TestHighlightKeepsColours(t *testing.T) {
|
|||||||
t.Error("reverse kept the colours")
|
t.Error("reverse kept the colours")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListHeader(t *testing.T) {
|
|
||||||
l := NewList("t", Item[string]{
|
|
||||||
ID: func(s string) string { return s },
|
|
||||||
Filter: func(s string) string { return s },
|
|
||||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
|
||||||
})
|
|
||||||
l.Header, l.HeaderShort = []string{"ART1", "ART2"}, "short"
|
|
||||||
l.SetItems([]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"})
|
|
||||||
l.SetSize(30, 2+2+1+6) // the art, a blank line and 6 rows
|
|
||||||
st := styles.Default()
|
|
||||||
if v := l.View(st, true, ""); !strings.Contains(v, "ART1") || strings.Contains(v, "short") {
|
|
||||||
t.Errorf("at the top, the art:\n%s", v)
|
|
||||||
}
|
|
||||||
l.Move(6) // below the rows under the art
|
|
||||||
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") || !strings.Contains(v, " g") {
|
|
||||||
t.Errorf("further down, the text:\n%s", v)
|
|
||||||
}
|
|
||||||
l.Top()
|
|
||||||
l.SetSize(30, 8) // too short for the art and 5 rows
|
|
||||||
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") {
|
|
||||||
t.Errorf("no room, the text:\n%s", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package panes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/json"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A revealed value is shown as it reads best: JSON indented, and PEM
|
|
||||||
// certificates as what they say (subject, issuer, dates, names) rather
|
|
||||||
// than base64, with any other PEM block, a private key say, named but not
|
|
||||||
// shown. Anything else is shown as it is. y copies it as stored.
|
|
||||||
|
|
||||||
// valueView is how to show v, and what's been done to it ("" when it's
|
|
||||||
// shown as it is).
|
|
||||||
func valueView(v string) (lines []string, note string) {
|
|
||||||
if lines, ok := pemView(v); ok {
|
|
||||||
return lines, "certificate"
|
|
||||||
}
|
|
||||||
if t := strings.TrimSpace(v); (strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) && json.Valid([]byte(t)) {
|
|
||||||
var b bytes.Buffer
|
|
||||||
if json.Indent(&b, []byte(t), "", " ") == nil {
|
|
||||||
return strings.Split(b.String(), "\n"), "JSON, formatted"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Split(v, "\n"), ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// pemView describes the PEM blocks in v, if it's PEM with a certificate.
|
|
||||||
func pemView(v string) ([]string, bool) {
|
|
||||||
rest := []byte(strings.TrimSpace(v))
|
|
||||||
var out []string
|
|
||||||
certs := 0
|
|
||||||
for len(rest) > 0 {
|
|
||||||
block, after := pem.Decode(rest)
|
|
||||||
if block == nil {
|
|
||||||
return nil, false // text that isn't PEM: show it all
|
|
||||||
}
|
|
||||||
rest = bytes.TrimSpace(after)
|
|
||||||
if len(out) > 0 {
|
|
||||||
out = append(out, "")
|
|
||||||
}
|
|
||||||
if block.Type != "CERTIFICATE" {
|
|
||||||
out = append(out, block.Type+" (not shown)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cert, err := x509.ParseCertificate(block.Bytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
certs++
|
|
||||||
out = append(out, certLines(cert)...)
|
|
||||||
}
|
|
||||||
return out, certs > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func certLines(c *x509.Certificate) []string {
|
|
||||||
day := func(t time.Time) string { return t.UTC().Format("2006-01-02") }
|
|
||||||
out := []string{
|
|
||||||
"subject " + c.Subject.String(),
|
|
||||||
"issuer " + c.Issuer.String(),
|
|
||||||
"valid " + day(c.NotBefore) + " to " + day(c.NotAfter),
|
|
||||||
}
|
|
||||||
names := append([]string{}, c.DNSNames...)
|
|
||||||
for _, ip := range c.IPAddresses {
|
|
||||||
names = append(names, ip.String())
|
|
||||||
}
|
|
||||||
names = append(names, c.EmailAddresses...)
|
|
||||||
if len(names) > 0 {
|
|
||||||
out = append(out, "names "+strings.Join(names, ", "))
|
|
||||||
}
|
|
||||||
sum := sha1.Sum(c.Raw) // what Azure and Windows call the thumbprint
|
|
||||||
return append(out, "SHA-1 "+fmt.Sprintf("%X", sum[:]))
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package panes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/x509"
|
|
||||||
"crypto/x509/pkix"
|
|
||||||
"encoding/pem"
|
|
||||||
"math/big"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValueViewJSON(t *testing.T) {
|
|
||||||
lines, note := valueView(`{"user":"app","port":5432,"tags":["a"]}`)
|
|
||||||
want := []string{"{", ` "user": "app",`, ` "port": 5432,`, ` "tags": [`, ` "a"`, " ]", "}"}
|
|
||||||
if !slices.Equal(lines, want) || note != "JSON, formatted" {
|
|
||||||
t.Errorf("lines %q, note %q", lines, note)
|
|
||||||
}
|
|
||||||
for _, v := range []string{"plain value", "{not json", "42", "line one\nline two"} {
|
|
||||||
if lines, note := valueView(v); note != "" || strings.Join(lines, "\n") != v {
|
|
||||||
t.Errorf("%q shown as %q (%q)", v, lines, note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValueViewCertificate(t *testing.T) {
|
|
||||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
||||||
tmpl := &x509.Certificate{
|
|
||||||
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "app.example.com"},
|
|
||||||
DNSNames: []string{"app.example.com", "www.app.example.com"},
|
|
||||||
NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
}
|
|
||||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
keyDER, _ := x509.MarshalECPrivateKey(key)
|
|
||||||
bundle := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +
|
|
||||||
string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
|
||||||
|
|
||||||
lines, note := valueView(bundle)
|
|
||||||
text := strings.Join(lines, "\n")
|
|
||||||
for _, want := range []string{"subject CN=app.example.com", "valid 2026-01-01 to 2027-01-01",
|
|
||||||
"names app.example.com, www.app.example.com", "SHA-1 ", "EC PRIVATE KEY (not shown)"} {
|
|
||||||
if !strings.Contains(text, want) {
|
|
||||||
t.Errorf("missing %q:\n%s", want, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if note != "certificate" || strings.Contains(text, "BEGIN") {
|
|
||||||
t.Errorf("note %q, or PEM shown:\n%s", note, text)
|
|
||||||
}
|
|
||||||
// A private key alone isn't a certificate: shown as it is.
|
|
||||||
if _, note := valueView(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))); note != "" {
|
|
||||||
t.Errorf("a key alone: note %q", note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
package styles
|
package styles
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
@@ -34,10 +32,6 @@ type Styles struct {
|
|||||||
KindAzure lipgloss.Style
|
KindAzure lipgloss.Style
|
||||||
KindDatabricks lipgloss.Style
|
KindDatabricks lipgloss.Style
|
||||||
KindRepo lipgloss.Style
|
KindRepo lipgloss.Style
|
||||||
|
|
||||||
// themed marks the selection colours as set by the theme, so
|
|
||||||
// OnBackground leaves them.
|
|
||||||
themed, themedBlurred bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Styles {
|
func Default() Styles {
|
||||||
@@ -49,12 +43,9 @@ func Default() Styles {
|
|||||||
TitleBlurred: lipgloss.NewStyle(),
|
TitleBlurred: lipgloss.NewStyle(),
|
||||||
Footer: lipgloss.NewStyle().Faint(true),
|
Footer: lipgloss.NewStyle().Faint(true),
|
||||||
|
|
||||||
// A background rather than reverse, so the row keeps its colours;
|
// A background rather than reverse, so the row keeps its colours.
|
||||||
// in every pane, so each shows what's selected in it. The focused
|
|
||||||
// pane's is bold too. Palette 8 until the terminal says what its
|
|
||||||
// background is (OnBackground).
|
|
||||||
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
|
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
|
||||||
SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack),
|
SelectedBlurred: lipgloss.NewStyle().Bold(true),
|
||||||
Muted: lipgloss.NewStyle().Faint(true),
|
Muted: lipgloss.NewStyle().Faint(true),
|
||||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||||
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||||
@@ -83,23 +74,3 @@ func (s Styles) Kind(k provider.Kind) lipgloss.Style {
|
|||||||
}
|
}
|
||||||
return s.KindAzure
|
return s.KindAzure
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnBackground shades the selection colours from the terminal's
|
|
||||||
// background, a little lighter on a dark one and darker on a light one:
|
|
||||||
// a quiet band that keeps the row's colours readable, where palette 8 is
|
|
||||||
// often too bright. Colours the theme sets are kept.
|
|
||||||
func (s Styles) OnBackground(bg color.Color, dark bool) Styles {
|
|
||||||
shade := func(p float64) color.Color {
|
|
||||||
if dark {
|
|
||||||
return lipgloss.Lighten(bg, p)
|
|
||||||
}
|
|
||||||
return lipgloss.Darken(bg, p)
|
|
||||||
}
|
|
||||||
if !s.themed {
|
|
||||||
s.Selected = s.Selected.Background(shade(0.09))
|
|
||||||
}
|
|
||||||
if !s.themedBlurred {
|
|
||||||
s.SelectedBlurred = s.SelectedBlurred.Background(shade(0.05))
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -45,9 +45,7 @@ var Roles = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FromTheme returns the default styles with the theme's roles applied, and
|
// FromTheme returns the default styles with the theme's roles applied, and
|
||||||
// the default text colour if the theme sets one. Roles it doesn't know are
|
// the default text colour if the theme sets one.
|
||||||
// skipped (see Unknown): a theme file made for a newer lazysecrets still
|
|
||||||
// works.
|
|
||||||
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||||
st := Default()
|
st := Default()
|
||||||
var fg color.Color
|
var fg color.Color
|
||||||
@@ -58,7 +56,7 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
|||||||
sort.Strings(roles)
|
sort.Strings(roles)
|
||||||
for _, role := range roles {
|
for _, role := range roles {
|
||||||
if _, ok := Roles[role]; !ok {
|
if _, ok := Roles[role]; !ok {
|
||||||
continue
|
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
|
||||||
}
|
}
|
||||||
v, err := parse(theme[role])
|
v, err := parse(theme[role])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,10 +70,8 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
|||||||
st.BorderBlurred = v.fg(lipgloss.NewStyle())
|
st.BorderBlurred = v.fg(lipgloss.NewStyle())
|
||||||
case "selectedLineBgColor":
|
case "selectedLineBgColor":
|
||||||
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
|
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||||
st.themed = true
|
|
||||||
case "inactiveViewSelectedLineBgColor":
|
case "inactiveViewSelectedLineBgColor":
|
||||||
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
|
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||||
st.themedBlurred = true
|
|
||||||
case "defaultFgColor":
|
case "defaultFgColor":
|
||||||
fg = v.color
|
fg = v.color
|
||||||
case "optionsTextColor":
|
case "optionsTextColor":
|
||||||
@@ -109,20 +105,13 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
|||||||
return st, fg, nil
|
return st, fg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown says which of the theme's roles aren't known, for a warning, or
|
func sortedRoles() []string {
|
||||||
// is empty.
|
var out []string
|
||||||
func Unknown(theme map[string][]string) string {
|
for r := range Roles {
|
||||||
var unknown []string
|
out = append(out, r)
|
||||||
for r := range theme {
|
|
||||||
if _, ok := Roles[r]; !ok {
|
|
||||||
unknown = append(unknown, r)
|
|
||||||
}
|
}
|
||||||
}
|
slices.Sort(out)
|
||||||
if len(unknown) == 0 {
|
return out
|
||||||
return ""
|
|
||||||
}
|
|
||||||
slices.Sort(unknown)
|
|
||||||
return "theme: skipped unknown " + strings.Join(unknown, ", ") + " (a typo, or from a newer lazysecrets)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// value is a parsed role: a colour (nil for default) and attributes.
|
// value is a parsed role: a colour (nil for default) and attributes.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package styles
|
package styles
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -44,61 +43,22 @@ func TestFromTheme(t *testing.T) {
|
|||||||
|
|
||||||
func TestFromThemeRejectsMistakes(t *testing.T) {
|
func TestFromThemeRejectsMistakes(t *testing.T) {
|
||||||
for name, theme := range map[string]map[string][]string{
|
for name, theme := range map[string]map[string][]string{
|
||||||
|
"role": {"activeBorder": {"red"}},
|
||||||
"colour": {"errorColor": {"redish"}},
|
"colour": {"errorColor": {"redish"}},
|
||||||
"hex": {"errorColor": {"#fff"}},
|
"hex": {"errorColor": {"#fff"}},
|
||||||
"palette": {"errorColor": {"300"}},
|
"palette": {"errorColor": {"300"}},
|
||||||
} {
|
} {
|
||||||
if _, _, err := FromTheme(theme); err == nil {
|
if _, _, err := FromTheme(theme); err == nil {
|
||||||
t.Errorf("%s: no error", name)
|
t.Errorf("%s: no error", name)
|
||||||
|
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
|
||||||
|
t.Errorf("unknown role error should list the known ones: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownRolesSkipped(t *testing.T) {
|
|
||||||
theme := map[string][]string{"activeBorder": {"red"}, "errorColor": {"red"}}
|
|
||||||
if _, _, err := FromTheme(theme); err != nil {
|
|
||||||
t.Errorf("unknown role failed the theme: %v", err)
|
|
||||||
}
|
|
||||||
if w := Unknown(theme); !strings.Contains(w, "unknown activeBorder ") || strings.Contains(w, "errorColor") {
|
|
||||||
t.Errorf("warning = %q", w)
|
|
||||||
}
|
|
||||||
if w := Unknown(map[string][]string{"errorColor": {"red"}}); w != "" {
|
|
||||||
t.Errorf("warning for known roles: %q", w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmptyThemeIsDefault(t *testing.T) {
|
func TestEmptyThemeIsDefault(t *testing.T) {
|
||||||
st, fg, err := FromTheme(nil)
|
st, fg, err := FromTheme(nil)
|
||||||
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
|
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
|
||||||
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
|
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOnBackground(t *testing.T) {
|
|
||||||
hex := func(c any) string {
|
|
||||||
r, g, b, _ := c.(interface{ RGBA() (r, g, b, a uint32) }).RGBA()
|
|
||||||
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
|
||||||
}
|
|
||||||
st := Default().OnBackground(lipgloss.Color("#282c34"), true)
|
|
||||||
if got := hex(st.Selected.GetBackground()); got != "#3e424a" {
|
|
||||||
t.Errorf("dark: selected %s", got)
|
|
||||||
}
|
|
||||||
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
|
|
||||||
t.Errorf("dark: blurred %s", got)
|
|
||||||
}
|
|
||||||
if got := hex(Default().OnBackground(lipgloss.Color("#ffffff"), false).Selected.GetBackground()); got != "#e8e8e8" {
|
|
||||||
t.Errorf("light: selected %s", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
themed, _, err := FromTheme(map[string][]string{"selectedLineBgColor": {"#2d3f76"}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
st = themed.OnBackground(lipgloss.Color("#282c34"), true)
|
|
||||||
if got := hex(st.Selected.GetBackground()); got != "#2d3f76" {
|
|
||||||
t.Errorf("themed: selected %s (want the theme's)", got)
|
|
||||||
}
|
|
||||||
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
|
|
||||||
t.Errorf("themed: blurred %s (not themed: shaded)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+43
-150
@@ -3,9 +3,6 @@
|
|||||||
// against the release's checksums.txt, tried (--version), then renamed
|
// against the release's checksums.txt, tried (--version), then renamed
|
||||||
// over the binary. A running lazysecrets keeps its old binary until it
|
// over the binary. A running lazysecrets keeps its old binary until it
|
||||||
// restarts.
|
// restarts.
|
||||||
//
|
|
||||||
// Releases are published in more than one place; each is tried in turn
|
|
||||||
// (GitHub, then git.kebcloud.no), so one being down doesn't stop updates.
|
|
||||||
package update
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,98 +18,49 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultRepo is lazysecrets' home: its source, docs and releases.
|
// Where releases are published.
|
||||||
// GitHubRepo mirrors it, with the issues and the releases too.
|
|
||||||
const (
|
const (
|
||||||
|
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets"
|
||||||
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
|
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
|
||||||
GitHubRepo = "https://github.com/karleinarb/lazysecrets"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultServers are where releases are looked for, in order.
|
|
||||||
var DefaultServers = []string{GitHubRepo, DefaultRepo}
|
|
||||||
|
|
||||||
// latestTimeout bounds asking one server for the latest release, so a
|
|
||||||
// server that's down leaves time for the next.
|
|
||||||
const latestTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
// maxDownload bounds what's read from the server.
|
// maxDownload bounds what's read from the server.
|
||||||
const maxDownload = 200 << 20
|
const maxDownload = 200 << 20
|
||||||
|
|
||||||
// Release is a published release.
|
// Release is a published release.
|
||||||
type Release struct {
|
type Release struct {
|
||||||
Tag string `json:"tag_name"`
|
Tag string `json:"tag_name"`
|
||||||
// Body is the changelog section, then install notes. From Latest,
|
Body string `json:"body"` // the changelog section, then install notes
|
||||||
// it's the changelogs of the recent releases, each under a "## v…"
|
|
||||||
// heading, so someone a few versions behind sees all they missed.
|
|
||||||
Body string `json:"body"`
|
|
||||||
URL string `json:"html_url"`
|
URL string `json:"html_url"`
|
||||||
Draft bool `json:"draft"`
|
|
||||||
Prerelease bool `json:"prerelease"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes is the changelog since the version current: of each release
|
// Notes is the release's changelog: its body without the install notes
|
||||||
// newer than it (just this one for a dev build), without the install
|
// every release ends with.
|
||||||
// notes every release ends with.
|
func (r Release) Notes() string {
|
||||||
func (r Release) Notes(current string) string {
|
notes, _, _ := strings.Cut(r.Body, "## Install")
|
||||||
body, _, _ := strings.Cut(r.Body, "## Install")
|
return strings.TrimSpace(notes)
|
||||||
var keep, headings []string
|
|
||||||
in := true // before any heading: a single release's notes
|
|
||||||
for _, l := range strings.Split(body, "\n") {
|
|
||||||
if tag, ok := strings.CutPrefix(l, "## "); ok && semver.IsValid(tag) {
|
|
||||||
in = Newer(current, tag) || tag == r.Tag
|
|
||||||
if in {
|
|
||||||
headings = append(headings, l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if in {
|
|
||||||
keep = append(keep, l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notes := strings.Join(keep, "\n")
|
|
||||||
if len(headings) == 1 { // the title names it
|
|
||||||
notes = strings.Replace(notes, headings[0], "", 1)
|
|
||||||
}
|
|
||||||
return PlainLinks(strings.TrimSpace(notes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// recentReleases is how many releases Latest reads the changelogs of.
|
// Client talks to the release server.
|
||||||
const recentReleases = 10
|
|
||||||
|
|
||||||
// PlainLinks shows Markdown links as their text, for a terminal.
|
|
||||||
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
|
|
||||||
|
|
||||||
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
|
|
||||||
// and a terminal shows them as their text.
|
|
||||||
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
|
||||||
|
|
||||||
// Client talks to the release servers.
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
// Servers are the repositories' web addresses, on GitHub or a Gitea
|
API string // the repository's API, DefaultAPI when empty
|
||||||
// or Forgejo server, tried in order; DefaultServers when empty.
|
Repo string // the repository's web address, DefaultRepo when empty
|
||||||
Servers []string
|
|
||||||
HTTP *http.Client
|
HTTP *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) servers() []string {
|
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
|
||||||
if len(c.Servers) > 0 {
|
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
|
||||||
return c.Servers
|
|
||||||
}
|
|
||||||
return DefaultServers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) client() *http.Client {
|
func (c *Client) client() *http.Client {
|
||||||
if c.HTTP != nil {
|
if c.HTTP != nil {
|
||||||
@@ -121,63 +69,27 @@ func (c *Client) client() *http.Client {
|
|||||||
return &http.Client{Timeout: 2 * time.Minute}
|
return &http.Client{Timeout: 2 * time.Minute}
|
||||||
}
|
}
|
||||||
|
|
||||||
// API is a repository's API: GitHub's for github.com, else Gitea's (and
|
func cmpOr(a, b string) string {
|
||||||
// Forgejo's).
|
if a != "" {
|
||||||
func API(repo string) (string, error) {
|
return a
|
||||||
u, err := url.Parse(strings.TrimSuffix(repo, "/"))
|
|
||||||
if err != nil || u.Host == "" || strings.Count(u.Path, "/") != 2 {
|
|
||||||
return "", fmt.Errorf("%q isn't a repository's address, like %s", repo, GitHubRepo)
|
|
||||||
}
|
}
|
||||||
if u.Host == "github.com" {
|
return b
|
||||||
return "https://api.github.com/repos" + u.Path, nil
|
|
||||||
}
|
|
||||||
return u.Scheme + "://" + u.Host + "/api/v1/repos" + u.Path, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latest is the newest release (pre-releases aside), from the first
|
// Latest is the newest release (pre-releases aside).
|
||||||
// server that answers.
|
|
||||||
func (c *Client) Latest(ctx context.Context) (Release, error) {
|
func (c *Client) Latest(ctx context.Context) (Release, error) {
|
||||||
var errs []error
|
b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20)
|
||||||
for _, repo := range c.servers() {
|
if err != nil {
|
||||||
r, err := c.latest(ctx, repo)
|
return Release{}, err
|
||||||
if err == nil {
|
}
|
||||||
|
var r Release
|
||||||
|
if err := json.Unmarshal(b, &r); err != nil {
|
||||||
|
return Release{}, fmt.Errorf("reading the latest release: %w", err)
|
||||||
|
}
|
||||||
|
if !semver.IsValid(r.Tag) {
|
||||||
|
return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag)
|
||||||
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
return Release{}, errors.Join(errs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
|
||||||
api, err := API(repo)
|
|
||||||
if err != nil {
|
|
||||||
return Release{}, err
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
|
||||||
defer cancel()
|
|
||||||
// The recent releases in one request (per_page on GitHub, limit on
|
|
||||||
// Gitea), for their changelogs.
|
|
||||||
b, err := c.get(ctx, fmt.Sprintf("%s/releases?per_page=%d&limit=%[2]d", api, recentReleases), 1<<20)
|
|
||||||
if err != nil {
|
|
||||||
return Release{}, err
|
|
||||||
}
|
|
||||||
var all []Release
|
|
||||||
if err := json.Unmarshal(b, &all); err != nil {
|
|
||||||
return Release{}, fmt.Errorf("reading the releases from %s: %w", repo, err)
|
|
||||||
}
|
|
||||||
all = slices.DeleteFunc(all, func(r Release) bool { return r.Draft || r.Prerelease || !semver.IsValid(r.Tag) })
|
|
||||||
if len(all) == 0 {
|
|
||||||
return Release{}, fmt.Errorf("no releases on %s", repo)
|
|
||||||
}
|
|
||||||
slices.SortFunc(all, func(a, b Release) int { return semver.Compare(b.Tag, a.Tag) })
|
|
||||||
var notes strings.Builder
|
|
||||||
for _, r := range all {
|
|
||||||
section, _, _ := strings.Cut(r.Body, "## Install")
|
|
||||||
fmt.Fprintf(¬es, "## %s\n\n%s\n\n", r.Tag, strings.TrimSpace(section))
|
|
||||||
}
|
|
||||||
latest := all[0]
|
|
||||||
latest.Body = strings.TrimSpace(notes.String())
|
|
||||||
return latest, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Newer reports whether latest is newer than current. A build that isn't
|
// Newer reports whether latest is newer than current. A build that isn't
|
||||||
@@ -227,10 +139,23 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
|||||||
}
|
}
|
||||||
version := strings.TrimPrefix(r.Tag, "v")
|
version := strings.TrimPrefix(r.Tag, "v")
|
||||||
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
||||||
archive, err := c.download(ctx, r.Tag, name+".tar.gz")
|
base := c.repo() + "/releases/download/" + r.Tag + "/"
|
||||||
|
|
||||||
|
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
want, err := checksum(sums, name+".tar.gz")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
archive, err := c.get(ctx, base+name+".tar.gz", maxDownload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if got := sha256.Sum256(archive); hex.EncodeToString(got[:]) != want {
|
||||||
|
return fmt.Errorf("%s.tar.gz doesn't match checksums.txt: damaged, or not the release", name)
|
||||||
|
}
|
||||||
bin, err := extract(archive, path.Join(name, "lazysecrets"))
|
bin, err := extract(archive, path.Join(name, "lazysecrets"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -265,38 +190,6 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// download fetches a release's file from the first server that has it,
|
|
||||||
// checked against that server's checksums.txt. A file that doesn't match
|
|
||||||
// stops there: it's damaged, or not the release.
|
|
||||||
func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error) {
|
|
||||||
var errs []error
|
|
||||||
for _, repo := range c.servers() {
|
|
||||||
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
|
|
||||||
sctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
|
||||||
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
want, err := checksum(sums, file)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("%s: %w", repo, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b, err := c.get(ctx, base+file, maxDownload)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if got := sha256.Sum256(b); hex.EncodeToString(got[:]) != want {
|
|
||||||
return nil, fmt.Errorf("%s from %s doesn't match checksums.txt: damaged, or not the release", file, repo)
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
return nil, errors.Join(errs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
|
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -39,42 +39,11 @@ func TestNewer(t *testing.T) {
|
|||||||
|
|
||||||
func TestNotes(t *testing.T) {
|
func TestNotes(t *testing.T) {
|
||||||
r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"}
|
r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"}
|
||||||
if got := r.Notes("0.1.1"); got != "### Added\n\n- Install with a one-liner (#7)" {
|
if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" {
|
||||||
t.Errorf("notes %q", got)
|
t.Errorf("notes %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latest reads the recent releases, so the notes cover every version
|
|
||||||
// since the one running.
|
|
||||||
func TestNotesSince(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/api/v1/repos/keb/lazysecrets/releases" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprint(w, `[
|
|
||||||
{"tag_name":"v0.4.0-rc1","body":"### Added\n\n- rc","prerelease":true},
|
|
||||||
{"tag_name":"v0.2.0","body":"### Added\n\n- two\n\n## Install\n\ncurl"},
|
|
||||||
{"tag_name":"v0.3.0","body":"### Fixed\n\n- three\n\n## Install\n\ncurl","html_url":"https://example.com/v0.3.0"},
|
|
||||||
{"tag_name":"v0.1.1","body":"### Added\n\n- one"},
|
|
||||||
{"tag_name":"v9.0.0","body":"draft","draft":true}]`)
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
r, err := (&Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}).Latest(context.Background())
|
|
||||||
if err != nil || r.Tag != "v0.3.0" || r.URL != "https://example.com/v0.3.0" {
|
|
||||||
t.Fatalf("latest %+v %v", r, err)
|
|
||||||
}
|
|
||||||
for current, want := range map[string]string{
|
|
||||||
"0.1.1": "## v0.3.0\n\n### Fixed\n\n- three\n\n## v0.2.0\n\n### Added\n\n- two",
|
|
||||||
"0.2.0": "### Fixed\n\n- three",
|
|
||||||
"dev-2ba762c": "### Fixed\n\n- three",
|
|
||||||
} {
|
|
||||||
if got := r.Notes(current); got != want {
|
|
||||||
t.Errorf("notes since %s: %q, want %q", current, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManaged(t *testing.T) {
|
func TestManaged(t *testing.T) {
|
||||||
if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" {
|
if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" {
|
||||||
t.Error("package managers not recognised")
|
t.Error("package managers not recognised")
|
||||||
@@ -101,13 +70,13 @@ func server(t *testing.T, tamper bool) *httptest.Server {
|
|||||||
sum[0] ^= 0xff
|
sum[0] ^= 0xff
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
fmt.Fprint(w, `[{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}]`)
|
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
|
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Write(archive.Bytes())
|
w.Write(archive.Bytes())
|
||||||
})
|
})
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
@@ -120,7 +89,7 @@ func TestInstall(t *testing.T) {
|
|||||||
t.Skip("releases are for macOS and Linux")
|
t.Skip("releases are for macOS and Linux")
|
||||||
}
|
}
|
||||||
srv := server(t, false)
|
srv := server(t, false)
|
||||||
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
|
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
|
||||||
r, err := c.Latest(context.Background())
|
r, err := c.Latest(context.Background())
|
||||||
if err != nil || r.Tag != "v0.2.0" {
|
if err != nil || r.Tag != "v0.2.0" {
|
||||||
t.Fatalf("latest %+v %v", r, err)
|
t.Fatalf("latest %+v %v", r, err)
|
||||||
@@ -138,59 +107,12 @@ func TestInstall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A server that's down, or doesn't have the release, is skipped.
|
|
||||||
func TestFallsBackToTheNextServer(t *testing.T) {
|
|
||||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
|
||||||
t.Skip("releases are for macOS and Linux")
|
|
||||||
}
|
|
||||||
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
http.Error(w, "down", http.StatusBadGateway)
|
|
||||||
}))
|
|
||||||
t.Cleanup(down.Close)
|
|
||||||
srv := server(t, false)
|
|
||||||
c := &Client{Servers: []string{down.URL + "/keb/lazysecrets", srv.URL + "/keb/lazysecrets"}}
|
|
||||||
r, err := c.Latest(context.Background())
|
|
||||||
if err != nil || r.Tag != "v0.2.0" {
|
|
||||||
t.Fatalf("latest %+v %v", r, err)
|
|
||||||
}
|
|
||||||
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
|
||||||
os.WriteFile(exe, []byte("old"), 0o755)
|
|
||||||
if err := c.Install(context.Background(), r, exe); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if b, _ := os.ReadFile(exe); !strings.Contains(string(b), "echo lazysecrets 0.2.0") {
|
|
||||||
t.Errorf("not replaced: %q", b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every server down: both say why.
|
|
||||||
c = &Client{Servers: []string{down.URL + "/a/b", down.URL + "/c/d"}}
|
|
||||||
if _, err := c.Latest(context.Background()); err == nil || strings.Count(err.Error(), "502") != 2 {
|
|
||||||
t.Errorf("all down: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPI(t *testing.T) {
|
|
||||||
for repo, want := range map[string]string{
|
|
||||||
"https://github.com/karleinarb/lazysecrets": "https://api.github.com/repos/karleinarb/lazysecrets",
|
|
||||||
"https://git.kebcloud.no/keb/lazysecrets/": "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets",
|
|
||||||
"https://codeberg.org/someone/lazysecrets": "https://codeberg.org/api/v1/repos/someone/lazysecrets",
|
|
||||||
"https://git.kebcloud.no/keb": "",
|
|
||||||
"git.kebcloud.no/keb/lazysecrets": "",
|
|
||||||
"https://github.com/karleinarb/lazysecrets/x": "",
|
|
||||||
} {
|
|
||||||
got, err := API(repo)
|
|
||||||
if got != want || (err != nil) != (want == "") {
|
|
||||||
t.Errorf("API(%q) = %q, %v", repo, got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInstallRefusesATamperedArchive(t *testing.T) {
|
func TestInstallRefusesATamperedArchive(t *testing.T) {
|
||||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||||
t.Skip("releases are for macOS and Linux")
|
t.Skip("releases are for macOS and Linux")
|
||||||
}
|
}
|
||||||
srv := server(t, true)
|
srv := server(t, true)
|
||||||
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
|
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
|
||||||
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
||||||
os.WriteFile(exe, []byte("old"), 0o755)
|
os.WriteFile(exe, []byte("old"), 0o755)
|
||||||
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
|
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
|
||||||
@@ -201,10 +123,3 @@ func TestInstallRefusesATamperedArchive(t *testing.T) {
|
|||||||
t.Error("replaced anyway")
|
t.Error("replaced anyway")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNotesShowLinksAsText(t *testing.T) {
|
|
||||||
r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"}
|
|
||||||
if got, want := r.Notes("0.1.1"), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
|
|
||||||
t.Errorf("notes %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user