1 Commits
Author SHA1 Message Date
keb db868e1744 docs: fix what no longer matches lazysecrets, and say each thing once
CI / Checks (pull_request) Successful in 37s
PR title / Conventional title (pull_request) Successful in 1s
Fixes:
- A newer release shows in the status pane (`↑ v0.2.0`), not the footer.
- The account in `lazysecrets list` is plain `Azure` with one az account; `Azure:<user>/<tenant>` only with several.
- Sharing lists subscription, workspace and scope links, and opening takes Azure portal links.
- `--timeout` and `--clipboard-clear` are documented, next to their config keys.
- `--kind`'s description reads, and says `vault` covers scopes and repo files.
- `lazysecrets -h` lists `list`'s `--stars`, `--notes`, `--recent` and `--expiry`.
- README typos; "Supported platforms" is "Sources".

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

No files matched your search

+28 -8
View File
@@ -1,13 +1,33 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Build output
/lazysecrets
# Release builds (GoReleaser, see .goreleaser.yaml)
/dist/
# go test -c, coverage profiles
*.test
*.out
go.work
go.work.sum
.env
-6
View File
@@ -2,12 +2,6 @@
Every release of lazysecrets, newest first.
## v0.2.1 – 2026-10-15
### Fixed
- A link opens lazysecrets with your cache and next to your running instances ([#71](https://git.kebcloud.no/keb/lazysecrets/pulls/71)) ([bb41767](https://git.kebcloud.no/keb/lazysecrets/commit/bb41767cbad00bab4f1824f4e0754cc7abd98c59))
## v0.2.0 – 2026-10-14
### Added
+2 -2
View File
@@ -23,7 +23,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
- **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env` and Spring Boot config files in your git repos.
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
@@ -87,7 +87,7 @@ pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
- [SOPS, .env and Spring files](docs/repos.md): which repos and files, decrypting
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
opening links, also from the browser and chat
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
+10 -4
View File
@@ -18,7 +18,7 @@ import (
// It ends with a newline only when printing to a terminal, so command
// substitution and pipes get the value exactly.
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
v, _, err := readKey(ctx, providers, st, r)
v, err := value(ctx, providers, st, r)
if err != nil {
return err
}
@@ -30,9 +30,15 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err
}
// readKey reads what r points at (ref.Read), and the version read. The
// account asked for (--account) goes first, else the one that last read
// it.
// value reads what r points at (ref.Read), with the account that last
// read it first.
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
v, _, err := readKey(ctx, providers, st, r)
return v, err
}
// readKey is value, also returning the version read. The account asked
// for (--account) goes first, else the one that last read it.
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
key := r
key.Version = ""
+2 -2
View File
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
} {
r, _ := ref.Parse(link)
v, _, err := readKey(context.Background(), providers, st, r)
v, err := value(context.Background(), providers, st, r)
if err != nil || v.Reveal() != want {
t.Errorf("%s: %q, %v", link, v.Reveal(), err)
}
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
"{{secrets/app/storage-key}}": "names no workspace",
} {
r, _ := ref.Parse(link)
if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Errorf("%s: %v, want %q", link, err, wantErr)
}
}
+8 -10
View File
@@ -69,7 +69,11 @@ func installHandler(cfg config.Config, configPath, term string) (string, string,
if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
return "", "", err
}
launch := append(append([]string{exe}, configArgs(configPath)...), "launch")
launch := []string{exe}
if configPath != defaultConfigPath() {
launch = append(launch, "--config", configPath)
}
launch = append(launch, "launch")
if term != "" {
launch = append(launch, "--terminal", term)
}
@@ -184,14 +188,8 @@ func launchCommand(cfg config.Config, configPath, term, exe, link string) ([]str
// innerCommand runs lazysecrets on link, with the environment it needs.
func innerCommand(configPath, exe, link string) []string {
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe)
inner = append(inner, configArgs(configPath)...)
if configPath != defaultConfigPath() {
inner = append(inner, "--config", configPath)
}
return append(inner, "open", link)
}
// configArgs passes the config files on, unless they're the default.
func configArgs(configPath string) []string {
if configPath == defaultConfigPath() {
return nil
}
return []string{"--config", configPath}
}
+73 -49
View File
@@ -68,20 +68,27 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
if c.Key == "" {
continue
}
name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil {
return err
}
for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
}
}
}
return nil
}
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column) and,
// for open, what to do with it rather than asking (--action).
@@ -171,22 +178,26 @@ Flags:
return nil
}
sub := "" // the subcommand; "" browses
var subArgs []string
var open *ref.Ref
var getRef ref.Ref
var open, getRef *ref.Ref
var openAction string
var shareArgs, listArgs []string
printKeys := false
switch args := flag.Args(); {
case len(args) > 0 && (args[0] == "share" || args[0] == "list"):
sub, subArgs = args[0], args[1:]
case len(args) > 0 && args[0] == "share":
shareArgs = args[1:]
if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how
}
case len(args) > 0 && args[0] == "list":
listArgs = append([]string{}, args[1:]...) // not nil: listing
case len(args) == 1 && args[0] == "keybindings":
sub = "keybindings"
printKeys = true
case len(args) > 0 && args[0] == "get":
r, _, err := refArg("get", args[1:])
if err != nil {
return err
}
sub, getRef = "get", r
getRef = &r
case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch":
@@ -232,7 +243,7 @@ Flags:
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
return err
}
if sub == "keybindings" {
if printKeys {
fmt.Print(keymap.YAML())
return nil
}
@@ -260,13 +271,25 @@ Flags:
}
// timeoutFor is how long a request to a provider of kind k may take.
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] {
*clipClear = valueOr(cfg.Values.ClipboardClear, *clipClear)
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
*clipClear = *cfg.Values.ClipboardClear
}
expiryDays := 30
if cfg.UI.ExpiryWarningDays != nil {
expiryDays = *cfg.UI.ExpiryWarningDays
}
revealTimeout := 60 * time.Second
if cfg.Values.RevealTimeout != nil {
revealTimeout = *cfg.Values.RevealTimeout
}
loadDelay := 150 * time.Millisecond
if cfg.UI.LoadDelay != nil {
loadDelay = *cfg.UI.LoadDelay
}
var updateEvery time.Duration // 0: the app's default, a day
if cfg.Updates.AutoCheckInterval != nil {
updateEvery = *cfg.Updates.AutoCheckInterval
}
expiryWarning := time.Duration(valueOr(cfg.UI.ExpiryWarningDays, 30)) * 24 * time.Hour
revealTimeout := valueOr(cfg.Values.RevealTimeout, 60*time.Second)
loadDelay := valueOr(cfg.UI.LoadDelay, 150*time.Millisecond)
updateEvery := valueOr(cfg.Updates.AutoCheckInterval, 0) // 0: the app's default, a day
if !set["icons"] {
switch {
case os.Getenv("LAZYSECRETS_ICONS") != "":
@@ -276,17 +299,7 @@ Flags:
}
}
recentIcon := valueOr(cfg.UI.RecentIcon, false)
startDir := *dir
if startDir == "" {
startDir, _ = os.Getwd()
}
// What was loaded last time.
cacheFile := ""
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon
// Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file.
@@ -344,9 +357,17 @@ Flags:
providers = append(providers, dbx)
}
if config.On(pc.Repos.Enabled) {
start := *dir
if start == "" {
start, _ = os.Getwd()
}
roots := repo.DefaultRoots
if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots
}
repos := repo.New(repo.Settings{
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, Spring: pc.Repos.Spring,
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
})
// The repo it started in (shown on top as Repo) and the
// others, checked for secret files in the background like
@@ -362,14 +383,19 @@ Flags:
// the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard)
switch sub {
case "get":
if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
defer cancel()
return get(ctx, providers, st, getRef)
case "list":
return listCmd(providers, st, cacheFile, timeoutFor, theme, *icons, recentIcon, expiryWarning, subArgs, os.Stdout)
case "share":
return get(ctx, providers, st, *getRef)
}
if listArgs != nil {
cachePath := ""
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cachePath = filepath.Join(d, "cache.json")
}
return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout)
}
if shareArgs != nil {
// The files from a repo, the key from whichever provider it's
// in: the longest timeout.
d := *timeout
@@ -378,7 +404,7 @@ Flags:
}
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel()
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), subArgs, os.Stdout)
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
}
// Links opened elsewhere (lazysecrets launch) can come here.
@@ -412,10 +438,16 @@ Flags:
onActive = srv.Touch
}
// Where it was left in this folder.
placeDir := ""
// What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string
if !*demo {
placeDir = startDir
if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
if placeDir == "" {
placeDir, _ = os.Getwd()
}
if abs, err := filepath.Abs(placeDir); err == nil {
placeDir = abs
}
@@ -432,7 +464,7 @@ Flags:
NoHistory: !config.On(cfg.Storage.History),
OnActive: onActive,
RevealTimeout: revealTimeout,
ExpiryWarning: expiryWarning,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.Values.HideOnBlur),
HandlerSetup: setup,
Updater: updater(cfg.Updates.Sources),
@@ -465,14 +497,6 @@ Flags:
return err
}
// valueOr is *p, or def when p is nil (not set in the config).
func valueOr[T any](p *T, def T) T {
if p == nil {
return def
}
return *p
}
// 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 {
+4 -9
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"strings"
"time"
@@ -33,12 +32,8 @@ func updater(servers []string) *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it.
func updateCmd(configPath string, args []string) error {
fs := flag.NewFlagSet("update", flag.ContinueOnError)
check := fs.Bool("check", false, "just say if there's a newer release")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
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, ","))
@@ -54,14 +49,14 @@ func updateCmd(configPath string, args []string) error {
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
}
if !update.Newer(current, r.Tag) {
if strings.HasPrefix(current, "dev") {
if !update.Newer(current, "v999.0.0") {
return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag)
}
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil
}
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
if *check {
if check {
fmt.Println("lazysecrets update installs it.")
return nil
}
+5 -4
View File
@@ -3,6 +3,7 @@ package main
import (
"regexp"
"runtime/debug"
"strings"
)
// version is set by release builds:
@@ -10,13 +11,13 @@ import (
// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets
var version string
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
// maybe +dirty): its commit.
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
func versionString() string {
if version != "" {
return version
@@ -30,7 +31,7 @@ func versionString() string {
// go install …@<commit>.
if m := pseudo.FindStringSubmatch(v); m != nil {
dirty := ""
if m[2] != "" {
if strings.HasSuffix(v, "+dirty") {
dirty = "-dirty"
}
return "dev-" + m[1][:7] + dirty
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 258 KiB

-1
View File
@@ -74,7 +74,6 @@ theme:
azureColor: ["#82aaff"]
databricksColor: ["#ff757f"]
repoColor: ["#c099ff"]
springColor: ["#c3e88d"] # Spring Boot config files
```
A role lazysecrets doesn't know, a typo or one from a newer version, is
+1 -1
View File
@@ -53,7 +53,7 @@ match case exactly.
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) |
| `e` | open the selected SOPS, `.env` or Spring file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
| `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) |
+9 -63
View File
@@ -1,9 +1,9 @@
# SOPS, .env and Spring files in git repos
# SOPS and .env files in git repos
lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI), plain env files and [Spring Boot config](#spring-boot-config)
as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
`.env`, INI) and plain env files as vaults, and the keys in them
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
escaped, `app\.name`, so it doesn't read as nesting.
## Which repos
@@ -46,7 +46,7 @@ Folders of dependencies and caches are never looked in, tracked or not:
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
4. the whole repo.
`include`/`exclude` apply to env and Spring files too.
`include`/`exclude` apply to env files too.
```yaml
providers:
@@ -65,51 +65,13 @@ sharing:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
```
## Spring Boot config
`application.yml`, `application-prod.properties`, `bootstrap.yaml` and the
like, tracked by git (not those in a Helm chart's `templates/`). Most of
their keys aren't secret, so only those that look like one are listed:
- **keys** named like a secret: ending in `password`, `pwd`, `secret`,
`token`, `key`, `credentials`, `salt`, `connection-string` and the
like (`spring.datasource.password`, `client-secret`, `checkoutKey`),
- **values** that look like one: Jasypt's `ENC(...)`, a URL with a
password (`jdbc:postgresql://app:pw@db/app`), or a connection string
with `AccountKey=`, `SharedAccessKey=` or `Password=`.
A file's details count the keys left out, and `e` opens it whole. A
`${DB_PASSWORD}` value is marked as a reference: the secret is set
elsewhere. A tracked file is marked in red only when a listed key has its
value in plain text.
Each list of regexps replaces its default; `keys: [".*"]` lists every key:
```yaml
providers:
repos:
spring:
keys: ["(?i)(password|secret|token|key)$", "bearer$"] # matched against the key's name
values: ["^ENC\\(.+\\)$"] # and against its value
```
A repo's `.lazysecrets.yaml` can set `spring` too, for everyone, and wins
over the config, one list at a time.
Keys are named as Spring names them, `spring.datasource.password`; in a
file with several documents (`---`, or `#---` in `.properties`), a later
document's keys get its profile, `spring.datasource.password (prod)`.
Values Maven fills in at build time (`@project.version@`) are read as
they are.
## Decrypting
Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and
metadata (encryption, last modified) are read straight from the files.
Revealing, copying or `lazysecrets get` decrypts one value; SOPS's with
your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault
through az, AWS/GCP KMS, PGP). When sops can't decrypt, its whole error
message is shown.
Listing never decrypts: SOPS encrypts values only, so keys and metadata
(encryption, last modified) are read straight from the files. Revealing,
copying or `lazysecrets get` decrypts one value with your `sops` binary, so
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
KMS, PGP). When sops can't decrypt, its whole error message is shown.
On a file, the right pane shows how it's encrypted and the keys it's
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
@@ -118,22 +80,6 @@ tracked, ignored or neither, and its last commit. A plain `.env` file that
git tracks, or that isn't ignored, is marked in red: its values are in the
repo's history, or easily could be.
### Jasypt
A value like `ENC(...)` in a plain `.env` or Spring file is
[Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied
with jasypt-spring-boot's default algorithm (3.0+:
`PBEWITHHMACSHA512ANDAES_256`). The password is, as in Spring:
1. `$JASYPT_ENCRYPTOR_PASSWORD`,
2. the file's own: `jasypt.encryptor.password` in a Spring file,
`JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` (or
`${VAR:default}`) in it is read from the environment.
Environment variables are lazysecrets' own: a link opened from the browser
starts it without your shell's. A `.env` whose values are all Jasypt's
isn't marked in red.
## Opening files
`e` opens the selected file, or on a secret, its file at that key's line:
+3
View File
@@ -42,6 +42,9 @@ func New() Clipboard {
}
}
// Native reports whether the system clipboard is used rather than OSC52.
func (c Clipboard) Native() bool { return c.native }
// Write puts s on the clipboard.
func (c Clipboard) Write(s string) tea.Cmd {
if !c.native {
+4 -2
View File
@@ -16,7 +16,6 @@
package commands
import (
"cmp"
"fmt"
"regexp"
"strings"
@@ -61,7 +60,10 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config.
func (c Command) Validate() error {
name := cmp.Or(c.Description, c.Command)
name := c.Description
if name == "" {
name = c.Command
}
switch {
case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description)
+11 -12
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"strings"
@@ -15,7 +14,6 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
@@ -49,8 +47,7 @@ type UI struct {
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
// and in lazysecrets list. Defaults to false.
RecentIcon *bool `yaml:"recentIcon"`
// SourcesView is "grouped" (default) or "flat"; the backtick key
// toggles it.
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
@@ -154,8 +151,6 @@ type Repos struct {
// a repo's .sops.yaml creation_rules say where to look.
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
// Spring says which keys of Spring Boot config files are secrets.
Spring repo.SpringPatterns `yaml:"spring"`
}
type Azure struct {
@@ -208,12 +203,17 @@ func xdgDir(env, fallback string) (string, error) {
return filepath.Join(base, "lazysecrets"), nil
}
// Load reads path. A missing file is an empty config. Unknown keys are an
// error, so typos don't silently do nothing.
func Load(path string) (Config, error) {
return LoadAll([]string{path})
}
// LoadAll reads several config files in order, each on top of the ones
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
// settings it sets, and theme roles merge one by one. That lets an editor
// plugin add a generated theme file to the user's own config. Missing
// files are skipped. Unknown keys are an error, so typos don't silently do
// nothing.
// files are skipped.
func LoadAll(paths []string) (Config, error) {
var c Config
keybinding := map[string]map[string][]string{}
@@ -228,7 +228,9 @@ func LoadAll(paths []string) (Config, error) {
if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{}
}
maps.Copy(keybinding[ctx], actions)
for name, keys := range actions {
keybinding[ctx][name] = keys
}
}
}
c.Keybinding = keybinding
@@ -295,9 +297,6 @@ func (c Config) validate(path string) error {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
}
}
if err := c.Providers.Repos.Spring.Check(); err != nil {
return fmt.Errorf("%s: providers.repos.%w", path, err)
}
for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
+7 -8
View File
@@ -18,7 +18,7 @@ func write(t *testing.T, content string) string {
}
func TestLoad(t *testing.T) {
c, err := LoadAll([]string{write(t, `
c, err := Load(write(t, `
ui:
icons: false
sourcesView: flat
@@ -36,7 +36,7 @@ providers:
databricks:
enabled: false
hideProfiles: [test]
`)})
`))
if err != nil {
t.Fatal(err)
}
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""),
} {
c, err := LoadAll([]string{path})
c, err := Load(path)
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err)
}
@@ -84,9 +84,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
"regexp": "providers:\n repos:\n spring:\n keys: [\"(pass\"]\n",
} {
if _, err := LoadAll([]string{write(t, content)}); err == nil {
if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err)
@@ -133,18 +132,18 @@ theme:
}
func TestUpdates(t *testing.T) {
c, err := LoadAll([]string{write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")})
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n"))
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
t.Errorf("updates = %+v, %v", c.Updates, err)
}
}
func TestLoadDelay(t *testing.T) {
c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
}
if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted")
}
}
+7 -9
View File
@@ -9,6 +9,7 @@
package instance
import (
"bufio"
"context"
"encoding/json"
"errors"
@@ -35,18 +36,15 @@ func Dir(stateDir string) string {
type Status struct {
PID int `json:"pid"`
LastActive time.Time `json:"lastActive"` // last key press or focus
Reach int `json:"reach"` // how readily it can be shown: unreachable, switchable or inView
// Reach is how readily it can be shown: 2 in view (outside tmux, or
// in a tmux session a client shows), 1 in a tmux session that a tmux
// client can be switched to, 0 not at all.
Reach int `json:"reach"`
// App is the terminal app it would show in (macOS), preferring the
// app the link was clicked in.
App string `json:"app,omitempty"`
}
const (
unreachable = 0
switchable = 1 // in a tmux session that a tmux client can be switched to
inView = 2 // outside tmux, or in a tmux session a client shows
)
type request struct {
Op string `json:"op"` // "status" or "open"
Link string `json:"link,omitempty"`
@@ -116,7 +114,7 @@ func (s *Server) handle(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request
if err := json.NewDecoder(c).Decode(&req); err != nil {
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
return
}
var resp response
@@ -190,7 +188,7 @@ func Best(found []Found, front string, terminal bool) (Found, bool) {
best = f
}
}
return best, best.Reach > unreachable
return best, best.Reach > 0
}
// Open hands link, clicked in front, to f.
+23 -22
View File
@@ -1,7 +1,6 @@
package instance
import (
"cmp"
"os"
"os/exec"
"slices"
@@ -26,8 +25,7 @@ func tmux(args ...string) (string, error) {
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
type client struct {
name, session string
activity int // Unix seconds
name, session, activity string // activity: Unix seconds
pid int
app string
}
@@ -44,31 +42,30 @@ func clients() []client {
if len(f) < 4 {
continue
}
activity, err1 := strconv.Atoi(f[0])
pid, err2 := strconv.Atoi(f[1])
if err1 != nil || err2 != nil {
pid, err := strconv.Atoi(f[1])
if err != nil {
continue
}
cs = append(cs, client{name: f[2], session: f[3], activity: activity, pid: pid, app: linkhandler.HostApp(pid)})
cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)})
}
slices.SortStableFunc(cs, func(a, b client) int { return cmp.Compare(b.activity, a.activity) })
// Activity has the same width for centuries: strings compare like
// numbers.
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
return cs
}
// place is where this instance would show: the tmux client (zero outside
// tmux), the app, and its reach.
// tmux), the app, and reach: 2 already on screen there (outside tmux, or
// a client on its session), 1 after switching a client, 0 nowhere.
func place(front string) (client, string, int) {
if !inTmux() {
return client{}, linkhandler.HostApp(os.Getpid()), inView
return client{}, linkhandler.HostApp(os.Getpid()), 2
}
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
if err != nil {
return client{}, "", unreachable
return client{}, "", 0
}
cs := clients()
if len(cs) == 0 {
return client{}, "", unreachable
}
// Best first: in the front app on its session, in the front app,
// on its session, any.
rank := func(c client) int {
@@ -81,16 +78,20 @@ func place(front string) (client, string, int) {
}
return r
}
best := cs[0]
for _, c := range cs[1:] {
if rank(c) > rank(best) {
best = c
var best client
found := false
for _, c := range cs {
if !found || rank(c) > rank(best) {
best, found = c, true
}
}
if !found {
return client{}, "", 0
}
if best.session == session {
return best, best.app, inView
return best, best.app, 2
}
return best, best.app, switchable
return best, best.app, 1
}
// Raise brings this instance into view for a link clicked in front: in
@@ -98,14 +99,14 @@ func place(front string) (client, string, int) {
// then its app to the front, showing a hidden dropdown terminal.
func Raise(front string) {
c, app, reach := place(front)
if reach == unreachable {
if reach == 0 {
return
}
if inTmux() {
pane := os.Getenv("TMUX_PANE")
_, _ = tmux("select-window", "-t", pane)
_, _ = tmux("select-pane", "-t", pane)
if reach == switchable {
if reach == 1 {
_, _ = tmux("switch-client", "-c", c.name, "-t", pane)
}
}
-67
View File
@@ -1,67 +0,0 @@
// Package jasypt decrypts values encrypted with Jasypt the way
// jasypt-spring-boot 3.0 and later does by default: ENC(...) around the
// base64 of a random salt, a random IV and the ciphertext, with
// PBEWITHHMACSHA512ANDAES_256 (PBKDF2-HMAC-SHA512, 1000 iterations,
// AES-256-CBC).
package jasypt
import (
"crypto/aes"
"crypto/cipher"
"crypto/pbkdf2"
"crypto/sha512"
"encoding/base64"
"errors"
"strings"
"unicode/utf8"
)
const iterations = 1000
// ErrDecrypt is a value that doesn't decrypt with the password: a wrong
// password, or one encrypted with another algorithm.
var ErrDecrypt = errors.New("doesn't decrypt: a wrong password, or not Jasypt's default algorithm (PBEWITHHMACSHA512ANDAES_256)")
// Is reports whether v is a Jasypt value, ENC(...).
func Is(v string) bool {
return len(v) > len("ENC()") && strings.HasPrefix(v, "ENC(") && strings.HasSuffix(v, ")")
}
// Decrypt decrypts an ENC(...) value.
func Decrypt(v, password string) (string, error) {
b, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(v, "ENC("), ")"))
if err != nil {
return "", errors.New("not Jasypt: ENC(...) isn't base64")
}
const size = aes.BlockSize // the salt's and the IV's too
if len(b) < 3*size || len(b)%size != 0 {
return "", ErrDecrypt
}
salt, iv, data := b[:size], b[size:2*size], b[2*size:]
key, err := pbkdf2.Key(sha512.New, password, salt, iterations, 32)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plain := make([]byte, len(data))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data)
// A wrong password decrypts to noise: its padding is almost never
// valid, and the rest rarely UTF-8.
n := int(plain[len(plain)-1])
if n == 0 || n > size {
return "", ErrDecrypt
}
for _, c := range plain[len(plain)-n:] {
if int(c) != n {
return "", ErrDecrypt
}
}
plain = plain[:len(plain)-n]
if !utf8.Valid(plain) {
return "", ErrDecrypt
}
return string(plain), nil
}
-37
View File
@@ -1,37 +0,0 @@
package jasypt
import (
"errors"
"testing"
)
// Encrypted by Jasypt 1.9.3 itself, set up as jasypt-spring-boot's default
// encryptor, with the password "test-password".
var vectors = map[string]string{
"ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)": "hunter2",
"ENC(w4pm2JVZcbG5R+Ud9uKHg8VhJ/0aAK7sFXtNBv+Vo450SAchTQtfhw0TnIJ9gN+D0VFbwqJzfXU369coTJz2rw==)": "blåbærsyltetøy ✓",
}
func TestDecrypt(t *testing.T) {
for enc, want := range vectors {
if !Is(enc) {
t.Errorf("%s: not Jasypt", enc)
}
if got, err := Decrypt(enc, "test-password"); err != nil || got != want {
t.Errorf("%s: %q %v, want %q", enc, got, err, want)
}
if _, err := Decrypt(enc, "wrong"); !errors.Is(err, ErrDecrypt) {
t.Errorf("%s with a wrong password: %v", enc, err)
}
}
for _, v := range []string{"ENC(not base64!)", "ENC(aGVsbG8=)"} {
if _, err := Decrypt(v, "test-password"); err == nil {
t.Errorf("%s: no error", v)
}
}
for _, v := range []string{"ENC()", "hunter2", "ENC[AES256_GCM,data:x]", "${DB_PASSWORD}"} {
if Is(v) {
t.Errorf("%s: Jasypt", v)
}
}
}
+7 -3
View File
@@ -114,15 +114,19 @@ func Check() string {
}
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
if err != nil {
return "" // can't tell: assume it works
return "" // installed before the key was recorded: assume it works
}
exe := strings.TrimSpace(string(out))
if _, err := os.Stat(exe); err != nil {
if exe := strings.TrimSpace(string(out)); !exists(exe) {
return "lazysecrets:// links run " + exe + ", which no longer exists"
}
return ""
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration.
func Uninstall() (string, error) {
app, err := AppPath()
+2 -1
View File
@@ -140,10 +140,11 @@ func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
// Launch opens a new terminal window running argv, without waiting for
// Launch opens a new terminal window running inner, without waiting for
// it.
func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil {
return err
}
+3
View File
@@ -107,6 +107,9 @@ func parseTerm(tok string) (term, bool) {
return t, true
}
// Empty reports whether the query has no terms: everything matches.
func (q Query) Empty() bool { return len(q.groups) == 0 }
// Item is a row to match: Text is everything searched, and runes
// NameStart up to NameEnd of it are the row's name (the rest is context,
// like a path or a note). Without a name, nothing gets the name bonus.
+6
View File
@@ -99,3 +99,9 @@ func TestAnchorsOnTheName(t *testing.T) {
t.Errorf("password$: %v", got)
}
}
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+109 -87
View File
@@ -16,7 +16,6 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
@@ -100,7 +99,13 @@ func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindA
// Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []provider.Source) {
var out []provider.Source
pager := p.subs.NewListPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
@@ -113,7 +118,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.mu.Lock()
p.tenants[id] = tenant
p.mu.Unlock()
srcs = append(srcs, provider.Source{
out = append(out, provider.Source{
Kind: provider.KindAzure,
ID: id,
Name: deref(s.DisplayName),
@@ -121,10 +126,6 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
})
}
return srcs
})
if err != nil {
return nil, err
}
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
if len(p.opts.Vaults) > 0 {
@@ -229,29 +230,22 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
if err != nil {
return nil, err
}
return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) {
for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
vaults = append(vaults, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
return vaults
})
}
// all lists every page, items picking out what to keep of each.
func all[P, T any](ctx context.Context, pager *runtime.Pager[P], items func(P) []T) ([]T, error) {
var out []T
var out []provider.Container
pager := client.NewListBySubscriptionPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
out = append(out, items(page)...)
for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
out = append(out, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
}
return out, nil
}
@@ -294,40 +288,51 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
var out []provider.Object
switch t {
case provider.TypeSecret:
tenant := p.tenant(c.Source.ID)
out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) {
pager := vc.secrets.NewListSecretPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates.
if s.ID != nil && !deref(s.Managed) {
objs = append(objs, secretObject(c, s, tenant))
if s.ID == nil || deref(s.Managed) {
continue
}
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
}
}
return objs
})
case provider.TypeKey:
out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) {
pager := vc.keys.NewListKeyPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID != nil && !deref(k.Managed) {
objs = append(objs, keyObject(c, k))
if k.KID == nil || deref(k.Managed) {
continue
}
out = append(out, keyObject(c, k))
}
}
return objs
})
case provider.TypeCertificate:
out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) {
pager := vc.certs.NewListCertificatePropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID != nil {
objs = append(objs, certObject(c, cp))
if cp.ID == nil {
continue
}
out = append(out, certObject(c, cp))
}
}
return objs
})
default:
return nil, provider.ErrUnsupported
}
if err != nil {
return nil, err
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil
}
@@ -348,7 +353,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
if resp.Key != nil {
if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID))
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
}
o.Attrs = keyAttrs(resp.Key)
}
@@ -359,7 +364,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
}
if resp.ID != nil {
o.Version = resp.ID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.ID))
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
}
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType)
@@ -376,46 +381,56 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err != nil {
return nil, err
}
tenant := p.tenant(o.Container.Source.ID)
var out []provider.Object
switch o.Type {
case provider.TypeSecret:
out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (objs []provider.Object) {
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
if s.ID != nil {
objs = append(objs, secretObject(o.Container, s, tenant))
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID)))
}
}
}
return objs
})
case provider.TypeKey:
out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) {
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID != nil {
objs = append(objs, keyObject(o.Container, k))
out = append(out, keyObject(o.Container, k))
}
}
}
return objs
})
case provider.TypeCertificate:
out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) {
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID != nil {
objs = append(objs, certObject(o.Container, cp))
out = append(out, certObject(o.Container, cp))
}
}
}
return objs
})
default:
return nil, provider.ErrUnsupported
}
if err != nil {
return nil, err
}
tenant := p.tenant(o.Container.Source.ID)
for i := range out {
out[i].Reference = versionedReference(out[i])
if o.Type != provider.TypeSecret { // secretObject's is the version's already
out[i].URL = portalAssetURL(tenant, o.Type, out[i].ID+"/"+out[i].Version) // the version's page
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type]
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
}
}
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
@@ -437,19 +452,47 @@ func versionedReference(o provider.Object) string {
// Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty).
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, err
}
switch o.Type {
case provider.TypeSecret:
v, _, err := p.VersionedValue(ctx, o)
return v, err
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
pemKey, err := publicKeyPEM(resp.Key)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
}
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
}
return provider.SecretValue{}, provider.ErrUnsupported
}
// VersionedValue is Value, with the version read.
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
if o.Type != provider.TypeSecret {
v, err := p.Value(ctx, o)
return v, o.Version, err
}
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, "", err
}
var text string
switch o.Type {
case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
@@ -459,27 +502,6 @@ func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provi
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if text, err = publicKeyPEM(resp.Key); err != nil {
return provider.SecretValue{}, "", err
}
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, "", errors.New("certificate has no public certificate yet (pending issuance?)")
}
text = certPEM(resp.CER)
default:
return provider.SecretValue{}, "", provider.ErrUnsupported
}
return provider.NewSecretValue([]byte(text)), o.Version, nil
}
var (
+15 -9
View File
@@ -55,8 +55,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
// portalAssetURL links to a Key Vault object's page: a version's with
// one in objectID; a secret's own (its versions) without.
func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID
func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
}
func tenantPrefix(tenant string) string {
@@ -73,13 +73,19 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
if !ok {
return ""
}
return base + "/" + t.Collection()
switch t {
case provider.TypeKey:
return base + "/keys"
case provider.TypeCertificate:
return base + "/certificates"
}
return base + "/secrets"
}
// objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, t provider.ObjectType, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name
func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
}
// secretObject is a secret, or a version of one, from a listing. Its URL
@@ -87,7 +93,7 @@ func objectURL(c provider.Container, t provider.ObjectType, name string) string
// a version, that version's.
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
name := p.ID.Name()
url := objectURL(c, provider.TypeSecret, name)
url := objectURL(c, "secrets", name)
o := provider.Object{
Container: c,
Type: provider.TypeSecret,
@@ -97,7 +103,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
ContentType: deref(p.ContentType),
Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: portalAssetURL(tenant, provider.TypeSecret, strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
@@ -107,7 +113,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name()
url := objectURL(c, provider.TypeKey, name)
url := objectURL(c, "keys", name)
o := provider.Object{
Container: c,
Type: provider.TypeKey,
@@ -126,7 +132,7 @@ func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, provider.TypeCertificate, name)
url := objectURL(c, "certificates", name)
o := provider.Object{
Container: c,
Type: provider.TypeCertificate,
+13 -13
View File
@@ -2,7 +2,6 @@ package azure
import (
"fmt"
"maps"
"slices"
"strings"
@@ -71,10 +70,13 @@ func graphInfo(m map[string]any) vaultInfo {
// armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo {
info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)}
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
info.group = id.ResourceGroupName
}
for k, t := range v.Tags {
info.tags[k] = deref(t)
}
pr := v.Properties
if pr == nil {
return info
@@ -120,11 +122,7 @@ func (v vaultInfo) attrs() []provider.Attr {
} else {
add("soft delete", "off")
}
if on(v.purge, false) {
add("purge prot.", "on")
} else {
add("purge prot.", "off")
}
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
switch {
case strings.EqualFold(v.publicAccess, "disabled"):
add("network", "private endpoints only")
@@ -133,13 +131,15 @@ func (v vaultInfo) attrs() []provider.Attr {
default:
add("network", "all networks")
}
switch {
case v.endpoints == 1:
add("private", "1 endpoint")
case v.endpoints > 1:
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
if v.endpoints > 0 {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
}
for i, k := range slices.Sorted(maps.Keys(v.tags)) {
keys := make([]string, 0, len(v.tags))
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := ""
if i == 0 {
label = "tags"
+11 -14
View File
@@ -203,20 +203,17 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
return out, nil
}
// fallbackVault is the backing vault of an AKV-backed scope, to read it
// from when Databricks fails, or "" when there's none to fall back to. It
// falls back to the scope's Detail, so a scope remembered from an earlier
// session (a favourite) works before its workspace's scopes are listed.
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
if p.keyVault == nil || ctx.Err() != nil {
return ""
}
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
// back to the scope's Detail, so a scope remembered from an earlier session
// (a favourite) works before its workspace's scopes are listed.
func (p *Provider) vaultURL(c provider.Container) string {
p.mu.Lock()
url := p.vaults[c.ID]
p.mu.Unlock()
if url == "" {
if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok {
url = detail
url, _ = strings.CutPrefix(c.Detail, akvDetail)
if url == c.Detail {
url = ""
}
}
return url
@@ -267,8 +264,8 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
url := p.fallbackVault(ctx, c)
if url == "" {
url := p.vaultURL(c)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return nil, errNoFallback
}
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
@@ -305,8 +302,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
}
dbxErr := classify(pr, err)
url := p.fallbackVault(ctx, o.Container)
if url == "" {
url := p.vaultURL(o.Container)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return provider.SecretValue{}, dbxErr
}
v, kvErr := p.keyVault.Value(ctx, provider.Object{
+49 -30
View File
@@ -48,8 +48,19 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
func (p *Provider) ID() string {
if p.id != "" {
return p.id
}
return string(p.kind)
}
func (p *Provider) Label() string {
if p.label != "" {
return p.label
}
return string(p.kind)
}
// As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider {
@@ -225,7 +236,9 @@ func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.O
o.Type = t
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
o.URL = base + "/" + t.Collection()
o.URL = base + map[provider.ObjectType]string{
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
}[t]
}
k := listKey{c.ID, t}
p.objects[k] = append(p.objects[k], o)
@@ -239,7 +252,11 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
}
}
var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
var (
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
yes = true
no = false
)
// ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
@@ -254,13 +271,12 @@ const (
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
func secretRef(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
// Azure returns a fake Azure provider with two subscriptions.
func Azure(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
objectURL := func(vault, kind, name string) string {
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
}
@@ -268,32 +284,32 @@ func Azure(delay time.Duration) *Provider {
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
}
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0),
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"},
@@ -301,7 +317,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"},
@@ -310,7 +326,7 @@ func Azure(delay time.Duration) *Provider {
}, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"},
@@ -322,7 +338,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"},
@@ -335,10 +351,10 @@ func Azure(delay time.Duration) *Provider {
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
@@ -348,19 +364,19 @@ func Azure(delay time.Duration) *Provider {
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-password")
// Left behind, as dev vaults are: expired, disabled, or both.
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
}, "whsec_demo_expired")
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0),
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0),
Reference: secretRef("kv-app-dev", "test-user-password"),
}, "disabled-demo-password")
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0),
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled")
@@ -372,21 +388,24 @@ func Azure(delay time.Duration) *Provider {
// its own, like an admin account next to a personal one.
func AzureOps(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0),
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
// Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey)
@@ -394,12 +413,12 @@ func AzureOps(delay time.Duration) *Provider {
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token")
// Expiring soon whenever the demo runs: the other dates are fixed,
// and would have passed. Tests use the first account only.
p.add(kv, provider.TypeSecret, provider.Object{
Name: "grafana-api-key", Enabled: new(true), Updated: base.AddDate(0, -11, 0),
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0),
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
}, "glsa_demo_expiring")
-15
View File
@@ -84,20 +84,6 @@ const (
TypeCertificate ObjectType = "Certificate"
)
// Collection is the type's path segment in Key Vault URLs and links, e.g.
// "secrets".
func (t ObjectType) Collection() string {
switch t {
case TypeSecret:
return "secrets"
case TypeKey:
return "keys"
case TypeCertificate:
return "certificates"
}
return ""
}
// Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct {
Source Source
@@ -133,7 +119,6 @@ type Object struct {
Type ObjectType
ID string
Name string
Detail string // shown after the name, e.g. "jasypt" for how its value is kept
Version string
Enabled *bool
Updated time.Time
+4 -36
View File
@@ -2,8 +2,6 @@ package repo
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -24,12 +22,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
}
var out []provider.Attr
meta, isSops := readMeta(c.ID)
// A plain file's values are at risk in git, unless they're all Jasypt;
// a Spring file's only when one of its secrets is in plain text.
plain := !isSops
if !isSops && IsSpring(c.ID) {
out, plain = p.springDetails(c)
} else if isSops {
if isSops {
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
for i, k := range meta.recipients {
label := ""
@@ -42,7 +35,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
}
} else {
out, plain = envDetails(c.ID)
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
}
git := func(args ...string) (string, bool) {
@@ -50,7 +43,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
return strings.TrimSpace(string(b)), err == nil
}
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
case tracked && plain:
case tracked && !isSops:
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
case tracked:
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
@@ -58,7 +51,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
} else {
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: plain})
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
}
return out, nil
}
@@ -67,28 +60,3 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
}
return out, nil
}
// envDetails says how a plain env file's values are encrypted, and whether
// any is in plain text.
func envDetails(file string) (attrs []provider.Attr, plain bool) {
b, err := os.ReadFile(file)
if err != nil {
return []provider.Attr{{Label: "encryption", Value: err.Error(), Warn: true}}, true
}
keys, _ := parseKeys(file, b)
n := 0
for _, k := range keys {
if k.jasypt {
n++
}
}
return []provider.Attr{{Label: "encryption", Value: jasyptShare(n, len(keys))}}, n < len(keys)
}
// jasyptShare is how many of a plain file's values are Jasypt's.
func jasyptShare(n, of int) string {
if n == 0 {
return "none: plain text"
}
return fmt.Sprintf("jasypt: %d of %d values", n, of)
}
-74
View File
@@ -1,74 +0,0 @@
package repo
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// jasyptEnv is where Spring looks for the password first, before the
// config's jasypt.encryptor.password, and so does lazysecrets.
const jasyptEnv = "JASYPT_ENCRYPTOR_PASSWORD"
// jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain
// file; other values are as they are.
func jasyptValue(file string, v provider.SecretValue) (provider.SecretValue, error) {
s := v.Reveal()
if !jasypt.Is(s) {
return v, nil
}
password, from, err := jasyptPassword(file)
if err != nil {
return provider.SecretValue{}, err
}
plain, err := jasypt.Decrypt(s, password)
if errors.Is(err, jasypt.ErrDecrypt) {
return provider.SecretValue{}, fmt.Errorf("jasypt: with the password from %s: %w", from, err)
}
if err != nil {
return provider.SecretValue{}, fmt.Errorf("jasypt: %w", err)
}
return provider.NewSecretValue([]byte(plain)), nil
}
// jasyptPassword is the password for a file's Jasypt values, and where it's
// from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's own setting
// (jasypt.encryptor.password in a Spring file, JASYPT_ENCRYPTOR_PASSWORD
// in an env file), with a ${VAR} in it read from the environment.
func jasyptPassword(file string) (password, from string, err error) {
if v := os.Getenv(jasyptEnv); v != "" {
return v, "$" + jasyptEnv, nil
}
setting, read := jasyptEnv, plainValue
if IsSpring(file) {
setting, read = "jasypt.encryptor.password", springValue
}
v, err := read(file, setting)
if err != nil {
return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file))
}
return resolvePlaceholder(v.Reveal(), setting+" in "+filepath.Base(file))
}
// placeholder is Spring's ${VAR} or ${VAR:default}.
var placeholder = regexp.MustCompile(`^\$\{([^:}]+)(:[^}]*)?\}$`)
// resolvePlaceholder reads a ${VAR} value from the environment, falling
// back to its default; other values are as they are.
func resolvePlaceholder(v, from string) (string, string, error) {
m := placeholder.FindStringSubmatch(v)
switch {
case m == nil:
return v, from, nil
case os.Getenv(m[1]) != "":
return os.Getenv(m[1]), "$" + m[1] + " (" + from + ")", nil
case m[2] != "":
return m[2][1:], "the default in " + from, nil
}
return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v, m[1])
}
-102
View File
@@ -1,102 +0,0 @@
package repo
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// hunter2, encrypted by Jasypt with the password "test-password".
const jasyptHunter2 = "ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)"
func TestJasyptPassword(t *testing.T) {
dir := t.TempDir()
value := func(name, body string) (string, error) {
file := filepath.Join(dir, name)
if err := os.WriteFile(file, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
key := "app.token"
if !IsSpring(name) {
key = "TOKEN"
}
v, err := (&Provider{}).Value(context.Background(), provider.Object{ID: file + "\x00" + key, Name: key})
return v.Reveal(), err
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
t.Setenv("MY_PASSWORD", "")
spring := func(password string) string {
return "jasypt.encryptor.password: " + password + "\napp.token: " + jasyptHunter2 + "\n"
}
for _, c := range []struct{ name, file, body, env, want string }{
{"the file's own", "application.yml", spring("test-password"), "", "hunter2"},
{"a ${VAR}", "application.yml", spring("${MY_PASSWORD}"), "test-password", "hunter2"},
{"a ${VAR}'s default", "application.yml", spring("${MY_PASSWORD:test-password}"), "", "hunter2"},
{"properties", "application-local.properties", "jasypt.encryptor.password=test-password\napp.token=" + jasyptHunter2 + "\n", "", "hunter2"},
{"env file", ".env", "JASYPT_ENCRYPTOR_PASSWORD=test-password\nTOKEN=" + jasyptHunter2 + "\n", "", "hunter2"},
{"not jasypt", ".env", "TOKEN=hunter2\n", "", "hunter2"},
} {
t.Setenv("MY_PASSWORD", c.env)
if got, err := value(c.file, c.body); err != nil || got != c.want {
t.Errorf("%s: %q %v", c.name, got, err)
}
}
// $JASYPT_ENCRYPTOR_PASSWORD wins over the file's, as in Spring.
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
if got, err := value("application.yml", spring("wrong")); err != nil || got != "hunter2" {
t.Errorf("environment first: %q %v", got, err)
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "wrong")
if _, err := value("application.yml", spring("test-password")); err == nil || !strings.Contains(err.Error(), "the password from $JASYPT_ENCRYPTOR_PASSWORD") {
t.Errorf("a wrong one says where it's from: %v", err)
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
t.Setenv("MY_PASSWORD", "")
for body, want := range map[string]string{
spring("${MY_PASSWORD}"): "$MY_PASSWORD isn't set",
"app.token: " + jasyptHunter2: "set $JASYPT_ENCRYPTOR_PASSWORD, or jasypt.encryptor.password in application.yml",
} {
if _, err := value("application.yml", body); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("no password: %v, want %q", err, want)
}
}
}
func TestJasyptEnvFile(t *testing.T) {
root := gitRepo(t, map[string]string{
"jasypt.env": "TOKEN=" + jasyptHunter2 + "\n",
"mixed.env": "TOKEN=" + jasyptHunter2 + "\nURL=https://example.com\n",
})
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, _ := p.Containers(ctx, srcs[0])
for _, c := range cs {
attrs, _ := p.Details(ctx, c)
got := map[string]provider.Attr{}
for _, a := range attrs {
got[a.Label] = a
}
switch c.Name {
case "jasypt.env":
if got["encryption"].Value != "jasypt: 1 of 1 values" || got["git"].Warn {
t.Errorf("all jasypt: %+v", got)
}
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
if len(keys) != 1 || keys[0].Attrs[1].Value != "jasypt" || keys[0].Detail != "jasypt" {
t.Errorf("keys: %+v", keys)
}
case "mixed.env":
if got["encryption"].Value != "jasypt: 1 of 2 values" || !got["git"].Warn {
t.Errorf("mixed: %+v", got)
}
}
}
}
+86 -102
View File
@@ -1,7 +1,6 @@
// Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and
// their keys the secrets. Secret files are SOPS files, plain env files and
// Spring Boot config (spring.go).
// their keys the secrets. SOPS files are the one type so far.
//
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
// metadata are read directly. A value is decrypted when it's revealed or
@@ -32,7 +31,6 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -51,9 +49,6 @@ type Settings struct {
// Include and Exclude are globs relative to a repo's root (** matches
// any depth), for repos without their own .lazysecrets.yaml.
Include, Exclude []string
// Spring says which keys of Spring Boot config files are secrets, for
// repos whose .lazysecrets.yaml doesn't.
Spring SpringPatterns
// Sops is the sops binary; "sops" (on PATH) when empty.
Sops string
}
@@ -111,6 +106,8 @@ func (p *Provider) load() {
roots = append(roots, root)
}
}
// The repo lazysecrets started in, if any: marked, so the UI can
// put it on top, with its files under Dir first.
here, inRepo := p.current()
if inRepo {
seen[here.root] = true
@@ -140,6 +137,7 @@ func (p *Provider) load() {
add(root)
}
}
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil {
add(root)
@@ -208,11 +206,13 @@ func parallel(n int, f func(i int)) {
var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
f(i)
})
}()
}
wg.Wait()
}
@@ -325,8 +325,6 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
return // a SOPS file where .sops.yaml says SOPS files don't go
case IsEnv(f):
c.Detail = provider.DetailPlainEnv
case IsSpring(f):
c.Detail = "spring · " + springFormat(f)
default:
return
}
@@ -363,29 +361,30 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
if err != nil {
return nil, err
}
meta, isSops := parseMeta(c.ID, b)
if !isSops && IsSpring(c.ID) {
return p.springObjects(c, b)
}
meta, isSops := readMeta(c.ID)
keys, err := parseKeys(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
if isSops {
attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}
if !isSops { // a plain env file
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
})
}
return out, nil
}
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
o := provider.Object{
Container: c, Type: provider.TypeSecret,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
}
switch {
case isSops && !k.encrypted:
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
case !isSops && k.jasypt:
o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt"
if !k.encrypted {
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
}
out = append(out, o)
}
@@ -400,29 +399,18 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
return nil, provider.ErrUnsupported
}
// Value reads one key: from a plain file, decrypting a Jasypt value; from
// a SOPS file, with sops.
// Value decrypts one key with sops.
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key
file = filePath(o.Container)
file = o.Container.ID
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
extract = extractFor(file, o.Name)
}
if _, isSops := readMeta(file); !isSops {
read := plainValue
if IsSpring(file) {
read = springValue
}
v, err := read(file, o.Name)
if err != nil {
return v, err
}
return jasyptValue(file, v)
}
if !ok {
var err error
if extract, err = extractOf(file, o.Name); err != nil {
return provider.SecretValue{}, err
}
return plainValue(file, o.Name)
}
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
@@ -446,18 +434,12 @@ func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, er
return os.ReadFile(f.Path)
}
// filePath is the file c is. A container made from a link has no ID: the
// file is its path in the repo.
func filePath(c provider.Container) string {
if c.ID != "" {
return c.ID
}
return filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
// File is the file c is, and the line o's key is on.
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := filePath(c)
path := c.ID
if path == "" { // made from a link
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
f := provider.File{Path: path}
if _, isSops := readMeta(path); isSops {
f.Sops = p.s.Sops
@@ -465,12 +447,6 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File
if o == nil {
return f, nil
}
if f.Sops == "" && IsSpring(path) {
if k, err := springKeyNamed(path, o.Name); err == nil {
f.Line = k.line
}
return f, nil
}
b, err := os.ReadFile(path)
if err != nil {
return f, err
@@ -624,7 +600,6 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile struct {
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
Spring SpringPatterns `yaml:"spring"`
Sharing struct {
// Key is the link to the secret that encrypts the repo's
// shared env files (package share).
@@ -634,39 +609,34 @@ type lazysecretsFile struct {
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) {
f, err := readLazysecretsFile(root)
return f.Sharing.Key, err
}
// readLazysecretsFile reads the repo's .lazysecrets.yaml; without one, it's
// empty.
func readLazysecretsFile(root string) (lazysecretsFile, error) {
var f lazysecretsFile
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) {
return f, nil
return "", nil
}
if err != nil {
return f, err
return "", err
}
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return f, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return f, nil
return f.Sharing.Key, nil
}
// selector says which files to look at: user (the include/exclude of the
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
f, err := readLazysecretsFile(root)
if err != nil {
return nil, nil, err
}
include, exclude := p.s.Include, p.s.Exclude
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude
}
}
inc, err := globs(include)
if err != nil {
return nil, nil, err
@@ -937,21 +907,12 @@ func readMeta(file string) (sopsMeta, bool) {
return sopsMeta{}, false // not a format sops writes: skip reading
}
b, err := os.ReadFile(file)
if err != nil {
return sopsMeta{}, false
}
return parseMeta(file, b)
}
// parseMeta is readMeta, of the file's contents b.
func parseMeta(file string, b []byte) (sopsMeta, bool) {
format := formatOf(file)
if len(b) > maxFileSize || format == "" || !bytes.Contains(b, []byte("sops")) {
if err != nil || !bytes.Contains(b, []byte("sops")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
}
var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch format {
switch formatOf(file) {
case "yaml", "json":
var doc struct {
Sops map[string]any `yaml:"sops"`
@@ -1055,7 +1016,6 @@ type key struct {
name string // db.password, list[0], with . [ ] \ in keys escaped
extract string // sops --extract: ["db"]["password"]
encrypted bool
jasypt bool // a plain env file's ENC(...) value
line int // 1-based
}
@@ -1080,8 +1040,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
if !ok || strings.HasPrefix(k, "sops_") {
continue
}
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), jasypt: jasypt.Is(v), line: i + 1})
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
}
return out, nil
case "ini":
@@ -1146,23 +1105,48 @@ func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractOf is the sops --extract path of the key named name, for objects
// made from a link.
func extractOf(file, name string) (string, error) {
b, err := os.ReadFile(file)
if err != nil {
return "", err
// extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
// its names are the keys as they are.
func extractFor(file, name string) string {
if formatOf(file) == "dotenv" {
return quote(name)
}
keys, err := parseKeys(file, b)
if err != nil {
return "", err
}
for _, k := range keys {
if k.name == name {
return k.extract, nil
var b, part strings.Builder
flush := func() {
if part.Len() > 0 {
b.WriteString(quote(part.String()))
part.Reset()
}
}
return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
for i := 0; i < len(name); i++ {
switch c := name[i]; c {
case '\\':
if i+1 < len(name) {
i++
}
part.WriteByte(name[i])
case '.':
flush()
case '[':
end := strings.IndexByte(name[i:], ']')
if end < 0 || !digits(name[i+1:i+end]) {
part.WriteByte(c)
continue
}
flush()
b.WriteString(name[i : i+end+1])
i += end
default:
part.WriteByte(c)
}
}
flush()
return b.String()
}
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
}
func absPath(p string) string {
+5 -31
View File
@@ -237,25 +237,6 @@ func TestDecrypt(t *testing.T) {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
// A dotenv file's export lines: sops keeps export in the key.
os.WriteFile(plain, []byte("export TOKEN=s3cret\n"), 0o600)
enc, err = exec.Command("sops", "encrypt", "--age", recipient, "--input-type", "dotenv", "--output-type", "dotenv", plain).Output()
if err != nil {
t.Fatal(err)
}
envFile := filepath.Join(root, "prod.env")
os.WriteFile(envFile, enc, 0o600)
envC := provider.Container{Source: srcs[0], ID: envFile, Name: "prod.env"}
envKeys, _ := p.Objects(ctx, envC, provider.TypeSecret)
if len(envKeys) != 1 {
t.Fatalf("env keys: %v", envKeys)
}
for _, o := range []provider.Object{envKeys[0], {Container: provider.Container{Source: srcs[0], Name: "prod.env"}, Name: "TOKEN"}} {
if v, err := p.Value(ctx, o); err != nil || v.Reveal() != "s3cret" {
t.Errorf("export line (link: %t): %q %v", o.ID == "", v.Reveal(), err)
}
}
// Without the key: sops' own error.
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
@@ -573,22 +554,15 @@ func TestKeyNamesEscaped(t *testing.T) {
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
keys, err = parseKeys(".env", []byte("spring.url=a\nexport TOKEN=b\nSPACED = c\n"))
if err != nil {
t.Fatal(err)
}
got = nil
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
}
want = `spring.url ["spring.url"]|TOKEN ["export TOKEN"]|SPACED ["SPACED "]`
if strings.Join(got, "|") != want {
t.Errorf("dotenv keys = %s\nwant %s", strings.Join(got, "|"), want)
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
-411
View File
@@ -1,411 +0,0 @@
package repo
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode"
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Spring Boot config files (application*.properties/.yml, bootstrap*) hold
// all of an app's config, most of it not secret: of their keys, only those
// named like a secret or with a value that looks like one are listed. The
// patterns are configurable, and the file's details count the rest, so a
// secret the patterns miss isn't hidden without a trace.
// DefaultSpringKeys and DefaultSpringValues are the patterns used when
// neither the config nor the repo's .lazysecrets.yaml sets them. The key
// pattern is anchored at the end of the name: spring.datasource.password,
// client-secret and checkoutKey, not the oauth2.*, keycloak.* and
// token-uri keys every Spring app has. passord is Norwegian. The file's
// details count the keys left out.
var (
DefaultSpringKeys = []string{
`(?i)(pass(word|wd|phrase|ord)?|pwd|secret|token|key|credentials?|salt|connection-?string)$`,
}
DefaultSpringValues = []string{
`^ENC\(.+\)$`, // Jasypt
`://[^/\s:@]*:[^/\s@]+@`, // a URL with a password: jdbc:postgresql://user:pass@host
`(?i)(AccountKey|SharedAccessKey|Password|Pwd)=`, // a connection string with one
}
)
// SpringPatterns are regexps matched against a Spring config key's name
// and its value: a key is listed when either matches. A list that isn't
// set is DefaultSpringKeys or DefaultSpringValues.
type SpringPatterns struct {
Keys *[]string `yaml:"keys"`
Values *[]string `yaml:"values"`
}
// Check says which pattern doesn't compile.
func (p SpringPatterns) Check() error {
_, err := compileSpring(p)
return err
}
type springFilter struct{ keys, values []*regexp.Regexp }
func compileSpring(p SpringPatterns) (springFilter, error) {
var f springFilter
var errs []error
add := func(field string, patterns *[]string, defaults []string, out *[]*regexp.Regexp) {
list := defaults
if patterns != nil {
list = *patterns
}
for i, s := range list {
re, err := regexp.Compile(s)
if err != nil {
errs = append(errs, fmt.Errorf("spring.%s[%d]: %w", field, i, err))
continue
}
*out = append(*out, re)
}
}
add("keys", p.Keys, DefaultSpringKeys, &f.keys)
add("values", p.Values, DefaultSpringValues, &f.values)
return f, errors.Join(errs...)
}
func (f springFilter) secret(k springKey) bool {
return anyMatch(f.keys, k.key) || anyMatch(f.values, k.value)
}
// springFilterFor is the repo's patterns: its .lazysecrets.yaml's, else the
// config's, each list on its own.
func (p *Provider) springFilterFor(root string) (springFilter, error) {
f, err := readLazysecretsFile(root)
if err != nil {
return springFilter{}, err
}
patterns := p.s.Spring
if f.Spring.Keys != nil {
patterns.Keys = f.Spring.Keys
}
if f.Spring.Values != nil {
patterns.Values = f.Spring.Values
}
filter, err := compileSpring(patterns)
if err != nil && (f.Spring.Keys != nil || f.Spring.Values != nil) {
err = fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return filter, err
}
// springName is application.yml, application-prod.properties, bootstrap.yaml
// and the like; not applications.yaml (Argo CD's) or application.yml.example.
var springName = regexp.MustCompile(`^(application|bootstrap)(-[^.]+)?\.(properties|ya?ml)$`)
// IsSpring reports whether f is named like a Spring Boot config file, and
// isn't a Helm chart's template of one.
func IsSpring(f string) bool {
return springName.MatchString(strings.ToLower(path.Base(f))) && path.Base(path.Dir(f)) != "templates"
}
func springFormat(f string) string {
if strings.ToLower(filepath.Ext(f)) == ".properties" {
return "properties"
}
return "yaml"
}
type springKey struct {
key string // spring.datasource.password
name string // key, with (profile) after it in a later document
value string
line int // 1-based
}
// reference reports whether the value is only a placeholder, ${DB_PASSWORD}:
// the secret is elsewhere. One with a default, ${DB_PASSWORD:hunter2}, has
// the default in the file.
func (k springKey) reference() bool {
return strings.HasPrefix(k.value, "${") && strings.HasSuffix(k.value, "}") &&
!strings.ContainsAny(k.value[2:len(k.value)-1], ":${}")
}
func (k springKey) jasypt() bool {
return jasypt.Is(k.value)
}
// plain reports whether the file holds the value itself, readable.
func (k springKey) plain() bool {
return k.value != "" && !k.reference() && !k.jasypt()
}
// readSpring lists a Spring config file's keys, every one, with values.
func readSpring(file string) ([]springKey, error) {
b, err := os.ReadFile(file)
if err != nil {
return nil, err
}
return parseSpring(file, b)
}
func parseSpring(file string, b []byte) ([]springKey, error) {
if springFormat(file) == "properties" {
return parseProperties(b), nil
}
return parseSpringYAML(b)
}
// springDocs names the keys of a multi-document file's later documents
// after the profile they're for, so each name is one key:
// spring.datasource.password (prod).
func springDocs(docs [][]springKey) []springKey {
var out []springKey
for i, doc := range docs {
suffix := ""
if i > 0 {
suffix = fmt.Sprintf(" (document %d)", i+1)
for _, k := range doc {
if k.key == "spring.config.activate.on-profile" || k.key == "spring.profiles" {
suffix = " (" + k.value + ")"
}
}
}
for _, k := range doc {
k.name = k.key + suffix
out = append(out, k)
}
}
return out
}
// mavenFiltered is a value Maven fills in at build time, @project.version@:
// not YAML until it's quoted.
var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`)
func parseSpringYAML(b []byte) ([]springKey, error) {
b = mavenFiltered.ReplaceAllFunc(b, func(m []byte) []byte {
sub := mavenFiltered.FindSubmatch(m)
return fmt.Appendf(nil, "%s'%s'", sub[1], bytes.ReplaceAll(sub[2], []byte("'"), []byte("''")))
})
dec := yaml.NewDecoder(bytes.NewReader(b))
var docs [][]springKey
for {
var doc yaml.Node
err := dec.Decode(&doc)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
var keys []springKey
if len(doc.Content) > 0 {
springWalk(doc.Content[0], "", &keys)
}
docs = append(docs, keys)
}
return springDocs(docs), nil
}
// springWalk names keys the way Spring does: nested keys joined by dots,
// list items by index (servers[0]).
func springWalk(n *yaml.Node, name string, out *[]springKey) {
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
child := n.Content[i].Value
if name != "" {
child = name + "." + child
}
springWalk(n.Content[i+1], child, out)
}
case yaml.SequenceNode:
for i, c := range n.Content {
springWalk(c, fmt.Sprintf("%s[%d]", name, i), out)
}
case yaml.AliasNode:
if n.Alias != nil {
springWalk(n.Alias, name, out)
}
case yaml.ScalarNode:
if name != "" {
*out = append(*out, springKey{key: name, value: n.Value, line: n.Line})
}
}
}
// parseProperties reads a Java .properties file: key=value, key: value or
// key value, lines continued with a trailing backslash, # and ! comments,
// and #--- between documents (Spring Boot 2.4+).
func parseProperties(b []byte) []springKey {
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
var docs [][]springKey
var doc []springKey
for i := 0; i < len(lines); i++ {
start := i + 1
line := strings.TrimLeftFunc(lines[i], unicode.IsSpace)
if line == "#---" || line == "!---" {
docs, doc = append(docs, doc), nil
continue
}
if line == "" || line[0] == '#' || line[0] == '!' {
continue
}
for continued(line) && i+1 < len(lines) {
i++
line = line[:len(line)-1] + strings.TrimLeftFunc(lines[i], unicode.IsSpace)
}
k, v := splitProperty(line)
doc = append(doc, springKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start})
}
return springDocs(append(docs, doc))
}
// continued reports whether the line ends in an odd number of backslashes.
func continued(line string) bool {
n := len(line) - len(strings.TrimRight(line, `\`))
return n%2 == 1
}
// splitProperty cuts a logical line at its first unescaped =, : or
// whitespace; whitespace around the separator isn't part of either side.
func splitProperty(line string) (key, value string) {
const space = " \t\f"
for i := 0; i < len(line); i++ {
switch c := line[i]; {
case c == '\\':
i++
case c == '=' || c == ':':
return line[:i], strings.TrimLeft(line[i+1:], space)
case strings.IndexByte(space, c) >= 0:
rest := strings.TrimLeft(line[i:], space)
if rest != "" && (rest[0] == '=' || rest[0] == ':') {
rest = strings.TrimLeft(rest[1:], space)
}
return line[:i], rest
}
}
return line, ""
}
func unescapeProperty(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '\\' || i+1 == len(s) {
b.WriteByte(s[i])
continue
}
i++
switch s[i] {
case 't':
b.WriteByte('\t')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 'f':
b.WriteByte('\f')
case 'u':
if i+5 <= len(s) {
if r, err := strconv.ParseUint(s[i+1:i+5], 16, 32); err == nil {
b.WriteRune(rune(r))
i += 4
continue
}
}
b.WriteByte('u')
default:
b.WriteByte(s[i])
}
}
return b.String()
}
// springObjects lists the file's secret keys.
func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Object, error) {
keys, err := parseSpring(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
filter, err := p.springFilterFor(c.Source.ID)
if err != nil {
return nil, err
}
format := provider.Attr{Label: "format", Value: "spring " + springFormat(c.ID)}
var out []provider.Object
for _, k := range keys {
if !filter.secret(k) {
continue
}
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
switch {
case k.reference():
state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference"
case k.jasypt():
state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt"
}
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail,
Attrs: []provider.Attr{format, state},
})
}
return out, nil
}
func springValue(file, name string) (provider.SecretValue, error) {
k, err := springKeyNamed(file, name)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(k.value)), nil
}
func springKeyNamed(file, name string) (springKey, error) {
keys, err := readSpring(file)
if err != nil {
return springKey{}, err
}
for _, k := range keys {
if k.name == name {
return k, nil
}
}
return springKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
}
// springDetails says how many of the file's keys aren't listed, and
// whether any listed one is in plain text.
func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, plain bool) {
keys, err := readSpring(c.ID)
if err != nil {
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
}
filter, err := p.springFilterFor(c.Source.ID)
if err != nil {
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
}
secrets, jasypt := 0, 0
for _, k := range keys {
if filter.secret(k) {
secrets++
plain = plain || k.plain()
}
if k.jasypt() {
jasypt++
}
}
return []provider.Attr{
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
{Label: "other keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
}, plain
}
-223
View File
@@ -1,223 +0,0 @@
package repo
import (
"context"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestIsSpring(t *testing.T) {
for f, want := range map[string]bool{
"src/main/resources/application.yml": true,
"application-prod.properties": true,
"config/application-local.yaml": true,
"bootstrap.yml": true,
"applicationset/dev/applications.yaml": false, // Argo CD's
"chart/templates/application.yaml": false, // Helm's
"application.yml.example": false,
"application.json": false,
"src/main/resources/applicationContext.xml": false,
} {
if IsSpring(f) != want {
t.Errorf("%s: %t", f, !want)
}
}
}
func springNames(keys []springKey) map[string]string {
out := map[string]string{}
for _, k := range keys {
out[k.name] = k.value
}
return out
}
func TestParseProperties(t *testing.T) {
got := springNames(parseProperties([]byte(`# a comment
! another
a=1
b : 2
c 3
d\:e=4
long=one, \
two
unicode=café
empty=
#---
spring.config.activate.on-profile=prod
a=prod-1
`)))
want := map[string]string{
"a": "1", "b": "2", "c": "3", "d:e": "4", "long": "one, two", "unicode": "café", "empty": "",
"spring.config.activate.on-profile (prod)": "prod", "a (prod)": "prod-1",
}
if !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestParseSpringYAML(t *testing.T) {
keys, err := parseSpringYAML([]byte(`info.version: @project.version@
artifacts:
- @project.artifactId@
spring:
datasource:
password: ${DB_PASSWORD}
servers:
- name: a
secret: x
logging.level:
com.example: DEBUG
---
spring:
config.activate.on-profile: prod
datasource:
password: hunter2
---
other: y
`))
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"info.version": "@project.version@", "artifacts[0]": "@project.artifactId@",
"spring.datasource.password": "${DB_PASSWORD}", "servers[0].name": "a", "servers[0].secret": "x",
"logging.level.com.example": "DEBUG",
"spring.config.activate.on-profile (prod)": "prod", "spring.datasource.password (prod)": "hunter2",
"other (document 3)": "y",
}
if got := springNames(keys); !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestSpringDefaults(t *testing.T) {
f, err := compileSpring(SpringPatterns{})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]string{
"spring.datasource.password": "x",
"spring.security.oauth2.client.registration.a.client-secret": "x",
"profapp.payment.checkoutKey": "x",
"app.clients.sgig.sgig-passord": "x",
"jasypt.encryptor.password": "${JASYPT_ENCRYPTOR_PASSWORD}",
"spring.datasource.url": "jdbc:postgresql://app:hunter2@db:5432/app",
"app.servicebus.connection": "Endpoint=sb://x/;SharedAccessKeyName=a;SharedAccessKey=b",
"app.name": "ENC(abc==)",
} {
if !f.secret(springKey{key: key, value: value}) {
t.Errorf("%s=%s isn't listed", key, value)
}
}
for key, value := range map[string]string{
"spring.security.oauth2.client.provider.a.token-uri": "https://idp/token",
"spring.security.oauth2.client.registration.a.authorization-grant-type": "client_credentials",
"app.frontend.keycloak.url": "https://keycloak",
"server.port": "8080",
"spring.datasource.url": "jdbc:postgresql://db:5432/app",
} {
if f.secret(springKey{key: key, value: value}) {
t.Errorf("%s=%s is listed", key, value)
}
}
}
func TestSpringFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"src/main/resources/application.yml": `server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://db/app
password: ${DB_PASSWORD}
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD}
app:
api-key: ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)
`,
"src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n",
"applicationset/applications.yaml": "kind: ApplicationSet\n",
})
ctx := context.Background()
details := func(p *Provider, c provider.Container) map[string]provider.Attr {
attrs, err := p.Details(ctx, c)
if err != nil {
t.Fatal(err)
}
out := map[string]provider.Attr{}
for _, a := range attrs {
out[a.Label] = a
}
return out
}
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, err := p.Containers(ctx, srcs[0])
if err != nil {
t.Fatal(err)
}
var files []string
for _, c := range cs {
files = append(files, c.Name+" · "+c.Detail)
}
if want := []string{
"src/main/resources/application-local.properties · spring · properties",
"src/main/resources/application.yml · spring · yaml",
}; !slices.Equal(files, want) {
t.Fatalf("files %v\nwant %v", files, want)
}
local, yml := cs[0], cs[1]
keys, _ := p.Objects(ctx, yml, provider.TypeSecret)
var names []string
for _, k := range keys {
names = append(names, strings.TrimSpace(k.Name+" "+k.Detail))
}
if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) {
t.Errorf("keys %v, want %v", names, want)
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "hunter2" {
t.Errorf("value: %q %v", v.Reveal(), err)
}
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"}
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "${DB_PASSWORD}" {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
if f, _ := p.File(yml, &keys[1]); f.Line != 9 || f.Sops != "" {
t.Errorf("file: %+v", f)
}
// References and Jasypt aren't plain text in git; a password is.
if d := details(p, yml); d["other keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" {
t.Errorf("yml: %+v", d)
}
if d := details(p, local); !d["git"].Warn {
t.Errorf("local: %+v", d)
}
// The config's patterns, and the repo's over them.
all := []string{".*"}
p = New(Settings{Dir: root, Spring: SpringPatterns{Keys: &all}})
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 5 {
t.Errorf("every key: %d", len(keys))
}
lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"url$\"]\n values: []\n"), 0o644)
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 1 || keys[0].Name != "spring.datasource.url" {
t.Errorf("repo's patterns: %v", keys)
}
os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"(\"]\n"), 0o644)
if _, err := p.Objects(ctx, yml, provider.TypeSecret); err == nil || !strings.Contains(err.Error(), ".lazysecrets.yaml: spring.keys[0]") {
t.Errorf("bad pattern: %v", err)
}
}
+2
View File
@@ -21,6 +21,8 @@ func NewSecretValue(b []byte) SecretValue {
// Reveal returns the plaintext. Call it only to display or copy the value.
func (v SecretValue) Reveal() string { return string(v.b) }
func (v SecretValue) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked }
+13 -7
View File
@@ -92,9 +92,15 @@ func (r Ref) ContainerName() string {
// Container.Detail.
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
var collections = map[provider.ObjectType]string{
provider.TypeSecret: "secrets",
provider.TypeKey: "keys",
provider.TypeCertificate: "certificates",
}
func typeOf(collection string) (provider.ObjectType, bool) {
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
if strings.EqualFold(t.Collection(), collection) {
for t, c := range collections {
if strings.EqualFold(c, collection) {
return t, true
}
}
@@ -112,7 +118,7 @@ func (r Ref) IdentifierURL() string {
if r.Type == "" {
return u + "/"
}
for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} {
for _, seg := range []string{collections[r.Type], r.Name, r.Version} {
if seg != "" {
u += "/" + url.PathEscape(seg)
}
@@ -134,7 +140,7 @@ func (r Ref) String() string {
segs = append(segs, r.Scope, r.Name)
default:
if r.Type != "" {
segs = append(segs, r.Type.Collection())
segs = append(segs, collections[r.Type])
}
segs = append(segs, r.Name, r.Version)
}
@@ -267,7 +273,7 @@ func parseLink(s string) (Ref, error) {
r.Host = strings.ToLower(segs[0])
rest := segs[1:]
switch host := strings.ToLower(u.Host); host {
switch strings.ToLower(u.Host) {
case "azure":
r.Kind = provider.KindAzure
if r.Host == "subscriptions" {
@@ -294,11 +300,11 @@ func parseLink(s string) (Ref, error) {
}
case "databricks", "repo":
r.Kind = provider.KindDatabricks
if host == "repo" {
if strings.EqualFold(u.Host, "repo") {
r.Kind = provider.KindRepo
}
if len(rest) > 2 {
return Ref{}, fmt.Errorf("%s link has too many parts", host)
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host))
}
if len(rest) > 0 {
r.Scope = rest[0]
+2 -7
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// Rules are what a key's value must be to share with it: random enough
@@ -41,18 +40,14 @@ func (r Rules) Check(key []byte) error {
kinds++
}
}
n := utf8.RuneCountInString(s)
n := len([]rune(s))
var has, needs []string
if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n))
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
}
if kinds < r.MinClasses {
kind := "kinds"
if kinds == 1 {
kind = "kind"
}
has = append(has, fmt.Sprintf("%d %s of character", kinds, kind))
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
}
if len(has) == 0 {
+1 -1
View File
@@ -174,7 +174,7 @@ func Read(r ref.Ref) (Sealed, error) {
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
}
n, size := binary.Uvarint(b[1:])
if size <= 0 {
if size <= 0 || n > uint64(len(b)) {
return Sealed{}, bad
}
rest := b[1+size:]
+29 -20
View File
@@ -3,11 +3,11 @@
package app
import (
"cmp"
"context"
"errors"
"fmt"
"image/color"
"math"
"os"
"os/exec"
"strings"
@@ -654,7 +654,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case detailsMsg:
if msg.req == m.reqID[slotDetails] {
m.cancelReq(slotDetails)
m.done(slotDetails)
}
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again
@@ -685,7 +685,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotContainers] {
break
}
m.cancelReq(slotContainers)
m.done(slotContainers)
if msg.err != nil {
m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok {
@@ -703,7 +703,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotObjects] {
break
}
m.cancelReq(slotObjects)
m.done(slotObjects)
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
m.closeVersions()
@@ -724,7 +724,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.versionsOf != nil {
m.setVersions(msg.items)
} else {
m.secrets.SetItems(m.starredObjectsFirst(msg.items))
m.secrets.SetItems(m.starredFirst(msg.items))
cmds = append(cmds, m.refreshShown(before))
}
}
@@ -733,7 +733,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotValue] {
break
}
m.cancelReq(slotValue)
m.done(slotValue)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -748,7 +748,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotVersions] {
break
}
m.cancelReq(slotVersions)
m.done(slotVersions)
// Errors and unsupported providers just leave the count out.
if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
@@ -760,7 +760,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotDescribe] {
break
}
m.cancelReq(slotDescribe)
m.done(slotDescribe)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -775,7 +775,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotCopy] {
break
}
m.cancelReq(slotCopy)
m.done(slotCopy)
if msg.err != nil {
m.noteErr(msg.err)
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
@@ -887,7 +887,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Quit):
return nil, true
case key.Matches(msg, k.Help):
return m.showKeybindingsFor(""), false
return m.showKeybindings(), false
case key.Matches(msg, k.Up):
m.move(-1)
case key.Matches(msg, k.Down):
@@ -1167,11 +1167,14 @@ func (m *Model) syncSelection() tea.Cmd {
if m.onLinks() {
m.showLinkedContainers()
} else if src, ok := m.selectedSource(); ok {
m.containers.Title = "[2] Vaults"
m.containers.Empty = "no vaults found"
switch src.Kind {
case provider.KindDatabricks:
m.containers.Title = "[2] Scopes"
m.containers.Empty = "no secret scopes"
case provider.KindRepo:
m.containers.Title = "[2] Files"
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
}
cmds = append(cmds, m.showContainers(src, id))
@@ -1256,7 +1259,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
m.secrets.SetLoading()
return m.loadObjects(c, m.objType, key)
}
m.secrets.SetItems(m.starredObjectsFirst(e.value))
m.secrets.SetItems(m.starredFirst(e.value))
m.secrets.Select(m.cache.cursor[key])
if e.fresh(m.now()) {
return nil
@@ -1316,7 +1319,8 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
})
}
// closeVersions returns the Secrets pane to the object list.
// closeVersions returns the Secrets pane to the object list, with the
// cursor on the object whose versions were shown.
func (m *Model) closeVersions() {
m.versionsOf = nil
}
@@ -1513,6 +1517,9 @@ func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
}
}
// done releases the context of a completed request.
func (m *Model) done(p slot) { m.cancelReq(p) }
// loadSources starts a new generation of source loads, superseding any in
// flight.
func (m *Model) loadSources() tea.Cmd {
@@ -1632,7 +1639,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
}
return nil
}
@@ -1675,8 +1682,12 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
// clipTick fires when the countdown's displayed second next changes.
func (m *Model) clipTick() tea.Cmd {
wait := m.clipDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.clipToken
return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} })
return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} })
}
func (m *Model) cancelClipClear() {
@@ -1783,15 +1794,13 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else {
reveal := "show"
if m.detail.State() == panes.ValueShown {
reveal = "hide"
}
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), reveal: reveal}.ShortHelp(), 2)
revealed := m.detail.State() == panes.ValueShown
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
}
var lines []string
if !m.clipDeadline.IsZero() {
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, secondsLeft(m.clipDeadline, m.now())))))
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
}
if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash))
+4 -4
View File
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
}
if !strings.Contains(lastLines(scr, 1), "z show") {
t.Errorf("help bar: %q", lastLines(scr, 1))
if !strings.Contains(lastLine(scr), "z show") {
t.Errorf("help bar: %q", lastLine(scr))
}
d.keys("z")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar)
}
d.keys("z")
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr)
}
bar := lastLines(scr, 1)
bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar)
}
+1 -1
View File
@@ -66,7 +66,7 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
if i < 0 || sameListing(before[i], s) {
return nil
}
m.detail.SetDescribed(s)
m.detail.Refresh(s)
uncache(m, m.cache.details, detailKey(s))
return m.describe(s)
}
+20 -10
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"context"
"fmt"
"os"
@@ -85,7 +84,10 @@ func (m *Model) showCommands() tea.Cmd {
}
var opts []option
for _, c := range cmds {
label := cmp.Or(c.Description, c.Command)
label := c.Description
if label == "" {
label = c.Command
}
if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
}
@@ -120,7 +122,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
in.SetValue(p.Default)
in.CursorEnd()
m.prompt = &modal{
title: cmp.Or(c.Description, "Command"), input: &in,
title: cmpOr(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1)
@@ -149,7 +151,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
}
m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
})
return nil
@@ -175,7 +177,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
// execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) {
switch {
case c.Value == "stdin":
@@ -193,7 +195,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
}
m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
@@ -217,7 +219,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmp.Or(msg.cmd.Description, "command")
name := cmpOr(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch {
case msg.err != nil:
@@ -296,7 +298,11 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String())
}
o.Version = version // "": the current value, like copying it
if version != "" {
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true
}
@@ -307,5 +313,9 @@ func newTextInput(prompt string, width int) textinput.Model {
return in
}
// pathInputWidth fits a link or a folder path in a prompt, in the window.
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
+1 -2
View File
@@ -8,7 +8,6 @@ import (
"time"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"github.com/charmbracelet/x/ansi"
)
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
@@ -35,7 +34,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
// temporary path's length.
shown := ""
if d.m.prompt != nil {
shown = ansi.Strip(strings.Join(d.m.prompt.lines, ""))
shown = ansiStrip(strings.Join(d.m.prompt.lines, ""))
}
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
+1 -1
View File
@@ -242,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
return tea.QuitMsg{}
}
}
if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{}
+4 -5
View File
@@ -6,7 +6,6 @@ import (
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -31,7 +30,7 @@ func (m Model) expiringItems() []expiringItem {
for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != expiry.None {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
}
@@ -52,11 +51,11 @@ func (m Model) expiringFields() []panes.Field {
var out []panes.Field
for _, it := range items {
state, left := m.expiry(it.o)
mark := "⚠ " + expiry.Left(left)
if state == expiry.Expired {
mark := "⚠ " + daysLeft(left)
if state == expired {
mark = "✗ expired"
}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String()
}
+15 -3
View File
@@ -4,6 +4,7 @@ import (
"time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -11,19 +12,30 @@ import (
// list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState = expiry.State
const (
notExpiring = expiry.None
expiresSoon = expiry.Soon
expired = expiry.Expired
)
// expiry tells whether o has expired or will within the warning period,
// and how long it has left.
func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
}
// expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state {
case expiry.Expired:
case expired:
return "expired"
case expiry.Soon:
case expiresSoon:
return "expiring"
}
return ""
}
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { return expiry.Left(d) }
+23 -42
View File
@@ -8,7 +8,6 @@ import (
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -101,14 +100,11 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
n := st.Node(storeKey(c))
line := c.Name
if c.Source.Kind == provider.KindRepo {
// A secret file: a lock for an encrypted one, a leaf
// for Spring config, a file in the warning colour for plain
// text, and its type after it ("sops · age", "env · plain text").
// A secret file: a lock for an encrypted one, a file in the
// warning colour for plain text, and its type after it
// ("sops · age", "env · plain text").
icon, style := iconSet.Encrypted, sty.KindRepo
switch {
case strings.HasPrefix(c.Detail, "spring"):
icon, style = iconSet.Spring, sty.Spring
case !strings.HasPrefix(c.Detail, "sops"):
if !strings.HasPrefix(c.Detail, "sops") {
icon, style = iconSet.PlainFile, sty.Warning
}
if icon != "" {
@@ -119,7 +115,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
if withSource {
src := c.Source.Name
if account := accountOf(c.Source); account != "" {
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
src = account + " / " + src
}
line += sty.Muted.Render(" · " + src)
@@ -137,49 +133,34 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
line = sty.Muted.Render(o.Name + " (disabled)")
}
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
if o.Detail != "" {
line += sty.Muted.Render(" " + o.Detail)
}
switch state, left := m.expiry(o); state {
case expiry.Expired:
case expired:
line += " " + sty.Error.Render("✗ expired")
case expiry.Soon:
line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
case expiresSoon:
line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
}
return withNote(sty, line, n.Note, width)
}
}
// starredFirst moves starred items to the front, keeping the order
// otherwise. It doesn't modify items, which may be cached.
func starredFirst[T any](items []T, starred func(T) bool) []T {
rank := func(x T) int {
if starred(x) {
return 0
}
return 1
}
// starredContainersFirst moves starred vaults and scopes to the front,
// keeping the order otherwise. It doesn't modify items, which may be cached.
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
slices.SortStableFunc(out, func(a, b provider.Container) int {
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
})
return out
}
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
return starredFirst(sources, func(s provider.Source) bool {
return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
})
}
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
return starredFirst(items, func(c provider.Container) bool {
return m.store.Node(storeKey(c)).StarredFor(c.Source.Provider)
})
}
func (m Model) starredObjectsFirst(items []provider.Object) []provider.Object {
return starredFirst(items, func(o provider.Object) bool {
return m.store.Node(objectKey(o)).StarredFor(o.Container.Source.Provider)
// starredFirst moves starred objects to the front, keeping the order
// otherwise. It doesn't modify items, which may be cached.
func (m Model) starredFirst(items []provider.Object) []provider.Object {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Object) int {
return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider))
})
return out
}
// showLinkedContainers lists the vaults opened from links.
@@ -217,7 +198,7 @@ func (m *Model) resortStarred() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
}
if m.versionsOf == nil {
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
@@ -263,7 +244,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
switch {
+1 -2
View File
@@ -14,7 +14,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
)
// jump opens the favourites list, filters it and jumps to the first match.
@@ -430,7 +429,7 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("F")
var labels []string
for _, o := range d.m.search.shown {
labels = append(labels, ansi.Strip(o.label))
labels = append(labels, ansiStrip(o.label))
}
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels)
+4 -5
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"errors"
"fmt"
"strings"
@@ -62,7 +61,7 @@ func (m *Model) syncFields() tea.Cmd {
case !ok:
m.fields.Show("", nil)
case m.selectedProblem() != "":
m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup:
m.showAccount(r)
case r.kind == rowSource:
@@ -135,7 +134,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
}
return out
}
@@ -159,7 +158,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
case provider.KindRepo:
add("format", c.Detail)
}
add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL)
for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
@@ -222,5 +221,5 @@ func (m Model) problemFields(problem string) []panes.Field {
// fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value.
func fieldName(f panes.Field) string {
return cmp.Or(f.Label, f.Value)
return cmpOr(f.Label, f.Value)
}
+18 -9
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"fmt"
"slices"
"strings"
@@ -17,7 +16,7 @@ import (
)
// The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindingsFor).
// of them in a menu (showKeybindings).
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The
@@ -39,9 +38,10 @@ type contextHelp struct {
commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes
// reveal, when set, is what the reveal key will do, "show" or "hide":
// the same width so the bar doesn't shift.
reveal string
// revealed, when set, says whether the selected value is shown, so
// the reveal key says just what it'll do: show or hide, the same
// width so the bar doesn't shift.
revealed *bool
}
func (c contextHelp) ShortHelp() []key.Binding {
@@ -69,8 +69,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default:
reveal := k.Reveal
if c.reveal != "" {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal))
if c.revealed != nil {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
}
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
}
@@ -84,6 +84,11 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
// and group. Enter runs the selected one.
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the
// selected one. Opened from the panes, it lists the focused pane's keys
@@ -149,7 +154,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom")
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
}
}
@@ -258,7 +263,11 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des
// move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint {
return keyHint{joinKeys(up, down), "move"}
u, d := keys.Name(up), keys.Name(down)
if u == "" || d == "" {
return keyHint{u + d, "move"}
}
return keyHint{u + "/" + d, "move"}
}
// withKey fills a hint's format with the binding's key ("· %s loads
+12 -16
View File
@@ -38,13 +38,11 @@ func (m *Model) storeEntries() (favs, recent []option) {
return nil
}
var out []entry
detail := m.objectDetail(key)
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
account := ""
if p, ok := m.byID[acct]; ok {
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
}
account = strings.Trim(detail+" · "+account, " ·")
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
out = append(out, entry{depth, sort, option{
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
@@ -159,19 +157,6 @@ func (m Model) inPlainEnv(r ref.Ref) bool {
return false
}
// objectDetail is the Detail of the secret with the store key key, as far
// as the lists loaded tell.
func (m Model) objectDetail(key string) string {
for _, e := range m.cache.objects {
for _, o := range e.value {
if o.Detail != "" && objectKey(o) == key {
return o.Detail
}
}
}
return ""
}
// 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 {
@@ -214,7 +199,18 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil
}
choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" })
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if src.ID == n.Source && n.Source != "" {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) {
case 0:
return m.openRef(r, actGo)
+8 -3
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) {
t.Parallel()
d := newDriver(t)
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
t.Errorf("Sources help bar: %q", bar)
}
d.keys("3")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar)
}
}
@@ -225,6 +225,11 @@ func TestKeyPress(t *testing.T) {
}
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
return lines[len(lines)-1]
}
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel()
d := newDriver(t)
@@ -259,7 +264,7 @@ func TestVersionInStatus(t *testing.T) {
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen())
}
if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar)
}
}
+19 -24
View File
@@ -8,9 +8,9 @@ import (
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -209,7 +209,18 @@ const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) {
choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) })
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
if row, ok := preferredRow(r, choices); ok {
choices = []choice{{sourceRow: row}}
}
@@ -231,24 +242,6 @@ func (m *Model) openSource(r ref.Ref) {
}
}
// openWith offers each loaded source of kind that matches, one per
// account that lists it.
func (m Model) openWith(kind provider.Kind, matches func(provider.Source) bool) []choice {
var choices []choice
for _, p := range m.providers {
if p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if matches(src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
return choices
}
// refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The
@@ -440,7 +433,7 @@ func (m *Model) handleResolved(msg resolvedMsg) {
// (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices {
if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account {
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
return c.sourceRow, true
}
}
@@ -693,7 +686,7 @@ func (m *Model) share(menu bool) tea.Cmd {
share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
}
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
}
@@ -721,7 +714,7 @@ func (m *Model) share(menu bool) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
}
opts := []option{
{key: "o", label: "Lazysecrets link", run: share("o")},
option{key: "o", label: "Lazysecrets link", run: share("o")},
}
if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
@@ -837,8 +830,10 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
}
func (m *Model) startOpenPrompt() tea.Cmd {
in := newTextInput("open: ", m.pathInputWidth())
in := textinput.New()
in.Prompt = "open: "
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{
title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
+3 -1
View File
@@ -75,8 +75,10 @@ type modal struct {
// showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := newTextInput("/", 40)
in := textinput.New()
in.Prompt = "/"
in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0)
m.flash = flash{}
+4 -1
View File
@@ -3,6 +3,7 @@ package app
import (
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
@@ -57,9 +58,11 @@ func (m *Model) editNote() tea.Cmd {
c := o.Container
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source
}
in := newTextInput("note: ", 48)
in := textinput.New()
in.Prompt = "note: "
in.Placeholder = "e.g. prod SQL admin"
in.CharLimit = 80
in.SetWidth(48)
in.SetValue(m.store.Node(key).Note)
in.CursorEnd()
m.prompt = &modal{
+7 -5
View File
@@ -77,15 +77,17 @@ func TestNotesOnRows(t *testing.T) {
func TestWithNoteGivesWay(t *testing.T) {
t.Parallel()
st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 ||
!strings.HasSuffix(ansi.Strip(got), "prod SQL admin") {
t.Errorf("fits: %q", ansi.Strip(got))
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
t.Errorf("fits: %q", stripANSI(got))
}
if got := ansi.Strip(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
!strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", got)
}
if got := ansi.Strip(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
t.Errorf("no room: %q", got)
}
}
func stripANSI(s string) string { return ansi.Strip(s) }
+15 -18
View File
@@ -29,43 +29,41 @@ type persistTickMsg struct{}
type persistedMsg struct{ err error }
// keepers report whether a provider ID, or a cache key's, is an account
// that still exists and isn't a repo.
func (m *Model) keepers() (keep, keepKey func(string) bool) {
keep = cachefile.Keep(m.providers)
return keep, func(key string) bool { return keep(cachefile.ProviderOf(key)) }
}
// keepCached reports whether a cache key's account still exists and
// isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
// loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() {
f := cachefile.Read(m.cachePath)
keep, keepKey := m.keepers()
now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
for id, e := range f.Sources {
if keep(id) && recent(e.At) {
if m.keepProvider(id) && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At
}
}
for k, e := range f.Containers {
if keepKey(k) && recent(e.At) {
if m.keepCached(k) && recent(e.At) {
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
}
}
for k, e := range f.Objects {
if keepKey(k) && recent(e.At) {
if m.keepCached(k) && recent(e.At) {
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
}
}
for k, e := range f.Details {
if keepKey(k) && recent(e.At) {
if m.keepCached(k) && recent(e.At) {
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
}
}
for k, v := range f.Cursor {
if keepKey(k) {
if m.keepCached(k) {
m.cache.cursor[k] = v
}
}
@@ -74,30 +72,29 @@ func (m *Model) loadCacheFile() {
// cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New()
keep, keepKey := m.keepers()
f.Dropped, m.cacheDropped = m.cacheDropped, nil
for id, r := range m.sourceResults {
if r.err == nil && keep(id) {
if r.err == nil && m.keepProvider(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
}
}
for k, e := range m.cache.containers {
if keepKey(k) {
if m.keepCached(k) {
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
}
}
for k, e := range m.cache.objects {
if keepKey(k) {
if m.keepCached(k) {
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
}
}
for k, e := range m.cache.details {
if keepKey(k) {
if m.keepCached(k) {
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
}
}
for k, v := range m.cache.cursor {
if keepKey(k) {
if m.keepCached(k) {
f.Cursor[k] = v
}
}
+2 -3
View File
@@ -16,7 +16,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
)
const sopsFile = `db:
@@ -226,9 +225,9 @@ func TestSearchAllRepos(t *testing.T) {
var got []string
for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansi.Strip(o.label))
t.Errorf("not a repo: %s", ansiStrip(o.label))
}
got = append(got, ansi.Strip(o.label))
got = append(got, ansiStrip(o.label))
}
if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got)
+7 -16
View File
@@ -28,22 +28,12 @@ func (m *Model) startRevealTimer() tea.Cmd {
// revealTickCmd fires when the displayed second next changes.
func (m *Model) revealTickCmd() tea.Cmd {
wait := m.revealDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.revealToken
return tea.Tick(untilNextSecond(m.revealDeadline, m.now()), func(time.Time) tea.Msg { return revealTickMsg{token} })
}
// untilNextSecond is how long until a countdown to deadline shows its
// next whole second.
func untilNextSecond(deadline, now time.Time) time.Duration {
if wait := deadline.Sub(now) % time.Second; wait > 0 {
return wait
}
return time.Second
}
// secondsLeft is a countdown to deadline, in whole seconds rounded up.
func secondsLeft(deadline, now time.Time) int {
return max(0, int(math.Ceil(deadline.Sub(now).Seconds())))
return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} })
}
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
@@ -59,7 +49,8 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
}
func (m *Model) showHidesIn() {
m.detail.SetHidesIn(fmt.Sprintf("%ds", secondsLeft(m.revealDeadline, m.now())))
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds())))
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
}
// hideValue hides the revealed value and stops its countdown.
+32 -22
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
@@ -45,6 +46,10 @@ type crawlMsg struct {
err error
}
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group.
@@ -62,7 +67,7 @@ func (m *Model) searchEntries() (sources, items []option) {
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: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)),
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
}
@@ -90,7 +95,7 @@ func (m *Model) searchEntries() (sources, items []option) {
if err != nil {
continue
}
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·")
suffix := accountOf(src)
if t != provider.TypeSecret {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·")
@@ -129,6 +134,8 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
}
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// providerFilter picks the providers a search or a load covers; nil is
// all of them.
type providerFilter func(provider.Provider) bool
@@ -141,7 +148,7 @@ func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id }
}
// coverageOf is the coverage of the providers in.
// coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage
for _, p := range m.providers {
@@ -176,7 +183,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
// searchLines says what the search covers.
func (m *Model) searchLines() []string {
c := m.coverageOf(nil)
c := m.searchCoverage()
switch {
case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
@@ -203,8 +210,11 @@ func plural(n int, word string) string {
return fmt.Sprintf("%d %ss", n, word)
}
// startCrawlOf loads, for the providers in, every unlisted subscription's
// vaults and every vault's secrets not loaded yet.
// startCrawl loads every unlisted subscription's vaults and every vault's
// secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers {
if in != nil && !in(p) {
@@ -291,7 +301,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Containers(ctx, src)
for i := range items {
@@ -304,7 +314,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items {
@@ -341,19 +351,6 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
return cmd
}
// vaultsLoaded counts the vaults whose secrets are cached or can't be
// read, and says whether any are loading.
func (m *Model) vaultsLoaded(vaults []provider.Container) (loaded int, loading bool) {
for _, c := range vaults {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
return loaded, loading
}
// sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string {
@@ -367,7 +364,14 @@ func (m *Model) sourceMark(row string) string {
if !ok {
return m.st.Muted.Render("○")
}
loaded, loading := m.vaultsLoaded(e.value)
loaded, loading := 0, false
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
switch {
case loading:
return m.st.Muted.Render("◌")
@@ -392,3 +396,9 @@ func (m *Model) vaultMark(c provider.Container) string {
}
return " "
}
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+20 -19
View File
@@ -6,7 +6,6 @@ import (
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi"
)
@@ -116,19 +115,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Fatal("platform-dev not in sources")
}
}
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "○") {
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") {
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
}
if len(d.m.search.shown) != 0 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
}
d.send(ctrl('r'))
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "●") {
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") {
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
}
for _, o := range d.m.search.shown {
if !strings.Contains(ansi.Strip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansi.Strip(o.label))
if !strings.Contains(ansiStrip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
}
}
if len(d.m.search.shown) == 0 {
@@ -178,17 +177,19 @@ func TestStarFromSearch(t *testing.T) {
}
}
func ansiStrip(s string) string { return ansi.Strip(s) }
func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch()
v := d.m.search
scope := func() string { return ansi.Strip(v.scopes[v.src].label) }
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
if scope() != "All" {
t.Errorf("opened on %q, want All", scope())
}
if v.res >= len(v.shown) || !strings.Contains(ansi.Strip(v.shown[v.res].label), "kv-shared-prod") {
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen())
}
d.send(ctrl('s')) // narrows to the source, and back
@@ -232,13 +233,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
t.Errorf("no results in the account:\n%s", d.screen())
}
for _, o := range v.shown {
if cachefile.ProviderOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansi.Strip(o.label))
if providerOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label))
}
}
// The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || cachefile.ProviderOf(next.group) != header.provider || !strings.HasPrefix(ansi.Strip(next.label), " ") {
t.Errorf("after the account: %q", ansi.Strip(next.label))
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label))
}
}
@@ -326,7 +327,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
t.Fatal("no results")
}
o := d.m.search.shown[0]
plain := []rune(ansi.Strip(o.label))
plain := []rune(ansiStrip(o.label))
var got string
for _, h := range o.hits {
got += string(plain[h])
@@ -347,14 +348,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("F")
bar := lastLines(d.screen(), 1)
bar := lastLine(d.screen())
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want)
}
}
d.keys("/")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
t.Errorf("typing: help bar %q", bar)
}
}
@@ -374,8 +375,8 @@ func TestHelpFromPopups(t *testing.T) {
if o.header {
sections = append(sections, o.label)
}
if strings.Contains(ansi.Strip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label))
if strings.Contains(ansiStrip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
}
}
if strings.Join(sections, ",") != "Go to,While typing, after /" {
@@ -427,7 +428,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLines(d.screen(), 1))
bar := strings.TrimSpace(lastLine(d.screen()))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar)
}
@@ -442,11 +443,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for i := 0; !strings.Contains(ansi.Strip(d.m.search.scope().label), "platform-dev"); i++ {
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
if i > len(d.m.search.scopes) {
var labels []string
for _, o := range d.m.search.scopes {
labels = append(labels, ansi.Strip(o.label))
labels = append(labels, ansiStrip(o.label))
}
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
}
+31 -7
View File
@@ -95,7 +95,9 @@ func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
in := newTextInput("/", searchWidth(m.w)-6) // as wide as the popup
in := textinput.New()
in.Prompt = "/"
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
v := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries()
m.search = v
@@ -201,7 +203,7 @@ func (m *Model) fillSearch(keep bool) {
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
})
for _, o := range sources {
if cachefile.ProviderOf(o.group) == p.ID() {
if providerOf(o.group) == p.ID() {
o.label = " " + o.label
scopes = append(scopes, o)
}
@@ -265,7 +267,7 @@ func (v *searchView) match() []option {
default:
account, ofAccount := v.scopeAccount()
for _, o := range v.items {
if sc.id == allSources || ofAccount && cachefile.ProviderOf(o.group) == account || sc.group != "" && o.group == sc.group {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o)
}
}
@@ -521,7 +523,7 @@ func (m *Model) searchLoads() tea.Cmd {
return m.startCrawlOf(v.scopeFilter())
}
if !v.onSource() {
return m.startCrawlOf(nil)
return m.startCrawl()
}
providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources {
@@ -614,7 +616,13 @@ func (m Model) loadHint() string {
}
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
}
loaded, _ := m.vaultsLoaded(e.value)
loaded := 0
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
if loaded == len(e.value) {
return ""
}
@@ -762,7 +770,7 @@ func (m Model) searchViewBox() string {
body = append(body, " "+m.st.Error.Render(l))
}
}
body = append(body, "", " "+panes.Tabs(m.st, ts, tabNames[:], m.search.tab))
body = append(body, "", " "+m.goToTabs(ts))
}
body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") {
@@ -795,7 +803,7 @@ func (m Model) searchViewBox() string {
// Tabs in the title, the current one underlined, like the panes; a
// picker has its own title, and the tabs over the input.
if v.picker == nil {
head := ts.Render(goToTitle+" ") + panes.Tabs(m.st, ts, tabNames[:], m.search.tab)
head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
}
// A picker: in its own border, under what it's for.
@@ -848,3 +856,19 @@ func pickable(o option) (ref.Ref, bool) {
}
return r, true
}
// goToTabs draws Go to's tabs, the current one underlined.
func (m Model) goToTabs(ts lipgloss.Style) string {
var out string
for i, name := range tabNames {
if i > 0 {
out += ts.Render(" - ")
}
if i == m.search.tab {
out += ts.Underline(true).Render(name)
} else {
out += m.st.Muted.Render(name)
}
}
return out
}
+5 -2
View File
@@ -10,6 +10,7 @@ import (
"slices"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
@@ -303,7 +304,7 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
}
// openFailed says why a share link couldn't be decrypted, in a popup
// where its offer was: its first line (wrapped), or, with whole, all of it
// where its offer was: its first line (wrapped), or with whole all of it
// (Key Vault's refusals say what to check on later lines). From there,
// the key's secret can be gone to: its version, which may be disabled.
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
@@ -337,7 +338,9 @@ func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
// askShareDir asks where to save files from a repo that isn't here. It
// stays until they're saved, saying why when they can't be.
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
in := newTextInput("folder: ", m.pathInputWidth())
in := textinput.New()
in.Prompt = "folder: "
in.SetWidth(max(20, min(m.w-12, 64)))
dir := m.placeDir
if dir == "" {
dir, _ = os.Getwd()
+22 -1
View File
@@ -134,7 +134,10 @@ func (m *Model) rebuildSources() {
}
for _, p := range m.providers {
kind, id := p.Kind(), p.ID()
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ")
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id]
sources := r.sources
if !flat {
@@ -212,6 +215,17 @@ func (m Model) currentSource() (provider.Source, bool) {
return provider.Source{}, false
}
// starredSourcesFirst moves starred sources to the front, keeping the
// order otherwise. It doesn't modify sources.
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
out := slices.Clone(sources)
rank := func(s provider.Source) int {
return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider))
}
slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) })
return out
}
// starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int {
switch {
@@ -225,6 +239,13 @@ func starRank(mem *store.Store, r sourceRow) int {
return 3
}
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected()
+2 -3
View File
@@ -1,7 +1,6 @@
package app
import (
"cmp"
"errors"
"fmt"
"strings"
@@ -84,7 +83,7 @@ func (m Model) statusFields() []panes.Field {
}
}
// A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")}
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
}
@@ -160,7 +159,7 @@ func (m Model) accountStatus(p provider.Provider) string {
return r.err.Error()
}
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmp.Or(noun, "source"))
return plural(len(r.sources), cmpOr(noun, "source"))
}
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
+11 -1
View File
@@ -2,11 +2,13 @@ package app
import (
"context"
"errors"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
@@ -128,7 +130,15 @@ func (m *Model) showUpdate() {
run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" {
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
return m.openPage("the release page", r.URL, func() {})
switch err := m.openURL(r.URL); {
case errors.Is(err, browser.ErrRemote):
cmd := m.copyText("link to the release", r.URL, false)
m.flash.text += " (can't open a browser over SSH)"
return cmd
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
}
return nil
}})
}
m.showMenu("lazysecrets "+r.Tag, lines, opts)
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
)
func TestRecentScope(t *testing.T) {
@@ -22,7 +21,7 @@ func TestRecentScope(t *testing.T) {
d.keys("R")
var recent []string
for _, o := range d.m.search.shown {
recent = append(recent, ansi.Strip(o.label))
recent = append(recent, ansiStrip(o.label))
}
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
t.Fatalf("want newest first: %q", recent)
@@ -32,7 +31,7 @@ func TestRecentScope(t *testing.T) {
t.Errorf("jumped to %q", s)
}
d.keys("F")
if len(d.m.search.shown) != 1 || !strings.Contains(ansi.Strip(d.m.search.shown[0].label), "kv-shared-prod") {
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") {
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
}
}
-2
View File
@@ -11,7 +11,6 @@ type Set struct {
Repo string
Encrypted string // an encrypted file (SOPS)
PlainFile string // a plain text secrets file (.env)
Spring string // a Spring Boot config file
Note string // has a note (lazysecrets list)
}
@@ -26,7 +25,6 @@ var NerdFont = Set{
Repo: "\U000F02A2", // md-git
Encrypted: "\U000F033E", // md-lock
PlainFile: "\U000F0214", // md-file
Spring: "\U000F032A", // md-leaf
Note: "\U000F09ED",
}
+14 -5
View File
@@ -15,8 +15,8 @@ package keys
import (
"fmt"
"maps"
"slices"
"sort"
"strings"
"charm.land/bubbles/v2/key"
@@ -266,11 +266,11 @@ func (m *Map) Apply(cfg map[string]map[string][]string) error {
byName[a.Context+"."+a.Name] = a
contexts[a.Context] = true
}
for _, ctx := range slices.Sorted(maps.Keys(cfg)) {
for _, ctx := range sortedKeys(cfg) {
if !contexts[ctx] {
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(slices.Sorted(maps.Keys(contexts)), ", "))
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
}
for _, name := range slices.Sorted(maps.Keys(cfg[ctx])) {
for _, name := range sortedKeys(cfg[ctx]) {
a, ok := byName[ctx+"."+name]
if !ok {
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
@@ -289,7 +289,16 @@ func (m *Map) names(ctx string) []string {
out = append(out, a.Name)
}
}
slices.Sort(out)
sort.Strings(out)
return out
}
func sortedKeys[V any](set map[string]V) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
-17
View File
@@ -69,20 +69,3 @@ func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
}
return st.TitleBlurred
}
// Tabs draws tab names for a title in style ts, the current one
// underlined and the rest muted.
func Tabs(st styles.Styles, ts lipgloss.Style, names []string, current int) string {
var out string
for i, name := range names {
if i > 0 {
out += ts.Render(" - ")
}
if i == current {
out += ts.Underline(true).Render(name)
} else {
out += st.Muted.Render(name)
}
}
return out
}
+31 -27
View File
@@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) {
d.container = &s.Container
}
// Refresh shows s's newer metadata if s is the secret shown, keeping
// its account and any value shown or loading.
func (d *Detail) Refresh(s provider.Object) {
if d.secret == nil || d.secret.ID != s.ID {
return
}
s.Container = d.secret.Container
d.secret = &s
}
// ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) {
d.Clear()
@@ -188,31 +198,20 @@ func carryStyle(lines []string) []string {
return lines
}
var valueIndent = strings.Repeat(" ", 12)
// fieldLabel starts a detail line: the label, padded so values line up
// at valueIndent.
func fieldLabel(st styles.Styles, label string) string {
return " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
}
// lines renders the body, wrapped to the pane width.
func (d Detail) lines(st styles.Styles) []string {
inner := max(1, d.w-3)
var out []string
field := func(label, value string) {
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
indent := strings.Repeat(" ", 12)
wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n"))
out = append(out, fieldLabel(st, label)+wrapped[0])
out = append(out, prefix+wrapped[0])
for _, w := range wrapped[1:] {
out = append(out, valueIndent+w)
out = append(out, indent+w)
}
}
muted := st.Muted.Render
note := func() {
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
}
if d.container == nil {
return []string{muted(" Select a vault or scope to browse its secrets.")}
@@ -222,27 +221,30 @@ func (d Detail) lines(st styles.Styles) []string {
if containerLabel == "" {
containerLabel = "vault"
}
source := d.Icons.Prefix(c.Source.Kind) + cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind)) + " / " + c.Source.Name
if d.secret == nil {
field(containerLabel, c.Name)
note()
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
if c.Detail != "" {
field("", muted(c.Detail))
}
field("source", source)
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
out = append(out, "", muted(" Select a secret to see its details."))
return out
}
s := *d.secret
field("name", s.Name)
note()
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
field(containerLabel, c.Name)
if c.Source.Kind != provider.KindAzure && c.Detail != "" {
field("", muted(c.Detail))
}
field("source", source)
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
if s.Version != "" {
field("version", s.Version)
}
@@ -306,12 +308,14 @@ func (d Detail) lines(st styles.Styles) []string {
case provider.TypeCertificate:
label, hidden, reveal = "cert", "", "show the certificate"
}
indent := strings.Repeat(" ", 12)
out = append(out, "")
switch d.state {
case ValueHidden:
if hidden != "" {
field(label, hidden)
out = append(out, valueIndent+hints(reveal))
out = append(out, indent+hints(reveal))
} else {
field(label, hints(reveal))
}
@@ -328,9 +332,9 @@ func (d Detail) lines(st styles.Styles) []string {
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
w = lead + w
if i == 0 && j == 0 {
out = append(out, fieldLabel(st, label)+st.Error.Render(w))
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w))
} else {
out = append(out, valueIndent+st.Error.Render(w))
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
}
}
}
@@ -341,10 +345,10 @@ func (d Detail) lines(st styles.Styles) []string {
for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first {
out = append(out, fieldLabel(st, label)+st.Value.Render(w))
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
first = false
} else {
out = append(out, valueIndent+st.Value.Render(w))
out = append(out, indent+st.Value.Render(w))
}
}
}
@@ -358,7 +362,7 @@ func (d Detail) lines(st styles.Styles) []string {
if d.hidesIn != "" {
more = append(more, "hides in "+d.hidesIn)
}
out = append(out, valueIndent+hints("hide", more...))
out = append(out, indent+hints("hide", more...))
}
return append(out, d.versionLines(st)...)
}
@@ -379,7 +383,7 @@ func (d Detail) versionLines(st styles.Styles) []string {
head += muted(" · " + d.VersionsKey + " to browse")
}
}
out := []string{"", fieldLabel(st, "versions") + head}
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
current := d.versions[0].Version
for i, v := range d.versions {
if i == maxVersionLines {
+1
View File
@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/x/ansi"
)
// sgr matches SGR escape sequences.
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
// Highlight draws a cursor row: line, padded to width, on style's
+35 -3
View File
@@ -301,6 +301,15 @@ func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Rang
return out
}
// RuneIndexes converts byte offsets in s to rune offsets.
func RuneIndexes(s string, bytes []int) []int {
out := make([]int, 0, len(bytes))
for _, b := range bytes {
out = append(out, utf8.RuneCountInString(s[:b]))
}
return out
}
// highlight returns the rune indexes to highlight in a row's plain text:
// the filter hits, shifted to where the Filter text appears in the row.
// It returns nil if the row doesn't contain the Filter text verbatim.
@@ -329,7 +338,17 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
ts := TitleStyle(st, focused)
title := ts.Render(l.Title)
if len(l.Tabs) > 1 {
title += ts.Render(" ") + Tabs(st, ts, l.Tabs, l.Tab)
for i, tab := range l.Tabs {
sep := " - "
if i == 0 {
sep = " "
}
if i == l.Tab {
title += ts.Render(sep) + ts.Underline(true).Render(tab)
} else {
title += ts.Render(sep) + st.Muted.Render(tab)
}
}
}
if q := l.input.Value(); q != "" && !l.filtering {
title += ts.Render(" /" + q)
@@ -360,13 +379,26 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
default:
for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ {
line := l.item.Render(st, l.items[l.visible[i]], inner-1)
line = " " + MarkMatches(line, l.highlight(i, ansi.Strip(line)), st.Match)
plain := ansi.Strip(line)
hits := l.highlight(i, plain)
if i == l.cursor {
base := st.SelectedBlurred
if focused {
base = st.Selected
}
line = Highlight(ansi.Truncate(line, inner, "…"), base, inner)
text := " " + line
if len(hits) > 0 {
for j := range hits {
hits[j]++ // leading space
}
text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...)
}
line = Highlight(ansi.Truncate(text, inner, "…"), base, inner)
} else if len(hits) > 0 {
// Style just the matches, keeping the row's own colours.
line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...)
} else {
line = " " + line
}
body = append(body, line)
}
-2
View File
@@ -34,7 +34,6 @@ type Styles struct {
KindAzure lipgloss.Style
KindDatabricks lipgloss.Style
KindRepo lipgloss.Style
Spring lipgloss.Style // Spring Boot config files
// themed marks the selection colours as set by the theme, so
// OnBackground leaves them.
@@ -71,7 +70,6 @@ func Default() Styles {
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
Spring: lipgloss.NewStyle().Foreground(lipgloss.Green),
}
}
+8 -5
View File
@@ -3,8 +3,8 @@ package styles
import (
"fmt"
"image/color"
"maps"
"slices"
"sort"
"strconv"
"strings"
@@ -42,7 +42,6 @@ var Roles = map[string]string{
"azureColor": "Azure sources and vaults",
"databricksColor": "Databricks workspaces and scopes",
"repoColor": "repos and their secret files (SOPS)",
"springColor": "Spring Boot config files",
}
// FromTheme returns the default styles with the theme's roles applied, and
@@ -52,7 +51,12 @@ var Roles = map[string]string{
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default()
var fg color.Color
for _, role := range slices.Sorted(maps.Keys(theme)) {
roles := make([]string, 0, len(theme))
for r := range theme {
roles = append(roles, r)
}
sort.Strings(roles)
for _, role := range roles {
if _, ok := Roles[role]; !ok {
continue
}
@@ -100,8 +104,6 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st.KindDatabricks = v.fg(lipgloss.NewStyle())
case "repoColor":
st.KindRepo = v.fg(lipgloss.NewStyle())
case "springColor":
st.Spring = v.fg(lipgloss.NewStyle())
}
}
return st, fg, nil
@@ -129,6 +131,7 @@ type value struct {
bold, italic, underline, reverse, faint bool
}
// fg styles s with the value as foreground, bg as background.
func (v value) fg(s lipgloss.Style) lipgloss.Style {
if v.color != nil {
s = s.Foreground(v.color)
+11 -9
View File
@@ -45,9 +45,9 @@ const (
// DefaultServers are where releases are looked for, in order.
var DefaultServers = []string{GitHubRepo, DefaultRepo}
// serverTimeout bounds asking one server for the latest release or its
// checksums, so a server that's down leaves time for the next.
const serverTimeout = 5 * time.Second
// 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
@@ -89,9 +89,14 @@ func (r Release) Notes(current string) string {
return PlainLinks(strings.TrimSpace(notes))
}
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
// PlainLinks shows Markdown links as their text, for a terminal.
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
// and a terminal shows them as their text.
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
// Client talks to the release servers.
@@ -143,15 +148,12 @@ func (c *Client) Latest(ctx context.Context) (Release, error) {
return Release{}, errors.Join(errs...)
}
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
api, err := API(repo)
if err != nil {
return Release{}, err
}
ctx, cancel := context.WithTimeout(ctx, serverTimeout)
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
defer cancel()
// The recent releases in one request (per_page on GitHub, limit on
// Gitea), for their changelogs.
@@ -220,7 +222,7 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
if m := Managed(exe); m != "" {
return fmt.Errorf("%s is managed by %s: update it there", exe, m)
}
if (runtime.GOOS != "darwin" && runtime.GOOS != "linux") || (runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64") {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" || runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH)
}
version := strings.TrimPrefix(r.Tag, "v")
@@ -270,7 +272,7 @@ 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, serverTimeout)
sctx, cancel := context.WithTimeout(ctx, latestTimeout)
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
cancel()
if err != nil {