17 Commits
Author SHA1 Message Date
keb 4d601bb82f refactor: one copy of the UI's repeated helpers
CI / Checks (pull_request) Successful in 36s
PR title / Conventional title (pull_request) Successful in 0s
- Text inputs come from newTextInput; the link and folder prompts share one width.
- One starred-first sort for sources, vaults and secrets; accountOf where the account was cut from the label by hand.
- One helper each for the reveal and clipboard countdowns' tick and seconds, the "Open with" choices, the vaults-loaded count, and Go to's and the panes' tabs.
- The details pane builds its label prefix, indent, note and source once; list rows mark matches with MarkMatches.
- The release page opens through openPage, so it now says it opened, as other pages do.
- The cache's account filter is built once per load or save, not per key.
2026-10-15 08:54:47 +02:00
keb b1eda53d5d chore(release): v0.2.0
Release / Build and publish (push) Successful in 53s
2026-10-14 21:43:27 +02:00
keb 39454d47a8 feat: lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers (#70)
`lazysecrets list` prints every source, vault and secret lazysecrets can find, one per line, for fzf, editor pickers and scripts: what Go to searches, without the TUI. What's cached prints at once; what's missing or older than `--max-age` loads in the background and streams in. It stops when the reader goes (a pick in fzf), saving what it loaded.

- Text: kind, mark (✗/⚠, ★, ◷ and the provider icon), name, status (why it couldn't be read, or expired / expires in 12d), note, account and link, tab-separated; kind first and account and link last, so new fields go between. Coloured from the theme; `--color never`, `--icons=false`, `--stars=false`, `--recent`, `--notes=false`, `--expiry=false`.
- `--format json`: an object per line, with starred, recent, lastUsed and uses, expires, enabled, note, reference and url too.
- `--kind` lists only some kinds, and doesn't load lists only other kinds need; `--cached` loads nothing.
- `lazysecrets open --action go|copy|show` skips the "what to do" menu and ends in the list, like Go to; `open` and `get` take `--account`, the account list printed, rather than asking which.
- The cache file moved to `internal/cachefile`: saves merge with the file under a lock, so list and the TUI don't undo each other.
- `ui.recentIcon` marks items used lately with ◷ in the TUI's lists too; Recent now means the last 90 days.
- --demo has stars, notes, recent use, and expired, expiring and disabled secrets.
- docs/integration.md documents get, list and an fzf picker; docs/links.md is docs/sharing.md.

Reviewed-on: #70
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:30:44 +02:00
keb dbb0eb2bbd fix: a wrapped value in the details keeps its colour on every line (#69)
A long value in the details pane (a ref, a note, a vault's detail, an error) was coloured, then wrapped: the colour's reset only came on the last line, and each line is drawn on its own, so the lines after the first lost the colour. Each wrapped line now starts with the styles still on at the end of the one before.

Reviewed-on: #69
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:23:53 +02:00
keb e825a51b30 fix: local builds are dev-<commit> again, not a Go pseudo-version (#68)
Go now stamps local builds with a module pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507+dirty), so the version showed that instead of dev-<commit> as the docs say. The update check also took it for a pre-release of 0.1.3, a version that doesn't exist, rather than a development build. A pseudo-version is now dev-<its commit>, -dirty with uncommitted changes; real versions are unchanged.

Reviewed-on: #68
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:23:06 +02:00
keb 4480e8fed0 fix: lazysecrets starts from fzf's become, on a non-blocking terminal (#67)
fzf's become hands lazysecrets its /dev/tty still non-blocking. Go can't poll /dev/tty on macOS, so it reads it directly, and the TUI's first read failed with "resource temporarily unavailable" (EAGAIN). lazysecrets now makes a terminal on stdin blocking before the TUI starts (Unix; nothing on Windows).

Reviewed-on: #67
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:22:19 +02:00
keb 3e6db064ab chore(release): v0.1.2
Release / Build and publish (push) Successful in 53s
2026-10-13 23:40:42 +02:00
keb a81fcaa5a6 fix: unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one (#66)
Changes that would break configs or links once people use lazysecrets, made while nobody does yet.

- **Repo key names**: a dot or bracket inside a key is escaped (`app\.name`), so links to it resolve to that key and not to `app` → `name`. Dotenv files are flat, so their names stay as they are.
- **Config**: `updates.updateSources` is now `updates.sources`. A negative provider timeout, or `--timeout` of 0 or less, is an error instead of making every request fail.
- **Keybindings**: `lists.share` is now `lists.copyLink`, `lists.search` is `lists.goTo` and `lists.enter` is `lists.open`, matching what they do.
- **Version skew**, e.g. a lazysecrets.nvim theme file made for a newer lazysecrets:
  - an unknown theme role is skipped with a warning instead of stopping startup;
  - a state file from a newer version is read but not saved to, with a warning;
  - a link with an unknown provider suggests `lazysecrets update`.

Reviewed-on: #66
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 23:28:44 +02:00
keb e28328ef82 docs: SECURITY.md says how to report a vulnerability (#65)
SECURITY.md asks for vulnerabilities to be reported privately, through GitHub's private vulnerability reporting or by email to karl-einarb@outlook.com.

docs/development.md: the "Pull requests" heading is now "How changes land". The section covers squash merges, PR titles and branch protection, not how to send one.

Reviewed-on: #65
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 23:27:43 +02:00
keb 6b3edd45d8 fix: help and errors name sharing.key and sharing.keyRules, not share.* (#64)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 22:57:49 +02:00
keb 5cf9ecc0e2 chore: install segment on readme (#63)
Reviewed-on: #63
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 20:54:10 +02:00
keb a46fc9fa19 chore: .mailmap shows Gitea's old noreply.localhost addresses as noreply.git.kebcloud.no (#62)
Gitea wrote merges as 1+keb@noreply.localhost and 3+renovate-bot@noreply.localhost until NO_REPLY_ADDRESS was set. git log, shortlog and blame now show those as noreply.git.kebcloud.no, without rewriting history.

Reviewed-on: #62
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 20:41:17 +02:00
Karl-Einar Bergstrøm 75bab25286 feat: the update changelog covers every version you missed (#61)
Latest reads the last 10 releases in one request instead of only the
newest, so U and lazysecrets update --check show the changes of every
release since the running version, each under its version. Drafts and
pre-releases are skipped. A changelog taller than the screen is cut
with a pointer to the release pages.

Reviewed-on: #61
2026-10-13 20:25:28 +02:00
keb 10d1fc63c7 feat: config grouped by area, with per-provider timeouts and an update interval (#60)
Regroups the config, while there are no users to migrate:

- `ui`: icons, sourcesView, collapseGroups, loadDelay, expiryWarningDays
- `values`: revealTimeout, hideOnBlur, clipboardClear
- `storage`: cache, history
- `updates`: autoCheck (was updateCheck), autoCheckInterval (new, 24h by default), updateSources (was releases)
- `providers.timeout` (was timeout), plus `timeout` on azure, databricks and repos overriding it for that provider; `--timeout` still sets them all
- `sharing` (was share), in the config and in a repo's `.lazysecrets.yaml`

docs/config.md and the pages naming these keys are updated.

Reviewed-on: #60
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 20:23:55 +02:00
renovate-bot Bot 5cd6d90555 chore(deps): update non-major (#59)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-13 17:19:14 +02:00
keb dc99779885 docs: the README shows the GitHub install URL too (#58)
Reviewed-on: #58
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 15:47:21 +02:00
keb 47ef86293d feat: look for repos in the usual folders, not just ~/git (#57)
Without roots in the config, repos are found under whichever of ~/git,
~/repo, ~/repos, ~/projects, ~/code, ~/source and ~/src exist. Missing
ones cost a stat each. A root that's a symlink to another is scanned
once, and a repo under two roots keeps the first one's name.

Reviewed-on: #57
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 15:37:55 +02:00
84 changed files with 3051 additions and 691 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
<1+keb@noreply.git.kebcloud.no> <1+keb@noreply.localhost>
<3+renovate-bot@noreply.git.kebcloud.no> <3+renovate-bot@noreply.localhost>
+25
View File
@@ -2,6 +2,31 @@
Every release of lazysecrets, newest first. Every release of lazysecrets, newest first.
## v0.2.0 – 2026-10-14
### Added
- Lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers ([#70](https://git.kebcloud.no/keb/lazysecrets/pulls/70)) ([39454d4](https://git.kebcloud.no/keb/lazysecrets/commit/39454d47a864c8628c50b023aee9539d33be2f44))
### Fixed
- Lazysecrets starts from fzf's become, on a non-blocking terminal ([#67](https://git.kebcloud.no/keb/lazysecrets/pulls/67)) ([4480e8f](https://git.kebcloud.no/keb/lazysecrets/commit/4480e8fed00024691091eb7096abb9dc83b8044d))
- Local builds are dev-<commit> again, not a Go pseudo-version ([#68](https://git.kebcloud.no/keb/lazysecrets/pulls/68)) ([e825a51](https://git.kebcloud.no/keb/lazysecrets/commit/e825a51b30261b55c536af18fefde79dc6308377))
- A wrapped value in the details keeps its colour on every line ([#69](https://git.kebcloud.no/keb/lazysecrets/pulls/69)) ([dbb0eb2](https://git.kebcloud.no/keb/lazysecrets/commit/dbb0eb2bbdf6d695cf0d9ac15a1b513af8ba431d))
## v0.1.2 – 2026-10-13
### Added
- Look for repos in the usual folders, not just ~/git ([#57](https://git.kebcloud.no/keb/lazysecrets/pulls/57)) ([47ef862](https://git.kebcloud.no/keb/lazysecrets/commit/47ef86293d757f965069b1ea100a4d53d5a9b876))
- Config grouped by area, with per-provider timeouts and an update interval ([#60](https://git.kebcloud.no/keb/lazysecrets/pulls/60)) ([10d1fc6](https://git.kebcloud.no/keb/lazysecrets/commit/10d1fc63c75fbb91088119a2e81884e44c36c4a3))
- The update changelog covers every version you missed ([#61](https://git.kebcloud.no/keb/lazysecrets/pulls/61)) ([75bab25](https://git.kebcloud.no/keb/lazysecrets/commit/75bab2528677086219817ff9bc08a36755c96dcd))
### Fixed
- Help and errors name sharing.key and sharing.keyRules, not share.* ([#64](https://git.kebcloud.no/keb/lazysecrets/pulls/64)) ([6b3edd4](https://git.kebcloud.no/keb/lazysecrets/commit/6b3edd45d83b9b2af06a40f1f7e67f35c6861a69))
- Unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one ([#66](https://git.kebcloud.no/keb/lazysecrets/pulls/66)) ([a81fcaa](https://git.kebcloud.no/keb/lazysecrets/commit/a81fcaa5a6cef3ce0d07d6195f3e7bbb98808253))
## v0.1.1 – 2026-10-13 ## v0.1.1 – 2026-10-13
### Added ### Added
+29 -12
View File
@@ -12,10 +12,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching. fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it - **Share links**: Share `lazysecrets://` links with colleagues.
with their own access, from the terminal or with a click in the browser
or chat. `.env` files can be shared too, as a link encrypted with a
secret your team can read.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim) - **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
@@ -34,18 +31,20 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Install ## Install
On macOS or Linux (arm64 or x86_64): On macOS or Linux (arm64 or x86_64):
```sh
curl -fsSL https://raw.githubusercontent.com/karleinarb/lazysecrets/main/install.sh | sh
```
Or
```sh ```sh
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
``` ```
It installs the latest release, from GitHub or else Both approaches will install the latest release from [GitHub](https://github.com/karleinarb/lazysecrets/releases) with fallback to
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
`~/.local/bin` after checking it against the release's checksums. `~/.local/bin` after checking it against the release's checksums.
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR` `sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else. somewhere else.
lazysecrets looks for a newer release once a day: the footer then shows lazysecrets looks for a newer release once a day: the footer then shows
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run `↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
`lazysecrets update`). `lazysecrets update`).
@@ -78,29 +77,47 @@ use most:
| `?` | every key, searchable | | `?` | every key, searchable |
| `q` | quit | | `q` | quit |
## Integration
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
Here is an example of implementing a picker in `fzf`:
```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](docs/assets/fzf-example.png)
More defails in the [Integration](docs/integration.md) docs
## Docs ## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys - [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults - [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes - [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting - [SOPS and .env files](docs/repos.md): which repos and files, decrypting
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts, - [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
and opening links from the browser and chat opening links, also from the browser and chat
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
- [Custom commands](docs/custom-commands.md): run your own commands on a - [Custom commands](docs/custom-commands.md): run your own commands on a
secret secret
- [Config](docs/config.md): every option, themes, layered configs, the - [Config](docs/config.md): every option, themes, layered configs, the
state file state file
- [Development](docs/development.md): building, tests, pull requests and - [Development](docs/development.md): building, tests, how changes land and
releasing releasing
## Privacy ## Privacy
lazysecrets never stores, caches or sends your secret values. It keeps the lazysecrets never stores, caches or sends your secret values. It keeps the
names and metadata of what you've loaded in a cache file readable only by names and metadata of what you've loaded in a cache file readable only by
you, to show it at once next time (`cache: false` turns it off). It talks you, to show it at once next time (`storage.cache: false` turns it off). It talks
only to the services you use it with, and collects no usage data. The one only to the services you use it with, and collects no usage data. The one
call of its own is a daily check for a new release, on GitHub or else call of its own is a daily check for a new release, on GitHub or else
git.kebcloud.no, which `updateCheck: false` turns off. git.kebcloud.no, which `updates.autoCheck: false` turns off.
## Repositories ## Repositories
+11
View File
@@ -0,0 +1,11 @@
# Security
Please report a vulnerability privately, not in a public issue:
- on GitHub, with **Report a vulnerability** under the
[Security tab](https://github.com/karleinarb/lazysecrets/security), or
- by email to karl-einarb@outlook.com.
Say what's affected, how to reproduce it and the version
(`lazysecrets --version`). Fixes go into the next release, and only the
latest release gets them.
+7 -2
View File
@@ -37,10 +37,15 @@ func value(ctx context.Context, providers []provider.Provider, st *store.Store,
return v, err return v, err
} }
// readKey is value, also returning the version read. // 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) { func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
key := r key := r
key.Version = "" key.Version = ""
node := st.Node(key.String()) node := st.Node(key.String())
return ref.Read(ctx, providers, r, node.Account, node.Source) account, source := node.Account, node.Source
if r.Account != "" && r.Account != account {
account, source = r.Account, ""
}
return ref.Read(ctx, providers, r, account, source)
} }
+269
View File
@@ -0,0 +1,269 @@
package main
import (
"cmp"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// listCmd prints every source, vault and secret lazysecrets can find, one
// per line, for fzf, editor pickers and scripts:
//
// lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1
//
// Each line is the kind, a mark (✗ or ⚠, ★, ◷ and the provider's icon),
// the name, a status (why it couldn't be read, or that it has expired or
// expires soon; else empty), its note (muted, or empty), the account and
// the link, tab-separated. The mark has a field of its own so a search can
// leave it out (fzf --nth), and ^prod finds names starting with prod. The
// name is coloured from the theme,
// with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS,
// ui.icons); the link never is.
//
// What's cached prints at once; what's missing or older than --max-age
// follows as it loads. A list that can't be read is a line too, as it
// fails: the vault or source, then ✗ and why in the theme's error colour.
// They're in the output rather than on stderr, which would draw over fzf.
func listCmd(providers []provider.Provider, st *store.Store, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons, withRecent bool, expiryWarn time.Duration, args []string, out io.Writer) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks")
maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again")
format := fs.String("format", "text", "text: kind, mark, name, status, note, account and link, tab-separated; json: an object per line")
color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi")
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)")
recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)")
notes := fs.Bool("notes", true, "start a note with a note icon (✎ without --icons) (text; the note is printed either way)")
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() > 0 {
return fmt.Errorf("usage: lazysecrets list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]")
}
var only []string
for k := range strings.SplitSeq(*kinds, ",") {
if k = strings.TrimSpace(k); k == "" {
continue
}
if !slices.Contains(listing.Kinds, k) {
return fmt.Errorf("--kind: %q is not one of %s", k, strings.Join(listing.Kinds, ", "))
}
only = append(only, k)
}
if *color != "always" && *color != "never" {
return fmt.Errorf("--color: %q is not always or never", *color)
}
now := time.Now()
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry,
color: *color == "always", warn: expiryWarn, now: now}
if *iconsOn {
m.icons = icons.NerdFont
}
var write func(listing.Entry) error
switch *format {
case "text":
write = func(e listing.Entry) error {
colored := *color == "always"
mark := m.of(e)
name := nameOf(e, theme, colored)
// Why it couldn't be read (vaults, sources), or when it
// expires (secrets, keys, certificates): never both.
status := errorOf(e, theme, colored)
if status == "" && *expiry {
status = expiryOf(e, theme, expiryWarn, now, colored)
}
note := noteOf(e, theme, m.icons, *notes, colored)
// A tab or line break in a name would break the line up.
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(status),
oneLine.Replace(note), e.Account, e.Link)
return err
}
case "json":
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
write = func(e listing.Entry) error { return enc.Encode(e) }
default:
return fmt.Errorf("--format: %q is not text or json", *format)
}
// When the reader goes away (a pick in fzf), writing fails instead of
// killing the process, so what loaded is still saved.
signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
var gone error
emit := func(e listing.Entry) error {
if e.Error != "" && !*showErrors {
return nil
}
if err := write(e); err != nil {
gone = err
return err
}
return nil
}
f := cachefile.New()
if cachePath != "" {
f = cachefile.Read(cachePath)
}
f, loaded := listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
Mark: func(link, account string) listing.Marks {
n := st.Node(link)
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
},
}, emit)
// Only what was loaded: list never moves a cursor, so the TUI's stay.
if cachePath != "" && loaded {
f.Cursor = nil
if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil {
return err
}
}
if gone != nil && !errors.Is(gone, syscall.EPIPE) {
return gone
}
return nil // or the reader has what it wanted
}
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
// noteOf is the entry's note, muted, after a note icon (✎ without Nerd
// Font icons) with icon; or "".
func noteOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, icon, color bool) string {
if e.Note == "" {
return ""
}
note := e.Note
if icon {
note = cmp.Or(iconSet.Note, "✎") + " " + note
}
return renderer(color)(theme.Muted, note)
}
// expiryOf says that a secret, key or certificate has expired, in red,
// or when it will if that's within warn (expires in 12d), in yellow, or
// is "". Its mark (✗, ⚠) is before the name.
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
render := renderer(color)
switch state, left := expiry.Of(e.Expires, now, warn); state {
case expiry.Expired:
return render(theme.Error, "expired")
case expiry.Soon:
return render(theme.Warning, "expires in "+expiry.Left(left))
}
return ""
}
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
// is before the name.
func errorOf(e listing.Entry, theme styles.Styles, color bool) string {
return renderer(color)(theme.Error, e.Error)
}
// nameOf is an entry's name for the text format: the path with the
// item's own name standing out, and the account.
func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
render := renderer(color)
var name string
if n := len(e.Path); n > 0 {
if n > 1 {
name = render(theme.Muted, strings.Join(e.Path[:n-1], " / ")+" / ")
}
name += e.Path[n-1]
}
if e.AccountLabel != "" {
name += render(theme.Muted, " · "+e.AccountLabel)
}
return name
}
// marks says what goes before the name, in a field of its own so a
// search leaves it out: ✗ in red when something's wrong (it couldn't be
// read, or has expired) or ⚠ when it expires within warn; ★ for a starred
// entry and ◷ for one used lately, as the TUI marks them; then the
// provider's icon. The marks are right-aligned in room for every kind
// turned on, so they sit by the icon and names line up.
type marks struct {
theme styles.Styles
icons icons.Set
stars, recent, expiry, color bool
warn time.Duration // ui.expiryWarningDays
now time.Time
}
func (m marks) of(e listing.Entry) string {
render := renderer(m.color)
var parts []string
room := 0 // marks turned on
mark := func(on bool, st lipgloss.Style, s string) {
room++
if on {
parts = append(parts, render(st, s))
}
}
// What's wrong, then what to watch: room for one of them.
state := expiry.None
if m.expiry {
state, _ = expiry.Of(e.Expires, m.now, m.warn)
}
switch {
case e.Error != "" || state == expiry.Expired:
mark(true, m.theme.Error, "✗")
case state == expiry.Soon:
mark(true, m.theme.Warning, "⚠")
default:
mark(false, m.theme.Error, "")
}
if m.stars {
mark(e.Starred, m.theme.Favourite, "★")
}
if m.recent {
mark(e.Recent, m.theme.Muted, "◷")
}
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
kind := provider.Kind("")
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
if strings.EqualFold(string(k), e.Provider) {
kind = k
}
}
if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" {
parts = append(parts, render(m.theme.Kind(kind), icon))
} else if len(parts) == 0 {
pad = strings.TrimSuffix(pad, " ") // no mark after the room
}
return pad + strings.Join(parts, " ")
}
func renderer(color bool) func(lipgloss.Style, string) string {
return func(st lipgloss.Style, s string) string {
if !color || s == "" {
return s
}
return st.Render(s)
}
}
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
func TestListNames(t *testing.T) {
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, AccountLabel: "alice"}
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-app-prod / db-password · alice" {
t.Errorf("plain: %q", got)
}
// The item's own name isn't muted with its path.
if got := nameOf(e, styles.Default(), true); !strings.Contains(got, "\x1b[mdb-password") {
t.Errorf("name not set apart: %q", got)
}
}
func TestListMarks(t *testing.T) {
az := icons.NerdFont.Azure
plain := marks{theme: styles.Default(), icons: icons.NerdFont, stars: true}
noIcons := plain
noIcons.icons = icons.None
colored := noIcons
colored.color = true
for _, c := range []struct {
m marks
e listing.Entry
want string
}{
// Room for the status mark and the star, so names line up.
{plain, listing.Entry{Provider: "azure", Starred: true}, " ★ " + az},
{plain, listing.Entry{Provider: "azure"}, " " + az},
{marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, " " + az},
{noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"},
{marks{}, listing.Entry{Provider: "azure", Starred: true}, " "},
{colored, listing.Entry{Provider: "azure", Starred: true}, " " + styles.Default().Favourite.Render("★")},
} {
if got := c.m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
}
}
func TestListRecentMarks(t *testing.T) {
az := icons.NerdFont.Azure
m := marks{icons: icons.NerdFont, stars: true, recent: true}
for _, c := range []struct {
e listing.Entry
want string
}{
// Right-aligned in room for three: by the icon, names lined up.
{listing.Entry{Provider: "azure", Starred: true, Recent: true}, " ★ ◷ " + az},
{listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az},
{listing.Entry{Provider: "azure"}, " " + az},
} {
if got := m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
}
}
func TestListExpiry(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
warn := 30 * 24 * time.Hour
for _, c := range []struct {
expires time.Time
want string
}{
{time.Time{}, ""},
{now.Add(-time.Hour), "expired"},
{now.Add(12*24*time.Hour + time.Hour), "expires in 12d"},
{now.Add(5 * time.Hour), "expires in 5h"},
{now.Add(60 * 24 * time.Hour), ""},
} {
if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want {
t.Errorf("expires %v: %q, want %q", c.expires, got, c.want)
}
}
if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("expired") {
t.Errorf("expired isn't red: %q", got)
}
}
func TestListNotes(t *testing.T) {
e := listing.Entry{Note: "rotate in May"}
if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" {
t.Errorf("icons: %q", got)
}
if got := noteOf(e, styles.Default(), icons.None, true, false); got != "✎ rotate in May" {
t.Errorf("no icons: %q", got)
}
if got := noteOf(e, styles.Default(), icons.NerdFont, false, false); got != "rotate in May" {
t.Errorf("--notes=false: %q", got)
}
if got := noteOf(listing.Entry{}, styles.Default(), icons.NerdFont, true, true); got != "" {
t.Errorf("no note: %q", got)
}
}
func TestListStatusMark(t *testing.T) {
az := icons.NerdFont.Azure
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
m := marks{icons: icons.NerdFont, stars: true, expiry: true, warn: 30 * 24 * time.Hour, now: now}
for _, c := range []struct {
e listing.Entry
want string
}{
// Right-aligned in room for two, as with ★ and ◷.
{listing.Entry{Provider: "azure", Error: "403 Forbidden"}, " ✗ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour), Starred: true}, "✗ ★ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(5 * 24 * time.Hour)}, " ⚠ " + az},
{listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour), Starred: true}, " ★ " + az},
} {
if got := m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
}
m.expiry = false // --expiry=false: no ⚠ or ✗ for expiry; errors still
if got := m.of(listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}); got != " "+az {
t.Errorf("--expiry=false: %q", got)
}
m.expiry, m.color, m.theme = true, true, styles.Default()
if got := m.of(listing.Entry{Error: "403"}); !strings.Contains(got, styles.Default().Error.Render("✗")) {
t.Errorf("not a red ✗: %q", got)
}
if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) {
t.Errorf("not a yellow ⚠: %q", got)
}
}
func TestListErrors(t *testing.T) {
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"}
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-restricted" {
t.Errorf("the name has more: %q", got)
}
if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" {
t.Errorf("plain: %q", got)
}
if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("403 Forbidden") {
t.Errorf("why isn't red: %q", got)
}
if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" {
t.Errorf("no error: %q", got)
}
}
+129 -37
View File
@@ -3,7 +3,9 @@
package main package main
import ( import (
"cmp"
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -87,6 +89,32 @@ func cmpOr(a, b string) string {
return b return b
} }
// refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column) and,
// for open, what to do with it rather than asking (--action).
func refArg(cmd string, args []string) (r ref.Ref, action string, err error) {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
account := fs.String("account", "", "the account to use when several could, as lazysecrets list prints it")
usage := "usage: lazysecrets get [--account <account>] <ref>"
act := new(string)
if cmd == "open" {
act = fs.String("action", "", "do this rather than ask: go (to it), copy (go, and copy its value) or show (go, and show it)")
usage = "usage: lazysecrets open [--account <account>] [--action go|copy|show] <ref>"
}
if err := fs.Parse(args); err != nil {
return r, "", err
}
if fs.NArg() != 1 {
return r, "", errors.New(usage)
}
if _, ok := app.OpenActions[*act]; *act != "" && !ok {
return r, "", fmt.Errorf("--action: %q is not go, copy or show", *act)
}
r, err = ref.Parse(fs.Arg(0))
r.Account = *account
return r, *act, err
}
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the // defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
// config directory. // config directory.
func defaultConfigPath() string { func defaultConfigPath() string {
@@ -106,22 +134,28 @@ func run() error {
showVersion := flag.Bool("version", false, "print the version and exit") showVersion := flag.Bool("version", false, "print the version and exit")
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)") dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends") demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request") timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request, every provider's (overrides the config)")
icons := flag.Bool("icons", true, icons := flag.Bool("icons", true,
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0") "show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)") clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), `Usage: fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets lazysecrets [flags] browse secrets
lazysecrets [flags] [open] <ref> lazysecrets [flags] [open [--account <account>] [--action go|copy|show]] <ref>
open a lazysecrets:// link (also a share link), open a lazysecrets:// link (also a share link),
Key Vault URL, @Microsoft.KeyVault(...) or Key Vault URL, @Microsoft.KeyVault(...) or
{{secrets/scope/key}} {{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at, lazysecrets [flags] get [--account <account>] <ref>
print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false]
print every source, vault and secret it can
find, one per line, for fzf and pickers:
what's cached at once, then what loads
lazysecrets [flags] share [--key <secret>] <env file>... lazysecrets [flags] share [--key <secret>] <env file>...
print a link to env files of a repo, encrypted print a link to env files of a repo, encrypted
with a secret's value (default: share.key) with a secret's value (default: sharing.key)
lazysecrets [flags] keybindings print every action and its keys (with your lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME] lazysecrets handler install [--terminal NAME]
@@ -144,7 +178,8 @@ Flags:
} }
var open, getRef *ref.Ref var open, getRef *ref.Ref
var shareArgs []string var openAction string
var shareArgs, listArgs []string
printKeys := false printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) > 0 && args[0] == "share": case len(args) > 0 && args[0] == "share":
@@ -152,10 +187,12 @@ Flags:
if len(shareArgs) == 0 { if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how 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": case len(args) == 1 && args[0] == "keybindings":
printKeys = true printKeys = true
case len(args) == 2 && args[0] == "get": case len(args) > 0 && args[0] == "get":
r, err := ref.Parse(args[1]) r, _, err := refArg("get", args[1:])
if err != nil { if err != nil {
return err return err
} }
@@ -167,12 +204,12 @@ Flags:
case len(args) > 0 && args[0] == "update": case len(args) > 0 && args[0] == "update":
return updateCmd(*configPath, args[1:]) return updateCmd(*configPath, args[1:])
case len(args) == 0: case len(args) == 0:
case args[0] == "open" && len(args) == 2: case args[0] == "open" && len(args) >= 2:
r, err := ref.Parse(args[1]) r, act, err := refArg("open", args[1:])
if err != nil { if err != nil {
return err return err
} }
open = &r open, openAction = &r, act
case len(args) == 1 && looksLikeRef(args[0]): case len(args) == 1 && looksLikeRef(args[0]):
// lazysecrets <link>, like lazysecrets open <link> // lazysecrets <link>, like lazysecrets open <link>
r, err := ref.Parse(args[0]) r, err := ref.Parse(args[0])
@@ -193,6 +230,11 @@ Flags:
if err != nil { if err != nil {
return err return err
} }
// Warnings show when the window opens.
var warnings []string
if w := styles.Unknown(cfg.Theme); w != "" {
warnings = append(warnings, w)
}
keymap := keys.Default() keymap := keys.Default()
if err := keymap.Apply(cfg.Keybinding); err != nil { if err := keymap.Apply(cfg.Keybinding); err != nil {
return err return err
@@ -207,33 +249,57 @@ Flags:
// Precedence: flag > environment > config file > default. // Precedence: flag > environment > config file > default.
set := map[string]bool{} set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true }) flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if !set["timeout"] && cfg.Timeout > 0 { if *timeout <= 0 {
*timeout = cfg.Timeout return fmt.Errorf("--timeout: %s is not positive", *timeout)
} }
if !set["clipboard-clear"] && cfg.ClipboardClear != nil { // Each provider's own timeout, unless --timeout sets them all.
*clipClear = *cfg.ClipboardClear timeouts := map[provider.Kind]time.Duration{}
if !set["timeout"] {
if cfg.Providers.Timeout > 0 {
*timeout = cfg.Providers.Timeout
}
for k, d := range map[provider.Kind]time.Duration{
provider.KindAzure: cfg.Providers.Azure.Timeout,
provider.KindDatabricks: cfg.Providers.Databricks.Timeout,
provider.KindRepo: cfg.Providers.Repos.Timeout,
} {
if d > 0 {
timeouts[k] = d
}
}
}
// timeoutFor is how long a request to a provider of kind k may take.
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
*clipClear = *cfg.Values.ClipboardClear
} }
expiryDays := 30 expiryDays := 30
if cfg.ExpiryWarningDays != nil { if cfg.UI.ExpiryWarningDays != nil {
expiryDays = *cfg.ExpiryWarningDays expiryDays = *cfg.UI.ExpiryWarningDays
} }
revealTimeout := 60 * time.Second revealTimeout := 60 * time.Second
if cfg.RevealTimeout != nil { if cfg.Values.RevealTimeout != nil {
revealTimeout = *cfg.RevealTimeout revealTimeout = *cfg.Values.RevealTimeout
} }
loadDelay := 150 * time.Millisecond loadDelay := 150 * time.Millisecond
if cfg.LoadDelay != nil { if cfg.UI.LoadDelay != nil {
loadDelay = *cfg.LoadDelay loadDelay = *cfg.UI.LoadDelay
}
var updateEvery time.Duration // 0: the app's default, a day
if cfg.Updates.AutoCheckInterval != nil {
updateEvery = *cfg.Updates.AutoCheckInterval
} }
if !set["icons"] { if !set["icons"] {
switch { switch {
case os.Getenv("LAZYSECRETS_ICONS") != "": case os.Getenv("LAZYSECRETS_ICONS") != "":
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0" *icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
case cfg.Icons != nil: case cfg.UI.Icons != nil:
*icons = *cfg.Icons *icons = *cfg.UI.Icons
} }
} }
recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon
// Stars, notes and the layout. Demo mode keeps them in memory, so // Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file. // sample vaults never end up in the real file.
stateDir, err := config.StateDir() stateDir, err := config.StateDir()
@@ -245,16 +311,23 @@ Flags:
statePath = filepath.Join(stateDir, "state.json") statePath = filepath.Join(stateDir, "state.json")
} }
st, err := store.Load(statePath) st, err := store.Load(statePath)
if err != nil { if errors.Is(err, store.ErrNewer) {
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
} else if err != nil {
return err return err
} }
var providers []provider.Provider var providers []provider.Provider
if *demo { if *demo {
// Two Azure accounts, like a personal and an admin login in az. // Two Azure accounts, like a personal and an admin login in az.
alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
fake.Seed(st, alice, admin)
az := fake.Azure(400*time.Millisecond).As(alice, "Azure · alice")
// Due soon whenever the demo runs (the tests' dates are fixed).
az.Expire("kv-app-prod", "api-key", time.Now().AddDate(0, 0, 9).Truncate(24*time.Hour))
providers = []provider.Provider{ providers = []provider.Provider{
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"), az,
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"), fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
fake.Databricks(600 * time.Millisecond), fake.Databricks(600 * time.Millisecond),
fake.Repos(), fake.Repos(),
} }
@@ -287,7 +360,7 @@ Flags:
if start == "" { if start == "" {
start, _ = os.Getwd() start, _ = os.Getwd()
} }
roots := []string{"~/git"} roots := repo.DefaultRoots
if pc.Repos.Roots != nil { if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots roots = *pc.Repos.Roots
} }
@@ -310,14 +383,27 @@ Flags:
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
if getRef != nil { if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout) ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
defer cancel() defer cancel()
return get(ctx, providers, st, *getRef) 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 { if shareArgs != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout) // The files from a repo, the key from whichever provider it's
// in: the longest timeout.
d := *timeout
for _, t := range timeouts {
d = max(d, t)
}
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel() defer cancel()
return shareFiles(ctx, providers, st, cfg.Share.Key, shareRules(cfg), shareArgs, os.Stdout) return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
} }
// Links opened elsewhere (lazysecrets launch) can come here. // Links opened elsewhere (lazysecrets launch) can come here.
@@ -354,7 +440,7 @@ Flags:
// What was loaded last time, and where it was left in this folder. // What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string var cacheFile, placeDir string
if !*demo { if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) { if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
cacheFile = filepath.Join(d, "cache.json") cacheFile = filepath.Join(d, "cache.json")
} }
placeDir = *dir placeDir = *dir
@@ -368,34 +454,40 @@ Flags:
m := app.New(providers, app.Options{ m := app.New(providers, app.Options{
Timeout: *timeout, Timeout: *timeout,
Timeouts: timeouts,
Icons: *icons, Icons: *icons,
ClipboardClear: *clipClear, ClipboardClear: *clipClear,
FlatSources: cfg.SourcesView == "flat", FlatSources: cfg.UI.SourcesView == "flat",
CollapseGroups: config.On(cfg.CollapseGroups), CollapseGroups: config.On(cfg.UI.CollapseGroups),
LoadDelay: loadDelay, LoadDelay: loadDelay,
NoHistory: !config.On(cfg.History), NoHistory: !config.On(cfg.Storage.History),
OnActive: onActive, OnActive: onActive,
RevealTimeout: revealTimeout, RevealTimeout: revealTimeout,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.HideOnBlur), HideOnBlur: config.On(cfg.Values.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(cfg.Releases), Updater: updater(cfg.Updates.Sources),
UpdateCheck: config.On(cfg.UpdateCheck), UpdateCheck: config.On(cfg.Updates.AutoCheck),
UpdateEvery: updateEvery,
Keys: &keymap, Keys: &keymap,
Commands: cfg.CustomCommands, Commands: cfg.CustomCommands,
Version: versionString(), Version: versionString(),
Store: st, Store: st,
Warning: strings.Join(warnings, " · "),
CacheFile: cacheFile, CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath), ConfigFiles: configFiles(*configPath),
StatePath: statePath, StatePath: statePath,
Changelog: lazysecrets.Changelog, Changelog: lazysecrets.Changelog,
Dir: placeDir, Dir: placeDir,
ShareKey: cfg.Share.Key, ShareKey: cfg.Sharing.Key,
ShareRules: shareRules(cfg), ShareRules: shareRules(cfg),
Open: open, Open: open,
OpenAction: openAction,
RecentIcon: recentIcon,
Styles: &theme, Styles: &theme,
DefaultFg: defaultFg, DefaultFg: defaultFg,
}) })
blockingStdin()
p = tea.NewProgram(m) p = tea.NewProgram(m)
final, err := p.Run() final, err := p.Run()
if fm, ok := final.(app.Model); ok { if fm, ok := final.(app.Model); ok {
+5 -5
View File
@@ -17,12 +17,12 @@ import (
) )
// shareFiles prints a share link to env files of one repo, encrypted with // shareFiles prints a share link to env files of one repo, encrypted with
// the --key secret's value, else the repo's or the config's share.key: // the --key secret's value, else the repo's or the config's sharing.key:
// //
// lazysecrets share .env apps/api/.env // lazysecrets share .env apps/api/.env
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error { func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error {
fs := flag.NewFlagSet("share", flag.ContinueOnError) fs := flag.NewFlagSet("share", flag.ContinueOnError)
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: share.key)") keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: sharing.key)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
@@ -73,7 +73,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
return err return err
} }
if !ok { if !ok {
return errors.New("which secret to encrypt with? --key <secret>, or share.key in the repo's .lazysecrets.yaml or the config") return errors.New("which secret to encrypt with? --key <secret>, or sharing.key in the repo's .lazysecrets.yaml or the config")
} }
v, version, err := readKey(ctx, providers, st, key) v, version, err := readKey(ctx, providers, st, key)
if err != nil { if err != nil {
@@ -87,7 +87,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
return err return err
} }
// shareRules are the config's share.keyRules. // shareRules are the config's sharing.keyRules.
func shareRules(cfg config.Config) share.Rules { func shareRules(cfg config.Config) share.Rules {
return share.Rules{MinLength: cfg.Share.KeyRules.MinLength, MinClasses: cfg.Share.KeyRules.MinClasses} return share.Rules{MinLength: cfg.Sharing.KeyRules.MinLength, MinClasses: cfg.Sharing.KeyRules.MinClasses}
} }
+5
View File
@@ -0,0 +1,5 @@
//go:build !unix
package main
func blockingStdin() {}
+20
View File
@@ -0,0 +1,20 @@
//go:build unix
package main
import (
"os"
"syscall"
"golang.org/x/term"
)
// blockingStdin makes a terminal on stdin blocking again. fzf's become
// hands on its /dev/tty non-blocking; Go can't poll /dev/tty on macOS,
// so it reads it directly, and the TUI's first read would fail with
// EAGAIN ("resource temporarily unavailable").
func blockingStdin() {
if fd := int(os.Stdin.Fd()); term.IsTerminal(fd) {
_ = syscall.SetNonblock(fd, false)
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ func updateCmd(configPath string, args []string) error {
current := versionString() current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
c := &update.Client{Servers: cfg.Releases} c := &update.Client{Servers: cfg.Updates.Sources}
r, err := c.Latest(ctx) r, err := c.Latest(ctx)
if err != nil { if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
@@ -55,7 +55,7 @@ func updateCmd(configPath string, args []string) error {
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag) fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil return nil
} }
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes())) 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.") fmt.Println("lazysecrets update installs it.")
return nil return nil
+20 -2
View File
@@ -1,6 +1,10 @@
package main package main
import "runtime/debug" import (
"regexp"
"runtime/debug"
"strings"
)
// version is set by release builds: // version is set by release builds:
// //
@@ -8,7 +12,12 @@ import "runtime/debug"
var version string var version string
// versionString is the release version, or for other builds what Go // versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit. // 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})(\+.*)?$`)
func versionString() string { func versionString() string {
if version != "" { if version != "" {
return version return version
@@ -18,6 +27,15 @@ func versionString() string {
return "dev" return "dev"
} }
if v := bi.Main.Version; v != "" && v != "(devel)" { if v := bi.Main.Version; v != "" && v != "(devel)" {
// A commit, not a release: a local build (Go stamps those too) or
// go install …@<commit>.
if m := pseudo.FindStringSubmatch(v); m != nil {
dirty := ""
if strings.HasSuffix(v, "+dirty") {
dirty = "-dirty"
}
return "dev-" + m[1][:7] + dirty
}
return v return v
} }
var rev, dirty string var rev, dirty string
+22
View File
@@ -0,0 +1,22 @@
package main
import "testing"
func TestPseudoVersions(t *testing.T) {
for v, want := range map[string]string{
"v0.1.3-0.20261014174204-1c31a75d0507": "1c31a75d0507",
"v0.1.3-0.20261014174204-1c31a75d0507+dirty": "1c31a75d0507",
"v0.0.0-20261014174204-1c31a75d0507": "1c31a75d0507",
"v0.2.0-rc.1.0.20261014174204-1c31a75d0507": "1c31a75d0507",
"v0.1.2": "",
"v0.2.0-rc.1": "",
} {
got := ""
if m := pseudo.FindStringSubmatch(v); m != nil {
got = m[1]
}
if got != want {
t.Errorf("%s: %q, want %q", v, got, want)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 258 KiB

+1 -1
View File
@@ -63,7 +63,7 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane
(`[`/`]`). `space` reveals a secret's value, or the public material of a (`[`/`]`). `space` reveals a secret's value, or the public material of a
key (PEM public key) or certificate; private key material is never key (PEM public key) or certificate; private key material is never
fetched. `v` lists an item's versions. Items expiring within fetched. `v` lists an item's versions. Items expiring within
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`. `ui.expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
The status pane (`0`) lists them across every vault loaded, opened or The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
+42 -26
View File
@@ -7,37 +7,46 @@ status pane (`0`) opens it in your editor, as in lazygit (making it if
it isn't there yet); changes apply when lazysecrets starts again. it isn't there yet); changes apply when lazysecrets starts again.
```yaml ```yaml
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0 ui:
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown recentIcon: false # mark items used lately (Recent, R) with ◷, here and in lazysecrets list
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on) sourcesView: grouped # or flat; ` toggles it
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗ collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
timeout: 30s # per backend request loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d; ⚠ in lazysecrets list); expired ones get ✗
sourcesView: grouped # or flat; ` toggles it values:
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way) hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server) clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
storage:
cache: true # keep what was loaded (never values) for the next start
history: true # count uses of items for Recent (R)
updates:
autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way)
autoCheckInterval: 24h # at most this often
sources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets - https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets - https://git.kebcloud.no/keb/lazysecrets
history: true # count uses of items for Recent (R)
cache: true # keep what was loaded (never values) for the next start
providers: providers:
timeout: 30s # per backend request; a provider's own timeout overrides it
azure: # applies to every az account azure: # applies to every az account
enabled: true # default enabled: true # default
timeout: 30s
hideSubscriptions: [platform-mgmt] # by name or ID hideSubscriptions: [platform-mgmt] # by name or ID
vaults: # listed under "Pinned vaults", for vaults vaults: # listed under "Pinned vaults", for vaults
- https://kv-foo.vault.azure.net/ # you can read but not discover - https://kv-foo.vault.azure.net/ # you can read but not discover
databricks: databricks:
enabled: true enabled: true
timeout: 30s
hideProfiles: [test] hideProfiles: [test]
repos: repos:
enabled: true enabled: true
timeout: 30s
roots: [~/git] roots: [~/git]
paths: [~/work/infra] paths: [~/work/infra]
include: ["secrets/**"] include: ["secrets/**"]
exclude: ["**/testdata/**"] exclude: ["**/testdata/**"]
share: # sharing env files as encrypted links sharing: # sharing env files as encrypted links
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
keyRules: # what a key's value must be keyRules: # what a key's value must be
minLength: 32 # characters, 16 at least minLength: 32 # characters, 16 at least
@@ -45,8 +54,8 @@ share: # sharing env files as encrypted links
``` ```
More, each on its own page: [repos](repos.md) (`providers.repos`), More, each on its own page: [repos](repos.md) (`providers.repos`),
[sharing env files](links.md#sharing-env-files) (`share`), the [sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`), [link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`). [custom commands](custom-commands.md) (`customCommands`).
@@ -83,8 +92,11 @@ theme:
repoColor: ["#c099ff"] repoColor: ["#c099ff"]
``` ```
A role lazysecrets doesn't know, a typo or one from a newer version, is
skipped with a warning.
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
as boxes, turn them off with `icons: false`. as boxes, turn them off with `ui.icons: false`.
## Layering ## Layering
@@ -108,11 +120,13 @@ starred the item under (a vault two of your accounts can see can be starred
for one); a note describes the item, so it shows under every account. It's for one); a note describes the item, so it shows under every account. It's
JSON so an editor plugin can read it with a built-in parser; several JSON so an editor plugin can read it with a built-in parser; several
lazysecrets windows can save to it without losing each other's changes. lazysecrets windows can save to it without losing each other's changes.
One written by a newer lazysecrets is read but not saved to, with a
warning, until you update.
Revealing, copying, opening in the browser, sharing and jumping to an item Revealing, copying, opening in the browser, sharing and jumping to an item
count as using it: its record keeps when it was last used and how often, count as using it: its record keeps when it was last used and how often,
for Recent (`R`). Records kept only for usage are dropped after 90 days for Recent (`R`). Records kept only for usage are dropped after 90 days
unused, and beyond the 100 most recent. `history: false` stops counting. unused, and beyond the 100 most recent. `storage.history: false` stops counting.
It also keeps where you left lazysecrets in each folder (the selected It also keeps where you left lazysecrets in each folder (the selected
source, vault or secret), and goes back there the next time it starts in source, vault or secret), and goes back there the next time it starts in
@@ -127,22 +141,24 @@ you: the Sources lists, vaults and scopes, and secrets' names and metadata
once, and anything older than two minutes reloads in the background when once, and anything older than two minutes reloads in the background when
you go to it, or when Go to searches it. While it does, the pane's you go to it, or when Go to searches it. While it does, the pane's
bottom border says how old what it shows is (`cached 3h ago`). Revealing bottom border says how old what it shows is (`cached 3h ago`). Revealing
and copying always fetch the value live. and copying always fetch the value live. [`lazysecrets list`](integration.md#listing-for-fzf-and-pickers)
reads it and adds to it too.
Lists not loaded for 30 days are dropped, and so is everything from an Lists not loaded for 30 days are dropped, and so is everything from an
account you're no longer signed in to. Repos aren't cached: their files account you're no longer signed in to. Repos aren't cached: their files
are read from disk anyway. Deleting the file is always safe, and are read from disk anyway. Deleting the file is always safe, and
`cache: false` stops using it. `storage.cache: false` stops using it.
## Updates ## Updates
Once a day (`updateCheck`), in the background, lazysecrets asks for the At startup, in the background, lazysecrets asks for the latest release
latest release; within the day it reuses the answer from the state file. (`updates.autoCheck`), once a day or every `updates.autoCheckInterval`;
Releases are published on GitHub and on git.kebcloud.no, and it asks until then it reuses the answer from the state file. Releases are
them in that order, the next when one doesn't answer within 5 seconds; published on GitHub and on git.kebcloud.no, and it asks them in that
`releases` lists others, such as a mirror of your own. Offline, or with order, the next when one doesn't answer within 5 seconds;
`updates.sources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows its changelog. `lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
Updating (`u` there, or `lazysecrets update`) downloads the release for Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against your OS and CPU from the first server that has it, checks it against
+1 -1
View File
@@ -22,7 +22,7 @@ Development builds show `dev-<commit>` as their version (`-dirty` with
uncommitted changes); `go install …@v0.2.0` shows the tag, releases the uncommitted changes); `go install …@v0.2.0` shows the tag, releases the
version they were built for. version they were built for.
## Pull requests ## How changes land
Pull requests into `main` are squash-merged, so each is one commit titled Pull requests into `main` are squash-merged, so each is one commit titled
like the PR. The title is a [conventional commit](https://www.conventionalcommits.org) like the PR. The title is a [conventional commit](https://www.conventionalcommits.org)
+94
View File
@@ -0,0 +1,94 @@
# Integration
lazysecrets works outside its own window too: `lazysecrets get` reads a
value for scripts, and `lazysecrets list` lists everything for fzf, editor
pickers and your own tools. Both take the [links](sharing.md) lazysecrets
copies and shares. For Neovim, there's
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
## Reading a value
`lazysecrets get <link>` prints the value, for scripts and pipes:
```sh
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
```
It reads the value directly, with your az accounts (the one that last read
it first), the Databricks profile for the workspace, or sops in your clone
of the repo; a version in the link reads that version. `--account` tries
that account first, as [`list`](#listing-for-fzf-and-pickers) prints it.
The value ends with a newline only on a terminal. When it can't read the
value, it says why on stderr and exits with status 1.
## Listing, for fzf and pickers
`lazysecrets list` prints every source, vault and secret, one per line,
tab-separated:
| Field | |
|---|---|
| kind | `source`, `vault`, `secret`, `key` or `certificate` |
| mark | ✗ when something's wrong (expired, or couldn't be read) or ⚠ when it expires soon; ★ when starred; ◷ when used lately (with `--recent`); and the provider's icon |
| name | `platform-prod / kv-app-prod / db-password` |
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
| link | its `lazysecrets://` link |
What's cached prints at once, then what loads follows as it arrives: read
it as a stream. Values are never listed. It stops quietly when the reader
goes, like fzf after a pick. Kind stays the first field, and account and
link the last two; new fields only ever go between. The mark has a field of
its own so a search can leave it out: in fzf, `--nth` searches the name,
so `^prod` finds names starting with prod.
| Flag | |
|---|---|
| `--format json` | an object per line instead, for editor plugins (below) |
| `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded |
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read |
| `--color never` | no colours, for scripts |
| `--icons=false` | no provider icons |
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
| `--notes=false` | no note icon before a note |
| `--recent` | ◷ before names used lately, in `mutedColor`; default `ui.recentIcon` |
| `--expiry=false` | no expiry: not in the status, and no ✗ or ⚠ for it |
With `--format json`, each line is an object with these fields; new ones
may be added:
| Field | |
|---|---|
| `link`, `kind`, `account` | as above |
| `error` | why it couldn't be read, only then |
| `provider` | `azure`, `databricks` or `repo` |
| `path` | the names: `["platform-prod", "kv-app-prod", "db-password"]` |
| `accountLabel` | the account's short name, when several az accounts are signed in |
| `starred` | starred for this account |
| `note` | your note on it, if any |
| `reference` | how config refers to a secret: `@Microsoft.KeyVault(SecretUri=…)` or `{{secrets/scope/key}}` (`Y` in lazysecrets) |
| `url` | its web page, in the Azure portal or the Databricks workspace (`o` in lazysecrets) |
| `recent` | used lately, as Recent (`R`) lists: in the last 90 days |
| `lastUsed`, `uses` | when it was last used, and how often; only when `recent`, to sort by |
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
`lazysecrets open --action go --account <account> <link>` opens one in
lazysecrets without asking what to do or which account, ending in the
secrets list, like Go to.
### Example usage with fzf
```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](assets/fzf-example.png)
+3 -3
View File
@@ -54,7 +54,7 @@ match case exactly.
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) | | `o` | open the selected item in the browser (over SSH: copy the link) |
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) | | `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-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) | | `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) | | `x` | [custom commands](custom-commands.md) |
| `L` | sign in again (shown when a login has expired) | | `L` | sign in again (shown when a login has expired) |
@@ -65,8 +65,8 @@ certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored. it exactly as stored.
A revealed value hides again after `revealTimeout` (60s) and when the A revealed value hides again after `values.revealTimeout` (60s) and
terminal loses focus (`hideOnBlur`); see [config](config.md). when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
## Stars and notes ## Stars and notes
+12 -10
View File
@@ -3,14 +3,16 @@
lazysecrets shows git repositories under **Repos**: their lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, [SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI) and plain env files as vaults, and the keys in them `.env`, INI) and plain env files as vaults, and the keys in them
(`db.password`, `list[0]`) as secrets. (`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 ## Which repos
**Repos** lists every repo with secret files under the `roots` (`~/git` **Repos** lists every repo with secret files under the `roots` (by
by default; one or two levels down, `repo` or `org/repo`), any listed in default whichever of `~/git`, `~/repo`, `~/repos`, `~/projects`, `~/code`,
`paths`, and any you starred something in (the repo, a file or a key), `~/source` and `~/src` exist; one or two levels down, `repo` or
wherever it is. They're checked in the background. `org/repo`), any listed in `paths`, and any you starred something in
(the repo, a file or a key), wherever it is. They're checked in the background.
Started inside a repo, that repo gets a row of its own at the top, Started inside a repo, that repo gets a row of its own at the top,
`Repo · <name>`, and lazysecrets opens on it, with the files under the `Repo · <name>`, and lazysecrets opens on it, with the files under the
@@ -49,17 +51,17 @@ Folders of dependencies and caches are never looked in, tracked or not:
```yaml ```yaml
providers: providers:
repos: repos:
roots: [~/git] # folders of repos (the default; [] turns it off) roots: [~/git] # folders of repos (replaces the defaults; [] turns it off)
paths: [~/work/infra] # single repos elsewhere paths: [~/work/infra] # single repos elsewhere
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
exclude: ["**/testdata/**"] exclude: ["**/testdata/**"]
``` ```
A repo's `.lazysecrets.yaml` can also name the secret its env files are A repo's `.lazysecrets.yaml` can also name the secret its env files are
[shared](links.md#sharing-env-files) with: [shared](sharing.md#sharing-env-files) with:
```yaml ```yaml
share: sharing:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
``` ```
@@ -98,7 +100,7 @@ sops 3.8 or later.
## Links ## Links
`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files). `S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files).
Links name the repo by its git remote, so they open in a colleague's clone Links name the repo by its git remote, so they open in a colleague's clone
wherever it is: wherever it is:
@@ -107,4 +109,4 @@ wherever it is:
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
``` ```
See [links](links.md). See [sharing](sharing.md).
+11 -21
View File
@@ -1,7 +1,7 @@
# Links # Sharing
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can Subscriptions, workspaces, vaults, scopes, repos, files and secrets are
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
it lands on that item, reading it with their own account and it lands on that item, reading it with their own account and
permissions: the link names the item, never its value. Env files can permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself. also be [shared encrypted](#sharing-env-files), in the link itself.
@@ -15,7 +15,7 @@ lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
``` ```
## Sharing ## Sharing a link
`s` copies a link to the selected item (from the versions view, pinned to `s` copies a link to the selected item (from the versions view, pinned to
that version). `S` opens the share menu: that version). `S` opens the share menu:
@@ -54,7 +54,7 @@ uses it) or your config (for repos that don't name one), by its
reference: reference:
```yaml ```yaml
share: sharing:
key: https://kv-team.vault.azure.net/secrets/env-share key: https://kv-team.vault.azure.net/secrets/env-share
``` ```
@@ -67,7 +67,7 @@ isn't long and varied enough, saying how (never showing it). By default
that's 32 characters of two kinds; your config can ask for more, or less: that's 32 characters of two kinds; your config can ask for more, or less:
```yaml ```yaml
share: sharing:
keyRules: keyRules:
minLength: 32 # characters; 16 at least minLength: 32 # characters; 16 at least
minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols
@@ -99,7 +99,7 @@ the value breaks every old link. Whoever has read the secret's value
before can decrypt links made with it without asking the vault, so rotate before can decrypt links made with it without asking the vault, so rotate
it now and then: new links use the newest version. it now and then: new links use the newest version.
## Opening ## Opening a link
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard - Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
and fills in any reference it finds there. Key Vault URLs, and fills in any reference it finds there. Key Vault URLs,
@@ -121,20 +121,10 @@ account) and jumps to it, asking which account to use when several can see
it, ending on a secret's details as `enter` on it would. Links say what, it, ending on a secret's details as `enter` on it would. Links say what,
not what to do: opening a link to a secret asks whether to just go to it, not what to do: opening a link to a secret asks whether to just go to it,
also copy or show its value, or (`y`) copy its value without going there. also copy or show its value, or (`y`) copy its value without going there.
`lazysecrets open --action go|copy|show <link>` does one without asking
## In scripts and ends in the list, like Go to,
and `--account <account>` picks the account (as [`list`](integration.md#listing-for-fzf-and-pickers)
`lazysecrets get <link>` prints the value, for scripts and pipes: prints it) when several can see it.
```sh
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
```
It reads the value directly, with your az accounts (the one that last read
it first), the Databricks profile for the workspace, or sops in your clone
of the repo; a version in the link reads that version. The value ends with
a newline only on a terminal.
## From the browser and chat ## From the browser and chat
+2 -2
View File
@@ -18,8 +18,8 @@ require (
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/x/ansi v0.11.9 github.com/charmbracelet/x/ansi v0.11.9
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
github.com/databricks/databricks-sdk-go v0.187.0 github.com/databricks/databricks-sdk-go v0.188.0
github.com/junegunn/fzf v0.74.4 github.com/junegunn/fzf v0.74.5
go.yaml.in/yaml/v3 v3.0.5 go.yaml.in/yaml/v3 v3.0.5
golang.org/x/mod v0.42.0 golang.org/x/mod v0.42.0
golang.org/x/term v0.47.0 golang.org/x/term v0.47.0
+4 -4
View File
@@ -72,8 +72,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/databricks/databricks-sdk-go v0.187.0 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8= github.com/databricks/databricks-sdk-go v0.188.0 h1:1nxBqlctGnPzlReyziG199abHs9cR/KNjsCnSM+oDGA=
github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8= github.com/databricks/databricks-sdk-go v0.188.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -100,8 +100,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso= github.com/junegunn/fzf v0.74.5 h1:mpJlCdgGuddKerHrdjvV3cnezEBgyjZAVn8KCo/4OMc=
github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM= github.com/junegunn/fzf v0.74.5/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs= github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+172
View File
@@ -0,0 +1,172 @@
// Package cachefile is the cache of what lazysecrets has loaded
// (cache.json): the Sources lists, vaults, secrets' metadata and where
// the cursor was in each, never values. The TUI and lazysecrets list
// both read it and save to it; a save merges with what's on disk, so
// neither undoes what the other loaded.
package cachefile
import (
"encoding/json"
"maps"
"os"
"path/filepath"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Version is the file's format; a file of another version is ignored,
// and replaced on the next save.
const Version = 1
// MaxAge drops what hasn't been loaded again for this long.
const MaxAge = 30 * 24 * time.Hour
// File is the cache. Every key starts with the provider ID: Sources are
// keyed by it, the rest by Row, Objects' keys and the like.
type File struct {
Version int `json:"version"`
Sources map[string]Stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
Containers map[string]Stamped[[]provider.Container] `json:"containers,omitempty"`
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"`
// Dropped are keys deleted since the file was read (a list that
// failed to load): Save deletes them from the file too, rather than
// merging the old entry back. Not saved.
Dropped map[string]bool `json:"-"`
}
// Stamped is a list or detail with when it was loaded.
type Stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
// New is an empty cache.
func New() File {
return File{
Version: Version,
Sources: map[string]Stamped[[]provider.Source]{},
Containers: map[string]Stamped[[]provider.Container]{},
Objects: map[string]Stamped[[]provider.Object]{},
Details: map[string]Stamped[provider.Object]{},
Cursor: map[string]string{},
}
}
// Row keys a source's vaults or scopes.
func Row(providerID, sourceID string) string { return providerID + "|" + sourceID }
// ContainerKey keys what's cached about a vault or scope.
func ContainerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
// ObjectsKey keys a vault's secrets, keys or certificates.
func ObjectsKey(c provider.Container, t provider.ObjectType) string {
return ContainerKey(c) + "|" + string(t)
}
// ProviderOf is the provider ID a key starts with.
func ProviderOf(key string) string {
id, _, _ := strings.Cut(key, "|")
return id
}
// Keep is Save's keep for these providers: their lists are kept, but not
// repos', whose files are read from disk anyway, nor other accounts'.
func Keep(providers []provider.Provider) func(providerID string) bool {
keep := map[string]bool{}
for _, p := range providers {
keep[p.ID()] = p.Kind() != provider.KindRepo
}
return func(id string) bool { return keep[id] }
}
// Read reads path; a missing, unreadable or other version's file is an
// empty cache.
func Read(path string) File {
f := New()
b, err := os.ReadFile(path)
if err != nil {
return f
}
var d File
if json.Unmarshal(b, &d) != nil || d.Version != Version {
return f
}
maps.Copy(f.Sources, d.Sources)
maps.Copy(f.Containers, d.Containers)
maps.Copy(f.Objects, d.Objects)
maps.Copy(f.Details, d.Details)
maps.Copy(f.Cursor, d.Cursor)
return f
}
// Save writes f to path, merged with the file there: of a list in both,
// the one loaded last wins, and f's cursors win (a nil Cursor keeps the
// file's). Entries older than MaxAge, those of providers keep says no to
// (accounts signed out of), and f.Dropped are dropped, from the file's
// side as well. Saves from several lazysecrets take turns (a lock file),
// so none undoes another's.
func Save(path string, f File, keep func(providerID string) bool, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
unlock, err := lock(path + ".lock")
if err != nil {
return err
}
defer unlock()
out := New()
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
disk := Read(path)
merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped)
merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped)
merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped)
merge(out.Details, disk.Details, f.Details, ok, f.Dropped)
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
for k, v := range m {
if keep(ProviderOf(k)) {
out.Cursor[k] = v
}
}
}
return write(path, out)
}
// merge puts disk's entries in out, but not those dropped, then ours
// where they're newer.
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool, dropped map[string]bool) {
for k, e := range disk {
if ok(k, e.At) && !dropped[k] {
out[k] = e
}
}
for k, e := range ours {
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
out[k] = e
}
}
}
// write writes f atomically, readable by the user only.
func write(path string, f File) error {
b, err := json.Marshal(f)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
+122
View File
@@ -0,0 +1,122 @@
package cachefile
import (
"path/filepath"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func list(at time.Time, names ...string) Stamped[[]provider.Container] {
var cs []provider.Container
for _, n := range names {
cs = append(cs, provider.Container{Name: n})
}
return Stamped[[]provider.Container]{At: at, Value: cs}
}
func TestSaveMerges(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
all := func(string) bool { return true }
// The TUI saved two lists.
tui := New()
tui.Containers["az|a"] = list(now.Add(-time.Hour), "kv-a-old")
tui.Containers["az|b"] = list(now.Add(-time.Minute), "kv-b")
tui.Cursor["az|b"] = "kv-b"
if err := Save(path, tui, all, now); err != nil {
t.Fatal(err)
}
// lazysecrets list loads a again, and c.
listed := Read(path)
listed.Containers["az|a"] = list(now, "kv-a-new")
listed.Containers["az|c"] = list(now, "kv-c")
if err := Save(path, listed, all, now); err != nil {
t.Fatal(err)
}
// The TUI saves what it has, older: list's lists stay.
if err := Save(path, tui, all, now); err != nil {
t.Fatal(err)
}
f := Read(path)
for key, want := range map[string]string{"az|a": "kv-a-new", "az|b": "kv-b", "az|c": "kv-c"} {
if got := f.Containers[key].Value; len(got) != 1 || got[0].Name != want {
t.Errorf("%s = %v, want %s", key, got, want)
}
}
if f.Cursor["az|b"] != "kv-b" {
t.Error("cursor lost")
}
}
func TestSaveDrops(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
f := New()
f.Containers["az|old"] = list(now.Add(-MaxAge-time.Hour), "kv-old")
f.Containers["gone|x"] = list(now, "kv-gone")
f.Containers["az|x"] = list(now, "kv-x")
if err := Save(path, f, func(id string) bool { return true }, now); err != nil {
t.Fatal(err)
}
// Signed out of "gone" since: dropped from the file too.
if err := Save(path, New(), func(id string) bool { return id == "az" }, now); err != nil {
t.Fatal(err)
}
got := Read(path).Containers
if _, ok := got["az|old"]; ok {
t.Error("kept a list older than MaxAge")
}
if _, ok := got["gone|x"]; ok {
t.Error("kept a signed-out account's list")
}
if _, ok := got["az|x"]; !ok {
t.Error("dropped a list on disk the save didn't have")
}
}
func TestReadOtherVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
f := New()
f.Version = Version + 1
f.Containers["az|x"] = list(time.Now(), "kv-x")
if err := write(path, f); err != nil {
t.Fatal(err)
}
if got := Read(path); len(got.Containers) != 0 || got.Version != Version {
t.Errorf("read another version's file: %+v", got)
}
}
func TestSaveDroppedAndCursors(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
all := func(string) bool { return true }
f := New()
f.Containers["az|bad"] = list(now, "kv-old")
f.Cursor["az|x"] = "kv-tui"
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
// The TUI fails to load it again and drops it: it stays gone.
f = New()
f.Dropped = map[string]bool{"az|bad": true}
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
if _, ok := Read(path).Containers["az|bad"]; ok {
t.Error("a dropped list came back")
}
// lazysecrets list saves without cursors: the TUI's stay.
listed := New()
listed.Cursor = nil
listed.Containers["az|y"] = list(now, "kv-y")
if err := Save(path, listed, all, now); err != nil {
t.Fatal(err)
}
if Read(path).Cursor["az|x"] != "kv-tui" {
t.Error("the TUI's cursor was lost")
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !unix
package cachefile
// lock does nothing here: saves can then race, losing lists that load
// again.
func lock(string) (func(), error) { return func() {}, nil }
+25
View File
@@ -0,0 +1,25 @@
//go:build unix
package cachefile
import (
"os"
"syscall"
)
// lock takes path as a lock file, waiting while another lazysecrets has
// it, and returns how to let go.
func lock(path string) (unlock func(), err error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
f.Close()
return nil, err
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
+97 -52
View File
@@ -19,44 +19,18 @@ import (
) )
type Config struct { type Config struct {
Icons *bool `yaml:"icons"` UI UI `yaml:"ui"`
ClipboardClear *time.Duration `yaml:"clipboardClear"` // Values keeps revealed and copied values from lingering.
// RevealTimeout hides a revealed value after this long; 0s never. Values Values `yaml:"values"`
// Defaults to 60s. // Storage is what's kept between runs.
RevealTimeout *time.Duration `yaml:"revealTimeout"` Storage Storage `yaml:"storage"`
// ExpiryWarningDays marks secrets, keys and certificates expiring // Updates sets how lazysecrets looks for newer releases.
// within this many days; 0 marks only expired ones. Defaults to 30. Updates Updates `yaml:"updates"`
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
Timeout time.Duration `yaml:"timeout"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
// UpdateCheck looks for a newer release once a day, in the
// background; defaults to true. U and lazysecrets update work either
// way.
UpdateCheck *bool `yaml:"updateCheck"`
// Releases are the repositories updates come from, tried in order:
// on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then
// git.kebcloud.no (update.DefaultServers).
Releases []string `yaml:"releases"`
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
Providers Providers `yaml:"providers"` Providers Providers `yaml:"providers"`
// LinkHandler sets how lazysecrets:// links open; see linkhandler. // LinkHandler sets how lazysecrets:// links open; see linkhandler.
LinkHandler LinkHandler `yaml:"linkHandler"` LinkHandler LinkHandler `yaml:"linkHandler"`
// Share sets how env files are shared; see package share. // Sharing sets how env files are shared; see package share.
Share Share `yaml:"share"` Sharing Sharing `yaml:"sharing"`
// CustomCommands run shell commands on the selected item; see // CustomCommands run shell commands on the selected item; see
// package commands. // package commands.
CustomCommands []commands.Command `yaml:"customCommands"` CustomCommands []commands.Command `yaml:"customCommands"`
@@ -68,6 +42,55 @@ type Config struct {
Theme map[string][]string `yaml:"theme"` Theme map[string][]string `yaml:"theme"`
} }
type UI struct {
Icons *bool `yaml:"icons"`
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
// and in lazysecrets list. Defaults to false.
RecentIcon *bool `yaml:"recentIcon"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// ExpiryWarningDays marks secrets, keys and certificates expiring
// within this many days; 0 marks only expired ones. Defaults to 30.
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
}
type Values struct {
// RevealTimeout hides a revealed value after this long; 0s never.
// Defaults to 60s.
RevealTimeout *time.Duration `yaml:"revealTimeout"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
ClipboardClear *time.Duration `yaml:"clipboardClear"`
}
type Storage struct {
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
}
type Updates struct {
// AutoCheck looks for a newer release in the background at startup,
// at most once per AutoCheckInterval; defaults to true. U and
// lazysecrets update work either way.
AutoCheck *bool `yaml:"autoCheck"`
// AutoCheckInterval defaults to 24h.
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
// Sources are the repositories updates come from, tried in
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
// then git.kebcloud.no (update.DefaultServers).
Sources []string `yaml:"sources"`
}
type LinkHandler struct { type LinkHandler struct {
// Terminal to open links in, overriding the one handler install // Terminal to open links in, overriding the one handler install
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty, // detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
@@ -85,7 +108,7 @@ type LinkHandler struct {
Prompt *bool `yaml:"prompt"` Prompt *bool `yaml:"prompt"`
} }
type Share struct { type Sharing struct {
// Key is the secret whose value encrypts shared files (its // Key is the secret whose value encrypts shared files (its
// lazysecrets:// link, Key Vault URL or reference), for repos whose // lazysecrets:// link, Key Vault URL or reference), for repos whose
// .lazysecrets.yaml doesn't name one. // .lazysecrets.yaml doesn't name one.
@@ -103,6 +126,9 @@ type KeyRules struct {
} }
type Providers struct { type Providers struct {
// Timeout bounds each backend request; defaults to 30s. Each
// provider's own timeout overrides it for that provider.
Timeout time.Duration `yaml:"timeout"`
Azure Azure `yaml:"azure"` Azure Azure `yaml:"azure"`
Databricks Databricks `yaml:"databricks"` Databricks Databricks `yaml:"databricks"`
Repos Repos `yaml:"repos"` Repos Repos `yaml:"repos"`
@@ -112,11 +138,13 @@ type Providers struct {
// that repo is the only one shown; elsewhere, the ones in Paths and under // that repo is the only one shown; elsewhere, the ones in Paths and under
// Roots. // Roots.
type Repos struct { type Repos struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
Paths []string `yaml:"paths"` Paths []string `yaml:"paths"`
// Roots are folders of repos. Defaults to ~/git; an empty list // Roots are folders of repos. Defaults to repo.DefaultRoots (~/git,
// turns it off. // ~/src, ~/code and the like); an empty list turns it off.
Roots *[]string `yaml:"roots"` Roots *[]string `yaml:"roots"`
// Include and Exclude are globs relative to a repo's root (** for any // Include and Exclude are globs relative to a repo's root (** for any
// depth), for repos without their own .lazysecrets.yaml. Without them, // depth), for repos without their own .lazysecrets.yaml. Without them,
@@ -126,6 +154,8 @@ type Repos struct {
} }
type Azure struct { type Azure struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
// HideSubscriptions lists subscriptions to leave out, by name or ID. // HideSubscriptions lists subscriptions to leave out, by name or ID.
@@ -136,6 +166,8 @@ type Azure struct {
} }
type Databricks struct { type Databricks struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
HideProfiles []string `yaml:"hideProfiles"` HideProfiles []string `yaml:"hideProfiles"`
@@ -222,35 +254,48 @@ func decodeInto(c *Config, path string) error {
} }
func (c Config) validate(path string) error { func (c Config) validate(path string) error {
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" { if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" {
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v) return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v)
} }
for _, cmd := range c.CustomCommands { for _, cmd := range c.CustomCommands {
if err := cmd.Validate(); err != nil { if err := cmd.Validate(); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
} }
if d := c.ExpiryWarningDays; d != nil && *d < 0 { if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 {
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d) return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d)
} }
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) { if d := c.UI.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d) return fmt.Errorf("%s: ui.loadDelay: %s is not between 0s and 5s", path, *d)
} }
for i, r := range c.Releases { if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
}
for i, r := range c.Updates.Sources {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") { if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) return fmt.Errorf("%s: updates.sources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
} }
} }
if k := c.Share.Key; k != "" { if k := c.Sharing.Key; k != "" {
if _, err := share.ParseKey(k); err != nil { if _, err := share.ParseKey(k); err != nil {
return fmt.Errorf("%s: share.key: %w", path, err) return fmt.Errorf("%s: sharing.key: %w", path, err)
} }
} }
if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 { if n := c.Sharing.KeyRules.MinLength; n != 0 && n < 16 {
return fmt.Errorf("%s: share.keyRules.minLength: %d is under 16", path, n) return fmt.Errorf("%s: sharing.keyRules.minLength: %d is under 16", path, n)
}
if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
}
for name, d := range map[string]time.Duration{
"providers.timeout": c.Providers.Timeout,
"providers.azure.timeout": c.Providers.Azure.Timeout,
"providers.databricks.timeout": c.Providers.Databricks.Timeout,
"providers.repos.timeout": c.Providers.Repos.Timeout,
} {
if d < 0 {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
} }
if n := c.Share.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: share.keyRules.minClasses: %d is not between 1 and 4", path, n)
} }
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") { if !strings.HasPrefix(v, "https://") {
+39 -27
View File
@@ -19,12 +19,17 @@ func write(t *testing.T, content string) string {
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
c, err := Load(write(t, ` c, err := Load(write(t, `
icons: false ui:
sourcesView: flat icons: false
clipboardClear: 45s sourcesView: flat
timeout: 1m values:
clipboardClear: 45s
storage:
history: false
providers: providers:
timeout: 1m
azure: azure:
timeout: 2m
hideSubscriptions: [platform-mgmt] hideSubscriptions: [platform-mgmt]
vaults: vaults:
- https://kv-foo.vault.azure.net/ - https://kv-foo.vault.azure.net/
@@ -35,12 +40,12 @@ providers:
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second || if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second ||
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" { c.Providers.Timeout != time.Minute || c.Providers.Azure.Timeout != 2*time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
t.Errorf("config = %+v", c) t.Errorf("config = %+v", c)
} }
if c.SourcesView != "flat" { if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) {
t.Errorf("sourcesView = %q", c.SourcesView) t.Errorf("ui = %+v, storage = %+v", c.UI, c.Storage)
} }
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) { if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
t.Error("enabled flags wrong") t.Error("enabled flags wrong")
@@ -57,7 +62,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"empty": write(t, ""), "empty": write(t, ""),
} { } {
c, err := Load(path) c, err := Load(path)
if err != nil || c.Icons != nil || c.ClipboardClear != nil { if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err) t.Errorf("%s: %+v, %v", name, c, err)
} }
} }
@@ -68,14 +73,17 @@ func TestLoadRejectsMistakes(t *testing.T) {
"typo": "icon: true\n", "typo": "icon: true\n",
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n", "bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"duration": "timeout: soon\n", "flat keys": "icons: false\n",
"view": "sourcesView: tree\n", "duration": "providers:\n timeout: soon\n",
"releases": "releases: [git.example.com/team/lazysecrets]\n", "negative": "providers:\n repos:\n timeout: -1s\n",
"http": "releases: [http://git.example.com/team/lazysecrets]\n", "view": "ui:\n sourcesView: tree\n",
"share key": "share:\n key: https://kv.vault.azure.net/\n", "sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n",
"workspace": "share:\n key: \"{{secrets/team/env-share}}\"\n", "http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n",
"short keys": "share:\n keyRules:\n minLength: 8\n", "interval": "updates:\n autoCheckInterval: 0s\n",
"classes": "share:\n keyRules:\n minClasses: 5\n", "share key": "sharing:\n key: https://kv.vault.azure.net/\n",
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
} { } {
if _, err := Load(write(t, content)); err == nil { if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
@@ -87,7 +95,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
func TestLoadAllLayers(t *testing.T) { func TestLoadAllLayers(t *testing.T) {
user := write(t, ` user := write(t, `
icons: false ui:
icons: false
theme: theme:
azureColor: [blue] azureColor: [blue]
errorColor: [red, bold] errorColor: [red, bold]
@@ -96,6 +105,8 @@ providers:
hideSubscriptions: [a] hideSubscriptions: [a]
`) `)
theme := write(t, ` theme := write(t, `
ui:
sourcesView: flat
theme: theme:
azureColor: ["#82aaff"] azureColor: ["#82aaff"]
activeBorderColor: ["#ff966c", bold] activeBorderColor: ["#ff966c", bold]
@@ -104,7 +115,8 @@ theme:
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 { // A group set in both files merges: icons from one, sourcesView the other.
if c.UI.Icons == nil || *c.UI.Icons || c.UI.SourcesView != "flat" || len(c.Providers.Azure.HideSubscriptions) != 1 {
t.Errorf("user settings lost: %+v", c) t.Errorf("user settings lost: %+v", c)
} }
want := map[string][]string{ want := map[string][]string{
@@ -119,19 +131,19 @@ theme:
} }
} }
func TestReleases(t *testing.T) { func TestUpdates(t *testing.T) {
c, err := Load(write(t, "releases: [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 || len(c.Releases) != 1 { if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
t.Errorf("releases = %v, %v", c.Releases, err) t.Errorf("updates = %+v, %v", c.Updates, err)
} }
} }
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "loadDelay: 300ms\n")) c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond { if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.LoadDelay, err) t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
} }
if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil { if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted") t.Error("a minute's delay accepted")
} }
} }
+41
View File
@@ -0,0 +1,41 @@
// Package expiry says whether a secret, key or certificate has expired
// or will soon, and how long it has left, the same way everywhere: the
// TUI's lists and status pane, and lazysecrets list.
package expiry
import (
"fmt"
"time"
)
type State int
const (
None State = iota // no expiry, or not within the warning period
Soon // within the warning period
Expired // already
)
// Of tells whether something expiring at expires (zero: never) has
// expired by now or will within warn, and how long it has left.
func Of(expires, now time.Time, warn time.Duration) (State, time.Duration) {
if expires.IsZero() {
return None, 0
}
left := expires.Sub(now)
switch {
case left <= 0:
return Expired, 0
case left <= warn:
return Soon, left
}
return None, left
}
// Left is a short "12d", or "5h" on the last day.
func Left(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
+27
View File
@@ -0,0 +1,27 @@
package expiry
import (
"testing"
"time"
)
func TestOf(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
warn := 30 * 24 * time.Hour
for _, c := range []struct {
expires time.Time
want State
left string
}{
{time.Time{}, None, ""},
{now.Add(-time.Hour), Expired, ""},
{now.Add(12*24*time.Hour + time.Hour), Soon, "12d"},
{now.Add(5 * time.Hour), Soon, "5h"},
{now.Add(60 * 24 * time.Hour), None, ""},
} {
state, left := Of(c.expires, now, warn)
if state != c.want || (c.left != "" && Left(left) != c.left) {
t.Errorf("%v: %v %s, want %v %s", c.expires, state, Left(left), c.want, c.left)
}
}
}
+399
View File
@@ -0,0 +1,399 @@
// Package listing lists what lazysecrets can find, for lazysecrets list:
// what Go to searches, without the TUI. What's cached comes first, at
// once; then lists that are missing or older than MaxAge load in the
// background, a few at a time, and what's new in them follows as they
// arrive. Repos are always read, from disk. Values are never listed.
package listing
import (
"cmp"
"context"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// Workers is how many lists load at once, as in Go to.
const Workers = 4
// Kinds are the kinds of entry, from sources down.
var Kinds = []string{"source", "vault", "secret", "key", "certificate"}
// Entry is a source, vault or item, by its lazysecrets:// link.
type Entry struct {
Link string `json:"link"`
// Kind is source (a subscription, workspace or repo), vault (a Key
// Vault, secret scope or repo file), secret, key or certificate.
Kind string `json:"kind"`
// Provider is azure, databricks or repo, as in links.
Provider string `json:"provider"`
// Path names it: its source's, vault's and own names.
Path []string `json:"path"`
// Account is the provider it's listed through: an az account
// (Azure:<user>/<tenant>), Databricks or Repos. lazysecrets open and
// get take it (--account) to use that account when several could.
Account string `json:"account"`
// AccountLabel names the az account, when there are several.
AccountLabel string `json:"accountLabel,omitempty"`
// Starred is starred for this account; Recent is used lately (Recent,
// R, in the TUI): LastUsed is when, Uses how often.
Starred bool `json:"starred"`
Recent bool `json:"recent"`
LastUsed time.Time `json:"lastUsed,omitzero"`
Uses int `json:"uses,omitempty"`
// Expires is when a secret, key or certificate expires, if it does;
// Enabled whether it's enabled, where the provider says (Azure).
Expires time.Time `json:"expires,omitzero"`
Enabled *bool `json:"enabled,omitempty"`
// Note is the user's note on it, if any.
Note string `json:"note,omitempty"`
// Reference is how config refers to a secret, where the provider has
// a way (@Microsoft.KeyVault(...), {{secrets/scope/key}}); URL its
// web page (portal, workspace), if any.
Reference string `json:"reference,omitempty"`
URL string `json:"url,omitempty"`
// Error says why a source's vaults or a vault's secrets couldn't be
// listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link.
Error string `json:"error,omitempty"`
}
type Options struct {
Providers []provider.Provider
// Cache is what was loaded before; Run adds what it loads.
Cache cachefile.File
// MaxAge: cached lists loaded longer ago are loaded again.
MaxAge time.Duration
// Cached lists only what's cached (and repos): nothing is loaded.
Cached bool
// Kinds are the kinds of entry to list (Kinds' names); none is all.
// Lists only needed for other kinds aren't loaded: sources and vaults
// alone don't load any secrets. Lists that fail are always listed.
Kinds []string
// Mark tells what's remembered about the entry with link, seen
// through account; nil marks nothing.
Mark func(link, account string) Marks
Timeout func(provider.Kind) time.Duration // nil: 30s
Now func() time.Time
}
// Marks are what's remembered about an entry: starred for the account,
// used lately (Recent) and when and how often, and its note.
type Marks struct {
Starred, Recent bool
LastUsed time.Time
Uses int
Note string
}
// Run lists everything, calling emit for each entry once, from this
// goroutine, and for each list that can't be loaded, as it fails. An
// error from emit (the reader went away) stops it. It returns the cache
// with what was loaded, and whether anything was.
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
results: make(chan result)}
if l.o.Now == nil {
l.o.Now = time.Now
}
if l.o.Timeout == nil {
l.o.Timeout = func(provider.Kind) time.Duration { return 30 * time.Second }
}
for _, p := range o.Providers {
if l.stopped != nil {
break
}
l.provider(p)
}
l.start(ctx)
for l.active > 0 {
r := <-l.results
l.active--
if l.stopped == nil {
l.handle(r)
l.start(ctx)
}
if l.stopped != nil {
cancel()
}
}
return l.o.Cache, l.loaded
}
type job struct {
p provider.Provider
src *provider.Source // list its vaults
c *provider.Container // list its secrets, keys or certificates (t)
t provider.ObjectType
key string // where the list is cached
}
type result struct {
job
sources []provider.Source
containers []provider.Container
objects []provider.Object
err error
}
type lister struct {
o Options
emit func(Entry) error
seen map[string]bool // provider ID + link of entries emitted
pending map[string]bool // cache keys queued or loading
failed map[string]bool // cache keys that couldn't be loaded: not again
loaded bool // something was cached
queue []job
active int
results chan result
stopped error // emit's error
}
func (l *lister) cacheable(p provider.Provider) bool { return p.Kind() != provider.KindRepo }
// load reports whether to load a list: a repo's always (from disk), else
// unless Cached, when it isn't cached or is older than MaxAge.
func (l *lister) load(p provider.Provider, cached bool, at time.Time) bool {
if !l.cacheable(p) {
return true
}
return !l.o.Cached && (!cached || l.o.Now().Sub(at) >= l.o.MaxAge)
}
func (l *lister) provider(p provider.Provider) {
e, ok := l.o.Cache.Sources[p.ID()]
if l.cacheable(p) && ok {
for _, src := range sorted(e.Value, func(s provider.Source) string { return s.Name }) {
l.source(p, src)
}
}
if l.load(p, ok, e.At) {
l.enqueue(job{p: p, key: p.ID()})
}
}
// source emits src, and what's cached under it, and queues what's not.
func (l *lister) source(p provider.Provider, src provider.Source) {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
l.put(p, sourceEntry(src))
row := cachefile.Row(p.ID(), src.ID)
e, ok := l.o.Cache.Containers[row]
if l.cacheable(p) && ok {
l.containers(p, src, e.Value)
}
if l.load(p, ok, e.At) {
if l.wants("vault", "secret", "key", "certificate") {
l.enqueue(job{p: p, src: &src, key: row})
}
}
}
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
c.Source = src
l.put(p, containerEntry(c))
for _, t := range c.ObjectTypes() {
key := cachefile.ObjectsKey(c, t)
e, ok := l.o.Cache.Objects[key]
if l.cacheable(p) {
l.objects(p, c, e.Value)
}
// Keys and certificates load only when asked for; else,
// like Go to, only as far as the TUI opened them.
asked := l.wants(strings.ToLower(string(t)))
if t != provider.TypeSecret {
asked = len(l.o.Kinds) > 0 && asked
}
if asked && l.load(p, ok, e.At) {
l.enqueue(job{p: p, c: &c, t: t, key: key})
}
}
}
}
func (l *lister) objects(p provider.Provider, c provider.Container, objs []provider.Object) {
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
o.Container = c
r, err := ref.ForObject(o, "")
if err != nil {
continue
}
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL})
}
}
func sourceEntry(src provider.Source) Entry {
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
if r, err := ref.ForSource(src); err == nil {
e.Link = r.String()
}
return e
}
func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String()
}
return e
}
// put emits e, unless it was already (or emitting has failed). Entries
// without a link (one couldn't be made) are left out, but not errors.
func (l *lister) put(p provider.Provider, e Entry) {
if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
return
}
e.Account = p.ID()
if l.o.Mark != nil && e.Link != "" {
m := l.o.Mark(e.Link, e.Account)
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
if m.Recent {
e.LastUsed, e.Uses = m.LastUsed, m.Uses
}
}
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] {
return
}
l.seen[id] = true
l.stopped = l.emit(e)
}
// wants reports whether any of kinds is listed.
func (l *lister) wants(kinds ...string) bool {
if len(l.o.Kinds) == 0 {
return true
}
for _, k := range kinds {
if slices.Contains(l.o.Kinds, k) {
return true
}
}
return false
}
func (l *lister) enqueue(j job) {
if l.pending[j.key] || l.failed[j.key] {
return
}
l.pending[j.key] = true
l.queue = append(l.queue, j)
}
func (l *lister) start(ctx context.Context) {
for l.stopped == nil && l.active < Workers && len(l.queue) > 0 {
j := l.queue[0]
l.queue = l.queue[1:]
l.active++
go func() { l.results <- l.fetch(ctx, j) }()
}
}
func (l *lister) fetch(ctx context.Context, j job) result {
kind := j.p.Kind()
ctx, cancel := context.WithTimeout(ctx, l.o.Timeout(kind))
defer cancel()
r := result{job: j}
switch {
case j.c != nil:
r.objects, r.err = j.p.Objects(ctx, *j.c, j.t)
case j.src != nil:
r.containers, r.err = j.p.Containers(ctx, *j.src)
default:
r.sources, r.err = j.p.Sources(ctx)
}
return r
}
func (l *lister) handle(r result) {
delete(l.pending, r.key)
now := l.o.Now()
if r.err != nil {
l.failed[r.key] = true
l.put(r.p, r.failed())
return
}
l.loaded = l.loaded || l.cacheable(r.p)
cache := l.cacheable(r.p)
switch {
case r.c != nil:
if cache {
l.o.Cache.Objects[r.key] = cachefile.Stamped[[]provider.Object]{At: now, Value: r.objects}
}
l.objects(r.p, *r.c, r.objects)
case r.src != nil:
r.containers = slices.Clone(r.containers) // the provider's may be shared
for i := range r.containers {
r.containers[i].Source = *r.src
}
if cache {
l.o.Cache.Containers[r.key] = cachefile.Stamped[[]provider.Container]{At: now, Value: r.containers}
}
l.containers(r.p, *r.src, r.containers)
default:
if cache {
l.o.Cache.Sources[r.key] = cachefile.Stamped[[]provider.Source]{At: now, Value: r.sources}
}
for _, src := range r.sources {
l.source(r.p, src)
}
}
}
// failed is the entry for a list that couldn't be loaded.
func (r result) failed() Entry {
var e Entry
switch {
case r.c != nil:
e = containerEntry(*r.c)
case r.src != nil:
src := *r.src
src.Provider, src.ProviderLabel = r.p.ID(), r.p.Label()
e = sourceEntry(src)
default:
e = Entry{Kind: "source", Provider: kindOf(r.p.Kind()), Path: []string{r.p.Label()}}
}
e.Error = firstLine(r.err.Error())
return e
}
// firstLine is an error's first line, the summary (Azure's go on with
// the caller's IDs and more): an entry is one line. Tabs, which would
// split it into columns, are spaces.
func firstLine(s string) string {
for line := range strings.Lines(s) {
if line = strings.TrimSpace(line); line != "" {
return strings.ReplaceAll(line, "\t", " ")
}
}
return "unreadable"
}
func kindOf(k provider.Kind) string { return strings.ToLower(string(k)) }
// accountOf is the account part of a source's provider label ("alice" in
// "Azure · alice"), or "" when there's a single account.
func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok {
return ""
}
return account
}
func sorted[T any](s []T, name func(T) string) []T {
s = slices.Clone(s)
slices.SortStableFunc(s, func(a, b T) int {
return cmp.Compare(strings.ToLower(name(a)), strings.ToLower(name(b)))
})
return s
}
+297
View File
@@ -0,0 +1,297 @@
package listing
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// counting counts a provider's requests.
type counting struct {
provider.Provider
calls atomic.Int32
}
func (c *counting) Sources(ctx context.Context) ([]provider.Source, error) {
c.calls.Add(1)
return c.Provider.Sources(ctx)
}
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
c.calls.Add(1)
return c.Provider.Containers(ctx, src)
}
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
c.calls.Add(1)
return c.Provider.Objects(ctx, ct, t)
}
// run lists, returning the entries and, apart, the lists that failed.
func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
t.Helper()
if o.Cache.Version == 0 {
o.Cache = cachefile.New()
}
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
var got, failed []Entry
f, _ := Run(context.Background(), o, func(e Entry) error {
if e.Error != "" {
failed = append(failed, e)
} else {
got = append(got, e)
}
return nil
})
return got, f, failed
}
func has(es []Entry, kind, path string) bool {
for _, e := range es {
if e.Kind == kind && strings.Join(e.Path, "/") == path {
return true
}
}
return false
}
func TestListLoadsAndCaches(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
got, f, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, MaxAge: time.Minute})
for _, want := range [][2]string{
{"source", "platform-prod"}, {"vault", "platform-prod/kv-app-prod"}, {"secret", "platform-prod/kv-app-prod/db-password"},
{"source", "lazysecrets-demo"}, {"secret", "lazysecrets-demo/.env/DATABASE_URL"},
} {
if !has(got, want[0], want[1]) {
t.Errorf("no %s %s", want[0], want[1])
}
}
if len(errs) != 1 || errs[0].Kind != "vault" || errs[0].Path[1] != "kv-restricted" || !strings.Contains(errs[0].Error, "403") || errs[0].Link == "" {
t.Errorf("failed = %+v", errs)
}
seen := map[string]bool{}
for _, e := range got {
if seen[e.Link] {
t.Errorf("%s listed twice", e.Link)
}
seen[e.Link] = true
if e.Provider != "azure" && e.Provider != "repo" {
t.Errorf("provider %q", e.Provider)
}
}
for _, e := range got {
if e.Kind == "secret" && e.Provider == "azure" && (!strings.HasPrefix(e.Reference, "@Microsoft.KeyVault(") || e.URL == "") {
t.Errorf("%s: reference %q, url %q", e.Link, e.Reference, e.URL)
}
}
if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 {
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
}
for k := range f.Containers {
if cachefile.ProviderOf(k) != az.ID() {
t.Errorf("cached a repo: %s", k)
}
}
// Again with that cache: the same, without asking Azure.
before := az.calls.Load()
again, _, _ := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, MaxAge: time.Minute})
if len(again) != len(got) {
t.Errorf("%d entries from the cache, %d loaded", len(again), len(got))
}
// The vault that couldn't be read wasn't cached: it's asked again.
if n := az.calls.Load() - before; n != 1 {
t.Errorf("%d requests with a fresh cache, want 1 (kv-restricted)", n)
}
// Stale: loaded again, but each entry is still listed once.
before = az.calls.Load()
stale, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if az.calls.Load()-before < 3 {
t.Error("stale lists weren't loaded again")
}
links := map[string]bool{}
for _, e := range stale {
if links[e.Link] {
t.Errorf("%s listed twice", e.Link)
}
links[e.Link] = true
}
}
func TestListCachedOnly(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
before := az.calls.Load()
got, _, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, Cached: true,
Now: func() time.Time { return time.Now().Add(24 * time.Hour) }})
if az.calls.Load() != before || len(errs) != 0 {
t.Errorf("--cached asked Azure: %d requests, %v", az.calls.Load()-before, errs)
}
if !has(got, "secret", "platform-prod/kv-app-prod/db-password") || !has(got, "secret", "lazysecrets-demo/.env/DATABASE_URL") {
t.Error("cached secrets or repos missing")
}
}
func TestListCachedFirst(t *testing.T) {
t.Parallel()
az := fake.Azure(0)
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Forget one vault's secrets: they load, after everything cached.
var dropped string
for k := range f.Objects {
if strings.Contains(k, "kv-app-prod") && strings.HasSuffix(k, "|Secret") {
dropped = k
delete(f.Objects, k)
break
}
}
if dropped == "" {
t.Fatalf("no kv-app-prod in %v", f.Objects)
}
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute})
last := got[len(got)-1]
if last.Kind != "secret" || last.Path[1] != "kv-app-prod" {
t.Errorf("last = %+v, want a kv-app-prod secret, loaded after the cache", last)
}
}
func TestListStopsWhenTheReaderGoes(t *testing.T) {
t.Parallel()
gone := errors.New("broken pipe")
n := 0
var errs []Entry
Run(context.Background(), Options{
Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute,
Timeout: func(provider.Kind) time.Duration { return time.Second },
}, func(e Entry) error {
if e.Error != "" {
errs = append(errs, e)
}
n++
if n == 2 {
return gone
}
return nil
})
if n != 2 || len(errs) != 0 {
t.Errorf("emitted %d after the reader went, errs %v", n, errs)
}
}
func TestListKinds(t *testing.T) {
t.Parallel()
// Sources only: nothing under them is asked for.
az := &counting{Provider: fake.Azure(0)}
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"source"}})
if len(got) == 0 || az.calls.Load() != 1 {
t.Errorf("%d sources, %d requests, want just the sources'", len(got), az.calls.Load())
}
for _, e := range got {
if e.Kind != "source" {
t.Errorf("listed a %s", e.Kind)
}
}
// Keys, asked for: loaded, though Go to wouldn't; no secrets.
got, _, failed := run(t, Options{Providers: []provider.Provider{fake.Azure(0)}, MaxAge: time.Minute, Kinds: []string{"key"}})
if !has(got, "key", "platform-prod/kv-app-prod/signing-key") {
t.Errorf("no keys: %+v", got)
}
for _, e := range got {
if e.Kind != "key" {
t.Errorf("listed a %s", e.Kind)
}
}
if len(failed) == 0 {
t.Error("a list that failed wasn't listed")
}
}
func TestErrorsAreOneLine(t *testing.T) {
for in, want := range map[string]string{
"403 Forbidden: Caller is not authorized\nCaller: appid=x;oid=y\nVault: kv;location=z": "403 Forbidden: Caller is not authorized",
"\n timeout\tafter 30s\n": "timeout after 30s",
"": "unreadable",
} {
if got := firstLine(in); got != want {
t.Errorf("firstLine(%q) = %q, want %q", in, got, want)
}
}
}
func TestListMarks(t *testing.T) {
t.Parallel()
az := fake.Azure(0)
var starredLink string
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
Mark: func(link, account string) Marks {
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
starredLink = link
}
if strings.HasSuffix(link, "/api-key") {
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
}
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
}
return Marks{Starred: link == starredLink && account == az.ID()}
}})
var starred, recent int
for _, e := range got {
if e.Starred {
starred++
if e.Link != starredLink {
t.Errorf("%s starred", e.Link)
}
}
if (e.Note != "") != strings.HasSuffix(e.Link, "/api-key") {
t.Errorf("%s: note %q", e.Link, e.Note)
}
if e.Recent {
recent++
if e.Uses != 3 || e.LastUsed.IsZero() {
t.Errorf("%s: %d uses, last %v", e.Link, e.Uses, e.LastUsed)
}
} else if e.Uses != 0 || !e.LastUsed.IsZero() {
t.Errorf("%s not recent, but %d uses, last %v", e.Link, e.Uses, e.LastUsed)
}
}
if starred != 1 || recent == 0 {
t.Errorf("%d starred, %d recent", starred, recent)
}
}
func TestFailedListNotRetried(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Everything stale: sources reload, and their vaults are queued again
// as each list arrives. kv-restricted fails once, and isn't asked again.
before := az.calls.Load()
_, _, failed := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if len(failed) != 1 {
t.Errorf("failed: %+v", failed)
}
fresh := &counting{Provider: fake.Azure(0)}
run(t, Options{Providers: []provider.Provider{fresh}, MaxAge: time.Minute})
if n := az.calls.Load() - before; n > fresh.calls.Load() {
t.Errorf("%d requests with a stale cache, more than %d from scratch: something was asked twice", n, fresh.calls.Load())
}
}
func TestRunWithoutTimeout(t *testing.T) {
t.Parallel()
Run(context.Background(), Options{Providers: []provider.Provider{fake.Repos()}, Cache: cachefile.New()},
func(Entry) error { return nil }) // no Timeout: a default, not a panic
}
+40 -3
View File
@@ -218,6 +218,19 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
return c return c
} }
// Expire sets when the object name in vault expires, for --demo: a date
// relative to today in the test data would change the tests' screenshots
// from day to day.
func (p *Provider) Expire(vault, name string, at time.Time) {
for _, objs := range p.objects {
for i := range objs {
if objs[i].Container.Name == vault && objs[i].Name == name {
objs[i].Expires = at
}
}
}
}
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object { func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
o.Container = c o.Container = c
o.Type = t o.Type = t
@@ -287,9 +300,13 @@ func Azure(delay time.Duration) *Provider {
Reference: secretRef("kv-app-prod", "api-key"), Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000") }, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0), Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Reference: secretRef("kv-app-prod", "legacy-token"), Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated") }, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0), Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
@@ -332,7 +349,7 @@ func Azure(delay time.Duration) *Provider {
}, demoCert) }, demoCert)
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as share.keyRules wants. // A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: &yes, 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=") }, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
@@ -349,6 +366,19 @@ func Azure(delay time.Duration) *Provider {
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: &yes, 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") }, "dev-password")
// Left behind, as dev vaults are: expired, disabled, or both.
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
}, "whsec_demo_expired")
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0),
Reference: secretRef("kv-app-dev", "test-user-password"),
}, "disabled-demo-password")
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled")
return p return p
} }
@@ -366,6 +396,7 @@ func AzureOps(delay time.Duration) *Provider {
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...) breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{ p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: &yes, 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"), Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password") }, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
@@ -384,6 +415,12 @@ func AzureOps(delay time.Duration) *Provider {
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"), Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token") }, "pd-demo-token")
// Expiring soon whenever the demo runs: the other dates are fixed,
// and would have passed. Tests use the first account only.
p.add(kv, provider.TypeSecret, provider.Object{
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0),
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
}, "glsa_demo_expiring")
return p return p
} }
+65
View File
@@ -0,0 +1,65 @@
package fake
import (
"cmp"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// Seed stars, notes and uses a few of the demo's secrets, so --demo
// shows Favourites (F), Recent (R), notes, and stars in lazysecrets list. alice and
// admin are the two Azure accounts' provider IDs.
func Seed(st *store.Store, alice, admin string) {
type item struct {
link, account, source string
name string // else the link's last part
note string
path []string
star bool
uses int
ago time.Duration
}
const azure, dbx = "lazysecrets://azure/", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/"
for _, it := range []item{
{link: azure + "kv-app-prod.vault.azure.net/secrets/db-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, star: true, uses: 12, ago: 20 * time.Minute,
note: "rotated by the data team each quarter"},
{link: azure + "kv-app-prod.vault.azure.net/secrets/api-key", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, uses: 4, ago: 2 * time.Hour},
{link: azure + "kv-breakglass.vault.azure.net/secrets/emergency-admin", account: admin, source: "sub-prod",
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"},
{link: azure + "kv-shared-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-shared-prod"}, uses: 2, ago: 26 * time.Hour},
{link: azure + "kv-app-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, note: "migrated to Microsoft Graph sendMail; delete after Q4"},
{link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
// A subscription, for both accounts, and a repo's file.
{link: azure + "subscriptions/sub-prod", account: alice, source: "sub-prod",
name: "platform-prod", star: true},
{link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
name: "platform-prod", star: true},
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
source: "/home/dev/git/lazysecrets-demo", name: "deploy/prod/secrets.yaml",
path: []string{"lazysecrets-demo"}, star: true},
} {
context := func(n *store.Node) {
n.Name = cmp.Or(it.name, it.link[strings.LastIndex(it.link, "/")+1:])
n.Path, n.Source, n.Account = it.path, it.source, it.account
}
if it.star || it.note != "" {
_ = st.Update(it.link, func(n *store.Node) {
context(n)
if it.star {
n.ToggleStar(it.account)
}
n.Note = it.note
})
}
for range it.uses {
_ = st.Use(it.link, time.Now().Add(-it.ago), context)
}
}
}
+68 -22
View File
@@ -80,6 +80,10 @@ type repoInfo struct {
here bool // the repo lazysecrets started in here bool // the repo lazysecrets started in
} }
// DefaultRoots are the usual folders of repos, used when the config
// doesn't set roots. Ones that don't exist cost a stat each.
var DefaultRoots = []string{"~/git", "~/repo", "~/repos", "~/projects", "~/code", "~/source", "~/src"}
func New(s Settings) *Provider { func New(s Settings) *Provider {
if s.Sops == "" { if s.Sops == "" {
s.Sops = "sops" s.Sops = "sops"
@@ -116,10 +120,18 @@ func (p *Provider) load() {
} }
add(root) add(root)
} }
// Missing roots are skipped (most of the defaults are), and one
// that's a symlink to another is scanned once.
scanned := map[string]bool{}
for _, dir := range p.s.Roots { for _, dir := range p.s.Roots {
base, _ := filepath.EvalSymlinks(expandHome(dir)) base, err := filepath.EvalSymlinks(expandHome(dir))
for _, root := range reposIn(expandHome(dir), 2) { if err != nil || scanned[base] {
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") { continue
}
scanned[base] = true
for _, root := range reposIn(base, 2) {
// A repo under two roots keeps the first one's name.
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") && names[root] == "" {
names[root] = filepath.ToSlash(name) names[root] = filepath.ToSlash(name)
} }
add(root) add(root)
@@ -391,10 +403,11 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00") file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key if !ok { // made from a link: the repo's root, the file's path, the key
file, extract = o.Container.ID, extractFor(o.Name) file = o.Container.ID
if file == "" { if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name)) file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
} }
extract = extractFor(file, o.Name)
} }
if _, isSops := readMeta(file); !isSops { if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name) return plainValue(file, o.Name)
@@ -587,14 +600,14 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile struct { type lazysecretsFile struct {
Include []string `yaml:"include"` Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"` Exclude []string `yaml:"exclude"`
Share struct { Sharing struct {
// Key is the link to the secret that encrypts the repo's // Key is the link to the secret that encrypts the repo's
// shared env files (package share). // shared env files (package share).
Key string `yaml:"key"` Key string `yaml:"key"`
} `yaml:"share"` } `yaml:"sharing"`
} }
// ShareKey is the share.key of the repo's .lazysecrets.yaml, if any. // ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) { func ShareKey(root string) (string, error) {
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")) b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@@ -607,7 +620,7 @@ func ShareKey(root string) (string, error) {
if err := yaml.Unmarshal(b, &f); err != nil { if err := yaml.Unmarshal(b, &f); err != nil {
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err) return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
} }
return f.Share.Key, nil return f.Sharing.Key, nil
} }
// selector says which files to look at: user (the include/exclude of the // selector says which files to look at: user (the include/exclude of the
@@ -1000,7 +1013,7 @@ func formatOf(file string) string {
} }
type key struct { type key struct {
name string // db.password, list[0] name string // db.password, list[0], with . [ ] \ in keys escaped
extract string // sops --extract: ["db"]["password"] extract string // sops --extract: ["db"]["password"]
encrypted bool encrypted bool
line int // 1-based line int // 1-based
@@ -1045,7 +1058,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue continue
} }
k, v = strings.TrimSpace(k), strings.TrimSpace(v) k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
} }
} }
return out, nil return out, nil
@@ -1062,9 +1075,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
if top && k == "sops" { if top && k == "sops" {
continue continue
} }
child := k child := escape(k)
if name != "" { if name != "" {
child = name + "." + k child = name + "." + child
} }
walk(n.Content[i+1], child, extract+quote(k), false, out) walk(n.Content[i+1], child, extract+quote(k), false, out)
} }
@@ -1086,23 +1099,56 @@ func quote(k string) string {
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]` return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
} }
// escape keeps a key's dots and brackets from reading as nesting in a
// name: app\.name is one key, app.name two.
func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractFor rebuilds the extract path from a key name, for objects made // extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0]). Names with dots in keys can't be // from a link (db.password, list[0], app\.name). A dotenv file is flat:
// told apart from nesting: those come from the listing instead. // its names are the keys as they are.
func extractFor(name string) string { func extractFor(file, name string) string {
var b strings.Builder if formatOf(file) == "dotenv" {
for _, part := range strings.Split(name, ".") { return quote(name)
base, idx, _ := strings.Cut(part, "[")
if base != "" {
b.WriteString(quote(base))
} }
if idx != "" { var b, part strings.Builder
b.WriteString("[" + idx) flush := func() {
if part.Len() > 0 {
b.WriteString(quote(part.String()))
part.Reset()
} }
} }
for i := 0; i < len(name); i++ {
switch c := name[i]; c {
case '\\':
if i+1 < len(name) {
i++
}
part.WriteByte(name[i])
case '.':
flush()
case '[':
end := strings.IndexByte(name[i:], ']')
if end < 0 || !digits(name[i+1:i+end]) {
part.WriteByte(c)
continue
}
flush()
b.WriteString(name[i : i+end+1])
i += end
default:
part.WriteByte(c)
}
}
flush()
return b.String() return b.String()
} }
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
}
func absPath(p string) string { func absPath(p string) string {
if a, err := filepath.Abs(p); err == nil { if a, err := filepath.Abs(p); err == nil {
return a return a
+45
View File
@@ -336,6 +336,30 @@ func TestRoots(t *testing.T) {
} }
} }
func TestRootsDeduped(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir())
os.Rename(gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}), filepath.Join(root, "infra"))
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
os.Rename(gitRepo(t, map[string]string{"prod.env": sopsEnv}), filepath.Join(root, "acme", "billing"))
// ~/src → ~/git, and a root reaching one repo at another depth.
links := t.TempDir()
alias := filepath.Join(links, "src")
os.Symlink(root, alias)
missing := filepath.Join(links, "nope")
acme := filepath.Join(root, "acme")
srcs, err := New(Settings{Dir: links, Roots: []string{missing, root, alias, acme}}).Sources(context.Background())
if err != nil {
t.Fatal(err)
}
got := names(srcs, func(s provider.Source) string { return s.Name })
slices.Sort(got)
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
t.Errorf("repos: %v, want each once, named from the first root", got)
}
}
func TestStarredReposAlongside(t *testing.T) { func TestStarredReposAlongside(t *testing.T) {
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}) here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv}) starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
@@ -521,3 +545,24 @@ func TestFileDetails(t *testing.T) {
t.Errorf("untracked env: %+v", d) t.Errorf("untracked env: %+v", d)
} }
} }
func TestKeyNamesEscaped(t *testing.T) {
keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n"))
if err != nil {
t.Fatal(err)
}
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
+5 -1
View File
@@ -56,6 +56,10 @@ type Ref struct {
// Share is a share link's sealed files, as in the link; the other // Share is a share link's sealed files, as in the link; the other
// fields are empty. Package share reads it. // fields are empty. Package share reads it.
Share string Share string
// Account, when set, is the provider (its ID) to open or read it
// with when several could, from lazysecrets list. It isn't part of
// the link.
Account string
} }
// IsShare reports whether r is a share link. // IsShare reports whether r is a share link.
@@ -310,7 +314,7 @@ func parseLink(s string) (Ref, error) {
} }
default: default:
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host) return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host)
} }
return r, nil return r, nil
} }
+1 -1
View File
@@ -53,7 +53,7 @@ func (r Rules) Check(key []byte) error {
if len(has) == 0 { if len(has) == 0 {
return nil return nil
} }
return fmt.Errorf("its value has %s; share.keyRules needs %s", return fmt.Errorf("its value has %s; sharing.keyRules needs %s",
strings.Join(has, " and "), strings.Join(needs, " and ")) strings.Join(has, " and "), strings.Join(needs, " and "))
} }
+3 -3
View File
@@ -259,8 +259,8 @@ func CheckPath(p string) error {
return nil return nil
} }
// DefaultKey is the key for a repo's files: share.key in the repo's // DefaultKey is the key for a repo's files: sharing.key in the repo's
// .lazysecrets.yaml, else general (share.key in the config). ok is false // .lazysecrets.yaml, else general (sharing.key in the config). ok is false
// when neither names one. // when neither names one.
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) { func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
link, err := repo.ShareKey(root) link, err := repo.ShareKey(root)
@@ -276,7 +276,7 @@ func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
} }
key, err = ParseKey(link) key, err = ParseKey(link)
if err != nil { if err != nil {
return ref.Ref{}, false, fmt.Errorf("share.key in %s: %w", where, err) return ref.Ref{}, false, fmt.Errorf("sharing.key in %s: %w", where, err)
} }
return key, true, nil return key, true, nil
} }
+1 -1
View File
@@ -243,7 +243,7 @@ func TestDefaultKey(t *testing.T) {
if err != nil || !ok || k.Name != "env-share" { if err != nil || !ok || k.Name != "env-share" {
t.Errorf("general: %+v %v %v", k, ok, err) t.Errorf("general: %+v %v %v", k, ok, err)
} }
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("share:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644) os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("sharing:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" { if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
t.Errorf("the repo's should win: %+v", k) t.Errorf("the repo's should win: %+v", k)
} }
+20 -6
View File
@@ -43,6 +43,13 @@ type Node struct {
// alone isn't. // alone isn't.
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 } func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
// UsedLately reports whether the item was used within the last 90 days
// (what Recent lists). Starred and noted records are kept longer, so a
// use alone doesn't say.
func (n Node) UsedLately(now time.Time) bool {
return !n.LastUsed.IsZero() && now.Sub(n.LastUsed) <= usageMaxAge
}
// StarredFor reports whether the item is starred for account. // StarredFor reports whether the item is starred for account.
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) } func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
@@ -145,16 +152,23 @@ type Store struct {
data file data file
} }
// ErrNewer is a state file written by a newer lazysecrets.
var ErrNewer = errors.New("written by a newer lazysecrets")
// Load reads path; a missing file is an empty store. path "" gives a store // Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo. // that never saves, for tests and --demo. A file from a newer lazysecrets
// is read but never saved, so nothing it keeps is lost: Load returns the
// store with an ErrNewer error.
func Load(path string) (*Store, error) { func Load(path string) (*Store, error) {
s := &Store{path: path} s := &Store{path: path}
d, err := read(path) d, err := read(path)
if err != nil { if errors.Is(err, ErrNewer) {
s.path = ""
} else if err != nil {
return nil, err return nil, err
} }
s.data = d s.data = d
return s, nil return s, err
} }
func read(path string) (file, error) { func read(path string) (file, error) {
@@ -172,12 +186,12 @@ func read(path string) (file, error) {
if err := json.Unmarshal(b, &d); err != nil { if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err) return d, fmt.Errorf("%s: %w", path, err)
} }
if d.Version > version {
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
}
if d.Nodes == nil { if d.Nodes == nil {
d.Nodes = map[string]Node{} d.Nodes = map[string]Node{}
} }
if d.Version > version {
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
}
return d, nil return d, nil
} }
+23 -4
View File
@@ -1,6 +1,7 @@
package store package store
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -65,11 +66,17 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
} }
} }
func TestNewerVersionRefused(t *testing.T) { func TestNewerVersionReadOnly(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json") path := filepath.Join(t.TempDir(), "state.json")
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600) newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
if _, err := Load(path); err == nil { _ = os.WriteFile(path, []byte(newer), 0o600)
t.Error("a newer file was accepted") s, err := Load(path)
if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" {
t.Fatalf("Load = %v, %v", s, err)
}
_ = s.SetFlag("x", true)
if b, _ := os.ReadFile(path); string(b) != newer {
t.Errorf("the newer file was overwritten: %s", b)
} }
} }
@@ -112,3 +119,15 @@ func TestStarsPerAccount(t *testing.T) {
t.Errorf("unstar alice: %v", n.StarredBy) t.Errorf("unstar alice: %v", n.StarredBy)
} }
} }
func TestUsedLately(t *testing.T) {
now := time.Now()
for ago, want := range map[time.Duration]bool{time.Hour: true, 89 * 24 * time.Hour: true, 2 * 365 * 24 * time.Hour: false} {
if got := (Node{LastUsed: now.Add(-ago)}).UsedLately(now); got != want {
t.Errorf("used %v ago: %v", ago, got)
}
}
if (Node{}).UsedLately(now) {
t.Error("never used is lately")
}
}
+91 -40
View File
@@ -7,7 +7,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"image/color" "image/color"
"math"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -65,6 +64,8 @@ const (
type Options struct { type Options struct {
// Timeout bounds every provider call. Defaults to 30s. // Timeout bounds every provider call. Defaults to 30s.
Timeout time.Duration Timeout time.Duration
// Timeouts override Timeout for the providers of a kind.
Timeouts map[provider.Kind]time.Duration
// Icons shows Nerd Font glyphs next to backends. // Icons shows Nerd Font glyphs next to backends.
Icons bool Icons bool
// Clipboard defaults to the system clipboard, or OSC52 over SSH. // Clipboard defaults to the system clipboard, or OSC52 over SSH.
@@ -77,12 +78,15 @@ type Options struct {
// and keeps its state, for the status pane. // and keeps its state, for the status pane.
ConfigFiles []string ConfigFiles []string
StatePath string StatePath string
// Warning shows when the window opens: what lazysecrets skipped
// instead of failing to start.
Warning string
// Changelog is CHANGELOG.md, for the status pane. // Changelog is CHANGELOG.md, for the status pane.
Changelog string Changelog string
// CacheFile keeps what was loaded between runs; "" keeps it for this // CacheFile keeps what was loaded between runs; "" keeps it for this
// run only. // run only.
CacheFile string CacheFile string
// ShareKey is the config's share.key: the secret that encrypts shared // ShareKey is the config's sharing.key: the secret that encrypts shared
// env files, for repos whose .lazysecrets.yaml names none. // env files, for repos whose .lazysecrets.yaml names none.
ShareKey string ShareKey string
// ShareRules are what a key's value must be to share with it. // ShareRules are what a key's value must be to share with it.
@@ -117,15 +121,24 @@ type Options struct {
// Keys, if set, replaces the default keymap (keys.Map.Apply). // Keys, if set, replaces the default keymap (keys.Map.Apply).
Keys *keys.Map Keys *keys.Map
// Updater, if set, finds and installs newer releases (U); with // Updater, if set, finds and installs newer releases (U); with
// UpdateCheck, lazysecrets also looks once a day on its own. // UpdateCheck, lazysecrets also looks on its own at startup, at most
// once per UpdateEvery (0 for a day).
Updater *Updater Updater *Updater
UpdateCheck bool UpdateCheck bool
UpdateEvery time.Duration
// HandlerSetup, if set, offers to set up the link handler at startup. // HandlerSetup, if set, offers to set up the link handler at startup.
HandlerSetup *HandlerSetup HandlerSetup *HandlerSetup
// NoHistory stops counting uses of items for the Recent list. // NoHistory stops counting uses of items for the Recent list.
NoHistory bool NoHistory bool
// RecentIcon marks items used lately with ◷ in the lists
// (ui.recentIcon).
RecentIcon bool
// Open is a reference to open at startup (lazysecrets open <ref>). // Open is a reference to open at startup (lazysecrets open <ref>).
Open *ref.Ref Open *ref.Ref
// OpenAction, if set, is what to do with Open, rather than asking:
// go to it, and maybe copy or show its value (OpenActions). It ends in
// the list, like Go to, rather than on the details.
OpenAction string
// Styles, if set, replaces the default theme (styles.FromTheme), and // Styles, if set, replaces the default theme (styles.FromTheme), and
// DefaultFg the terminal's default text colour. // DefaultFg the terminal's default text colour.
Styles *styles.Styles Styles *styles.Styles
@@ -144,7 +157,7 @@ type Clipboard interface {
type Model struct { type Model struct {
providers []provider.Provider providers []provider.Provider
byID map[string]provider.Provider byID map[string]provider.Provider
timeout time.Duration timeout timeouts
keys keys.Map keys keys.Map
st styles.Styles st styles.Styles
@@ -199,6 +212,9 @@ type Model struct {
statusW int // the status pane's width: the left column's, zoomed or not statusW int // the status pane's width: the left column's, zoomed or not
changelog []panes.Field changelog []panes.Field
cacheDirty bool cacheDirty bool
// cacheDropped are cache keys deleted since the last save, so saving
// deletes them from the file too.
cacheDropped map[string]bool
sourcesAt map[string]time.Time sourcesAt map[string]time.Time
placeDir string placeDir string
savedPlace string // the link last saved as the place savedPlace string // the link last saved as the place
@@ -236,6 +252,7 @@ type Model struct {
updater *Updater updater *Updater
updateCheck bool updateCheck bool
updateEvery time.Duration
fetchOnStart bool // look for a newer release in Init fetchOnStart bool // look for a newer release in Init
newer *update.Release // a newer release to offer newer *update.Release // a newer release to offer
updated string // the release installed, waiting for a restart updated string // the release installed, waiting for a restart
@@ -275,9 +292,11 @@ type Model struct {
prompt *modal // popup that has the keyboard prompt *modal // popup that has the keyboard
linked []provider.Container // vaults opened from links, this session linked []provider.Container // vaults opened from links, this session
open *ref.Ref // reference to open at startup open *ref.Ref // reference to open at startup
recentIcon bool // mark items used lately (◷)
openAction string // and what to do with it, or ask
shareKey string // the config's share.key shareKey string // the config's sharing.key
shareRules share.Rules // and share.keyRules shareRules share.Rules // and sharing.keyRules
// A lazysecrets:// link found on the clipboard, offered in the footer. // A lazysecrets:// link found on the clipboard, offered in the footer.
dismissedLink string // the last link offered: not offered again dismissedLink string // the last link offered: not offered again
@@ -375,7 +394,7 @@ func New(providers []provider.Provider, opts Options) Model {
m := Model{ m := Model{
providers: providers, providers: providers,
byID: map[string]provider.Provider{}, byID: map[string]provider.Provider{},
timeout: opts.Timeout, timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
keys: keys.Default(), keys: keys.Default(),
st: styles.Default(), st: styles.Default(),
spin: spinner.New(spinner.WithSpinner(spinner.Dot)), spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
@@ -407,7 +426,8 @@ func New(providers []provider.Provider, opts Options) Model {
if m.store == nil { if m.store == nil {
m.store, _ = store.Load("") m.store, _ = store.Load("")
} }
m.sources = newSourcesList(iconSet, m.store) m.recentIcon = opts.RecentIcon
m.sources = newSourcesList(iconSet, m.store, m.recentIcon)
m.sources.Empty = "no sources found" m.sources.Empty = "no sources found"
m.collapsed = map[string]bool{} m.collapsed = map[string]bool{}
m.flatSources = opts.FlatSources m.flatSources = opts.FlatSources
@@ -427,7 +447,10 @@ func New(providers []provider.Provider, opts Options) Model {
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
m.expiryWarning = opts.ExpiryWarning m.expiryWarning = opts.ExpiryWarning
m.handlerSetup = opts.HandlerSetup m.handlerSetup = opts.HandlerSetup
m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck m.updater, m.updateCheck, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery
if m.updateEvery == 0 {
m.updateEvery = updateEvery
}
m.startUpdateCheck() m.startUpdateCheck()
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt) m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
m.settle = opts.LoadDelay m.settle = opts.LoadDelay
@@ -436,7 +459,7 @@ func New(providers []provider.Provider, opts Options) Model {
} }
m.defaultFg = opts.DefaultFg m.defaultFg = opts.DefaultFg
m.help = newHelp(m.st) m.help = newHelp(m.st)
m.open = opts.Open m.open, m.openAction = opts.Open, opts.OpenAction
saved := m.store.Layout() saved := m.store.Layout()
m.split, m.heights = saved.Split, saved.Heights m.split, m.heights = saved.Split, saved.Heights
@@ -466,6 +489,9 @@ func New(providers []provider.Provider, opts Options) Model {
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if opts.Warning != "" {
m.flash = warning(opts.Warning)
}
if m.cachePath != "" { if m.cachePath != "" {
m.loadCacheFile() m.loadCacheFile()
if len(m.sourceResults) > 0 { if len(m.sourceResults) > 0 {
@@ -507,18 +533,26 @@ func (m Model) Init() tea.Cmd {
cmds = append(cmds, m.fetchLatest(false)) cmds = append(cmds, m.fetchLatest(false))
} }
if m.open != nil { if m.open != nil {
r := *m.open msg := openRefMsg{ref: *m.open, action: m.openAction}
cmds = append(cmds, func() tea.Msg { return openRefMsg{r} }) cmds = append(cmds, func() tea.Msg { return msg })
} }
return tea.Batch(cmds...) return tea.Batch(cmds...)
} }
// openRefMsg starts opening a reference from outside the key handler. // openRefMsg starts opening a reference from outside the key handler:
type openRefMsg struct{ ref ref.Ref } // asking what to do with it, or doing action (OpenActions).
type openRefMsg struct {
ref ref.Ref
action string
}
// OpenActions are what lazysecrets open --action takes, the Open link
// menu's choices that go there.
var OpenActions = map[string]linkAction{"go": actGo, "copy": actCopy, "show": actShow}
// OpenLink is a message offering r in the Open link popup, for links // OpenLink is a message offering r in the Open link popup, for links
// handed over by the link handler (Program.Send). // handed over by the link handler (Program.Send).
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} } func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{ref: r} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
@@ -575,7 +609,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case openRefMsg: case openRefMsg:
m.deferHandlerSetup() // the link first m.deferHandlerSetup() // the link first
if act, ok := OpenActions[msg.action]; ok && !msg.ref.IsShare() {
cmds = append(cmds, m.findRef(msg.ref, act))
} else {
cmds = append(cmds, m.offerRef("Open link", msg.ref, false)) cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
}
case resolvedMsg: case resolvedMsg:
m.handleResolved(msg) m.handleResolved(msg)
@@ -652,7 +690,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if src, ok := m.selectedSource(); ok { if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err) m.logErrOf(src.Name, msg.err)
} }
delete(m.cache.containers, msg.key) uncache(&m, m.cache.containers, msg.key)
m.containers.SetError(msg.err) m.containers.SetError(msg.err)
} else { } else {
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()} m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
@@ -673,7 +711,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if c, ok := m.containers.Selected(); ok { if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err) m.logErrOf(c.Name, msg.err)
} }
delete(m.cache.objects, msg.key) uncache(&m, m.cache.objects, msg.key)
m.secrets.SetError(msg.err) m.secrets.SetError(msg.err)
} else { } else {
if msg.bound != nil { if msg.bound != nil {
@@ -685,7 +723,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.versionsOf != nil { if m.versionsOf != nil {
m.setVersions(msg.items) m.setVersions(msg.items)
} else { } else {
m.secrets.SetItems(m.starredFirst(msg.items)) m.secrets.SetItems(m.starredObjectsFirst(msg.items))
cmds = append(cmds, m.refreshShown(before)) cmds = append(cmds, m.refreshShown(before))
} }
} }
@@ -1220,7 +1258,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
m.secrets.SetLoading() m.secrets.SetLoading()
return m.loadObjects(c, m.objType, key) return m.loadObjects(c, m.objType, key)
} }
m.secrets.SetItems(m.starredFirst(e.value)) m.secrets.SetItems(m.starredObjectsFirst(e.value))
m.secrets.Select(m.cache.cursor[key]) m.secrets.Select(m.cache.cursor[key])
if e.fresh(m.now()) { if e.fresh(m.now()) {
return nil return nil
@@ -1240,7 +1278,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
} }
m.secrets.SetLoading() m.secrets.SetLoading()
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotObjects) ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
items, err := p.Versions(ctx, o) items, err := p.Versions(ctx, o)
for i := range items { for i := range items {
@@ -1270,7 +1308,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
} }
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotVersions) ctx, req := m.begin(slotVersions, o.Container.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
items, err := p.Versions(ctx, o) items, err := p.Versions(ctx, o)
for i := range items { for i := range items {
@@ -1421,11 +1459,26 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
return m.crawlStart() return m.crawlStart()
} }
// begin starts a cancellable request for pane p, cancelling any in flight. // timeouts bound each provider request: its kind's, else the default.
func (m *Model) begin(p slot) (context.Context, int) { type timeouts struct {
def time.Duration
byKind map[provider.Kind]time.Duration
}
// of is how long a request to a provider of kind k may take.
func (t timeouts) of(k provider.Kind) time.Duration {
if d, ok := t.byKind[k]; ok {
return d
}
return t.def
}
// begin starts a cancellable request for pane p to a provider of kind k,
// cancelling any in flight.
func (m *Model) begin(p slot, k provider.Kind) (context.Context, int) {
m.cancelReq(p) m.cancelReq(p)
m.seq++ m.seq++
ctx, cancel := context.WithTimeout(context.Background(), m.timeout) ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k))
m.reqID[p], m.cancel[p] = m.seq, cancel m.reqID[p], m.cancel[p] = m.seq, cancel
return ctx, m.seq return ctx, m.seq
} }
@@ -1479,7 +1532,7 @@ func (m Model) sourcesCmd() tea.Cmd {
gen := m.sourcesGen gen := m.sourcesGen
var cmds []tea.Cmd var cmds []tea.Cmd
for _, p := range m.providers { for _, p := range m.providers {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout) ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
defer cancel() defer cancel()
s, err := p.Sources(ctx) s, err := p.Sources(ctx)
@@ -1490,7 +1543,7 @@ func (m Model) sourcesCmd() tea.Cmd {
}) })
// The source it started in, quickly, on its own. // The source it started in, quickly, on its own.
if c, ok := p.(provider.Current); ok { if c, ok := p.(provider.Current); ok {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout) ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
defer cancel() defer cancel()
src, ok := c.Current(ctx) src, ok := c.Current(ctx)
@@ -1507,7 +1560,7 @@ func (m Model) sourcesCmd() tea.Cmd {
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd { func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
p := m.providerFor(src) p := m.providerFor(src)
ctx, req := m.begin(slotContainers) ctx, req := m.begin(slotContainers, src.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
items, err := p.Containers(ctx, src) items, err := p.Containers(ctx, src)
// Carry the source as given, with its provider ID, so later // Carry the source as given, with its provider ID, so later
@@ -1526,7 +1579,7 @@ func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd { func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
cands := m.candidates(c.Source) cands := m.candidates(c.Source)
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
ctx, req := m.begin(slotObjects) ctx, req := m.begin(slotObjects, c.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
var errs []error var errs []error
for _, p := range cands { for _, p := range cands {
@@ -1554,7 +1607,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
func (m *Model) fetchValue(s provider.Object) tea.Cmd { func (m *Model) fetchValue(s provider.Object) tea.Cmd {
p := m.providerFor(s.Container.Source) p := m.providerFor(s.Container.Source)
ctx, req := m.begin(slotValue) ctx, req := m.begin(slotValue, s.Container.Source.Kind)
m.detail.SetLoading() m.detail.SetLoading()
return func() tea.Msg { return func() tea.Msg {
v, err := p.Value(ctx, s) v, err := p.Value(ctx, s)
@@ -1573,7 +1626,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
m.detail.SetDescribing() m.detail.SetDescribing()
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotDescribe) ctx, req := m.begin(slotDescribe, o.Container.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
obj, err := p.Describe(ctx, o) obj, err := p.Describe(ctx, o)
return describeMsg{req: req, key: key, obj: obj, err: err} return describeMsg{req: req, key: key, obj: obj, err: err}
@@ -1601,7 +1654,7 @@ func (m *Model) copyValue() tea.Cmd {
return m.copyText(o.Name, v.Reveal(), true) return m.copyText(o.Name, v.Reveal(), true)
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotCopy) ctx, req := m.begin(slotCopy, o.Container.Source.Kind)
m.flash = flash{text: "copying " + o.Name + "…"} m.flash = flash{text: "copying " + o.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
v, err := p.Value(ctx, o) v, err := p.Value(ctx, o)
@@ -1628,12 +1681,8 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
// clipTick fires when the countdown's displayed second next changes. // clipTick fires when the countdown's displayed second next changes.
func (m *Model) clipTick() tea.Cmd { func (m *Model) clipTick() tea.Cmd {
wait := m.clipDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.clipToken token := m.clipToken
return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} }) return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} })
} }
func (m *Model) cancelClipClear() { func (m *Model) cancelClipClear() {
@@ -1740,13 +1789,15 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() { } else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else { } else {
revealed := m.detail.State() == panes.ValueShown reveal := "show"
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) 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)
} }
var lines []string var lines []string
if !m.clipDeadline.IsZero() { if !m.clipDeadline.IsZero() {
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds()))) lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, secondsLeft(m.clipDeadline, m.now())))))
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
} }
if m.flash.text != "" { if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash)) lines = append(lines, m.flashLine(m.flash))
+17 -15
View File
@@ -536,7 +536,7 @@ func TestBackspaceGoesBack(t *testing.T) {
} }
// While typing a filter, backspace deletes a character instead. // While typing a filter, backspace deletes a character instead.
d.keys("/") d.keys("/")
d.typeText("apx") d.typeText("api-x")
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace}) d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 { if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 {
t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len()) t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len())
@@ -666,7 +666,7 @@ func TestFilter(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.typeText("api") d.typeText("api-k")
if d.m.secrets.Len() != 1 { if d.m.secrets.Len() != 1 {
t.Fatalf("visible = %d, want 1", d.m.secrets.Len()) t.Fatalf("visible = %d, want 1", d.m.secrets.Len())
} }
@@ -684,9 +684,9 @@ func TestFilter(t *testing.T) {
} }
d.keys("esc") // leave filter mode, clearing the query d.keys("esc") // leave filter mode, clearing the query
d.keys("/") d.keys("/")
d.typeText("api") d.typeText("api-k")
d.keys("enter", "esc") // apply, then esc clears the applied filter d.keys("enter", "esc") // apply, then esc clears the applied filter
if d.m.secrets.Len() != 3 { if d.m.secrets.Len() != 4 {
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len()) t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
} }
if _, _, s := d.selected(); s != "api-key" { if _, _, s := d.selected(); s != "api-key" {
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.typeText("a") // all three secrets contain an a d.typeText("a") // all four secrets contain an a
if d.m.secrets.Len() != 3 { if d.m.secrets.Len() != 4 {
t.Fatalf("visible = %d", d.m.secrets.Len()) t.Fatalf("visible = %d", d.m.secrets.Len())
} }
_, _, initial := d.selected() // kept from before filtering: the middle row _, _, initial := d.selected() // kept from before filtering: the middle row
var seen []string var seen []string
for _, k := range []rune{'j', 'j', 'k'} { for _, k := range []rune{'j', 'j', 'j', 'k'} {
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl}) d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
_, _, s := d.selected() _, _, s := d.selected()
seen = append(seen, s) seen = append(seen, s)
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
if !d.m.secrets.Filtering() { if !d.m.secrets.Filtering() {
t.Fatal("ctrl+j/k left filter mode") t.Fatal("ctrl+j/k left filter mode")
} }
// down to the bottom, stay there, back up to where it started // down to the bottom, stay there, back up one
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial { if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen) t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
} }
if !strings.Contains(d.screen(), "ctrl+j/n down") { if !strings.Contains(d.screen(), "ctrl+j/n down") {
t.Errorf("filter help missing ctrl+j:\n%s", d.screen()) t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
@@ -848,7 +848,7 @@ func TestCopyAndRevealWhileFiltering(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.typeText("api") d.typeText("api-k")
d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
if d.clip().content != "sk_live_51HxDEMO0000000000000000" { if d.clip().content != "sk_live_51HxDEMO0000000000000000" {
t.Errorf("ctrl+y copied %q", d.clip().content) t.Errorf("ctrl+y copied %q", d.clip().content)
@@ -1187,7 +1187,8 @@ func TestDetailsFollowFocus(t *testing.T) {
func TestStatusLineCountsRepos(t *testing.T) { func TestStatusLineCountsRepos(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t, fake.Azure(0), fake.Repos()) d := newDriver(t, fake.Azure(0), fake.Repos())
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") { // The status line, not the pane, which an expired secret's ⚠ fills.
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 3 repos") {
t.Errorf("status line: %q", line) t.Errorf("status line: %q", line)
} }
d = newDriver(t, fake.Azure(0)) d = newDriver(t, fake.Azure(0))
@@ -1202,8 +1203,8 @@ func TestStatusPane(t *testing.T) {
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json", d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
OpenURL: func(u string) error { opened = append(opened, u); return nil }}, OpenURL: func(u string) error { opened = append(opened, u); return nil }},
fake.Azure(0), signedOut{fake.Azure(0)}) fake.Azure(0), signedOut{fake.Azure(0)})
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") { if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "2 accounts · 1 need sign-in") {
t.Errorf("status line:\n%s", d.screen()) t.Errorf("status line: %q", line)
} }
d.keys("0") d.keys("0")
if d.m.focus != paneStatus { if d.m.focus != paneStatus {
@@ -1303,7 +1304,8 @@ func TestExpiringInStatus(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0)) d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
d.keys("3", "]", "]") // load kv-app-prod's certificates d.keys("3", "]", "]") // load kv-app-prod's certificates
if !strings.Contains(d.screen(), "⚠ 1 expiring") { // old-tls and legacy-token.
if !strings.Contains(d.screen(), "⚠ 2 expiring") {
t.Errorf("status line:\n%s", d.screen()) t.Errorf("status line:\n%s", d.screen())
} }
d.keys("0") d.keys("0")
+14 -1
View File
@@ -67,10 +67,23 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
return nil return nil
} }
m.detail.Refresh(s) m.detail.Refresh(s)
delete(m.cache.details, detailKey(s)) uncache(m, m.cache.details, detailKey(s))
return m.describe(s) return m.describe(s)
} }
// uncache deletes key from a cache, and, at the next save, from the file.
func uncache[T any](m *Model, c map[string]entry[T], key string) {
if _, ok := c[key]; !ok {
return
}
delete(c, key)
if m.cacheDropped == nil {
m.cacheDropped = map[string]bool{}
}
m.cacheDropped[key] = true
m.cacheDirty = true
}
// sameListing reports whether a and b list the same: what a list // sameListing reports whether a and b list the same: what a list
// says of an object, not what describing it adds. // says of an object, not what describing it adds.
func sameListing(a, b provider.Object) bool { func sameListing(a, b provider.Object) bool {
+4 -1
View File
@@ -165,7 +165,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
o := *obj o := *obj
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"} m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout timeout := m.timeout.of(o.Container.Source.Kind)
m.usedObject(o) m.usedObject(o)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
@@ -313,6 +313,9 @@ func newTextInput(prompt string, width int) textinput.Model {
return in 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 { func cmpOr(a, b string) string {
if a != "" { if a != "" {
return a return a
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"slices" "slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -28,7 +29,7 @@ func (m Model) expiringItems() []expiringItem {
row := sourceRowKey(p.ID(), src.ID) row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value { for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != notExpiring { if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row}) out = append(out, expiringItem{o, row})
} }
+8 -22
View File
@@ -1,9 +1,10 @@
package app package app
import ( import (
"fmt"
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -11,28 +12,18 @@ import (
// list once they have expired (✗ expired) or will within the configured // list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them. // warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState int type expiryState = expiry.State
const ( const (
notExpiring expiryState = iota notExpiring = expiry.None
expiresSoon expiresSoon = expiry.Soon
expired expired = expiry.Expired
) )
// expiry tells whether o has expired or will within the warning period, // expiry tells whether o has expired or will within the warning period,
// and how long it has left. // and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) { func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
if o.Expires.IsZero() { return expiry.Of(o.Expires, m.now(), m.expiryWarning)
return notExpiring, 0
}
left := o.Expires.Sub(m.now())
switch {
case left <= 0:
return expired, 0
case left <= m.expiryWarning:
return expiresSoon, left
}
return notExpiring, left
} }
// expiryWord is matched by the filter: "expired" or "expiring". // expiryWord is matched by the filter: "expired" or "expiring".
@@ -47,9 +38,4 @@ func (m Model) expiryWord(o provider.Object) string {
} }
// daysLeft is a short "12d", or "5h" on the last day. // daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { func daysLeft(d time.Duration) string { return expiry.Left(d) }
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
+49 -23
View File
@@ -3,9 +3,11 @@ package app
import ( import (
"slices" "slices"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" 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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -19,6 +21,22 @@ import (
const star = "★ " const star = "★ "
// recentMark marks items used lately (Recent), with ui.recentIcon.
const recentMark = "◷ "
// marks is what goes before a list row's name: ★ when it's starred for
// account and, with recent, ◷ when it was used lately.
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
var s string
if n.StarredFor(account) {
s = st.Favourite.Render(star)
}
if recent && n.UsedLately(now) {
s += st.Muted.Render(recentMark)
}
return s
}
func sourceKey(s provider.Source) string { func sourceKey(s provider.Source) string {
if r, err := ref.ForSource(s); err == nil { if r, err := ref.ForSource(s); err == nil {
return r.String() return r.String()
@@ -94,12 +112,10 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
} }
line += sty.Muted.Render(" " + c.Detail) line += sty.Muted.Render(" " + c.Detail)
} }
if n.StarredFor(c.Source.Provider) { line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
line = sty.Favourite.Render(star) + line
}
if withSource { if withSource {
src := c.Source.Name src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok { if account := accountOf(c.Source); account != "" {
src = account + " / " + src src = account + " / " + src
} }
line += sty.Muted.Render(" · " + src) line += sty.Muted.Render(" · " + src)
@@ -116,9 +132,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Enabled != nil && !*o.Enabled { if o.Enabled != nil && !*o.Enabled {
line = sty.Muted.Render(o.Name + " (disabled)") line = sty.Muted.Render(o.Name + " (disabled)")
} }
if n.StarredFor(o.Container.Source.Provider) { line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
line = sty.Favourite.Render(star) + line
}
switch state, left := m.expiry(o); state { switch state, left := m.expiry(o); state {
case expired: case expired:
line += " " + sty.Error.Render("✗ expired") line += " " + sty.Error.Render("✗ expired")
@@ -129,24 +143,36 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
} }
} }
// starredContainersFirst moves starred vaults and scopes to the front, // starredFirst moves starred items to the front, keeping the order
// keeping the order otherwise. It doesn't modify items, which may be cached. // otherwise. It doesn't modify items, which may be cached.
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container { func starredFirst[T any](items []T, starred func(T) bool) []T {
rank := func(x T) int {
if starred(x) {
return 0
}
return 1
}
out := slices.Clone(items) out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Container) int { slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
})
return out return out
} }
// starredFirst moves starred objects to the front, keeping the order func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
// otherwise. It doesn't modify items, which may be cached. return starredFirst(sources, func(s provider.Source) bool {
func (m Model) starredFirst(items []provider.Object) []provider.Object { return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
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))
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)
}) })
return out
} }
// showLinkedContainers lists the vaults opened from links. // showLinkedContainers lists the vaults opened from links.
@@ -167,7 +193,7 @@ func (m *Model) rebind(c provider.Container) string {
m.linked[i].Source = c.Source m.linked[i].Source = c.Source
} }
} }
key := containerKey(c) + "|" + string(m.objType) key := cachefile.ObjectsKey(c, m.objType)
if m.onLinks() { if m.onLinks() {
m.containers.SetItems(m.linked) m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c)) m.containers.Select(containerKey(c))
@@ -184,7 +210,7 @@ func (m *Model) resortStarred() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items())) m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
} }
if m.versionsOf == nil { if m.versionsOf == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items())) m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
} }
} }
@@ -230,7 +256,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
c := o.Container c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) } 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 { if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items())) m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
} }
} }
switch { switch {
+19 -1
View File
@@ -6,6 +6,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
@@ -394,7 +395,7 @@ func TestStarWhileFiltering(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
for _, r := range "api" { for _, r := range "api-k" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
} }
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}) d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
@@ -434,3 +435,20 @@ func TestStarIsPerAccount(t *testing.T) {
t.Errorf("favourites should list alice's star only: %q", labels) t.Errorf("favourites should list alice's star only: %q", labels)
} }
} }
func TestRecentIcon(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
_ = mem.Use("lazysecrets://azure/"+kv+"/secrets/api-key", time.Now(), func(*store.Node) {})
d := newDriverOpts(t, Options{Store: mem, RecentIcon: true})
d.keys("3")
scr := d.screen()
if !strings.Contains(scr, "◷ api-key") || strings.Contains(scr, "◷ db-password") {
t.Errorf("want ◷ on api-key only:\n%s", scr)
}
off := newDriverOpts(t, Options{Store: mem})
off.keys("3")
if strings.Contains(off.screen(), "◷") {
t.Errorf("◷ without ui.recentIcon:\n%s", off.screen())
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
) )
@@ -160,7 +161,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
add("source", cmpOr(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) add("web", c.URL)
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok { if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value))) add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
} }
} }
@@ -196,7 +197,7 @@ func (m *Model) loadDetails(c provider.Container) tea.Cmd {
} }
} }
m.details[key] = detailsState{loading: true} m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails) ctx, req := m.begin(slotDetails, c.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c) attrs, err := d.Details(ctx, c)
if ctx.Err() != nil { if ctx.Err() != nil {
+6 -11
View File
@@ -38,10 +38,9 @@ type contextHelp struct {
commands bool // custom commands apply here commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go) account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes of pane // what the fourth pane describes
// revealed, when set, says whether the selected value is shown, so // reveal, when set, is what the reveal key will do, "show" or "hide":
// the reveal key says just what it'll do: show or hide, the same // the same width so the bar doesn't shift.
// width so the bar doesn't shift. reveal string
revealed *bool
} }
func (c contextHelp) ShortHelp() []key.Binding { func (c contextHelp) ShortHelp() []key.Binding {
@@ -69,8 +68,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter) b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default: default:
reveal := k.Reveal reveal := k.Reveal
if c.revealed != nil { if c.reveal != "" {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed])) reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal))
} }
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter) b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
} }
@@ -263,11 +262,7 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des
// move is "↑/↓ move" for an up and a down binding. // move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint { func move(up, down key.Binding) keyHint {
u, d := keys.Name(up), keys.Name(down) return keyHint{joinKeys(up, down), "move"}
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 // withKey fills a hint's format with the binding's key ("· %s loads
+2 -13
View File
@@ -107,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
if m.history { if m.history {
var used []string var used []string
for _, k := range keys { for _, k := range keys {
if !nodes[k].LastUsed.IsZero() { if nodes[k].UsedLately(m.now()) {
used = append(used, k) used = append(used, k)
} }
} }
@@ -199,18 +199,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source)) m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil return nil
} }
var choices []choice choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" })
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) { switch len(choices) {
case 0: case 0:
return m.openRef(r, actGo) return m.openRef(r, actGo)
+71 -20
View File
@@ -8,7 +8,6 @@ import (
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -56,6 +55,9 @@ type resolvedMsg struct {
act linkAction act linkAction
sourceRow string sourceRow string
others []string // other accounts' rows that also found the vault others []string // other accounts' rows that also found the vault
// find ends in the list, like Go to, not on the details: opened by a
// tool that found it (lazysecrets open --action, from fzf).
find bool
choices []choice // several candidates: ask choices []choice // several candidates: ask
linked *provider.Container linked *provider.Container
err error err error
@@ -158,7 +160,7 @@ func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true} m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
return nil return nil
} }
ctx, req := m.begin(slotCopy) ctx, req := m.begin(slotCopy, r.Kind)
m.flash = flash{text: "copying " + r.Name + "…"} m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
var errs []error var errs []error
@@ -206,17 +208,9 @@ const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account // openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup. // when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) { func (m *Model) openSource(r ref.Ref) {
var choices []choice choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) })
for _, p := range m.providers { if row, ok := preferredRow(r, choices); ok {
if p.Kind() != r.Kind { choices = []choice{{sourceRow: row}}
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})
}
}
} }
switch len(choices) { switch len(choices) {
case 0: case 0:
@@ -236,6 +230,24 @@ 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 // refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod / // provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The // kv-app-prod / api-key", with the type and version after it. The
@@ -404,18 +416,59 @@ func (m *Model) handleResolved(msg resolvedMsg) {
for i := range msg.choices { for i := range msg.choices {
msg.choices[i].label = "Open in " + msg.choices[i].label msg.choices[i].label = "Open in " + msg.choices[i].label
} }
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true) m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, !msg.find)
case len(msg.others) > 0: case len(msg.others) > 0:
var choices []choice var choices []choice
for _, row := range append([]string{msg.sourceRow}, msg.others...) { for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
} }
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true) if row, ok := preferredRow(msg.ref, choices); ok {
m.navResolved(msg, row)
return
}
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, !msg.find)
case msg.linked != nil: case msg.linked != nil:
m.addLinked(*msg.linked) m.addLinked(*msg.linked)
m.startLinkNav(msg.ref, msg.act, linksRowID) m.navResolved(msg, linksRowID)
default: default:
m.startLinkNav(msg.ref, msg.act, msg.sourceRow) m.navResolved(msg, msg.sourceRow)
}
}
// preferredRow is the choice under the account the reference asks for
// (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices {
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
return c.sourceRow, true
}
}
return "", false
}
// navResolved goes to what a resolved link points at, under row.
func (m *Model) navResolved(msg resolvedMsg, row string) {
if msg.find {
m.startNav(msg.ref, msg.act, row)
return
}
m.startLinkNav(msg.ref, msg.act, row)
}
// findRef is openRef ending in the list, like Go to: for a link a tool
// found (lazysecrets open --action).
func (m *Model) findRef(r ref.Ref, act linkAction) tea.Cmd {
cmd := m.openRef(r, act)
if cmd == nil {
return nil
}
return func() tea.Msg {
msg := cmd()
if rm, ok := msg.(resolvedMsg); ok {
rm.find = true
return rm
}
return msg
} }
} }
@@ -783,10 +836,8 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
} }
func (m *Model) startOpenPrompt() tea.Cmd { func (m *Model) startOpenPrompt() tea.Cmd {
in := textinput.New() in := newTextInput("open: ", m.pathInputWidth())
in.Prompt = "open: "
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference" in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{ m.prompt = &modal{
title: "Open a link", input: &in, title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")}, lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
+30 -3
View File
@@ -181,7 +181,7 @@ func TestPasteWhileFilteringFilters(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.send(tea.PasteMsg{Content: "api"}) d.send(tea.PasteMsg{Content: "api-k"})
if d.m.secrets.Len() != 1 || d.m.nav != nil { if d.m.secrets.Len() != 1 || d.m.nav != nil {
t.Errorf("paste in filter: visible %d", d.m.secrets.Len()) t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
} }
@@ -203,8 +203,21 @@ func TestOpenPromptAndStartupOpen(t *testing.T) {
t.Fatal("open at startup didn't ask what to do") t.Fatal("open at startup didn't ask what to do")
} }
d2.keys("o") d2.keys("o")
if _, _, s := d2.selected(); s != "api-key" { if _, _, s := d2.selected(); s != "api-key" || d2.m.focus != paneDetail {
t.Errorf("open at startup: selected %q", s) t.Errorf("open at startup: selected %q, focus %v, want the details", s, d2.m.focus)
}
// --action: no question, it does that.
d3 := newDriverOpts(t, Options{Open: &r, OpenAction: "go"})
if d3.m.prompt != nil {
t.Fatalf("--action go asked: %q", d3.m.prompt.title)
}
if _, _, s := d3.selected(); s != "api-key" || d3.m.focus != paneSecrets {
t.Errorf("--action go: selected %q, focus %v, want the secrets list, like Go to", s, d3.m.focus)
}
d4 := newDriverOpts(t, Options{Open: &r, OpenAction: "copy"})
if _, _, s := d4.selected(); s != "api-key" || d4.clip().content == "" {
t.Errorf("--action copy: selected %q, copied %q", s, d4.clip().content)
} }
} }
@@ -305,6 +318,20 @@ func TestOpenAsksWhichAccount(t *testing.T) {
t.Errorf("j enter picked %q", c.Source.Provider) t.Errorf("j enter picked %q", c.Source.Provider)
} }
// lazysecrets open --account: no question, that account.
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
r.Account = "Azure:bob@example.com/t1"
d.send(OpenLink(r))
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
d.keys("o")
}
if strings.Contains(d.screen(), "Several accounts can see") {
t.Fatalf("asked, with an account given:\n%s", d.screen())
}
if c, _ := d.m.containers.Selected(); c.Source.Provider != r.Account {
t.Errorf("--account opened via %q", c.Source.Provider)
}
// Opening into a folded account unfolds it. // Opening into a folded account unfolds it.
d.keys("1", "g") // alice's header d.keys("1", "g") // alice's header
d.keys("enter") // fold it d.keys("enter") // fold it
+1 -3
View File
@@ -75,10 +75,8 @@ type modal struct {
// showFilterMenu opens a menu with a filter input above the options. // showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd { func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := textinput.New() in := newTextInput("/", 40)
in.Prompt = "/"
in.Placeholder = "type to filter" in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in} m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0) m.prompt.move(0)
m.flash = flash{} m.flash = flash{}
+1 -4
View File
@@ -3,7 +3,6 @@ package app
import ( import (
"strings" "strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -58,11 +57,9 @@ func (m *Model) editNote() tea.Cmd {
c := o.Container c := o.Container
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source
} }
in := textinput.New() in := newTextInput("note: ", 48)
in.Prompt = "note: "
in.Placeholder = "e.g. prod SQL admin" in.Placeholder = "e.g. prod SQL admin"
in.CharLimit = 80 in.CharLimit = 80
in.SetWidth(48)
in.SetValue(m.store.Node(key).Note) in.SetValue(m.store.Node(key).Note)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
+35 -87
View File
@@ -1,14 +1,12 @@
package app package app
import ( import (
"encoding/json"
"errors" "errors"
"os"
"path/filepath"
"time" "time"
tea "charm.land/bubbletea/v2" 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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -23,144 +21,94 @@ import (
// And where lazysecrets was left, per folder it ran in, goes in the store, // And where lazysecrets was left, per folder it ran in, goes in the store,
// to go back there on the next start. // to go back there on the next start.
// cacheFileVersion is the file's format; a file of another version is
// ignored, and replaced on the next save.
const cacheFileVersion = 1
// cacheMaxAge drops what hasn't been loaded again for this long.
const cacheMaxAge = 30 * 24 * time.Hour
// persistEvery is how often a changed cache is saved while running, so // persistEvery is how often a changed cache is saved while running, so
// little is lost if lazysecrets is killed rather than quit. // little is lost if lazysecrets is killed rather than quit.
const persistEvery = 30 * time.Second const persistEvery = 30 * time.Second
type cacheFile struct {
Version int `json:"version"`
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"`
}
type stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
type persistTickMsg struct{} type persistTickMsg struct{}
type persistedMsg struct{ err error } type persistedMsg struct{ err error }
// keepCached reports whether a cache key's account still exists and // keepers report whether a provider ID, or a cache key's, is an account
// isn't a repo: every key starts with the provider ID. // that still exists and isn't a repo.
func (m *Model) keepCached(key string) bool { func (m *Model) keepers() (keep, keepKey func(string) bool) {
id, _ := splitSourceRow(key) keep = cachefile.Keep(m.providers)
p, ok := m.byID[id] return keep, func(key string) bool { return keep(cachefile.ProviderOf(key)) }
return ok && p.Kind() != provider.KindRepo
} }
// loadCacheFile fills the cache from the file, leaving out what's too // loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more. // old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() { func (m *Model) loadCacheFile() {
b, err := os.ReadFile(m.cachePath) f := cachefile.Read(m.cachePath)
if err != nil { keep, keepKey := m.keepers()
return
}
var f cacheFile
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
return
}
now := m.now() now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge } recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
for id, e := range f.Sources { for id, e := range f.Sources {
if m.keepCached(id+"|") && recent(e.At) { if keep(id) && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value} m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At m.sourcesAt[id] = e.At
} }
} }
for k, e := range f.Containers { for k, e := range f.Containers {
if m.keepCached(k) && recent(e.At) { if keepKey(k) && recent(e.At) {
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At} m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
} }
} }
for k, e := range f.Objects { for k, e := range f.Objects {
if m.keepCached(k) && recent(e.At) { if keepKey(k) && recent(e.At) {
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At} m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
} }
} }
for k, e := range f.Details { for k, e := range f.Details {
if m.keepCached(k) && recent(e.At) { if keepKey(k) && recent(e.At) {
m.cache.details[k] = entry[provider.Object]{e.Value, e.At} m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
} }
} }
for k, v := range f.Cursor { for k, v := range f.Cursor {
if m.keepCached(k) { if keepKey(k) {
m.cache.cursor[k] = v m.cache.cursor[k] = v
} }
} }
} }
// cacheSnapshot is the cache as the file holds it. // cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cacheFile { func (m *Model) cacheSnapshot() cachefile.File {
f := cacheFile{ f := cachefile.New()
Version: cacheFileVersion, keep, keepKey := m.keepers()
Sources: map[string]stamped[[]provider.Source]{}, f.Dropped, m.cacheDropped = m.cacheDropped, nil
Containers: map[string]stamped[[]provider.Container]{},
Objects: map[string]stamped[[]provider.Object]{},
Details: map[string]stamped[provider.Object]{},
Cursor: map[string]string{},
}
for id, r := range m.sourceResults { for id, r := range m.sourceResults {
if r.err == nil && m.keepCached(id+"|") { if r.err == nil && keep(id) {
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources} f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
} }
} }
for k, e := range m.cache.containers { for k, e := range m.cache.containers {
if m.keepCached(k) { if keepKey(k) {
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value} f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.objects { for k, e := range m.cache.objects {
if m.keepCached(k) { if keepKey(k) {
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value} f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.details { for k, e := range m.cache.details {
if m.keepCached(k) { if keepKey(k) {
f.Details[k] = stamped[provider.Object]{e.at, e.value} f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, v := range m.cache.cursor { for k, v := range m.cache.cursor {
if m.keepCached(k) { if keepKey(k) {
f.Cursor[k] = v f.Cursor[k] = v
} }
} }
return f return f
} }
// writeCacheFile writes f atomically, readable by the user only. // saveCache saves f, merged with the file (lazysecrets list may have
func writeCacheFile(path string, f cacheFile) error { // loaded more since), from any goroutine.
b, err := json.Marshal(f) func (m *Model) saveCache(f cachefile.File) func() error {
if err != nil { path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
return err return func() error { return cachefile.Save(path, f, keep, now) }
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
} }
// persistTick saves the cache, and where lazysecrets is, in the // persistTick saves the cache, and where lazysecrets is, in the
@@ -169,8 +117,8 @@ func (m *Model) persistTick() tea.Cmd {
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })} cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
if m.cacheDirty && m.cachePath != "" { if m.cacheDirty && m.cachePath != "" {
m.cacheDirty = false m.cacheDirty = false
f, path := m.cacheSnapshot(), m.cachePath save := m.saveCache(m.cacheSnapshot())
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} }) cmds = append(cmds, func() tea.Msg { return persistedMsg{save()} })
} }
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace { if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
m.savedPlace = p.Link m.savedPlace = p.Link
@@ -185,7 +133,7 @@ func (m *Model) persistTick() tea.Cmd {
func (m Model) Persist() error { func (m Model) Persist() error {
var errs []error var errs []error
if m.cachePath != "" { if m.cachePath != "" {
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot())) errs = append(errs, m.saveCache(m.cacheSnapshot())())
} }
if m.placeDir != "" { if m.placeDir != "" {
if p, ok := m.place(); ok { if p, ok := m.place(); ok {
+11 -8
View File
@@ -1,12 +1,14 @@
package app package app
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
@@ -80,15 +82,16 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
file := filepath.Join(t.TempDir(), "cache.json") file := filepath.Join(t.TempDir(), "cache.json")
az := fake.Azure(0) az := fake.Azure(0)
now := time.Now() now := time.Now()
f := cacheFile{ f := cachefile.File{
Version: cacheFileVersion, Version: cachefile.Version,
Containers: map[string]stamped[[]provider.Container]{ Containers: map[string]cachefile.Stamped[[]provider.Container]{
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}}, az.ID() + "|recent": {At: now.Add(-time.Hour), Value: []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}}, az.ID() + "|old": {At: now.Add(-cachefile.MaxAge - time.Hour), Value: []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}}, "Azure:gone@x/t|recent": {At: now, Value: []provider.Container{{Name: "kv-gone"}}},
}, },
} }
if err := writeCacheFile(file, f); err != nil { b, _ := json.Marshal(f) // as an older lazysecrets left it
if err := os.WriteFile(file, b, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := newDriverOpts(t, Options{CacheFile: file}, az) d := newDriverOpts(t, Options{CacheFile: file}, az)
@@ -97,7 +100,7 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Error("dropped a recent entry") t.Error("dropped a recent entry")
} }
if _, ok := c[az.ID()+"|old"]; ok { if _, ok := c[az.ID()+"|old"]; ok {
t.Error("kept an entry older than cacheMaxAge") t.Error("kept an entry older than cachefile.MaxAge")
} }
if _, ok := c["Azure:gone@x/t|recent"]; ok { if _, ok := c["Azure:gone@x/t|recent"]; ok {
t.Error("kept an entry of an account that isn't signed in") t.Error("kept an entry of an account that isn't signed in")
+16 -7
View File
@@ -28,12 +28,22 @@ func (m *Model) startRevealTimer() tea.Cmd {
// revealTickCmd fires when the displayed second next changes. // revealTickCmd fires when the displayed second next changes.
func (m *Model) revealTickCmd() tea.Cmd { func (m *Model) revealTickCmd() tea.Cmd {
wait := m.revealDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.revealToken token := m.revealToken
return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} }) 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())))
} }
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd { func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
@@ -49,8 +59,7 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
} }
func (m *Model) showHidesIn() { func (m *Model) showHidesIn() {
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds()))) m.detail.SetHidesIn(fmt.Sprintf("%ds", secondsLeft(m.revealDeadline, m.now())))
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
} }
// hideValue hides the revealed value and stops its countdown. // hideValue hides the revealed value and stops its countdown.
+2 -1
View File
@@ -5,13 +5,14 @@ import (
"fmt" "fmt"
"slices" "slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
// containerKey identifies a container in the UI and caches. The same vault // containerKey identifies a container in the UI and caches. The same vault
// seen through two accounts must not share cached lists, since each // seen through two accounts must not share cached lists, since each
// account may see different things. // account may see different things.
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID } func containerKey(c provider.Container) string { return cachefile.ContainerKey(c) }
// providerFor finds the provider instance a source came from. Sources // providerFor finds the provider instance a source came from. Sources
// remembered from an earlier run (favourites) may name an account that // remembered from an earlier run (favourites) may name an account that
+22 -14
View File
@@ -10,6 +10,7 @@ import (
tea "charm.land/bubbletea/v2" 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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
) )
@@ -89,7 +90,7 @@ func (m *Model) searchEntries() (sources, items []option) {
return nil return nil
}, reload) }, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} { for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
r, err := ref.ForObject(o, "") r, err := ref.ForObject(o, "")
if err != nil { if err != nil {
continue continue
@@ -166,7 +167,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
} }
for _, v := range e.value { for _, v := range e.value {
c.vaults++ c.vaults++
key := containerKey(v) + "|" + string(provider.TypeSecret) key := cachefile.ObjectsKey(v, provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok { if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++ c.vaultsLoaded++
c.secrets += len(objs.value) c.secrets += len(objs.value)
@@ -263,7 +264,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
// queueContainer queues loading c's secrets, unless loaded lately and not // queueContainer queues loading c's secrets, unless loaded lately and not
// force. // force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) { func (m *Model) queueContainer(c provider.Container, row string, force bool) {
key := containerKey(c) + "|" + string(provider.TypeSecret) key := cachefile.ObjectsKey(c, provider.TypeSecret)
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force { if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return return
} }
@@ -295,10 +296,10 @@ func (m *Model) crawlNext() tea.Cmd {
} }
func (m *Model) crawlCmd(job crawlJob) tea.Cmd { func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
timeout := m.timeout
if job.src != nil { if job.src != nil {
p := m.providerFor(*job.src) p := m.providerFor(*job.src)
src := *job.src src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
@@ -311,6 +312,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
} }
c := *job.container c := *job.container
p := m.providerFor(c.Source) p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
@@ -326,7 +328,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active-- m.crawl.active--
key := msg.job.row key := msg.job.row
if msg.job.container != nil { if msg.job.container != nil {
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret) key = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
} }
delete(m.crawl.pending, key) delete(m.crawl.pending, key)
switch { switch {
@@ -349,6 +351,19 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
return 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: // sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable. // ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string { func (m *Model) sourceMark(row string) string {
@@ -362,14 +377,7 @@ func (m *Model) sourceMark(row string) string {
if !ok { if !ok {
return m.st.Muted.Render("○") return m.st.Muted.Render("○")
} }
loaded, loading := 0, false loaded, loading := m.vaultsLoaded(e.value)
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
switch { switch {
case loading: case loading:
return m.st.Muted.Render("◌") return m.st.Muted.Render("◌")
@@ -385,7 +393,7 @@ func (m *Model) sourceMark(row string) string {
// (✗) or are loading (◌): whether a subscription is loaded says whether // (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable. // its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string { func (m *Model) vaultMark(c provider.Container) string {
key := containerKey(c) + "|" + string(provider.TypeSecret) key := cachefile.ObjectsKey(c, provider.TypeSecret)
switch { switch {
case m.crawl.pending[key]: case m.crawl.pending[key]:
return m.st.Muted.Render("◌") return m.st.Muted.Render("◌")
+4 -28
View File
@@ -94,9 +94,7 @@ 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) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd { func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
in := textinput.New() in := newTextInput("/", searchWidth(m.w)-6) // as wide as the popup
in.Prompt = "/"
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
v := &searchView{input: in, tab: tab, results: true} v := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries() v.favs, v.recent = m.storeEntries()
m.search = v m.search = v
@@ -615,13 +613,7 @@ func (m Model) loadHint() string {
} }
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name)) return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
} }
loaded := 0 loaded, _ := m.vaultsLoaded(e.value)
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
if loaded == len(e.value) { if loaded == len(e.value) {
return "" return ""
} }
@@ -769,7 +761,7 @@ func (m Model) searchViewBox() string {
body = append(body, " "+m.st.Error.Render(l)) body = append(body, " "+m.st.Error.Render(l))
} }
} }
body = append(body, "", " "+m.goToTabs(ts)) body = append(body, "", " "+panes.Tabs(m.st, ts, tabNames[:], m.search.tab))
} }
body = append(body, " "+v.input.View(), "") body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") { for _, l := range strings.Split(lists, "\n") {
@@ -802,7 +794,7 @@ func (m Model) searchViewBox() string {
// Tabs in the title, the current one underlined, like the panes; a // Tabs in the title, the current one underlined, like the panes; a
// picker has its own title, and the tabs over the input. // picker has its own title, and the tabs over the input.
if v.picker == nil { if v.picker == nil {
head := ts.Render(goToTitle+" ") + m.goToTabs(ts) head := ts.Render(goToTitle+" ") + panes.Tabs(m.st, ts, tabNames[:], m.search.tab)
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
} }
// A picker: in its own border, under what it's for. // A picker: in its own border, under what it's for.
@@ -855,19 +847,3 @@ func pickable(o option) (ref.Ref, bool) {
} }
return r, true 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 -8
View File
@@ -10,7 +10,6 @@ import (
"slices" "slices"
"strings" "strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -38,7 +37,7 @@ type shareFilesMsg struct {
type sealedMsg struct { type sealedMsg struct {
link ref.Ref link ref.Ref
n int n int
// from is the picker the key was picked in; nil for share.key's. // from is the picker the key was picked in; nil for sharing.key's.
from *picker from *picker
err error err error
} }
@@ -59,7 +58,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
m.flash = warning("these files can't be shared") m.flash = warning("these files can't be shared")
return nil return nil
} }
general, timeout := m.shareKey, m.timeout general, timeout := m.shareKey, m.timeout.of(src.Kind)
m.flash = flash{text: "reading the files…"} m.flash = flash{text: "reading the files…"}
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
@@ -216,7 +215,7 @@ func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) { func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
providers, st, timeout := m.providers, m.store, m.timeout providers, st, timeout := m.providers, m.store, m.timeout
return func(key ref.Ref) (provider.SecretValue, string, error) { return func(key ref.Ref) (provider.SecretValue, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout.of(key.Kind))
defer cancel() defer cancel()
k := key k := key
k.Version = "" k.Version = ""
@@ -258,7 +257,7 @@ func (m *Model) offerShare(r ref.Ref) tea.Cmd {
// openShare reads the key, decrypts the files and finds the repo's clone. // openShare reads the key, decrypts the files and finds the repo's clone.
func (m *Model) openShare(s share.Sealed) tea.Cmd { func (m *Model) openShare(s share.Sealed) tea.Cmd {
read := m.keyReader() read := m.keyReader()
providers, timeout := m.providers, m.timeout providers, timeout := m.providers, m.timeout.of(provider.KindRepo)
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"} m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
msg := shareOpenedMsg{sealed: s} msg := shareOpenedMsg{sealed: s}
@@ -338,9 +337,7 @@ 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 // 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. // stays until they're saved, saying why when they can't be.
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
in := textinput.New() in := newTextInput("folder: ", m.pathInputWidth())
in.Prompt = "folder: "
in.SetWidth(max(20, min(m.w-12, 64)))
dir := m.placeDir dir := m.placeDir
if dir == "" { if dir == "" {
dir, _ = os.Getwd() dir, _ = os.Getwd()
+3 -3
View File
@@ -50,7 +50,7 @@ func shareFrom(t *testing.T, root string, paste bool) string {
t.Fatalf("selected %v, want the repo", src.ID) t.Fatalf("selected %v, want the repo", src.ID)
} }
d.keys("S", "f") d.keys("S", "f")
// No share.key set: it asks which secret, in Go to, which lists // No sharing.key set: it asks which secret, in Go to, which lists
// only secrets. // only secrets.
v := d.m.search v := d.m.search
if v == nil || v.picker == nil { if v == nil || v.picker == nil {
@@ -137,7 +137,7 @@ func TestShareWithShareKeyConfirms(t *testing.T) {
} }
root := cloneWith(t, map[string]string{ root := cloneWith(t, map[string]string{
".env": "A=1\n", ".env": "A=1\n",
".lazysecrets.yaml": "share:\n key: https://" + kv + "/secrets/db-password\n", ".lazysecrets.yaml": "sharing:\n key: https://" + kv + "/secrets/db-password\n",
}) })
clip := &fakeClipboard{} clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root})) d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
@@ -218,7 +218,7 @@ func TestShareInDemo(t *testing.T) {
} }
// Too short a key: the picker stays, saying why. // Too short a key: the picker stays, saying why.
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"}) d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"})
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") { if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; sharing.keyRules needs 32 (minLength)") {
t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen()) t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
} }
if !strings.Contains(d.screen(), "28 characters") { if !strings.Contains(d.screen(), "28 characters") {
+7 -26
View File
@@ -5,7 +5,9 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
@@ -39,7 +41,7 @@ type sourceRow struct {
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in // sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
// either; provider IDs contain "/" (Azure:user/tenant). // either; provider IDs contain "/" (Azure:user/tenant).
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID } func sourceRowKey(providerID, sourceID string) string { return cachefile.Row(providerID, sourceID) }
func splitSourceRow(row string) (providerID, sourceID string) { func splitSourceRow(row string) (providerID, sourceID string) {
providerID, sourceID, _ = strings.Cut(row, "|") providerID, sourceID, _ = strings.Cut(row, "|")
@@ -56,7 +58,7 @@ func sourceRowID(r sourceRow) string {
return sourceRowKey(r.provider, r.source.ID) return sourceRowKey(r.provider, r.source.ID)
} }
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] { func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
return panes.NewList("[1] Sources", panes.Item[sourceRow]{ return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID, ID: sourceRowID,
// Headers have no filter text, so filtering shows just sources. // Headers have no filter text, so filtering shows just sources.
@@ -78,8 +80,8 @@ func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
node := mem.Node(sourceKey(r.source)) node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group) kind := st.Kind(r.group)
starred := "" starred := ""
if r.kind == rowSource && node.StarredFor(r.source.Provider) { if r.kind == rowSource {
starred = st.Favourite.Render(star) starred = marks(st, node, r.source.Provider, recent, time.Now())
} }
switch { switch {
case r.kind == rowLinks: case r.kind == rowLinks:
@@ -132,10 +134,7 @@ func (m *Model) rebuildSources() {
} }
for _, p := range m.providers { for _, p := range m.providers {
kind, id := p.Kind(), p.ID() kind, id := p.Kind(), p.ID()
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ") account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id] r, done := m.sourceResults[id]
sources := r.sources sources := r.sources
if !flat { if !flat {
@@ -213,17 +212,6 @@ func (m Model) currentSource() (provider.Source, bool) {
return provider.Source{}, false 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. // starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int { func starRank(mem *store.Store, r sourceRow) int {
switch { switch {
@@ -237,13 +225,6 @@ func starRank(mem *store.Store, r sourceRow) int {
return 3 return 3
} }
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header. // selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) { func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected() r, ok := m.sources.Selected()
+4 -4
View File
@@ -1,5 +1,5 @@
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮ ╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
│ 2 accounts ││ name platform-prod │ │ ⚠ 1 expiring · 2 accounts ││ name platform-prod │
╰───────────────────────────────╯│ ID sub-prod │ ╰───────────────────────────────╯│ ID sub-prod │
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│ ╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
│ ▾ Azure (2) ││ vaults 3 │ │ ▾ Azure (2) ││ vaults 3 │
@@ -20,7 +20,8 @@
╭─ [3] Secrets - Keys - Certs ──╮│ │ ╭─ [3] Secrets - Keys - Certs ──╮│ │
│ db-password ││ │ │ db-password ││ │
│ api-key ││ │ │ api-key ││ │
│ legacy-token (disabled) ││ │ │ legacy-token ✗ expired ││ │
│ smtp-password (disabled) ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
@@ -28,6 +29,5 @@
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ ╰────────────────────── 1 of 4 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
+25
View File
@@ -0,0 +1,25 @@
package app
import (
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// A provider's own timeout applies to it alone.
func TestTimeoutPerProvider(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Timeout: 5 * time.Second, Timeouts: map[provider.Kind]time.Duration{provider.KindAzure: 10 * time.Millisecond}},
fake.Azure(300*time.Millisecond), fake.Databricks(300*time.Millisecond))
scr := d.screen()
if !strings.Contains(scr, "✗ Azure: context deadline exceeded") || !strings.Contains(scr, "Databricks (…)") ||
strings.Contains(scr, "Databricks: context deadline") {
t.Errorf("want Azure timed out and Databricks still loading:\n%s", scr)
}
if got := (timeouts{def: time.Minute}).of(provider.KindRepo); got != time.Minute {
t.Errorf("no override: %s, want the default", got)
}
}
+13 -15
View File
@@ -2,13 +2,11 @@ package app
import ( import (
"context" "context"
"errors"
"strings" "strings"
"time" "time"
tea "charm.land/bubbletea/v2" 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/store"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
@@ -20,7 +18,8 @@ type Updater struct {
} }
// updateEvery is how often lazysecrets looks for a newer release on its // updateEvery is how often lazysecrets looks for a newer release on its
// own; within that, the last answer (in the state file) is used. // own unless the config says otherwise; within that, the last answer (in
// the state file) is used.
const updateEvery = 24 * time.Hour const updateEvery = 24 * time.Hour
type updateMsg struct { type updateMsg struct {
@@ -35,14 +34,14 @@ type installedMsg struct {
} }
// startUpdateCheck sets up the startup look for a newer release: the // startUpdateCheck sets up the startup look for a newer release: the
// state file's answer when it looked less than a day ago, else a quiet // state file's answer when it looked within updateEvery, else a quiet
// look at the server (fetchOnStart, for Init). // look at the server (fetchOnStart, for Init).
func (m *Model) startUpdateCheck() { func (m *Model) startUpdateCheck() {
if m.updater == nil || !m.updateCheck { if m.updater == nil || !m.updateCheck {
return return
} }
last := m.store.UpdateCheck() last := m.store.UpdateCheck()
if m.now().Sub(last.At) < updateEvery { if m.now().Sub(last.At) < m.updateEvery {
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL}) m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
return return
} }
@@ -109,28 +108,27 @@ func (m *Model) update() tea.Cmd {
func (m *Model) showUpdate() { func (m *Model) showUpdate() {
r := *m.newer r := *m.newer
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")} lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
if notes := r.Notes(); notes != "" { if notes := r.Notes(m.version); notes != "" {
lines = append(lines, "") lines = append(lines, "")
for _, l := range strings.Split(notes, "\n") { for _, l := range strings.Split(notes, "\n") {
if h, ok := strings.CutPrefix(l, "### "); ok { if h, ok := strings.CutPrefix(l, "### "); ok {
l = m.st.Label.Render(h) l = m.st.Label.Render(h)
} else if h, ok := strings.CutPrefix(l, "## "); ok { // a release, when several are new
l = m.st.TitleFocused.Render(h)
} }
lines = append(lines, l) lines = append(lines, l)
} }
} }
// A popup doesn't scroll: several releases behind, the rest is on
// the release pages.
if most := max(6, m.h-12); len(lines) > most {
lines = append(lines[:most-1], m.st.Muted.Render("… and more on the release pages"))
}
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.", opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
run: func(m *Model) tea.Cmd { return m.install(r) }}} run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" { if r.URL != "" {
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd { opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
switch err := m.openURL(r.URL); { return m.openPage("the release page", r.URL, func() {})
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) m.showMenu("lazysecrets "+r.Tag, lines, opts)
+14
View File
@@ -83,6 +83,12 @@ func TestUpdate(t *testing.T) {
if f2.asked != 1 { if f2.asked != 1 {
t.Errorf("asked %d times a day later", f2.asked) t.Errorf("asked %d times a day later", f2.asked)
} }
// With autoCheckInterval: 1h, two hours on is long enough.
mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-2 * time.Hour), Tag: "v0.2.0"})
newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, UpdateEvery: time.Hour, Store: mem})
if f2.asked != 2 {
t.Errorf("asked %d times, want a look after the hour", f2.asked)
}
} }
func TestUpdateAsked(t *testing.T) { func TestUpdateAsked(t *testing.T) {
@@ -116,3 +122,11 @@ func TestUpdateAsked(t *testing.T) {
t.Errorf("U on a dev build:\n%s", dev.screen()) t.Errorf("U on a dev build:\n%s", dev.screen())
} }
} }
func TestStartupWarning(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Warning: "theme: skipped unknown activeBorder"})
if !strings.Contains(d.screen(), "theme: skipped unknown activeBorder") {
t.Errorf("no warning:\n%s", d.screen())
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// Usage: revealing, copying, opening in the browser, sharing and jumping // Usage: revealing, copying, opening in the browser, sharing and jumping
// to an item count as using it. The store keeps when each item was last // to an item count as using it. The store keeps when each item was last
// used and how often, for the Recent section of the jump list. The config's // used and how often, for the Recent section of the jump list. The config's
// history: false turns this off. // storage.history: false turns this off.
// used counts a use of the remembered item key, keeping its context. // used counts a use of the remembered item key, keeping its context.
func (m *Model) used(key string) { func (m *Model) used(key string) {
+2
View File
@@ -11,6 +11,7 @@ type Set struct {
Repo string Repo string
Encrypted string // an encrypted file (SOPS) Encrypted string // an encrypted file (SOPS)
PlainFile string // a plain text secrets file (.env) PlainFile string // a plain text secrets file (.env)
Note string // has a note (lazysecrets list)
} }
// None renders no icons. // None renders no icons.
@@ -24,6 +25,7 @@ var NerdFont = Set{
Repo: "\U000F02A2", // md-git Repo: "\U000F02A2", // md-git
Encrypted: "\U000F033E", // md-lock Encrypted: "\U000F033E", // md-lock
PlainFile: "\U000F0214", // md-file PlainFile: "\U000F0214", // md-file
Note: "\U000F09ED",
} }
// Prefix returns the icon for kind followed by a space, or "" if the set has // Prefix returns the icon for kind followed by a space, or "" if the set has
+6 -6
View File
@@ -95,7 +95,7 @@ func (m *Map) Actions() []Action {
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."), a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."), a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."), a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."), a(Lists, "open", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."), a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."), a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
@@ -103,7 +103,7 @@ func (m *Map) Actions() []Action {
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."), a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."), a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
@@ -116,7 +116,7 @@ func (m *Map) Actions() []Action {
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."), a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."), a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), a(Lists, "copyLink", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."), a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."),
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."), a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
@@ -184,13 +184,13 @@ var defaults = map[string][]string{
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"}, "lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, "lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"}, "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"}, "lists.open": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"}, "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"},
"lists.commands": {"x"}, "lists.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
"lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"}, "lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"},
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"}, "lists.copyLink": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"}, "lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"}, "lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="}, "lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
+17
View File
@@ -69,3 +69,20 @@ func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
} }
return st.TitleBlurred 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
}
+46 -22
View File
@@ -180,20 +180,49 @@ func (d Detail) View(st styles.Styles, focused bool, spin string) string {
const spinnerPlaceholder = "\x00spin\x00" const spinnerPlaceholder = "\x00spin\x00"
// carryStyle starts each wrapped line with the styles still on at the end
// of the line before: a styled value wrapped in the middle (a long ref)
// would go on unstyled, as each line is drawn on its own.
func carryStyle(lines []string) []string {
var on string
for i, line := range lines {
lines[i] = on + line
for _, code := range sgr.FindAllString(line, -1) {
if code == "\x1b[m" || code == "\x1b[0m" {
on = ""
} else {
on += code
}
}
}
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. // lines renders the body, wrapped to the pane width.
func (d Detail) lines(st styles.Styles) []string { func (d Detail) lines(st styles.Styles) []string {
inner := max(1, d.w-3) inner := max(1, d.w-3)
var out []string var out []string
field := func(label, value string) { field := func(label, value string) {
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " " wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n"))
indent := strings.Repeat(" ", 12) out = append(out, fieldLabel(st, label)+wrapped[0])
wrapped := strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")
out = append(out, prefix+wrapped[0])
for _, w := range wrapped[1:] { for _, w := range wrapped[1:] {
out = append(out, indent+w) out = append(out, valueIndent+w)
} }
} }
muted := st.Muted.Render muted := st.Muted.Render
note := func() {
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
}
if d.container == nil { if d.container == nil {
return []string{muted(" Select a vault or scope to browse its secrets.")} return []string{muted(" Select a vault or scope to browse its secrets.")}
@@ -203,30 +232,27 @@ func (d Detail) lines(st styles.Styles) []string {
if containerLabel == "" { if containerLabel == "" {
containerLabel = "vault" 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 { if d.secret == nil {
field(containerLabel, c.Name) field(containerLabel, c.Name)
if d.note != "" { note()
field("note", st.Favourite.Render(d.note))
}
if c.Detail != "" { if c.Detail != "" {
field("", muted(c.Detail)) field("", muted(c.Detail))
} }
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) field("source", source)
out = append(out, "", muted(" Select a secret to see its details.")) out = append(out, "", muted(" Select a secret to see its details."))
return out return out
} }
s := *d.secret s := *d.secret
field("name", s.Name) field("name", s.Name)
if d.note != "" { note()
field("note", st.Favourite.Render(d.note))
}
field(containerLabel, c.Name) field(containerLabel, c.Name)
if c.Source.Kind != provider.KindAzure && c.Detail != "" { if c.Source.Kind != provider.KindAzure && c.Detail != "" {
field("", muted(c.Detail)) field("", muted(c.Detail))
} }
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) field("source", source)
if s.Version != "" { if s.Version != "" {
field("version", s.Version) field("version", s.Version)
} }
@@ -290,14 +316,12 @@ func (d Detail) lines(st styles.Styles) []string {
case provider.TypeCertificate: case provider.TypeCertificate:
label, hidden, reveal = "cert", "", "show the certificate" label, hidden, reveal = "cert", "", "show the certificate"
} }
indent := strings.Repeat(" ", 12)
out = append(out, "") out = append(out, "")
switch d.state { switch d.state {
case ValueHidden: case ValueHidden:
if hidden != "" { if hidden != "" {
field(label, hidden) field(label, hidden)
out = append(out, indent+hints(reveal)) out = append(out, valueIndent+hints(reveal))
} else { } else {
field(label, hints(reveal)) field(label, hints(reveal))
} }
@@ -314,9 +338,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") { for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
w = lead + w w = lead + w
if i == 0 && j == 0 { if i == 0 && j == 0 {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w)) out = append(out, fieldLabel(st, label)+st.Error.Render(w))
} else { } else {
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w)) out = append(out, valueIndent+st.Error.Render(w))
} }
} }
} }
@@ -327,10 +351,10 @@ func (d Detail) lines(st styles.Styles) []string {
for _, line := range lines { for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") { for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first { if first {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w)) out = append(out, fieldLabel(st, label)+st.Value.Render(w))
first = false first = false
} else { } else {
out = append(out, indent+st.Value.Render(w)) out = append(out, valueIndent+st.Value.Render(w))
} }
} }
} }
@@ -344,7 +368,7 @@ func (d Detail) lines(st styles.Styles) []string {
if d.hidesIn != "" { if d.hidesIn != "" {
more = append(more, "hides in "+d.hidesIn) more = append(more, "hides in "+d.hidesIn)
} }
out = append(out, indent+hints("hide", more...)) out = append(out, valueIndent+hints("hide", more...))
} }
return append(out, d.versionLines(st)...) return append(out, d.versionLines(st)...)
} }
@@ -365,7 +389,7 @@ func (d Detail) versionLines(st styles.Styles) []string {
head += muted(" · " + d.VersionsKey + " to browse") head += muted(" · " + d.VersionsKey + " to browse")
} }
} }
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head} out := []string{"", fieldLabel(st, "versions") + head}
current := d.versions[0].Version current := d.versions[0].Version
for i, v := range d.versions { for i, v := range d.versions {
if i == maxVersionLines { if i == maxVersionLines {
+17
View File
@@ -0,0 +1,17 @@
package panes
import (
"strings"
"testing"
)
func TestWrappedValueKeepsItsStyle(t *testing.T) {
red := "\x1b[31m"
got := carryStyle([]string{red + "@Microsoft.KeyVault(SecretUri=https://kv", "-app-prod.vault.azure.net/secrets/x)\x1b[m", "after"})
if !strings.HasPrefix(got[1], red) {
t.Errorf("the wrapped line lost the style: %q", got[1])
}
if strings.HasPrefix(got[2], red) {
t.Errorf("the style went on past its reset: %q", got[2])
}
}
+3 -26
View File
@@ -338,17 +338,7 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
ts := TitleStyle(st, focused) ts := TitleStyle(st, focused)
title := ts.Render(l.Title) title := ts.Render(l.Title)
if len(l.Tabs) > 1 { if len(l.Tabs) > 1 {
for i, tab := range l.Tabs { title += ts.Render(" ") + Tabs(st, ts, l.Tabs, l.Tab)
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 { if q := l.input.Value(); q != "" && !l.filtering {
title += ts.Render(" /" + q) title += ts.Render(" /" + q)
@@ -379,26 +369,13 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
default: default:
for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ { 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 := l.item.Render(st, l.items[l.visible[i]], inner-1)
plain := ansi.Strip(line) line = " " + MarkMatches(line, l.highlight(i, ansi.Strip(line)), st.Match)
hits := l.highlight(i, plain)
if i == l.cursor { if i == l.cursor {
base := st.SelectedBlurred base := st.SelectedBlurred
if focused { if focused {
base = st.Selected base = st.Selected
} }
text := " " + line line = Highlight(ansi.Truncate(line, inner, "…"), base, inner)
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) body = append(body, line)
} }
+17 -8
View File
@@ -45,7 +45,9 @@ var Roles = map[string]string{
} }
// FromTheme returns the default styles with the theme's roles applied, and // FromTheme returns the default styles with the theme's roles applied, and
// the default text colour if the theme sets one. // the default text colour if the theme sets one. Roles it doesn't know are
// skipped (see Unknown): a theme file made for a newer lazysecrets still
// works.
func FromTheme(theme map[string][]string) (Styles, color.Color, error) { func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default() st := Default()
var fg color.Color var fg color.Color
@@ -56,7 +58,7 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
sort.Strings(roles) sort.Strings(roles)
for _, role := range roles { for _, role := range roles {
if _, ok := Roles[role]; !ok { if _, ok := Roles[role]; !ok {
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", ")) continue
} }
v, err := parse(theme[role]) v, err := parse(theme[role])
if err != nil { if err != nil {
@@ -107,13 +109,20 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
return st, fg, nil return st, fg, nil
} }
func sortedRoles() []string { // Unknown says which of the theme's roles aren't known, for a warning, or
var out []string // is empty.
for r := range Roles { func Unknown(theme map[string][]string) string {
out = append(out, r) var unknown []string
for r := range theme {
if _, ok := Roles[r]; !ok {
unknown = append(unknown, r)
} }
slices.Sort(out) }
return out if len(unknown) == 0 {
return ""
}
slices.Sort(unknown)
return "theme: skipped unknown " + strings.Join(unknown, ", ") + " (a typo, or from a newer lazysecrets)"
} }
// value is a parsed role: a colour (nil for default) and attributes. // value is a parsed role: a colour (nil for default) and attributes.
+13 -3
View File
@@ -44,19 +44,29 @@ func TestFromTheme(t *testing.T) {
func TestFromThemeRejectsMistakes(t *testing.T) { func TestFromThemeRejectsMistakes(t *testing.T) {
for name, theme := range map[string]map[string][]string{ for name, theme := range map[string]map[string][]string{
"role": {"activeBorder": {"red"}},
"colour": {"errorColor": {"redish"}}, "colour": {"errorColor": {"redish"}},
"hex": {"errorColor": {"#fff"}}, "hex": {"errorColor": {"#fff"}},
"palette": {"errorColor": {"300"}}, "palette": {"errorColor": {"300"}},
} { } {
if _, _, err := FromTheme(theme); err == nil { if _, _, err := FromTheme(theme); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
t.Errorf("unknown role error should list the known ones: %v", err)
} }
} }
} }
func TestUnknownRolesSkipped(t *testing.T) {
theme := map[string][]string{"activeBorder": {"red"}, "errorColor": {"red"}}
if _, _, err := FromTheme(theme); err != nil {
t.Errorf("unknown role failed the theme: %v", err)
}
if w := Unknown(theme); !strings.Contains(w, "unknown activeBorder ") || strings.Contains(w, "errorColor") {
t.Errorf("warning = %q", w)
}
if w := Unknown(map[string][]string{"errorColor": {"red"}}); w != "" {
t.Errorf("warning for known roles: %q", w)
}
}
func TestEmptyThemeIsDefault(t *testing.T) { func TestEmptyThemeIsDefault(t *testing.T) {
st, fg, err := FromTheme(nil) st, fg, err := FromTheme(nil)
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack { if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
+50 -12
View File
@@ -28,6 +28,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strings" "strings"
"time" "time"
@@ -54,17 +55,43 @@ const maxDownload = 200 << 20
// Release is a published release. // Release is a published release.
type Release struct { type Release struct {
Tag string `json:"tag_name"` Tag string `json:"tag_name"`
Body string `json:"body"` // the changelog section, then install notes // Body is the changelog section, then install notes. From Latest,
// it's the changelogs of the recent releases, each under a "## v…"
// heading, so someone a few versions behind sees all they missed.
Body string `json:"body"`
URL string `json:"html_url"` URL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
} }
// Notes is the release's changelog: its body without the install notes // Notes is the changelog since the version current: of each release
// every release ends with. // newer than it (just this one for a dev build), without the install
func (r Release) Notes() string { // notes every release ends with.
notes, _, _ := strings.Cut(r.Body, "## Install") func (r Release) Notes(current string) string {
body, _, _ := strings.Cut(r.Body, "## Install")
var keep, headings []string
in := true // before any heading: a single release's notes
for _, l := range strings.Split(body, "\n") {
if tag, ok := strings.CutPrefix(l, "## "); ok && semver.IsValid(tag) {
in = Newer(current, tag) || tag == r.Tag
if in {
headings = append(headings, l)
}
}
if in {
keep = append(keep, l)
}
}
notes := strings.Join(keep, "\n")
if len(headings) == 1 { // the title names it
notes = strings.Replace(notes, headings[0], "", 1)
}
return PlainLinks(strings.TrimSpace(notes)) 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. // PlainLinks shows Markdown links as their text, for a terminal.
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") } func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
@@ -128,18 +155,29 @@ func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
} }
ctx, cancel := context.WithTimeout(ctx, latestTimeout) ctx, cancel := context.WithTimeout(ctx, latestTimeout)
defer cancel() defer cancel()
b, err := c.get(ctx, api+"/releases/latest", 1<<20) // The recent releases in one request (per_page on GitHub, limit on
// Gitea), for their changelogs.
b, err := c.get(ctx, fmt.Sprintf("%s/releases?per_page=%d&limit=%[2]d", api, recentReleases), 1<<20)
if err != nil { if err != nil {
return Release{}, err return Release{}, err
} }
var r Release var all []Release
if err := json.Unmarshal(b, &r); err != nil { if err := json.Unmarshal(b, &all); err != nil {
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err) return Release{}, fmt.Errorf("reading the releases from %s: %w", repo, err)
} }
if !semver.IsValid(r.Tag) { all = slices.DeleteFunc(all, func(r Release) bool { return r.Draft || r.Prerelease || !semver.IsValid(r.Tag) })
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo) if len(all) == 0 {
return Release{}, fmt.Errorf("no releases on %s", repo)
} }
return r, nil slices.SortFunc(all, func(a, b Release) int { return semver.Compare(b.Tag, a.Tag) })
var notes strings.Builder
for _, r := range all {
section, _, _ := strings.Cut(r.Body, "## Install")
fmt.Fprintf(&notes, "## %s\n\n%s\n\n", r.Tag, strings.TrimSpace(section))
}
latest := all[0]
latest.Body = strings.TrimSpace(notes.String())
return latest, nil
} }
// Newer reports whether latest is newer than current. A build that isn't // Newer reports whether latest is newer than current. A build that isn't
+35 -4
View File
@@ -39,11 +39,42 @@ func TestNewer(t *testing.T) {
func TestNotes(t *testing.T) { func TestNotes(t *testing.T) {
r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"} r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"}
if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" { if got := r.Notes("0.1.1"); got != "### Added\n\n- Install with a one-liner (#7)" {
t.Errorf("notes %q", got) t.Errorf("notes %q", got)
} }
} }
// Latest reads the recent releases, so the notes cover every version
// since the one running.
func TestNotesSince(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/keb/lazysecrets/releases" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `[
{"tag_name":"v0.4.0-rc1","body":"### Added\n\n- rc","prerelease":true},
{"tag_name":"v0.2.0","body":"### Added\n\n- two\n\n## Install\n\ncurl"},
{"tag_name":"v0.3.0","body":"### Fixed\n\n- three\n\n## Install\n\ncurl","html_url":"https://example.com/v0.3.0"},
{"tag_name":"v0.1.1","body":"### Added\n\n- one"},
{"tag_name":"v9.0.0","body":"draft","draft":true}]`)
}))
t.Cleanup(srv.Close)
r, err := (&Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}).Latest(context.Background())
if err != nil || r.Tag != "v0.3.0" || r.URL != "https://example.com/v0.3.0" {
t.Fatalf("latest %+v %v", r, err)
}
for current, want := range map[string]string{
"0.1.1": "## v0.3.0\n\n### Fixed\n\n- three\n\n## v0.2.0\n\n### Added\n\n- two",
"0.2.0": "### Fixed\n\n- three",
"dev-2ba762c": "### Fixed\n\n- three",
} {
if got := r.Notes(current); got != want {
t.Errorf("notes since %s: %q, want %q", current, got, want)
}
}
}
func TestManaged(t *testing.T) { func TestManaged(t *testing.T) {
if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" { if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" {
t.Error("package managers not recognised") t.Error("package managers not recognised")
@@ -70,8 +101,8 @@ func server(t *testing.T, tamper bool) *httptest.Server {
sum[0] ^= 0xff sum[0] ^= 0xff
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`) fmt.Fprint(w, `[{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}]`)
}) })
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name) fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
@@ -173,7 +204,7 @@ func TestInstallRefusesATamperedArchive(t *testing.T) {
func TestNotesShowLinksAsText(t *testing.T) { func TestNotesShowLinksAsText(t *testing.T) {
r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"} r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"}
if got, want := r.Notes(), "### Added\n\n- Search (#11) (65dc2d7)"; got != want { if got, want := r.Notes("0.1.1"), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
t.Errorf("notes %q, want %q", got, want) t.Errorf("notes %q, want %q", got, want)
} }
} }