Compare commits
24
Commits
v0.2.0
..
db8ce90ee7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8ce90ee7
|
||
|
|
40b90df68c
|
||
|
|
8757284b0c
|
||
|
|
7d6a095e42
|
||
|
|
2a3379b1c1
|
||
|
|
61de5e616d
|
||
|
|
25dbb38842
|
||
|
|
0757898d61
|
||
|
|
80332680e5
|
||
|
|
027e748c92
|
||
|
|
166790d825
|
||
|
|
777c8cb532
|
||
|
|
86fb79a915
|
||
|
|
8d65b60e69
|
||
|
|
176600be54
|
||
|
|
5c97a8e249
|
||
|
|
8cd0003f8c
|
||
|
|
cd265a632e
|
||
|
|
0bfa2a3141
|
||
|
|
b46991c7c2
|
||
|
|
68fc76d938
|
||
|
|
bc691d786a
|
||
|
|
4d57c19772
|
||
|
|
4b2f685921
|
No files matched your search
@@ -1,2 +0,0 @@
|
||||
<1+keb@noreply.git.kebcloud.no> <1+keb@noreply.localhost>
|
||||
<3+renovate-bot@noreply.git.kebcloud.no> <3+renovate-bot@noreply.localhost>
|
||||
@@ -2,38 +2,6 @@
|
||||
|
||||
Every release of lazysecrets, newest first.
|
||||
|
||||
## v0.2.0 – 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers ([#70](https://git.kebcloud.no/keb/lazysecrets/pulls/70)) ([39454d4](https://git.kebcloud.no/keb/lazysecrets/commit/39454d47a864c8628c50b023aee9539d33be2f44))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Lazysecrets starts from fzf's become, on a non-blocking terminal ([#67](https://git.kebcloud.no/keb/lazysecrets/pulls/67)) ([4480e8f](https://git.kebcloud.no/keb/lazysecrets/commit/4480e8fed00024691091eb7096abb9dc83b8044d))
|
||||
- Local builds are dev-<commit> again, not a Go pseudo-version ([#68](https://git.kebcloud.no/keb/lazysecrets/pulls/68)) ([e825a51](https://git.kebcloud.no/keb/lazysecrets/commit/e825a51b30261b55c536af18fefde79dc6308377))
|
||||
- A wrapped value in the details keeps its colour on every line ([#69](https://git.kebcloud.no/keb/lazysecrets/pulls/69)) ([dbb0eb2](https://git.kebcloud.no/keb/lazysecrets/commit/dbb0eb2bbdf6d695cf0d9ac15a1b513af8ba431d))
|
||||
|
||||
## v0.1.2 – 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- Look for repos in the usual folders, not just ~/git ([#57](https://git.kebcloud.no/keb/lazysecrets/pulls/57)) ([47ef862](https://git.kebcloud.no/keb/lazysecrets/commit/47ef86293d757f965069b1ea100a4d53d5a9b876))
|
||||
- Config grouped by area, with per-provider timeouts and an update interval ([#60](https://git.kebcloud.no/keb/lazysecrets/pulls/60)) ([10d1fc6](https://git.kebcloud.no/keb/lazysecrets/commit/10d1fc63c75fbb91088119a2e81884e44c36c4a3))
|
||||
- The update changelog covers every version you missed ([#61](https://git.kebcloud.no/keb/lazysecrets/pulls/61)) ([75bab25](https://git.kebcloud.no/keb/lazysecrets/commit/75bab2528677086219817ff9bc08a36755c96dcd))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Help and errors name sharing.key and sharing.keyRules, not share.* ([#64](https://git.kebcloud.no/keb/lazysecrets/pulls/64)) ([6b3edd4](https://git.kebcloud.no/keb/lazysecrets/commit/6b3edd45d83b9b2af06a40f1f7e67f35c6861a69))
|
||||
- Unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one ([#66](https://git.kebcloud.no/keb/lazysecrets/pulls/66)) ([a81fcaa](https://git.kebcloud.no/keb/lazysecrets/commit/a81fcaa5a6cef3ce0d07d6195f3e7bbb98808253))
|
||||
|
||||
## v0.1.1 – 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- `e` on the status pane edits the config ([#55](https://git.kebcloud.no/keb/lazysecrets/pulls/55)) ([2044341](https://git.kebcloud.no/keb/lazysecrets/commit/2044341b20d795fa7cadcabb9514631990993e0a))
|
||||
- Share env files as encrypted links ([#54](https://git.kebcloud.no/keb/lazysecrets/pulls/54)) ([4377eb9](https://git.kebcloud.no/keb/lazysecrets/commit/4377eb98bf0dd523d5c4371bf84668412053290e))
|
||||
|
||||
## v0.1.0 – 2026-10-12
|
||||
|
||||
Hello world 👋
|
||||
|
||||
@@ -12,7 +12,10 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
||||
CLI, `sops`, `git`). No config file needed.
|
||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||
fzf-style matching.
|
||||
- **Share links**: Share `lazysecrets://` links with colleagues.
|
||||
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
||||
with their own access, from the terminal or with a click in the browser
|
||||
or chat. `.env` files can be shared too, as a link encrypted with a
|
||||
secret your team can read.
|
||||
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
||||
- **Careful with values**: values stay hidden until you reveal them, and
|
||||
copied values are cleared from the clipboard after 30 seconds.
|
||||
@@ -31,20 +34,18 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
||||
## Install
|
||||
|
||||
On macOS or Linux (arm64 or x86_64):
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/karleinarb/lazysecrets/main/install.sh | sh
|
||||
```
|
||||
Or
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
Both approaches will install the latest release from [GitHub](https://github.com/karleinarb/lazysecrets/releases) with fallback to
|
||||
It installs the latest release, from GitHub or else
|
||||
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
|
||||
`~/.local/bin` after checking it against the release's checksums.
|
||||
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
|
||||
somewhere else.
|
||||
|
||||
|
||||
lazysecrets looks for a newer release once a day: the footer then shows
|
||||
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
|
||||
`lazysecrets update`).
|
||||
@@ -77,47 +78,29 @@ use most:
|
||||
| `?` | every key, searchable |
|
||||
| `q` | quit |
|
||||
|
||||
|
||||
## Integration
|
||||
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
|
||||
|
||||
Here is an example of implementing a picker in `fzf`:
|
||||
```sh
|
||||
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
|
||||
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
|
||||
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
|
||||
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
|
||||
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
||||
```
|
||||
|
||||

|
||||
|
||||
More defails in the [Integration](docs/integration.md) docs
|
||||
|
||||
## Docs
|
||||
|
||||
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
||||
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
||||
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
||||
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
|
||||
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
|
||||
opening links, also from the browser and chat
|
||||
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
|
||||
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts,
|
||||
and opening links from the browser and chat
|
||||
- [Custom commands](docs/custom-commands.md): run your own commands on a
|
||||
secret
|
||||
- [Config](docs/config.md): every option, themes, layered configs, the
|
||||
state file
|
||||
- [Development](docs/development.md): building, tests, how changes land and
|
||||
- [Development](docs/development.md): building, tests, pull requests and
|
||||
releasing
|
||||
|
||||
## Privacy
|
||||
|
||||
lazysecrets never stores, caches or sends your secret values. It keeps the
|
||||
names and metadata of what you've loaded in a cache file readable only by
|
||||
you, to show it at once next time (`storage.cache: false` turns it off). It talks
|
||||
you, to show it at once next time (`cache: false` turns it off). It talks
|
||||
only to the services you use it with, and collects no usage data. The one
|
||||
call of its own is a daily check for a new release, on GitHub or else
|
||||
git.kebcloud.no, which `updates.autoCheck: false` turns off.
|
||||
git.kebcloud.no, which `updateCheck: false` turns off.
|
||||
|
||||
## Repositories
|
||||
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
# Security
|
||||
|
||||
Please report a vulnerability privately, not in a public issue:
|
||||
|
||||
- on GitHub, with **Report a vulnerability** under the
|
||||
[Security tab](https://github.com/karleinarb/lazysecrets/security), or
|
||||
- by email to karl-einarb@outlook.com.
|
||||
|
||||
Say what's affected, how to reproduce it and the version
|
||||
(`lazysecrets --version`). Fixes go into the next release, and only the
|
||||
latest release gets them.
|
||||
@@ -51,8 +51,6 @@ commit_preprocessors = [
|
||||
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
|
||||
# #11 was squash-merged with #10 (the status pane) in it: name both.
|
||||
{ pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" },
|
||||
# #55's title starts with a key, which capitalised reads as another.
|
||||
{ pattern = "^feat: e on the status pane edits the config", replace = "feat: `e` on the status pane edits the config" },
|
||||
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
|
||||
# pr-title.yml); all become "feat: x".
|
||||
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
|
||||
|
||||
@@ -37,15 +37,10 @@ func value(ctx context.Context, providers []provider.Provider, st *store.Store,
|
||||
return v, err
|
||||
}
|
||||
|
||||
// readKey is value, also returning the version read. The account asked
|
||||
// for (--account) goes first, else the one that last read it.
|
||||
// readKey is value, also returning the version read.
|
||||
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
||||
key := r
|
||||
key.Version = ""
|
||||
node := st.Node(key.String())
|
||||
account, source := node.Account, node.Source
|
||||
if r.Account != "" && r.Account != account {
|
||||
account, source = r.Account, ""
|
||||
}
|
||||
return ref.Read(ctx, providers, r, account, source)
|
||||
return ref.Read(ctx, providers, r, node.Account, node.Source)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/listing"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// listCmd prints every source, vault and secret lazysecrets can find, one
|
||||
// per line, for fzf, editor pickers and scripts:
|
||||
//
|
||||
// lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1
|
||||
//
|
||||
// Each line is the kind, a mark (✗ or ⚠, ★, ◷ and the provider's icon),
|
||||
// the name, a status (why it couldn't be read, or that it has expired or
|
||||
// expires soon; else empty), its note (muted, or empty), the account and
|
||||
// the link, tab-separated. The mark has a field of its own so a search can
|
||||
// leave it out (fzf --nth), and ^prod finds names starting with prod. The
|
||||
// name is coloured from the theme,
|
||||
// with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS,
|
||||
// ui.icons); the link never is.
|
||||
//
|
||||
// What's cached prints at once; what's missing or older than --max-age
|
||||
// follows as it loads. A list that can't be read is a line too, as it
|
||||
// fails: the vault or source, then ✗ and why in the theme's error colour.
|
||||
// They're in the output rather than on stderr, which would draw over fzf.
|
||||
func listCmd(providers []provider.Provider, st *store.Store, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons, withRecent bool, expiryWarn time.Duration, args []string, out io.Writer) error {
|
||||
fs := flag.NewFlagSet("list", flag.ContinueOnError)
|
||||
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks")
|
||||
maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again")
|
||||
format := fs.String("format", "text", "text: kind, mark, name, status, note, account and link, tab-separated; json: an object per line")
|
||||
color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi")
|
||||
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
|
||||
stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)")
|
||||
recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)")
|
||||
notes := fs.Bool("notes", true, "start a note with a note icon (✎ without --icons) (text; the note is printed either way)")
|
||||
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
|
||||
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
|
||||
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
return fmt.Errorf("usage: lazysecrets list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]")
|
||||
}
|
||||
var only []string
|
||||
for k := range strings.SplitSeq(*kinds, ",") {
|
||||
if k = strings.TrimSpace(k); k == "" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(listing.Kinds, k) {
|
||||
return fmt.Errorf("--kind: %q is not one of %s", k, strings.Join(listing.Kinds, ", "))
|
||||
}
|
||||
only = append(only, k)
|
||||
}
|
||||
if *color != "always" && *color != "never" {
|
||||
return fmt.Errorf("--color: %q is not always or never", *color)
|
||||
}
|
||||
now := time.Now()
|
||||
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry,
|
||||
color: *color == "always", warn: expiryWarn, now: now}
|
||||
if *iconsOn {
|
||||
m.icons = icons.NerdFont
|
||||
}
|
||||
var write func(listing.Entry) error
|
||||
switch *format {
|
||||
case "text":
|
||||
write = func(e listing.Entry) error {
|
||||
colored := *color == "always"
|
||||
mark := m.of(e)
|
||||
name := nameOf(e, theme, colored)
|
||||
// Why it couldn't be read (vaults, sources), or when it
|
||||
// expires (secrets, keys, certificates): never both.
|
||||
status := errorOf(e, theme, colored)
|
||||
if status == "" && *expiry {
|
||||
status = expiryOf(e, theme, expiryWarn, now, colored)
|
||||
}
|
||||
note := noteOf(e, theme, m.icons, *notes, colored)
|
||||
// A tab or line break in a name would break the line up.
|
||||
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(status),
|
||||
oneLine.Replace(note), e.Account, e.Link)
|
||||
return err
|
||||
}
|
||||
case "json":
|
||||
enc := json.NewEncoder(out)
|
||||
enc.SetEscapeHTML(false)
|
||||
write = func(e listing.Entry) error { return enc.Encode(e) }
|
||||
default:
|
||||
return fmt.Errorf("--format: %q is not text or json", *format)
|
||||
}
|
||||
|
||||
// When the reader goes away (a pick in fzf), writing fails instead of
|
||||
// killing the process, so what loaded is still saved.
|
||||
signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
|
||||
var gone error
|
||||
emit := func(e listing.Entry) error {
|
||||
if e.Error != "" && !*showErrors {
|
||||
return nil
|
||||
}
|
||||
if err := write(e); err != nil {
|
||||
gone = err
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
f := cachefile.New()
|
||||
if cachePath != "" {
|
||||
f = cachefile.Read(cachePath)
|
||||
}
|
||||
f, loaded := listing.Run(context.Background(), listing.Options{
|
||||
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
|
||||
Mark: func(link, account string) listing.Marks {
|
||||
n := st.Node(link)
|
||||
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
|
||||
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
|
||||
},
|
||||
}, emit)
|
||||
// Only what was loaded: list never moves a cursor, so the TUI's stay.
|
||||
if cachePath != "" && loaded {
|
||||
f.Cursor = nil
|
||||
if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if gone != nil && !errors.Is(gone, syscall.EPIPE) {
|
||||
return gone
|
||||
}
|
||||
return nil // or the reader has what it wanted
|
||||
}
|
||||
|
||||
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
|
||||
|
||||
// noteOf is the entry's note, muted, after a note icon (✎ without Nerd
|
||||
// Font icons) with icon; or "".
|
||||
func noteOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, icon, color bool) string {
|
||||
if e.Note == "" {
|
||||
return ""
|
||||
}
|
||||
note := e.Note
|
||||
if icon {
|
||||
note = cmp.Or(iconSet.Note, "✎") + " " + note
|
||||
}
|
||||
return renderer(color)(theme.Muted, note)
|
||||
}
|
||||
|
||||
// expiryOf says that a secret, key or certificate has expired, in red,
|
||||
// or when it will if that's within warn (expires in 12d), in yellow, or
|
||||
// is "". Its mark (✗, ⚠) is before the name.
|
||||
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
|
||||
render := renderer(color)
|
||||
switch state, left := expiry.Of(e.Expires, now, warn); state {
|
||||
case expiry.Expired:
|
||||
return render(theme.Error, "expired")
|
||||
case expiry.Soon:
|
||||
return render(theme.Warning, "expires in "+expiry.Left(left))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
|
||||
// is before the name.
|
||||
func errorOf(e listing.Entry, theme styles.Styles, color bool) string {
|
||||
return renderer(color)(theme.Error, e.Error)
|
||||
}
|
||||
|
||||
// nameOf is an entry's name for the text format: the path with the
|
||||
// item's own name standing out, and the account.
|
||||
func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
|
||||
render := renderer(color)
|
||||
var name string
|
||||
if n := len(e.Path); n > 0 {
|
||||
if n > 1 {
|
||||
name = render(theme.Muted, strings.Join(e.Path[:n-1], " / ")+" / ")
|
||||
}
|
||||
name += e.Path[n-1]
|
||||
}
|
||||
if e.AccountLabel != "" {
|
||||
name += render(theme.Muted, " · "+e.AccountLabel)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// marks says what goes before the name, in a field of its own so a
|
||||
// search leaves it out: ✗ in red when something's wrong (it couldn't be
|
||||
// read, or has expired) or ⚠ when it expires within warn; ★ for a starred
|
||||
// entry and ◷ for one used lately, as the TUI marks them; then the
|
||||
// provider's icon. The marks are right-aligned in room for every kind
|
||||
// turned on, so they sit by the icon and names line up.
|
||||
type marks struct {
|
||||
theme styles.Styles
|
||||
icons icons.Set
|
||||
stars, recent, expiry, color bool
|
||||
warn time.Duration // ui.expiryWarningDays
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (m marks) of(e listing.Entry) string {
|
||||
render := renderer(m.color)
|
||||
var parts []string
|
||||
room := 0 // marks turned on
|
||||
mark := func(on bool, st lipgloss.Style, s string) {
|
||||
room++
|
||||
if on {
|
||||
parts = append(parts, render(st, s))
|
||||
}
|
||||
}
|
||||
// What's wrong, then what to watch: room for one of them.
|
||||
state := expiry.None
|
||||
if m.expiry {
|
||||
state, _ = expiry.Of(e.Expires, m.now, m.warn)
|
||||
}
|
||||
switch {
|
||||
case e.Error != "" || state == expiry.Expired:
|
||||
mark(true, m.theme.Error, "✗")
|
||||
case state == expiry.Soon:
|
||||
mark(true, m.theme.Warning, "⚠")
|
||||
default:
|
||||
mark(false, m.theme.Error, "")
|
||||
}
|
||||
if m.stars {
|
||||
mark(e.Starred, m.theme.Favourite, "★")
|
||||
}
|
||||
if m.recent {
|
||||
mark(e.Recent, m.theme.Muted, "◷")
|
||||
}
|
||||
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
|
||||
kind := provider.Kind("")
|
||||
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
|
||||
if strings.EqualFold(string(k), e.Provider) {
|
||||
kind = k
|
||||
}
|
||||
}
|
||||
if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" {
|
||||
parts = append(parts, render(m.theme.Kind(kind), icon))
|
||||
} else if len(parts) == 0 {
|
||||
pad = strings.TrimSuffix(pad, " ") // no mark after the room
|
||||
}
|
||||
return pad + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func renderer(color bool) func(lipgloss.Style, string) string {
|
||||
return func(st lipgloss.Style, s string) string {
|
||||
if !color || s == "" {
|
||||
return s
|
||||
}
|
||||
return st.Render(s)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/listing"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func TestListNames(t *testing.T) {
|
||||
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, AccountLabel: "alice"}
|
||||
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-app-prod / db-password · alice" {
|
||||
t.Errorf("plain: %q", got)
|
||||
}
|
||||
// The item's own name isn't muted with its path.
|
||||
if got := nameOf(e, styles.Default(), true); !strings.Contains(got, "\x1b[mdb-password") {
|
||||
t.Errorf("name not set apart: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarks(t *testing.T) {
|
||||
az := icons.NerdFont.Azure
|
||||
plain := marks{theme: styles.Default(), icons: icons.NerdFont, stars: true}
|
||||
noIcons := plain
|
||||
noIcons.icons = icons.None
|
||||
colored := noIcons
|
||||
colored.color = true
|
||||
for _, c := range []struct {
|
||||
m marks
|
||||
e listing.Entry
|
||||
want string
|
||||
}{
|
||||
// Room for the status mark and the star, so names line up.
|
||||
{plain, listing.Entry{Provider: "azure", Starred: true}, " ★ " + az},
|
||||
{plain, listing.Entry{Provider: "azure"}, " " + az},
|
||||
{marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, " " + az},
|
||||
{noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"},
|
||||
{marks{}, listing.Entry{Provider: "azure", Starred: true}, " "},
|
||||
{colored, listing.Entry{Provider: "azure", Starred: true}, " " + styles.Default().Favourite.Render("★")},
|
||||
} {
|
||||
if got := c.m.of(c.e); got != c.want {
|
||||
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRecentMarks(t *testing.T) {
|
||||
az := icons.NerdFont.Azure
|
||||
m := marks{icons: icons.NerdFont, stars: true, recent: true}
|
||||
for _, c := range []struct {
|
||||
e listing.Entry
|
||||
want string
|
||||
}{
|
||||
// Right-aligned in room for three: by the icon, names lined up.
|
||||
{listing.Entry{Provider: "azure", Starred: true, Recent: true}, " ★ ◷ " + az},
|
||||
{listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az},
|
||||
{listing.Entry{Provider: "azure"}, " " + az},
|
||||
} {
|
||||
if got := m.of(c.e); got != c.want {
|
||||
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListExpiry(t *testing.T) {
|
||||
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
||||
warn := 30 * 24 * time.Hour
|
||||
for _, c := range []struct {
|
||||
expires time.Time
|
||||
want string
|
||||
}{
|
||||
{time.Time{}, ""},
|
||||
{now.Add(-time.Hour), "expired"},
|
||||
{now.Add(12*24*time.Hour + time.Hour), "expires in 12d"},
|
||||
{now.Add(5 * time.Hour), "expires in 5h"},
|
||||
{now.Add(60 * 24 * time.Hour), ""},
|
||||
} {
|
||||
if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want {
|
||||
t.Errorf("expires %v: %q, want %q", c.expires, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("expired") {
|
||||
t.Errorf("expired isn't red: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNotes(t *testing.T) {
|
||||
e := listing.Entry{Note: "rotate in May"}
|
||||
if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" {
|
||||
t.Errorf("icons: %q", got)
|
||||
}
|
||||
if got := noteOf(e, styles.Default(), icons.None, true, false); got != "✎ rotate in May" {
|
||||
t.Errorf("no icons: %q", got)
|
||||
}
|
||||
if got := noteOf(e, styles.Default(), icons.NerdFont, false, false); got != "rotate in May" {
|
||||
t.Errorf("--notes=false: %q", got)
|
||||
}
|
||||
if got := noteOf(listing.Entry{}, styles.Default(), icons.NerdFont, true, true); got != "" {
|
||||
t.Errorf("no note: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListStatusMark(t *testing.T) {
|
||||
az := icons.NerdFont.Azure
|
||||
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
||||
m := marks{icons: icons.NerdFont, stars: true, expiry: true, warn: 30 * 24 * time.Hour, now: now}
|
||||
for _, c := range []struct {
|
||||
e listing.Entry
|
||||
want string
|
||||
}{
|
||||
// Right-aligned in room for two, as with ★ and ◷.
|
||||
{listing.Entry{Provider: "azure", Error: "403 Forbidden"}, " ✗ " + az},
|
||||
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az},
|
||||
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour), Starred: true}, "✗ ★ " + az},
|
||||
{listing.Entry{Provider: "azure", Expires: now.Add(5 * 24 * time.Hour)}, " ⚠ " + az},
|
||||
{listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour), Starred: true}, " ★ " + az},
|
||||
} {
|
||||
if got := m.of(c.e); got != c.want {
|
||||
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
|
||||
}
|
||||
}
|
||||
m.expiry = false // --expiry=false: no ⚠ or ✗ for expiry; errors still
|
||||
if got := m.of(listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}); got != " "+az {
|
||||
t.Errorf("--expiry=false: %q", got)
|
||||
}
|
||||
m.expiry, m.color, m.theme = true, true, styles.Default()
|
||||
if got := m.of(listing.Entry{Error: "403"}); !strings.Contains(got, styles.Default().Error.Render("✗")) {
|
||||
t.Errorf("not a red ✗: %q", got)
|
||||
}
|
||||
if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) {
|
||||
t.Errorf("not a yellow ⚠: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListErrors(t *testing.T) {
|
||||
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"}
|
||||
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-restricted" {
|
||||
t.Errorf("the name has more: %q", got)
|
||||
}
|
||||
if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" {
|
||||
t.Errorf("plain: %q", got)
|
||||
}
|
||||
if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("403 Forbidden") {
|
||||
t.Errorf("why isn't red: %q", got)
|
||||
}
|
||||
if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" {
|
||||
t.Errorf("no error: %q", got)
|
||||
}
|
||||
}
|
||||
+37
-129
@@ -3,9 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -89,32 +87,6 @@ func cmpOr(a, b string) string {
|
||||
return b
|
||||
}
|
||||
|
||||
// refArg parses get's and open's arguments: a reference, and optionally
|
||||
// the account to use when several could (lazysecrets list's column) and,
|
||||
// for open, what to do with it rather than asking (--action).
|
||||
func refArg(cmd string, args []string) (r ref.Ref, action string, err error) {
|
||||
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
||||
account := fs.String("account", "", "the account to use when several could, as lazysecrets list prints it")
|
||||
usage := "usage: lazysecrets get [--account <account>] <ref>"
|
||||
act := new(string)
|
||||
if cmd == "open" {
|
||||
act = fs.String("action", "", "do this rather than ask: go (to it), copy (go, and copy its value) or show (go, and show it)")
|
||||
usage = "usage: lazysecrets open [--account <account>] [--action go|copy|show] <ref>"
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return r, "", err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
return r, "", errors.New(usage)
|
||||
}
|
||||
if _, ok := app.OpenActions[*act]; *act != "" && !ok {
|
||||
return r, "", fmt.Errorf("--action: %q is not go, copy or show", *act)
|
||||
}
|
||||
r, err = ref.Parse(fs.Arg(0))
|
||||
r.Account = *account
|
||||
return r, *act, err
|
||||
}
|
||||
|
||||
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
|
||||
// config directory.
|
||||
func defaultConfigPath() string {
|
||||
@@ -134,28 +106,22 @@ func run() error {
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
|
||||
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request, every provider's (overrides the config)")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
|
||||
icons := flag.Bool("icons", true,
|
||||
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
|
||||
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||
lazysecrets [flags] browse secrets
|
||||
lazysecrets [flags] [open [--account <account>] [--action go|copy|show]] <ref>
|
||||
lazysecrets [flags] [open] <ref>
|
||||
open a lazysecrets:// link (also a share link),
|
||||
Key Vault URL, @Microsoft.KeyVault(...) or
|
||||
{{secrets/scope/key}}
|
||||
lazysecrets [flags] get [--account <account>] <ref>
|
||||
print the value a lazysecrets:// link points at,
|
||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||
for scripts: VAR=$(lazysecrets get <ref>)
|
||||
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
|
||||
[--color always|never] [--icons=false] [--errors=false]
|
||||
print every source, vault and secret it can
|
||||
find, one per line, for fzf and pickers:
|
||||
what's cached at once, then what loads
|
||||
lazysecrets [flags] share [--key <secret>] <env file>...
|
||||
print a link to env files of a repo, encrypted
|
||||
with a secret's value (default: sharing.key)
|
||||
with a secret's value (default: share.key)
|
||||
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||
config applied), as config to copy and edit
|
||||
lazysecrets handler install [--terminal NAME]
|
||||
@@ -178,8 +144,7 @@ Flags:
|
||||
}
|
||||
|
||||
var open, getRef *ref.Ref
|
||||
var openAction string
|
||||
var shareArgs, listArgs []string
|
||||
var shareArgs []string
|
||||
printKeys := false
|
||||
switch args := flag.Args(); {
|
||||
case len(args) > 0 && args[0] == "share":
|
||||
@@ -187,12 +152,10 @@ Flags:
|
||||
if len(shareArgs) == 0 {
|
||||
shareArgs = []string{} // still sharing: says how
|
||||
}
|
||||
case len(args) > 0 && args[0] == "list":
|
||||
listArgs = append([]string{}, args[1:]...) // not nil: listing
|
||||
case len(args) == 1 && args[0] == "keybindings":
|
||||
printKeys = true
|
||||
case len(args) > 0 && args[0] == "get":
|
||||
r, _, err := refArg("get", args[1:])
|
||||
case len(args) == 2 && args[0] == "get":
|
||||
r, err := ref.Parse(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -204,12 +167,12 @@ Flags:
|
||||
case len(args) > 0 && args[0] == "update":
|
||||
return updateCmd(*configPath, args[1:])
|
||||
case len(args) == 0:
|
||||
case args[0] == "open" && len(args) >= 2:
|
||||
r, act, err := refArg("open", args[1:])
|
||||
case args[0] == "open" && len(args) == 2:
|
||||
r, err := ref.Parse(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open, openAction = &r, act
|
||||
open = &r
|
||||
case len(args) == 1 && looksLikeRef(args[0]):
|
||||
// lazysecrets <link>, like lazysecrets open <link>
|
||||
r, err := ref.Parse(args[0])
|
||||
@@ -230,11 +193,6 @@ Flags:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Warnings show when the window opens.
|
||||
var warnings []string
|
||||
if w := styles.Unknown(cfg.Theme); w != "" {
|
||||
warnings = append(warnings, w)
|
||||
}
|
||||
keymap := keys.Default()
|
||||
if err := keymap.Apply(cfg.Keybinding); err != nil {
|
||||
return err
|
||||
@@ -249,57 +207,33 @@ Flags:
|
||||
// Precedence: flag > environment > config file > default.
|
||||
set := map[string]bool{}
|
||||
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
|
||||
if *timeout <= 0 {
|
||||
return fmt.Errorf("--timeout: %s is not positive", *timeout)
|
||||
if !set["timeout"] && cfg.Timeout > 0 {
|
||||
*timeout = cfg.Timeout
|
||||
}
|
||||
// Each provider's own timeout, unless --timeout sets them all.
|
||||
timeouts := map[provider.Kind]time.Duration{}
|
||||
if !set["timeout"] {
|
||||
if cfg.Providers.Timeout > 0 {
|
||||
*timeout = cfg.Providers.Timeout
|
||||
}
|
||||
for k, d := range map[provider.Kind]time.Duration{
|
||||
provider.KindAzure: cfg.Providers.Azure.Timeout,
|
||||
provider.KindDatabricks: cfg.Providers.Databricks.Timeout,
|
||||
provider.KindRepo: cfg.Providers.Repos.Timeout,
|
||||
} {
|
||||
if d > 0 {
|
||||
timeouts[k] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
// timeoutFor is how long a request to a provider of kind k may take.
|
||||
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
|
||||
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
|
||||
*clipClear = *cfg.Values.ClipboardClear
|
||||
if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
|
||||
*clipClear = *cfg.ClipboardClear
|
||||
}
|
||||
expiryDays := 30
|
||||
if cfg.UI.ExpiryWarningDays != nil {
|
||||
expiryDays = *cfg.UI.ExpiryWarningDays
|
||||
if cfg.ExpiryWarningDays != nil {
|
||||
expiryDays = *cfg.ExpiryWarningDays
|
||||
}
|
||||
revealTimeout := 60 * time.Second
|
||||
if cfg.Values.RevealTimeout != nil {
|
||||
revealTimeout = *cfg.Values.RevealTimeout
|
||||
if cfg.RevealTimeout != nil {
|
||||
revealTimeout = *cfg.RevealTimeout
|
||||
}
|
||||
loadDelay := 150 * time.Millisecond
|
||||
if cfg.UI.LoadDelay != nil {
|
||||
loadDelay = *cfg.UI.LoadDelay
|
||||
}
|
||||
var updateEvery time.Duration // 0: the app's default, a day
|
||||
if cfg.Updates.AutoCheckInterval != nil {
|
||||
updateEvery = *cfg.Updates.AutoCheckInterval
|
||||
if cfg.LoadDelay != nil {
|
||||
loadDelay = *cfg.LoadDelay
|
||||
}
|
||||
if !set["icons"] {
|
||||
switch {
|
||||
case os.Getenv("LAZYSECRETS_ICONS") != "":
|
||||
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
|
||||
case cfg.UI.Icons != nil:
|
||||
*icons = *cfg.UI.Icons
|
||||
case cfg.Icons != nil:
|
||||
*icons = *cfg.Icons
|
||||
}
|
||||
}
|
||||
|
||||
recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon
|
||||
|
||||
// Stars, notes and the layout. Demo mode keeps them in memory, so
|
||||
// sample vaults never end up in the real file.
|
||||
stateDir, err := config.StateDir()
|
||||
@@ -311,23 +245,16 @@ Flags:
|
||||
statePath = filepath.Join(stateDir, "state.json")
|
||||
}
|
||||
st, err := store.Load(statePath)
|
||||
if errors.Is(err, store.ErrNewer) {
|
||||
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var providers []provider.Provider
|
||||
if *demo {
|
||||
// Two Azure accounts, like a personal and an admin login in az.
|
||||
alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
|
||||
fake.Seed(st, alice, admin)
|
||||
az := fake.Azure(400*time.Millisecond).As(alice, "Azure · alice")
|
||||
// Due soon whenever the demo runs (the tests' dates are fixed).
|
||||
az.Expire("kv-app-prod", "api-key", time.Now().AddDate(0, 0, 9).Truncate(24*time.Hour))
|
||||
providers = []provider.Provider{
|
||||
az,
|
||||
fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
|
||||
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
|
||||
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
|
||||
fake.Databricks(600 * time.Millisecond),
|
||||
fake.Repos(),
|
||||
}
|
||||
@@ -360,7 +287,7 @@ Flags:
|
||||
if start == "" {
|
||||
start, _ = os.Getwd()
|
||||
}
|
||||
roots := repo.DefaultRoots
|
||||
roots := []string{"~/git"}
|
||||
if pc.Repos.Roots != nil {
|
||||
roots = *pc.Repos.Roots
|
||||
}
|
||||
@@ -383,27 +310,14 @@ Flags:
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
if getRef != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
return get(ctx, providers, st, *getRef)
|
||||
}
|
||||
if listArgs != nil {
|
||||
cachePath := ""
|
||||
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
|
||||
cachePath = filepath.Join(d, "cache.json")
|
||||
}
|
||||
return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout)
|
||||
}
|
||||
if shareArgs != nil {
|
||||
// The files from a repo, the key from whichever provider it's
|
||||
// in: the longest timeout.
|
||||
d := *timeout
|
||||
for _, t := range timeouts {
|
||||
d = max(d, t)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
|
||||
return shareFiles(ctx, providers, st, cfg.Share.Key, shareRules(cfg), shareArgs, os.Stdout)
|
||||
}
|
||||
|
||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||
@@ -440,7 +354,7 @@ Flags:
|
||||
// What was loaded last time, and where it was left in this folder.
|
||||
var cacheFile, placeDir string
|
||||
if !*demo {
|
||||
if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
|
||||
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
|
||||
cacheFile = filepath.Join(d, "cache.json")
|
||||
}
|
||||
placeDir = *dir
|
||||
@@ -454,40 +368,34 @@ Flags:
|
||||
|
||||
m := app.New(providers, app.Options{
|
||||
Timeout: *timeout,
|
||||
Timeouts: timeouts,
|
||||
Icons: *icons,
|
||||
ClipboardClear: *clipClear,
|
||||
FlatSources: cfg.UI.SourcesView == "flat",
|
||||
CollapseGroups: config.On(cfg.UI.CollapseGroups),
|
||||
FlatSources: cfg.SourcesView == "flat",
|
||||
CollapseGroups: config.On(cfg.CollapseGroups),
|
||||
LoadDelay: loadDelay,
|
||||
NoHistory: !config.On(cfg.Storage.History),
|
||||
NoHistory: !config.On(cfg.History),
|
||||
OnActive: onActive,
|
||||
RevealTimeout: revealTimeout,
|
||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||
HideOnBlur: config.On(cfg.Values.HideOnBlur),
|
||||
HideOnBlur: config.On(cfg.HideOnBlur),
|
||||
HandlerSetup: setup,
|
||||
Updater: updater(cfg.Updates.Sources),
|
||||
UpdateCheck: config.On(cfg.Updates.AutoCheck),
|
||||
UpdateEvery: updateEvery,
|
||||
Updater: updater(cfg.Releases),
|
||||
UpdateCheck: config.On(cfg.UpdateCheck),
|
||||
Keys: &keymap,
|
||||
Commands: cfg.CustomCommands,
|
||||
Version: versionString(),
|
||||
Store: st,
|
||||
Warning: strings.Join(warnings, " · "),
|
||||
CacheFile: cacheFile,
|
||||
ConfigFiles: configFiles(*configPath),
|
||||
StatePath: statePath,
|
||||
Changelog: lazysecrets.Changelog,
|
||||
Dir: placeDir,
|
||||
ShareKey: cfg.Sharing.Key,
|
||||
ShareKey: cfg.Share.Key,
|
||||
ShareRules: shareRules(cfg),
|
||||
Open: open,
|
||||
OpenAction: openAction,
|
||||
RecentIcon: recentIcon,
|
||||
Styles: &theme,
|
||||
DefaultFg: defaultFg,
|
||||
})
|
||||
blockingStdin()
|
||||
p = tea.NewProgram(m)
|
||||
final, err := p.Run()
|
||||
if fm, ok := final.(app.Model); ok {
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
)
|
||||
|
||||
// shareFiles prints a share link to env files of one repo, encrypted with
|
||||
// the --key secret's value, else the repo's or the config's sharing.key:
|
||||
// the --key secret's value, else the repo's or the config's share.key:
|
||||
//
|
||||
// lazysecrets share .env apps/api/.env
|
||||
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error {
|
||||
fs := flag.NewFlagSet("share", flag.ContinueOnError)
|
||||
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: sharing.key)")
|
||||
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: share.key)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("which secret to encrypt with? --key <secret>, or sharing.key in the repo's .lazysecrets.yaml or the config")
|
||||
return errors.New("which secret to encrypt with? --key <secret>, or share.key in the repo's .lazysecrets.yaml or the config")
|
||||
}
|
||||
v, version, err := readKey(ctx, providers, st, key)
|
||||
if err != nil {
|
||||
@@ -87,7 +87,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
|
||||
return err
|
||||
}
|
||||
|
||||
// shareRules are the config's sharing.keyRules.
|
||||
// shareRules are the config's share.keyRules.
|
||||
func shareRules(cfg config.Config) share.Rules {
|
||||
return share.Rules{MinLength: cfg.Sharing.KeyRules.MinLength, MinClasses: cfg.Sharing.KeyRules.MinClasses}
|
||||
return share.Rules{MinLength: cfg.Share.KeyRules.MinLength, MinClasses: cfg.Share.KeyRules.MinClasses}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build !unix
|
||||
|
||||
package main
|
||||
|
||||
func blockingStdin() {}
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// blockingStdin makes a terminal on stdin blocking again. fzf's become
|
||||
// hands on its /dev/tty non-blocking; Go can't poll /dev/tty on macOS,
|
||||
// so it reads it directly, and the TUI's first read would fail with
|
||||
// EAGAIN ("resource temporarily unavailable").
|
||||
func blockingStdin() {
|
||||
if fd := int(os.Stdin.Fd()); term.IsTerminal(fd) {
|
||||
_ = syscall.SetNonblock(fd, false)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func updateCmd(configPath string, args []string) error {
|
||||
current := versionString()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
c := &update.Client{Servers: cfg.Updates.Sources}
|
||||
c := &update.Client{Servers: cfg.Releases}
|
||||
r, err := c.Latest(ctx)
|
||||
if err != nil {
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
|
||||
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes()))
|
||||
if check {
|
||||
fmt.Println("lazysecrets update installs it.")
|
||||
return nil
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
)
|
||||
import "runtime/debug"
|
||||
|
||||
// version is set by release builds:
|
||||
//
|
||||
@@ -12,12 +8,7 @@ import (
|
||||
var version string
|
||||
|
||||
// versionString is the release version, or for other builds what Go
|
||||
// recorded: the module version (go install …@v1.2.3), else the commit
|
||||
// (dev-c1a4f90, -dirty with changes not committed).
|
||||
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
|
||||
// maybe +dirty): its commit.
|
||||
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
|
||||
|
||||
// recorded: the module version (go install …@v1.2.3), else the commit.
|
||||
func versionString() string {
|
||||
if version != "" {
|
||||
return version
|
||||
@@ -27,15 +18,6 @@ func versionString() string {
|
||||
return "dev"
|
||||
}
|
||||
if v := bi.Main.Version; v != "" && v != "(devel)" {
|
||||
// A commit, not a release: a local build (Go stamps those too) or
|
||||
// go install …@<commit>.
|
||||
if m := pseudo.FindStringSubmatch(v); m != nil {
|
||||
dirty := ""
|
||||
if strings.HasSuffix(v, "+dirty") {
|
||||
dirty = "-dirty"
|
||||
}
|
||||
return "dev-" + m[1][:7] + dirty
|
||||
}
|
||||
return v
|
||||
}
|
||||
var rev, dirty string
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPseudoVersions(t *testing.T) {
|
||||
for v, want := range map[string]string{
|
||||
"v0.1.3-0.20261014174204-1c31a75d0507": "1c31a75d0507",
|
||||
"v0.1.3-0.20261014174204-1c31a75d0507+dirty": "1c31a75d0507",
|
||||
"v0.0.0-20261014174204-1c31a75d0507": "1c31a75d0507",
|
||||
"v0.2.0-rc.1.0.20261014174204-1c31a75d0507": "1c31a75d0507",
|
||||
"v0.1.2": "",
|
||||
"v0.2.0-rc.1": "",
|
||||
} {
|
||||
got := ""
|
||||
if m := pseudo.FindStringSubmatch(v); m != nil {
|
||||
got = m[1]
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%s: %q, want %q", v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 263 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 232 KiB |
+1
-1
@@ -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
|
||||
key (PEM public key) or certificate; private key material is never
|
||||
fetched. `v` lists an item's versions. Items expiring within
|
||||
`ui.expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
||||
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
||||
|
||||
The status pane (`0`) lists them across every vault loaded, opened or
|
||||
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
|
||||
|
||||
+29
-47
@@ -2,51 +2,40 @@
|
||||
|
||||
Optional, at `~/.config/lazysecrets/config.yaml` (or
|
||||
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
|
||||
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the
|
||||
status pane (`0`) opens it in your editor, as in lazygit (making it if
|
||||
it isn't there yet); changes apply when lazysecrets starts again.
|
||||
it. Unknown keys are an error, so typos don't go unnoticed.
|
||||
|
||||
```yaml
|
||||
ui:
|
||||
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
||||
recentIcon: false # mark items used lately (Recent, R) with ◷, here and in lazysecrets list
|
||||
sourcesView: grouped # or flat; ` toggles it
|
||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
||||
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
|
||||
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d; ⚠ in lazysecrets list); expired ones get ✗
|
||||
values:
|
||||
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
||||
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
||||
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
|
||||
storage:
|
||||
cache: true # keep what was loaded (never values) for the next start
|
||||
history: true # count uses of items for Recent (R)
|
||||
updates:
|
||||
autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way)
|
||||
autoCheckInterval: 24h # at most this often
|
||||
sources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
|
||||
- https://github.com/karleinarb/lazysecrets
|
||||
- https://git.kebcloud.no/keb/lazysecrets
|
||||
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
||||
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
|
||||
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
||||
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
||||
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
|
||||
timeout: 30s # per backend request
|
||||
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
|
||||
sourcesView: grouped # or flat; ` toggles it
|
||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
||||
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
|
||||
releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
|
||||
- https://github.com/karleinarb/lazysecrets
|
||||
- https://git.kebcloud.no/keb/lazysecrets
|
||||
history: true # count uses of items for Recent (R)
|
||||
cache: true # keep what was loaded (never values) for the next start
|
||||
providers:
|
||||
timeout: 30s # per backend request; a provider's own timeout overrides it
|
||||
azure: # applies to every az account
|
||||
enabled: true # default
|
||||
timeout: 30s
|
||||
hideSubscriptions: [platform-mgmt] # by name or ID
|
||||
vaults: # listed under "Pinned vaults", for vaults
|
||||
- https://kv-foo.vault.azure.net/ # you can read but not discover
|
||||
databricks:
|
||||
enabled: true
|
||||
timeout: 30s
|
||||
hideProfiles: [test]
|
||||
repos:
|
||||
enabled: true
|
||||
timeout: 30s
|
||||
roots: [~/git]
|
||||
paths: [~/work/infra]
|
||||
include: ["secrets/**"]
|
||||
exclude: ["**/testdata/**"]
|
||||
sharing: # sharing env files as encrypted links
|
||||
share: # sharing env files as encrypted links
|
||||
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
|
||||
keyRules: # what a key's value must be
|
||||
minLength: 32 # characters, 16 at least
|
||||
@@ -54,8 +43,8 @@ sharing: # sharing env files as encrypted links
|
||||
```
|
||||
|
||||
More, each on its own page: [repos](repos.md) (`providers.repos`),
|
||||
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the
|
||||
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
|
||||
[sharing env files](links.md#sharing-env-files) (`share`), the
|
||||
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
||||
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
||||
[custom commands](custom-commands.md) (`customCommands`).
|
||||
|
||||
@@ -92,11 +81,8 @@ theme:
|
||||
repoColor: ["#c099ff"]
|
||||
```
|
||||
|
||||
A role lazysecrets doesn't know, a typo or one from a newer version, is
|
||||
skipped with a warning.
|
||||
|
||||
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
|
||||
as boxes, turn them off with `ui.icons: false`.
|
||||
as boxes, turn them off with `icons: false`.
|
||||
|
||||
## Layering
|
||||
|
||||
@@ -120,13 +106,11 @@ starred the item under (a vault two of your accounts can see can be starred
|
||||
for one); a note describes the item, so it shows under every account. It's
|
||||
JSON so an editor plugin can read it with a built-in parser; several
|
||||
lazysecrets windows can save to it without losing each other's changes.
|
||||
One written by a newer lazysecrets is read but not saved to, with a
|
||||
warning, until you update.
|
||||
|
||||
Revealing, copying, opening in the browser, sharing and jumping to an item
|
||||
count as using it: its record keeps when it was last used and how often,
|
||||
for Recent (`R`). Records kept only for usage are dropped after 90 days
|
||||
unused, and beyond the 100 most recent. `storage.history: false` stops counting.
|
||||
unused, and beyond the 100 most recent. `history: false` stops counting.
|
||||
|
||||
It also keeps where you left lazysecrets in each folder (the selected
|
||||
source, vault or secret), and goes back there the next time it starts in
|
||||
@@ -141,24 +125,22 @@ you: the Sources lists, vaults and scopes, and secrets' names and metadata
|
||||
once, and anything older than two minutes reloads in the background when
|
||||
you go to it, or when Go to searches it. While it does, the pane's
|
||||
bottom border says how old what it shows is (`cached 3h ago`). Revealing
|
||||
and copying always fetch the value live. [`lazysecrets list`](integration.md#listing-for-fzf-and-pickers)
|
||||
reads it and adds to it too.
|
||||
and copying always fetch the value live.
|
||||
|
||||
Lists not loaded for 30 days are dropped, and so is everything from an
|
||||
account you're no longer signed in to. Repos aren't cached: their files
|
||||
are read from disk anyway. Deleting the file is always safe, and
|
||||
`storage.cache: false` stops using it.
|
||||
`cache: false` stops using it.
|
||||
|
||||
## Updates
|
||||
|
||||
At startup, in the background, lazysecrets asks for the latest release
|
||||
(`updates.autoCheck`), once a day or every `updates.autoCheckInterval`;
|
||||
until then it reuses the answer from the state file. Releases are
|
||||
published on GitHub and on git.kebcloud.no, and it asks them in that
|
||||
order, the next when one doesn't answer within 5 seconds;
|
||||
`updates.sources` lists others, such as a mirror of your own. Offline, or with
|
||||
Once a day (`updateCheck`), in the background, lazysecrets asks for the
|
||||
latest release; within the day it reuses the answer from the state file.
|
||||
Releases are published on GitHub and on git.kebcloud.no, and it asks
|
||||
them in that order, the next when one doesn't answer within 5 seconds;
|
||||
`releases` lists others, such as a mirror of your own. Offline, or with
|
||||
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
|
||||
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
|
||||
`lazysecrets update --check`) shows its changelog.
|
||||
|
||||
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
||||
your OS and CPU from the first server that has it, checks it against
|
||||
|
||||
+1
-1
@@ -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
|
||||
version they were built for.
|
||||
|
||||
## How changes land
|
||||
## Pull requests
|
||||
|
||||
Pull requests into `main` are squash-merged, so each is one commit titled
|
||||
like the PR. The title is a [conventional commit](https://www.conventionalcommits.org)
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# Integration
|
||||
|
||||
lazysecrets works outside its own window too: `lazysecrets get` reads a
|
||||
value for scripts, and `lazysecrets list` lists everything for fzf, editor
|
||||
pickers and your own tools. Both take the [links](sharing.md) lazysecrets
|
||||
copies and shares. For Neovim, there's
|
||||
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
|
||||
|
||||
## Reading a value
|
||||
|
||||
`lazysecrets get <link>` prints the value, for scripts and pipes:
|
||||
|
||||
```sh
|
||||
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
|
||||
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
|
||||
```
|
||||
|
||||
It reads the value directly, with your az accounts (the one that last read
|
||||
it first), the Databricks profile for the workspace, or sops in your clone
|
||||
of the repo; a version in the link reads that version. `--account` tries
|
||||
that account first, as [`list`](#listing-for-fzf-and-pickers) prints it.
|
||||
The value ends with a newline only on a terminal. When it can't read the
|
||||
value, it says why on stderr and exits with status 1.
|
||||
|
||||
## Listing, for fzf and pickers
|
||||
|
||||
`lazysecrets list` prints every source, vault and secret, one per line,
|
||||
tab-separated:
|
||||
|
||||
| Field | |
|
||||
|---|---|
|
||||
| kind | `source`, `vault`, `secret`, `key` or `certificate` |
|
||||
| mark | ✗ when something's wrong (expired, or couldn't be read) or ⚠ when it expires soon; ★ when starred; ◷ when used lately (with `--recent`); and the provider's icon |
|
||||
| name | `platform-prod / kv-app-prod / db-password` |
|
||||
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
|
||||
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
|
||||
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
|
||||
| link | its `lazysecrets://` link |
|
||||
|
||||
What's cached prints at once, then what loads follows as it arrives: read
|
||||
it as a stream. Values are never listed. It stops quietly when the reader
|
||||
goes, like fzf after a pick. Kind stays the first field, and account and
|
||||
link the last two; new fields only ever go between. The mark has a field of
|
||||
its own so a search can leave it out: in fzf, `--nth` searches the name,
|
||||
so `^prod` finds names starting with prod.
|
||||
|
||||
| Flag | |
|
||||
|---|---|
|
||||
| `--format json` | an object per line instead, for editor plugins (below) |
|
||||
| `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded |
|
||||
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
|
||||
| `--max-age 2m` | load cached lists older than this again |
|
||||
| `--errors=false` | leave out the vaults and sources it couldn't read |
|
||||
| `--color never` | no colours, for scripts |
|
||||
| `--icons=false` | no provider icons |
|
||||
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
|
||||
| `--notes=false` | no note icon before a note |
|
||||
| `--recent` | ◷ before names used lately, in `mutedColor`; default `ui.recentIcon` |
|
||||
| `--expiry=false` | no expiry: not in the status, and no ✗ or ⚠ for it |
|
||||
|
||||
With `--format json`, each line is an object with these fields; new ones
|
||||
may be added:
|
||||
|
||||
| Field | |
|
||||
|---|---|
|
||||
| `link`, `kind`, `account` | as above |
|
||||
| `error` | why it couldn't be read, only then |
|
||||
| `provider` | `azure`, `databricks` or `repo` |
|
||||
| `path` | the names: `["platform-prod", "kv-app-prod", "db-password"]` |
|
||||
| `accountLabel` | the account's short name, when several az accounts are signed in |
|
||||
| `starred` | starred for this account |
|
||||
| `note` | your note on it, if any |
|
||||
| `reference` | how config refers to a secret: `@Microsoft.KeyVault(SecretUri=…)` or `{{secrets/scope/key}}` (`Y` in lazysecrets) |
|
||||
| `url` | its web page, in the Azure portal or the Databricks workspace (`o` in lazysecrets) |
|
||||
| `recent` | used lately, as Recent (`R`) lists: in the last 90 days |
|
||||
| `lastUsed`, `uses` | when it was last used, and how often; only when `recent`, to sort by |
|
||||
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
|
||||
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
|
||||
|
||||
`lazysecrets open --action go --account <account> <link>` opens one in
|
||||
lazysecrets without asking what to do or which account, ending in the
|
||||
secrets list, like Go to.
|
||||
|
||||
|
||||
### Example usage with fzf
|
||||
```sh
|
||||
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
|
||||
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
|
||||
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
|
||||
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
|
||||
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
||||
```
|
||||

|
||||
|
||||
+4
-5
@@ -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 |
|
||||
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
||||
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
||||
| `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
|
||||
| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-files)) |
|
||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||
| `x` | [custom commands](custom-commands.md) |
|
||||
| `L` | sign in again (shown when a login has expired) |
|
||||
@@ -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
|
||||
it exactly as stored.
|
||||
|
||||
A revealed value hides again after `values.revealTimeout` (60s) and
|
||||
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
|
||||
A revealed value hides again after `revealTimeout` (60s) and when the
|
||||
terminal loses focus (`hideOnBlur`); see [config](config.md).
|
||||
|
||||
## Stars and notes
|
||||
|
||||
@@ -122,8 +122,7 @@ Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
|
||||
it's hidden.
|
||||
|
||||
On top, the status pane (`0`) shows the version, a newer release and how
|
||||
your accounts are doing; `e` there opens your [config](config.md) in your
|
||||
editor, as in lazygit. Focused, the right pane lists more under the
|
||||
your accounts are doing. Focused, the right pane lists more under the
|
||||
logo (when there's room): the version and updates (`U`), each account
|
||||
(`L` on one that needs it signs in), what's expiring (`enter` goes to
|
||||
it), this session's errors, newest first (what a pane showed, or the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Sharing
|
||||
# Links
|
||||
|
||||
Subscriptions, workspaces, vaults, scopes, repos, files and secrets are
|
||||
shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
|
||||
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
|
||||
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
|
||||
it lands on that item, reading it with their own account and
|
||||
permissions: the link names the item, never its value. Env files can
|
||||
also be [shared encrypted](#sharing-env-files), in the link itself.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## Sharing a link
|
||||
## Sharing
|
||||
|
||||
`s` copies a link to the selected item (from the versions view, pinned to
|
||||
that version). `S` opens the share menu:
|
||||
@@ -54,7 +54,7 @@ uses it) or your config (for repos that don't name one), by its
|
||||
reference:
|
||||
|
||||
```yaml
|
||||
sharing:
|
||||
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:
|
||||
|
||||
```yaml
|
||||
sharing:
|
||||
share:
|
||||
keyRules:
|
||||
minLength: 32 # characters; 16 at least
|
||||
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
|
||||
it now and then: new links use the newest version.
|
||||
|
||||
## Opening a link
|
||||
## Opening
|
||||
|
||||
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
|
||||
and fills in any reference it finds there. Key Vault URLs,
|
||||
@@ -121,10 +121,20 @@ account) and jumps to it, asking which account to use when several can see
|
||||
it, ending on a secret's details as `enter` on it would. Links say what,
|
||||
not what to do: opening a link to a secret asks whether to just go to it,
|
||||
also copy or show its value, or (`y`) copy its value without going there.
|
||||
`lazysecrets open --action go|copy|show <link>` does one without asking
|
||||
and ends in the list, like Go to,
|
||||
and `--account <account>` picks the account (as [`list`](integration.md#listing-for-fzf-and-pickers)
|
||||
prints it) when several can see it.
|
||||
|
||||
## In scripts
|
||||
|
||||
`lazysecrets get <link>` prints the value, for scripts and pipes:
|
||||
|
||||
```sh
|
||||
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
|
||||
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
|
||||
```
|
||||
|
||||
It reads the value directly, with your az accounts (the one that last read
|
||||
it first), the Databricks profile for the workspace, or sops in your clone
|
||||
of the repo; a version in the link reads that version. The value ends with
|
||||
a newline only on a terminal.
|
||||
|
||||
## From the browser and chat
|
||||
|
||||
+10
-12
@@ -3,16 +3,14 @@
|
||||
lazysecrets shows git repositories under **Repos**: their
|
||||
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
||||
`.env`, INI) and plain env files as vaults, and the keys in them
|
||||
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
|
||||
escaped, `app\.name`, so it doesn't read as nesting.
|
||||
(`db.password`, `list[0]`) as secrets.
|
||||
|
||||
## Which repos
|
||||
|
||||
**Repos** lists every repo with secret files under the `roots` (by
|
||||
default whichever of `~/git`, `~/repo`, `~/repos`, `~/projects`, `~/code`,
|
||||
`~/source` and `~/src` exist; one or two levels down, `repo` or
|
||||
`org/repo`), any listed in `paths`, and any you starred something in
|
||||
(the repo, a file or a key), wherever it is. They're checked in the background.
|
||||
**Repos** lists every repo with secret files under the `roots` (`~/git`
|
||||
by default; one or two levels down, `repo` or `org/repo`), any listed in
|
||||
`paths`, and any you starred something in (the repo, a file or a key),
|
||||
wherever it is. They're checked in the background.
|
||||
|
||||
Started inside a repo, that repo gets a row of its own at the top,
|
||||
`Repo · <name>`, and lazysecrets opens on it, with the files under the
|
||||
@@ -51,17 +49,17 @@ Folders of dependencies and caches are never looked in, tracked or not:
|
||||
```yaml
|
||||
providers:
|
||||
repos:
|
||||
roots: [~/git] # folders of repos (replaces the defaults; [] turns it off)
|
||||
roots: [~/git] # folders of repos (the default; [] turns it off)
|
||||
paths: [~/work/infra] # single repos elsewhere
|
||||
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
|
||||
exclude: ["**/testdata/**"]
|
||||
```
|
||||
|
||||
A repo's `.lazysecrets.yaml` can also name the secret its env files are
|
||||
[shared](sharing.md#sharing-env-files) with:
|
||||
[shared](links.md#sharing-env-files) with:
|
||||
|
||||
```yaml
|
||||
sharing:
|
||||
share:
|
||||
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
||||
```
|
||||
|
||||
@@ -100,7 +98,7 @@ sops 3.8 or later.
|
||||
|
||||
## Links
|
||||
|
||||
`S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files).
|
||||
`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files).
|
||||
|
||||
Links name the repo by its git remote, so they open in a colleague's clone
|
||||
wherever it is:
|
||||
@@ -109,4 +107,4 @@ wherever it is:
|
||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||
```
|
||||
|
||||
See [sharing](sharing.md).
|
||||
See [links](links.md).
|
||||
@@ -18,8 +18,8 @@ require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/x/ansi v0.11.9
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
|
||||
github.com/databricks/databricks-sdk-go v0.188.0
|
||||
github.com/junegunn/fzf v0.74.5
|
||||
github.com/databricks/databricks-sdk-go v0.187.0
|
||||
github.com/junegunn/fzf v0.74.4
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
golang.org/x/mod v0.42.0
|
||||
golang.org/x/term v0.47.0
|
||||
|
||||
@@ -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/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/databricks/databricks-sdk-go v0.188.0 h1:1nxBqlctGnPzlReyziG199abHs9cR/KNjsCnSM+oDGA=
|
||||
github.com/databricks/databricks-sdk-go v0.188.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
|
||||
github.com/databricks/databricks-sdk-go v0.187.0 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8=
|
||||
github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/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/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/junegunn/fzf v0.74.5 h1:mpJlCdgGuddKerHrdjvV3cnezEBgyjZAVn8KCo/4OMc=
|
||||
github.com/junegunn/fzf v0.74.5/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
|
||||
github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso=
|
||||
github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
|
||||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
// Package cachefile is the cache of what lazysecrets has loaded
|
||||
// (cache.json): the Sources lists, vaults, secrets' metadata and where
|
||||
// the cursor was in each, never values. The TUI and lazysecrets list
|
||||
// both read it and save to it; a save merges with what's on disk, so
|
||||
// neither undoes what the other loaded.
|
||||
package cachefile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Version is the file's format; a file of another version is ignored,
|
||||
// and replaced on the next save.
|
||||
const Version = 1
|
||||
|
||||
// MaxAge drops what hasn't been loaded again for this long.
|
||||
const MaxAge = 30 * 24 * time.Hour
|
||||
|
||||
// File is the cache. Every key starts with the provider ID: Sources are
|
||||
// keyed by it, the rest by Row, Objects' keys and the like.
|
||||
type File struct {
|
||||
Version int `json:"version"`
|
||||
Sources map[string]Stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
|
||||
Containers map[string]Stamped[[]provider.Container] `json:"containers,omitempty"`
|
||||
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
|
||||
Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
|
||||
Cursor map[string]string `json:"cursor,omitempty"`
|
||||
// Dropped are keys deleted since the file was read (a list that
|
||||
// failed to load): Save deletes them from the file too, rather than
|
||||
// merging the old entry back. Not saved.
|
||||
Dropped map[string]bool `json:"-"`
|
||||
}
|
||||
|
||||
// Stamped is a list or detail with when it was loaded.
|
||||
type Stamped[T any] struct {
|
||||
At time.Time `json:"at"`
|
||||
Value T `json:"value"`
|
||||
}
|
||||
|
||||
// New is an empty cache.
|
||||
func New() File {
|
||||
return File{
|
||||
Version: Version,
|
||||
Sources: map[string]Stamped[[]provider.Source]{},
|
||||
Containers: map[string]Stamped[[]provider.Container]{},
|
||||
Objects: map[string]Stamped[[]provider.Object]{},
|
||||
Details: map[string]Stamped[provider.Object]{},
|
||||
Cursor: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Row keys a source's vaults or scopes.
|
||||
func Row(providerID, sourceID string) string { return providerID + "|" + sourceID }
|
||||
|
||||
// ContainerKey keys what's cached about a vault or scope.
|
||||
func ContainerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
|
||||
|
||||
// ObjectsKey keys a vault's secrets, keys or certificates.
|
||||
func ObjectsKey(c provider.Container, t provider.ObjectType) string {
|
||||
return ContainerKey(c) + "|" + string(t)
|
||||
}
|
||||
|
||||
// ProviderOf is the provider ID a key starts with.
|
||||
func ProviderOf(key string) string {
|
||||
id, _, _ := strings.Cut(key, "|")
|
||||
return id
|
||||
}
|
||||
|
||||
// Keep is Save's keep for these providers: their lists are kept, but not
|
||||
// repos', whose files are read from disk anyway, nor other accounts'.
|
||||
func Keep(providers []provider.Provider) func(providerID string) bool {
|
||||
keep := map[string]bool{}
|
||||
for _, p := range providers {
|
||||
keep[p.ID()] = p.Kind() != provider.KindRepo
|
||||
}
|
||||
return func(id string) bool { return keep[id] }
|
||||
}
|
||||
|
||||
// Read reads path; a missing, unreadable or other version's file is an
|
||||
// empty cache.
|
||||
func Read(path string) File {
|
||||
f := New()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return f
|
||||
}
|
||||
var d File
|
||||
if json.Unmarshal(b, &d) != nil || d.Version != Version {
|
||||
return f
|
||||
}
|
||||
maps.Copy(f.Sources, d.Sources)
|
||||
maps.Copy(f.Containers, d.Containers)
|
||||
maps.Copy(f.Objects, d.Objects)
|
||||
maps.Copy(f.Details, d.Details)
|
||||
maps.Copy(f.Cursor, d.Cursor)
|
||||
return f
|
||||
}
|
||||
|
||||
// Save writes f to path, merged with the file there: of a list in both,
|
||||
// the one loaded last wins, and f's cursors win (a nil Cursor keeps the
|
||||
// file's). Entries older than MaxAge, those of providers keep says no to
|
||||
// (accounts signed out of), and f.Dropped are dropped, from the file's
|
||||
// side as well. Saves from several lazysecrets take turns (a lock file),
|
||||
// so none undoes another's.
|
||||
func Save(path string, f File, keep func(providerID string) bool, now time.Time) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
unlock, err := lock(path + ".lock")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
out := New()
|
||||
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
|
||||
disk := Read(path)
|
||||
merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped)
|
||||
merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped)
|
||||
merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped)
|
||||
merge(out.Details, disk.Details, f.Details, ok, f.Dropped)
|
||||
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
|
||||
for k, v := range m {
|
||||
if keep(ProviderOf(k)) {
|
||||
out.Cursor[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return write(path, out)
|
||||
}
|
||||
|
||||
// merge puts disk's entries in out, but not those dropped, then ours
|
||||
// where they're newer.
|
||||
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool, dropped map[string]bool) {
|
||||
for k, e := range disk {
|
||||
if ok(k, e.At) && !dropped[k] {
|
||||
out[k] = e
|
||||
}
|
||||
}
|
||||
for k, e := range ours {
|
||||
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
|
||||
out[k] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// write writes f atomically, readable by the user only.
|
||||
func write(path string, f File) error {
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package cachefile
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func list(at time.Time, names ...string) Stamped[[]provider.Container] {
|
||||
var cs []provider.Container
|
||||
for _, n := range names {
|
||||
cs = append(cs, provider.Container{Name: n})
|
||||
}
|
||||
return Stamped[[]provider.Container]{At: at, Value: cs}
|
||||
}
|
||||
|
||||
func TestSaveMerges(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
now := time.Now()
|
||||
all := func(string) bool { return true }
|
||||
|
||||
// The TUI saved two lists.
|
||||
tui := New()
|
||||
tui.Containers["az|a"] = list(now.Add(-time.Hour), "kv-a-old")
|
||||
tui.Containers["az|b"] = list(now.Add(-time.Minute), "kv-b")
|
||||
tui.Cursor["az|b"] = "kv-b"
|
||||
if err := Save(path, tui, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// lazysecrets list loads a again, and c.
|
||||
listed := Read(path)
|
||||
listed.Containers["az|a"] = list(now, "kv-a-new")
|
||||
listed.Containers["az|c"] = list(now, "kv-c")
|
||||
if err := Save(path, listed, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The TUI saves what it has, older: list's lists stay.
|
||||
if err := Save(path, tui, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := Read(path)
|
||||
for key, want := range map[string]string{"az|a": "kv-a-new", "az|b": "kv-b", "az|c": "kv-c"} {
|
||||
if got := f.Containers[key].Value; len(got) != 1 || got[0].Name != want {
|
||||
t.Errorf("%s = %v, want %s", key, got, want)
|
||||
}
|
||||
}
|
||||
if f.Cursor["az|b"] != "kv-b" {
|
||||
t.Error("cursor lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDrops(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
now := time.Now()
|
||||
f := New()
|
||||
f.Containers["az|old"] = list(now.Add(-MaxAge-time.Hour), "kv-old")
|
||||
f.Containers["gone|x"] = list(now, "kv-gone")
|
||||
f.Containers["az|x"] = list(now, "kv-x")
|
||||
if err := Save(path, f, func(id string) bool { return true }, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Signed out of "gone" since: dropped from the file too.
|
||||
if err := Save(path, New(), func(id string) bool { return id == "az" }, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := Read(path).Containers
|
||||
if _, ok := got["az|old"]; ok {
|
||||
t.Error("kept a list older than MaxAge")
|
||||
}
|
||||
if _, ok := got["gone|x"]; ok {
|
||||
t.Error("kept a signed-out account's list")
|
||||
}
|
||||
if _, ok := got["az|x"]; !ok {
|
||||
t.Error("dropped a list on disk the save didn't have")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOtherVersion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
f := New()
|
||||
f.Version = Version + 1
|
||||
f.Containers["az|x"] = list(time.Now(), "kv-x")
|
||||
if err := write(path, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := Read(path); len(got.Containers) != 0 || got.Version != Version {
|
||||
t.Errorf("read another version's file: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDroppedAndCursors(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
now := time.Now()
|
||||
all := func(string) bool { return true }
|
||||
f := New()
|
||||
f.Containers["az|bad"] = list(now, "kv-old")
|
||||
f.Cursor["az|x"] = "kv-tui"
|
||||
if err := Save(path, f, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The TUI fails to load it again and drops it: it stays gone.
|
||||
f = New()
|
||||
f.Dropped = map[string]bool{"az|bad": true}
|
||||
if err := Save(path, f, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := Read(path).Containers["az|bad"]; ok {
|
||||
t.Error("a dropped list came back")
|
||||
}
|
||||
// lazysecrets list saves without cursors: the TUI's stay.
|
||||
listed := New()
|
||||
listed.Cursor = nil
|
||||
listed.Containers["az|y"] = list(now, "kv-y")
|
||||
if err := Save(path, listed, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Read(path).Cursor["az|x"] != "kv-tui" {
|
||||
t.Error("the TUI's cursor was lost")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !unix
|
||||
|
||||
package cachefile
|
||||
|
||||
// lock does nothing here: saves can then race, losing lists that load
|
||||
// again.
|
||||
func lock(string) (func(), error) { return func() {}, nil }
|
||||
@@ -1,25 +0,0 @@
|
||||
//go:build unix
|
||||
|
||||
package cachefile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// lock takes path as a lock file, waiting while another lazysecrets has
|
||||
// it, and returns how to let go.
|
||||
func lock(path string) (unlock func(), err error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
f.Close()
|
||||
}, nil
|
||||
}
|
||||
+56
-101
@@ -19,18 +19,44 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
UI UI `yaml:"ui"`
|
||||
// Values keeps revealed and copied values from lingering.
|
||||
Values Values `yaml:"values"`
|
||||
// Storage is what's kept between runs.
|
||||
Storage Storage `yaml:"storage"`
|
||||
// Updates sets how lazysecrets looks for newer releases.
|
||||
Updates Updates `yaml:"updates"`
|
||||
Providers Providers `yaml:"providers"`
|
||||
Icons *bool `yaml:"icons"`
|
||||
ClipboardClear *time.Duration `yaml:"clipboardClear"`
|
||||
// RevealTimeout hides a revealed value after this long; 0s never.
|
||||
// Defaults to 60s.
|
||||
RevealTimeout *time.Duration `yaml:"revealTimeout"`
|
||||
// ExpiryWarningDays marks secrets, keys and certificates expiring
|
||||
// within this many days; 0 marks only expired ones. Defaults to 30.
|
||||
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
|
||||
// HideOnBlur hides a revealed value when the terminal loses focus.
|
||||
// Defaults to true.
|
||||
HideOnBlur *bool `yaml:"hideOnBlur"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// LoadDelay is how long the cursor must rest on a row before its
|
||||
// contents load; 0s loads at once. Defaults to 150ms.
|
||||
LoadDelay *time.Duration `yaml:"loadDelay"`
|
||||
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
|
||||
SourcesView string `yaml:"sourcesView"`
|
||||
// History counts uses of items for the Recent list; defaults to true.
|
||||
History *bool `yaml:"history"`
|
||||
// UpdateCheck looks for a newer release once a day, in the
|
||||
// background; defaults to true. U and lazysecrets update work either
|
||||
// way.
|
||||
UpdateCheck *bool `yaml:"updateCheck"`
|
||||
// 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"`
|
||||
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
||||
LinkHandler LinkHandler `yaml:"linkHandler"`
|
||||
// Sharing sets how env files are shared; see package share.
|
||||
Sharing Sharing `yaml:"sharing"`
|
||||
// Share sets how env files are shared; see package share.
|
||||
Share Share `yaml:"share"`
|
||||
// CustomCommands run shell commands on the selected item; see
|
||||
// package commands.
|
||||
CustomCommands []commands.Command `yaml:"customCommands"`
|
||||
@@ -42,55 +68,6 @@ type Config struct {
|
||||
Theme map[string][]string `yaml:"theme"`
|
||||
}
|
||||
|
||||
type UI struct {
|
||||
Icons *bool `yaml:"icons"`
|
||||
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
|
||||
// and in lazysecrets list. Defaults to false.
|
||||
RecentIcon *bool `yaml:"recentIcon"`
|
||||
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
|
||||
SourcesView string `yaml:"sourcesView"`
|
||||
// CollapseGroups starts the Sources pane with its groups folded;
|
||||
// defaults to true.
|
||||
CollapseGroups *bool `yaml:"collapseGroups"`
|
||||
// LoadDelay is how long the cursor must rest on a row before its
|
||||
// contents load; 0s loads at once. Defaults to 150ms.
|
||||
LoadDelay *time.Duration `yaml:"loadDelay"`
|
||||
// ExpiryWarningDays marks secrets, keys and certificates expiring
|
||||
// within this many days; 0 marks only expired ones. Defaults to 30.
|
||||
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
|
||||
}
|
||||
|
||||
type Values struct {
|
||||
// RevealTimeout hides a revealed value after this long; 0s never.
|
||||
// Defaults to 60s.
|
||||
RevealTimeout *time.Duration `yaml:"revealTimeout"`
|
||||
// HideOnBlur hides a revealed value when the terminal loses focus.
|
||||
// Defaults to true.
|
||||
HideOnBlur *bool `yaml:"hideOnBlur"`
|
||||
ClipboardClear *time.Duration `yaml:"clipboardClear"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
// Cache keeps what was loaded (never values) between runs, so lists
|
||||
// show at once and refresh in the background; defaults to true.
|
||||
Cache *bool `yaml:"cache"`
|
||||
// History counts uses of items for the Recent list; defaults to true.
|
||||
History *bool `yaml:"history"`
|
||||
}
|
||||
|
||||
type Updates struct {
|
||||
// AutoCheck looks for a newer release in the background at startup,
|
||||
// at most once per AutoCheckInterval; defaults to true. U and
|
||||
// lazysecrets update work either way.
|
||||
AutoCheck *bool `yaml:"autoCheck"`
|
||||
// AutoCheckInterval defaults to 24h.
|
||||
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
|
||||
// Sources are the repositories updates come from, tried in
|
||||
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
|
||||
// then git.kebcloud.no (update.DefaultServers).
|
||||
Sources []string `yaml:"sources"`
|
||||
}
|
||||
|
||||
type LinkHandler struct {
|
||||
// Terminal to open links in, overriding the one handler install
|
||||
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
|
||||
@@ -108,7 +85,7 @@ type LinkHandler struct {
|
||||
Prompt *bool `yaml:"prompt"`
|
||||
}
|
||||
|
||||
type Sharing struct {
|
||||
type Share struct {
|
||||
// Key is the secret whose value encrypts shared files (its
|
||||
// lazysecrets:// link, Key Vault URL or reference), for repos whose
|
||||
// .lazysecrets.yaml doesn't name one.
|
||||
@@ -126,25 +103,20 @@ type KeyRules 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"`
|
||||
Databricks Databricks `yaml:"databricks"`
|
||||
Repos Repos `yaml:"repos"`
|
||||
Azure Azure `yaml:"azure"`
|
||||
Databricks Databricks `yaml:"databricks"`
|
||||
Repos Repos `yaml:"repos"`
|
||||
}
|
||||
|
||||
// Repos are git repositories' secret files (SOPS, .env). Inside a repo,
|
||||
// that repo is the only one shown; elsewhere, the ones in Paths and under
|
||||
// Roots.
|
||||
type Repos struct {
|
||||
// Timeout overrides providers.timeout for these requests.
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Paths []string `yaml:"paths"`
|
||||
// Roots are folders of repos. Defaults to repo.DefaultRoots (~/git,
|
||||
// ~/src, ~/code and the like); an empty list turns it off.
|
||||
// Roots are folders of repos. Defaults to ~/git; an empty list
|
||||
// turns it off.
|
||||
Roots *[]string `yaml:"roots"`
|
||||
// Include and Exclude are globs relative to a repo's root (** for any
|
||||
// depth), for repos without their own .lazysecrets.yaml. Without them,
|
||||
@@ -154,8 +126,6 @@ type Repos struct {
|
||||
}
|
||||
|
||||
type Azure struct {
|
||||
// Timeout overrides providers.timeout for these requests.
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
||||
@@ -166,8 +136,6 @@ type Azure struct {
|
||||
}
|
||||
|
||||
type Databricks struct {
|
||||
// Timeout overrides providers.timeout for these requests.
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
HideProfiles []string `yaml:"hideProfiles"`
|
||||
@@ -254,48 +222,35 @@ func decodeInto(c *Config, path string) error {
|
||||
}
|
||||
|
||||
func (c Config) validate(path string) error {
|
||||
if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
||||
return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v)
|
||||
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
||||
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
|
||||
}
|
||||
for _, cmd := range c.CustomCommands {
|
||||
if err := cmd.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 {
|
||||
return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d)
|
||||
if d := c.ExpiryWarningDays; d != nil && *d < 0 {
|
||||
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d)
|
||||
}
|
||||
if d := c.UI.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
||||
return fmt.Errorf("%s: ui.loadDelay: %s is not between 0s and 5s", path, *d)
|
||||
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
||||
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
|
||||
}
|
||||
if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
|
||||
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
|
||||
}
|
||||
for i, r := range c.Updates.Sources {
|
||||
for i, r := range c.Releases {
|
||||
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
|
||||
return fmt.Errorf("%s: updates.sources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
||||
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
||||
}
|
||||
}
|
||||
if k := c.Sharing.Key; k != "" {
|
||||
if k := c.Share.Key; k != "" {
|
||||
if _, err := share.ParseKey(k); err != nil {
|
||||
return fmt.Errorf("%s: sharing.key: %w", path, err)
|
||||
return fmt.Errorf("%s: share.key: %w", path, err)
|
||||
}
|
||||
}
|
||||
if n := c.Sharing.KeyRules.MinLength; n != 0 && n < 16 {
|
||||
return fmt.Errorf("%s: sharing.keyRules.minLength: %d is under 16", path, n)
|
||||
if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 {
|
||||
return fmt.Errorf("%s: share.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 {
|
||||
if !strings.HasPrefix(v, "https://") {
|
||||
|
||||
@@ -19,17 +19,12 @@ func write(t *testing.T, content string) string {
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
c, err := Load(write(t, `
|
||||
ui:
|
||||
icons: false
|
||||
sourcesView: flat
|
||||
values:
|
||||
clipboardClear: 45s
|
||||
storage:
|
||||
history: false
|
||||
icons: false
|
||||
sourcesView: flat
|
||||
clipboardClear: 45s
|
||||
timeout: 1m
|
||||
providers:
|
||||
timeout: 1m
|
||||
azure:
|
||||
timeout: 2m
|
||||
hideSubscriptions: [platform-mgmt]
|
||||
vaults:
|
||||
- https://kv-foo.vault.azure.net/
|
||||
@@ -40,12 +35,12 @@ providers:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second ||
|
||||
c.Providers.Timeout != time.Minute || c.Providers.Azure.Timeout != 2*time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
|
||||
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second ||
|
||||
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
|
||||
t.Errorf("config = %+v", c)
|
||||
}
|
||||
if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) {
|
||||
t.Errorf("ui = %+v, storage = %+v", c.UI, c.Storage)
|
||||
if c.SourcesView != "flat" {
|
||||
t.Errorf("sourcesView = %q", c.SourcesView)
|
||||
}
|
||||
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
|
||||
t.Error("enabled flags wrong")
|
||||
@@ -62,7 +57,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
|
||||
"empty": write(t, ""),
|
||||
} {
|
||||
c, err := Load(path)
|
||||
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
|
||||
if err != nil || c.Icons != nil || c.ClipboardClear != nil {
|
||||
t.Errorf("%s: %+v, %v", name, c, err)
|
||||
}
|
||||
}
|
||||
@@ -73,17 +68,14 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
||||
"typo": "icon: true\n",
|
||||
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
|
||||
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
||||
"flat keys": "icons: false\n",
|
||||
"duration": "providers:\n timeout: soon\n",
|
||||
"negative": "providers:\n repos:\n timeout: -1s\n",
|
||||
"view": "ui:\n sourcesView: tree\n",
|
||||
"sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n",
|
||||
"http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n",
|
||||
"interval": "updates:\n autoCheckInterval: 0s\n",
|
||||
"share key": "sharing:\n key: https://kv.vault.azure.net/\n",
|
||||
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
|
||||
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
|
||||
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
|
||||
"duration": "timeout: soon\n",
|
||||
"view": "sourcesView: tree\n",
|
||||
"releases": "releases: [git.example.com/team/lazysecrets]\n",
|
||||
"http": "releases: [http://git.example.com/team/lazysecrets]\n",
|
||||
"share key": "share:\n key: https://kv.vault.azure.net/\n",
|
||||
"workspace": "share:\n key: \"{{secrets/team/env-share}}\"\n",
|
||||
"short keys": "share:\n keyRules:\n minLength: 8\n",
|
||||
"classes": "share:\n keyRules:\n minClasses: 5\n",
|
||||
} {
|
||||
if _, err := Load(write(t, content)); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
@@ -95,8 +87,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
||||
|
||||
func TestLoadAllLayers(t *testing.T) {
|
||||
user := write(t, `
|
||||
ui:
|
||||
icons: false
|
||||
icons: false
|
||||
theme:
|
||||
azureColor: [blue]
|
||||
errorColor: [red, bold]
|
||||
@@ -105,8 +96,6 @@ providers:
|
||||
hideSubscriptions: [a]
|
||||
`)
|
||||
theme := write(t, `
|
||||
ui:
|
||||
sourcesView: flat
|
||||
theme:
|
||||
azureColor: ["#82aaff"]
|
||||
activeBorderColor: ["#ff966c", bold]
|
||||
@@ -115,8 +104,7 @@ theme:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A group set in both files merges: icons from one, sourcesView the other.
|
||||
if c.UI.Icons == nil || *c.UI.Icons || c.UI.SourcesView != "flat" || len(c.Providers.Azure.HideSubscriptions) != 1 {
|
||||
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 {
|
||||
t.Errorf("user settings lost: %+v", c)
|
||||
}
|
||||
want := map[string][]string{
|
||||
@@ -131,19 +119,19 @@ theme:
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdates(t *testing.T) {
|
||||
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n"))
|
||||
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
|
||||
t.Errorf("updates = %+v, %v", c.Updates, err)
|
||||
func TestReleases(t *testing.T) {
|
||||
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n"))
|
||||
if err != nil || len(c.Releases) != 1 {
|
||||
t.Errorf("releases = %v, %v", c.Releases, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDelay(t *testing.T) {
|
||||
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
|
||||
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
|
||||
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
|
||||
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
||||
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
||||
t.Errorf("loadDelay = %v, %v", c.LoadDelay, err)
|
||||
}
|
||||
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
|
||||
if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil {
|
||||
t.Error("a minute's delay accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Package expiry says whether a secret, key or certificate has expired
|
||||
// or will soon, and how long it has left, the same way everywhere: the
|
||||
// TUI's lists and status pane, and lazysecrets list.
|
||||
package expiry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State int
|
||||
|
||||
const (
|
||||
None State = iota // no expiry, or not within the warning period
|
||||
Soon // within the warning period
|
||||
Expired // already
|
||||
)
|
||||
|
||||
// Of tells whether something expiring at expires (zero: never) has
|
||||
// expired by now or will within warn, and how long it has left.
|
||||
func Of(expires, now time.Time, warn time.Duration) (State, time.Duration) {
|
||||
if expires.IsZero() {
|
||||
return None, 0
|
||||
}
|
||||
left := expires.Sub(now)
|
||||
switch {
|
||||
case left <= 0:
|
||||
return Expired, 0
|
||||
case left <= warn:
|
||||
return Soon, left
|
||||
}
|
||||
return None, left
|
||||
}
|
||||
|
||||
// Left is a short "12d", or "5h" on the last day.
|
||||
func Left(d time.Duration) string {
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package expiry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOf(t *testing.T) {
|
||||
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
||||
warn := 30 * 24 * time.Hour
|
||||
for _, c := range []struct {
|
||||
expires time.Time
|
||||
want State
|
||||
left string
|
||||
}{
|
||||
{time.Time{}, None, ""},
|
||||
{now.Add(-time.Hour), Expired, ""},
|
||||
{now.Add(12*24*time.Hour + time.Hour), Soon, "12d"},
|
||||
{now.Add(5 * time.Hour), Soon, "5h"},
|
||||
{now.Add(60 * 24 * time.Hour), None, ""},
|
||||
} {
|
||||
state, left := Of(c.expires, now, warn)
|
||||
if state != c.want || (c.left != "" && Left(left) != c.left) {
|
||||
t.Errorf("%v: %v %s, want %v %s", c.expires, state, Left(left), c.want, c.left)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
// Package listing lists what lazysecrets can find, for lazysecrets list:
|
||||
// what Go to searches, without the TUI. What's cached comes first, at
|
||||
// once; then lists that are missing or older than MaxAge load in the
|
||||
// background, a few at a time, and what's new in them follows as they
|
||||
// arrive. Repos are always read, from disk. Values are never listed.
|
||||
package listing
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
// Workers is how many lists load at once, as in Go to.
|
||||
const Workers = 4
|
||||
|
||||
// Kinds are the kinds of entry, from sources down.
|
||||
var Kinds = []string{"source", "vault", "secret", "key", "certificate"}
|
||||
|
||||
// Entry is a source, vault or item, by its lazysecrets:// link.
|
||||
type Entry struct {
|
||||
Link string `json:"link"`
|
||||
// Kind is source (a subscription, workspace or repo), vault (a Key
|
||||
// Vault, secret scope or repo file), secret, key or certificate.
|
||||
Kind string `json:"kind"`
|
||||
// Provider is azure, databricks or repo, as in links.
|
||||
Provider string `json:"provider"`
|
||||
// Path names it: its source's, vault's and own names.
|
||||
Path []string `json:"path"`
|
||||
// Account is the provider it's listed through: an az account
|
||||
// (Azure:<user>/<tenant>), Databricks or Repos. lazysecrets open and
|
||||
// get take it (--account) to use that account when several could.
|
||||
Account string `json:"account"`
|
||||
// AccountLabel names the az account, when there are several.
|
||||
AccountLabel string `json:"accountLabel,omitempty"`
|
||||
// Starred is starred for this account; Recent is used lately (Recent,
|
||||
// R, in the TUI): LastUsed is when, Uses how often.
|
||||
Starred bool `json:"starred"`
|
||||
Recent bool `json:"recent"`
|
||||
LastUsed time.Time `json:"lastUsed,omitzero"`
|
||||
Uses int `json:"uses,omitempty"`
|
||||
// Expires is when a secret, key or certificate expires, if it does;
|
||||
// Enabled whether it's enabled, where the provider says (Azure).
|
||||
Expires time.Time `json:"expires,omitzero"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// Note is the user's note on it, if any.
|
||||
Note string `json:"note,omitempty"`
|
||||
// Reference is how config refers to a secret, where the provider has
|
||||
// a way (@Microsoft.KeyVault(...), {{secrets/scope/key}}); URL its
|
||||
// web page (portal, workspace), if any.
|
||||
Reference string `json:"reference,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
// Error says why a source's vaults or a vault's secrets couldn't be
|
||||
// listed. The entry is then that source or vault, again, or with an
|
||||
// account's sources unreadable, the account without a link.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Providers []provider.Provider
|
||||
// Cache is what was loaded before; Run adds what it loads.
|
||||
Cache cachefile.File
|
||||
// MaxAge: cached lists loaded longer ago are loaded again.
|
||||
MaxAge time.Duration
|
||||
// Cached lists only what's cached (and repos): nothing is loaded.
|
||||
Cached bool
|
||||
// Kinds are the kinds of entry to list (Kinds' names); none is all.
|
||||
// Lists only needed for other kinds aren't loaded: sources and vaults
|
||||
// alone don't load any secrets. Lists that fail are always listed.
|
||||
Kinds []string
|
||||
// Mark tells what's remembered about the entry with link, seen
|
||||
// through account; nil marks nothing.
|
||||
Mark func(link, account string) Marks
|
||||
Timeout func(provider.Kind) time.Duration // nil: 30s
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Marks are what's remembered about an entry: starred for the account,
|
||||
// used lately (Recent) and when and how often, and its note.
|
||||
type Marks struct {
|
||||
Starred, Recent bool
|
||||
LastUsed time.Time
|
||||
Uses int
|
||||
Note string
|
||||
}
|
||||
|
||||
// Run lists everything, calling emit for each entry once, from this
|
||||
// goroutine, and for each list that can't be loaded, as it fails. An
|
||||
// error from emit (the reader went away) stops it. It returns the cache
|
||||
// with what was loaded, and whether anything was.
|
||||
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
|
||||
results: make(chan result)}
|
||||
if l.o.Now == nil {
|
||||
l.o.Now = time.Now
|
||||
}
|
||||
if l.o.Timeout == nil {
|
||||
l.o.Timeout = func(provider.Kind) time.Duration { return 30 * time.Second }
|
||||
}
|
||||
for _, p := range o.Providers {
|
||||
if l.stopped != nil {
|
||||
break
|
||||
}
|
||||
l.provider(p)
|
||||
}
|
||||
l.start(ctx)
|
||||
for l.active > 0 {
|
||||
r := <-l.results
|
||||
l.active--
|
||||
if l.stopped == nil {
|
||||
l.handle(r)
|
||||
l.start(ctx)
|
||||
}
|
||||
if l.stopped != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
return l.o.Cache, l.loaded
|
||||
}
|
||||
|
||||
type job struct {
|
||||
p provider.Provider
|
||||
src *provider.Source // list its vaults
|
||||
c *provider.Container // list its secrets, keys or certificates (t)
|
||||
t provider.ObjectType
|
||||
key string // where the list is cached
|
||||
}
|
||||
|
||||
type result struct {
|
||||
job
|
||||
sources []provider.Source
|
||||
containers []provider.Container
|
||||
objects []provider.Object
|
||||
err error
|
||||
}
|
||||
|
||||
type lister struct {
|
||||
o Options
|
||||
emit func(Entry) error
|
||||
seen map[string]bool // provider ID + link of entries emitted
|
||||
pending map[string]bool // cache keys queued or loading
|
||||
failed map[string]bool // cache keys that couldn't be loaded: not again
|
||||
loaded bool // something was cached
|
||||
queue []job
|
||||
active int
|
||||
results chan result
|
||||
stopped error // emit's error
|
||||
}
|
||||
|
||||
func (l *lister) cacheable(p provider.Provider) bool { return p.Kind() != provider.KindRepo }
|
||||
|
||||
// load reports whether to load a list: a repo's always (from disk), else
|
||||
// unless Cached, when it isn't cached or is older than MaxAge.
|
||||
func (l *lister) load(p provider.Provider, cached bool, at time.Time) bool {
|
||||
if !l.cacheable(p) {
|
||||
return true
|
||||
}
|
||||
return !l.o.Cached && (!cached || l.o.Now().Sub(at) >= l.o.MaxAge)
|
||||
}
|
||||
|
||||
func (l *lister) provider(p provider.Provider) {
|
||||
e, ok := l.o.Cache.Sources[p.ID()]
|
||||
if l.cacheable(p) && ok {
|
||||
for _, src := range sorted(e.Value, func(s provider.Source) string { return s.Name }) {
|
||||
l.source(p, src)
|
||||
}
|
||||
}
|
||||
if l.load(p, ok, e.At) {
|
||||
l.enqueue(job{p: p, key: p.ID()})
|
||||
}
|
||||
}
|
||||
|
||||
// source emits src, and what's cached under it, and queues what's not.
|
||||
func (l *lister) source(p provider.Provider, src provider.Source) {
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
l.put(p, sourceEntry(src))
|
||||
row := cachefile.Row(p.ID(), src.ID)
|
||||
e, ok := l.o.Cache.Containers[row]
|
||||
if l.cacheable(p) && ok {
|
||||
l.containers(p, src, e.Value)
|
||||
}
|
||||
if l.load(p, ok, e.At) {
|
||||
if l.wants("vault", "secret", "key", "certificate") {
|
||||
l.enqueue(job{p: p, src: &src, key: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
|
||||
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
|
||||
c.Source = src
|
||||
l.put(p, containerEntry(c))
|
||||
for _, t := range c.ObjectTypes() {
|
||||
key := cachefile.ObjectsKey(c, t)
|
||||
e, ok := l.o.Cache.Objects[key]
|
||||
if l.cacheable(p) {
|
||||
l.objects(p, c, e.Value)
|
||||
}
|
||||
// Keys and certificates load only when asked for; else,
|
||||
// like Go to, only as far as the TUI opened them.
|
||||
asked := l.wants(strings.ToLower(string(t)))
|
||||
if t != provider.TypeSecret {
|
||||
asked = len(l.o.Kinds) > 0 && asked
|
||||
}
|
||||
if asked && l.load(p, ok, e.At) {
|
||||
l.enqueue(job{p: p, c: &c, t: t, key: key})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lister) objects(p provider.Provider, c provider.Container, objs []provider.Object) {
|
||||
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
|
||||
o.Container = c
|
||||
r, err := ref.ForObject(o, "")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
|
||||
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
|
||||
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL})
|
||||
}
|
||||
}
|
||||
|
||||
func sourceEntry(src provider.Source) Entry {
|
||||
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
|
||||
if r, err := ref.ForSource(src); err == nil {
|
||||
e.Link = r.String()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func containerEntry(c provider.Container) Entry {
|
||||
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL}
|
||||
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
||||
e.Link = r.String()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// put emits e, unless it was already (or emitting has failed). Entries
|
||||
// without a link (one couldn't be made) are left out, but not errors.
|
||||
func (l *lister) put(p provider.Provider, e Entry) {
|
||||
if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
|
||||
return
|
||||
}
|
||||
e.Account = p.ID()
|
||||
if l.o.Mark != nil && e.Link != "" {
|
||||
m := l.o.Mark(e.Link, e.Account)
|
||||
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
|
||||
if m.Recent {
|
||||
e.LastUsed, e.Uses = m.LastUsed, m.Uses
|
||||
}
|
||||
}
|
||||
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
|
||||
if l.stopped != nil || l.seen[id] {
|
||||
return
|
||||
}
|
||||
l.seen[id] = true
|
||||
l.stopped = l.emit(e)
|
||||
}
|
||||
|
||||
// wants reports whether any of kinds is listed.
|
||||
func (l *lister) wants(kinds ...string) bool {
|
||||
if len(l.o.Kinds) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, k := range kinds {
|
||||
if slices.Contains(l.o.Kinds, k) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (l *lister) enqueue(j job) {
|
||||
if l.pending[j.key] || l.failed[j.key] {
|
||||
return
|
||||
}
|
||||
l.pending[j.key] = true
|
||||
l.queue = append(l.queue, j)
|
||||
}
|
||||
|
||||
func (l *lister) start(ctx context.Context) {
|
||||
for l.stopped == nil && l.active < Workers && len(l.queue) > 0 {
|
||||
j := l.queue[0]
|
||||
l.queue = l.queue[1:]
|
||||
l.active++
|
||||
go func() { l.results <- l.fetch(ctx, j) }()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lister) fetch(ctx context.Context, j job) result {
|
||||
kind := j.p.Kind()
|
||||
ctx, cancel := context.WithTimeout(ctx, l.o.Timeout(kind))
|
||||
defer cancel()
|
||||
r := result{job: j}
|
||||
switch {
|
||||
case j.c != nil:
|
||||
r.objects, r.err = j.p.Objects(ctx, *j.c, j.t)
|
||||
case j.src != nil:
|
||||
r.containers, r.err = j.p.Containers(ctx, *j.src)
|
||||
default:
|
||||
r.sources, r.err = j.p.Sources(ctx)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (l *lister) handle(r result) {
|
||||
delete(l.pending, r.key)
|
||||
now := l.o.Now()
|
||||
if r.err != nil {
|
||||
l.failed[r.key] = true
|
||||
l.put(r.p, r.failed())
|
||||
return
|
||||
}
|
||||
l.loaded = l.loaded || l.cacheable(r.p)
|
||||
cache := l.cacheable(r.p)
|
||||
switch {
|
||||
case r.c != nil:
|
||||
if cache {
|
||||
l.o.Cache.Objects[r.key] = cachefile.Stamped[[]provider.Object]{At: now, Value: r.objects}
|
||||
}
|
||||
l.objects(r.p, *r.c, r.objects)
|
||||
case r.src != nil:
|
||||
r.containers = slices.Clone(r.containers) // the provider's may be shared
|
||||
for i := range r.containers {
|
||||
r.containers[i].Source = *r.src
|
||||
}
|
||||
if cache {
|
||||
l.o.Cache.Containers[r.key] = cachefile.Stamped[[]provider.Container]{At: now, Value: r.containers}
|
||||
}
|
||||
l.containers(r.p, *r.src, r.containers)
|
||||
default:
|
||||
if cache {
|
||||
l.o.Cache.Sources[r.key] = cachefile.Stamped[[]provider.Source]{At: now, Value: r.sources}
|
||||
}
|
||||
for _, src := range r.sources {
|
||||
l.source(r.p, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failed is the entry for a list that couldn't be loaded.
|
||||
func (r result) failed() Entry {
|
||||
var e Entry
|
||||
switch {
|
||||
case r.c != nil:
|
||||
e = containerEntry(*r.c)
|
||||
case r.src != nil:
|
||||
src := *r.src
|
||||
src.Provider, src.ProviderLabel = r.p.ID(), r.p.Label()
|
||||
e = sourceEntry(src)
|
||||
default:
|
||||
e = Entry{Kind: "source", Provider: kindOf(r.p.Kind()), Path: []string{r.p.Label()}}
|
||||
}
|
||||
e.Error = firstLine(r.err.Error())
|
||||
return e
|
||||
}
|
||||
|
||||
// firstLine is an error's first line, the summary (Azure's go on with
|
||||
// the caller's IDs and more): an entry is one line. Tabs, which would
|
||||
// split it into columns, are spaces.
|
||||
func firstLine(s string) string {
|
||||
for line := range strings.Lines(s) {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
return strings.ReplaceAll(line, "\t", " ")
|
||||
}
|
||||
}
|
||||
return "unreadable"
|
||||
}
|
||||
|
||||
func kindOf(k provider.Kind) string { return strings.ToLower(string(k)) }
|
||||
|
||||
// accountOf is the account part of a source's provider label ("alice" in
|
||||
// "Azure · alice"), or "" when there's a single account.
|
||||
func accountOf(src provider.Source) string {
|
||||
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
func sorted[T any](s []T, name func(T) string) []T {
|
||||
s = slices.Clone(s)
|
||||
slices.SortStableFunc(s, func(a, b T) int {
|
||||
return cmp.Compare(strings.ToLower(name(a)), strings.ToLower(name(b)))
|
||||
})
|
||||
return s
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
)
|
||||
|
||||
// counting counts a provider's requests.
|
||||
type counting struct {
|
||||
provider.Provider
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (c *counting) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
c.calls.Add(1)
|
||||
return c.Provider.Sources(ctx)
|
||||
}
|
||||
|
||||
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
c.calls.Add(1)
|
||||
return c.Provider.Containers(ctx, src)
|
||||
}
|
||||
|
||||
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
c.calls.Add(1)
|
||||
return c.Provider.Objects(ctx, ct, t)
|
||||
}
|
||||
|
||||
// run lists, returning the entries and, apart, the lists that failed.
|
||||
func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
|
||||
t.Helper()
|
||||
if o.Cache.Version == 0 {
|
||||
o.Cache = cachefile.New()
|
||||
}
|
||||
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
|
||||
var got, failed []Entry
|
||||
f, _ := Run(context.Background(), o, func(e Entry) error {
|
||||
if e.Error != "" {
|
||||
failed = append(failed, e)
|
||||
} else {
|
||||
got = append(got, e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return got, f, failed
|
||||
}
|
||||
|
||||
func has(es []Entry, kind, path string) bool {
|
||||
for _, e := range es {
|
||||
if e.Kind == kind && strings.Join(e.Path, "/") == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestListLoadsAndCaches(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := &counting{Provider: fake.Azure(0)}
|
||||
got, f, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, MaxAge: time.Minute})
|
||||
for _, want := range [][2]string{
|
||||
{"source", "platform-prod"}, {"vault", "platform-prod/kv-app-prod"}, {"secret", "platform-prod/kv-app-prod/db-password"},
|
||||
{"source", "lazysecrets-demo"}, {"secret", "lazysecrets-demo/.env/DATABASE_URL"},
|
||||
} {
|
||||
if !has(got, want[0], want[1]) {
|
||||
t.Errorf("no %s %s", want[0], want[1])
|
||||
}
|
||||
}
|
||||
if len(errs) != 1 || errs[0].Kind != "vault" || errs[0].Path[1] != "kv-restricted" || !strings.Contains(errs[0].Error, "403") || errs[0].Link == "" {
|
||||
t.Errorf("failed = %+v", errs)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, e := range got {
|
||||
if seen[e.Link] {
|
||||
t.Errorf("%s listed twice", e.Link)
|
||||
}
|
||||
seen[e.Link] = true
|
||||
if e.Provider != "azure" && e.Provider != "repo" {
|
||||
t.Errorf("provider %q", e.Provider)
|
||||
}
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Kind == "secret" && e.Provider == "azure" && (!strings.HasPrefix(e.Reference, "@Microsoft.KeyVault(") || e.URL == "") {
|
||||
t.Errorf("%s: reference %q, url %q", e.Link, e.Reference, e.URL)
|
||||
}
|
||||
}
|
||||
if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 {
|
||||
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
|
||||
}
|
||||
for k := range f.Containers {
|
||||
if cachefile.ProviderOf(k) != az.ID() {
|
||||
t.Errorf("cached a repo: %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Again with that cache: the same, without asking Azure.
|
||||
before := az.calls.Load()
|
||||
again, _, _ := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, MaxAge: time.Minute})
|
||||
if len(again) != len(got) {
|
||||
t.Errorf("%d entries from the cache, %d loaded", len(again), len(got))
|
||||
}
|
||||
// The vault that couldn't be read wasn't cached: it's asked again.
|
||||
if n := az.calls.Load() - before; n != 1 {
|
||||
t.Errorf("%d requests with a fresh cache, want 1 (kv-restricted)", n)
|
||||
}
|
||||
|
||||
// Stale: loaded again, but each entry is still listed once.
|
||||
before = az.calls.Load()
|
||||
stale, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
|
||||
Now: func() time.Time { return time.Now().Add(time.Hour) }})
|
||||
if az.calls.Load()-before < 3 {
|
||||
t.Error("stale lists weren't loaded again")
|
||||
}
|
||||
links := map[string]bool{}
|
||||
for _, e := range stale {
|
||||
if links[e.Link] {
|
||||
t.Errorf("%s listed twice", e.Link)
|
||||
}
|
||||
links[e.Link] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCachedOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := &counting{Provider: fake.Azure(0)}
|
||||
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
|
||||
before := az.calls.Load()
|
||||
got, _, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, Cached: true,
|
||||
Now: func() time.Time { return time.Now().Add(24 * time.Hour) }})
|
||||
if az.calls.Load() != before || len(errs) != 0 {
|
||||
t.Errorf("--cached asked Azure: %d requests, %v", az.calls.Load()-before, errs)
|
||||
}
|
||||
if !has(got, "secret", "platform-prod/kv-app-prod/db-password") || !has(got, "secret", "lazysecrets-demo/.env/DATABASE_URL") {
|
||||
t.Error("cached secrets or repos missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCachedFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := fake.Azure(0)
|
||||
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
|
||||
// Forget one vault's secrets: they load, after everything cached.
|
||||
var dropped string
|
||||
for k := range f.Objects {
|
||||
if strings.Contains(k, "kv-app-prod") && strings.HasSuffix(k, "|Secret") {
|
||||
dropped = k
|
||||
delete(f.Objects, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
if dropped == "" {
|
||||
t.Fatalf("no kv-app-prod in %v", f.Objects)
|
||||
}
|
||||
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute})
|
||||
last := got[len(got)-1]
|
||||
if last.Kind != "secret" || last.Path[1] != "kv-app-prod" {
|
||||
t.Errorf("last = %+v, want a kv-app-prod secret, loaded after the cache", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListStopsWhenTheReaderGoes(t *testing.T) {
|
||||
t.Parallel()
|
||||
gone := errors.New("broken pipe")
|
||||
n := 0
|
||||
var errs []Entry
|
||||
Run(context.Background(), Options{
|
||||
Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute,
|
||||
Timeout: func(provider.Kind) time.Duration { return time.Second },
|
||||
}, func(e Entry) error {
|
||||
if e.Error != "" {
|
||||
errs = append(errs, e)
|
||||
}
|
||||
n++
|
||||
if n == 2 {
|
||||
return gone
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if n != 2 || len(errs) != 0 {
|
||||
t.Errorf("emitted %d after the reader went, errs %v", n, errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListKinds(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Sources only: nothing under them is asked for.
|
||||
az := &counting{Provider: fake.Azure(0)}
|
||||
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"source"}})
|
||||
if len(got) == 0 || az.calls.Load() != 1 {
|
||||
t.Errorf("%d sources, %d requests, want just the sources'", len(got), az.calls.Load())
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Kind != "source" {
|
||||
t.Errorf("listed a %s", e.Kind)
|
||||
}
|
||||
}
|
||||
// Keys, asked for: loaded, though Go to wouldn't; no secrets.
|
||||
got, _, failed := run(t, Options{Providers: []provider.Provider{fake.Azure(0)}, MaxAge: time.Minute, Kinds: []string{"key"}})
|
||||
if !has(got, "key", "platform-prod/kv-app-prod/signing-key") {
|
||||
t.Errorf("no keys: %+v", got)
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Kind != "key" {
|
||||
t.Errorf("listed a %s", e.Kind)
|
||||
}
|
||||
}
|
||||
if len(failed) == 0 {
|
||||
t.Error("a list that failed wasn't listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsAreOneLine(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"403 Forbidden: Caller is not authorized\nCaller: appid=x;oid=y\nVault: kv;location=z": "403 Forbidden: Caller is not authorized",
|
||||
"\n timeout\tafter 30s\n": "timeout after 30s",
|
||||
"": "unreadable",
|
||||
} {
|
||||
if got := firstLine(in); got != want {
|
||||
t.Errorf("firstLine(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarks(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := fake.Azure(0)
|
||||
var starredLink string
|
||||
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
|
||||
Mark: func(link, account string) Marks {
|
||||
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
|
||||
starredLink = link
|
||||
}
|
||||
if strings.HasSuffix(link, "/api-key") {
|
||||
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
|
||||
}
|
||||
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
|
||||
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
|
||||
}
|
||||
return Marks{Starred: link == starredLink && account == az.ID()}
|
||||
}})
|
||||
var starred, recent int
|
||||
for _, e := range got {
|
||||
if e.Starred {
|
||||
starred++
|
||||
if e.Link != starredLink {
|
||||
t.Errorf("%s starred", e.Link)
|
||||
}
|
||||
}
|
||||
if (e.Note != "") != strings.HasSuffix(e.Link, "/api-key") {
|
||||
t.Errorf("%s: note %q", e.Link, e.Note)
|
||||
}
|
||||
if e.Recent {
|
||||
recent++
|
||||
if e.Uses != 3 || e.LastUsed.IsZero() {
|
||||
t.Errorf("%s: %d uses, last %v", e.Link, e.Uses, e.LastUsed)
|
||||
}
|
||||
} else if e.Uses != 0 || !e.LastUsed.IsZero() {
|
||||
t.Errorf("%s not recent, but %d uses, last %v", e.Link, e.Uses, e.LastUsed)
|
||||
}
|
||||
}
|
||||
if starred != 1 || recent == 0 {
|
||||
t.Errorf("%d starred, %d recent", starred, recent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedListNotRetried(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := &counting{Provider: fake.Azure(0)}
|
||||
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
|
||||
// Everything stale: sources reload, and their vaults are queued again
|
||||
// as each list arrives. kv-restricted fails once, and isn't asked again.
|
||||
before := az.calls.Load()
|
||||
_, _, failed := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
|
||||
Now: func() time.Time { return time.Now().Add(time.Hour) }})
|
||||
if len(failed) != 1 {
|
||||
t.Errorf("failed: %+v", failed)
|
||||
}
|
||||
fresh := &counting{Provider: fake.Azure(0)}
|
||||
run(t, Options{Providers: []provider.Provider{fresh}, MaxAge: time.Minute})
|
||||
if n := az.calls.Load() - before; n > fresh.calls.Load() {
|
||||
t.Errorf("%d requests with a stale cache, more than %d from scratch: something was asked twice", n, fresh.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithoutTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
Run(context.Background(), Options{Providers: []provider.Provider{fake.Repos()}, Cache: cachefile.New()},
|
||||
func(Entry) error { return nil }) // no Timeout: a default, not a panic
|
||||
}
|
||||
@@ -218,19 +218,6 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
|
||||
return c
|
||||
}
|
||||
|
||||
// Expire sets when the object name in vault expires, for --demo: a date
|
||||
// relative to today in the test data would change the tests' screenshots
|
||||
// from day to day.
|
||||
func (p *Provider) Expire(vault, name string, at time.Time) {
|
||||
for _, objs := range p.objects {
|
||||
for i := range objs {
|
||||
if objs[i].Container.Name == vault && objs[i].Name == name {
|
||||
objs[i].Expires = at
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
|
||||
o.Container = c
|
||||
o.Type = t
|
||||
@@ -300,13 +287,9 @@ func Azure(delay time.Duration) *Provider {
|
||||
Reference: secretRef("kv-app-prod", "api-key"),
|
||||
}, "sk_live_51HxDEMO0000000000000000")
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
||||
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
|
||||
Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||
}, "deprecated")
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
|
||||
Reference: secretRef("kv-app-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
|
||||
p.add(kv, provider.TypeKey, provider.Object{
|
||||
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
||||
@@ -349,7 +332,7 @@ func Azure(delay time.Duration) *Provider {
|
||||
}, demoCert)
|
||||
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
// A key to share env files with (S → f): random, as sharing.keyRules wants.
|
||||
// A key to share env files with (S → f): random, as share.keyRules wants.
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
|
||||
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
|
||||
@@ -366,19 +349,6 @@ func Azure(delay time.Duration) *Provider {
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
||||
}, "dev-password")
|
||||
// Left behind, as dev vaults are: expired, disabled, or both.
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
||||
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
|
||||
}, "whsec_demo_expired")
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0),
|
||||
Reference: secretRef("kv-app-dev", "test-user-password"),
|
||||
}, "disabled-demo-password")
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0),
|
||||
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
|
||||
}, "expired-and-disabled")
|
||||
|
||||
return p
|
||||
}
|
||||
@@ -396,8 +366,7 @@ func AzureOps(delay time.Duration) *Provider {
|
||||
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
||||
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
||||
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
||||
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
|
||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||
}, "break-glass-demo-password")
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
@@ -415,12 +384,6 @@ func AzureOps(delay time.Duration) *Provider {
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
||||
}, "pd-demo-token")
|
||||
// Expiring soon whenever the demo runs: the other dates are fixed,
|
||||
// and would have passed. Tests use the first account only.
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0),
|
||||
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
|
||||
}, "glsa_demo_expiring")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package fake
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// Seed stars, notes and uses a few of the demo's secrets, so --demo
|
||||
// shows Favourites (F), Recent (R), notes, and stars in lazysecrets list. alice and
|
||||
// admin are the two Azure accounts' provider IDs.
|
||||
func Seed(st *store.Store, alice, admin string) {
|
||||
type item struct {
|
||||
link, account, source string
|
||||
name string // else the link's last part
|
||||
note string
|
||||
path []string
|
||||
star bool
|
||||
uses int
|
||||
ago time.Duration
|
||||
}
|
||||
const azure, dbx = "lazysecrets://azure/", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/"
|
||||
for _, it := range []item{
|
||||
{link: azure + "kv-app-prod.vault.azure.net/secrets/db-password", account: alice, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-app-prod"}, star: true, uses: 12, ago: 20 * time.Minute,
|
||||
note: "rotated by the data team each quarter"},
|
||||
{link: azure + "kv-app-prod.vault.azure.net/secrets/api-key", account: alice, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-app-prod"}, uses: 4, ago: 2 * time.Hour},
|
||||
{link: azure + "kv-breakglass.vault.azure.net/secrets/emergency-admin", account: admin, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"},
|
||||
{link: azure + "kv-shared-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-shared-prod"}, uses: 2, ago: 26 * time.Hour},
|
||||
{link: azure + "kv-app-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-app-prod"}, note: "migrated to Microsoft Graph sendMail; delete after Q4"},
|
||||
{link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
|
||||
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
|
||||
// A subscription, for both accounts, and a repo's file.
|
||||
{link: azure + "subscriptions/sub-prod", account: alice, source: "sub-prod",
|
||||
name: "platform-prod", star: true},
|
||||
{link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
|
||||
name: "platform-prod", star: true},
|
||||
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
|
||||
source: "/home/dev/git/lazysecrets-demo", name: "deploy/prod/secrets.yaml",
|
||||
path: []string{"lazysecrets-demo"}, star: true},
|
||||
} {
|
||||
context := func(n *store.Node) {
|
||||
n.Name = cmp.Or(it.name, it.link[strings.LastIndex(it.link, "/")+1:])
|
||||
n.Path, n.Source, n.Account = it.path, it.source, it.account
|
||||
}
|
||||
if it.star || it.note != "" {
|
||||
_ = st.Update(it.link, func(n *store.Node) {
|
||||
context(n)
|
||||
if it.star {
|
||||
n.ToggleStar(it.account)
|
||||
}
|
||||
n.Note = it.note
|
||||
})
|
||||
}
|
||||
for range it.uses {
|
||||
_ = st.Use(it.link, time.Now().Add(-it.ago), context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,10 +80,6 @@ type repoInfo struct {
|
||||
here bool // the repo lazysecrets started in
|
||||
}
|
||||
|
||||
// DefaultRoots are the usual folders of repos, used when the config
|
||||
// doesn't set roots. Ones that don't exist cost a stat each.
|
||||
var DefaultRoots = []string{"~/git", "~/repo", "~/repos", "~/projects", "~/code", "~/source", "~/src"}
|
||||
|
||||
func New(s Settings) *Provider {
|
||||
if s.Sops == "" {
|
||||
s.Sops = "sops"
|
||||
@@ -120,18 +116,10 @@ func (p *Provider) load() {
|
||||
}
|
||||
add(root)
|
||||
}
|
||||
// Missing roots are skipped (most of the defaults are), and one
|
||||
// that's a symlink to another is scanned once.
|
||||
scanned := map[string]bool{}
|
||||
for _, dir := range p.s.Roots {
|
||||
base, err := filepath.EvalSymlinks(expandHome(dir))
|
||||
if err != nil || scanned[base] {
|
||||
continue
|
||||
}
|
||||
scanned[base] = true
|
||||
for _, root := range reposIn(base, 2) {
|
||||
// A repo under two roots keeps the first one's name.
|
||||
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") && names[root] == "" {
|
||||
base, _ := filepath.EvalSymlinks(expandHome(dir))
|
||||
for _, root := range reposIn(expandHome(dir), 2) {
|
||||
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
|
||||
names[root] = filepath.ToSlash(name)
|
||||
}
|
||||
add(root)
|
||||
@@ -403,11 +391,10 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
file, extract, ok := strings.Cut(o.ID, "\x00")
|
||||
if !ok { // made from a link: the repo's root, the file's path, the key
|
||||
file = o.Container.ID
|
||||
file, extract = o.Container.ID, extractFor(o.Name)
|
||||
if file == "" {
|
||||
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
|
||||
}
|
||||
extract = extractFor(file, o.Name)
|
||||
}
|
||||
if _, isSops := readMeta(file); !isSops {
|
||||
return plainValue(file, o.Name)
|
||||
@@ -600,14 +587,14 @@ func normalizeRemote(remote string) (string, string) {
|
||||
type lazysecretsFile struct {
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Sharing struct {
|
||||
Share struct {
|
||||
// Key is the link to the secret that encrypts the repo's
|
||||
// shared env files (package share).
|
||||
Key string `yaml:"key"`
|
||||
} `yaml:"sharing"`
|
||||
} `yaml:"share"`
|
||||
}
|
||||
|
||||
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
|
||||
// ShareKey is the share.key of the repo's .lazysecrets.yaml, if any.
|
||||
func ShareKey(root string) (string, error) {
|
||||
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -620,7 +607,7 @@ func ShareKey(root string) (string, error) {
|
||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
||||
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
||||
}
|
||||
return f.Sharing.Key, nil
|
||||
return f.Share.Key, nil
|
||||
}
|
||||
|
||||
// selector says which files to look at: user (the include/exclude of the
|
||||
@@ -1013,7 +1000,7 @@ func formatOf(file string) string {
|
||||
}
|
||||
|
||||
type key struct {
|
||||
name string // db.password, list[0], with . [ ] \ in keys escaped
|
||||
name string // db.password, list[0]
|
||||
extract string // sops --extract: ["db"]["password"]
|
||||
encrypted bool
|
||||
line int // 1-based
|
||||
@@ -1058,7 +1045,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@@ -1075,9 +1062,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
|
||||
if top && k == "sops" {
|
||||
continue
|
||||
}
|
||||
child := escape(k)
|
||||
child := k
|
||||
if name != "" {
|
||||
child = name + "." + child
|
||||
child = name + "." + k
|
||||
}
|
||||
walk(n.Content[i+1], child, extract+quote(k), false, out)
|
||||
}
|
||||
@@ -1099,56 +1086,23 @@ func quote(k string) string {
|
||||
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
|
||||
}
|
||||
|
||||
// escape keeps a key's dots and brackets from reading as nesting in a
|
||||
// name: app\.name is one key, app.name two.
|
||||
func escape(k string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
|
||||
}
|
||||
|
||||
// extractFor rebuilds the extract path from a key name, for objects made
|
||||
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
|
||||
// its names are the keys as they are.
|
||||
func extractFor(file, name string) string {
|
||||
if formatOf(file) == "dotenv" {
|
||||
return quote(name)
|
||||
}
|
||||
var b, part strings.Builder
|
||||
flush := func() {
|
||||
if part.Len() > 0 {
|
||||
b.WriteString(quote(part.String()))
|
||||
part.Reset()
|
||||
// from a link (db.password, list[0]). Names with dots in keys can't be
|
||||
// told apart from nesting: those come from the listing instead.
|
||||
func extractFor(name string) string {
|
||||
var b strings.Builder
|
||||
for _, part := range strings.Split(name, ".") {
|
||||
base, idx, _ := strings.Cut(part, "[")
|
||||
if base != "" {
|
||||
b.WriteString(quote(base))
|
||||
}
|
||||
if idx != "" {
|
||||
b.WriteString("[" + idx)
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
switch c := name[i]; c {
|
||||
case '\\':
|
||||
if i+1 < len(name) {
|
||||
i++
|
||||
}
|
||||
part.WriteByte(name[i])
|
||||
case '.':
|
||||
flush()
|
||||
case '[':
|
||||
end := strings.IndexByte(name[i:], ']')
|
||||
if end < 0 || !digits(name[i+1:i+end]) {
|
||||
part.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
b.WriteString(name[i : i+end+1])
|
||||
i += end
|
||||
default:
|
||||
part.WriteByte(c)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func digits(s string) bool {
|
||||
return s != "" && strings.Trim(s, "0123456789") == ""
|
||||
}
|
||||
|
||||
func absPath(p string) string {
|
||||
if a, err := filepath.Abs(p); err == nil {
|
||||
return a
|
||||
|
||||
@@ -336,30 +336,6 @@ func TestRoots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootsDeduped(t *testing.T) {
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
os.Rename(gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}), filepath.Join(root, "infra"))
|
||||
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
|
||||
os.Rename(gitRepo(t, map[string]string{"prod.env": sopsEnv}), filepath.Join(root, "acme", "billing"))
|
||||
|
||||
// ~/src → ~/git, and a root reaching one repo at another depth.
|
||||
links := t.TempDir()
|
||||
alias := filepath.Join(links, "src")
|
||||
os.Symlink(root, alias)
|
||||
missing := filepath.Join(links, "nope")
|
||||
acme := filepath.Join(root, "acme")
|
||||
|
||||
srcs, err := New(Settings{Dir: links, Roots: []string{missing, root, alias, acme}}).Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(srcs, func(s provider.Source) string { return s.Name })
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
|
||||
t.Errorf("repos: %v, want each once, named from the first root", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarredReposAlongside(t *testing.T) {
|
||||
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
||||
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
|
||||
@@ -545,24 +521,3 @@ func TestFileDetails(t *testing.T) {
|
||||
t.Errorf("untracked env: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyNamesEscaped(t *testing.T) {
|
||||
keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, k := range keys {
|
||||
got = append(got, k.name+" "+k.extract)
|
||||
if e := extractFor("s.yaml", k.name); e != k.extract {
|
||||
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
|
||||
}
|
||||
}
|
||||
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
|
||||
if strings.Join(got, "|") != want {
|
||||
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
|
||||
}
|
||||
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
|
||||
t.Errorf("dotenv: %s", e)
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -56,10 +56,6 @@ type Ref struct {
|
||||
// Share is a share link's sealed files, as in the link; the other
|
||||
// fields are empty. Package share reads it.
|
||||
Share string
|
||||
// Account, when set, is the provider (its ID) to open or read it
|
||||
// with when several could, from lazysecrets list. It isn't part of
|
||||
// the link.
|
||||
Account string
|
||||
}
|
||||
|
||||
// IsShare reports whether r is a share link.
|
||||
@@ -314,7 +310,7 @@ func parseLink(s string) (Ref, error) {
|
||||
}
|
||||
|
||||
default:
|
||||
return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host)
|
||||
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (r Rules) Check(key []byte) error {
|
||||
if len(has) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("its value has %s; sharing.keyRules needs %s",
|
||||
return fmt.Errorf("its value has %s; share.keyRules needs %s",
|
||||
strings.Join(has, " and "), strings.Join(needs, " and "))
|
||||
}
|
||||
|
||||
|
||||
@@ -259,8 +259,8 @@ func CheckPath(p string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultKey is the key for a repo's files: sharing.key in the repo's
|
||||
// .lazysecrets.yaml, else general (sharing.key in the config). ok is false
|
||||
// DefaultKey is the key for a repo's files: share.key in the repo's
|
||||
// .lazysecrets.yaml, else general (share.key in the config). ok is false
|
||||
// when neither names one.
|
||||
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
|
||||
link, err := repo.ShareKey(root)
|
||||
@@ -276,7 +276,7 @@ func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
|
||||
}
|
||||
key, err = ParseKey(link)
|
||||
if err != nil {
|
||||
return ref.Ref{}, false, fmt.Errorf("sharing.key in %s: %w", where, err)
|
||||
return ref.Ref{}, false, fmt.Errorf("share.key in %s: %w", where, err)
|
||||
}
|
||||
return key, true, nil
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestDefaultKey(t *testing.T) {
|
||||
if err != nil || !ok || k.Name != "env-share" {
|
||||
t.Errorf("general: %+v %v %v", k, ok, err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("sharing:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("share:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
|
||||
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
|
||||
t.Errorf("the repo's should win: %+v", k)
|
||||
}
|
||||
|
||||
+6
-20
@@ -43,13 +43,6 @@ type Node struct {
|
||||
// alone isn't.
|
||||
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
|
||||
|
||||
// UsedLately reports whether the item was used within the last 90 days
|
||||
// (what Recent lists). Starred and noted records are kept longer, so a
|
||||
// use alone doesn't say.
|
||||
func (n Node) UsedLately(now time.Time) bool {
|
||||
return !n.LastUsed.IsZero() && now.Sub(n.LastUsed) <= usageMaxAge
|
||||
}
|
||||
|
||||
// StarredFor reports whether the item is starred for account.
|
||||
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
|
||||
|
||||
@@ -152,23 +145,16 @@ type Store struct {
|
||||
data file
|
||||
}
|
||||
|
||||
// ErrNewer is a state file written by a newer lazysecrets.
|
||||
var ErrNewer = errors.New("written by a newer lazysecrets")
|
||||
|
||||
// Load reads path; a missing file is an empty store. path "" gives a store
|
||||
// that never saves, for tests and --demo. A file from a newer lazysecrets
|
||||
// is read but never saved, so nothing it keeps is lost: Load returns the
|
||||
// store with an ErrNewer error.
|
||||
// that never saves, for tests and --demo.
|
||||
func Load(path string) (*Store, error) {
|
||||
s := &Store{path: path}
|
||||
d, err := read(path)
|
||||
if errors.Is(err, ErrNewer) {
|
||||
s.path = ""
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.data = d
|
||||
return s, err
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func read(path string) (file, error) {
|
||||
@@ -186,12 +172,12 @@ func read(path string) (file, error) {
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
return d, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if d.Version > version {
|
||||
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
|
||||
}
|
||||
if d.Nodes == nil {
|
||||
d.Nodes = map[string]Node{}
|
||||
}
|
||||
if d.Version > version {
|
||||
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -66,17 +65,11 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerVersionReadOnly(t *testing.T) {
|
||||
func TestNewerVersionRefused(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
|
||||
_ = os.WriteFile(path, []byte(newer), 0o600)
|
||||
s, err := Load(path)
|
||||
if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" {
|
||||
t.Fatalf("Load = %v, %v", s, err)
|
||||
}
|
||||
_ = s.SetFlag("x", true)
|
||||
if b, _ := os.ReadFile(path); string(b) != newer {
|
||||
t.Errorf("the newer file was overwritten: %s", b)
|
||||
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Error("a newer file was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,15 +112,3 @@ func TestStarsPerAccount(t *testing.T) {
|
||||
t.Errorf("unstar alice: %v", n.StarredBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsedLately(t *testing.T) {
|
||||
now := time.Now()
|
||||
for ago, want := range map[time.Duration]bool{time.Hour: true, 89 * 24 * time.Hour: true, 2 * 365 * 24 * time.Hour: false} {
|
||||
if got := (Node{LastUsed: now.Add(-ago)}).UsedLately(now); got != want {
|
||||
t.Errorf("used %v ago: %v", ago, got)
|
||||
}
|
||||
}
|
||||
if (Node{}).UsedLately(now) {
|
||||
t.Error("never used is lately")
|
||||
}
|
||||
}
|
||||
+32
-86
@@ -65,8 +65,6 @@ const (
|
||||
type Options struct {
|
||||
// Timeout bounds every provider call. Defaults to 30s.
|
||||
Timeout time.Duration
|
||||
// Timeouts override Timeout for the providers of a kind.
|
||||
Timeouts map[provider.Kind]time.Duration
|
||||
// Icons shows Nerd Font glyphs next to backends.
|
||||
Icons bool
|
||||
// Clipboard defaults to the system clipboard, or OSC52 over SSH.
|
||||
@@ -79,15 +77,12 @@ type Options struct {
|
||||
// and keeps its state, for the status pane.
|
||||
ConfigFiles []string
|
||||
StatePath string
|
||||
// Warning shows when the window opens: what lazysecrets skipped
|
||||
// instead of failing to start.
|
||||
Warning string
|
||||
// Changelog is CHANGELOG.md, for the status pane.
|
||||
Changelog string
|
||||
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
||||
// run only.
|
||||
CacheFile string
|
||||
// ShareKey is the config's sharing.key: the secret that encrypts shared
|
||||
// ShareKey is the config's share.key: the secret that encrypts shared
|
||||
// env files, for repos whose .lazysecrets.yaml names none.
|
||||
ShareKey string
|
||||
// ShareRules are what a key's value must be to share with it.
|
||||
@@ -122,24 +117,15 @@ type Options struct {
|
||||
// Keys, if set, replaces the default keymap (keys.Map.Apply).
|
||||
Keys *keys.Map
|
||||
// Updater, if set, finds and installs newer releases (U); with
|
||||
// UpdateCheck, lazysecrets also looks on its own at startup, at most
|
||||
// once per UpdateEvery (0 for a day).
|
||||
// UpdateCheck, lazysecrets also looks once a day on its own.
|
||||
Updater *Updater
|
||||
UpdateCheck bool
|
||||
UpdateEvery time.Duration
|
||||
// HandlerSetup, if set, offers to set up the link handler at startup.
|
||||
HandlerSetup *HandlerSetup
|
||||
// NoHistory stops counting uses of items for the Recent list.
|
||||
NoHistory bool
|
||||
// RecentIcon marks items used lately with ◷ in the lists
|
||||
// (ui.recentIcon).
|
||||
RecentIcon bool
|
||||
// Open is a reference to open at startup (lazysecrets open <ref>).
|
||||
Open *ref.Ref
|
||||
// OpenAction, if set, is what to do with Open, rather than asking:
|
||||
// go to it, and maybe copy or show its value (OpenActions). It ends in
|
||||
// the list, like Go to, rather than on the details.
|
||||
OpenAction string
|
||||
// Styles, if set, replaces the default theme (styles.FromTheme), and
|
||||
// DefaultFg the terminal's default text colour.
|
||||
Styles *styles.Styles
|
||||
@@ -158,7 +144,7 @@ type Clipboard interface {
|
||||
type Model struct {
|
||||
providers []provider.Provider
|
||||
byID map[string]provider.Provider
|
||||
timeout timeouts
|
||||
timeout time.Duration
|
||||
|
||||
keys keys.Map
|
||||
st styles.Styles
|
||||
@@ -213,12 +199,9 @@ type Model struct {
|
||||
statusW int // the status pane's width: the left column's, zoomed or not
|
||||
changelog []panes.Field
|
||||
cacheDirty bool
|
||||
// cacheDropped are cache keys deleted since the last save, so saving
|
||||
// deletes them from the file too.
|
||||
cacheDropped map[string]bool
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
savedPlace string // the link last saved as the place
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
savedPlace string // the link last saved as the place
|
||||
|
||||
// A pending clear of a copied value, counted down in the footer.
|
||||
// clipToken identifies the latest copy so older ticks are ignored.
|
||||
@@ -253,7 +236,6 @@ type Model struct {
|
||||
|
||||
updater *Updater
|
||||
updateCheck bool
|
||||
updateEvery time.Duration
|
||||
fetchOnStart bool // look for a newer release in Init
|
||||
newer *update.Release // a newer release to offer
|
||||
updated string // the release installed, waiting for a restart
|
||||
@@ -293,11 +275,9 @@ type Model struct {
|
||||
prompt *modal // popup that has the keyboard
|
||||
linked []provider.Container // vaults opened from links, this session
|
||||
open *ref.Ref // reference to open at startup
|
||||
recentIcon bool // mark items used lately (◷)
|
||||
openAction string // and what to do with it, or ask
|
||||
|
||||
shareKey string // the config's sharing.key
|
||||
shareRules share.Rules // and sharing.keyRules
|
||||
shareKey string // the config's share.key
|
||||
shareRules share.Rules // and share.keyRules
|
||||
|
||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
||||
dismissedLink string // the last link offered: not offered again
|
||||
@@ -395,7 +375,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m := Model{
|
||||
providers: providers,
|
||||
byID: map[string]provider.Provider{},
|
||||
timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
|
||||
timeout: opts.Timeout,
|
||||
keys: keys.Default(),
|
||||
st: styles.Default(),
|
||||
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
|
||||
@@ -427,8 +407,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
if m.store == nil {
|
||||
m.store, _ = store.Load("")
|
||||
}
|
||||
m.recentIcon = opts.RecentIcon
|
||||
m.sources = newSourcesList(iconSet, m.store, m.recentIcon)
|
||||
m.sources = newSourcesList(iconSet, m.store)
|
||||
m.sources.Empty = "no sources found"
|
||||
m.collapsed = map[string]bool{}
|
||||
m.flatSources = opts.FlatSources
|
||||
@@ -448,10 +427,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
|
||||
m.expiryWarning = opts.ExpiryWarning
|
||||
m.handlerSetup = opts.HandlerSetup
|
||||
m.updater, m.updateCheck, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery
|
||||
if m.updateEvery == 0 {
|
||||
m.updateEvery = updateEvery
|
||||
}
|
||||
m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck
|
||||
m.startUpdateCheck()
|
||||
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
|
||||
m.settle = opts.LoadDelay
|
||||
@@ -460,7 +436,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
}
|
||||
m.defaultFg = opts.DefaultFg
|
||||
m.help = newHelp(m.st)
|
||||
m.open, m.openAction = opts.Open, opts.OpenAction
|
||||
m.open = opts.Open
|
||||
saved := m.store.Layout()
|
||||
m.split, m.heights = saved.Split, saved.Heights
|
||||
|
||||
@@ -490,9 +466,6 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
||||
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
|
||||
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
|
||||
if opts.Warning != "" {
|
||||
m.flash = warning(opts.Warning)
|
||||
}
|
||||
if m.cachePath != "" {
|
||||
m.loadCacheFile()
|
||||
if len(m.sourceResults) > 0 {
|
||||
@@ -534,26 +507,18 @@ func (m Model) Init() tea.Cmd {
|
||||
cmds = append(cmds, m.fetchLatest(false))
|
||||
}
|
||||
if m.open != nil {
|
||||
msg := openRefMsg{ref: *m.open, action: m.openAction}
|
||||
cmds = append(cmds, func() tea.Msg { return msg })
|
||||
r := *m.open
|
||||
cmds = append(cmds, func() tea.Msg { return openRefMsg{r} })
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// openRefMsg starts opening a reference from outside the key handler:
|
||||
// asking what to do with it, or doing action (OpenActions).
|
||||
type openRefMsg struct {
|
||||
ref ref.Ref
|
||||
action string
|
||||
}
|
||||
|
||||
// OpenActions are what lazysecrets open --action takes, the Open link
|
||||
// menu's choices that go there.
|
||||
var OpenActions = map[string]linkAction{"go": actGo, "copy": actCopy, "show": actShow}
|
||||
// openRefMsg starts opening a reference from outside the key handler.
|
||||
type openRefMsg struct{ ref ref.Ref }
|
||||
|
||||
// OpenLink is a message offering r in the Open link popup, for links
|
||||
// handed over by the link handler (Program.Send).
|
||||
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{ref: r} }
|
||||
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
@@ -610,11 +575,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case openRefMsg:
|
||||
m.deferHandlerSetup() // the link first
|
||||
if act, ok := OpenActions[msg.action]; ok && !msg.ref.IsShare() {
|
||||
cmds = append(cmds, m.findRef(msg.ref, act))
|
||||
} else {
|
||||
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
|
||||
}
|
||||
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
|
||||
|
||||
case resolvedMsg:
|
||||
m.handleResolved(msg)
|
||||
@@ -691,7 +652,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if src, ok := m.selectedSource(); ok {
|
||||
m.logErrOf(src.Name, msg.err)
|
||||
}
|
||||
uncache(&m, m.cache.containers, msg.key)
|
||||
delete(m.cache.containers, msg.key)
|
||||
m.containers.SetError(msg.err)
|
||||
} else {
|
||||
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
|
||||
@@ -712,7 +673,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if c, ok := m.containers.Selected(); ok {
|
||||
m.logErrOf(c.Name, msg.err)
|
||||
}
|
||||
uncache(&m, m.cache.objects, msg.key)
|
||||
delete(m.cache.objects, msg.key)
|
||||
m.secrets.SetError(msg.err)
|
||||
} else {
|
||||
if msg.bound != nil {
|
||||
@@ -1279,7 +1240,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
|
||||
}
|
||||
m.secrets.SetLoading()
|
||||
p := m.providerFor(o.Container.Source)
|
||||
ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotObjects)
|
||||
return func() tea.Msg {
|
||||
items, err := p.Versions(ctx, o)
|
||||
for i := range items {
|
||||
@@ -1309,7 +1270,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
|
||||
}
|
||||
}
|
||||
p := m.providerFor(o.Container.Source)
|
||||
ctx, req := m.begin(slotVersions, o.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotVersions)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
items, err := p.Versions(ctx, o)
|
||||
for i := range items {
|
||||
@@ -1460,26 +1421,11 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
|
||||
return m.crawlStart()
|
||||
}
|
||||
|
||||
// timeouts bound each provider request: its kind's, else the default.
|
||||
type timeouts struct {
|
||||
def time.Duration
|
||||
byKind map[provider.Kind]time.Duration
|
||||
}
|
||||
|
||||
// of is how long a request to a provider of kind k may take.
|
||||
func (t timeouts) of(k provider.Kind) time.Duration {
|
||||
if d, ok := t.byKind[k]; ok {
|
||||
return d
|
||||
}
|
||||
return t.def
|
||||
}
|
||||
|
||||
// begin starts a cancellable request for pane p to a provider of kind k,
|
||||
// cancelling any in flight.
|
||||
func (m *Model) begin(p slot, k provider.Kind) (context.Context, int) {
|
||||
// begin starts a cancellable request for pane p, cancelling any in flight.
|
||||
func (m *Model) begin(p slot) (context.Context, int) {
|
||||
m.cancelReq(p)
|
||||
m.seq++
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
||||
m.reqID[p], m.cancel[p] = m.seq, cancel
|
||||
return ctx, m.seq
|
||||
}
|
||||
@@ -1533,7 +1479,7 @@ func (m Model) sourcesCmd() tea.Cmd {
|
||||
gen := m.sourcesGen
|
||||
var cmds []tea.Cmd
|
||||
for _, p := range m.providers {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
defer cancel()
|
||||
s, err := p.Sources(ctx)
|
||||
@@ -1544,7 +1490,7 @@ func (m Model) sourcesCmd() tea.Cmd {
|
||||
})
|
||||
// The source it started in, quickly, on its own.
|
||||
if c, ok := p.(provider.Current); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
defer cancel()
|
||||
src, ok := c.Current(ctx)
|
||||
@@ -1561,7 +1507,7 @@ func (m Model) sourcesCmd() tea.Cmd {
|
||||
|
||||
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
|
||||
p := m.providerFor(src)
|
||||
ctx, req := m.begin(slotContainers, src.Kind)
|
||||
ctx, req := m.begin(slotContainers)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
items, err := p.Containers(ctx, src)
|
||||
// Carry the source as given, with its provider ID, so later
|
||||
@@ -1580,7 +1526,7 @@ func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
|
||||
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
|
||||
cands := m.candidates(c.Source)
|
||||
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
|
||||
ctx, req := m.begin(slotObjects, c.Source.Kind)
|
||||
ctx, req := m.begin(slotObjects)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
var errs []error
|
||||
for _, p := range cands {
|
||||
@@ -1608,7 +1554,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
|
||||
|
||||
func (m *Model) fetchValue(s provider.Object) tea.Cmd {
|
||||
p := m.providerFor(s.Container.Source)
|
||||
ctx, req := m.begin(slotValue, s.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotValue)
|
||||
m.detail.SetLoading()
|
||||
return func() tea.Msg {
|
||||
v, err := p.Value(ctx, s)
|
||||
@@ -1627,7 +1573,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
||||
m.detail.SetDescribing()
|
||||
}
|
||||
p := m.providerFor(o.Container.Source)
|
||||
ctx, req := m.begin(slotDescribe, o.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotDescribe)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
obj, err := p.Describe(ctx, o)
|
||||
return describeMsg{req: req, key: key, obj: obj, err: err}
|
||||
@@ -1655,7 +1601,7 @@ func (m *Model) copyValue() tea.Cmd {
|
||||
return m.copyText(o.Name, v.Reveal(), true)
|
||||
}
|
||||
p := m.providerFor(o.Container.Source)
|
||||
ctx, req := m.begin(slotCopy, o.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotCopy)
|
||||
m.flash = flash{text: "copying " + o.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
v, err := p.Value(ctx, o)
|
||||
|
||||
+15
-17
@@ -536,7 +536,7 @@ func TestBackspaceGoesBack(t *testing.T) {
|
||||
}
|
||||
// While typing a filter, backspace deletes a character instead.
|
||||
d.keys("/")
|
||||
d.typeText("api-x")
|
||||
d.typeText("apx")
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
||||
if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 {
|
||||
t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len())
|
||||
@@ -666,7 +666,7 @@ func TestFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.typeText("api-k")
|
||||
d.typeText("api")
|
||||
if d.m.secrets.Len() != 1 {
|
||||
t.Fatalf("visible = %d, want 1", d.m.secrets.Len())
|
||||
}
|
||||
@@ -684,9 +684,9 @@ func TestFilter(t *testing.T) {
|
||||
}
|
||||
d.keys("esc") // leave filter mode, clearing the query
|
||||
d.keys("/")
|
||||
d.typeText("api-k")
|
||||
d.typeText("api")
|
||||
d.keys("enter", "esc") // apply, then esc clears the applied filter
|
||||
if d.m.secrets.Len() != 4 {
|
||||
if d.m.secrets.Len() != 3 {
|
||||
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
|
||||
}
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.typeText("a") // all four secrets contain an a
|
||||
if d.m.secrets.Len() != 4 {
|
||||
d.typeText("a") // all three secrets contain an a
|
||||
if d.m.secrets.Len() != 3 {
|
||||
t.Fatalf("visible = %d", d.m.secrets.Len())
|
||||
}
|
||||
_, _, initial := d.selected() // kept from before filtering: the middle row
|
||||
var seen []string
|
||||
for _, k := range []rune{'j', 'j', 'j', 'k'} {
|
||||
for _, k := range []rune{'j', 'j', 'k'} {
|
||||
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
|
||||
_, _, s := d.selected()
|
||||
seen = append(seen, s)
|
||||
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
|
||||
if !d.m.secrets.Filtering() {
|
||||
t.Fatal("ctrl+j/k left filter mode")
|
||||
}
|
||||
// down to the bottom, stay there, back up one
|
||||
if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
|
||||
t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
|
||||
// down to the bottom, stay there, back up to where it started
|
||||
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial {
|
||||
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "ctrl+j/n down") {
|
||||
t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
|
||||
@@ -848,7 +848,7 @@ func TestCopyAndRevealWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.typeText("api-k")
|
||||
d.typeText("api")
|
||||
d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
|
||||
if d.clip().content != "sk_live_51HxDEMO0000000000000000" {
|
||||
t.Errorf("ctrl+y copied %q", d.clip().content)
|
||||
@@ -1187,8 +1187,7 @@ func TestDetailsFollowFocus(t *testing.T) {
|
||||
func TestStatusLineCountsRepos(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t, fake.Azure(0), fake.Repos())
|
||||
// The status line, not the pane, which an expired secret's ⚠ fills.
|
||||
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 3 repos") {
|
||||
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") {
|
||||
t.Errorf("status line: %q", line)
|
||||
}
|
||||
d = newDriver(t, fake.Azure(0))
|
||||
@@ -1203,8 +1202,8 @@ func TestStatusPane(t *testing.T) {
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
|
||||
OpenURL: func(u string) error { opened = append(opened, u); return nil }},
|
||||
fake.Azure(0), signedOut{fake.Azure(0)})
|
||||
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "2 accounts · 1 need sign-in") {
|
||||
t.Errorf("status line: %q", line)
|
||||
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") {
|
||||
t.Errorf("status line:\n%s", d.screen())
|
||||
}
|
||||
d.keys("0")
|
||||
if d.m.focus != paneStatus {
|
||||
@@ -1304,8 +1303,7 @@ func TestExpiringInStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
|
||||
d.keys("3", "]", "]") // load kv-app-prod's certificates
|
||||
// old-tls and legacy-token.
|
||||
if !strings.Contains(d.screen(), "⚠ 2 expiring") {
|
||||
if !strings.Contains(d.screen(), "⚠ 1 expiring") {
|
||||
t.Errorf("status line:\n%s", d.screen())
|
||||
}
|
||||
d.keys("0")
|
||||
|
||||
@@ -67,23 +67,10 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
m.detail.Refresh(s)
|
||||
uncache(m, m.cache.details, detailKey(s))
|
||||
delete(m.cache.details, detailKey(s))
|
||||
return m.describe(s)
|
||||
}
|
||||
|
||||
// uncache deletes key from a cache, and, at the next save, from the file.
|
||||
func uncache[T any](m *Model, c map[string]entry[T], key string) {
|
||||
if _, ok := c[key]; !ok {
|
||||
return
|
||||
}
|
||||
delete(c, key)
|
||||
if m.cacheDropped == nil {
|
||||
m.cacheDropped = map[string]bool{}
|
||||
}
|
||||
m.cacheDropped[key] = true
|
||||
m.cacheDirty = true
|
||||
}
|
||||
|
||||
// sameListing reports whether a and b list the same: what a list
|
||||
// says of an object, not what describing it adds.
|
||||
func sameListing(a, b provider.Object) bool {
|
||||
|
||||
@@ -165,7 +165,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
|
||||
o := *obj
|
||||
p := m.providerFor(o.Container.Source)
|
||||
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
|
||||
timeout := m.timeout.of(o.Container.Source.Kind)
|
||||
timeout := m.timeout
|
||||
m.usedObject(o)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
|
||||
+4
-40
@@ -3,7 +3,6 @@ package app
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -14,20 +13,18 @@ import (
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// Opening a repo's file (e), or on the status pane the config: SOPS files with sops edit, which decrypts
|
||||
// Opening a repo's file (e): SOPS files with sops edit, which decrypts
|
||||
// them for the editor and encrypts them again after. Other files, inside
|
||||
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or
|
||||
// $EDITOR, coming back after; else in the system's default app. On a
|
||||
// secret, editors that can open at a line start on its key.
|
||||
|
||||
type editDoneMsg struct {
|
||||
c provider.Container
|
||||
name string
|
||||
config bool // the config file: read at startup
|
||||
err error
|
||||
c provider.Container
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
// sopsUnchanged is sops edit's exit code when the file wasn't changed.
|
||||
@@ -47,8 +44,6 @@ func (m *Model) edit() tea.Cmd {
|
||||
var c provider.Container
|
||||
var o *provider.Object
|
||||
switch m.focus {
|
||||
case paneStatus: // like lazygit's status panel
|
||||
return m.editConfig()
|
||||
case paneSources:
|
||||
m.flash = warning("select a file to open it")
|
||||
return nil
|
||||
@@ -98,33 +93,6 @@ func (m *Model) edit() tea.Cmd {
|
||||
return m.openInEditor(f, finished)
|
||||
}
|
||||
|
||||
// configHeader starts a config file made to be edited.
|
||||
const configHeader = "# lazysecrets config: " + update.DefaultRepo + "/src/branch/main/docs/config.md\n"
|
||||
|
||||
// editConfig opens the user's config file (the first of them), as e
|
||||
// opens a file, making it if it isn't there yet: e on the status pane.
|
||||
func (m *Model) editConfig() tea.Cmd {
|
||||
if len(m.configFiles) == 0 {
|
||||
m.flash = warning("no config file")
|
||||
return nil
|
||||
}
|
||||
path := m.configFiles[0]
|
||||
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
|
||||
err := os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err == nil {
|
||||
err = os.WriteFile(path, []byte(configHeader), 0o644)
|
||||
}
|
||||
if err != nil {
|
||||
m.flash = flash{text: "edit config: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
return m.openInEditor(provider.File{Path: path}, func(err error) tea.Msg {
|
||||
return editDoneMsg{name: name, config: true, err: err}
|
||||
})
|
||||
}
|
||||
|
||||
// plusLine are editors that open at a line with +N before the file.
|
||||
var plusLine = map[string]bool{
|
||||
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
|
||||
@@ -174,10 +142,6 @@ func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
|
||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if msg.config {
|
||||
m.flash = done("closed " + msg.name + ": restart lazysecrets to use it")
|
||||
return nil
|
||||
}
|
||||
m.flash = done("closed " + msg.name)
|
||||
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
|
||||
m.hideValue()
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
@@ -29,7 +28,7 @@ func (m Model) expiringItems() []expiringItem {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
for _, c := range m.cache.containers[row].value {
|
||||
for _, t := range c.ObjectTypes() {
|
||||
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
if state, _ := m.expiry(o); state != notExpiring {
|
||||
out = append(out, expiringItem{o, row})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -12,18 +11,28 @@ import (
|
||||
// list once they have expired (✗ expired) or will within the configured
|
||||
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
||||
|
||||
type expiryState = expiry.State
|
||||
type expiryState int
|
||||
|
||||
const (
|
||||
notExpiring = expiry.None
|
||||
expiresSoon = expiry.Soon
|
||||
expired = expiry.Expired
|
||||
notExpiring expiryState = iota
|
||||
expiresSoon
|
||||
expired
|
||||
)
|
||||
|
||||
// expiry tells whether o has expired or will within the warning period,
|
||||
// and how long it has left.
|
||||
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
|
||||
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
|
||||
if o.Expires.IsZero() {
|
||||
return notExpiring, 0
|
||||
}
|
||||
left := o.Expires.Sub(m.now())
|
||||
switch {
|
||||
case left <= 0:
|
||||
return expired, 0
|
||||
case left <= m.expiryWarning:
|
||||
return expiresSoon, left
|
||||
}
|
||||
return notExpiring, left
|
||||
}
|
||||
|
||||
// expiryWord is matched by the filter: "expired" or "expiring".
|
||||
@@ -38,4 +47,9 @@ func (m Model) expiryWord(o provider.Object) string {
|
||||
}
|
||||
|
||||
// daysLeft is a short "12d", or "5h" on the last day.
|
||||
func daysLeft(d time.Duration) string { return expiry.Left(d) }
|
||||
func daysLeft(d time.Duration) string {
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
@@ -3,11 +3,9 @@ package app
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
@@ -21,22 +19,6 @@ import (
|
||||
|
||||
const star = "★ "
|
||||
|
||||
// recentMark marks items used lately (Recent), with ui.recentIcon.
|
||||
const recentMark = "◷ "
|
||||
|
||||
// marks is what goes before a list row's name: ★ when it's starred for
|
||||
// account and, with recent, ◷ when it was used lately.
|
||||
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
|
||||
var s string
|
||||
if n.StarredFor(account) {
|
||||
s = st.Favourite.Render(star)
|
||||
}
|
||||
if recent && n.UsedLately(now) {
|
||||
s += st.Muted.Render(recentMark)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sourceKey(s provider.Source) string {
|
||||
if r, err := ref.ForSource(s); err == nil {
|
||||
return r.String()
|
||||
@@ -112,7 +94,9 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
|
||||
}
|
||||
line += sty.Muted.Render(" " + c.Detail)
|
||||
}
|
||||
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
|
||||
if n.StarredFor(c.Source.Provider) {
|
||||
line = sty.Favourite.Render(star) + line
|
||||
}
|
||||
if withSource {
|
||||
src := c.Source.Name
|
||||
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
|
||||
@@ -132,7 +116,9 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
||||
if o.Enabled != nil && !*o.Enabled {
|
||||
line = sty.Muted.Render(o.Name + " (disabled)")
|
||||
}
|
||||
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
||||
if n.StarredFor(o.Container.Source.Provider) {
|
||||
line = sty.Favourite.Render(star) + line
|
||||
}
|
||||
switch state, left := m.expiry(o); state {
|
||||
case expired:
|
||||
line += " " + sty.Error.Render("✗ expired")
|
||||
@@ -181,7 +167,7 @@ func (m *Model) rebind(c provider.Container) string {
|
||||
m.linked[i].Source = c.Source
|
||||
}
|
||||
}
|
||||
key := cachefile.ObjectsKey(c, m.objType)
|
||||
key := containerKey(c) + "|" + string(m.objType)
|
||||
if m.onLinks() {
|
||||
m.containers.SetItems(m.linked)
|
||||
m.containers.Select(containerKey(c))
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
@@ -395,7 +394,7 @@ func TestStarWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
for _, r := range "api-k" {
|
||||
for _, r := range "api" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
@@ -435,20 +434,3 @@ func TestStarIsPerAccount(t *testing.T) {
|
||||
t.Errorf("favourites should list alice's star only: %q", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentIcon(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load("")
|
||||
_ = mem.Use("lazysecrets://azure/"+kv+"/secrets/api-key", time.Now(), func(*store.Node) {})
|
||||
d := newDriverOpts(t, Options{Store: mem, RecentIcon: true})
|
||||
d.keys("3")
|
||||
scr := d.screen()
|
||||
if !strings.Contains(scr, "◷ api-key") || strings.Contains(scr, "◷ db-password") {
|
||||
t.Errorf("want ◷ on api-key only:\n%s", scr)
|
||||
}
|
||||
off := newDriverOpts(t, Options{Store: mem})
|
||||
off.keys("3")
|
||||
if strings.Contains(off.screen(), "◷") {
|
||||
t.Errorf("◷ without ui.recentIcon:\n%s", off.screen())
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
@@ -161,7 +160,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
|
||||
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
add("web", c.URL)
|
||||
for _, t := range c.ObjectTypes() {
|
||||
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
|
||||
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
|
||||
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
|
||||
}
|
||||
}
|
||||
@@ -197,7 +196,7 @@ func (m *Model) loadDetails(c provider.Container) tea.Cmd {
|
||||
}
|
||||
}
|
||||
m.details[key] = detailsState{loading: true}
|
||||
ctx, req := m.begin(slotDetails, c.Source.Kind)
|
||||
ctx, req := m.begin(slotDetails)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
attrs, err := d.Details(ctx, c)
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -54,8 +54,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
||||
switch {
|
||||
case c.focus == paneStatus:
|
||||
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details"))
|
||||
config := key.NewBinding(key.WithKeys(k.Edit.Keys()...), key.WithHelp(k.Edit.Help().Key, "edit config"))
|
||||
return append(b, details, config, k.Update, k.Help, k.Quit)
|
||||
return append(b, details, k.Update, k.Help, k.Quit)
|
||||
case c.focus == paneDetail && c.account && c.of == paneStatus:
|
||||
open := key.NewBinding(key.WithKeys(k.Open.Keys()...), key.WithHelp(k.Open.Help().Key, "open link"))
|
||||
return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
|
||||
|
||||
@@ -107,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
||||
if m.history {
|
||||
var used []string
|
||||
for _, k := range keys {
|
||||
if nodes[k].UsedLately(m.now()) {
|
||||
if !nodes[k].LastUsed.IsZero() {
|
||||
used = append(used, k)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,12 +56,9 @@ type resolvedMsg struct {
|
||||
act linkAction
|
||||
sourceRow string
|
||||
others []string // other accounts' rows that also found the vault
|
||||
// find ends in the list, like Go to, not on the details: opened by a
|
||||
// tool that found it (lazysecrets open --action, from fzf).
|
||||
find bool
|
||||
choices []choice // several candidates: ask
|
||||
linked *provider.Container
|
||||
err error
|
||||
choices []choice // several candidates: ask
|
||||
linked *provider.Container
|
||||
err error
|
||||
}
|
||||
|
||||
type choice struct {
|
||||
@@ -161,7 +158,7 @@ func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
|
||||
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
|
||||
return nil
|
||||
}
|
||||
ctx, req := m.begin(slotCopy, r.Kind)
|
||||
ctx, req := m.begin(slotCopy)
|
||||
m.flash = flash{text: "copying " + r.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
var errs []error
|
||||
@@ -221,9 +218,6 @@ func (m *Model) openSource(r ref.Ref) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if row, ok := preferredRow(r, choices); ok {
|
||||
choices = []choice{{sourceRow: row}}
|
||||
}
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
|
||||
@@ -410,59 +404,18 @@ func (m *Model) handleResolved(msg resolvedMsg) {
|
||||
for i := range msg.choices {
|
||||
msg.choices[i].label = "Open in " + msg.choices[i].label
|
||||
}
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, !msg.find)
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true)
|
||||
case len(msg.others) > 0:
|
||||
var choices []choice
|
||||
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
if row, ok := preferredRow(msg.ref, choices); ok {
|
||||
m.navResolved(msg, row)
|
||||
return
|
||||
}
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, !msg.find)
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
|
||||
case msg.linked != nil:
|
||||
m.addLinked(*msg.linked)
|
||||
m.navResolved(msg, linksRowID)
|
||||
m.startLinkNav(msg.ref, msg.act, linksRowID)
|
||||
default:
|
||||
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
|
||||
m.startLinkNav(msg.ref, msg.act, msg.sourceRow)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestPasteWhileFilteringFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.send(tea.PasteMsg{Content: "api-k"})
|
||||
d.send(tea.PasteMsg{Content: "api"})
|
||||
if d.m.secrets.Len() != 1 || d.m.nav != nil {
|
||||
t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
|
||||
}
|
||||
@@ -203,21 +203,8 @@ func TestOpenPromptAndStartupOpen(t *testing.T) {
|
||||
t.Fatal("open at startup didn't ask what to do")
|
||||
}
|
||||
d2.keys("o")
|
||||
if _, _, s := d2.selected(); s != "api-key" || d2.m.focus != paneDetail {
|
||||
t.Errorf("open at startup: selected %q, focus %v, want the details", s, d2.m.focus)
|
||||
}
|
||||
|
||||
// --action: no question, it does that.
|
||||
d3 := newDriverOpts(t, Options{Open: &r, OpenAction: "go"})
|
||||
if d3.m.prompt != nil {
|
||||
t.Fatalf("--action go asked: %q", d3.m.prompt.title)
|
||||
}
|
||||
if _, _, s := d3.selected(); s != "api-key" || d3.m.focus != paneSecrets {
|
||||
t.Errorf("--action go: selected %q, focus %v, want the secrets list, like Go to", s, d3.m.focus)
|
||||
}
|
||||
d4 := newDriverOpts(t, Options{Open: &r, OpenAction: "copy"})
|
||||
if _, _, s := d4.selected(); s != "api-key" || d4.clip().content == "" {
|
||||
t.Errorf("--action copy: selected %q, copied %q", s, d4.clip().content)
|
||||
if _, _, s := d2.selected(); s != "api-key" {
|
||||
t.Errorf("open at startup: selected %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,20 +305,6 @@ func TestOpenAsksWhichAccount(t *testing.T) {
|
||||
t.Errorf("j enter picked %q", c.Source.Provider)
|
||||
}
|
||||
|
||||
// lazysecrets open --account: no question, that account.
|
||||
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
|
||||
r.Account = "Azure:bob@example.com/t1"
|
||||
d.send(OpenLink(r))
|
||||
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
|
||||
d.keys("o")
|
||||
}
|
||||
if strings.Contains(d.screen(), "Several accounts can see") {
|
||||
t.Fatalf("asked, with an account given:\n%s", d.screen())
|
||||
}
|
||||
if c, _ := d.m.containers.Selected(); c.Source.Provider != r.Account {
|
||||
t.Errorf("--account opened via %q", c.Source.Provider)
|
||||
}
|
||||
|
||||
// Opening into a folded account unfolds it.
|
||||
d.keys("1", "g") // alice's header
|
||||
d.keys("enter") // fold it
|
||||
|
||||
+78
-23
@@ -1,12 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
@@ -21,28 +23,58 @@ import (
|
||||
// And where lazysecrets was left, per folder it ran in, goes in the store,
|
||||
// to go back there on the next start.
|
||||
|
||||
// cacheFileVersion is the file's format; a file of another version is
|
||||
// ignored, and replaced on the next save.
|
||||
const cacheFileVersion = 1
|
||||
|
||||
// cacheMaxAge drops what hasn't been loaded again for this long.
|
||||
const cacheMaxAge = 30 * 24 * time.Hour
|
||||
|
||||
// persistEvery is how often a changed cache is saved while running, so
|
||||
// little is lost if lazysecrets is killed rather than quit.
|
||||
const persistEvery = 30 * time.Second
|
||||
|
||||
type cacheFile struct {
|
||||
Version int `json:"version"`
|
||||
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
|
||||
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
|
||||
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
|
||||
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
|
||||
Cursor map[string]string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type stamped[T any] struct {
|
||||
At time.Time `json:"at"`
|
||||
Value T `json:"value"`
|
||||
}
|
||||
|
||||
type persistTickMsg struct{}
|
||||
|
||||
type persistedMsg struct{ err error }
|
||||
|
||||
// keepCached reports whether a cache key's account still exists and
|
||||
// isn't a repo: every key starts with the provider ID.
|
||||
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
|
||||
|
||||
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
|
||||
func (m *Model) keepCached(key string) bool {
|
||||
id, _ := splitSourceRow(key)
|
||||
p, ok := m.byID[id]
|
||||
return ok && p.Kind() != provider.KindRepo
|
||||
}
|
||||
|
||||
// loadCacheFile fills the cache from the file, leaving out what's too
|
||||
// old or belongs to accounts that aren't signed in any more.
|
||||
func (m *Model) loadCacheFile() {
|
||||
f := cachefile.Read(m.cachePath)
|
||||
b, err := os.ReadFile(m.cachePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var f cacheFile
|
||||
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
|
||||
return
|
||||
}
|
||||
now := m.now()
|
||||
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
|
||||
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
|
||||
for id, e := range f.Sources {
|
||||
if m.keepProvider(id) && recent(e.At) {
|
||||
if m.keepCached(id+"|") && recent(e.At) {
|
||||
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
|
||||
m.sourcesAt[id] = e.At
|
||||
}
|
||||
@@ -70,27 +102,33 @@ func (m *Model) loadCacheFile() {
|
||||
}
|
||||
|
||||
// cacheSnapshot is the cache as the file holds it.
|
||||
func (m *Model) cacheSnapshot() cachefile.File {
|
||||
f := cachefile.New()
|
||||
f.Dropped, m.cacheDropped = m.cacheDropped, nil
|
||||
func (m *Model) cacheSnapshot() cacheFile {
|
||||
f := cacheFile{
|
||||
Version: cacheFileVersion,
|
||||
Sources: map[string]stamped[[]provider.Source]{},
|
||||
Containers: map[string]stamped[[]provider.Container]{},
|
||||
Objects: map[string]stamped[[]provider.Object]{},
|
||||
Details: map[string]stamped[provider.Object]{},
|
||||
Cursor: map[string]string{},
|
||||
}
|
||||
for id, r := range m.sourceResults {
|
||||
if r.err == nil && m.keepProvider(id) {
|
||||
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
|
||||
if r.err == nil && m.keepCached(id+"|") {
|
||||
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.containers {
|
||||
if m.keepCached(k) {
|
||||
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
|
||||
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.objects {
|
||||
if m.keepCached(k) {
|
||||
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
|
||||
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.details {
|
||||
if m.keepCached(k) {
|
||||
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
|
||||
f.Details[k] = stamped[provider.Object]{e.at, e.value}
|
||||
}
|
||||
}
|
||||
for k, v := range m.cache.cursor {
|
||||
@@ -101,11 +139,28 @@ func (m *Model) cacheSnapshot() cachefile.File {
|
||||
return f
|
||||
}
|
||||
|
||||
// saveCache saves f, merged with the file (lazysecrets list may have
|
||||
// loaded more since), from any goroutine.
|
||||
func (m *Model) saveCache(f cachefile.File) func() error {
|
||||
path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
|
||||
return func() error { return cachefile.Save(path, f, keep, now) }
|
||||
// writeCacheFile writes f atomically, readable by the user only.
|
||||
func writeCacheFile(path string, f cacheFile) error {
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// persistTick saves the cache, and where lazysecrets is, in the
|
||||
@@ -114,8 +169,8 @@ func (m *Model) persistTick() tea.Cmd {
|
||||
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
|
||||
if m.cacheDirty && m.cachePath != "" {
|
||||
m.cacheDirty = false
|
||||
save := m.saveCache(m.cacheSnapshot())
|
||||
cmds = append(cmds, func() tea.Msg { return persistedMsg{save()} })
|
||||
f, path := m.cacheSnapshot(), m.cachePath
|
||||
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
|
||||
}
|
||||
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
|
||||
m.savedPlace = p.Link
|
||||
@@ -130,7 +185,7 @@ func (m *Model) persistTick() tea.Cmd {
|
||||
func (m Model) Persist() error {
|
||||
var errs []error
|
||||
if m.cachePath != "" {
|
||||
errs = append(errs, m.saveCache(m.cacheSnapshot())())
|
||||
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
|
||||
}
|
||||
if m.placeDir != "" {
|
||||
if p, ok := m.place(); ok {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
@@ -82,16 +80,15 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "cache.json")
|
||||
az := fake.Azure(0)
|
||||
now := time.Now()
|
||||
f := cachefile.File{
|
||||
Version: cachefile.Version,
|
||||
Containers: map[string]cachefile.Stamped[[]provider.Container]{
|
||||
az.ID() + "|recent": {At: now.Add(-time.Hour), Value: []provider.Container{{Name: "kv-recent"}}},
|
||||
az.ID() + "|old": {At: now.Add(-cachefile.MaxAge - time.Hour), Value: []provider.Container{{Name: "kv-old"}}},
|
||||
"Azure:gone@x/t|recent": {At: now, Value: []provider.Container{{Name: "kv-gone"}}},
|
||||
f := cacheFile{
|
||||
Version: cacheFileVersion,
|
||||
Containers: map[string]stamped[[]provider.Container]{
|
||||
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
|
||||
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
|
||||
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(f) // as an older lazysecrets left it
|
||||
if err := os.WriteFile(file, b, 0o600); err != nil {
|
||||
if err := writeCacheFile(file, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := newDriverOpts(t, Options{CacheFile: file}, az)
|
||||
@@ -100,7 +97,7 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
||||
t.Error("dropped a recent entry")
|
||||
}
|
||||
if _, ok := c[az.ID()+"|old"]; ok {
|
||||
t.Error("kept an entry older than cachefile.MaxAge")
|
||||
t.Error("kept an entry older than cacheMaxAge")
|
||||
}
|
||||
if _, ok := c["Azure:gone@x/t|recent"]; ok {
|
||||
t.Error("kept an entry of an account that isn't signed in")
|
||||
|
||||
@@ -342,53 +342,3 @@ func TestEditorAtLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "lazysecrets", "config.yaml")
|
||||
var opened []string
|
||||
d := newDriverOpts(t, Options{ConfigFiles: []string{path}, OpenURL: func(u string) error { opened = append(opened, u); return nil }})
|
||||
d.m.getenv = func(string) string { return "" }
|
||||
|
||||
// e on the status pane. No editor: the system's default app, on a
|
||||
// config made for it.
|
||||
d.keys("0", "e")
|
||||
if !slices.Equal(opened, []string{path}) {
|
||||
t.Errorf("opened %q", opened)
|
||||
}
|
||||
if b, err := os.ReadFile(path); err != nil || !strings.HasPrefix(string(b), "# lazysecrets config") {
|
||||
t.Errorf("config made: %q, %v", b, err)
|
||||
}
|
||||
|
||||
// Inside nvim: in that nvim; an existing config is left as it is.
|
||||
os.WriteFile(path, []byte("icons: false\n"), 0o644)
|
||||
d.m.getenv = func(k string) string { return map[string]string{"NVIM": "/tmp/nvim.sock"}[k] }
|
||||
var nvim []string
|
||||
d.m.nvimOpen = func(server, path string, line int) error {
|
||||
nvim = append(nvim, server+" "+path)
|
||||
return nil
|
||||
}
|
||||
if msg := d.m.editConfig()(); msg != (tea.QuitMsg{}) {
|
||||
t.Errorf("didn't quit for nvim: %v", msg)
|
||||
}
|
||||
if want := []string{"/tmp/nvim.sock " + path}; !slices.Equal(nvim, want) {
|
||||
t.Errorf("nvim got %q", nvim)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != "icons: false\n" {
|
||||
t.Errorf("config changed: %q", b)
|
||||
}
|
||||
|
||||
d.send(editDoneMsg{name: "config.yaml", config: true})
|
||||
if !strings.Contains(d.m.flash.text, "restart") {
|
||||
t.Errorf("flash %q", d.m.flash.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFooterEditsConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("0")
|
||||
if !strings.Contains(d.screen(), "e edit config") {
|
||||
t.Errorf("no edit config in the footer:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,13 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// containerKey identifies a container in the UI and caches. The same vault
|
||||
// seen through two accounts must not share cached lists, since each
|
||||
// account may see different things.
|
||||
func containerKey(c provider.Container) string { return cachefile.ContainerKey(c) }
|
||||
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
|
||||
|
||||
// providerFor finds the provider instance a source came from. Sources
|
||||
// remembered from an earlier run (favourites) may name an account that
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
@@ -90,7 +89,7 @@ func (m *Model) searchEntries() (sources, items []option) {
|
||||
return nil
|
||||
}, reload)
|
||||
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
r, err := ref.ForObject(o, "")
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -167,7 +166,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
|
||||
}
|
||||
for _, v := range e.value {
|
||||
c.vaults++
|
||||
key := cachefile.ObjectsKey(v, provider.TypeSecret)
|
||||
key := containerKey(v) + "|" + string(provider.TypeSecret)
|
||||
if objs, ok := m.cache.objects[key]; ok {
|
||||
c.vaultsLoaded++
|
||||
c.secrets += len(objs.value)
|
||||
@@ -264,7 +263,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
|
||||
// queueContainer queues loading c's secrets, unless loaded lately and not
|
||||
// force.
|
||||
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
|
||||
return
|
||||
}
|
||||
@@ -296,10 +295,10 @@ func (m *Model) crawlNext() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
||||
timeout := m.timeout
|
||||
if job.src != nil {
|
||||
p := m.providerFor(*job.src)
|
||||
src := *job.src
|
||||
timeout := m.timeout.of(src.Kind)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
@@ -312,7 +311,6 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
||||
}
|
||||
c := *job.container
|
||||
p := m.providerFor(c.Source)
|
||||
timeout := m.timeout.of(c.Source.Kind)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
@@ -328,7 +326,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
|
||||
m.crawl.active--
|
||||
key := msg.job.row
|
||||
if msg.job.container != nil {
|
||||
key = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
|
||||
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
|
||||
}
|
||||
delete(m.crawl.pending, key)
|
||||
switch {
|
||||
@@ -366,7 +364,7 @@ func (m *Model) sourceMark(row string) string {
|
||||
}
|
||||
loaded, loading := 0, false
|
||||
for _, c := range e.value {
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
loading = loading || m.crawl.pending[key]
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
@@ -387,7 +385,7 @@ func (m *Model) sourceMark(row string) string {
|
||||
// (✗) or are loading (◌): whether a subscription is loaded says whether
|
||||
// its vaults are searchable.
|
||||
func (m *Model) vaultMark(c provider.Container) string {
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
switch {
|
||||
case m.crawl.pending[key]:
|
||||
return m.st.Muted.Render("◌")
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
@@ -618,7 +617,7 @@ func (m Model) loadHint() string {
|
||||
}
|
||||
loaded := 0
|
||||
for _, c := range e.value {
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ type shareFilesMsg struct {
|
||||
type sealedMsg struct {
|
||||
link ref.Ref
|
||||
n int
|
||||
// from is the picker the key was picked in; nil for sharing.key's.
|
||||
// from is the picker the key was picked in; nil for share.key's.
|
||||
from *picker
|
||||
err error
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
|
||||
m.flash = warning("these files can't be shared")
|
||||
return nil
|
||||
}
|
||||
general, timeout := m.shareKey, m.timeout.of(src.Kind)
|
||||
general, timeout := m.shareKey, m.timeout
|
||||
m.flash = flash{text: "reading the files…"}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
@@ -216,7 +216,7 @@ func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
|
||||
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
|
||||
providers, st, timeout := m.providers, m.store, m.timeout
|
||||
return func(key ref.Ref) (provider.SecretValue, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout.of(key.Kind))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
k := key
|
||||
k.Version = ""
|
||||
@@ -258,7 +258,7 @@ func (m *Model) offerShare(r ref.Ref) tea.Cmd {
|
||||
// openShare reads the key, decrypts the files and finds the repo's clone.
|
||||
func (m *Model) openShare(s share.Sealed) tea.Cmd {
|
||||
read := m.keyReader()
|
||||
providers, timeout := m.providers, m.timeout.of(provider.KindRepo)
|
||||
providers, timeout := m.providers, m.timeout
|
||||
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
msg := shareOpenedMsg{sealed: s}
|
||||
|
||||
@@ -50,7 +50,7 @@ func shareFrom(t *testing.T, root string, paste bool) string {
|
||||
t.Fatalf("selected %v, want the repo", src.ID)
|
||||
}
|
||||
d.keys("S", "f")
|
||||
// No sharing.key set: it asks which secret, in Go to, which lists
|
||||
// No share.key set: it asks which secret, in Go to, which lists
|
||||
// only secrets.
|
||||
v := d.m.search
|
||||
if v == nil || v.picker == nil {
|
||||
@@ -137,7 +137,7 @@ func TestShareWithShareKeyConfirms(t *testing.T) {
|
||||
}
|
||||
root := cloneWith(t, map[string]string{
|
||||
".env": "A=1\n",
|
||||
".lazysecrets.yaml": "sharing:\n key: https://" + kv + "/secrets/db-password\n",
|
||||
".lazysecrets.yaml": "share:\n key: https://" + kv + "/secrets/db-password\n",
|
||||
})
|
||||
clip := &fakeClipboard{}
|
||||
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
||||
@@ -218,7 +218,7 @@ func TestShareInDemo(t *testing.T) {
|
||||
}
|
||||
// Too short a key: the picker stays, saying why.
|
||||
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"})
|
||||
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; sharing.keyRules needs 32 (minLength)") {
|
||||
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") {
|
||||
t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
|
||||
}
|
||||
if !strings.Contains(d.screen(), "28 characters") {
|
||||
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
@@ -41,7 +39,7 @@ type sourceRow struct {
|
||||
|
||||
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
|
||||
// either; provider IDs contain "/" (Azure:user/tenant).
|
||||
func sourceRowKey(providerID, sourceID string) string { return cachefile.Row(providerID, sourceID) }
|
||||
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID }
|
||||
|
||||
func splitSourceRow(row string) (providerID, sourceID string) {
|
||||
providerID, sourceID, _ = strings.Cut(row, "|")
|
||||
@@ -58,7 +56,7 @@ func sourceRowID(r sourceRow) string {
|
||||
return sourceRowKey(r.provider, r.source.ID)
|
||||
}
|
||||
|
||||
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
|
||||
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
|
||||
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
|
||||
ID: sourceRowID,
|
||||
// Headers have no filter text, so filtering shows just sources.
|
||||
@@ -80,8 +78,8 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
|
||||
node := mem.Node(sourceKey(r.source))
|
||||
kind := st.Kind(r.group)
|
||||
starred := ""
|
||||
if r.kind == rowSource {
|
||||
starred = marks(st, node, r.source.Provider, recent, time.Now())
|
||||
if r.kind == rowSource && node.StarredFor(r.source.Provider) {
|
||||
starred = st.Favourite.Render(star)
|
||||
}
|
||||
switch {
|
||||
case r.kind == rowLinks:
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
|
||||
│ ⚠ 1 expiring · 2 accounts ││ name platform-prod │
|
||||
│ 2 accounts ││ name platform-prod │
|
||||
╰───────────────────────────────╯│ ID sub-prod │
|
||||
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
|
||||
│ ▾ Azure (2) ││ vaults 3 │
|
||||
@@ -20,8 +20,7 @@
|
||||
╭─ [3] Secrets - Keys - Certs ──╮│ │
|
||||
│ db-password ││ │
|
||||
│ api-key ││ │
|
||||
│ legacy-token ✗ expired ││ │
|
||||
│ smtp-password (disabled) ││ │
|
||||
│ legacy-token (disabled) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
@@ -29,5 +28,6 @@
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 4 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
||||
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
|
||||
@@ -1,25 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
)
|
||||
|
||||
// A provider's own timeout applies to it alone.
|
||||
func TestTimeoutPerProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Timeout: 5 * time.Second, Timeouts: map[provider.Kind]time.Duration{provider.KindAzure: 10 * time.Millisecond}},
|
||||
fake.Azure(300*time.Millisecond), fake.Databricks(300*time.Millisecond))
|
||||
scr := d.screen()
|
||||
if !strings.Contains(scr, "✗ Azure: context deadline exceeded") || !strings.Contains(scr, "Databricks (…)") ||
|
||||
strings.Contains(scr, "Databricks: context deadline") {
|
||||
t.Errorf("want Azure timed out and Databricks still loading:\n%s", scr)
|
||||
}
|
||||
if got := (timeouts{def: time.Minute}).of(provider.KindRepo); got != time.Minute {
|
||||
t.Errorf("no override: %s, want the default", got)
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ type Updater struct {
|
||||
}
|
||||
|
||||
// updateEvery is how often lazysecrets looks for a newer release on its
|
||||
// own unless the config says otherwise; within that, the last answer (in
|
||||
// the state file) is used.
|
||||
// own; within that, the last answer (in the state file) is used.
|
||||
const updateEvery = 24 * time.Hour
|
||||
|
||||
type updateMsg struct {
|
||||
@@ -36,14 +35,14 @@ type installedMsg struct {
|
||||
}
|
||||
|
||||
// startUpdateCheck sets up the startup look for a newer release: the
|
||||
// state file's answer when it looked within updateEvery, else a quiet
|
||||
// state file's answer when it looked less than a day ago, else a quiet
|
||||
// look at the server (fetchOnStart, for Init).
|
||||
func (m *Model) startUpdateCheck() {
|
||||
if m.updater == nil || !m.updateCheck {
|
||||
return
|
||||
}
|
||||
last := m.store.UpdateCheck()
|
||||
if m.now().Sub(last.At) < m.updateEvery {
|
||||
if m.now().Sub(last.At) < updateEvery {
|
||||
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
|
||||
return
|
||||
}
|
||||
@@ -110,22 +109,15 @@ func (m *Model) update() tea.Cmd {
|
||||
func (m *Model) showUpdate() {
|
||||
r := *m.newer
|
||||
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
|
||||
if notes := r.Notes(m.version); notes != "" {
|
||||
if notes := r.Notes(); notes != "" {
|
||||
lines = append(lines, "")
|
||||
for _, l := range strings.Split(notes, "\n") {
|
||||
if h, ok := strings.CutPrefix(l, "### "); ok {
|
||||
l = m.st.Label.Render(h)
|
||||
} else if h, ok := strings.CutPrefix(l, "## "); ok { // a release, when several are new
|
||||
l = m.st.TitleFocused.Render(h)
|
||||
}
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
// A popup doesn't scroll: several releases behind, the rest is on
|
||||
// the release pages.
|
||||
if most := max(6, m.h-12); len(lines) > most {
|
||||
lines = append(lines[:most-1], m.st.Muted.Render("… and more on the release pages"))
|
||||
}
|
||||
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
|
||||
run: func(m *Model) tea.Cmd { return m.install(r) }}}
|
||||
if r.URL != "" {
|
||||
|
||||
@@ -83,12 +83,6 @@ func TestUpdate(t *testing.T) {
|
||||
if f2.asked != 1 {
|
||||
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) {
|
||||
@@ -122,11 +116,3 @@ func TestUpdateAsked(t *testing.T) {
|
||||
t.Errorf("U on a dev build:\n%s", dev.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Warning: "theme: skipped unknown activeBorder"})
|
||||
if !strings.Contains(d.screen(), "theme: skipped unknown activeBorder") {
|
||||
t.Errorf("no warning:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
// Usage: revealing, copying, opening in the browser, sharing and jumping
|
||||
// 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
|
||||
// storage.history: false turns this off.
|
||||
// history: false turns this off.
|
||||
|
||||
// used counts a use of the remembered item key, keeping its context.
|
||||
func (m *Model) used(key string) {
|
||||
|
||||
@@ -11,7 +11,6 @@ type Set struct {
|
||||
Repo string
|
||||
Encrypted string // an encrypted file (SOPS)
|
||||
PlainFile string // a plain text secrets file (.env)
|
||||
Note string // has a note (lazysecrets list)
|
||||
}
|
||||
|
||||
// None renders no icons.
|
||||
@@ -25,7 +24,6 @@ var NerdFont = Set{
|
||||
Repo: "\U000F02A2", // md-git
|
||||
Encrypted: "\U000F033E", // md-lock
|
||||
PlainFile: "\U000F0214", // md-file
|
||||
Note: "\U000F09ED",
|
||||
}
|
||||
|
||||
// Prefix returns the icon for kind followed by a space, or "" if the set has
|
||||
|
||||
@@ -95,7 +95,7 @@ func (m *Map) Actions() []Action {
|
||||
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
|
||||
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
|
||||
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
|
||||
a(Lists, "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, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
|
||||
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
|
||||
|
||||
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, "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, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
|
||||
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
|
||||
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
||||
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
|
||||
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
|
||||
@@ -114,9 +114,9 @@ func (m *Map) Actions() []Action {
|
||||
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
|
||||
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
|
||||
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
|
||||
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; 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. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
|
||||
|
||||
a(Lists, "copyLink", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
||||
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
||||
a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."),
|
||||
a(Lists, "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.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.open": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
||||
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"},
|
||||
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
|
||||
"lists.commands": {"x"},
|
||||
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
|
||||
"lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"},
|
||||
"lists.copyLink": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
|
||||
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
|
||||
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
|
||||
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
|
||||
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
|
||||
|
||||
@@ -180,24 +180,6 @@ func (d Detail) View(st styles.Styles, focused bool, spin string) string {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// lines renders the body, wrapped to the pane width.
|
||||
func (d Detail) lines(st styles.Styles) []string {
|
||||
inner := max(1, d.w-3)
|
||||
@@ -205,7 +187,7 @@ func (d Detail) lines(st styles.Styles) []string {
|
||||
field := func(label, value string) {
|
||||
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
|
||||
indent := strings.Repeat(" ", 12)
|
||||
wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n"))
|
||||
wrapped := strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")
|
||||
out = append(out, prefix+wrapped[0])
|
||||
for _, w := range wrapped[1:] {
|
||||
out = append(out, indent+w)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,7 @@ var Roles = map[string]string{
|
||||
}
|
||||
|
||||
// FromTheme returns the default styles with the theme's roles applied, and
|
||||
// the default text colour if the theme sets one. Roles it doesn't know are
|
||||
// skipped (see Unknown): a theme file made for a newer lazysecrets still
|
||||
// works.
|
||||
// the default text colour if the theme sets one.
|
||||
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||
st := Default()
|
||||
var fg color.Color
|
||||
@@ -58,7 +56,7 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||
sort.Strings(roles)
|
||||
for _, role := range roles {
|
||||
if _, ok := Roles[role]; !ok {
|
||||
continue
|
||||
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
|
||||
}
|
||||
v, err := parse(theme[role])
|
||||
if err != nil {
|
||||
@@ -109,20 +107,13 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||
return st, fg, nil
|
||||
}
|
||||
|
||||
// Unknown says which of the theme's roles aren't known, for a warning, or
|
||||
// is empty.
|
||||
func Unknown(theme map[string][]string) string {
|
||||
var unknown []string
|
||||
for r := range theme {
|
||||
if _, ok := Roles[r]; !ok {
|
||||
unknown = append(unknown, r)
|
||||
}
|
||||
func sortedRoles() []string {
|
||||
var out []string
|
||||
for r := range Roles {
|
||||
out = append(out, r)
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return ""
|
||||
}
|
||||
slices.Sort(unknown)
|
||||
return "theme: skipped unknown " + strings.Join(unknown, ", ") + " (a typo, or from a newer lazysecrets)"
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// value is a parsed role: a colour (nil for default) and attributes.
|
||||
|
||||
@@ -44,29 +44,19 @@ func TestFromTheme(t *testing.T) {
|
||||
|
||||
func TestFromThemeRejectsMistakes(t *testing.T) {
|
||||
for name, theme := range map[string]map[string][]string{
|
||||
"role": {"activeBorder": {"red"}},
|
||||
"colour": {"errorColor": {"redish"}},
|
||||
"hex": {"errorColor": {"#fff"}},
|
||||
"palette": {"errorColor": {"300"}},
|
||||
} {
|
||||
if _, _, err := FromTheme(theme); err == nil {
|
||||
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) {
|
||||
st, fg, err := FromTheme(nil)
|
||||
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
|
||||
|
||||
+14
-52
@@ -28,7 +28,6 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -54,44 +53,18 @@ const maxDownload = 200 << 20
|
||||
|
||||
// Release is a published release.
|
||||
type Release struct {
|
||||
Tag string `json:"tag_name"`
|
||||
// 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"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Tag string `json:"tag_name"`
|
||||
Body string `json:"body"` // the changelog section, then install notes
|
||||
URL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// Notes is the changelog since the version current: of each release
|
||||
// newer than it (just this one for a dev build), without the install
|
||||
// notes every release ends with.
|
||||
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)
|
||||
}
|
||||
// Notes is the release's changelog: its body without the install notes
|
||||
// every release ends with.
|
||||
func (r Release) Notes() string {
|
||||
notes, _, _ := strings.Cut(r.Body, "## Install")
|
||||
return PlainLinks(strings.TrimSpace(notes))
|
||||
}
|
||||
|
||||
// recentReleases is how many releases Latest reads the changelogs of.
|
||||
const recentReleases = 10
|
||||
|
||||
// PlainLinks shows Markdown links as their text, for a terminal.
|
||||
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
|
||||
|
||||
@@ -155,29 +128,18 @@ func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
||||
defer cancel()
|
||||
// The recent releases in one request (per_page on GitHub, limit on
|
||||
// Gitea), for their changelogs.
|
||||
b, err := c.get(ctx, fmt.Sprintf("%s/releases?per_page=%d&limit=%[2]d", api, recentReleases), 1<<20)
|
||||
b, err := c.get(ctx, api+"/releases/latest", 1<<20)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
var all []Release
|
||||
if err := json.Unmarshal(b, &all); err != nil {
|
||||
return Release{}, fmt.Errorf("reading the releases from %s: %w", repo, err)
|
||||
var r Release
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err)
|
||||
}
|
||||
all = slices.DeleteFunc(all, func(r Release) bool { return r.Draft || r.Prerelease || !semver.IsValid(r.Tag) })
|
||||
if len(all) == 0 {
|
||||
return Release{}, fmt.Errorf("no releases on %s", repo)
|
||||
if !semver.IsValid(r.Tag) {
|
||||
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo)
|
||||
}
|
||||
slices.SortFunc(all, func(a, b Release) int { return semver.Compare(b.Tag, a.Tag) })
|
||||
var notes strings.Builder
|
||||
for _, r := range all {
|
||||
section, _, _ := strings.Cut(r.Body, "## Install")
|
||||
fmt.Fprintf(¬es, "## %s\n\n%s\n\n", r.Tag, strings.TrimSpace(section))
|
||||
}
|
||||
latest := all[0]
|
||||
latest.Body = strings.TrimSpace(notes.String())
|
||||
return latest, nil
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Newer reports whether latest is newer than current. A build that isn't
|
||||
|
||||
@@ -39,42 +39,11 @@ func TestNewer(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…"}
|
||||
if got := r.Notes("0.1.1"); got != "### Added\n\n- Install with a one-liner (#7)" {
|
||||
if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" {
|
||||
t.Errorf("notes %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" {
|
||||
t.Error("package managers not recognised")
|
||||
@@ -101,8 +70,8 @@ func server(t *testing.T, tamper bool) *httptest.Server {
|
||||
sum[0] ^= 0xff
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
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"}]`)
|
||||
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
|
||||
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
|
||||
})
|
||||
mux.HandleFunc("/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)
|
||||
@@ -204,7 +173,7 @@ func TestInstallRefusesATamperedArchive(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 …"}
|
||||
if got, want := r.Notes("0.1.1"), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
|
||||
if got, want := r.Notes(), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
|
||||
t.Errorf("notes %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user