Compare commits
22
Commits
87d98d038d
..
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87141cb53b
|
||
|
|
e9366faa9b | ||
|
|
4377eb98bf | ||
|
|
2044341b20 | ||
|
|
1a5e40a012 | ||
|
|
394dd85105 | ||
|
|
199fa039e4 | ||
|
|
a2ece5bf4f | ||
|
|
6d1c26639e | ||
|
|
d32bb63a9e | ||
|
|
9de519d515 | ||
|
|
548f19b292 | ||
|
|
51beea9d78 | ||
|
|
00924a4a1e | ||
|
|
3013d622e4 | ||
|
|
f19ac7845a | ||
|
|
915d8bf3bd | ||
|
|
392ea41865 | ||
|
|
da890a92cb | ||
|
|
01a51da443 | ||
|
|
595bdaad43 | ||
|
|
edf6e79327 |
No files matched your search
@@ -16,10 +16,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# The runner mounts Go, the module cache and the build cache as
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: staticcheck
|
||||
# Pinned, so a new release can't fail CI on its own.
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 ./...
|
||||
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
|
||||
@@ -4,8 +4,9 @@ name: Release
|
||||
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
|
||||
# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea
|
||||
# release with the archives, checksums and this release's section of the
|
||||
# changelog (git-cliff, cliff.toml). Pushing the same tag again (after
|
||||
# deleting it) replaces the release.
|
||||
# changelog (git-cliff, cliff.toml), then copies it to the GitHub
|
||||
# mirror. Pushing the same tag again (after deleting it) replaces the
|
||||
# release in both places.
|
||||
#
|
||||
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
|
||||
|
||||
@@ -22,12 +23,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout (full history, for the changelog)
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# The runner mounts Go, the module cache and the build cache as
|
||||
@@ -74,10 +75,21 @@ jobs:
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
|
||||
- name: GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: v2.18.3
|
||||
args: release --clean --release-notes ${{ runner.temp }}/notes.md
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Copy the release to GitHub
|
||||
# The same notes and files on the GitHub mirror, which lazysecrets
|
||||
# updates from first (internal/update). GH_RELEASE_TOKEN is a
|
||||
# fine-grained token that can write the mirror's contents; without
|
||||
# it the step does nothing.
|
||||
run: node scripts/github-release.mjs
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
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.
|
||||
@@ -0,0 +1,20 @@
|
||||
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.
|
||||
+8
-1
@@ -2,7 +2,14 @@
|
||||
|
||||
Every release of lazysecrets, newest first.
|
||||
|
||||
## v0.1.0 – 2026-10-10
|
||||
## 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 👋
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
# lazysecrets
|
||||
|
||||
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic lazygit.
|
||||
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic [lazygit](https://github.com/jesseduffield/lazygit).
|
||||
|
||||

|
||||
|
||||
## Main features
|
||||
|
||||
- **Read-only**: lazysecrets never creates, changes or deletes a secret, so
|
||||
it's safe to point at production. That's by design and may stay that way.
|
||||
(`e` opens a repo's SOPS or `.env` file in your editor, or `sops edit`;
|
||||
any change is yours.)
|
||||
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
|
||||
That's by design and may stay that way.
|
||||
- **Zero setup**: uses the logins you already have (`az`, the Databricks
|
||||
CLI, `sops`, `git`). No config file needed.
|
||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||
fzf-style matching (`'exact`, `^prefix`, `suffix$`, `!not`), and jumps
|
||||
straight to the result.
|
||||
- **Share links**: `s` copies a `lazysecrets://` link. A colleague opens it
|
||||
fzf-style matching.
|
||||
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
||||
with their own access, from the terminal or with a click in the browser
|
||||
or chat.
|
||||
or chat. `.env` files can be shared too, as a link encrypted with a
|
||||
secret your team can read.
|
||||
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
||||
- **Careful with values**: values stay hidden until you reveal them, and
|
||||
copied values are cleared from the clipboard after 30 seconds.
|
||||
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
||||
pipes.
|
||||
- **Favourites**: star the secrets you use most and get back to them in one
|
||||
key.
|
||||
- **Favourites**: star the secrets you use most for easy access.
|
||||
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
||||
marked.
|
||||
marked, listed together in the status pane.
|
||||
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||
on a secret.
|
||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
||||
@@ -39,8 +39,9 @@ On macOS or Linux (arm64 or x86_64):
|
||||
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases)
|
||||
into `~/.local/bin` after checking it against the release's checksums.
|
||||
It installs the latest release, from GitHub or else
|
||||
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
|
||||
`~/.local/bin` after checking it against the release's checksums.
|
||||
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
|
||||
somewhere else.
|
||||
|
||||
@@ -60,12 +61,14 @@ lazysecrets # start lazysecrets
|
||||
lazysecrets --demo # test with demo data
|
||||
```
|
||||
|
||||
Pick a source on the left, then a vault, then a secret. The keys you'll
|
||||
Pick a source on the left, then a vault, then a secret. With nothing
|
||||
signed in yet, the right pane says how to get started. The keys you'll
|
||||
use most:
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`/`k`, `h`/`l`, `enter`, `esc` | move, switch panes, drill in, back |
|
||||
| `j`/`k`, `enter`, `esc` | move, drill in, back |
|
||||
| `l` / `h` | the details of what's selected / back |
|
||||
| `/` | filter the list you're in |
|
||||
| `space` | reveal / hide the value |
|
||||
| `y` | copy the value (cleared from the clipboard after 30s) |
|
||||
@@ -96,9 +99,20 @@ lazysecrets never stores, caches or sends your secret values. It keeps the
|
||||
names and metadata of what you've loaded in a cache file readable only by
|
||||
you, to show it at once next time (`cache: false` turns it off). It talks
|
||||
only to the services you use it with, and collects no usage data. The one
|
||||
call of its own is a daily check for a new release on this server, which
|
||||
`updateCheck: false` turns off.
|
||||
call of its own is a daily check for a new release, on GitHub or else
|
||||
git.kebcloud.no, which `updateCheck: false` turns off.
|
||||
|
||||
## Repositories
|
||||
|
||||
The main repo of this codebase is at [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets).
|
||||
The [GitHub mirror](https://github.com/karleinarb/lazysecrets) is for discoverability, issue-tracking and releases.
|
||||
It also makes it possible to give a ⭐ if you like lazysecrets!
|
||||
|
||||
## Contributing
|
||||
|
||||
I don't take pull requests as I don't have time to review and test them.
|
||||
But bug reports and ideas are very welcome!
|
||||
Open an [issue on GitHub](https://github.com/karleinarb/lazysecrets/issues) and I'll have a look.
|
||||
|
||||
## AI disclosure
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// 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
|
||||
+8
-1
@@ -24,7 +24,8 @@ Hello world 👋
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim }}
|
||||
{% 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 %}\n
|
||||
"""
|
||||
@@ -33,6 +34,8 @@ trim = true
|
||||
# Runs on each part (header, every release) separately.
|
||||
postprocessors = [
|
||||
{ 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]
|
||||
@@ -46,6 +49,10 @@ commit_preprocessors = [
|
||||
# A merge commit (not a squash) says "Merge pull request 'feat: …' (#4)
|
||||
# from branch into main": keep the title and number.
|
||||
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
|
||||
# #11 was squash-merged with #10 (the status pane) in it: name both.
|
||||
{ pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" },
|
||||
# #55's title starts with a key, which capitalised reads as another.
|
||||
{ pattern = "^feat: e on the status pane edits the config", replace = "feat: `e` on the status pane edits the config" },
|
||||
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
|
||||
# pr-title.yml); all become "feat: x".
|
||||
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
|
||||
|
||||
+9
-77
@@ -1,10 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
@@ -33,82 +30,17 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
|
||||
return err
|
||||
}
|
||||
|
||||
// value reads what r points at without listing its vault: the vault and
|
||||
// name are enough for Azure, tried with each account (the one that last
|
||||
// read it first); for Databricks the workspace's profile is found by its
|
||||
// host.
|
||||
// value reads what r points at (ref.Read), with the account that last
|
||||
// read it first.
|
||||
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
|
||||
if r.IsSource() || r.IsContainer() {
|
||||
return provider.SecretValue{}, fmt.Errorf("%s points at a %s, not a secret", r, map[bool]string{true: "subscription or workspace", false: "vault or scope"}[r.IsSource()])
|
||||
}
|
||||
if r.Kind != provider.KindAzure && r.Host == "" {
|
||||
return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
|
||||
}
|
||||
v, _, err := readKey(ctx, providers, st, r)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// readKey is value, also returning the version read.
|
||||
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
||||
key := r
|
||||
key.Version = ""
|
||||
node := st.Node(key.String())
|
||||
|
||||
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...)
|
||||
return ref.Read(ctx, providers, r, node.Account, node.Source)
|
||||
}
|
||||
+47
-6
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/instance"
|
||||
@@ -113,10 +114,14 @@ func run() error {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||
lazysecrets [flags] browse secrets
|
||||
lazysecrets [flags] [open] <ref>
|
||||
open a lazysecrets:// link, Key Vault URL,
|
||||
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
||||
open a lazysecrets:// link (also a share link),
|
||||
Key Vault URL, @Microsoft.KeyVault(...) or
|
||||
{{secrets/scope/key}}
|
||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||
for scripts: VAR=$(lazysecrets get <ref>)
|
||||
lazysecrets [flags] share [--key <secret>] <env file>...
|
||||
print a link to env files of a repo, encrypted
|
||||
with a secret's value (default: share.key)
|
||||
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||
config applied), as config to copy and edit
|
||||
lazysecrets handler install [--terminal NAME]
|
||||
@@ -139,8 +144,14 @@ Flags:
|
||||
}
|
||||
|
||||
var open, getRef *ref.Ref
|
||||
var shareArgs []string
|
||||
printKeys := false
|
||||
switch args := flag.Args(); {
|
||||
case len(args) > 0 && args[0] == "share":
|
||||
shareArgs = args[1:]
|
||||
if len(shareArgs) == 0 {
|
||||
shareArgs = []string{} // still sharing: says how
|
||||
}
|
||||
case len(args) == 1 && args[0] == "keybindings":
|
||||
printKeys = true
|
||||
case len(args) == 2 && args[0] == "get":
|
||||
@@ -154,7 +165,7 @@ Flags:
|
||||
case len(args) > 0 && args[0] == "launch":
|
||||
return launch(*configPath, args[1:])
|
||||
case len(args) > 0 && args[0] == "update":
|
||||
return updateCmd(args[1:])
|
||||
return updateCmd(*configPath, args[1:])
|
||||
case len(args) == 0:
|
||||
case args[0] == "open" && len(args) == 2:
|
||||
r, err := ref.Parse(args[1])
|
||||
@@ -242,9 +253,10 @@ Flags:
|
||||
if *demo {
|
||||
// Two Azure accounts, like a personal and an admin login in az.
|
||||
providers = []provider.Provider{
|
||||
fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"),
|
||||
fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"),
|
||||
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
|
||||
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
|
||||
fake.Databricks(600 * time.Millisecond),
|
||||
fake.Repos(),
|
||||
}
|
||||
} else {
|
||||
pc := cfg.Providers
|
||||
@@ -302,6 +314,11 @@ Flags:
|
||||
defer cancel()
|
||||
return get(ctx, providers, st, *getRef)
|
||||
}
|
||||
if shareArgs != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
return shareFiles(ctx, providers, st, cfg.Share.Key, shareRules(cfg), shareArgs, os.Stdout)
|
||||
}
|
||||
|
||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||
var p *tea.Program
|
||||
@@ -362,14 +379,19 @@ Flags:
|
||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||
HideOnBlur: config.On(cfg.HideOnBlur),
|
||||
HandlerSetup: setup,
|
||||
Updater: updater(),
|
||||
Updater: updater(cfg.Releases),
|
||||
UpdateCheck: config.On(cfg.UpdateCheck),
|
||||
Keys: &keymap,
|
||||
Commands: cfg.CustomCommands,
|
||||
Version: versionString(),
|
||||
Store: st,
|
||||
CacheFile: cacheFile,
|
||||
ConfigFiles: configFiles(*configPath),
|
||||
StatePath: statePath,
|
||||
Changelog: lazysecrets.Changelog,
|
||||
Dir: placeDir,
|
||||
ShareKey: cfg.Share.Key,
|
||||
ShareRules: shareRules(cfg),
|
||||
Open: open,
|
||||
Styles: &theme,
|
||||
DefaultFg: defaultFg,
|
||||
@@ -381,3 +403,22 @@ Flags:
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 share.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: share.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 share.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 share.keyRules.
|
||||
func shareRules(cfg config.Config) share.Rules {
|
||||
return share.Rules{MinLength: cfg.Share.KeyRules.MinLength, MinClasses: cfg.Share.KeyRules.MinClasses}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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,14 +7,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// updater is the app's way to find and install releases, replacing the
|
||||
// binary that's running.
|
||||
func updater() *app.Updater {
|
||||
c := &update.Client{}
|
||||
// binary that's running, from the servers (config releases) or the
|
||||
// default ones.
|
||||
func updater(servers []string) *app.Updater {
|
||||
c := &update.Client{Servers: servers}
|
||||
return &app.Updater{
|
||||
Latest: c.Latest,
|
||||
Install: func(ctx context.Context, r update.Release) error {
|
||||
@@ -29,15 +31,19 @@ func updater() *app.Updater {
|
||||
|
||||
// updateCmd is lazysecrets update [--check]: the newer release's
|
||||
// changelog, then (unless --check) install it.
|
||||
func updateCmd(args []string) error {
|
||||
func updateCmd(configPath string, args []string) error {
|
||||
check := len(args) == 1 && args[0] == "--check"
|
||||
if len(args) > 0 && !check {
|
||||
return errors.New("usage: lazysecrets update [--check]")
|
||||
}
|
||||
cfg, err := config.LoadAll(strings.Split(configPath, ","))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := versionString()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
c := &update.Client{}
|
||||
c := &update.Client{Servers: cfg.Releases}
|
||||
r, err := c.Latest(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
+28
-4
@@ -13,8 +13,18 @@ is listed under each, since their permissions can differ.
|
||||
A service principal is used instead when `AZURE_CLIENT_ID`,
|
||||
`AZURE_TENANT_ID` and a secret or certificate are set in the environment.
|
||||
|
||||
If your az login expires mid-session, the status line says so: press `L` to
|
||||
run `az login` without leaving lazysecrets, and everything reloads.
|
||||
On an account's group row, the fourth pane shows who it signs in as: name,
|
||||
user, tenant, object ID, whether the session used MFA, directory roles
|
||||
(built-in ones by name) and its subscriptions, read from az's account list
|
||||
and the token lazysecrets already has: no requests of their own. `y` on
|
||||
the row copies the object ID, the one access policies and role
|
||||
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
|
||||
field, `/` filters them (`/tenant`) and `y` copies the selected one.
|
||||
|
||||
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
|
||||
|
||||
@@ -28,8 +38,10 @@ vault resources (e.g. *Reader*). Key Vault data-plane access is separate
|
||||
| read secret values | *Key Vault Secrets User* |
|
||||
| read certificate details | *Key Vault Certificate User* |
|
||||
|
||||
A missing permission is shown in the pane, including the action that was
|
||||
denied.
|
||||
A missing permission is shown in the pane, shortened to what was denied
|
||||
(the action, or the access policy permission), with what to check: a
|
||||
role or group to activate in PIM, access from the vault's owner, or, when
|
||||
its firewall refused, the network you're on.
|
||||
|
||||
## Vaults you can't discover
|
||||
|
||||
@@ -52,3 +64,15 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane
|
||||
key (PEM public key) or certificate; private key material is never
|
||||
fetched. `v` lists an item's versions. Items expiring within
|
||||
`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.
|
||||
+25
-10
@@ -2,7 +2,9 @@
|
||||
|
||||
Optional, at `~/.config/lazysecrets/config.yaml` (or
|
||||
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
|
||||
it. Unknown keys are an error, so typos don't go unnoticed.
|
||||
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the
|
||||
status pane (`0`) opens it in your editor, as in lazygit (making it if
|
||||
it isn't there yet); changes apply when lazysecrets starts again.
|
||||
|
||||
```yaml
|
||||
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
||||
@@ -15,6 +17,9 @@ loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s
|
||||
sourcesView: grouped # or flat; ` toggles it
|
||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
||||
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
|
||||
releases: # 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
|
||||
history: true # count uses of items for Recent (R)
|
||||
cache: true # keep what was loaded (never values) for the next start
|
||||
providers:
|
||||
@@ -32,9 +37,15 @@ providers:
|
||||
paths: [~/work/infra]
|
||||
include: ["secrets/**"]
|
||||
exclude: ["**/testdata/**"]
|
||||
share: # 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`), the
|
||||
More, each on its own page: [repos](repos.md) (`providers.repos`),
|
||||
[sharing env files](links.md#sharing-env-files) (`share`), the
|
||||
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
||||
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
||||
[custom commands](custom-commands.md) (`customCommands`).
|
||||
@@ -56,7 +67,7 @@ theme:
|
||||
activeBorderColor: ["#ff966c", bold] # focused pane
|
||||
inactiveBorderColor: ["#589ed7"]
|
||||
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
|
||||
inactiveViewSelectedLineBgColor: ["#2f334d"]
|
||||
inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold)
|
||||
defaultFgColor: ["#c8d3f5"]
|
||||
optionsTextColor: ["#82aaff"] # keys and labels
|
||||
searchMatchColor: ["#ffc777", bold]
|
||||
@@ -114,8 +125,9 @@ What lazysecrets has loaded is kept in
|
||||
you: the Sources lists, vaults and scopes, and secrets' names and metadata
|
||||
(tags, dates, references), never values. On the next start they show at
|
||||
once, and anything older than two minutes reloads in the background when
|
||||
you go to it, or when Go to searches it. Revealing and copying always
|
||||
fetch the value live.
|
||||
you go to it, or when Go to searches it. While it does, the pane's
|
||||
bottom border says how old what it shows is (`cached 3h ago`). Revealing
|
||||
and copying always fetch the value live.
|
||||
|
||||
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
|
||||
@@ -124,14 +136,17 @@ are read from disk anyway. Deleting the file is always safe, and
|
||||
|
||||
## Updates
|
||||
|
||||
Once a day (`updateCheck`), in the background, lazysecrets asks the
|
||||
release server for the latest release; within the day it reuses the
|
||||
answer from the state file. Offline, or with the server down, it says
|
||||
nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
|
||||
Once a day (`updateCheck`), in the background, lazysecrets asks for the
|
||||
latest release; within the day it reuses the answer from the state file.
|
||||
Releases are published on GitHub and on git.kebcloud.no, and it asks
|
||||
them in that order, the next when one doesn't answer within 5 seconds;
|
||||
`releases` 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 its changelog.
|
||||
|
||||
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
||||
your OS and CPU, checks it against the release's `checksums.txt`, makes
|
||||
your OS and CPU from the first server that has it, checks it against
|
||||
that server's `checksums.txt`, makes
|
||||
sure it runs, and only then replaces the binary, atomically: a
|
||||
lazysecrets that's running keeps working, and the next start is the new
|
||||
version. A binary a package manager owns (Homebrew, Nix, a distro
|
||||
|
||||
@@ -13,6 +13,10 @@ Secrets in Azure Key Vault-backed scopes are read through Databricks; if
|
||||
Databricks refuses, lazysecrets reads the backing vault directly with your
|
||||
az login (which then needs the [Key Vault roles](azure.md#permissions)).
|
||||
|
||||
When Databricks refuses, the pane says what to check: access to the
|
||||
workspace and READ on the scope, or, when the workspace's IP access list
|
||||
refused, the network you're on.
|
||||
|
||||
Hide profiles you don't want in the [config](config.md):
|
||||
|
||||
```yaml
|
||||
|
||||
+11
-2
@@ -7,6 +7,11 @@ go test ./...
|
||||
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
|
||||
```
|
||||
|
||||
In `--demo`, sharing env files works end to end: `S` → `f` on a demo
|
||||
repo, pick `kv-shared-prod / env-share` (the second account's) as the
|
||||
key, then paste the link back. The demo repos aren't on disk, so opening
|
||||
asks where to save, as for a repo you haven't cloned.
|
||||
|
||||
A stripped binary is about 21 MB instead of 30 MB:
|
||||
|
||||
```sh
|
||||
@@ -56,8 +61,12 @@ from the pull request titles. The tag runs the release workflow
|
||||
(`.gitea/workflows/release.yml`): it tests, then
|
||||
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
|
||||
archives and publishes a Gitea release with them and the release's
|
||||
changelog section. `goreleaser release --snapshot --clean` builds the same
|
||||
archives locally, into `dist/`, without publishing.
|
||||
changelog section. Then `scripts/github-release.mjs` copies that release,
|
||||
notes and files, to the GitHub mirror, with the `GH_RELEASE_TOKEN`
|
||||
secret (a fine-grained token that can write the mirror's contents);
|
||||
without the secret it skips that. `goreleaser release --snapshot
|
||||
--clean` builds the same archives locally, into `dist/`, without
|
||||
publishing.
|
||||
|
||||
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
|
||||
alone.
|
||||
+40
-11
@@ -13,7 +13,8 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
|
||||
|---|---|
|
||||
| `j`/`k`, `↑`/`↓` | move |
|
||||
| `g`/`G` | top / bottom |
|
||||
| `tab`/`l`, `shift+tab`/`h` | next / previous pane |
|
||||
| `tab`, `shift+tab`/`h` | next / previous pane |
|
||||
| `l` | the details of what's selected, on the right (`h` goes back) |
|
||||
| `1`–`4` | jump to pane |
|
||||
| `enter` / `esc`, `⌫` | drill in / back (also clears a filter or leaves the versions view) |
|
||||
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
|
||||
@@ -48,17 +49,22 @@ match case exactly.
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
||||
| `y` | copy the value without revealing it; cleared from the clipboard after 30s |
|
||||
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
|
||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
||||
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
||||
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
||||
| `e` | open the selected SOPS 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) |
|
||||
| `x` | [custom commands](custom-commands.md) |
|
||||
| `L` | sign in again (shown when a login has expired) |
|
||||
| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one |
|
||||
|
||||
A revealed value is shown as it reads best: JSON indented, and PEM
|
||||
certificates as their subject, issuer, dates, names and thumbprint, with
|
||||
any other PEM block (a private key) named but not shown. `y` still copies
|
||||
it exactly as stored.
|
||||
|
||||
A revealed value hides again after `revealTimeout` (60s) and when the
|
||||
terminal loses focus (`hideOnBlur`); see [config](config.md).
|
||||
|
||||
@@ -78,20 +84,24 @@ under every account. Both are kept in the [state file](config.md#state).
|
||||
starred) and `R` on Recent (what you used last). `enter` goes to the
|
||||
selected item.
|
||||
|
||||
Search has the scope on the left (All; all of Azure, Databricks or repos;
|
||||
or one source; with `○ ◐ ●` for how much of it is loaded) and what's in
|
||||
it on the right. All repos searches every repo without asking Azure or
|
||||
Databricks for anything. It opens on the
|
||||
source you're in. Typing a search loads what it covers: everything on
|
||||
All, else the source picked; results appear as lists arrive. That's once
|
||||
each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads
|
||||
again, lists that failed included.
|
||||
Search has the scope on the left and what's in it on the right. The
|
||||
scopes are All, then grouped like the Sources pane: each account (or
|
||||
Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how
|
||||
much of each is loaded. Repos searches every repo without asking Azure
|
||||
or Databricks for anything. Go to opens on All; `ctrl+s` again narrows
|
||||
it to the source or group selected in the Sources pane, and once more
|
||||
goes back to All. Typing a search loads what it covers: everything on
|
||||
All, else the account or source picked; results appear as lists
|
||||
arrive. That's once each time Go to opens, and only what isn't loaded
|
||||
yet; `ctrl+a` loads again, lists that failed included.
|
||||
|
||||
Like a pane, Go to is either browsing or typing a filter:
|
||||
|
||||
| Browsing | Typing | Action |
|
||||
|---|---|---|
|
||||
| `j`/`k` | `ctrl+j`/`ctrl+k` | move |
|
||||
| `g`/`G` | | top / bottom |
|
||||
| `ctrl+s` | `ctrl+s` | only the source or group you're in / All |
|
||||
| `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
|
||||
| `[` / `]` | | previous / next tab |
|
||||
| `f` | `ctrl+f` | star |
|
||||
@@ -105,6 +115,25 @@ anything loads.
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane |
|
||||
|
||||
+82
-8
@@ -1,9 +1,10 @@
|
||||
# Links
|
||||
|
||||
Every subscription, workspace, vault, scope, repo, file and secret has a
|
||||
`lazysecrets://` link. Share one in chat or a ticket, and whoever opens it
|
||||
lands on that item, reading it with their own account and permissions:
|
||||
links carry names, never values.
|
||||
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
|
||||
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
|
||||
it lands on that item, reading it with their own account and
|
||||
permissions: the link names the item, never its value. Env files can
|
||||
also be [shared encrypted](#sharing-env-files), in the link itself.
|
||||
|
||||
```
|
||||
lazysecrets://azure/kv-app-prod.vault.azure.net vault
|
||||
@@ -20,10 +21,83 @@ lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||
that version). `S` opens the share menu:
|
||||
|
||||
- secrets: the link (`o`), the plain Key Vault URL (`u`), the config
|
||||
reference (`y`) or the web page (`b`)
|
||||
reference (`y`) or the web page (`b`); in an env file, also the file as
|
||||
an [encrypted link](#sharing-env-files) (`f`)
|
||||
- vaults and scopes: a link (`o`), a link that opens on the current tab
|
||||
(`t`), the plain vault URL (`u`) or the web page (`b`)
|
||||
- subscriptions and workspaces: a link (`o`) or the web page (`b`)
|
||||
- repos and env files: also their env files as an
|
||||
[encrypted link](#sharing-env-files) (`f`)
|
||||
|
||||
## Sharing env files
|
||||
|
||||
`.env` files usually live only on your machine. `S` → `f` shares them as
|
||||
an encrypted link, on a repo (all its plain env files), an env file, or a
|
||||
key in one (its file):
|
||||
|
||||
```
|
||||
lazysecrets://share/AQ…
|
||||
```
|
||||
|
||||
The files are encrypted with a secret's value, so whoever can read that
|
||||
secret (in Key Vault, Databricks or a repo's SOPS file) can open the link,
|
||||
and nobody else: who may open it is up to the vault's access, as for
|
||||
everything else. Make a secret for it once, random:
|
||||
|
||||
```sh
|
||||
az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl rand -base64 32)"
|
||||
```
|
||||
|
||||
and name it in the repo's `.lazysecrets.yaml` (committed: the whole team
|
||||
uses it) or your config (for repos that don't name one), by its
|
||||
`lazysecrets://` link, Key Vault URL or `@Microsoft.KeyVault(...)`
|
||||
reference:
|
||||
|
||||
```yaml
|
||||
share:
|
||||
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
|
||||
share:
|
||||
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
|
||||
|
||||
@@ -44,9 +118,9 @@ read the clipboard over SSH.
|
||||
|
||||
lazysecrets finds the vault in your accounts (one Resource Graph query per
|
||||
account) and jumps to it, asking which account to use when several can see
|
||||
it. Links say what, not what to do: opening a link to a secret asks whether
|
||||
to just go to it, also copy or show its value, or (`y`) copy its value
|
||||
without going there.
|
||||
it, ending on a secret's details as `enter` on it would. Links say what,
|
||||
not what to do: opening a link to a secret asks whether to just go to it,
|
||||
also copy or show its value, or (`y`) copy its value without going there.
|
||||
|
||||
## In scripts
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ providers:
|
||||
exclude: ["**/testdata/**"]
|
||||
```
|
||||
|
||||
A repo's `.lazysecrets.yaml` can also name the secret its env files are
|
||||
[shared](links.md#sharing-env-files) with:
|
||||
|
||||
```yaml
|
||||
share:
|
||||
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
||||
```
|
||||
|
||||
## Decrypting
|
||||
|
||||
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
||||
@@ -63,6 +71,13 @@ copying or `lazysecrets get` decrypts one value with your `sops` binary, so
|
||||
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
|
||||
KMS, PGP). When sops can't decrypt, its whole error message is shown.
|
||||
|
||||
On a file, the right pane shows how it's encrypted and the keys it's
|
||||
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
|
||||
fingerprints), when it was last encrypted, and what git knows of it:
|
||||
tracked, ignored or neither, and its last commit. A plain `.env` file that
|
||||
git tracks, or that isn't ignored, is marked in red: its values are in the
|
||||
repo's history, or easily could be.
|
||||
|
||||
## Opening files
|
||||
|
||||
`e` opens the selected file, or on a secret, its file at that key's line:
|
||||
@@ -83,6 +98,8 @@ sops 3.8 or later.
|
||||
|
||||
## 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
|
||||
wherever it is:
|
||||
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
module git.kebcloud.no/keb/lazysecrets
|
||||
|
||||
go 1.26.4
|
||||
go 1.27.2
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.2.1
|
||||
charm.land/bubbletea/v2 v2.0.10
|
||||
charm.land/bubbletea/v2 v2.1.0
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1
|
||||
filippo.io/age v1.3.2
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.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 v1.3.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/x/ansi v0.11.8
|
||||
github.com/charmbracelet/x/ansi v0.11.9
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
|
||||
github.com/databricks/databricks-sdk-go v0.187.0
|
||||
github.com/junegunn/fzf v0.74.4
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
golang.org/x/mod v0.38.0
|
||||
golang.org/x/term v0.45.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
golang.org/x/mod v0.42.0
|
||||
golang.org/x/term v0.47.0
|
||||
gopkg.in/ini.v1 v1.67.3
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.4.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
@@ -45,37 +47,35 @@ require (
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.27 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/api v0.182.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.264.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
@@ -1,36 +1,41 @@
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
|
||||
charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=
|
||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go/auth v0.4.2 h1:sb0eyLkhRtpq5jA+a8KWw0W70YcdVca7KJ8TM0AFYDg=
|
||||
cloud.google.com/go/auth v0.4.2/go.mod h1:Kqvlz1cf1sNA0D+sYJnkPQOP+JMHkuHeIgVmCRtZOLc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
|
||||
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
|
||||
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1/go.mod h1:oXtinPO4OLj9d1DOTrqrL1oRwGhcqadvAmrl6wTeGlk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 h1:Terj1CMW4+NY5CpWjSRkqLoxh2DkYDdv/FH2z0897Uc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0/go.mod h1:7Ut2Qw9vcwpLW5Q4MBy43uSxXS7tK2yjs94OJ6j5thE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.2.0 h1:+lnLQhKh3cgSOIOVH61UZ3s/l9d+bAZp5d/spt1+7UI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.2.0/go.mod h1:tStOHrivWUrcBolspvKV70Us1ckESYGYSHdG4LX8zyY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0 h1:4dT2wTR+ViaGvwcAoMjqt0BvgwBkT6nXxg8flJhnLfw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0/go.mod h1:7fgu6AtY2gkWhtZt9SEHK/MkTVUn2HPvAZjv6XsrjuI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0 h1:+1fJwTilk/X7inNqwREnYEOgFCdg8ut7GULxARDbu34=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0/go.mod h1:EGwSLlGqrrfYQhtCi9JcIkPQKl9WxsL6ZPJd+63Vy1A=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v1.0.0 h1:67nFqWXpo0x5Nz0XEb1yI7s8D+EHy8NsTinYw9sZnLk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v1.0.0/go.mod h1:fewgRjNVE84QVVh798sIMFb7gPXPp7NmnekGnboSnXk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 h1:guyQA4b8XB2sbJZXzUnOF9mn0WDBv/ZT7me9wTipKtE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1/go.mod h1:8h8yhzh9o+0HeSIhUxYny+rEQajScrfIpNktvgYG3Q8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0 h1:TPaLKUUZw3XUaQy5aU+ApHXFKB2VnTWUA0xufkvFDE8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0/go.mod h1:Rn/a97OKDAoOGvk5/OFjqObHHqWyoMMVSGtikqbKcAg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0 h1:15MiSirdxWpIKFEHwfwfMWYieETCUnPTERPIlnytQeo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0/go.mod h1:0AAf/fHAqhIsvD6XGKlCsDO8u+cCiYBBuro+lWS6os4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM=
|
||||
@@ -43,20 +48,18 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
@@ -65,20 +68,14 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/databricks/databricks-sdk-go v0.187.0 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8=
|
||||
github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -88,42 +85,21 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
|
||||
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso=
|
||||
github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
|
||||
@@ -136,128 +112,79 @@ github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBql
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
|
||||
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
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/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.182.0 h1:if5fPvudRQ78GeRx3RayIoiuV7modtErPIZC/T2bIvE=
|
||||
google.golang.org/api v0.182.0/go.mod h1:cGhjy4caqA5yXRzEhkHI8Y9mfyC2VLTlER2l08xaqtM=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
|
||||
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
||||
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
+40
-14
@@ -7,10 +7,12 @@
|
||||
#
|
||||
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
|
||||
# and x86_64.
|
||||
#
|
||||
# Releases are on GitHub and git.kebcloud.no, tried in that order;
|
||||
# LAZYSECRETS_RELEASES (repository addresses, space-separated) uses others.
|
||||
set -eu
|
||||
|
||||
repo=https://git.kebcloud.no/keb/lazysecrets
|
||||
api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets
|
||||
repos="${LAZYSECRETS_RELEASES:-https://github.com/karleinarb/lazysecrets https://git.kebcloud.no/keb/lazysecrets}"
|
||||
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
fail() {
|
||||
@@ -18,12 +20,13 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Short timeouts, so a server that's down leaves time for the next.
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
fetch() { curl -fsSL "$1"; }
|
||||
download() { curl -fsSL -o "$2" "$1"; }
|
||||
fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; }
|
||||
download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; }
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
fetch() { wget -qO- "$1"; }
|
||||
download() { wget -qO "$2" "$1"; }
|
||||
fetch() { wget -qO- -T 15 "$1"; }
|
||||
download() { wget -qO "$2" -T 30 "$1"; }
|
||||
else
|
||||
fail "needs curl or wget"
|
||||
fi
|
||||
@@ -39,11 +42,26 @@ arm64 | aarch64) arch=arm64 ;;
|
||||
*) fail "no release for $(uname -m)" ;;
|
||||
esac
|
||||
|
||||
# api is a repository's API: GitHub's, or Gitea's (and Forgejo's).
|
||||
api() {
|
||||
case "$1" in
|
||||
https://github.com/*) echo "https://api.github.com/repos/${1#https://github.com/}" ;;
|
||||
*)
|
||||
host=${1#*://}
|
||||
host=${host%%/*}
|
||||
echo "${1%%://*}://$host/api/v1/repos/${1#*://"$host"/}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
tag="${1:-}"
|
||||
if [ -z "$tag" ]; then
|
||||
tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') ||
|
||||
fail "couldn't look up the latest release"
|
||||
[ -n "$tag" ] || fail "couldn't look up the latest release"
|
||||
for repo in $repos; do
|
||||
tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null |
|
||||
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag=
|
||||
[ -n "$tag" ] && break
|
||||
done
|
||||
[ -n "$tag" ] || fail "couldn't look up the latest release on $repos"
|
||||
fi
|
||||
case "$tag" in
|
||||
v*) ;;
|
||||
@@ -55,11 +73,19 @@ name="lazysecrets_${version}_${os}_${arch}"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
echo "Downloading lazysecrets $tag for $os/$arch"
|
||||
download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" ||
|
||||
fail "couldn't download $tag for $os/$arch: see $repo/releases"
|
||||
download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" ||
|
||||
fail "couldn't download $tag's checksums.txt"
|
||||
# The archive and its checksums.txt from the first server that has both.
|
||||
from=
|
||||
for repo in $repos; do
|
||||
base="$repo/releases/download/$tag"
|
||||
if download "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then
|
||||
echo "Downloading lazysecrets $tag for $os/$arch from $repo"
|
||||
if download "$base/$name.tar.gz" "$tmp/$name.tar.gz"; then
|
||||
from=$repo
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
[ -n "$from" ] || fail "couldn't download $tag for $os/$arch from $repos"
|
||||
|
||||
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"
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -40,6 +42,10 @@ type Config struct {
|
||||
// background; defaults to true. U and lazysecrets update work either
|
||||
// way.
|
||||
UpdateCheck *bool `yaml:"updateCheck"`
|
||||
// Releases 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).
|
||||
Releases []string `yaml:"releases"`
|
||||
// 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"`
|
||||
@@ -49,6 +55,8 @@ type Config struct {
|
||||
Providers Providers `yaml:"providers"`
|
||||
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
||||
LinkHandler LinkHandler `yaml:"linkHandler"`
|
||||
// Share sets how env files are shared; see package share.
|
||||
Share Share `yaml:"share"`
|
||||
// CustomCommands run shell commands on the selected item; see
|
||||
// package commands.
|
||||
CustomCommands []commands.Command `yaml:"customCommands"`
|
||||
@@ -77,6 +85,23 @@ type LinkHandler struct {
|
||||
Prompt *bool `yaml:"prompt"`
|
||||
}
|
||||
|
||||
type Share 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 {
|
||||
Azure Azure `yaml:"azure"`
|
||||
Databricks Databricks `yaml:"databricks"`
|
||||
@@ -211,6 +236,22 @@ func (c Config) validate(path string) error {
|
||||
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, r := range c.Releases {
|
||||
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
|
||||
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
||||
}
|
||||
}
|
||||
if k := c.Share.Key; k != "" {
|
||||
if _, err := share.ParseKey(k); err != nil {
|
||||
return fmt.Errorf("%s: share.key: %w", path, err)
|
||||
}
|
||||
}
|
||||
if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 {
|
||||
return fmt.Errorf("%s: share.keyRules.minLength: %d is under 16", path, n)
|
||||
}
|
||||
if n := c.Share.KeyRules.MinClasses; n < 0 || n > 4 {
|
||||
return fmt.Errorf("%s: share.keyRules.minClasses: %d is not between 1 and 4", path, n)
|
||||
}
|
||||
for i, v := range c.Providers.Azure.Vaults {
|
||||
if !strings.HasPrefix(v, "https://") {
|
||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||
|
||||
@@ -70,6 +70,12 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
||||
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
||||
"duration": "timeout: soon\n",
|
||||
"view": "sourcesView: tree\n",
|
||||
"releases": "releases: [git.example.com/team/lazysecrets]\n",
|
||||
"http": "releases: [http://git.example.com/team/lazysecrets]\n",
|
||||
"share key": "share:\n key: https://kv.vault.azure.net/\n",
|
||||
"workspace": "share:\n key: \"{{secrets/team/env-share}}\"\n",
|
||||
"short keys": "share:\n keyRules:\n minLength: 8\n",
|
||||
"classes": "share:\n keyRules:\n minClasses: 5\n",
|
||||
} {
|
||||
if _, err := Load(write(t, content)); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
@@ -113,6 +119,13 @@ theme:
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleases(t *testing.T) {
|
||||
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n"))
|
||||
if err != nil || len(c.Releases) != 1 {
|
||||
t.Errorf("releases = %v, %v", c.Releases, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDelay(t *testing.T) {
|
||||
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
||||
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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, ", ")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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,7 +19,10 @@ import (
|
||||
// account is one az CLI identity in one tenant.
|
||||
type account struct {
|
||||
user, tenant string
|
||||
userType string // "user" or "servicePrincipal"
|
||||
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
|
||||
anchorEnabled bool
|
||||
isDefault bool
|
||||
@@ -52,12 +55,15 @@ func readAccounts(path string) ([]account, error) {
|
||||
var profile struct {
|
||||
Subscriptions []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantDisplayName"`
|
||||
Cloud string `json:"environmentName"`
|
||||
User struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"user"`
|
||||
} `json:"subscriptions"`
|
||||
}
|
||||
@@ -72,7 +78,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 })
|
||||
if i < 0 {
|
||||
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName})
|
||||
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName, userType: s.User.Type, cloud: s.Cloud})
|
||||
i = len(out) - 1
|
||||
}
|
||||
a := &out[i]
|
||||
@@ -82,12 +88,21 @@ func readAccounts(path string) ([]account, error) {
|
||||
enabled := s.State == "Enabled"
|
||||
switch {
|
||||
case s.IsDefault:
|
||||
a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true
|
||||
a.anchor, a.anchorEnabled, a.isDefault, a.defaultSub = s.ID, enabled, true, s.Name
|
||||
case a.isDefault:
|
||||
case a.anchor == "" || enabled && !a.anchorEnabled:
|
||||
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 {
|
||||
if a.isDefault != b.isDefault {
|
||||
if a.isDefault {
|
||||
@@ -179,6 +194,7 @@ func NewAccounts(s Settings) ([]*Provider, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.account = &accountDetails{account: a, claims: cred.cred.(*cliCredential).claims}
|
||||
if label != "" {
|
||||
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
|
||||
p.label = label
|
||||
|
||||
@@ -181,11 +181,11 @@ func TestTokenUser(t *testing.T) {
|
||||
`{"upn":"alice@contoso.com"}`: false,
|
||||
`{"appid":"00000000-0000-0000-0000-000000000000"}`: false,
|
||||
} {
|
||||
if _, got := tokenUser(jwt(claims), users); got != want {
|
||||
if _, got := tokenUser(tokenClaims(jwt(claims)), users); got != want {
|
||||
t.Errorf("%s: %v", claims, got)
|
||||
}
|
||||
}
|
||||
if _, got := tokenUser("not-a-jwt", users); 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/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
||||
@@ -38,6 +38,7 @@ type Provider struct {
|
||||
|
||||
cred azcore.TokenCredential
|
||||
opts options
|
||||
account *accountDetails // nil for a service principal from AZURE_*
|
||||
subs *armsubscriptions.Client
|
||||
graph *armresourcegraph.Client
|
||||
|
||||
@@ -182,10 +183,6 @@ func (p *Provider) tenant(subscriptionID string) string {
|
||||
return p.tenants[subscriptionID]
|
||||
}
|
||||
|
||||
const vaultQuery = `resources
|
||||
| where type =~ 'microsoft.keyvault/vaults'
|
||||
| project id, name, uri = tostring(properties.vaultUri)`
|
||||
|
||||
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
var out []provider.Container
|
||||
req := armresourcegraph.QueryRequest{
|
||||
@@ -225,7 +222,7 @@ func graphVault(src provider.Source, row any) (provider.Container, bool) {
|
||||
if id == "" || name == "" || uri == "" {
|
||||
return provider.Container{}, false
|
||||
}
|
||||
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true
|
||||
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes, Attrs: graphInfo(m).attrs()}, true
|
||||
}
|
||||
|
||||
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
@@ -246,7 +243,7 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
|
||||
}
|
||||
out = append(out, provider.Container{
|
||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes,
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -302,7 +299,7 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
|
||||
if s.ID == nil || deref(s.Managed) {
|
||||
continue
|
||||
}
|
||||
out = append(out, secretObject(c, s))
|
||||
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
|
||||
}
|
||||
}
|
||||
case provider.TypeKey:
|
||||
@@ -395,7 +392,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
if s.ID != nil {
|
||||
out = append(out, secretObject(o.Container, s))
|
||||
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,8 +425,13 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
||||
default:
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
tenant := p.tenant(o.Container.Source.ID)
|
||||
for i := range out {
|
||||
out[i].Reference = versionedReference(out[i])
|
||||
if o.Type != provider.TypeSecret { // secretObject's is the version's already
|
||||
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type]
|
||||
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
|
||||
return out, nil
|
||||
@@ -456,11 +458,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
}
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
|
||||
v, _, err := p.VersionedValue(ctx, o)
|
||||
return v, err
|
||||
case provider.TypeKey:
|
||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
@@ -484,7 +483,31 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
return provider.SecretValue{}, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
var _ provider.Locator = (*Provider)(nil)
|
||||
// VersionedValue is Value, with the version read.
|
||||
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
|
||||
if o.Type != provider.TypeSecret {
|
||||
v, err := p.Value(ctx, o)
|
||||
return v, o.Version, err
|
||||
}
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", err
|
||||
}
|
||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", cleanError(err)
|
||||
}
|
||||
version := o.Version
|
||||
if resp.ID != nil {
|
||||
version = resp.ID.Version()
|
||||
}
|
||||
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ provider.Locator = (*Provider)(nil)
|
||||
_ provider.Versioned = (*Provider)(nil)
|
||||
)
|
||||
|
||||
// validHost guards the Resource Graph query against odd input.
|
||||
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
@@ -174,8 +176,11 @@ func TestForbiddenIsShortened(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want {
|
||||
t.Errorf("error = %q, want %q", got, want)
|
||||
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\n\nCheck:\n• your role or group is activated in PIM\n• "; !strings.HasPrefix(got, want) {
|
||||
t.Errorf("error = %q, want %q…", got, want)
|
||||
}
|
||||
if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") {
|
||||
t.Errorf("firewall checks = %q", checks)
|
||||
}
|
||||
var re *azcore.ResponseError
|
||||
if !errors.As(err, &re) || re.StatusCode != 403 {
|
||||
@@ -183,6 +188,13 @@ func TestForbiddenIsShortened(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortenPolicyDenied(t *testing.T) {
|
||||
msg := "The user, group or application 'appid=04b07795;oid=1234;numgroups=83;iss=https://sts.windows.net/t/' does not have secrets list permission on key vault 'kv-app;location=norwayeast'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287"
|
||||
if got := shortenDenied(msg); got != "no secrets list permission in the vault's access policies" {
|
||||
t.Errorf("shortenDenied = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortenDenied(t *testing.T) {
|
||||
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
|
||||
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
|
||||
@@ -323,6 +335,21 @@ func TestPortalURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretPages(t *testing.T) {
|
||||
c := provider.Container{Detail: "https://kv-app.vault.azure.net/"}
|
||||
base := "https://portal.azure.com/#@contoso.onmicrosoft.com/asset/Microsoft_Azure_KeyVault/Secret/https://kv-app.vault.azure.net/secrets/db"
|
||||
for id, want := range map[string]string{
|
||||
"https://kv-app.vault.azure.net/secrets/db": base, // listed: the secret's page
|
||||
"https://kv-app.vault.azure.net/secrets/db/06140fa2b9a64869a96ef040f6d4dcab": base + "/06140fa2b9a64869a96ef040f6d4dcab", // a version's
|
||||
} {
|
||||
sid := azsecrets.ID(id)
|
||||
o := secretObject(c, &azsecrets.SecretProperties{ID: &sid}, "contoso.onmicrosoft.com")
|
||||
if o.URL != want {
|
||||
t.Errorf("%s: %q, want %q", id, o.URL, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedReference(t *testing.T) {
|
||||
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
|
||||
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
|
||||
@@ -417,3 +444,49 @@ func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
|
||||
size := (len(b) - 1) / 2
|
||||
return b[1 : 1+size], b[1+size:]
|
||||
}
|
||||
|
||||
func TestVaultSettings(t *testing.T) {
|
||||
want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)",
|
||||
"soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)",
|
||||
"private=1 endpoint", "tags=env: prod", "=team: platform"}
|
||||
check := func(name string, attrs []provider.Attr) {
|
||||
t.Helper()
|
||||
var got []string
|
||||
for _, a := range attrs {
|
||||
got = append(got, a.Label+"="+a.Value)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s\n got %q\nwant %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Resource Graph: the query's columns, as JSON gives them.
|
||||
c, ok := graphVault(provider.Source{ID: "s"}, map[string]any{
|
||||
"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/", "location": "norwayeast",
|
||||
"resourceGroup": "rg-sec", "tags": map[string]any{"team": "platform", "env": "prod"},
|
||||
"sku": "premium", "rbac": true, "softDelete": nil, "retention": 30.0, "purge": true,
|
||||
"publicAccess": "", "defaultAction": "Deny", "ipRules": 1.0, "subnets": 0.0, "endpoints": 1.0, "policies": 0.0,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("row not read")
|
||||
}
|
||||
check("resource graph", c.Attrs)
|
||||
|
||||
// The management API, when Resource Graph fails.
|
||||
id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv"
|
||||
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": id, "name": "kv", "location": "norwayeast",
|
||||
"tags": map[string]any{"team": "platform", "env": "prod"},
|
||||
"properties": map[string]any{
|
||||
"tenantId": "t", "vaultUri": "https://kv.vault.azure.net/", "sku": map[string]any{"family": "A", "name": "premium"},
|
||||
"enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30,
|
||||
"networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}},
|
||||
"privateEndpointConnections": []any{map[string]any{"id": "pe1"}},
|
||||
}}}})
|
||||
})
|
||||
vaults, err := p.armVaults(context.Background(), provider.Source{ID: "s"})
|
||||
if err != nil || len(vaults) != 1 {
|
||||
t.Fatalf("arm: %v %v", vaults, err)
|
||||
}
|
||||
check("management API", vaults[0].Attrs)
|
||||
}
|
||||
@@ -42,6 +42,16 @@ type cliCredential struct {
|
||||
|
||||
mu sync.Mutex
|
||||
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 (c *cliCredential) claims() map[string]any {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.last
|
||||
}
|
||||
|
||||
func newCLICredential(user, tenant string, accounts func() ([]account, error)) *cliCredential {
|
||||
@@ -110,29 +120,38 @@ func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOp
|
||||
if err != nil {
|
||||
return azcore.AccessToken{}, err
|
||||
}
|
||||
if u, ok := tokenUser(tok.Token, others); ok {
|
||||
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
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// tokenUser reports which of users a token was issued to, if any, from
|
||||
// its claims. The token isn't verified: this only catches az picking the
|
||||
// wrong signed-in account.
|
||||
func tokenUser(token string, users []string) (string, bool) {
|
||||
// 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 || len(users) == 0 {
|
||||
return "", false
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
|
||||
if err != nil {
|
||||
return "", false
|
||||
return nil
|
||||
}
|
||||
var claims map[string]any
|
||||
if json.Unmarshal(b, &claims) != nil {
|
||||
return "", false
|
||||
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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
@@ -52,8 +53,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
|
||||
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
|
||||
}
|
||||
|
||||
// portalAssetURL links to a Key Vault object's page; objectID must include
|
||||
// the version.
|
||||
// portalAssetURL links to a Key Vault object's page: a version's with
|
||||
// one in objectID; a secret's own (its versions) without.
|
||||
func portalAssetURL(tenant, kind, objectID string) string {
|
||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
|
||||
}
|
||||
@@ -87,7 +88,10 @@ func objectURL(c provider.Container, collection, name string) string {
|
||||
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
|
||||
}
|
||||
|
||||
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
|
||||
// secretObject is a secret, or a version of one, from a listing. Its URL
|
||||
// is its page in the portal: the secret's (with its versions), or with
|
||||
// a version, that version's.
|
||||
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
|
||||
name := p.ID.Name()
|
||||
url := objectURL(c, "secrets", name)
|
||||
o := provider.Object{
|
||||
@@ -99,7 +103,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.
|
||||
ContentType: deref(p.ContentType),
|
||||
Tags: tags(p.Tags),
|
||||
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
|
||||
URL: ObjectListURL(c, provider.TypeSecret),
|
||||
URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
|
||||
}
|
||||
if a := p.Attributes; a != nil {
|
||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||
@@ -279,13 +283,24 @@ func cleanError(err error) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
||||
se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
||||
if se.status == http.StatusForbidden {
|
||||
return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)}
|
||||
}
|
||||
return se
|
||||
}
|
||||
|
||||
var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`)
|
||||
|
||||
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
|
||||
// sentences plus caller and resource IDs, to its first sentence and the
|
||||
// missing action.
|
||||
func shortenDenied(msg string) string {
|
||||
// Access policies: "The user, group or application 'appid=…;oid=…'
|
||||
// does not have secrets list permission on key vault 'kv;location=…'".
|
||||
if m := policyDenied.FindStringSubmatch(msg); m != nil {
|
||||
return "no " + m[1] + " permission in the vault's access policies"
|
||||
}
|
||||
const marker = "Action: '"
|
||||
i := strings.Index(msg, marker)
|
||||
if i < 0 {
|
||||
@@ -317,4 +332,20 @@ func (e *serviceError) Error() string {
|
||||
return s
|
||||
}
|
||||
|
||||
// deniedChecks are what to check when Key Vault refuses: the network,
|
||||
// for its firewall's refusals, else access.
|
||||
func deniedChecks(msg string) []string {
|
||||
if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") ||
|
||||
strings.Contains(msg, "private link") {
|
||||
return []string{
|
||||
"you're on a network the vault lets in (VPN, office)",
|
||||
"the vault's owner has let your network in",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"your role or group is activated in PIM",
|
||||
"the vault's owner has given you access: a Key Vault role, or its access policies",
|
||||
}
|
||||
}
|
||||
|
||||
func (e *serviceError) Unwrap() error { return e.err }
|
||||
@@ -0,0 +1,158 @@
|
||||
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,15 +317,52 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// deniedChecks are what to check when Databricks refuses: the network,
|
||||
// when the workspace's IP access list refused, else access, and for a
|
||||
// profile signed in through az, which account az is on.
|
||||
func deniedChecks(pr profile, msg string) []string {
|
||||
if strings.Contains(msg, "IP ACL") || strings.Contains(msg, "IP access list") {
|
||||
return []string{
|
||||
"you're on a network the workspace lets in (VPN, office)",
|
||||
"the workspace's admin has let your network in",
|
||||
}
|
||||
}
|
||||
var checks []string
|
||||
if pr.authType == "azure-cli" {
|
||||
checks = append(checks, "az is signed in as the account with access: profile "+pr.name+" uses your az login (az account show)")
|
||||
}
|
||||
return append(checks,
|
||||
"you have access to the workspace",
|
||||
"the scope's manager has given you READ on it, or a group you're in",
|
||||
)
|
||||
}
|
||||
|
||||
var errNoFallback = errors.New("no Key Vault fallback")
|
||||
|
||||
// combine reports both the Databricks and the Key Vault failure. An auth
|
||||
// error from either stays reachable with errors.As.
|
||||
// combine reports both the Databricks and the Key Vault failure, with
|
||||
// both their checks after. An auth error from either stays reachable
|
||||
// with errors.As.
|
||||
func combine(dbxErr, kvErr error) error {
|
||||
if errors.Is(kvErr, errNoFallback) {
|
||||
return dbxErr
|
||||
}
|
||||
return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr)
|
||||
var checks []string
|
||||
strip := func(err error) error {
|
||||
if d, ok := err.(*provider.Denied); ok {
|
||||
for _, c := range d.Checks {
|
||||
if !slices.Contains(checks, c) {
|
||||
checks = append(checks, c)
|
||||
}
|
||||
}
|
||||
return d.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
err := fmt.Errorf("%w; Key Vault fallback: %w", strip(dbxErr), strip(kvErr))
|
||||
if len(checks) > 0 {
|
||||
return &provider.Denied{Err: err, Checks: checks}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// classify turns authentication failures into *provider.AuthError: a 401
|
||||
@@ -336,6 +373,9 @@ func classify(pr profile, err error) error {
|
||||
}
|
||||
var api *apierr.APIError
|
||||
if errors.As(err, &api) {
|
||||
if api.StatusCode == 403 {
|
||||
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
|
||||
}
|
||||
if api.StatusCode != 401 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -140,6 +140,8 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server {
|
||||
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||
map[string]any{"key": "client-secret"},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked":
|
||||
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"})
|
||||
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"key": q.Get("key"),
|
||||
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
|
||||
@@ -291,3 +293,27 @@ func TestClassify(t *testing.T) {
|
||||
t.Error("cancellation classified as auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedSaysWhatToCheck(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||
_, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret)
|
||||
var d *provider.Denied
|
||||
// The test profile signs in through az: that's the first check.
|
||||
if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• az is signed in as the account with access: profile prod uses your az login") ||
|
||||
!strings.Contains(err.Error(), "\n• you have access to the workspace") {
|
||||
t.Errorf("err %q", err)
|
||||
}
|
||||
if got := deniedChecks(profile{authType: "pat"}, "denied"); strings.Contains(got[0], "az") {
|
||||
t.Errorf("a token profile's checks %q", got)
|
||||
}
|
||||
if got := deniedChecks(profile{}, "Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") {
|
||||
t.Errorf("IP ACL checks %q", got)
|
||||
}
|
||||
|
||||
// Both refused: one message, then both lists of checks.
|
||||
err = combine(&provider.Denied{Err: errors.New("dbx"), Checks: []string{"a", "b"}},
|
||||
&provider.Denied{Err: errors.New("kv"), Checks: []string{"b", "c"}})
|
||||
if got := err.Error(); got != "dbx; Key Vault fallback: kv\n\nCheck:\n• a\n• b\n• c" {
|
||||
t.Errorf("combined %q", got)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type Provider struct {
|
||||
errs map[listKey]error
|
||||
values map[string]string // by object ID
|
||||
versions map[string][]provider.Object // by object ID
|
||||
details map[string][]provider.Attr // by container ID
|
||||
}
|
||||
|
||||
type listKey struct {
|
||||
@@ -101,7 +102,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.kind == provider.KindDatabricks {
|
||||
if p.kind != provider.KindAzure { // only Key Vault keeps versions
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
vs := p.versions[o.ID]
|
||||
@@ -133,13 +134,14 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
return provider.NewSecretValue([]byte(v)), nil
|
||||
}
|
||||
|
||||
// Locate finds the source holding the vault (by host) or the workspace.
|
||||
// Locate finds the source holding the vault (by host), the workspace or
|
||||
// the repo (by its remote).
|
||||
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, src := range p.sources {
|
||||
if p.kind == provider.KindDatabricks && src.Detail == host {
|
||||
if (p.kind == provider.KindDatabricks || p.kind == provider.KindRepo) && strings.EqualFold(src.Detail, host) {
|
||||
return src.ID, true, nil
|
||||
}
|
||||
for _, c := range p.containers[src.ID] {
|
||||
@@ -209,7 +211,7 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
|
||||
if p.kind == provider.KindDatabricks {
|
||||
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
|
||||
}
|
||||
if p.kind == provider.KindAzure || vault != detail {
|
||||
if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) {
|
||||
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
|
||||
}
|
||||
p.containers[src.ID] = append(p.containers[src.ID], c)
|
||||
@@ -330,6 +332,10 @@ func Azure(delay time.Duration) *Provider {
|
||||
}, demoCert)
|
||||
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
// A key to share env files with (S → f): random, as share.keyRules wants.
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
|
||||
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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,6 +12,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -93,6 +94,8 @@ type Container struct {
|
||||
// secrets only.
|
||||
Types []ObjectType
|
||||
URL string // web page for the container, if any
|
||||
// Attrs describe it further, from the listing (a vault's settings).
|
||||
Attrs []Attr `json:",omitempty"`
|
||||
}
|
||||
|
||||
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||
@@ -107,6 +110,7 @@ func (c Container) ObjectTypes() []ObjectType {
|
||||
type Attr struct {
|
||||
Label string
|
||||
Value string
|
||||
Warn bool // worth a look (a plain .env tracked by git): shown in red
|
||||
}
|
||||
|
||||
// Object describes a secret, key or certificate without its value.
|
||||
@@ -163,6 +167,13 @@ type Current interface {
|
||||
Current(ctx context.Context) (Source, bool)
|
||||
}
|
||||
|
||||
// Versioned is implemented by providers whose secrets have versions: it
|
||||
// is Value, also returning the version read (the current one when
|
||||
// o.Version is empty), so a link can pin it.
|
||||
type Versioned interface {
|
||||
VersionedValue(ctx context.Context, o Object) (SecretValue, string, error)
|
||||
}
|
||||
|
||||
// Locator is implemented by providers that can find where a container lives,
|
||||
// to open links. address is a vault host (kv.vault.azure.net) or a
|
||||
// workspace host. It returns the ID of the source holding it.
|
||||
@@ -178,9 +189,57 @@ type Files interface {
|
||||
File(c Container, o *Object) (File, error)
|
||||
}
|
||||
|
||||
// Contents is implemented by providers whose containers are files, so
|
||||
// they can be shared (package share): the file as it is.
|
||||
type Contents interface {
|
||||
Contents(ctx context.Context, c Container) ([]byte, error)
|
||||
}
|
||||
|
||||
// File is a container's file, to open.
|
||||
type File struct {
|
||||
Path string
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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
|
||||
}
|
||||
@@ -311,8 +311,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
|
||||
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
|
||||
case isSops:
|
||||
return // a SOPS file where .sops.yaml says SOPS files don't go
|
||||
case isEnv(f):
|
||||
c.Detail = "env · plain text"
|
||||
case IsEnv(f):
|
||||
c.Detail = provider.DetailPlainEnv
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -412,6 +412,15 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
return provider.NewSecretValue(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
// Contents reads the file c is.
|
||||
func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, error) {
|
||||
f, err := p.File(c, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(f.Path)
|
||||
}
|
||||
|
||||
// File is the file c is, and the line o's key is on.
|
||||
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
|
||||
path := c.ID
|
||||
@@ -464,6 +473,16 @@ func (p *Provider) Locate(_ context.Context, host string) (string, bool, error)
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// Root is the root of the git repo dir is in, and the repo's ID: its
|
||||
// remote without scheme or .git (a repo link's host), else the root.
|
||||
func Root(dir string) (root, id string, err error) {
|
||||
root, err = gitRoot(dir)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s isn't in a git repository", dir)
|
||||
}
|
||||
return root, info(root, "").id, nil
|
||||
}
|
||||
|
||||
// --- git ---
|
||||
|
||||
func gitRoot(dir string) (string, error) {
|
||||
@@ -568,6 +587,27 @@ func normalizeRemote(remote string) (string, string) {
|
||||
type lazysecretsFile struct {
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Share struct {
|
||||
// Key is the link to the secret that encrypts the repo's
|
||||
// shared env files (package share).
|
||||
Key string `yaml:"key"`
|
||||
} `yaml:"share"`
|
||||
}
|
||||
|
||||
// ShareKey is the share.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.Share.Key, nil
|
||||
}
|
||||
|
||||
// selector says which files to look at: user (the include/exclude of the
|
||||
@@ -618,7 +658,7 @@ func envFiles(root string, tracked []string) []string {
|
||||
dirs := map[string]bool{".": true}
|
||||
for _, f := range tracked {
|
||||
isTracked[f] = true
|
||||
if isEnv(f) {
|
||||
if IsEnv(f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
|
||||
@@ -632,7 +672,7 @@ func envFiles(root string, tracked []string) []string {
|
||||
}
|
||||
for _, e := range entries {
|
||||
f := path.Join(d, e.Name())
|
||||
if e.Type().IsRegular() && !isTracked[f] && isEnv(f) {
|
||||
if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
@@ -663,10 +703,10 @@ func inPackages(f string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isEnv reports whether a file is named like an env file with real values:
|
||||
// IsEnv reports whether a file is named like an env file with real values:
|
||||
// .env, .env.local, prod.env; not .env.example and the like, nor one in
|
||||
// dependencies.
|
||||
func isEnv(f string) bool {
|
||||
func IsEnv(f string) bool {
|
||||
if inPackages(f) {
|
||||
return false
|
||||
}
|
||||
@@ -816,9 +856,31 @@ func anyMatch(res []*regexp.Regexp, f string) bool {
|
||||
|
||||
type sopsMeta struct {
|
||||
methods []string // age, azure_kv, …
|
||||
recipients []string // "age age1…", "azure_kv https://kv…/keys/sops"
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
// recipient names a key a file is encrypted for, from its sops metadata:
|
||||
// public, never a secret.
|
||||
func recipient(method string, f map[string]string) string {
|
||||
var v string
|
||||
switch method {
|
||||
case "age":
|
||||
v = f["recipient"]
|
||||
case "pgp":
|
||||
v = f["fp"]
|
||||
case "kms":
|
||||
v = f["arn"]
|
||||
case "gcp_kms":
|
||||
v = f["resource_id"]
|
||||
case "azure_kv":
|
||||
v = strings.TrimSuffix(f["vault_url"], "/") + "/keys/" + f["name"]
|
||||
case "hc_vault":
|
||||
v = strings.TrimSuffix(f["vault_address"], "/") + " " + f["engine_path"] + "/" + f["key_name"]
|
||||
}
|
||||
return method + " " + v
|
||||
}
|
||||
|
||||
const maxFileSize = 4 << 20
|
||||
|
||||
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
|
||||
@@ -848,6 +910,15 @@ func readMeta(file string) (sopsMeta, bool) {
|
||||
for _, k := range known {
|
||||
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
|
||||
m.methods = append(m.methods, k)
|
||||
for _, e := range v {
|
||||
fields := map[string]string{}
|
||||
if em, ok := e.(map[string]any); ok {
|
||||
for fk, fv := range em {
|
||||
fields[fk] = fmt.Sprint(fv)
|
||||
}
|
||||
}
|
||||
m.recipients = append(m.recipients, recipient(k, fields))
|
||||
}
|
||||
}
|
||||
}
|
||||
if s, ok := doc.Sops["lastmodified"].(string); ok {
|
||||
@@ -864,6 +935,7 @@ func readMeta(file string) (sopsMeta, bool) {
|
||||
for _, k := range known {
|
||||
if strings.Contains(text, "sops_"+k+"__") {
|
||||
m.methods = append(m.methods, k)
|
||||
m.recipients = append(m.recipients, envRecipients(text, k)...)
|
||||
}
|
||||
}
|
||||
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
|
||||
@@ -881,6 +953,35 @@ func readMeta(file string) (sopsMeta, bool) {
|
||||
return m, true
|
||||
}
|
||||
|
||||
// envRecipients are method's keys in a dotenv or ini sops file, whose
|
||||
// metadata is flattened: sops_age__list_0__map_recipient=age1….
|
||||
func envRecipients(text, method string) []string {
|
||||
prefix := "sops_" + method + "__list_"
|
||||
var order []string
|
||||
keys := map[string]map[string]string{}
|
||||
for line := range strings.Lines(text) {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
rest, found := strings.CutPrefix(strings.TrimSpace(k), prefix)
|
||||
if !ok || !found {
|
||||
continue
|
||||
}
|
||||
n, field, ok := strings.Cut(rest, "__map_")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if keys[n] == nil {
|
||||
keys[n] = map[string]string{}
|
||||
order = append(order, n)
|
||||
}
|
||||
keys[n][field] = strings.TrimSpace(v)
|
||||
}
|
||||
out := make([]string, len(order))
|
||||
for i, n := range order {
|
||||
out[i] = recipient(method, keys[n])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatOf(file string) string {
|
||||
switch strings.ToLower(filepath.Ext(file)) {
|
||||
case ".yaml", ".yml":
|
||||
|
||||
@@ -477,3 +477,47 @@ func TestFile(t *testing.T) {
|
||||
t.Errorf("lines %v\nwant %v", lines, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDetails(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
"prod.env": sopsEnv,
|
||||
"tracked.env": "TOKEN=x\n", // a plain .env in git: worth a look
|
||||
})
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=x\n"), 0o600)
|
||||
os.WriteFile(filepath.Join(root, "local.env"), []byte("TOKEN=x\n"), 0o600)
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
details := func(name string) map[string]provider.Attr {
|
||||
t.Helper()
|
||||
attrs, err := p.Details(ctx, provider.Container{Source: srcs[0], ID: filepath.Join(root, name), Name: name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]provider.Attr{}
|
||||
for _, a := range attrs {
|
||||
out[a.Label] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
d := details("secrets/app.yaml")
|
||||
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
|
||||
!strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" {
|
||||
t.Errorf("sops yaml: %+v", d)
|
||||
}
|
||||
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
|
||||
t.Errorf("sops env: %+v", d)
|
||||
}
|
||||
if d := details("tracked.env"); d["encryption"].Value != "none: plain text" || !d["git"].Warn {
|
||||
t.Errorf("tracked plain env: %+v", d)
|
||||
}
|
||||
if d := details(".env"); d["git"].Value != "ignored" || d["git"].Warn {
|
||||
t.Errorf("ignored env: %+v", d)
|
||||
}
|
||||
if d := details("local.env"); !d["git"].Warn || !strings.Contains(d["git"].Value, "not ignored") {
|
||||
t.Errorf("untracked env: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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
|
||||
}
|
||||
+25
-1
@@ -10,11 +10,15 @@
|
||||
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
|
||||
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
|
||||
// lazysecrets://repo/<repo>[/<file>[/<key>]]
|
||||
// lazysecrets://share/<sealed files>
|
||||
//
|
||||
// A repo is its git remote without scheme or .git
|
||||
// (git.kebcloud.no/keb/infra), the file its path in the repo and the key
|
||||
// its path in the file (db.password); slashes inside them are escaped.
|
||||
//
|
||||
// A share link carries files, encrypted (package share), instead of
|
||||
// naming something.
|
||||
//
|
||||
// Vault hosts always contain dots, so "subscriptions" can't be one.
|
||||
//
|
||||
// Every path segment is percent-encoded, so names may hold any character.
|
||||
@@ -49,10 +53,19 @@ type Ref struct {
|
||||
Type provider.ObjectType
|
||||
Name string // object name; empty for a container link
|
||||
Version string
|
||||
// Share is a share link's sealed files, as in the link; the other
|
||||
// fields are empty. Package share reads it.
|
||||
Share string
|
||||
}
|
||||
|
||||
// IsShare reports whether r is a share link.
|
||||
func (r Ref) IsShare() bool { return r.Share != "" }
|
||||
|
||||
// IsSource reports whether r points at a subscription or workspace.
|
||||
func (r Ref) IsSource() bool {
|
||||
if r.IsShare() {
|
||||
return false
|
||||
}
|
||||
if r.Kind != provider.KindAzure {
|
||||
return r.Scope == ""
|
||||
}
|
||||
@@ -60,7 +73,7 @@ func (r Ref) IsSource() bool {
|
||||
}
|
||||
|
||||
// IsContainer reports whether r points at a vault or scope.
|
||||
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() }
|
||||
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() && !r.IsShare() }
|
||||
|
||||
// ContainerName is the vault name (from its host) or the scope.
|
||||
func (r Ref) ContainerName() string {
|
||||
@@ -111,6 +124,9 @@ func (r Ref) IdentifierURL() string {
|
||||
|
||||
// String is the lazysecrets:// link.
|
||||
func (r Ref) String() string {
|
||||
if r.IsShare() {
|
||||
return Scheme + "://share/" + r.Share
|
||||
}
|
||||
if r.Kind == provider.KindAzure && r.Subscription != "" {
|
||||
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
|
||||
}
|
||||
@@ -208,6 +224,8 @@ var (
|
||||
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
|
||||
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
|
||||
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
|
||||
// a share link's sealed files: unpadded base64url
|
||||
sealed = regexp.MustCompile(`^[A-Za-z0-9_-]{16,}$`)
|
||||
)
|
||||
|
||||
// Parse reads any supported reference.
|
||||
@@ -238,6 +256,12 @@ func parseLink(s string) (Ref, error) {
|
||||
if err != nil {
|
||||
return Ref{}, err
|
||||
}
|
||||
if strings.EqualFold(u.Host, "share") {
|
||||
if len(segs) != 1 || !sealed.MatchString(segs[0]) {
|
||||
return Ref{}, errors.New("share link is cut short or garbled: copy all of it")
|
||||
}
|
||||
return Ref{Share: segs[0]}, nil
|
||||
}
|
||||
var r Ref
|
||||
if len(segs) == 0 || segs[0] == "" {
|
||||
return Ref{}, errors.New("link has no vault or workspace")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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; share.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
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// 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: share.key in the repo's
|
||||
// .lazysecrets.yaml, else general (share.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("share.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
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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("share:\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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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)
|
||||
}
|
||||
+269
-31
@@ -18,13 +18,13 @@ import (
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/clipboard"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
@@ -40,6 +40,9 @@ const (
|
||||
paneContainers
|
||||
paneSecrets
|
||||
paneDetail
|
||||
// paneStatus is on top of the left panes; after the right pane in
|
||||
// the tab order (status.go).
|
||||
paneStatus
|
||||
paneCount
|
||||
)
|
||||
|
||||
@@ -54,6 +57,7 @@ const (
|
||||
slotDescribe // extra metadata for the selected object
|
||||
slotCopy // fetch a value straight to the clipboard
|
||||
slotVersions // count the selected object's versions
|
||||
slotDetails // a file's details
|
||||
slotCount
|
||||
)
|
||||
|
||||
@@ -69,9 +73,20 @@ type Options struct {
|
||||
ClipboardClear time.Duration
|
||||
// OpenURL opens a web page; defaults to the system browser.
|
||||
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
|
||||
// Changelog is CHANGELOG.md, for the status pane.
|
||||
Changelog string
|
||||
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
||||
// run only.
|
||||
CacheFile string
|
||||
// ShareKey is the config's share.key: the secret that encrypts shared
|
||||
// env files, for repos whose .lazysecrets.yaml names none.
|
||||
ShareKey string
|
||||
// ShareRules are what a key's value must be to share with it.
|
||||
ShareRules share.Rules
|
||||
// Dir is the folder lazysecrets runs in: it goes back to where it was
|
||||
// left there, unless it was started on a link. "" doesn't.
|
||||
Dir string
|
||||
@@ -163,12 +178,26 @@ type Model struct {
|
||||
clip Clipboard
|
||||
clipClear time.Duration
|
||||
openURL func(string) error
|
||||
// fields is the fourth pane for anything but a secret; lastLeft is
|
||||
// the left pane it describes while the fourth has focus (fields.go).
|
||||
fields 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
|
||||
// when each provider's Sources list was loaded (persist.go).
|
||||
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
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
@@ -247,6 +276,9 @@ type Model struct {
|
||||
linked []provider.Container // vaults opened from links, this session
|
||||
open *ref.Ref // reference to open at startup
|
||||
|
||||
shareKey string // the config's share.key
|
||||
shareRules share.Rules // and share.keyRules
|
||||
|
||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
||||
dismissedLink string // the last link offered: not offered again
|
||||
clipChecked time.Time
|
||||
@@ -356,6 +388,8 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
getenv: os.Getenv,
|
||||
nvimOpen: nvimOpen,
|
||||
cache: newCache(),
|
||||
details: map[string]detailsState{},
|
||||
fields: panes.NewFields(),
|
||||
}
|
||||
if m.clip == nil {
|
||||
m.clip = clipboard.New()
|
||||
@@ -389,7 +423,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
if opts.Keys != nil {
|
||||
m.keys = *opts.Keys
|
||||
}
|
||||
m.detail.RevealKey, m.detail.VersionsKey = keys.Name(m.keys.Reveal), keys.Name(m.keys.Versions)
|
||||
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.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
|
||||
m.expiryWarning = opts.ExpiryWarning
|
||||
m.handlerSetup = opts.HandlerSetup
|
||||
@@ -430,10 +464,14 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m.spinning = true
|
||||
|
||||
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
||||
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
|
||||
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
|
||||
if m.cachePath != "" {
|
||||
m.loadCacheFile()
|
||||
if len(m.sourceResults) > 0 {
|
||||
m.rebuildSources()
|
||||
m.sources.Loading = true // reloading what the file had
|
||||
m.sources.Age = m.cachedAge(m.oldestSources())
|
||||
}
|
||||
}
|
||||
if m.placeDir != "" && opts.Open == nil {
|
||||
@@ -461,7 +499,7 @@ func (m *Model) setVersions(items []provider.Object) {
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()}
|
||||
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard(), tea.RequestBackgroundColor}
|
||||
if m.cachePath != "" || m.placeDir != "" {
|
||||
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
|
||||
}
|
||||
@@ -486,6 +524,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.BackgroundColorMsg:
|
||||
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
|
||||
return m, nil
|
||||
case tea.WindowSizeMsg:
|
||||
m.w, m.h = msg.Width, msg.Height
|
||||
|
||||
@@ -539,6 +580,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case resolvedMsg:
|
||||
m.handleResolved(msg)
|
||||
|
||||
case shareFilesMsg:
|
||||
cmds = append(cmds, m.handleShareFiles(msg))
|
||||
|
||||
case sealedMsg:
|
||||
cmds = append(cmds, m.handleSealed(msg))
|
||||
|
||||
case shareOpenedMsg:
|
||||
cmds = append(cmds, m.handleShareOpened(msg))
|
||||
|
||||
case tea.PasteMsg:
|
||||
cmds = append(cmds, m.handlePaste(msg))
|
||||
|
||||
@@ -563,6 +613,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case persistedMsg:
|
||||
m.handlePersisted(msg)
|
||||
|
||||
case detailsMsg:
|
||||
if msg.req == m.reqID[slotDetails] {
|
||||
m.done(slotDetails)
|
||||
}
|
||||
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
|
||||
delete(m.details, msg.key) // tried again when shown again
|
||||
} else {
|
||||
m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err}
|
||||
}
|
||||
|
||||
case crawlMsg:
|
||||
cmds = append(cmds, m.handleCrawl(msg))
|
||||
|
||||
@@ -589,6 +649,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.done(slotContainers)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
if src, ok := m.selectedSource(); ok {
|
||||
m.logErrOf(src.Name, msg.err)
|
||||
}
|
||||
delete(m.cache.containers, msg.key)
|
||||
m.containers.SetError(msg.err)
|
||||
} else {
|
||||
@@ -607,18 +670,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.closeVersions()
|
||||
} else if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
if c, ok := m.containers.Selected(); ok {
|
||||
m.logErrOf(c.Name, msg.err)
|
||||
}
|
||||
delete(m.cache.objects, msg.key)
|
||||
m.secrets.SetError(msg.err)
|
||||
} else {
|
||||
if msg.bound != nil {
|
||||
msg.key = m.rebind(*msg.bound)
|
||||
}
|
||||
before := m.cache.objects[msg.key].value
|
||||
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||
m.cacheDirty = true
|
||||
if m.versionsOf != nil {
|
||||
m.setVersions(msg.items)
|
||||
} else {
|
||||
m.secrets.SetItems(m.starredFirst(msg.items))
|
||||
cmds = append(cmds, m.refreshShown(before))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,6 +697,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.done(slotValue)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||
m.detail.SetValueError(msg.err)
|
||||
} else {
|
||||
m.detail.SetValue(msg.value)
|
||||
@@ -655,6 +724,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.done(slotDescribe)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||
m.detail.SetDescribeError(msg.err)
|
||||
} else {
|
||||
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
|
||||
@@ -721,7 +791,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.waiting = 0
|
||||
m.askHandlerSetup()
|
||||
m.keys.Login.SetEnabled(m.auth != nil && len(m.auth.Login) > 0)
|
||||
m.keys.Login.SetEnabled(m.loginFor() != nil)
|
||||
if m.focus != paneDetail {
|
||||
m.lastLeft = m.focus
|
||||
}
|
||||
m.logFlash()
|
||||
cmds = append(cmds, m.syncFields())
|
||||
if m.anyLoading() && !m.spinning {
|
||||
m.spinning = true
|
||||
cmds = append(cmds, m.spin.Tick)
|
||||
@@ -787,9 +862,19 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
case key.Matches(msg, k.Bottom):
|
||||
m.jump(false)
|
||||
case key.Matches(msg, k.NextPane):
|
||||
m.focus = (m.focus + 1) % paneCount
|
||||
// The four main panes, round; the status pane (0) leads into them.
|
||||
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):
|
||||
m.focus = (m.focus + paneCount - 1) % paneCount
|
||||
if m.focus == paneDetail {
|
||||
m.focus = m.lastLeft // back to what it describes
|
||||
} else {
|
||||
m.focus = (m.focus + paneStatus - 1) % paneStatus
|
||||
}
|
||||
case key.Matches(msg, k.Pane1):
|
||||
m.focus = paneSources
|
||||
case key.Matches(msg, k.Pane2):
|
||||
@@ -798,8 +883,18 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
m.focus = paneSecrets
|
||||
case key.Matches(msg, k.Pane4):
|
||||
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():
|
||||
// folded or unfolded a provider group
|
||||
case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "":
|
||||
r, err := ref.Parse(m.fieldLink())
|
||||
if err == nil {
|
||||
f, _ := m.fields.Selected()
|
||||
m.startNav(r, actGo, f.Row)
|
||||
}
|
||||
case key.Matches(msg, k.Enter):
|
||||
if m.focus < paneDetail {
|
||||
m.focus++
|
||||
@@ -855,8 +950,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
case key.Matches(msg, k.Back):
|
||||
if f := m.focusedFilter(); f != nil && f.HasFilter() {
|
||||
f.ClearFilter()
|
||||
} else if m.versionsOf != nil && m.focus >= paneSecrets {
|
||||
} else if m.versionsOf != nil && (m.focus == paneSecrets || m.focus == paneDetail) {
|
||||
m.closeVersions()
|
||||
} else if m.focus == paneStatus {
|
||||
m.focus = paneSources
|
||||
} else if m.focus > paneSources {
|
||||
m.focus--
|
||||
}
|
||||
@@ -881,8 +978,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
case key.Matches(msg, k.Edit):
|
||||
return m.edit(), false
|
||||
case key.Matches(msg, k.Login):
|
||||
if m.auth != nil && len(m.auth.Login) > 0 {
|
||||
c := exec.Command(m.auth.Login[0], m.auth.Login[1:]...)
|
||||
if auth := m.loginFor(); auth != nil {
|
||||
c := exec.Command(auth.Login[0], auth.Login[1:]...)
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg { return loginDoneMsg{err: err} }), false
|
||||
}
|
||||
case key.Matches(msg, k.Refresh):
|
||||
@@ -911,6 +1008,10 @@ func (m *Model) focusedFilter() filterable {
|
||||
return &m.containers
|
||||
case paneSecrets:
|
||||
return &m.secrets
|
||||
case paneDetail:
|
||||
if m.fields.Shown() {
|
||||
return &m.fields
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -925,8 +1026,12 @@ func (m *Model) move(delta int) {
|
||||
case paneSecrets:
|
||||
m.secrets.Move(delta)
|
||||
case paneDetail:
|
||||
if m.fields.Shown() {
|
||||
m.fields.Move(delta)
|
||||
} else {
|
||||
m.detail.Scroll(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) jump(top bool) {
|
||||
@@ -945,6 +1050,10 @@ func (m *Model) jump(top bool) {
|
||||
m.secrets.Top()
|
||||
case m.focus == paneSecrets:
|
||||
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:
|
||||
m.detail.ScrollTop()
|
||||
case m.focus == paneDetail:
|
||||
@@ -977,6 +1086,7 @@ func (m *Model) refresh() tea.Cmd {
|
||||
case paneContainers:
|
||||
if src, ok := m.selectedSource(); ok {
|
||||
m.containers.Refresh()
|
||||
m.details = map[string]detailsState{}
|
||||
return m.loadContainers(src, m.selSource)
|
||||
}
|
||||
case paneSecrets:
|
||||
@@ -1099,6 +1209,7 @@ func (m *Model) showContainers(src provider.Source, key string) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
m.containers.Refresh()
|
||||
m.containers.Age = m.cachedAge(e.at)
|
||||
return m.loadContainers(src, key)
|
||||
}
|
||||
|
||||
@@ -1115,6 +1226,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
m.secrets.Refresh()
|
||||
m.secrets.Age = m.cachedAge(e.at)
|
||||
return m.loadObjects(c, m.objType, key)
|
||||
}
|
||||
|
||||
@@ -1244,6 +1356,26 @@ func (m *Model) detailStale() bool {
|
||||
return hasSecret || shown != m.containers.SelectedID()
|
||||
}
|
||||
|
||||
// cachedAge is how old a list loaded at at is, if it's older than
|
||||
// cacheTTL: the panes say it while it reloads.
|
||||
func (m Model) cachedAge(at time.Time) string {
|
||||
if d := m.now().Sub(at); d >= cacheTTL {
|
||||
return ago(d)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// oldestSources is when the oldest of the cached Sources lists loaded.
|
||||
func (m Model) oldestSources() time.Time {
|
||||
var oldest time.Time
|
||||
for _, at := range m.sourcesAt {
|
||||
if oldest.IsZero() || at.Before(oldest) {
|
||||
oldest = at
|
||||
}
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
|
||||
func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
|
||||
if msg.gen != m.sourcesGen {
|
||||
return nil
|
||||
@@ -1258,10 +1390,18 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
|
||||
for _, p := range m.providers {
|
||||
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
|
||||
m.noteErr(r.err)
|
||||
m.logErrOf(p.Label(), r.err)
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
|
||||
}
|
||||
}
|
||||
m.status = strings.Join(errs, " · ")
|
||||
// Said once, when they change; after that the Details pane says it
|
||||
// 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.sources.Loading = m.sourcesPending > 0
|
||||
return m.autoLoad(msg)
|
||||
@@ -1443,6 +1583,15 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
||||
// copyValue copies the selected object's value, fetching it without
|
||||
// revealing it if it isn't shown already.
|
||||
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()
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -1497,6 +1646,25 @@ func (m *Model) cancelClipClear() {
|
||||
func (m *Model) openSelected() tea.Cmd {
|
||||
var name, url string
|
||||
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 {
|
||||
case paneSources:
|
||||
s, _ := m.selectedSource()
|
||||
@@ -1508,9 +1676,11 @@ func (m *Model) openSelected() tea.Cmd {
|
||||
o, _ := m.detail.Secret()
|
||||
name, url, used = o.Name, o.URL, func() { m.usedObject(o) }
|
||||
}
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
return name, url, used
|
||||
}
|
||||
|
||||
// openPage opens url in the browser, or copies it over SSH.
|
||||
func (m *Model) openPage(name, url string, used func()) tea.Cmd {
|
||||
if url == "" {
|
||||
m.flash = warning("no web page for " + name)
|
||||
return nil
|
||||
@@ -1532,14 +1702,27 @@ func (m *Model) openSelected() tea.Cmd {
|
||||
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) {
|
||||
var auth *provider.AuthError
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return m.sources.Loading || m.containers.Loading || m.secrets.Loading ||
|
||||
m.detail.State() == panes.ValueLoading
|
||||
@@ -1557,19 +1740,10 @@ func (m Model) footerView() string {
|
||||
} else if l != nil && l.Filtering() {
|
||||
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
|
||||
} else {
|
||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}.ShortHelp(), 2)
|
||||
revealed := m.detail.State() == panes.ValueShown
|
||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
|
||||
}
|
||||
var lines []string
|
||||
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() {
|
||||
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))))
|
||||
@@ -1577,12 +1751,6 @@ func (m Model) footerView() string {
|
||||
if m.flash.text != "" {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1624,3 +1792,73 @@ func (m Model) View() tea.View {
|
||||
v.WindowTitle = "lazysecrets"
|
||||
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()
|
||||
}
|
||||
+329
-9
@@ -19,6 +19,7 @@ import (
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -57,7 +58,7 @@ func newDriverOpts(t *testing.T, opts Options, providers ...provider.Provider) *
|
||||
m := New(providers, opts)
|
||||
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.send(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
d.send(tea.WindowSizeMsg{Width: 100, Height: 33}) // 30 for the main panes, 3 for status
|
||||
d.run(m.Init())
|
||||
return d
|
||||
}
|
||||
@@ -185,8 +186,9 @@ func TestIcons(t *testing.T) {
|
||||
if !strings.Contains(screen, azure+" Azure") || !strings.Contains(screen, dbx+" Databricks") {
|
||||
t.Errorf("icons missing:\n%s", screen)
|
||||
}
|
||||
if !strings.Contains(screen, "source "+azure+" Azure / platform-prod") {
|
||||
t.Errorf("detail source line missing icon:\n%s", screen)
|
||||
d.keys("3")
|
||||
if !strings.Contains(d.screen(), "source "+azure+" Azure / platform-prod") {
|
||||
t.Errorf("detail source line missing icon:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +206,7 @@ func TestObjectTypeTabs(t *testing.T) {
|
||||
t.Fatalf("selected = %q", s)
|
||||
}
|
||||
screen := d.screen()
|
||||
for _, want := range []string{"key type RSA 2048", "ops sign, verify", "space to show public key"} {
|
||||
for _, want := range []string{"key type RSA 2048", "ops sign, verify", "space to show the public key"} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Errorf("missing %q:\n%s", want, screen)
|
||||
}
|
||||
@@ -400,6 +402,7 @@ func (describing) Describe(_ context.Context, o provider.Object) (provider.Objec
|
||||
func TestDescribeUpdatesDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
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") {
|
||||
t.Fatalf("describe result not shown:\n%s", d.screen())
|
||||
}
|
||||
@@ -487,10 +490,11 @@ func TestVersions(t *testing.T) {
|
||||
func TestVersionHistoryInDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3") // the fourth pane follows focus: the secret
|
||||
screen := d.screen()
|
||||
for _, want := range []string{
|
||||
"versions 3 · v to browse",
|
||||
"▸ 3f9c2a 2026-09-01 current", // the version shown above
|
||||
" 3f9c2a 2026-09-01 current",
|
||||
" 91d3e0 2026-03-01 expired",
|
||||
" 0a77c4 2025-09-01 disabled",
|
||||
} {
|
||||
@@ -498,6 +502,9 @@ func TestVersionHistoryInDetail(t *testing.T) {
|
||||
t.Errorf("missing %q:\n%s", want, screen)
|
||||
}
|
||||
}
|
||||
if strings.Contains(screen, "▸ 3f9c2a") {
|
||||
t.Errorf("a cursor on a list that can't be moved in:\n%s", screen)
|
||||
}
|
||||
d.keys("3", "j") // api-key: only its current version
|
||||
if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") {
|
||||
t.Errorf("wrong history for api-key:\n%s", d.screen())
|
||||
@@ -594,7 +601,7 @@ func TestPermissionErrorShownInPane(t *testing.T) {
|
||||
if !strings.Contains(d.screen(), "403 Forbidden") {
|
||||
t.Error("error not rendered")
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Select a secret") {
|
||||
if !strings.Contains(d.screen(), "name kv-restricted") {
|
||||
t.Error("detail should show the container summary")
|
||||
}
|
||||
}
|
||||
@@ -754,7 +761,9 @@ func TestFocusKeys(t *testing.T) {
|
||||
}{
|
||||
{"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail},
|
||||
{"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers},
|
||||
{"4", paneDetail}, {"l", paneSources}, {"h", paneDetail},
|
||||
{"4", paneDetail}, {"tab", paneSources}, {"shift+tab", paneDetail},
|
||||
// l: what's selected, on the right; h: back to it.
|
||||
{"2", paneContainers}, {"l", paneDetail}, {"h", paneContainers}, {"h", paneSources}, {"l", paneDetail},
|
||||
} {
|
||||
d.keys(tc.key)
|
||||
if d.m.focus != tc.want {
|
||||
@@ -979,12 +988,17 @@ func TestRebindingShowsEverywhere(t *testing.T) {
|
||||
d := newDriverOpts(t, Options{Keys: &km})
|
||||
d.keys("3")
|
||||
scr := d.screen()
|
||||
if !strings.Contains(scr, "(z to reveal)") || strings.Contains(scr, "space to reveal") {
|
||||
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
|
||||
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
||||
}
|
||||
if !strings.Contains(lastLine(scr), "z reveal/hide") {
|
||||
if !strings.Contains(lastLine(scr), "z show") {
|
||||
t.Errorf("help bar: %q", lastLine(scr))
|
||||
}
|
||||
d.keys("z")
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
|
||||
t.Errorf("help bar with the value shown: %q", bar)
|
||||
}
|
||||
d.keys("z")
|
||||
if strings.Contains(scr, "to browse") {
|
||||
t.Errorf("hint for unbound versions:\n%s", scr)
|
||||
}
|
||||
@@ -1022,3 +1036,309 @@ func TestMultiLineErrorsShownWhole(t *testing.T) {
|
||||
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,8 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -48,3 +52,31 @@ func (c cache) rememberCursor(parent, child string) {
|
||||
c.cursor[parent] = child
|
||||
}
|
||||
}
|
||||
|
||||
// refreshShown shows the selected secret's newer metadata when its list
|
||||
// reloaded with it changed from before (a cached list refreshed in the
|
||||
// background), and describes it afresh: its cached details came from
|
||||
// the old listing.
|
||||
func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
|
||||
s, ok := m.secrets.Selected()
|
||||
if !ok || m.secrets.SelectedID() != m.selSecret {
|
||||
return nil // a new selection is shown as usual
|
||||
}
|
||||
i := slices.IndexFunc(before, func(o provider.Object) bool { return o.ID == s.ID })
|
||||
if i < 0 || sameListing(before[i], s) {
|
||||
return nil
|
||||
}
|
||||
m.detail.Refresh(s)
|
||||
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,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -159,3 +160,33 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) {
|
||||
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadedListRefreshesShownSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, _, clock := newCachingDriver(t)
|
||||
d.keys("3") // kv-app-prod's secrets, db-password selected
|
||||
shown, _ := d.m.detail.Secret()
|
||||
// What an older lazysecrets listed and cached for it.
|
||||
const old = "https://portal.example/old"
|
||||
key := d.m.selContainer
|
||||
e := d.m.cache.objects[key]
|
||||
items := slices.Clone(e.value)
|
||||
for i := range items {
|
||||
if items[i].ID == shown.ID {
|
||||
items[i].URL = old
|
||||
}
|
||||
}
|
||||
d.m.cache.objects[key] = entry[[]provider.Object]{items, e.at}
|
||||
stale := shown
|
||||
stale.URL = old
|
||||
d.m.cache.details[detailKey(shown)] = entry[provider.Object]{stale, *clock}
|
||||
|
||||
// Away and back once the list is stale: it's shown from the cache,
|
||||
// then reloaded, and the details pane follows.
|
||||
d.keys("1", "j")
|
||||
*clock = clock.Add(cacheTTL + time.Second)
|
||||
d.keys("k", "3")
|
||||
if got, _ := d.m.detail.Secret(); got.ID != shown.ID || got.URL == old {
|
||||
t.Errorf("shown %s with %q after the reload", got.Name, got.URL)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ func paneContext(p pane) string {
|
||||
|
||||
// commandsHere are the custom commands for the focused pane.
|
||||
func (m *Model) commandsHere() []commands.Command {
|
||||
if m.focus == paneStatus {
|
||||
return nil
|
||||
}
|
||||
var out []commands.Command
|
||||
for _, c := range m.commands {
|
||||
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
|
||||
|
||||
+76
-24
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -13,9 +14,10 @@ import (
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// Opening a repo's file (e): SOPS files with sops edit, which decrypts
|
||||
// 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
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
type editDoneMsg struct {
|
||||
c provider.Container
|
||||
name string
|
||||
config bool // the config file: read at startup
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -32,9 +35,20 @@ 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
|
||||
@@ -81,30 +95,34 @@ func (m *Model) edit() tea.Cmd {
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(cmd, finished)
|
||||
}
|
||||
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 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.
|
||||
@@ -156,6 +174,10 @@ func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
|
||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if msg.config {
|
||||
m.flash = done("closed " + msg.name + ": restart lazysecrets to use it")
|
||||
return nil
|
||||
}
|
||||
m.flash = done("closed " + msg.name)
|
||||
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
|
||||
m.hideValue()
|
||||
@@ -206,3 +228,33 @@ func nvimOpen(server, path string, line int) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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,8 +421,9 @@ func TestStarIsPerAccount(t *testing.T) {
|
||||
d.keys("n")
|
||||
d.typeText("shared note")
|
||||
d.keys("enter")
|
||||
if n := strings.Count(d.screen(), "shared note"); n != 2 {
|
||||
t.Errorf("note shown %d times, want 2:\n%s", n, d.screen())
|
||||
// On both accounts' rows, and in the selected one's details.
|
||||
if n := strings.Count(d.screen(), "shared note"); n != 3 {
|
||||
t.Errorf("note shown %d times, want 3:\n%s", n, d.screen())
|
||||
}
|
||||
d.keys("F")
|
||||
var labels []string
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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"}
|
||||
}
|
||||
+26
-4
@@ -36,6 +36,12 @@ type contextHelp struct {
|
||||
k keys.Map
|
||||
focus pane
|
||||
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 {
|
||||
@@ -44,13 +50,29 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
||||
if k.Login.Enabled() {
|
||||
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 {
|
||||
case paneSources:
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.SourcesView, k.Filter)
|
||||
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter)
|
||||
case paneContainers:
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter)
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
|
||||
default:
|
||||
b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter)
|
||||
reveal := k.Reveal
|
||||
if c.revealed != nil {
|
||||
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
|
||||
}
|
||||
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
|
||||
}
|
||||
if c.commands {
|
||||
b = append(b, k.Commands)
|
||||
@@ -60,7 +82,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
||||
|
||||
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
|
||||
|
||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail"}
|
||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
||||
|
||||
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, the focused pane's first, filterable by key, description
|
||||
|
||||
+28
-3
@@ -47,6 +47,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
||||
out = append(out, entry{depth, sort, option{
|
||||
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
||||
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
|
||||
plain: m.inPlainEnv(r),
|
||||
run: func(m *Model) tea.Cmd {
|
||||
m.used(key)
|
||||
return run(m)
|
||||
@@ -138,6 +139,30 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
|
||||
return label
|
||||
}
|
||||
|
||||
// inPlainEnv reports whether r is a key in a plain env file, as far as
|
||||
// the files loaded tell.
|
||||
func (m Model) inPlainEnv(r ref.Ref) bool {
|
||||
if r.Kind != provider.KindRepo || r.Name == "" {
|
||||
return false
|
||||
}
|
||||
file := r
|
||||
file.Name, file.Type = "", ""
|
||||
for _, e := range m.cache.containers {
|
||||
for _, c := range e.value {
|
||||
if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
|
||||
// icon and colour, "app / apps/api/.env".
|
||||
func (m Model) repoLabel(repo string, file ...string) string {
|
||||
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "")
|
||||
}
|
||||
|
||||
// sourceMatches reports whether src is the subscription or workspace r
|
||||
// links to.
|
||||
func sourceMatches(r ref.Ref, src provider.Source) bool {
|
||||
@@ -147,8 +172,8 @@ func sourceMatches(r ref.Ref, src provider.Source) bool {
|
||||
return src.ID == r.Subscription
|
||||
}
|
||||
|
||||
// accountOf is the account part of a source's provider label ("dev" in
|
||||
// "Azure · dev"), or "" when there's a single account.
|
||||
// accountOf is the account part of a source's provider label ("alice" in
|
||||
// "Azure · alice"), or "" when there's a single account.
|
||||
func accountOf(src provider.Source) string {
|
||||
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
||||
if !ok {
|
||||
@@ -192,7 +217,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
|
||||
case 1:
|
||||
m.startNav(r, actGo, choices[0].sourceRow)
|
||||
default:
|
||||
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
|
||||
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func (m *Model) resizeFocused(delta int) bool {
|
||||
if m.focus > paneSecrets {
|
||||
return false
|
||||
}
|
||||
avail := m.leftAvail()
|
||||
avail := m.listAvail()
|
||||
h := m.leftHeights(avail)
|
||||
i := int(m.focus)
|
||||
j := i + 1
|
||||
@@ -128,6 +128,9 @@ func (m *Model) saveLayout() {
|
||||
|
||||
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) {
|
||||
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
|
||||
}
|
||||
@@ -138,22 +141,22 @@ func (m *Model) layout() {
|
||||
}
|
||||
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
|
||||
avail := m.leftAvail()
|
||||
leftW, h := m.leftWidth(), m.leftHeights(avail)
|
||||
leftW, h := m.leftWidth(), m.leftHeights(avail-statusH)
|
||||
|
||||
switch m.zoom {
|
||||
case zoomHalf:
|
||||
if m.focus == paneDetail {
|
||||
if m.focus == paneDetail || m.focus == paneStatus {
|
||||
leftW = minLeftW
|
||||
} else {
|
||||
leftW = max(m.leftWidth(), m.w/2)
|
||||
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
|
||||
h[m.focus] = avail
|
||||
h = fitHeights(h, avail, tinyPaneH)
|
||||
h[m.focus] = avail - statusH
|
||||
h = fitHeights(h, avail-statusH, tinyPaneH)
|
||||
}
|
||||
case zoomFull:
|
||||
// Only the focused pane is drawn; it gets the whole screen.
|
||||
switch m.focus {
|
||||
case paneDetail:
|
||||
case paneDetail, paneStatus:
|
||||
leftW = 0
|
||||
default:
|
||||
leftW = m.w
|
||||
@@ -162,10 +165,12 @@ func (m *Model) layout() {
|
||||
}
|
||||
}
|
||||
|
||||
m.statusW = leftW
|
||||
m.sources.SetSize(leftW, h[0])
|
||||
m.containers.SetSize(leftW, h[1])
|
||||
m.secrets.SetSize(leftW, h[2])
|
||||
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
|
||||
@@ -175,15 +180,23 @@ func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred }
|
||||
|
||||
func (m Model) panesView() string {
|
||||
spin := m.spin.View()
|
||||
views := [paneCount]func() string{
|
||||
views := [paneStatus]func() string{
|
||||
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.secrets.View(m.st, m.active(paneSecrets), spin) },
|
||||
func() string { return m.detail.View(m.st, m.active(paneDetail), spin) },
|
||||
func() string {
|
||||
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.focus == paneStatus {
|
||||
return views[paneDetail]() // its rows
|
||||
}
|
||||
return views[m.focus]()
|
||||
}
|
||||
left := lipgloss.JoinVertical(lipgloss.Left, views[0](), views[1](), views[2]())
|
||||
left := lipgloss.JoinVertical(lipgloss.Left, m.statusView(), views[0](), views[1](), views[2]())
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
|
||||
}
|
||||
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
|
||||
func TestContextHelpBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share…") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space reveal") {
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
||||
t.Errorf("Sources help bar: %q", bar)
|
||||
}
|
||||
d.keys("3")
|
||||
if bar := 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)
|
||||
}
|
||||
}
|
||||
@@ -258,14 +258,13 @@ func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionBottomRight(t *testing.T) {
|
||||
func TestVersionInStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0"})
|
||||
bar := lastLine(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 !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
||||
t.Errorf("no version in the status pane:\n%s", d.screen())
|
||||
}
|
||||
if !strings.Contains(bar, "? help • q quit") {
|
||||
t.Errorf("help squeezed out by the version: %q", bar)
|
||||
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
||||
t.Errorf("help line %q", bar)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ type nav struct {
|
||||
// quiet gives up without a word: going back to where lazysecrets
|
||||
// was left, which may be gone.
|
||||
quiet bool
|
||||
// link ends on the object's details, as enter on it would: opening
|
||||
// a link to it opens it, rather than just finding it in its list.
|
||||
link bool
|
||||
}
|
||||
|
||||
type resolvedMsg struct {
|
||||
@@ -78,6 +81,9 @@ const (
|
||||
// just open it.
|
||||
// With ask false, vault and scope links open straight away.
|
||||
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
|
||||
if r.IsShare() {
|
||||
return m.offerShare(r)
|
||||
}
|
||||
if r.IsContainer() || r.IsSource() {
|
||||
if !ask {
|
||||
return m.openRef(r, actGo)
|
||||
@@ -398,23 +404,30 @@ func (m *Model) handleResolved(msg resolvedMsg) {
|
||||
for i := range msg.choices {
|
||||
msg.choices[i].label = "Open in " + msg.choices[i].label
|
||||
}
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices)
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true)
|
||||
case len(msg.others) > 0:
|
||||
var choices []choice
|
||||
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices)
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
|
||||
case msg.linked != nil:
|
||||
m.addLinked(*msg.linked)
|
||||
m.startNav(msg.ref, msg.act, linksRowID)
|
||||
m.startLinkNav(msg.ref, msg.act, linksRowID)
|
||||
default:
|
||||
m.startNav(msg.ref, msg.act, msg.sourceRow)
|
||||
m.startLinkNav(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.
|
||||
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) {
|
||||
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice, link bool) {
|
||||
var opts []option
|
||||
for i, c := range choices {
|
||||
key := ""
|
||||
@@ -424,6 +437,7 @@ func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []
|
||||
row := c.sourceRow
|
||||
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
||||
m.startNav(r, act, row)
|
||||
m.nav.link = link
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
@@ -574,6 +588,9 @@ func (m *Model) advanceNav() bool {
|
||||
m.pendingCmd = m.toggleReveal()
|
||||
}
|
||||
}
|
||||
if n.link {
|
||||
m.focus = paneDetail // after: copying acts on the focused pane
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -607,6 +624,9 @@ func (m *Model) share(menu bool) tea.Cmd {
|
||||
if s.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
|
||||
}
|
||||
if m.canShareFiles(s) {
|
||||
opts = append(opts, option{key: "f", label: "Encrypted link to its env files", run: func(m *Model) tea.Cmd { return m.shareFiles(s, nil) }})
|
||||
}
|
||||
m.showMenu("Share "+s.Name, nil, opts)
|
||||
case paneContainers:
|
||||
c, ok := m.containers.Selected()
|
||||
@@ -629,6 +649,11 @@ func (m *Model) share(menu bool) tea.Cmd {
|
||||
if c.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
|
||||
}
|
||||
if c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
|
||||
opts = append(opts, option{key: "f", label: "Encrypted link to the file", run: func(m *Model) tea.Cmd {
|
||||
return m.shareFiles(c.Source, []provider.Container{c})
|
||||
}})
|
||||
}
|
||||
m.showMenu("Share "+c.Name, nil, opts)
|
||||
default:
|
||||
o, ok := m.detail.Secret()
|
||||
@@ -653,6 +678,11 @@ func (m *Model) share(menu bool) tea.Cmd {
|
||||
if o.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
|
||||
}
|
||||
if c := o.Container; c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
|
||||
opts = append(opts, option{key: "f", label: "Encrypted link to its file", run: func(m *Model) tea.Cmd {
|
||||
return m.shareFiles(c.Source, []provider.Container{c})
|
||||
}})
|
||||
}
|
||||
m.showMenu("Share "+o.Name, nil, opts)
|
||||
}
|
||||
return nil
|
||||
@@ -802,6 +832,9 @@ func (m *Model) openText(s string) tea.Cmd {
|
||||
|
||||
// handlePaste opens a pasted reference, unless a text input has focus.
|
||||
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
||||
if m.search != nil && m.search.picker != nil {
|
||||
return m.pickPasted(msg.Content) // a reference to the secret
|
||||
}
|
||||
if p := m.prompt; p != nil && p.input != nil {
|
||||
in, cmd := p.input.Update(msg)
|
||||
p.input = &in
|
||||
|
||||
@@ -40,7 +40,8 @@ func TestOpenSecretLink(t *testing.T) {
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Fatalf("opened %s/%s/%s", src, c, s)
|
||||
}
|
||||
if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden {
|
||||
// Opened: on its details, as enter on it would; its value hidden.
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
@@ -40,6 +42,7 @@ type option struct {
|
||||
group string // the source row it's in (search)
|
||||
kind provider.Kind // the kind of provider it's from (search)
|
||||
hits []int // characters of the label the filter matched (Go to)
|
||||
plain bool // a key in a plain env file: no key to encrypt with (Go to)
|
||||
run func(m *Model) tea.Cmd
|
||||
// header makes this a section title: not selectable, and hidden
|
||||
// while a filter narrows the list.
|
||||
@@ -53,9 +56,13 @@ type modal struct {
|
||||
options []option
|
||||
cursor int
|
||||
|
||||
// A text input instead of options; submit runs on enter.
|
||||
// A text input instead of options; submit runs on enter (named
|
||||
// submitLabel in the footer; "open" if empty). err says why the last
|
||||
// one wasn't taken, under the input.
|
||||
input *textinput.Model
|
||||
submit func(m *Model, text string) tea.Cmd
|
||||
submitLabel string
|
||||
err string
|
||||
|
||||
// A filter over the options: typing narrows them (all holds them
|
||||
// all), so moving uses arrows or ctrl+j/k.
|
||||
@@ -259,7 +266,10 @@ func (m Model) modalView() string {
|
||||
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
|
||||
if p.input != nil {
|
||||
body = append(body, " "+p.input.View())
|
||||
foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel"))
|
||||
if p.err != "" {
|
||||
body = append(body, "", " "+m.st.Error.Render("✗ "+capitalize(p.err)))
|
||||
}
|
||||
foot = footer(hint(km.PopupSelect, cmp.Or(p.submitLabel, "open")), hint(km.PopupClose, "cancel"))
|
||||
}
|
||||
|
||||
// Size from all options, not the ones shown or the cursor's, so the
|
||||
@@ -406,3 +416,9 @@ func (m Model) overlay(base string) string {
|
||||
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
||||
).Render()
|
||||
}
|
||||
|
||||
// capitalize upper-cases the first letter of a message.
|
||||
func capitalize(s string) string {
|
||||
r, n := utf8.DecodeRuneInString(s)
|
||||
return string(unicode.ToUpper(r)) + s[n:]
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestNotesOnRows(t *testing.T) {
|
||||
d.note("main prod")
|
||||
|
||||
screen := d.screen()
|
||||
for _, want := range []string{"stripe live", "app secrets", "main prod", "note stripe live"} {
|
||||
for _, want := range []string{"stripe live", "app secrets", "main prod", "note main prod"} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Errorf("missing %q:\n%s", want, screen)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package app
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -244,6 +243,6 @@ func (m *Model) restorePlace() {
|
||||
|
||||
func (m *Model) handlePersisted(msg persistedMsg) {
|
||||
if msg.err != nil {
|
||||
m.noteErr(fmt.Errorf("saving the cache or place: %w", msg.err))
|
||||
m.logErrOf("saving the cache or place", msg.err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
|
||||
// Next run, with a backend too slow to answer during the test: the
|
||||
// lists show from the file, and the cursor is where it was.
|
||||
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
|
||||
if strings.Contains(d2.screen(), "cached") {
|
||||
t.Errorf("a fresh cache said its age:\n%s", d2.screen())
|
||||
}
|
||||
d2.keys("2")
|
||||
if _, v, _ := d2.selected(); v != vault {
|
||||
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
|
||||
@@ -49,6 +52,29 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Lists from an older cache say how old they are while they reload.
|
||||
func TestCacheFileSaysItsAge(t *testing.T) {
|
||||
t.Parallel()
|
||||
file := filepath.Join(t.TempDir(), "cache.json")
|
||||
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
|
||||
d.keys("3")
|
||||
at := time.Now().Add(-3 * time.Hour)
|
||||
for k, e := range d.m.cache.containers {
|
||||
d.m.cache.containers[k] = entry[[]provider.Container]{e.value, at}
|
||||
}
|
||||
for id := range d.m.sourcesAt {
|
||||
d.m.sourcesAt[id] = at
|
||||
}
|
||||
if err := d.m.Persist(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
|
||||
d2.keys("2")
|
||||
if n := strings.Count(d2.screen(), "cached 3h ago"); n != 2 {
|
||||
t.Errorf("Sources and Vaults should say they're from 3h ago (%d do):\n%s", n, d2.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
file := filepath.Join(t.TempDir(), "cache.json")
|
||||
|
||||
@@ -206,9 +206,9 @@ func TestSearchAllRepos(t *testing.T) {
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
for d.m.search.scope().id != kindScope+string(provider.KindRepo) {
|
||||
for d.m.search.scope().id != accountScope+"Repos" {
|
||||
if d.m.search.src == len(d.m.search.scopes)-1 {
|
||||
t.Fatalf("no All repos scope: %v", d.m.search.scopes)
|
||||
t.Fatalf("no Repos scope: %v", d.m.search.scopes)
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func TestSearchAllRepos(t *testing.T) {
|
||||
if azure() != azureBefore || d.m.crawl.running {
|
||||
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Results in all repos") {
|
||||
if !strings.Contains(d.screen(), "Results in Repos") {
|
||||
t.Errorf("title:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -342,3 +342,53 @@ func TestEditorAtLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "lazysecrets", "config.yaml")
|
||||
var opened []string
|
||||
d := newDriverOpts(t, Options{ConfigFiles: []string{path}, OpenURL: func(u string) error { opened = append(opened, u); return nil }})
|
||||
d.m.getenv = func(string) string { return "" }
|
||||
|
||||
// e on the status pane. No editor: the system's default app, on a
|
||||
// config made for it.
|
||||
d.keys("0", "e")
|
||||
if !slices.Equal(opened, []string{path}) {
|
||||
t.Errorf("opened %q", opened)
|
||||
}
|
||||
if b, err := os.ReadFile(path); err != nil || !strings.HasPrefix(string(b), "# lazysecrets config") {
|
||||
t.Errorf("config made: %q, %v", b, err)
|
||||
}
|
||||
|
||||
// Inside nvim: in that nvim; an existing config is left as it is.
|
||||
os.WriteFile(path, []byte("icons: false\n"), 0o644)
|
||||
d.m.getenv = func(k string) string { return map[string]string{"NVIM": "/tmp/nvim.sock"}[k] }
|
||||
var nvim []string
|
||||
d.m.nvimOpen = func(server, path string, line int) error {
|
||||
nvim = append(nvim, server+" "+path)
|
||||
return nil
|
||||
}
|
||||
if msg := d.m.editConfig()(); msg != (tea.QuitMsg{}) {
|
||||
t.Errorf("didn't quit for nvim: %v", msg)
|
||||
}
|
||||
if want := []string{"/tmp/nvim.sock " + path}; !slices.Equal(nvim, want) {
|
||||
t.Errorf("nvim got %q", nvim)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != "icons: false\n" {
|
||||
t.Errorf("config changed: %q", b)
|
||||
}
|
||||
|
||||
d.send(editDoneMsg{name: "config.yaml", config: true})
|
||||
if !strings.Contains(d.m.flash.text, "restart") {
|
||||
t.Errorf("flash %q", d.m.flash.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFooterEditsConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("0")
|
||||
if !strings.Contains(d.screen(), "e edit config") {
|
||||
t.Errorf("no edit config in the footer:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
+24
-10
@@ -61,13 +61,14 @@ func (m *Model) searchEntries() (sources, items []option) {
|
||||
var es []entry
|
||||
// key is the store key, so ctrl+f stars the entry; mark shows how
|
||||
// much is loaded, reload (ctrl+r) loads it again.
|
||||
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) {
|
||||
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) *option {
|
||||
n := m.store.Node(key)
|
||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||
es = append(es, entry{depth, sort,
|
||||
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
||||
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
||||
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
||||
return &es[len(es)-1].opt
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
@@ -98,10 +99,11 @@ func (m *Model) searchEntries() (sources, items []option) {
|
||||
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
||||
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
||||
}
|
||||
add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
|
||||
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
|
||||
m.startNav(r, actGo, row)
|
||||
return nil
|
||||
}, reload)
|
||||
e.plain = c.Detail == provider.DetailPlainEnv
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,13 +133,25 @@ func (c coverage) complete() bool {
|
||||
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
||||
}
|
||||
|
||||
func (m *Model) searchCoverage() coverage { return m.coverageOf("") }
|
||||
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
|
||||
|
||||
// coverageOf is searchCoverage for one kind of provider ("" for all).
|
||||
func (m *Model) coverageOf(kind provider.Kind) coverage {
|
||||
// providerFilter picks the providers a search or a load covers; nil is
|
||||
// all of them.
|
||||
type providerFilter func(provider.Provider) bool
|
||||
|
||||
func ofKind(k provider.Kind) providerFilter {
|
||||
return func(p provider.Provider) bool { return p.Kind() == k }
|
||||
}
|
||||
|
||||
func ofProvider(id string) providerFilter {
|
||||
return func(p provider.Provider) bool { return p.ID() == id }
|
||||
}
|
||||
|
||||
// coverageOf is searchCoverage for the providers in.
|
||||
func (m *Model) coverageOf(in providerFilter) coverage {
|
||||
var c coverage
|
||||
for _, p := range m.providers {
|
||||
if kind != "" && p.Kind() != kind {
|
||||
if in != nil && !in(p) {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
@@ -197,12 +211,12 @@ func plural(n int, word string) string {
|
||||
|
||||
// startCrawl loads every unlisted subscription's vaults and every vault's
|
||||
// secrets not loaded yet.
|
||||
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") }
|
||||
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
|
||||
|
||||
// startCrawlOf is startCrawl for one kind of provider ("" for all).
|
||||
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd {
|
||||
// startCrawlOf is startCrawl for the providers in.
|
||||
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
||||
for _, p := range m.providers {
|
||||
if kind != "" && p.Kind() != kind {
|
||||
if in != nil && !in(p) {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
|
||||
@@ -42,11 +42,15 @@ func TestSearchLoaded(t *testing.T) {
|
||||
t.Fatalf("missing %q:\n%s", want, d.screen())
|
||||
}
|
||||
}
|
||||
d.typeText("api-key")
|
||||
if !strings.Contains(d.screen(), "no matches here · All searches everything") {
|
||||
d.typeText("api-key") // on All: found at once
|
||||
if !strings.Contains(d.screen(), "ctrl+s only platform-dev") {
|
||||
t.Errorf("no hint to narrow to the source:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrl('s'))
|
||||
if !strings.Contains(d.screen(), "no matches here · All searches everything") || !strings.Contains(d.screen(), "ctrl+s searches all") {
|
||||
t.Errorf("no hint to search everything:\n%s", d.screen())
|
||||
}
|
||||
d.searchAll()
|
||||
d.send(ctrl('s'))
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
|
||||
t.Fatalf("loaded secret not found:\n%s", d.screen())
|
||||
}
|
||||
@@ -62,7 +66,8 @@ func TestSearchLoaded(t *testing.T) {
|
||||
func TestSearchLoadsAsYouType(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch() // on the source the cursor is on: platform-prod
|
||||
d.openSearch()
|
||||
d.send(ctrl('s')) // only the source the cursor is on: platform-prod
|
||||
// Typing loads the scope: that source, without ctrl+a.
|
||||
d.typeText("smtp")
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
|
||||
@@ -76,10 +81,7 @@ func TestSearchLoadsAsYouType(t *testing.T) {
|
||||
}
|
||||
|
||||
// On All: everything.
|
||||
d.keys("tab") // to the scopes
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
d.send(ctrl('s'))
|
||||
d.typeText("-")
|
||||
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
|
||||
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
|
||||
@@ -183,19 +185,61 @@ func TestSearchStartsWhereYouAre(t *testing.T) {
|
||||
d.keys("2", "j") // platform-prod / kv-shared-prod
|
||||
d.openSearch()
|
||||
v := d.m.search
|
||||
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") {
|
||||
t.Errorf("source not preselected: %q", got)
|
||||
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
|
||||
if scope() != "All" {
|
||||
t.Errorf("opened on %q, want All", scope())
|
||||
}
|
||||
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
|
||||
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrl('s')) // narrows to the source, and back
|
||||
if !strings.Contains(scope(), "platform-prod") {
|
||||
t.Errorf("ctrl+s narrowed to %q", scope())
|
||||
}
|
||||
d.send(ctrl('s'))
|
||||
if scope() != "All" {
|
||||
t.Errorf("second ctrl+s on %q, want All", scope())
|
||||
}
|
||||
|
||||
// From the Sources pane: just the source.
|
||||
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev
|
||||
// From Favourites: to Search, on All.
|
||||
d.keys("esc", "]", "]") // stop typing; Favourites
|
||||
d.send(ctrl('s'))
|
||||
if v.tab != tabSearch || scope() != "All" {
|
||||
t.Errorf("ctrl+s from another tab: tab %d, %q", v.tab, scope())
|
||||
}
|
||||
|
||||
// From the Sources pane: the source.
|
||||
d.keys("esc", "1", "j") // close; platform-dev
|
||||
d.openSearch()
|
||||
v = d.m.search
|
||||
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") {
|
||||
t.Errorf("source not preselected: %q", got)
|
||||
d.send(ctrl('s'))
|
||||
if !strings.Contains(scope(), "platform-dev") {
|
||||
t.Errorf("ctrl+s narrowed to %q", scope())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchNarrowsToAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("1", "g") // the first group header
|
||||
header, _ := d.m.sources.Selected()
|
||||
d.openSearch()
|
||||
d.send(ctrl('s'))
|
||||
v := d.m.search
|
||||
if v.scope().id != accountScope+header.provider {
|
||||
t.Fatalf("ctrl+s on %q narrowed to %q", header.label, v.scope().id)
|
||||
}
|
||||
if len(v.shown) == 0 || !strings.Contains(d.screen(), "Results in "+header.label) {
|
||||
t.Errorf("no results in the account:\n%s", d.screen())
|
||||
}
|
||||
for _, o := range v.shown {
|
||||
if providerOf(o.group) != header.provider {
|
||||
t.Errorf("result from another account: %q", ansiStrip(o.label))
|
||||
}
|
||||
}
|
||||
// The account's sources are under it, indented.
|
||||
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
|
||||
t.Errorf("after the account: %q", ansiStrip(next.label))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +283,24 @@ func TestGoToTabsAndModes(t *testing.T) {
|
||||
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
|
||||
}
|
||||
|
||||
// g and G: top and bottom of the list with the cursor.
|
||||
d.keys("G")
|
||||
if v.res != len(v.shown)-1 || v.res == 0 {
|
||||
t.Errorf("G on results: %d of %d", v.res, len(v.shown))
|
||||
}
|
||||
d.keys("g")
|
||||
if v.res != 0 {
|
||||
t.Errorf("g on results: %d", v.res)
|
||||
}
|
||||
d.keys("h", "G")
|
||||
if v.src != len(v.scopes)-1 {
|
||||
t.Errorf("G on scopes: %d of %d", v.src, len(v.scopes))
|
||||
}
|
||||
d.keys("g", "l")
|
||||
if v.src != 0 {
|
||||
t.Errorf("g on scopes: %d", v.src)
|
||||
}
|
||||
|
||||
d.keys("/")
|
||||
d.typeText("shared")
|
||||
if !v.typing || len(v.shown) == 0 {
|
||||
|
||||
+245
-73
@@ -14,22 +14,24 @@ import (
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// Go to is one popup for finding a place and going there. Tabs at the
|
||||
// top: Favourites and Recent (from the store, so they work before
|
||||
// anything loads) are one list each; Search has two lists side by side,
|
||||
// the scope on the left (All, or one source with how much of it is
|
||||
// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right.
|
||||
// top: Search has two lists side by side, the scope on the left (All, or
|
||||
// one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it
|
||||
// on the right; Favourites and Recent (from the store, so they work
|
||||
// before anything loads) are one list each.
|
||||
//
|
||||
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
|
||||
// star, / to type) or typing a filter (ctrl+ letters). esc while typing
|
||||
// clears the text and goes back to browsing; esc while browsing closes.
|
||||
// F opens it on Favourites and R on Recent, browsing; search on Search,
|
||||
// typing.
|
||||
// typing, on All. search again narrows to the source you were in, and
|
||||
// back to All.
|
||||
//
|
||||
// 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
|
||||
@@ -39,7 +41,7 @@ import (
|
||||
type searchView struct {
|
||||
input textinput.Model
|
||||
typing bool // the text field has the keyboard
|
||||
tab int // tabFavs, tabRecent or tabSearch
|
||||
tab int // tabSearch, tabFavs or tabRecent
|
||||
results bool // in Search, the results have the cursor (else the scopes)
|
||||
|
||||
scopes []option // All, then the sources (Search)
|
||||
@@ -49,6 +51,10 @@ type searchView struct {
|
||||
shown []option // the tab's (or scope's) entries matching the input
|
||||
src int // cursor in scopes
|
||||
res int // cursor in shown
|
||||
here string // the scope of the source Go to was opened from, if any
|
||||
|
||||
// picker, if set, makes Go to pick a secret instead of going to it.
|
||||
picker *picker
|
||||
|
||||
// loaded are the scopes a search has loaded since the popup opened:
|
||||
// typing loads the scope once, so lists that fail aren't asked for
|
||||
@@ -57,25 +63,25 @@ type searchView struct {
|
||||
}
|
||||
|
||||
const (
|
||||
tabFavs = iota
|
||||
tabSearch = iota
|
||||
tabFavs
|
||||
tabRecent
|
||||
tabSearch
|
||||
tabCount
|
||||
)
|
||||
|
||||
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"}
|
||||
var tabNames = [tabCount]string{"Search", "Favourites", "Recent"}
|
||||
|
||||
const (
|
||||
scopeFavs = "#favs"
|
||||
scopeRecent = "#recent"
|
||||
allSources = "#all"
|
||||
kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos
|
||||
accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos
|
||||
)
|
||||
|
||||
const goToTitle = "Go to"
|
||||
|
||||
// showSearch opens Go to on Search, typing, in the source you're in
|
||||
// (else All), with the selected vault under the cursor.
|
||||
// showSearch opens Go to on Search, typing, on All, with the selected
|
||||
// vault under the cursor.
|
||||
func (m *Model) showSearch() tea.Cmd {
|
||||
cmd := m.openGoTo(tabSearch, true)
|
||||
m.preselect()
|
||||
@@ -131,17 +137,20 @@ func (v *searchView) scope() option {
|
||||
// onSource reports whether the results come from one source.
|
||||
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
|
||||
|
||||
// preselect starts where the user is: the selected source picked on the
|
||||
// left, and the selected vault or scope under the cursor on the right.
|
||||
// preselect starts where the user is: the selected source is the one
|
||||
// search narrows to, and the selected vault or scope is under the cursor.
|
||||
func (m *Model) preselect() {
|
||||
v := m.search
|
||||
row := m.sources.SelectedID()
|
||||
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row })
|
||||
account := ""
|
||||
if id, ok := strings.CutPrefix(row, "group:"); ok { // a group header
|
||||
account = accountScope + id
|
||||
}
|
||||
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == account || o.group != "" && o.group == row })
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
v.src = i
|
||||
m.fillSearch(false)
|
||||
v.here = v.scopes[i].id
|
||||
if m.focus == paneSources {
|
||||
return
|
||||
}
|
||||
@@ -171,25 +180,36 @@ func (m *Model) fillSearch(keep bool) {
|
||||
}
|
||||
|
||||
sources, items := m.searchEntries()
|
||||
all := option{label: "All", id: allSources, mark: m.coverageMark("")}
|
||||
scopes := []option{all}
|
||||
// All of one kind, so a search can stay on repos (local, quick)
|
||||
// without asking Azure for every vault; only with several kinds.
|
||||
var kinds []provider.Kind
|
||||
all := option{label: "All", id: allSources, mark: m.coverageMark(nil)}
|
||||
// Grouped like the Sources pane: each account (or Databricks, or the
|
||||
// repos) is a scope too, its sources under it, so a search can stay on
|
||||
// one, like the repos (local, quick), without asking Azure for every
|
||||
// vault. With only one, it's the same as All.
|
||||
var groups []provider.Provider
|
||||
for _, p := range m.providers {
|
||||
if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) {
|
||||
kinds = append(kinds, p.Kind())
|
||||
if len(m.sourceResults[p.ID()].sources) > 0 {
|
||||
groups = append(groups, p)
|
||||
}
|
||||
}
|
||||
if len(kinds) > 1 {
|
||||
for _, k := range kinds {
|
||||
scopes := []option{all}
|
||||
if len(groups) < 2 {
|
||||
scopes = append(scopes, sources...)
|
||||
} else {
|
||||
for _, p := range groups {
|
||||
k := p.Kind()
|
||||
scopes = append(scopes, option{
|
||||
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)),
|
||||
id: kindScope + string(k), kind: k, mark: m.coverageMark(k),
|
||||
label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()),
|
||||
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
|
||||
})
|
||||
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.shown = v.match()
|
||||
@@ -202,17 +222,9 @@ func (m *Model) fillSearch(keep bool) {
|
||||
v.res = max(0, min(v.res, len(v.shown)-1))
|
||||
}
|
||||
|
||||
// kindScopeLabel names the scope of all of a kind's sources.
|
||||
func kindScopeLabel(k provider.Kind) string {
|
||||
if k == provider.KindRepo {
|
||||
return "All repos"
|
||||
}
|
||||
return "All " + string(k)
|
||||
}
|
||||
|
||||
// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded.
|
||||
func (m *Model) coverageMark(kind provider.Kind) string {
|
||||
switch c := m.coverageOf(kind); {
|
||||
// coverageMark is ○ ◐ ● for how much of the providers in is loaded.
|
||||
func (m *Model) coverageMark(in providerFilter) string {
|
||||
switch c := m.coverageOf(in); {
|
||||
case c.complete():
|
||||
return m.st.Success.Render("●")
|
||||
case c.vaultsLoaded > 0:
|
||||
@@ -221,15 +233,28 @@ func (m *Model) coverageMark(kind provider.Kind) string {
|
||||
return m.st.Muted.Render("○")
|
||||
}
|
||||
|
||||
// scopeKind is the kind a scope covers all of, if it's one of those.
|
||||
func (v *searchView) scopeKind() (provider.Kind, bool) {
|
||||
k, ok := strings.CutPrefix(v.scope().id, kindScope)
|
||||
// scopeAccount is the provider whose sources the scope covers, if it's
|
||||
// an account's.
|
||||
func (v *searchView) scopeAccount() (string, bool) {
|
||||
id, ok := strings.CutPrefix(v.scope().id, accountScope)
|
||||
if !ok || v.tab != tabSearch {
|
||||
return "", false
|
||||
}
|
||||
return provider.Kind(k), true
|
||||
return id, true
|
||||
}
|
||||
|
||||
// scopeFilter is what loading everything in the scope covers: an
|
||||
// account's, else all of them.
|
||||
func (v *searchView) scopeFilter() providerFilter {
|
||||
if id, ok := v.scopeAccount(); ok {
|
||||
return ofProvider(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopeName is the scope's label without colours or indent.
|
||||
func scopeName(o option) string { return strings.TrimSpace(ansi.Strip(o.label)) }
|
||||
|
||||
// match lists the scope's entries matching the input, best first.
|
||||
func (v *searchView) match() []option {
|
||||
var cand []option
|
||||
@@ -239,13 +264,16 @@ func (v *searchView) match() []option {
|
||||
case scopeRecent:
|
||||
cand = v.recent
|
||||
default:
|
||||
kind, ofKind := v.scopeKind()
|
||||
account, ofAccount := v.scopeAccount()
|
||||
for _, o := range v.items {
|
||||
if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group {
|
||||
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
|
||||
cand = append(cand, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.picker != nil {
|
||||
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
|
||||
}
|
||||
q := v.input.Value()
|
||||
if q == "" {
|
||||
return slices.Clone(cand)
|
||||
@@ -320,6 +348,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
||||
// The actions shared by both modes, by their keys in the mode.
|
||||
type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding }
|
||||
k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect}
|
||||
if key.Matches(msg, km.Search) {
|
||||
return m.narrow()
|
||||
}
|
||||
if v.typing {
|
||||
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
|
||||
if key.Matches(msg, km.PopupClose) {
|
||||
@@ -338,6 +369,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
||||
return nil
|
||||
case key.Matches(msg, km.GoFilter):
|
||||
return m.setTyping(true)
|
||||
case key.Matches(msg, km.GoTop), key.Matches(msg, km.GoBottom):
|
||||
n := len(v.shown)
|
||||
if !v.results {
|
||||
n = len(v.scopes)
|
||||
}
|
||||
i := 0
|
||||
if key.Matches(msg, km.GoBottom) {
|
||||
i = max(0, n-1)
|
||||
}
|
||||
if v.results {
|
||||
v.res = i
|
||||
} else if v.src != i {
|
||||
v.src, v.res = i, 0
|
||||
m.fillSearch(false)
|
||||
}
|
||||
return nil
|
||||
case key.Matches(msg, km.Help):
|
||||
return m.showKeybindingsFor(keys.GoTo)
|
||||
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
|
||||
@@ -383,9 +430,14 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
||||
return nil
|
||||
case key.Matches(msg, k.sel):
|
||||
switch {
|
||||
case v.results && v.res < len(v.shown) && v.picker != nil:
|
||||
r, _ := pickable(v.shown[v.res])
|
||||
return v.picker.choose(m, r)
|
||||
case v.results && v.res < len(v.shown):
|
||||
m.search = nil
|
||||
return v.shown[v.res].run(m)
|
||||
case !v.results && v.picker != nil:
|
||||
v.results = true // a picker only narrows to the scope
|
||||
case !v.results && v.onSource():
|
||||
m.search = nil
|
||||
return v.scope().run(m)
|
||||
@@ -401,11 +453,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
||||
if v.onSource() {
|
||||
return v.scope().reload(m)
|
||||
}
|
||||
kind, _ := v.scopeKind()
|
||||
return m.startCrawlOf(kind)
|
||||
return m.startCrawlOf(v.scopeFilter())
|
||||
case inSearch && key.Matches(msg, k.loadAll):
|
||||
kind, _ := v.scopeKind()
|
||||
return m.startCrawlOf(kind)
|
||||
return m.startCrawlOf(v.scopeFilter())
|
||||
case key.Matches(msg, k.star):
|
||||
if o, ok := v.selected(); ok && o.star != "" {
|
||||
m.toggleStarKey(o.star, o.starAccount)
|
||||
@@ -436,6 +486,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// narrow switches Search between All and the source Go to was opened
|
||||
// from; from another tab, or another scope, it goes to Search on All.
|
||||
func (m *Model) narrow() tea.Cmd {
|
||||
v := m.search
|
||||
src := 0
|
||||
if v.tab == tabSearch && v.scope().id == allSources {
|
||||
src = slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
|
||||
if src < 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
v.tab, v.src, v.res, v.results = tabSearch, src, 0, true
|
||||
m.fillSearch(false)
|
||||
return m.searchLoads()
|
||||
}
|
||||
|
||||
// searchLoads loads what a search in the Search tab covers, like the
|
||||
// load-all key: everything on All, else the source picked. Only what
|
||||
// isn't loaded yet, once a popup; repos load by themselves anyway.
|
||||
@@ -452,8 +518,8 @@ func (m *Model) searchLoads() tea.Cmd {
|
||||
v.loaded = map[string]bool{}
|
||||
}
|
||||
v.loaded[sc.id] = true
|
||||
if kind, ok := v.scopeKind(); ok {
|
||||
return m.startCrawlOf(kind)
|
||||
if _, ok := v.scopeAccount(); ok {
|
||||
return m.startCrawlOf(v.scopeFilter())
|
||||
}
|
||||
if !v.onSource() {
|
||||
return m.startCrawl()
|
||||
@@ -497,6 +563,35 @@ func (m *Model) toggleStarKey(key, account string) {
|
||||
// searchHint tops the results when what they come from isn't all
|
||||
// loaded, saying how to load it.
|
||||
func (m Model) searchHint() string {
|
||||
load, narrow := m.loadHint(), m.narrowHint()
|
||||
switch {
|
||||
case narrow == "":
|
||||
return load
|
||||
case load != "":
|
||||
narrow = " · " + narrow
|
||||
}
|
||||
return load + m.st.Muted.Render(narrow)
|
||||
}
|
||||
|
||||
// narrowHint names the key for the other of All and the source Go to was
|
||||
// opened from.
|
||||
func (m Model) narrowHint() string {
|
||||
v := m.search
|
||||
if v.tab != tabSearch || m.crawl.running {
|
||||
return ""
|
||||
}
|
||||
if v.scope().id == v.here && v.here != "" {
|
||||
return withKey(m.keys.Search, "%s searches all")
|
||||
}
|
||||
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
|
||||
if i < 0 || v.scope().id != allSources {
|
||||
return ""
|
||||
}
|
||||
return withKey(m.keys.Search, "%s only "+scopeName(v.scopes[i]))
|
||||
}
|
||||
|
||||
// loadHint says how much of the scope is loaded, and the key to load it.
|
||||
func (m Model) loadHint() string {
|
||||
v := m.search
|
||||
if m.crawl.running {
|
||||
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
|
||||
@@ -504,8 +599,8 @@ func (m Model) searchHint() string {
|
||||
if v.tab != tabSearch {
|
||||
return "" // from the store: nothing to load
|
||||
}
|
||||
if kind, ok := v.scopeKind(); ok || v.scope().id == allSources {
|
||||
c := m.coverageOf(kind)
|
||||
if _, ok := v.scopeAccount(); ok || v.scope().id == allSources {
|
||||
c := m.coverageOf(v.scopeFilter())
|
||||
if c.complete() {
|
||||
return ""
|
||||
}
|
||||
@@ -558,7 +653,16 @@ func (m Model) searchViewBox() string {
|
||||
v := m.search
|
||||
focused := !m.termBlurred
|
||||
w := searchWidth(m.w)
|
||||
popupW := w
|
||||
if v.picker != nil {
|
||||
w -= 4 // inside the picker's border, in the popup
|
||||
}
|
||||
listH := max(5, min(m.h-10, 14))
|
||||
if v.picker != nil {
|
||||
// What it's for, the help, a line or two on the key picked, the
|
||||
// tabs and a border more.
|
||||
listH = max(5, min(m.h-10-len(v.picker.lines)-len(v.picker.help)-8, 14))
|
||||
}
|
||||
|
||||
srcW := 10
|
||||
for _, o := range v.scopes {
|
||||
@@ -637,14 +741,8 @@ func (m Model) searchViewBox() string {
|
||||
}
|
||||
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
|
||||
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
|
||||
if v.onSource() {
|
||||
resultsTitle = "Results in " + ansi.Strip(v.scope().label)
|
||||
}
|
||||
if kind, ok := v.scopeKind(); ok {
|
||||
resultsTitle = "Results in " + string(kind)
|
||||
if kind == provider.KindRepo {
|
||||
resultsTitle = "Results in all repos"
|
||||
}
|
||||
if _, ok := v.scopeAccount(); ok || v.onSource() {
|
||||
resultsTitle = "Results in " + scopeName(v.scope())
|
||||
}
|
||||
var lists string
|
||||
if v.tab == tabSearch {
|
||||
@@ -656,7 +754,24 @@ func (m Model) searchViewBox() string {
|
||||
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
|
||||
}
|
||||
|
||||
body := []string{" " + v.input.View(), ""}
|
||||
ts := panes.TitleStyle(m.st, focused)
|
||||
var body []string
|
||||
if p := v.picker; p != nil {
|
||||
for _, l := range p.help {
|
||||
body = append(body, " "+l)
|
||||
}
|
||||
switch {
|
||||
case p.busy != "":
|
||||
body = append(body, "", " "+m.st.Notice.Render(p.busy))
|
||||
case p.err != "":
|
||||
body = append(body, "")
|
||||
for _, l := range strings.Split(ansi.Wordwrap(p.err, w-4, " "), "\n") {
|
||||
body = append(body, " "+m.st.Error.Render(l))
|
||||
}
|
||||
}
|
||||
body = append(body, "", " "+m.goToTabs(ts))
|
||||
}
|
||||
body = append(body, " "+v.input.View(), "")
|
||||
for _, l := range strings.Split(lists, "\n") {
|
||||
body = append(body, " "+l)
|
||||
}
|
||||
@@ -676,26 +791,83 @@ func (m Model) searchViewBox() string {
|
||||
if v.tab == tabSearch {
|
||||
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
|
||||
}
|
||||
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...)
|
||||
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, v.selectLabel()), hint(km.PopupClose, "stop typing"))...)
|
||||
default:
|
||||
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
|
||||
if v.tab == tabSearch {
|
||||
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
|
||||
}
|
||||
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...)
|
||||
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, v.selectLabel()), hint(km.GoClose, "close"))...)
|
||||
}
|
||||
// Tabs in the title, the current one underlined, like the panes.
|
||||
ts := panes.TitleStyle(m.st, focused)
|
||||
head := ts.Render(goToTitle + " ")
|
||||
// Tabs in the title, the current one underlined, like the panes; a
|
||||
// picker has its own title, and the tabs over the input.
|
||||
if v.picker == nil {
|
||||
head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
|
||||
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
|
||||
}
|
||||
// A picker: in its own border, under what it's for.
|
||||
var outer []string
|
||||
for _, l := range v.picker.lines {
|
||||
outer = append(outer, " "+l)
|
||||
}
|
||||
outer = append(outer, "")
|
||||
for _, l := range strings.Split(panes.Box(m.st, ts.Render(v.picker.heading), "", body, w, len(body)+2, focused), "\n") {
|
||||
outer = append(outer, " "+l)
|
||||
}
|
||||
return panes.Box(m.st, ts.Render(v.picker.title), foot, outer, popupW, len(outer)+2, focused)
|
||||
}
|
||||
|
||||
// picker is Go to picking a secret for something (the key to share
|
||||
// with): it lists secrets only, and enter hands the selected one's link
|
||||
// to pick, which closes Go to when it's done with it.
|
||||
type picker struct {
|
||||
pick func(m *Model, r ref.Ref) tea.Cmd
|
||||
title string // the popup's
|
||||
lines []string // what picking is for, above the picker
|
||||
heading string // the picker's title, on its border
|
||||
help []string // inside it, above the tabs
|
||||
busy string // what the pick is doing (picks wait meanwhile)
|
||||
err string // why the secret picked can't be used
|
||||
}
|
||||
|
||||
// choose hands r to the picker, unless it's still busy with one.
|
||||
func (p *picker) choose(m *Model, r ref.Ref) tea.Cmd {
|
||||
if p.busy != "" {
|
||||
return nil
|
||||
}
|
||||
return p.pick(m, r)
|
||||
}
|
||||
|
||||
// selectLabel names what enter does with the selected entry.
|
||||
func (v *searchView) selectLabel() string {
|
||||
if v.picker != nil {
|
||||
return "pick"
|
||||
}
|
||||
return "go"
|
||||
}
|
||||
|
||||
// pickable is the link of a Go to entry that a picker can pick: a
|
||||
// secret (not a key or certificate, nor a key in a plain env file).
|
||||
func pickable(o option) (ref.Ref, bool) {
|
||||
r, err := ref.Parse(o.star)
|
||||
if err != nil || o.plain || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
|
||||
return ref.Ref{}, false
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// goToTabs draws Go to's tabs, the current one underlined.
|
||||
func (m Model) goToTabs(ts lipgloss.Style) string {
|
||||
var out string
|
||||
for i, name := range tabNames {
|
||||
if i > 0 {
|
||||
head += ts.Render(" - ")
|
||||
out += ts.Render(" - ")
|
||||
}
|
||||
if i == v.tab {
|
||||
head += ts.Underline(true).Render(name)
|
||||
if i == m.search.tab {
|
||||
out += ts.Underline(true).Render(name)
|
||||
} else {
|
||||
head += m.st.Muted.Render(name)
|
||||
out += m.st.Muted.Render(name)
|
||||
}
|
||||
}
|
||||
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
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 share.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
|
||||
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)
|
||||
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
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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 share.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": "share:\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; share.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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-19
@@ -1,22 +1,25 @@
|
||||
╭─ [1] Sources - Favourites ────╮╭─ Secret ────────────────────────────────────────────────────────╮
|
||||
│ ▾ Azure (2) ││ name db-password │
|
||||
│ platform-prod ││ vault kv-app-prod │
|
||||
│ platform-dev ││ source Azure / platform-prod │
|
||||
│ ▾ Databricks (2) ││ version 3f9c2a │
|
||||
│ prod ││ enabled yes │
|
||||
╰────────────────────── 2 of 6 ─╯│ updated 2026-09-01 09:30 │
|
||||
╭─ [2] Vaults ──────────────────╮│ expires 2027-09-01 09:30 │
|
||||
│ kv-app-prod ││ content text/plain │
|
||||
│ kv-shared-prod ││ tags env=prod, owner=data │
|
||||
│ kv-restricted ││ ref @Microsoft.KeyVault(SecretUri=https://kv-app-prod.vau│
|
||||
│ ││ lt.azure.net/secrets/db-password) │
|
||||
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
|
||||
│ 2 accounts ││ name platform-prod │
|
||||
╰───────────────────────────────╯│ ID sub-prod │
|
||||
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
|
||||
│ ▾ Azure (2) ││ vaults 3 │
|
||||
│ platform-prod ││ │
|
||||
│ platform-dev ││ │
|
||||
│ ▾ Databricks (2) ││ │
|
||||
│ prod ││ │
|
||||
╰────────────────────── 2 of 6 ─╯│ │
|
||||
╭─ [2] Vaults ──────────────────╮│ │
|
||||
│ kv-app-prod ││ │
|
||||
│ 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 │
|
||||
│ db-password ││ 91d3e0 2026-03-01 expired │
|
||||
│ api-key ││ 0a77c4 2025-09-01 disabled │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯│ │
|
||||
╭─ [3] Secrets - Keys - Certs ──╮│ │
|
||||
│ db-password ││ │
|
||||
│ api-key ││ │
|
||||
│ legacy-token (disabled) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
@@ -26,5 +29,5 @@
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯╰─────────────────────────────────────────────────────────────────╯
|
||||
enter open • s copy link • S share… • f star • F favourites • n note • … • ? help • q quit
|
||||
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
||||
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
|
||||
@@ -155,15 +155,3 @@ func (m *Model) handleInstalled(msg installedMsg) {
|
||||
m.updated = msg.release.Tag
|
||||
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 {
|
||||
t.Fatalf("asked %d times at startup", f.asked)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "v0.1.1 ↑ v0.2.0 U") {
|
||||
t.Errorf("no update in the footer:\n%s", d.screen())
|
||||
if scr := d.screen(); !strings.Contains(scr, "[0] lazysecrets v0.1.1") || !strings.Contains(scr, "↑ v0.2.0 U") {
|
||||
t.Errorf("no update in the status pane:\n%s", scr)
|
||||
}
|
||||
|
||||
// U: the changelog, without the install notes; u installs.
|
||||
|
||||
+16
-11
@@ -36,8 +36,8 @@ const (
|
||||
type Map struct {
|
||||
// lists
|
||||
Up, Down, Top, Bottom key.Binding
|
||||
NextPane, PrevPane key.Binding
|
||||
Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||
NextPane, PrevPane, Details key.Binding
|
||||
Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||
NextTab, PrevTab key.Binding
|
||||
Enter, Back, Filter key.Binding
|
||||
Reveal, Copy, CopyRef, Open, Edit key.Binding
|
||||
@@ -61,7 +61,8 @@ type Map struct {
|
||||
SearchSources, SearchResults, SearchSwitch key.Binding
|
||||
SearchLoad, SearchLoadAll key.Binding
|
||||
// goto
|
||||
GoDown, GoUp, GoPrevTab, GoNextTab key.Binding
|
||||
GoDown, GoUp, GoTop, GoBottom key.Binding
|
||||
GoPrevTab, GoNextTab key.Binding
|
||||
GoSources, GoResults, GoSwitch key.Binding
|
||||
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
|
||||
GoSelect, GoClose key.Binding
|
||||
@@ -87,11 +88,13 @@ func (m *Map) Actions() []Action {
|
||||
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, "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."),
|
||||
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane; from the detail pane, the one it describes."),
|
||||
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, "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, "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, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
|
||||
|
||||
@@ -100,7 +103,7 @@ func (m *Map) Actions() []Action {
|
||||
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, "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, "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, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
|
||||
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
||||
a(Lists, "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."),
|
||||
@@ -111,10 +114,10 @@ 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, "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, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. 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, "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, "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, or the web page."),
|
||||
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, "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."),
|
||||
@@ -158,7 +161,9 @@ func (m *Map) Actions() []Action {
|
||||
|
||||
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, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."),
|
||||
a(GoTo, "top", &m.GoTop, "top", "go to", "Jump to the top of the list in Go to while browsing."),
|
||||
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, "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)."),
|
||||
@@ -177,8 +182,8 @@ func (m *Map) Actions() []Action {
|
||||
var defaults = map[string][]string{
|
||||
"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.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"},
|
||||
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"},
|
||||
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
|
||||
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
|
||||
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
||||
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
|
||||
@@ -204,7 +209,7 @@ var defaults = map[string][]string{
|
||||
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
|
||||
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
|
||||
|
||||
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
|
||||
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.top": {"g", "home"}, "goto.bottom": {"G", "end"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
|
||||
"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.select": {"enter"}, "goto.close": {"esc", "q"},
|
||||
|
||||
+57
-27
@@ -31,7 +31,7 @@ type Detail struct {
|
||||
Icons icons.Set
|
||||
// RevealKey and VersionsKey are the keys hints mention ("space to
|
||||
// reveal"); a hint is left out when its key is "" (unbound).
|
||||
RevealKey, VersionsKey string
|
||||
RevealKey, VersionsKey, CopyKey string
|
||||
|
||||
secret *provider.Object
|
||||
container *provider.Container
|
||||
@@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) {
|
||||
d.container = &s.Container
|
||||
}
|
||||
|
||||
// Refresh shows s's newer metadata if s is the secret shown, keeping
|
||||
// its account and any value shown or loading.
|
||||
func (d *Detail) Refresh(s provider.Object) {
|
||||
if d.secret == nil || d.secret.ID != s.ID {
|
||||
return
|
||||
}
|
||||
s.Container = d.secret.Container
|
||||
d.secret = &s
|
||||
}
|
||||
|
||||
// ShowContainer shows a container summary when no secret is selected.
|
||||
func (d *Detail) ShowContainer(c provider.Container) {
|
||||
d.Clear()
|
||||
@@ -68,7 +78,7 @@ func (d *Detail) ShowContainer(c provider.Container) {
|
||||
|
||||
// Clear drops the selection and any fetched value.
|
||||
func (d *Detail) Clear() {
|
||||
*d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, w: d.w, h: d.h} // drops the note too
|
||||
*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
|
||||
}
|
||||
|
||||
// SetDescribing marks extra metadata as being fetched.
|
||||
@@ -262,24 +272,35 @@ func (d Detail) lines(st styles.Styles) []string {
|
||||
}
|
||||
|
||||
// Secrets are masked; keys and certificates only expose public material.
|
||||
keyHint := func(k, what string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
// What the keys do with the value, on the line under it.
|
||||
hints := func(reveal string, more ...string) string {
|
||||
var h []string
|
||||
if d.RevealKey != "" {
|
||||
h = append(h, d.RevealKey+" to "+reveal)
|
||||
}
|
||||
return "(" + k + " to " + what + ")"
|
||||
if d.CopyKey != "" {
|
||||
h = append(h, d.CopyKey+" to copy")
|
||||
}
|
||||
label, hidden := "value", st.Masked.Render("••••••••")+muted(" "+keyHint(d.RevealKey, "reveal"))
|
||||
return muted(strings.Join(append(h, more...), " · "))
|
||||
}
|
||||
label, hidden, reveal := "value", st.Masked.Render("••••••••"), "reveal"
|
||||
switch s.Type {
|
||||
case provider.TypeKey:
|
||||
label, hidden = "public", muted(keyHint(d.RevealKey, "show public key"))
|
||||
label, hidden, reveal = "public", "", "show the public key"
|
||||
case provider.TypeCertificate:
|
||||
label, hidden = "cert", muted(keyHint(d.RevealKey, "show certificate"))
|
||||
label, hidden, reveal = "cert", "", "show the certificate"
|
||||
}
|
||||
indent := strings.Repeat(" ", 12)
|
||||
|
||||
out = append(out, "")
|
||||
switch d.state {
|
||||
case ValueHidden:
|
||||
if hidden != "" {
|
||||
field(label, hidden)
|
||||
out = append(out, indent+hints(reveal))
|
||||
} else {
|
||||
field(label, hints(reveal))
|
||||
}
|
||||
case ValueLoading:
|
||||
field(label, spinnerPlaceholder+" fetching…")
|
||||
case ValueFailed:
|
||||
@@ -300,23 +321,30 @@ func (d Detail) lines(st styles.Styles) []string {
|
||||
}
|
||||
}
|
||||
case ValueShown:
|
||||
var hints []string
|
||||
if d.RevealKey != "" {
|
||||
hints = append(hints, d.RevealKey+" to hide")
|
||||
// Where the dots were, continuing under itself; the keys below.
|
||||
first := true
|
||||
lines, note := valueView(d.value.Reveal())
|
||||
for _, line := range lines {
|
||||
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
|
||||
if first {
|
||||
out = append(out, " "+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 != "" {
|
||||
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))
|
||||
}
|
||||
more = append(more, "hides in "+d.hidesIn)
|
||||
}
|
||||
out = append(out, indent+hints("hide", more...))
|
||||
}
|
||||
return append(out, d.versionLines(st)...)
|
||||
}
|
||||
@@ -344,11 +372,13 @@ func (d Detail) versionLines(st styles.Styles) []string {
|
||||
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
|
||||
break
|
||||
}
|
||||
marker := " "
|
||||
if d.secret.Version != "" && v.Version == d.secret.Version {
|
||||
marker = "▸ " // the version shown above
|
||||
// A list to read, not to move in (v browses them): no cursor. The
|
||||
// version shown above, when it isn't the current one, is named.
|
||||
line := " " + VersionLine(st, v, d.Now(), current)
|
||||
if d.secret.Version != "" && v.Version == d.secret.Version && v.Version != current {
|
||||
line += muted(" · shown above")
|
||||
}
|
||||
out = append(out, " "+marker+VersionLine(st, v, d.Now(), current))
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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,6 +34,14 @@ type List[T any] struct {
|
||||
Empty string // shown when loaded with no items
|
||||
Loading bool
|
||||
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]
|
||||
items []T
|
||||
@@ -88,6 +96,7 @@ func (l *List[T]) SetError(err error) {
|
||||
func (l *List[T]) Refresh() {
|
||||
l.Loading = true
|
||||
l.Err = nil
|
||||
l.Age = ""
|
||||
}
|
||||
|
||||
func (l *List[T]) SetLoading() {
|
||||
@@ -159,9 +168,36 @@ func (l List[T]) rows() int {
|
||||
if l.filtering {
|
||||
r--
|
||||
}
|
||||
r -= len(l.header())
|
||||
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.
|
||||
func (l List[T]) Filtering() bool { return l.filtering }
|
||||
|
||||
@@ -325,12 +361,15 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
||||
if l.filtering {
|
||||
body = append(body, l.input.View())
|
||||
}
|
||||
for _, line := range l.header() {
|
||||
body = append(body, st.Notice.Render(" "+line))
|
||||
}
|
||||
|
||||
switch {
|
||||
case l.Loading && len(l.items) == 0:
|
||||
body = append(body, " "+spin+" loading…")
|
||||
case l.Err != nil:
|
||||
for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") {
|
||||
for _, line := range wrapLines(l.Err.Error(), inner-1) {
|
||||
body = append(body, st.Error.Render(" "+line))
|
||||
}
|
||||
case len(l.visible) == 0 && len(l.items) > 0:
|
||||
@@ -369,6 +408,9 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
||||
if len(l.visible) > 0 {
|
||||
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
||||
}
|
||||
if l.Loading && l.Age != "" && len(l.items) > 0 {
|
||||
counter = "cached " + l.Age + " · " + counter
|
||||
}
|
||||
return BoxCounted(st, title, "", counter, body, l.w, l.h, focused)
|
||||
}
|
||||
|
||||
@@ -386,3 +428,22 @@ func (l *List[T]) scroll() {
|
||||
}
|
||||
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,3 +177,27 @@ func TestHighlightKeepsColours(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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[:]))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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,6 +3,8 @@
|
||||
package styles
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
@@ -32,6 +34,10 @@ type Styles struct {
|
||||
KindAzure lipgloss.Style
|
||||
KindDatabricks 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 {
|
||||
@@ -43,9 +49,12 @@ func Default() Styles {
|
||||
TitleBlurred: lipgloss.NewStyle(),
|
||||
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),
|
||||
SelectedBlurred: lipgloss.NewStyle().Bold(true),
|
||||
SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack),
|
||||
Muted: lipgloss.NewStyle().Faint(true),
|
||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||
@@ -74,3 +83,23 @@ func (s Styles) Kind(k provider.Kind) lipgloss.Style {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -70,8 +70,10 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||
st.BorderBlurred = v.fg(lipgloss.NewStyle())
|
||||
case "selectedLineBgColor":
|
||||
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||
st.themed = true
|
||||
case "inactiveViewSelectedLineBgColor":
|
||||
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||
st.themedBlurred = true
|
||||
case "defaultFgColor":
|
||||
fg = v.color
|
||||
case "optionsTextColor":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package styles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -62,3 +63,32 @@ func TestEmptyThemeIsDefault(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+99
-30
@@ -3,6 +3,9 @@
|
||||
// against the release's checksums.txt, tried (--version), then renamed
|
||||
// over the binary. A running lazysecrets keeps its old binary until it
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -18,10 +21,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -29,12 +34,20 @@ import (
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// Where releases are published.
|
||||
// DefaultRepo is lazysecrets' home: its source, docs and releases.
|
||||
// GitHubRepo mirrors it, with the issues and the releases too.
|
||||
const (
|
||||
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/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.
|
||||
const maxDownload = 200 << 20
|
||||
|
||||
@@ -49,18 +62,30 @@ type Release struct {
|
||||
// every release ends with.
|
||||
func (r Release) Notes() string {
|
||||
notes, _, _ := strings.Cut(r.Body, "## Install")
|
||||
return strings.TrimSpace(notes)
|
||||
return PlainLinks(strings.TrimSpace(notes))
|
||||
}
|
||||
|
||||
// Client talks to the release server.
|
||||
// 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 {
|
||||
API string // the repository's API, DefaultAPI when empty
|
||||
Repo string // the repository's web address, DefaultRepo when empty
|
||||
// Servers are the repositories' web addresses, on GitHub or a Gitea
|
||||
// or Forgejo server, tried in order; DefaultServers when empty.
|
||||
Servers []string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
|
||||
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
|
||||
func (c *Client) servers() []string {
|
||||
if len(c.Servers) > 0 {
|
||||
return c.Servers
|
||||
}
|
||||
return DefaultServers
|
||||
}
|
||||
|
||||
func (c *Client) client() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
@@ -69,25 +94,50 @@ func (c *Client) client() *http.Client {
|
||||
return &http.Client{Timeout: 2 * time.Minute}
|
||||
}
|
||||
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
// API is a repository's API: GitHub's for github.com, else Gitea's (and
|
||||
// Forgejo's).
|
||||
func API(repo string) (string, error) {
|
||||
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)
|
||||
}
|
||||
return b
|
||||
if u.Host == "github.com" {
|
||||
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).
|
||||
// Latest is the newest release (pre-releases aside), from the first
|
||||
// server that answers.
|
||||
func (c *Client) Latest(ctx context.Context) (Release, error) {
|
||||
b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20)
|
||||
var errs []error
|
||||
for _, repo := range c.servers() {
|
||||
r, err := c.latest(ctx, repo)
|
||||
if err == 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()
|
||||
b, err := c.get(ctx, api+"/releases/latest", 1<<20)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
var r Release
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return Release{}, fmt.Errorf("reading the latest release: %w", err)
|
||||
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err)
|
||||
}
|
||||
if !semver.IsValid(r.Tag) {
|
||||
return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag)
|
||||
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -139,23 +189,10 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
||||
}
|
||||
version := strings.TrimPrefix(r.Tag, "v")
|
||||
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
||||
base := c.repo() + "/releases/download/" + r.Tag + "/"
|
||||
|
||||
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
|
||||
archive, err := c.download(ctx, r.Tag, name+".tar.gz")
|
||||
if err != nil {
|
||||
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"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -190,6 +227,38 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
||||
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) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -70,13 +70,13 @@ func server(t *testing.T, tamper bool) *httptest.Server {
|
||||
sum[0] ^= 0xff
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
|
||||
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/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"}`)
|
||||
})
|
||||
mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
|
||||
mux.HandleFunc("/keb/lazysecrets/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)
|
||||
})
|
||||
mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write(archive.Bytes())
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
@@ -89,7 +89,7 @@ func TestInstall(t *testing.T) {
|
||||
t.Skip("releases are for macOS and Linux")
|
||||
}
|
||||
srv := server(t, false)
|
||||
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
|
||||
c := &Client{Servers: []string{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)
|
||||
@@ -107,12 +107,59 @@ 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) {
|
||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||
t.Skip("releases are for macOS and Linux")
|
||||
}
|
||||
srv := server(t, true)
|
||||
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
|
||||
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
|
||||
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
||||
os.WriteFile(exe, []byte("old"), 0o755)
|
||||
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
|
||||
@@ -123,3 +170,10 @@ func TestInstallRefusesATamperedArchive(t *testing.T) {
|
||||
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(), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
|
||||
t.Errorf("notes %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"local>keb/renovate"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "A test helper pinned to a commit, with no releases to update to: Renovate can't look it up and warns on every run.",
|
||||
"matchPackageNames": ["github.com/charmbracelet/x/exp/golden"],
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copies a release GoReleaser published on Gitea to the GitHub mirror:
|
||||
// the same notes and the same files (dist/), so lazysecrets can update
|
||||
// from either (internal/update tries GitHub first). Run by the release
|
||||
// workflow after GoReleaser; a release already on GitHub for the tag is
|
||||
// replaced, like GoReleaser's mode: replace on Gitea.
|
||||
//
|
||||
// TAG=v0.2.0 GH_TOKEN=… GITEA_TOKEN=… node scripts/github-release.mjs
|
||||
//
|
||||
// GH_TOKEN is a fine-grained token that can write the mirror's contents;
|
||||
// without it, this does nothing. No dependencies: Node 20's fetch.
|
||||
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
|
||||
const github = "karleinarb/lazysecrets";
|
||||
const { TAG: tag, GH_TOKEN: token, GITEA_TOKEN: giteaToken, GITHUB_SHA: sha } = process.env;
|
||||
const gitea = `${process.env.GITHUB_SERVER_URL ?? "https://git.kebcloud.no"}/api/v1/repos/${process.env.GITHUB_REPOSITORY ?? "keb/lazysecrets"}`;
|
||||
|
||||
if (!token) {
|
||||
console.log("No GH_TOKEN: not copying the release to GitHub.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (!tag) throw new Error("TAG isn't set");
|
||||
|
||||
const gh = (path, init = {}) =>
|
||||
fetch(path.startsWith("https://") ? path : `https://api.github.com/repos/${github}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
async function ok(res, what) {
|
||||
if (!res.ok) throw new Error(`${what}: ${res.status} ${await res.text()}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
// The notes as GoReleaser wrote them on Gitea.
|
||||
const rel = await (
|
||||
await ok(await fetch(`${gitea}/releases/tags/${tag}`, { headers: giteaToken ? { Authorization: `token ${giteaToken}` } : {} }), "reading the Gitea release")
|
||||
).json();
|
||||
|
||||
// Replace what's there for the tag.
|
||||
const old = await gh(`/releases/tags/${tag}`);
|
||||
if (old.ok) {
|
||||
const { id } = await old.json();
|
||||
await ok(await gh(`/releases/${id}`, { method: "DELETE" }), "deleting the old GitHub release");
|
||||
console.log(`Deleted the GitHub release for ${tag}.`);
|
||||
}
|
||||
|
||||
// The mirror may not have the tag's commit yet: it pushes on its own.
|
||||
// Wait for it a while.
|
||||
const body = JSON.stringify({ tag_name: tag, target_commitish: sha, name: rel.name || tag, body: rel.body, prerelease: rel.prerelease });
|
||||
let created;
|
||||
for (let i = 0; ; i++) {
|
||||
const res = await gh("/releases", { method: "POST", body });
|
||||
if (res.ok) {
|
||||
created = await res.json();
|
||||
break;
|
||||
}
|
||||
if (res.status !== 422 || i === 9) await ok(res, "creating the GitHub release");
|
||||
console.log("GitHub doesn't have the commit yet; waiting for the mirror…");
|
||||
await new Promise((r) => setTimeout(r, 30_000));
|
||||
}
|
||||
|
||||
const files = (await readdir("dist")).filter((f) => f.endsWith(".tar.gz") || f === "checksums.txt");
|
||||
if (files.length === 0) throw new Error("nothing to upload in dist/");
|
||||
for (const f of files) {
|
||||
const url = `https://uploads.github.com/repos/${github}/releases/${created.id}/assets?name=${encodeURIComponent(f)}`;
|
||||
await ok(await gh(url, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: await readFile(`dist/${f}`) }), `uploading ${f}`);
|
||||
console.log(`Uploaded ${f}.`);
|
||||
}
|
||||
console.log(`Published ${tag} on GitHub: ${created.html_url}`);
|
||||
Reference in new issue
Block a user