1 Commits
Author SHA1 Message Date
renovate-bot Bot b3ece93752 chore(deps): update module golang.org/x/mod to v0.42.0
renovate/stability-days Updates have met minimum release age requirement
CI / Checks (pull_request) Successful in 27s
PR title / Conventional title (pull_request) Successful in 1s
2026-10-12 21:18:20 +00:00
63 changed files with 379 additions and 3197 deletions

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- name: staticcheck - name: staticcheck
# Pinned, so a new release can't fail CI on its own. # Pinned, so a new release can't fail CI on its own.
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 ./... run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
- name: go test - name: go test
run: go test ./... run: go test ./...
-2
View File
@@ -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>
-20
View File
@@ -2,26 +2,6 @@
Every release of lazysecrets, newest first. Every release of lazysecrets, newest first.
## 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 ## v0.1.0 – 2026-10-12
Hello world 👋 Hello world 👋
+7 -10
View File
@@ -14,8 +14,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
fzf-style matching. fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it - **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 with their own access, from the terminal or with a click in the browser
or chat. `.env` files can be shared too, as a link encrypted with a or chat.
secret your team can read.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim) - **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
@@ -34,20 +33,18 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Install ## Install
On macOS or Linux (arm64 or x86_64): On macOS or Linux (arm64 or x86_64):
```sh
curl -fsSL https://raw.githubusercontent.com/karleinarb/lazysecrets/main/install.sh | sh
```
Or
```sh ```sh
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
``` ```
Both approaches will install the latest release from [GitHub](https://github.com/karleinarb/lazysecrets/releases) with fallback to It installs the latest release, from GitHub or else
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
`~/.local/bin` after checking it against the release's checksums. `~/.local/bin` after checking it against the release's checksums.
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR` `sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else. somewhere else.
lazysecrets looks for a newer release once a day: the footer then shows lazysecrets looks for a newer release once a day: the footer then shows
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run `↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
`lazysecrets update`). `lazysecrets update`).
@@ -92,17 +89,17 @@ use most:
secret secret
- [Config](docs/config.md): every option, themes, layered configs, the - [Config](docs/config.md): every option, themes, layered configs, the
state file state file
- [Development](docs/development.md): building, tests, how changes land and - [Development](docs/development.md): building, tests, pull requests and
releasing releasing
## Privacy ## Privacy
lazysecrets never stores, caches or sends your secret values. It keeps the lazysecrets never stores, caches or sends your secret values. It keeps the
names and metadata of what you've loaded in a cache file readable only by names and metadata of what you've loaded in a cache file readable only by
you, to show it at once next time (`storage.cache: false` turns it off). It talks you, to show it at once next time (`cache: false` turns it off). It talks
only to the services you use it with, and collects no usage data. The one only to the services you use it with, and collects no usage data. The one
call of its own is a daily check for a new release, on GitHub or else call of its own is a daily check for a new release, on GitHub or else
git.kebcloud.no, which `updates.autoCheck: false` turns off. git.kebcloud.no, which `updateCheck: false` turns off.
## Repositories ## Repositories
-11
View File
@@ -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.
-2
View File
@@ -51,8 +51,6 @@ commit_preprocessors = [
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" }, { pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
# #11 was squash-merged with #10 (the status pane) in it: name both. # #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)" }, { pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" },
# #55's title starts with a key, which capitalised reads as another.
{ pattern = "^feat: e on the status pane edits the config", replace = "feat: `e` on the status pane edits the config" },
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see # Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
# pr-title.yml); all become "feat: x". # pr-title.yml); all become "feat: x".
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " }, { pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
+77 -9
View File
@@ -1,7 +1,10 @@
package main package main
import ( import (
"cmp"
"context" "context"
"errors"
"fmt"
"os" "os"
"golang.org/x/term" "golang.org/x/term"
@@ -30,17 +33,82 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err return err
} }
// value reads what r points at (ref.Read), with the account that last // value reads what r points at without listing its vault: the vault and
// read it first. // name are enough for Azure, tried with each account (the one that last
// read it first); for Databricks the workspace's profile is found by its
// host.
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) { func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
v, _, err := readKey(ctx, providers, st, r) if r.IsSource() || r.IsContainer() {
return v, err return provider.SecretValue{}, fmt.Errorf("%s points at a %s, not a secret", r, map[bool]string{true: "subscription or workspace", false: "vault or scope"}[r.IsSource()])
} }
if r.Kind != provider.KindAzure && r.Host == "" {
// readKey is value, also returning the version read. return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) { }
key := r key := r
key.Version = "" key.Version = ""
node := st.Node(key.String()) node := st.Node(key.String())
return ref.Read(ctx, providers, r, node.Account, node.Source)
type try struct {
p provider.Provider
o provider.Object
}
var tries []try
for _, p := range providers {
if p.Kind() != r.Kind {
continue
}
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
if r.Kind != provider.KindAzure { // by host: workspaces, repos
loc, ok := p.(provider.Locator)
if !ok {
continue
}
profile, found, err := loc.Locate(ctx, r.Host)
if err != nil || !found {
continue
}
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile}
o.Container = provider.Container{Source: src, Name: r.Scope}
// Listing the scopes tells a Key Vault-backed one, whose value
// can then be read from the vault if Databricks refuses.
if cs, err := p.Containers(ctx, src); err == nil {
for _, c := range cs {
if c.Name == r.Scope {
c.Source = src
o.Container = c
}
}
}
} else {
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
}
t := try{p, o}
if p.ID() == node.Account {
tries = append([]try{t}, tries...)
} else {
tries = append(tries, t)
}
}
if len(tries) == 0 {
switch r.Kind {
case provider.KindDatabricks:
return provider.SecretValue{}, fmt.Errorf("no Databricks profile for workspace %s", r.Host)
case provider.KindRepo:
return provider.SecretValue{}, fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host)
}
return provider.SecretValue{}, fmt.Errorf("no %s account to read %s with", r.Kind, r)
}
var errs []error
for _, t := range tries {
v, err := t.p.Value(ctx, t.o)
if err == nil {
return v, nil
}
if len(tries) == 1 {
return provider.SecretValue{}, err
}
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
}
return provider.SecretValue{}, errors.Join(errs...)
} }
+25 -82
View File
@@ -3,9 +3,7 @@
package main package main
import ( import (
"cmp"
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -108,7 +106,7 @@ func run() error {
showVersion := flag.Bool("version", false, "print the version and exit") showVersion := flag.Bool("version", false, "print the version and exit")
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)") dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends") demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request, every provider's (overrides the config)") timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
icons := flag.Bool("icons", true, icons := flag.Bool("icons", true,
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0") "show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)") clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
@@ -116,14 +114,10 @@ func run() error {
fmt.Fprintf(flag.CommandLine.Output(), `Usage: fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets lazysecrets [flags] browse secrets
lazysecrets [flags] [open] <ref> lazysecrets [flags] [open] <ref>
open a lazysecrets:// link (also a share link), open a lazysecrets:// link, Key Vault URL,
Key Vault URL, @Microsoft.KeyVault(...) or @Microsoft.KeyVault(...) or {{secrets/scope/key}}
{{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at, lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] share [--key <secret>] <env file>...
print a link to env files of a repo, encrypted
with a secret's value (default: sharing.key)
lazysecrets [flags] keybindings print every action and its keys (with your lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME] lazysecrets handler install [--terminal NAME]
@@ -146,14 +140,8 @@ Flags:
} }
var open, getRef *ref.Ref var open, getRef *ref.Ref
var shareArgs []string
printKeys := false printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) > 0 && args[0] == "share":
shareArgs = args[1:]
if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how
}
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
printKeys = true printKeys = true
case len(args) == 2 && args[0] == "get": case len(args) == 2 && args[0] == "get":
@@ -195,11 +183,6 @@ Flags:
if err != nil { if err != nil {
return err return err
} }
// Warnings show when the window opens.
var warnings []string
if w := styles.Unknown(cfg.Theme); w != "" {
warnings = append(warnings, w)
}
keymap := keys.Default() keymap := keys.Default()
if err := keymap.Apply(cfg.Keybinding); err != nil { if err := keymap.Apply(cfg.Keybinding); err != nil {
return err return err
@@ -214,52 +197,30 @@ Flags:
// Precedence: flag > environment > config file > default. // Precedence: flag > environment > config file > default.
set := map[string]bool{} set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true }) flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if *timeout <= 0 { if !set["timeout"] && cfg.Timeout > 0 {
return fmt.Errorf("--timeout: %s is not positive", *timeout) *timeout = cfg.Timeout
} }
// Each provider's own timeout, unless --timeout sets them all. if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
timeouts := map[provider.Kind]time.Duration{} *clipClear = *cfg.ClipboardClear
if !set["timeout"] {
if cfg.Providers.Timeout > 0 {
*timeout = cfg.Providers.Timeout
}
for k, d := range map[provider.Kind]time.Duration{
provider.KindAzure: cfg.Providers.Azure.Timeout,
provider.KindDatabricks: cfg.Providers.Databricks.Timeout,
provider.KindRepo: cfg.Providers.Repos.Timeout,
} {
if d > 0 {
timeouts[k] = d
}
}
}
// timeoutFor is how long a request to a provider of kind k may take.
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
*clipClear = *cfg.Values.ClipboardClear
} }
expiryDays := 30 expiryDays := 30
if cfg.UI.ExpiryWarningDays != nil { if cfg.ExpiryWarningDays != nil {
expiryDays = *cfg.UI.ExpiryWarningDays expiryDays = *cfg.ExpiryWarningDays
} }
revealTimeout := 60 * time.Second revealTimeout := 60 * time.Second
if cfg.Values.RevealTimeout != nil { if cfg.RevealTimeout != nil {
revealTimeout = *cfg.Values.RevealTimeout revealTimeout = *cfg.RevealTimeout
} }
loadDelay := 150 * time.Millisecond loadDelay := 150 * time.Millisecond
if cfg.UI.LoadDelay != nil { if cfg.LoadDelay != nil {
loadDelay = *cfg.UI.LoadDelay loadDelay = *cfg.LoadDelay
}
var updateEvery time.Duration // 0: the app's default, a day
if cfg.Updates.AutoCheckInterval != nil {
updateEvery = *cfg.Updates.AutoCheckInterval
} }
if !set["icons"] { if !set["icons"] {
switch { switch {
case os.Getenv("LAZYSECRETS_ICONS") != "": case os.Getenv("LAZYSECRETS_ICONS") != "":
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0" *icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
case cfg.UI.Icons != nil: case cfg.Icons != nil:
*icons = *cfg.UI.Icons *icons = *cfg.Icons
} }
} }
@@ -274,9 +235,7 @@ Flags:
statePath = filepath.Join(stateDir, "state.json") statePath = filepath.Join(stateDir, "state.json")
} }
st, err := store.Load(statePath) st, err := store.Load(statePath)
if errors.Is(err, store.ErrNewer) { if err != nil {
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
} else if err != nil {
return err return err
} }
@@ -318,7 +277,7 @@ Flags:
if start == "" { if start == "" {
start, _ = os.Getwd() start, _ = os.Getwd()
} }
roots := repo.DefaultRoots roots := []string{"~/git"}
if pc.Repos.Roots != nil { if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots roots = *pc.Repos.Roots
} }
@@ -341,21 +300,10 @@ Flags:
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
if getRef != nil { if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind)) ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel() defer cancel()
return get(ctx, providers, st, *getRef) return get(ctx, providers, st, *getRef)
} }
if shareArgs != nil {
// The files from a repo, the key from whichever provider it's
// in: the longest timeout.
d := *timeout
for _, t := range timeouts {
d = max(d, t)
}
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel()
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
}
// Links opened elsewhere (lazysecrets launch) can come here. // Links opened elsewhere (lazysecrets launch) can come here.
var p *tea.Program var p *tea.Program
@@ -391,7 +339,7 @@ Flags:
// What was loaded last time, and where it was left in this folder. // What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string var cacheFile, placeDir string
if !*demo { if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) { if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
cacheFile = filepath.Join(d, "cache.json") cacheFile = filepath.Join(d, "cache.json")
} }
placeDir = *dir placeDir = *dir
@@ -405,33 +353,28 @@ Flags:
m := app.New(providers, app.Options{ m := app.New(providers, app.Options{
Timeout: *timeout, Timeout: *timeout,
Timeouts: timeouts,
Icons: *icons, Icons: *icons,
ClipboardClear: *clipClear, ClipboardClear: *clipClear,
FlatSources: cfg.UI.SourcesView == "flat", FlatSources: cfg.SourcesView == "flat",
CollapseGroups: config.On(cfg.UI.CollapseGroups), CollapseGroups: config.On(cfg.CollapseGroups),
LoadDelay: loadDelay, LoadDelay: loadDelay,
NoHistory: !config.On(cfg.Storage.History), NoHistory: !config.On(cfg.History),
OnActive: onActive, OnActive: onActive,
RevealTimeout: revealTimeout, RevealTimeout: revealTimeout,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.Values.HideOnBlur), HideOnBlur: config.On(cfg.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(cfg.Updates.Sources), Updater: updater(cfg.Releases),
UpdateCheck: config.On(cfg.Updates.AutoCheck), UpdateCheck: config.On(cfg.UpdateCheck),
UpdateEvery: updateEvery,
Keys: &keymap, Keys: &keymap,
Commands: cfg.CustomCommands, Commands: cfg.CustomCommands,
Version: versionString(), Version: versionString(),
Store: st, Store: st,
Warning: strings.Join(warnings, " · "),
CacheFile: cacheFile, CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath), ConfigFiles: configFiles(*configPath),
StatePath: statePath, StatePath: statePath,
Changelog: lazysecrets.Changelog, Changelog: lazysecrets.Changelog,
Dir: placeDir, Dir: placeDir,
ShareKey: cfg.Sharing.Key,
ShareRules: shareRules(cfg),
Open: open, Open: open,
Styles: &theme, Styles: &theme,
DefaultFg: defaultFg, DefaultFg: defaultFg,
-93
View File
@@ -1,93 +0,0 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// shareFiles prints a share link to env files of one repo, encrypted with
// the --key secret's value, else the repo's or the config's sharing.key:
//
// lazysecrets share .env apps/api/.env
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error {
fs := flag.NewFlagSet("share", flag.ContinueOnError)
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: sharing.key)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() == 0 {
return errors.New("usage: lazysecrets share [--key <secret>] <env files>")
}
var root, id string
var files []share.File
for _, name := range fs.Args() {
abs, err := filepath.Abs(name)
if err != nil {
return err
}
r, i, err := repo.Root(filepath.Dir(abs))
if err != nil {
return err
}
if root == "" {
root, id = r, i
} else if r != root {
return fmt.Errorf("%s isn't in %s: share files of one repo at a time", name, root)
}
real, err := filepath.EvalSymlinks(abs)
if err != nil {
return err
}
rel, err := filepath.Rel(root, real)
if err != nil {
return err
}
f := share.File{Path: filepath.ToSlash(rel)}
if err := share.CheckPath(f.Path); err != nil {
return err
}
if f.Data, err = os.ReadFile(real); err != nil {
return err
}
files = append(files, f)
}
key, ok, err := share.DefaultKey(root, general)
if *keyLink != "" {
key, err = share.ParseKey(*keyLink)
ok = err == nil
}
if err != nil {
return err
}
if !ok {
return errors.New("which secret to encrypt with? --key <secret>, or sharing.key in the repo's .lazysecrets.yaml or the config")
}
v, version, err := readKey(ctx, providers, st, key)
if err != nil {
return fmt.Errorf("reading the key %s: %w", key, err)
}
link, err := share.Seal(share.Header{Key: share.Pin(key, version), Repo: id}, []byte(v.Reveal()), files, rules)
if err != nil {
return err
}
_, err = fmt.Fprintln(out, link)
return err
}
// shareRules are the config's sharing.keyRules.
func shareRules(cfg config.Config) share.Rules {
return share.Rules{MinLength: cfg.Sharing.KeyRules.MinLength, MinClasses: cfg.Sharing.KeyRules.MinClasses}
}
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// demoRules take the demo key, made up to be remembered.
var demoRules = share.Rules{MinLength: 16, MinClasses: 1}
func TestShareFiles(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir())
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", "git@git.example.com:team/app.git"}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Skip("git:", string(out))
}
}
os.WriteFile(filepath.Join(root, ".env"), []byte("A=1\n"), 0o600)
os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644)
providers := []provider.Provider{fake.Azure(0)}
st, _ := store.Load("")
ctx := context.Background()
key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password"
var out bytes.Buffer
if err := shareFiles(ctx, providers, st, key, demoRules, []string{filepath.Join(root, ".env")}, &out); err != nil {
t.Fatal(err)
}
r, err := ref.Parse(strings.TrimSpace(out.String()))
if err != nil {
t.Fatal(err)
}
s, err := share.Read(r)
if err != nil || s.Repo != "git.example.com/team/app" || s.Key.Name != "db-password" {
t.Fatalf("%+v, %v", s.Header, err)
}
files, err := s.Open([]byte("correct-horse-battery-staple"))
if err != nil || len(files) != 1 || files[0].Path != ".env" || string(files[0].Data) != "A=1\n" {
t.Errorf("%+v, %v", files, err)
}
for _, c := range []struct {
general string
args []string
want string
}{
{"", []string{filepath.Join(root, ".env")}, "which secret"},
{key, []string{filepath.Join(root, "main.go")}, "isn't an env file"},
{key, nil, "usage"},
{"", []string{"--key", "lazysecrets://azure/kv-app-prod.vault.azure.net", filepath.Join(root, ".env")}, "isn't a secret"},
} {
if err := shareFiles(ctx, providers, st, c.general, demoRules, c.args, &out); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%v: %v, want %q", c.args, err, c.want)
}
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ func updateCmd(configPath string, args []string) error {
current := versionString() current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
c := &update.Client{Servers: cfg.Updates.Sources} c := &update.Client{Servers: cfg.Releases}
r, err := c.Latest(ctx) r, err := c.Latest(ctx)
if err != nil { if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
@@ -55,7 +55,7 @@ func updateCmd(configPath string, args []string) error {
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag) fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil return nil
} }
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current))) fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes()))
if check { if check {
fmt.Println("lazysecrets update installs it.") fmt.Println("lazysecrets update installs it.")
return nil return nil
+1 -1
View File
@@ -63,7 +63,7 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane
(`[`/`]`). `space` reveals a secret's value, or the public material of a (`[`/`]`). `space` reveals a secret's value, or the public material of a
key (PEM public key) or certificate; private key material is never key (PEM public key) or certificate; private key material is never
fetched. `v` lists an item's versions. Items expiring within fetched. `v` lists an item's versions. Items expiring within
`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 The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
+26 -48
View File
@@ -2,58 +2,42 @@
Optional, at `~/.config/lazysecrets/config.yaml` (or Optional, at `~/.config/lazysecrets/config.yaml` (or
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override `$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the it. Unknown keys are an error, so typos don't go unnoticed.
status pane (`0`) opens it in your editor, as in lazygit (making it if
it isn't there yet); changes apply when lazysecrets starts again.
```yaml ```yaml
ui: icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0 clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
sourcesView: grouped # or flat; ` toggles it revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗ timeout: 30s # per backend request
values: loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown sourcesView: grouped # or flat; ` toggles it
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on) collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
storage: releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
cache: true # keep what was loaded (never values) for the next start - https://github.com/karleinarb/lazysecrets
history: true # count uses of items for Recent (R) - https://git.kebcloud.no/keb/lazysecrets
updates: history: true # count uses of items for Recent (R)
autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way) cache: true # keep what was loaded (never values) for the next start
autoCheckInterval: 24h # at most this often
sources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets
providers: providers:
timeout: 30s # per backend request; a provider's own timeout overrides it
azure: # applies to every az account azure: # applies to every az account
enabled: true # default enabled: true # default
timeout: 30s
hideSubscriptions: [platform-mgmt] # by name or ID hideSubscriptions: [platform-mgmt] # by name or ID
vaults: # listed under "Pinned vaults", for vaults vaults: # listed under "Pinned vaults", for vaults
- https://kv-foo.vault.azure.net/ # you can read but not discover - https://kv-foo.vault.azure.net/ # you can read but not discover
databricks: databricks:
enabled: true enabled: true
timeout: 30s
hideProfiles: [test] hideProfiles: [test]
repos: repos:
enabled: true enabled: true
timeout: 30s
roots: [~/git] roots: [~/git]
paths: [~/work/infra] paths: [~/work/infra]
include: ["secrets/**"] include: ["secrets/**"]
exclude: ["**/testdata/**"] exclude: ["**/testdata/**"]
sharing: # sharing env files as encrypted links
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
keyRules: # what a key's value must be
minLength: 32 # characters, 16 at least
minClasses: 2 # kinds: lowercase, uppercase, digits, symbols
``` ```
More, each on its own page: [repos](repos.md) (`providers.repos`), More, each on its own page: [repos](repos.md) (`providers.repos`), the
[sharing env files](links.md#sharing-env-files) (`sharing`), the
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`), [link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`). [custom commands](custom-commands.md) (`customCommands`).
@@ -91,11 +75,8 @@ theme:
repoColor: ["#c099ff"] repoColor: ["#c099ff"]
``` ```
A role lazysecrets doesn't know, a typo or one from a newer version, is
skipped with a warning.
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
as boxes, turn them off with `ui.icons: false`. as boxes, turn them off with `icons: false`.
## Layering ## Layering
@@ -119,13 +100,11 @@ starred the item under (a vault two of your accounts can see can be starred
for one); a note describes the item, so it shows under every account. It's for one); a note describes the item, so it shows under every account. It's
JSON so an editor plugin can read it with a built-in parser; several JSON so an editor plugin can read it with a built-in parser; several
lazysecrets windows can save to it without losing each other's changes. lazysecrets windows can save to it without losing each other's changes.
One written by a newer lazysecrets is read but not saved to, with a
warning, until you update.
Revealing, copying, opening in the browser, sharing and jumping to an item Revealing, copying, opening in the browser, sharing and jumping to an item
count as using it: its record keeps when it was last used and how often, count as using it: its record keeps when it was last used and how often,
for Recent (`R`). Records kept only for usage are dropped after 90 days for Recent (`R`). Records kept only for usage are dropped after 90 days
unused, and beyond the 100 most recent. `storage.history: false` stops counting. unused, and beyond the 100 most recent. `history: false` stops counting.
It also keeps where you left lazysecrets in each folder (the selected It also keeps where you left lazysecrets in each folder (the selected
source, vault or secret), and goes back there the next time it starts in source, vault or secret), and goes back there the next time it starts in
@@ -145,18 +124,17 @@ and copying always fetch the value live.
Lists not loaded for 30 days are dropped, and so is everything from an Lists not loaded for 30 days are dropped, and so is everything from an
account you're no longer signed in to. Repos aren't cached: their files account you're no longer signed in to. Repos aren't cached: their files
are read from disk anyway. Deleting the file is always safe, and are read from disk anyway. Deleting the file is always safe, and
`storage.cache: false` stops using it. `cache: false` stops using it.
## Updates ## Updates
At startup, in the background, lazysecrets asks for the latest release Once a day (`updateCheck`), in the background, lazysecrets asks for the
(`updates.autoCheck`), once a day or every `updates.autoCheckInterval`; latest release; within the day it reuses the answer from the state file.
until then it reuses the answer from the state file. Releases are Releases are published on GitHub and on git.kebcloud.no, and it asks
published on GitHub and on git.kebcloud.no, and it asks them in that them in that order, the next when one doesn't answer within 5 seconds;
order, the next when one doesn't answer within 5 seconds; `releases` lists others, such as a mirror of your own. Offline, or with
`updates.sources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows 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 Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against your OS and CPU from the first server that has it, checks it against
+1 -6
View File
@@ -7,11 +7,6 @@ go test ./...
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
``` ```
In `--demo`, sharing env files works end to end: `S` → `f` on a demo
repo, pick `kv-shared-prod / env-share` (the second account's) as the
key, then paste the link back. The demo repos aren't on disk, so opening
asks where to save, as for a repo you haven't cloned.
A stripped binary is about 21 MB instead of 30 MB: A stripped binary is about 21 MB instead of 30 MB:
```sh ```sh
@@ -22,7 +17,7 @@ Development builds show `dev-<commit>` as their version (`-dirty` with
uncommitted changes); `go install …@v0.2.0` shows the tag, releases the uncommitted changes); `go install …@v0.2.0` shows the tag, releases the
version they were built for. version they were built for.
## How changes land ## Pull requests
Pull requests into `main` are squash-merged, so each is one commit titled Pull requests into `main` are squash-merged, so each is one commit titled
like the PR. The title is a [conventional commit](https://www.conventionalcommits.org) like the PR. The title is a [conventional commit](https://www.conventionalcommits.org)
+4 -5
View File
@@ -54,7 +54,7 @@ match case exactly.
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) | | `o` | open the selected item in the browser (over SSH: copy the link) |
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) | | `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-files)) | | `s` / `S` | copy a [link](links.md) to the selected item / share menu |
| `ctrl+o` | open a link or reference (or just paste one) | | `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) | | `x` | [custom commands](custom-commands.md) |
| `L` | sign in again (shown when a login has expired) | | `L` | sign in again (shown when a login has expired) |
@@ -65,8 +65,8 @@ certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored. it exactly as stored.
A revealed value hides again after `values.revealTimeout` (60s) and A revealed value hides again after `revealTimeout` (60s) and when the
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md). terminal loses focus (`hideOnBlur`); see [config](config.md).
## Stars and notes ## Stars and notes
@@ -122,8 +122,7 @@ Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
it's hidden. it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how 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 your accounts are doing. Focused, the right pane lists more under the
editor, as in lazygit. Focused, the right pane lists more under the
logo (when there's room): the version and updates (`U`), each account 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 (`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 it), this session's errors, newest first (what a pane showed, or the
+8 -82
View File
@@ -1,10 +1,9 @@
# Links # Links
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can Every subscription, workspace, vault, scope, repo, file and secret has a
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens `lazysecrets://` link. Share one in chat or a ticket, and whoever opens it
it lands on that item, reading it with their own account and lands on that item, reading it with their own account and permissions:
permissions: the link names the item, never its value. Env files can links carry names, never values.
also be [shared encrypted](#sharing-env-files), in the link itself.
``` ```
lazysecrets://azure/kv-app-prod.vault.azure.net vault lazysecrets://azure/kv-app-prod.vault.azure.net vault
@@ -21,83 +20,10 @@ lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
that version). `S` opens the share menu: that version). `S` opens the share menu:
- secrets: the link (`o`), the plain Key Vault URL (`u`), the config - secrets: the link (`o`), the plain Key Vault URL (`u`), the config
reference (`y`) or the web page (`b`); in an env file, also the file as reference (`y`) or the web page (`b`)
an [encrypted link](#sharing-env-files) (`f`)
- vaults and scopes: a link (`o`), a link that opens on the current tab - vaults and scopes: a link (`o`), a link that opens on the current tab
(`t`), the plain vault URL (`u`) or the web page (`b`) (`t`), the plain vault URL (`u`) or the web page (`b`)
- subscriptions and workspaces: a link (`o`) or the web page (`b`) - subscriptions and workspaces: a link (`o`) or the web page (`b`)
- repos and env files: also their env files as an
[encrypted link](#sharing-env-files) (`f`)
## Sharing env files
`.env` files usually live only on your machine. `S` → `f` shares them as
an encrypted link, on a repo (all its plain env files), an env file, or a
key in one (its file):
```
lazysecrets://share/AQ…
```
The files are encrypted with a secret's value, so whoever can read that
secret (in Key Vault, Databricks or a repo's SOPS file) can open the link,
and nobody else: who may open it is up to the vault's access, as for
everything else. Make a secret for it once, random:
```sh
az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl rand -base64 32)"
```
and name it in the repo's `.lazysecrets.yaml` (committed: the whole team
uses it) or your config (for repos that don't name one), by its
`lazysecrets://` link, Key Vault URL or `@Microsoft.KeyVault(...)`
reference:
```yaml
sharing:
key: https://kv-team.vault.azure.net/secrets/env-share
```
With neither, sharing asks which secret, in [Go to](keys.md#go-to): it
lists only secrets, and `enter` picks one (pasting a reference picks it
too).
Anyone with a link can try to guess the key, so sharing refuses one that
isn't long and varied enough, saying how (never showing it). By default
that's 32 characters of two kinds; your config can ask for more, or less:
```yaml
sharing:
keyRules:
minLength: 32 # characters; 16 at least
minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols
```
Opening a link doesn't check them.
On the command line, `lazysecrets share [--key <secret>] <env file>...`
prints the link.
Opening a share link says which repo it's from, how many files and which
secret it's encrypted with, and decrypts only when asked: lazysecrets
reads the secret with your access, then shows where each file goes in
your clone of the repo (or asks for a folder, if it isn't here), which
ones would replace a file there and which ones git doesn't ignore. It
saves them all, only the new ones, or none. New files are readable by you
only.
A link holds the files' paths and contents encrypted ([age](https://age-encryption.org),
the secret's value as passphrase); the repo, the secret and the number of
files are in the clear. Links stay under 16 KB, which fits chat and
tickets.
A link stays readable as long as its secret is. On Azure it's pinned to
the secret's version, so adding a new version (rotating) doesn't break
old links, and disabling the old version, or taking away someone's
access, shuts them out; Databricks secrets have no versions, so changing
the value breaks every old link. Whoever has read the secret's value
before can decrypt links made with it without asking the vault, so rotate
it now and then: new links use the newest version.
## Opening ## Opening
@@ -118,9 +44,9 @@ read the clipboard over SSH.
lazysecrets finds the vault in your accounts (one Resource Graph query per lazysecrets finds the vault in your accounts (one Resource Graph query per
account) and jumps to it, asking which account to use when several can see account) and jumps to it, asking which account to use when several can see
it, ending on a secret's details as `enter` on it would. Links say what, it. Links say what, not what to do: opening a link to a secret asks whether
not what to do: opening a link to a secret asks whether to just go to it, to just go to it, also copy or show its value, or (`y`) copy its value
also copy or show its value, or (`y`) copy its value without going there. without going there.
## In scripts ## In scripts
+6 -18
View File
@@ -3,16 +3,14 @@
lazysecrets shows git repositories under **Repos**: their lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, [SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI) and plain env files as vaults, and the keys in them `.env`, INI) and plain env files as vaults, and the keys in them
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is (`db.password`, `list[0]`) as secrets.
escaped, `app\.name`, so it doesn't read as nesting.
## Which repos ## Which repos
**Repos** lists every repo with secret files under the `roots` (by **Repos** lists every repo with secret files under the `roots` (`~/git`
default whichever of `~/git`, `~/repo`, `~/repos`, `~/projects`, `~/code`, by default; one or two levels down, `repo` or `org/repo`), any listed in
`~/source` and `~/src` exist; one or two levels down, `repo` or `paths`, and any you starred something in (the repo, a file or a key),
`org/repo`), any listed in `paths`, and any you starred something in wherever it is. They're checked in the background.
(the repo, a file or a key), wherever it is. They're checked in the background.
Started inside a repo, that repo gets a row of its own at the top, Started inside a repo, that repo gets a row of its own at the top,
`Repo · <name>`, and lazysecrets opens on it, with the files under the `Repo · <name>`, and lazysecrets opens on it, with the files under the
@@ -51,20 +49,12 @@ Folders of dependencies and caches are never looked in, tracked or not:
```yaml ```yaml
providers: providers:
repos: repos:
roots: [~/git] # folders of repos (replaces the defaults; [] turns it off) roots: [~/git] # folders of repos (the default; [] turns it off)
paths: [~/work/infra] # single repos elsewhere paths: [~/work/infra] # single repos elsewhere
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
exclude: ["**/testdata/**"] exclude: ["**/testdata/**"]
``` ```
A repo's `.lazysecrets.yaml` can also name the secret its env files are
[shared](links.md#sharing-env-files) with:
```yaml
sharing:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
```
## Decrypting ## Decrypting
Listing never decrypts: SOPS encrypts values only, so keys and metadata Listing never decrypts: SOPS encrypts values only, so keys and metadata
@@ -100,8 +90,6 @@ sops 3.8 or later.
## Links ## Links
`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files).
Links name the repo by its git remote, so they open in a colleague's clone Links name the repo by its git remote, so they open in a colleague's clone
wherever it is: wherever it is:
+3 -5
View File
@@ -1,12 +1,11 @@
module git.kebcloud.no/keb/lazysecrets module git.kebcloud.no/keb/lazysecrets
go 1.27.2 go 1.26.9
require ( require (
charm.land/bubbles/v2 v2.2.1 charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0 charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6 charm.land/lipgloss/v2 v2.0.6
filippo.io/age v1.3.2
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0
@@ -18,8 +17,8 @@ require (
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/x/ansi v0.11.9 github.com/charmbracelet/x/ansi v0.11.9
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
github.com/databricks/databricks-sdk-go v0.188.0 github.com/databricks/databricks-sdk-go v0.187.0
github.com/junegunn/fzf v0.74.5 github.com/junegunn/fzf v0.74.4
go.yaml.in/yaml/v3 v3.0.5 go.yaml.in/yaml/v3 v3.0.5
golang.org/x/mod v0.42.0 golang.org/x/mod v0.42.0
golang.org/x/term v0.47.0 golang.org/x/term v0.47.0
@@ -30,7 +29,6 @@ require (
cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
+4 -10
View File
@@ -1,5 +1,3 @@
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
@@ -12,10 +10,6 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
@@ -72,8 +66,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/databricks/databricks-sdk-go v0.188.0 h1:1nxBqlctGnPzlReyziG199abHs9cR/KNjsCnSM+oDGA= github.com/databricks/databricks-sdk-go v0.187.0 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8=
github.com/databricks/databricks-sdk-go v0.188.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8= github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -100,8 +94,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/junegunn/fzf v0.74.5 h1:mpJlCdgGuddKerHrdjvV3cnezEBgyjZAVn8KCo/4OMc= github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso=
github.com/junegunn/fzf v0.74.5/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM= github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs= github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+47 -120
View File
@@ -14,23 +14,46 @@ import (
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
type Config struct { type Config struct {
UI UI `yaml:"ui"` Icons *bool `yaml:"icons"`
// Values keeps revealed and copied values from lingering. ClipboardClear *time.Duration `yaml:"clipboardClear"`
Values Values `yaml:"values"` // RevealTimeout hides a revealed value after this long; 0s never.
// Storage is what's kept between runs. // Defaults to 60s.
Storage Storage `yaml:"storage"` RevealTimeout *time.Duration `yaml:"revealTimeout"`
// Updates sets how lazysecrets looks for newer releases. // ExpiryWarningDays marks secrets, keys and certificates expiring
Updates Updates `yaml:"updates"` // within this many days; 0 marks only expired ones. Defaults to 30.
Providers Providers `yaml:"providers"` 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 sets how lazysecrets:// links open; see linkhandler.
LinkHandler LinkHandler `yaml:"linkHandler"` LinkHandler LinkHandler `yaml:"linkHandler"`
// Sharing sets how env files are shared; see package share.
Sharing Sharing `yaml:"sharing"`
// CustomCommands run shell commands on the selected item; see // CustomCommands run shell commands on the selected item; see
// package commands. // package commands.
CustomCommands []commands.Command `yaml:"customCommands"` CustomCommands []commands.Command `yaml:"customCommands"`
@@ -42,52 +65,6 @@ type Config struct {
Theme map[string][]string `yaml:"theme"` Theme map[string][]string `yaml:"theme"`
} }
type UI struct {
Icons *bool `yaml:"icons"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// ExpiryWarningDays marks secrets, keys and certificates expiring
// within this many days; 0 marks only expired ones. Defaults to 30.
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
}
type Values struct {
// RevealTimeout hides a revealed value after this long; 0s never.
// Defaults to 60s.
RevealTimeout *time.Duration `yaml:"revealTimeout"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
ClipboardClear *time.Duration `yaml:"clipboardClear"`
}
type Storage struct {
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
}
type Updates struct {
// AutoCheck looks for a newer release in the background at startup,
// at most once per AutoCheckInterval; defaults to true. U and
// lazysecrets update work either way.
AutoCheck *bool `yaml:"autoCheck"`
// AutoCheckInterval defaults to 24h.
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
// Sources are the repositories updates come from, tried in
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
// then git.kebcloud.no (update.DefaultServers).
Sources []string `yaml:"sources"`
}
type LinkHandler struct { type LinkHandler struct {
// Terminal to open links in, overriding the one handler install // Terminal to open links in, overriding the one handler install
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty, // detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
@@ -105,43 +82,21 @@ type LinkHandler struct {
Prompt *bool `yaml:"prompt"` Prompt *bool `yaml:"prompt"`
} }
type Sharing struct {
// Key is the secret whose value encrypts shared files (its
// lazysecrets:// link, Key Vault URL or reference), for repos whose
// .lazysecrets.yaml doesn't name one.
Key string `yaml:"key"`
// KeyRules are what a key's value must be to share with it.
KeyRules KeyRules `yaml:"keyRules"`
}
// KeyRules default to 32 characters of 2 kinds (share.DefaultRules).
type KeyRules struct {
MinLength int `yaml:"minLength"` // at least 16
// MinClasses is how many kinds of character: lowercase, uppercase,
// digits, symbols.
MinClasses int `yaml:"minClasses"`
}
type Providers struct { type Providers struct {
// Timeout bounds each backend request; defaults to 30s. Each Azure Azure `yaml:"azure"`
// provider's own timeout overrides it for that provider. Databricks Databricks `yaml:"databricks"`
Timeout time.Duration `yaml:"timeout"` 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, // 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 // that repo is the only one shown; elsewhere, the ones in Paths and under
// Roots. // Roots.
type Repos struct { type Repos struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
Paths []string `yaml:"paths"` Paths []string `yaml:"paths"`
// Roots are folders of repos. Defaults to repo.DefaultRoots (~/git, // Roots are folders of repos. Defaults to ~/git; an empty list
// ~/src, ~/code and the like); an empty list turns it off. // turns it off.
Roots *[]string `yaml:"roots"` Roots *[]string `yaml:"roots"`
// Include and Exclude are globs relative to a repo's root (** for any // Include and Exclude are globs relative to a repo's root (** for any
// depth), for repos without their own .lazysecrets.yaml. Without them, // depth), for repos without their own .lazysecrets.yaml. Without them,
@@ -151,8 +106,6 @@ type Repos struct {
} }
type Azure struct { type Azure struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
// HideSubscriptions lists subscriptions to leave out, by name or ID. // HideSubscriptions lists subscriptions to leave out, by name or ID.
@@ -163,8 +116,6 @@ type Azure struct {
} }
type Databricks struct { type Databricks struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
HideProfiles []string `yaml:"hideProfiles"` HideProfiles []string `yaml:"hideProfiles"`
@@ -251,47 +202,23 @@ func decodeInto(c *Config, path string) error {
} }
func (c Config) validate(path string) error { func (c Config) validate(path string) error {
if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" { if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v) return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
} }
for _, cmd := range c.CustomCommands { for _, cmd := range c.CustomCommands {
if err := cmd.Validate(); err != nil { if err := cmd.Validate(); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
} }
if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 { if d := c.ExpiryWarningDays; d != nil && *d < 0 {
return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d) return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d)
} }
if d := c.UI.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) { if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: ui.loadDelay: %s is not between 0s and 5s", path, *d) return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
} }
if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 { for i, r := range c.Releases {
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
}
for i, r := range c.Updates.Sources {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") { if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: 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 _, err := share.ParseKey(k); err != nil {
return fmt.Errorf("%s: sharing.key: %w", path, err)
}
}
if n := c.Sharing.KeyRules.MinLength; n != 0 && n < 16 {
return fmt.Errorf("%s: sharing.keyRules.minLength: %d is under 16", path, n)
}
if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
}
for name, d := range map[string]time.Duration{
"providers.timeout": c.Providers.Timeout,
"providers.azure.timeout": c.Providers.Azure.Timeout,
"providers.databricks.timeout": c.Providers.Databricks.Timeout,
"providers.repos.timeout": c.Providers.Repos.Timeout,
} {
if d < 0 {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
} }
} }
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
+23 -39
View File
@@ -19,17 +19,12 @@ func write(t *testing.T, content string) string {
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
c, err := Load(write(t, ` c, err := Load(write(t, `
ui: icons: false
icons: false sourcesView: flat
sourcesView: flat clipboardClear: 45s
values: timeout: 1m
clipboardClear: 45s
storage:
history: false
providers: providers:
timeout: 1m
azure: azure:
timeout: 2m
hideSubscriptions: [platform-mgmt] hideSubscriptions: [platform-mgmt]
vaults: vaults:
- https://kv-foo.vault.azure.net/ - https://kv-foo.vault.azure.net/
@@ -40,12 +35,12 @@ providers:
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second || if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second ||
c.Providers.Timeout != time.Minute || c.Providers.Azure.Timeout != 2*time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" { c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
t.Errorf("config = %+v", c) t.Errorf("config = %+v", c)
} }
if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) { if c.SourcesView != "flat" {
t.Errorf("ui = %+v, storage = %+v", c.UI, c.Storage) t.Errorf("sourcesView = %q", c.SourcesView)
} }
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) { if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
t.Error("enabled flags wrong") t.Error("enabled flags wrong")
@@ -62,7 +57,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"empty": write(t, ""), "empty": write(t, ""),
} { } {
c, err := Load(path) c, err := Load(path)
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil { if err != nil || c.Icons != nil || c.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err) t.Errorf("%s: %+v, %v", name, c, err)
} }
} }
@@ -73,17 +68,10 @@ func TestLoadRejectsMistakes(t *testing.T) {
"typo": "icon: true\n", "typo": "icon: true\n",
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n", "bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"flat keys": "icons: false\n", "duration": "timeout: soon\n",
"duration": "providers:\n timeout: soon\n", "view": "sourcesView: tree\n",
"negative": "providers:\n repos:\n timeout: -1s\n", "releases": "releases: [git.example.com/team/lazysecrets]\n",
"view": "ui:\n sourcesView: tree\n", "http": "releases: [http://git.example.com/team/lazysecrets]\n",
"sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n",
"http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n",
"interval": "updates:\n autoCheckInterval: 0s\n",
"share key": "sharing:\n key: https://kv.vault.azure.net/\n",
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
} { } {
if _, err := Load(write(t, content)); err == nil { if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
@@ -95,8 +83,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
func TestLoadAllLayers(t *testing.T) { func TestLoadAllLayers(t *testing.T) {
user := write(t, ` user := write(t, `
ui: icons: false
icons: false
theme: theme:
azureColor: [blue] azureColor: [blue]
errorColor: [red, bold] errorColor: [red, bold]
@@ -105,8 +92,6 @@ providers:
hideSubscriptions: [a] hideSubscriptions: [a]
`) `)
theme := write(t, ` theme := write(t, `
ui:
sourcesView: flat
theme: theme:
azureColor: ["#82aaff"] azureColor: ["#82aaff"]
activeBorderColor: ["#ff966c", bold] activeBorderColor: ["#ff966c", bold]
@@ -115,8 +100,7 @@ theme:
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// A group set in both files merges: icons from one, sourcesView the other. if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 {
if c.UI.Icons == nil || *c.UI.Icons || c.UI.SourcesView != "flat" || len(c.Providers.Azure.HideSubscriptions) != 1 {
t.Errorf("user settings lost: %+v", c) t.Errorf("user settings lost: %+v", c)
} }
want := map[string][]string{ want := map[string][]string{
@@ -131,19 +115,19 @@ theme:
} }
} }
func TestUpdates(t *testing.T) { func TestReleases(t *testing.T) {
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")) c, err := Load(write(t, "releases: [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 { if err != nil || len(c.Releases) != 1 {
t.Errorf("updates = %+v, %v", c.Updates, err) t.Errorf("releases = %v, %v", c.Releases, err)
} }
} }
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n")) c, err := Load(write(t, "loadDelay: 300ms\n"))
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond { if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err) t.Errorf("loadDelay = %v, %v", c.LoadDelay, err)
} }
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil { if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted") t.Error("a minute's delay accepted")
} }
} }
+8 -34
View File
@@ -299,7 +299,7 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
if s.ID == nil || deref(s.Managed) { if s.ID == nil || deref(s.Managed) {
continue continue
} }
out = append(out, secretObject(c, s, p.tenant(c.Source.ID))) out = append(out, secretObject(c, s))
} }
} }
case provider.TypeKey: case provider.TypeKey:
@@ -392,7 +392,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
} }
for _, s := range page.Value { for _, s := range page.Value {
if s.ID != nil { if s.ID != nil {
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID))) out = append(out, secretObject(o.Container, s))
} }
} }
} }
@@ -425,13 +425,8 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
default: default:
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
tenant := p.tenant(o.Container.Source.ID)
for i := range out { for i := range out {
out[i].Reference = versionedReference(out[i]) out[i].Reference = versionedReference(out[i])
if o.Type != provider.TypeSecret { // secretObject's is the version's already
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type]
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
}
} }
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) }) slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
return out, nil return out, nil
@@ -458,8 +453,11 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
} }
switch o.Type { switch o.Type {
case provider.TypeSecret: case provider.TypeSecret:
v, _, err := p.VersionedValue(ctx, o) resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
return v, err if err != nil {
return provider.SecretValue{}, cleanError(err)
}
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
case provider.TypeKey: case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil) resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil { if err != nil {
@@ -483,31 +481,7 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.SecretValue{}, provider.ErrUnsupported return provider.SecretValue{}, provider.ErrUnsupported
} }
// VersionedValue is Value, with the version read. var _ provider.Locator = (*Provider)(nil)
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
if o.Type != provider.TypeSecret {
v, err := p.Value(ctx, o)
return v, o.Version, err
}
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, "", err
}
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
version := o.Version
if resp.ID != nil {
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
}
var (
_ provider.Locator = (*Provider)(nil)
_ provider.Versioned = (*Provider)(nil)
)
// validHost guards the Resource Graph query against odd input. // validHost guards the Resource Graph query against odd input.
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`) var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
-16
View File
@@ -30,7 +30,6 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -335,21 +334,6 @@ func TestPortalURLs(t *testing.T) {
} }
} }
func TestSecretPages(t *testing.T) {
c := provider.Container{Detail: "https://kv-app.vault.azure.net/"}
base := "https://portal.azure.com/#@contoso.onmicrosoft.com/asset/Microsoft_Azure_KeyVault/Secret/https://kv-app.vault.azure.net/secrets/db"
for id, want := range map[string]string{
"https://kv-app.vault.azure.net/secrets/db": base, // listed: the secret's page
"https://kv-app.vault.azure.net/secrets/db/06140fa2b9a64869a96ef040f6d4dcab": base + "/06140fa2b9a64869a96ef040f6d4dcab", // a version's
} {
sid := azsecrets.ID(id)
o := secretObject(c, &azsecrets.SecretProperties{ID: &sid}, "contoso.onmicrosoft.com")
if o.URL != want {
t.Errorf("%s: %q, want %q", id, o.URL, want)
}
}
}
func TestVersionedReference(t *testing.T) { func TestVersionedReference(t *testing.T) {
sec := provider.Object{Type: provider.TypeSecret, Version: "v1", sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"} Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
+4 -7
View File
@@ -53,8 +53,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
} }
// portalAssetURL links to a Key Vault object's page: a version's with // portalAssetURL links to a Key Vault object's page; objectID must include
// one in objectID; a secret's own (its versions) without. // the version.
func portalAssetURL(tenant, kind, objectID string) string { func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
} }
@@ -88,10 +88,7 @@ func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
} }
// secretObject is a secret, or a version of one, from a listing. Its URL func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
// is its page in the portal: the secret's (with its versions), or with
// a version, that version's.
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, "secrets", name) url := objectURL(c, "secrets", name)
o := provider.Object{ o := provider.Object{
@@ -103,7 +100,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
ContentType: deref(p.ContentType), ContentType: deref(p.ContentType),
Tags: tags(p.Tags), Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")", Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")), URL: ObjectListURL(c, provider.TypeSecret),
} }
if a := p.Attributes; a != nil { if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
+2 -7
View File
@@ -134,14 +134,13 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.NewSecretValue([]byte(v)), nil return provider.NewSecretValue([]byte(v)), nil
} }
// Locate finds the source holding the vault (by host), the workspace or // Locate finds the source holding the vault (by host) or the workspace.
// the repo (by its remote).
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) { func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
if err := p.wait(ctx); err != nil { if err := p.wait(ctx); err != nil {
return "", false, err return "", false, err
} }
for _, src := range p.sources { for _, src := range p.sources {
if (p.kind == provider.KindDatabricks || p.kind == provider.KindRepo) && strings.EqualFold(src.Detail, host) { if p.kind == provider.KindDatabricks && src.Detail == host {
return src.ID, true, nil return src.ID, true, nil
} }
for _, c := range p.containers[src.ID] { for _, c := range p.containers[src.ID] {
@@ -332,10 +331,6 @@ func Azure(delay time.Duration) *Provider {
}, demoCert) }, demoCert)
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
+2 -17
View File
@@ -35,7 +35,7 @@ func Repos() *Provider {
// commit are what git says of it: "tracked", "ignored" or "untracked". // commit are what git says of it: "tracked", "ignored" or "untracked".
file := func(src provider.Source, path, method, git, commit string, kv ...string) { file := func(src provider.Source, path, method, git, commit string, kv ...string) {
sops := method != "" sops := method != ""
detail := provider.DetailPlainEnv detail := "env · plain text"
if sops { if sops {
detail = "sops · " + method detail = "sops · " + method
} }
@@ -94,7 +94,7 @@ func Repos() *Provider {
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app", "database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000") "stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago", file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago",
"github.client_secret", "8f3c2a9d7e1b6f4a0c5d2e9b8a7f6c3d1e0b4a5f", "github.client_secret", "ghs_demo0000000000000000000000",
"google.client_secret", "GOCSPX-demo-000000000000") "google.client_secret", "GOCSPX-demo-000000000000")
file(api, ".env", "", "ignored", "", file(api, ".env", "", "ignored", "",
"DATABASE_URL", "postgres://app:local@localhost:5432/app", "DATABASE_URL", "postgres://app:local@localhost:5432/app",
@@ -134,18 +134,3 @@ func format(path string) string {
} }
return "dotenv" return "dotenv"
} }
var _ provider.Contents = (*Provider)(nil)
// Contents is a plain env file as KEY=value lines, from its keys, so the
// demo repos' env files can be shared.
func (p *Provider) Contents(ctx context.Context, c provider.Container) ([]byte, error) {
if c.Detail != provider.DetailPlainEnv {
return nil, provider.ErrUnsupported
}
var b strings.Builder
for _, o := range p.objects[listKey{c.ID, provider.TypeSecret}] {
b.WriteString(o.Name + "=" + p.values[o.ID] + "\n")
}
return []byte(b.String()), ctx.Err()
}
-17
View File
@@ -167,13 +167,6 @@ type Current interface {
Current(ctx context.Context) (Source, bool) Current(ctx context.Context) (Source, bool)
} }
// Versioned is implemented by providers whose secrets have versions: it
// is Value, also returning the version read (the current one when
// o.Version is empty), so a link can pin it.
type Versioned interface {
VersionedValue(ctx context.Context, o Object) (SecretValue, string, error)
}
// Locator is implemented by providers that can find where a container lives, // Locator is implemented by providers that can find where a container lives,
// to open links. address is a vault host (kv.vault.azure.net) or a // to open links. address is a vault host (kv.vault.azure.net) or a
// workspace host. It returns the ID of the source holding it. // workspace host. It returns the ID of the source holding it.
@@ -189,12 +182,6 @@ type Files interface {
File(c Container, o *Object) (File, error) File(c Container, o *Object) (File, error)
} }
// Contents is implemented by providers whose containers are files, so
// they can be shared (package share): the file as it is.
type Contents interface {
Contents(ctx context.Context, c Container) ([]byte, error)
}
// File is a container's file, to open. // File is a container's file, to open.
type File struct { type File struct {
Path string Path string
@@ -233,10 +220,6 @@ func (d *Denied) Error() string {
func (d *Denied) Unwrap() error { return d.Err } func (d *Denied) Unwrap() error { return d.Err }
// DetailPlainEnv is the Detail of a container that is a plain (not
// encrypted) env file in a repo.
const DetailPlainEnv = "env · plain text"
// ContainerDetails is implemented by providers that can say more about a // ContainerDetails is implemented by providers that can say more about a
// container than its listing does: how a file is encrypted and what git // container than its listing does: how a file is encrypted and what git
// knows of it. ErrUnsupported means there's nothing more for c. // knows of it. ErrUnsupported means there's nothing more for c.
+25 -111
View File
@@ -80,10 +80,6 @@ type repoInfo struct {
here bool // the repo lazysecrets started in here bool // the repo lazysecrets started in
} }
// DefaultRoots are the usual folders of repos, used when the config
// doesn't set roots. Ones that don't exist cost a stat each.
var DefaultRoots = []string{"~/git", "~/repo", "~/repos", "~/projects", "~/code", "~/source", "~/src"}
func New(s Settings) *Provider { func New(s Settings) *Provider {
if s.Sops == "" { if s.Sops == "" {
s.Sops = "sops" s.Sops = "sops"
@@ -120,18 +116,10 @@ func (p *Provider) load() {
} }
add(root) add(root)
} }
// Missing roots are skipped (most of the defaults are), and one
// that's a symlink to another is scanned once.
scanned := map[string]bool{}
for _, dir := range p.s.Roots { for _, dir := range p.s.Roots {
base, err := filepath.EvalSymlinks(expandHome(dir)) base, _ := filepath.EvalSymlinks(expandHome(dir))
if err != nil || scanned[base] { for _, root := range reposIn(expandHome(dir), 2) {
continue if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
}
scanned[base] = true
for _, root := range reposIn(base, 2) {
// A repo under two roots keeps the first one's name.
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") && names[root] == "" {
names[root] = filepath.ToSlash(name) names[root] = filepath.ToSlash(name)
} }
add(root) add(root)
@@ -323,8 +311,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
c.Detail = "sops · " + strings.Join(meta.methods, ", ") c.Detail = "sops · " + strings.Join(meta.methods, ", ")
case isSops: case isSops:
return // a SOPS file where .sops.yaml says SOPS files don't go return // a SOPS file where .sops.yaml says SOPS files don't go
case IsEnv(f): case isEnv(f):
c.Detail = provider.DetailPlainEnv c.Detail = "env · plain text"
default: default:
return return
} }
@@ -403,11 +391,10 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00") file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key if !ok { // made from a link: the repo's root, the file's path, the key
file = o.Container.ID file, extract = o.Container.ID, extractFor(o.Name)
if file == "" { if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name)) file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
} }
extract = extractFor(file, o.Name)
} }
if _, isSops := readMeta(file); !isSops { if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name) return plainValue(file, o.Name)
@@ -425,15 +412,6 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.NewSecretValue(stdout.Bytes()), nil return provider.NewSecretValue(stdout.Bytes()), nil
} }
// Contents reads the file c is.
func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, error) {
f, err := p.File(c, nil)
if err != nil {
return nil, err
}
return os.ReadFile(f.Path)
}
// File is the file c is, and the line o's key is on. // File is the file c is, and the line o's key is on.
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) { func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := c.ID path := c.ID
@@ -486,16 +464,6 @@ func (p *Provider) Locate(_ context.Context, host string) (string, bool, error)
return "", false, nil return "", false, nil
} }
// Root is the root of the git repo dir is in, and the repo's ID: its
// remote without scheme or .git (a repo link's host), else the root.
func Root(dir string) (root, id string, err error) {
root, err = gitRoot(dir)
if err != nil {
return "", "", fmt.Errorf("%s isn't in a git repository", dir)
}
return root, info(root, "").id, nil
}
// --- git --- // --- git ---
func gitRoot(dir string) (string, error) { func gitRoot(dir string) (string, error) {
@@ -600,27 +568,6 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile struct { type lazysecretsFile struct {
Include []string `yaml:"include"` Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"` Exclude []string `yaml:"exclude"`
Sharing struct {
// Key is the link to the secret that encrypts the repo's
// shared env files (package share).
Key string `yaml:"key"`
} `yaml:"sharing"`
}
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) {
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return f.Sharing.Key, nil
} }
// selector says which files to look at: user (the include/exclude of the // selector says which files to look at: user (the include/exclude of the
@@ -671,7 +618,7 @@ func envFiles(root string, tracked []string) []string {
dirs := map[string]bool{".": true} dirs := map[string]bool{".": true}
for _, f := range tracked { for _, f := range tracked {
isTracked[f] = true isTracked[f] = true
if IsEnv(f) { if isEnv(f) {
out = append(out, f) out = append(out, f)
} }
if d := path.Dir(f); !dirs[d] && !inPackages(f) { if d := path.Dir(f); !dirs[d] && !inPackages(f) {
@@ -685,7 +632,7 @@ func envFiles(root string, tracked []string) []string {
} }
for _, e := range entries { for _, e := range entries {
f := path.Join(d, e.Name()) f := path.Join(d, e.Name())
if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) { if e.Type().IsRegular() && !isTracked[f] && isEnv(f) {
out = append(out, f) out = append(out, f)
} }
} }
@@ -716,10 +663,10 @@ func inPackages(f string) bool {
return false return false
} }
// IsEnv reports whether a file is named like an env file with real values: // isEnv reports whether a file is named like an env file with real values:
// .env, .env.local, prod.env; not .env.example and the like, nor one in // .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies. // dependencies.
func IsEnv(f string) bool { func isEnv(f string) bool {
if inPackages(f) { if inPackages(f) {
return false return false
} }
@@ -1013,7 +960,7 @@ func formatOf(file string) string {
} }
type key struct { type key struct {
name string // db.password, list[0], with . [ ] \ in keys escaped name string // db.password, list[0]
extract string // sops --extract: ["db"]["password"] extract string // sops --extract: ["db"]["password"]
encrypted bool encrypted bool
line int // 1-based line int // 1-based
@@ -1058,7 +1005,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue continue
} }
k, v = strings.TrimSpace(k), strings.TrimSpace(v) k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
} }
} }
return out, nil return out, nil
@@ -1075,9 +1022,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
if top && k == "sops" { if top && k == "sops" {
continue continue
} }
child := escape(k) child := k
if name != "" { if name != "" {
child = name + "." + child child = name + "." + k
} }
walk(n.Content[i+1], child, extract+quote(k), false, out) walk(n.Content[i+1], child, extract+quote(k), false, out)
} }
@@ -1099,56 +1046,23 @@ func quote(k string) string {
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]` return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
} }
// escape keeps a key's dots and brackets from reading as nesting in a
// name: app\.name is one key, app.name two.
func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractFor rebuilds the extract path from a key name, for objects made // extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0], app\.name). A dotenv file is flat: // from a link (db.password, list[0]). Names with dots in keys can't be
// its names are the keys as they are. // told apart from nesting: those come from the listing instead.
func extractFor(file, name string) string { func extractFor(name string) string {
if formatOf(file) == "dotenv" { var b strings.Builder
return quote(name) for _, part := range strings.Split(name, ".") {
} base, idx, _ := strings.Cut(part, "[")
var b, part strings.Builder if base != "" {
flush := func() { b.WriteString(quote(base))
if part.Len() > 0 { }
b.WriteString(quote(part.String())) if idx != "" {
part.Reset() 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() return b.String()
} }
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
}
func absPath(p string) string { func absPath(p string) string {
if a, err := filepath.Abs(p); err == nil { if a, err := filepath.Abs(p); err == nil {
return a return a
-45
View File
@@ -336,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) { func TestStarredReposAlongside(t *testing.T) {
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}) here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv}) starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
@@ -545,24 +521,3 @@ func TestFileDetails(t *testing.T) {
t.Errorf("untracked env: %+v", d) t.Errorf("untracked env: %+v", d)
} }
} }
func TestKeyNamesEscaped(t *testing.T) {
keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n"))
if err != nil {
t.Fatal(err)
}
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
-103
View File
@@ -1,103 +0,0 @@
package ref
import (
"cmp"
"context"
"errors"
"fmt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Read reads what r points at without listing its vault: the vault and
// name are enough for Azure, tried with each account (account, the one
// that last read it, first, in source); for Databricks and repos the
// profile or repo is found by its host. It also returns the version read,
// when the provider tells (provider.Versioned).
func Read(ctx context.Context, providers []provider.Provider, r Ref, account, source string) (provider.SecretValue, string, error) {
if r.IsShare() || r.IsSource() || r.IsContainer() {
what := "vault or scope"
switch {
case r.IsShare():
what = "share link"
case r.IsSource():
what = "subscription or workspace"
}
return provider.SecretValue{}, "", fmt.Errorf("%s points at a %s, not a secret", r, what)
}
if r.Kind != provider.KindAzure && r.Host == "" {
return provider.SecretValue{}, "", errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
}
type try struct {
p provider.Provider
o provider.Object
}
var tries []try
for _, p := range providers {
if p.Kind() != r.Kind {
continue
}
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
if r.Kind != provider.KindAzure { // by host: workspaces, repos
loc, ok := p.(provider.Locator)
if !ok {
continue
}
profile, found, err := loc.Locate(ctx, r.Host)
if err != nil || !found {
continue
}
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile}
o.Container = provider.Container{Source: src, Name: r.Scope}
// Listing the scopes tells a Key Vault-backed one, whose value
// can then be read from the vault if Databricks refuses.
if cs, err := p.Containers(ctx, src); err == nil {
for _, c := range cs {
if c.Name == r.Scope {
c.Source = src
o.Container = c
}
}
}
} else {
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: source}
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
}
t := try{p, o}
if p.ID() == account {
tries = append([]try{t}, tries...)
} else {
tries = append(tries, t)
}
}
if len(tries) == 0 {
switch r.Kind {
case provider.KindDatabricks:
return provider.SecretValue{}, "", fmt.Errorf("no Databricks profile for workspace %s", r.Host)
case provider.KindRepo:
return provider.SecretValue{}, "", fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host)
}
return provider.SecretValue{}, "", fmt.Errorf("no %s account to read %s with", r.Kind, r)
}
var errs []error
for _, t := range tries {
v, version, err := value(ctx, t.p, t.o)
if err == nil {
return v, version, nil
}
if len(tries) == 1 || ctx.Err() != nil {
return provider.SecretValue{}, "", err
}
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
}
return provider.SecretValue{}, "", errors.Join(errs...)
}
func value(ctx context.Context, p provider.Provider, o provider.Object) (provider.SecretValue, string, error) {
if vp, ok := p.(provider.Versioned); ok {
return vp.VersionedValue(ctx, o)
}
v, err := p.Value(ctx, o)
return v, o.Version, err
}
+2 -26
View File
@@ -10,15 +10,11 @@
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]] // lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]] // lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
// lazysecrets://repo/<repo>[/<file>[/<key>]] // lazysecrets://repo/<repo>[/<file>[/<key>]]
// lazysecrets://share/<sealed files>
// //
// A repo is its git remote without scheme or .git // A repo is its git remote without scheme or .git
// (git.kebcloud.no/keb/infra), the file its path in the repo and the key // (git.kebcloud.no/keb/infra), the file its path in the repo and the key
// its path in the file (db.password); slashes inside them are escaped. // its path in the file (db.password); slashes inside them are escaped.
// //
// A share link carries files, encrypted (package share), instead of
// naming something.
//
// Vault hosts always contain dots, so "subscriptions" can't be one. // Vault hosts always contain dots, so "subscriptions" can't be one.
// //
// Every path segment is percent-encoded, so names may hold any character. // Every path segment is percent-encoded, so names may hold any character.
@@ -53,19 +49,10 @@ type Ref struct {
Type provider.ObjectType Type provider.ObjectType
Name string // object name; empty for a container link Name string // object name; empty for a container link
Version string Version string
// Share is a share link's sealed files, as in the link; the other
// fields are empty. Package share reads it.
Share string
} }
// IsShare reports whether r is a share link.
func (r Ref) IsShare() bool { return r.Share != "" }
// IsSource reports whether r points at a subscription or workspace. // IsSource reports whether r points at a subscription or workspace.
func (r Ref) IsSource() bool { func (r Ref) IsSource() bool {
if r.IsShare() {
return false
}
if r.Kind != provider.KindAzure { if r.Kind != provider.KindAzure {
return r.Scope == "" return r.Scope == ""
} }
@@ -73,7 +60,7 @@ func (r Ref) IsSource() bool {
} }
// IsContainer reports whether r points at a vault or scope. // IsContainer reports whether r points at a vault or scope.
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() && !r.IsShare() } func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() }
// ContainerName is the vault name (from its host) or the scope. // ContainerName is the vault name (from its host) or the scope.
func (r Ref) ContainerName() string { func (r Ref) ContainerName() string {
@@ -124,9 +111,6 @@ func (r Ref) IdentifierURL() string {
// String is the lazysecrets:// link. // String is the lazysecrets:// link.
func (r Ref) String() string { func (r Ref) String() string {
if r.IsShare() {
return Scheme + "://share/" + r.Share
}
if r.Kind == provider.KindAzure && r.Subscription != "" { if r.Kind == provider.KindAzure && r.Subscription != "" {
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription) return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
} }
@@ -224,8 +208,6 @@ var (
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`) portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL> // …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`) portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
// a share link's sealed files: unpadded base64url
sealed = regexp.MustCompile(`^[A-Za-z0-9_-]{16,}$`)
) )
// Parse reads any supported reference. // Parse reads any supported reference.
@@ -256,12 +238,6 @@ func parseLink(s string) (Ref, error) {
if err != nil { if err != nil {
return Ref{}, err return Ref{}, err
} }
if strings.EqualFold(u.Host, "share") {
if len(segs) != 1 || !sealed.MatchString(segs[0]) {
return Ref{}, errors.New("share link is cut short or garbled: copy all of it")
}
return Ref{Share: segs[0]}, nil
}
var r Ref var r Ref
if len(segs) == 0 || segs[0] == "" { if len(segs) == 0 || segs[0] == "" {
return Ref{}, errors.New("link has no vault or workspace") return Ref{}, errors.New("link has no vault or workspace")
@@ -310,7 +286,7 @@ func parseLink(s string) (Ref, error) {
} }
default: default:
return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host) return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host)
} }
return r, nil return r, nil
} }
-69
View File
@@ -1,69 +0,0 @@
package share
import (
"fmt"
"strings"
"unicode"
)
// Rules are what a key's value must be to share with it: random enough
// that guessing it is out of reach, since anyone with a link can try.
type Rules struct {
MinLength int // characters
MinClasses int // kinds of character: lowercase, uppercase, digits, symbols
}
// DefaultRules take a random base64 or hex value (openssl rand -base64
// 32, or -hex 32) and refuse passwords made up to be remembered.
var DefaultRules = Rules{MinLength: 32, MinClasses: 2}
// Check reports what key lacks, without showing it.
func (r Rules) Check(key []byte) error {
r = r.withDefaults()
s := string(key)
var lower, upper, digit, symbol bool
for _, c := range s {
switch {
case unicode.IsLower(c):
lower = true
case unicode.IsUpper(c):
upper = true
case unicode.IsDigit(c):
digit = true
default:
symbol = true
}
}
kinds := 0
for _, has := range []bool{lower, upper, digit, symbol} {
if has {
kinds++
}
}
n := len([]rune(s))
var has, needs []string
if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n))
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
}
if kinds < r.MinClasses {
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
}
if len(has) == 0 {
return nil
}
return fmt.Errorf("its value has %s; sharing.keyRules needs %s",
strings.Join(has, " and "), strings.Join(needs, " and "))
}
func (r Rules) withDefaults() Rules {
if r.MinLength == 0 {
r.MinLength = DefaultRules.MinLength
}
if r.MinClasses == 0 {
r.MinClasses = DefaultRules.MinClasses
}
r.MinLength = max(r.MinLength, MinKey)
return r
}
-308
View File
@@ -1,308 +0,0 @@
// Package share seals env files into a lazysecrets://share link, so they
// can be passed on in chat or a ticket. They're encrypted with the value of
// a secret (in Key Vault, Databricks or a repo's SOPS file): whoever can
// read that secret can open the link, nobody else.
//
// A link says in the clear which secret it's encrypted with, the repo the
// files are from and how many there are, which is what's needed to open
// it. The files' paths and contents are encrypted, with age
// (https://age-encryption.org), the secret's value as its passphrase.
//
// The link's payload is unpadded base64url of:
//
// version (1) | uvarint length of the header | header (JSON) | age file
//
// The age file holds the files as deflated JSON.
//
// A change to the format gets the next version, and Read keeps reading
// every earlier one: links stay in chat and tickets for years. A
// version it doesn't know is from a newer lazysecrets.
package share
import (
"bytes"
"compress/flate"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"strings"
"unicode/utf8"
"filippo.io/age"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
const (
// version is the format's, written first; see the package doc.
version = 1
// MaxLink keeps a link short enough for chat and tickets (Teams takes
// about 28 KB) and for passing it to a terminal window.
MaxLink = 16 << 10
// MinKey is the shortest key the rules can ask for.
MinKey = 16
// maxUnpacked caps the bytes opening a link unpacks.
maxUnpacked = 1 << 20
)
// workFactor is the scrypt work factor for sealing: 2^15, about 40 ms
// each way, instead of age's 2^18 (a third of a second). scrypt makes
// guessing a weak passphrase slow; a key keeping to the rules is random
// and long enough not to need that. maxWorkFactor is the most opening
// accepts, so a link can't make it work for minutes.
var workFactor, maxWorkFactor = 15, 20
// File is a shared file.
type File struct {
Path string // relative to the repo's root, with slashes
Data []byte
}
// Header is what a link says in the clear.
type Header struct {
// Key is the secret whose value the files are encrypted with.
Key ref.Ref
// Repo is the repo the files are from: its remote without scheme or
// .git (a repo link's host).
Repo string
// Files is how many files the link holds.
Files int
}
// Sealed is a share link read, its files still encrypted.
type Sealed struct {
Header
box []byte
}
type header struct {
Key string `json:"key"`
Repo string `json:"repo,omitempty"`
Files int `json:"files"`
}
type file struct {
Path string `json:"path"`
Data string `json:"data"`
}
// Seal encrypts files with key, the value of h.Key, into a share link,
// if key keeps to rules (*KeyError if not).
func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) {
if _, err := ParseKey(h.Key.String()); err != nil {
return ref.Ref{}, fmt.Errorf("the key: %w", err)
}
if err := rules.Check(key); err != nil {
return ref.Ref{}, &KeyError{Name: h.Key.Name, Err: err}
}
if len(files) == 0 {
return ref.Ref{}, errors.New("no files to share")
}
plain := make([]file, len(files))
for i, f := range files {
if err := CheckPath(f.Path); err != nil {
return ref.Ref{}, err
}
if !utf8.Valid(f.Data) {
return ref.Ref{}, fmt.Errorf("%s isn't text", f.Path)
}
plain[i] = file{Path: f.Path, Data: string(f.Data)}
}
b, err := json.Marshal(plain)
if err != nil {
return ref.Ref{}, err
}
rcpt, err := age.NewScryptRecipient(string(key))
if err != nil {
return ref.Ref{}, err
}
rcpt.SetWorkFactor(workFactor)
var box bytes.Buffer
enc, err := age.Encrypt(&box, rcpt)
if err != nil {
return ref.Ref{}, err
}
z, _ := flate.NewWriter(enc, flate.BestCompression)
if _, err := z.Write(b); err != nil {
return ref.Ref{}, err
}
if err := z.Close(); err != nil {
return ref.Ref{}, err
}
if err := enc.Close(); err != nil {
return ref.Ref{}, err
}
hb, err := json.Marshal(header{Key: h.Key.String(), Repo: h.Repo, Files: len(files)})
if err != nil {
return ref.Ref{}, err
}
payload := binary.AppendUvarint([]byte{version}, uint64(len(hb)))
payload = append(append(payload, hb...), box.Bytes()...)
r := ref.Ref{Share: base64.RawURLEncoding.EncodeToString(payload)}
if n := len(r.String()); n > MaxLink {
return ref.Ref{}, fmt.Errorf("the link would be %d KB, more than the %d KB it can be: share fewer files", n>>10, MaxLink>>10)
}
return r, nil
}
// KeyError is a key that can't be used: it can't be read, or doesn't
// keep to the rules.
type KeyError struct {
Name string // the secret's
Err error
}
func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() }
func (e *KeyError) Unwrap() error { return e.Err }
// Read reads a share link's header.
func Read(r ref.Ref) (Sealed, error) {
bad := errors.New("share link is cut short or garbled: copy all of it")
b, err := base64.RawURLEncoding.DecodeString(r.Share)
if err != nil || len(b) < 2 {
return Sealed{}, bad
}
if b[0] != version {
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
}
n, size := binary.Uvarint(b[1:])
if size <= 0 || n > uint64(len(b)) {
return Sealed{}, bad
}
rest := b[1+size:]
if n > uint64(len(rest)) {
return Sealed{}, bad
}
var h header
if err := json.Unmarshal(rest[:n], &h); err != nil {
return Sealed{}, bad
}
key, err := ParseKey(h.Key)
if err != nil {
return Sealed{}, fmt.Errorf("share link's key: %w", err)
}
return Sealed{Header: Header{Key: key, Repo: h.Repo, Files: h.Files}, box: rest[n:]}, nil
}
// Open decrypts the files with key, the value of s.Key.
func (s Sealed) Open(key []byte) ([]File, error) {
id, err := age.NewScryptIdentity(string(key))
if err != nil {
return nil, err
}
id.SetMaxWorkFactor(maxWorkFactor)
dec, err := age.Decrypt(bytes.NewReader(s.box), id)
if err != nil {
var wrong *age.NoIdentityMatchError
if errors.As(err, &wrong) {
return nil, s.wrongKey()
}
return nil, fmt.Errorf("can't decrypt the link: %w", err)
}
b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxUnpacked+1))
if err != nil {
return nil, fmt.Errorf("can't decrypt the link: %w", err)
}
if len(b) > maxUnpacked {
return nil, errors.New("the link holds more than lazysecrets unpacks")
}
var plain []file
if err := json.Unmarshal(b, &plain); err != nil {
return nil, fmt.Errorf("can't read the link's files: %w", err)
}
out := make([]File, len(plain))
for i, f := range plain {
if err := CheckPath(f.Path); err != nil {
return nil, err
}
out[i] = File{Path: f.Path, Data: []byte(f.Data)}
}
return out, nil
}
// wrongKey says why the key, read fine, doesn't decrypt the link: its
// value isn't the one the link was made with.
func (s Sealed) wrongKey() error {
name := s.Key.Name
switch {
case s.Key.Kind == provider.KindAzure && s.Key.Version != "":
// A version's value never changes.
return fmt.Errorf("%s at version %s doesn't decrypt it: the link may be damaged", name, s.Key.Version)
case s.Key.Kind == provider.KindAzure:
return fmt.Errorf("%s's current version isn't the one the link was made with, so it can't decrypt it: ask for a new link", name)
}
// Databricks secrets and SOPS values are changed in place.
return fmt.Errorf("%s has changed since the link was made, so it can't decrypt it: ask for a new link", name)
}
// CheckPath accepts a path relative to a repo's root, with slashes, of an
// env file (.env, .env.local, prod.env) outside .git: what a link may
// write.
func CheckPath(p string) error {
switch {
case p == "" || path.IsAbs(p) || strings.ContainsAny(p, "\\\x00") || path.Clean(p) != p ||
p == ".." || strings.HasPrefix(p, "../"):
return fmt.Errorf("%q isn't a path inside a repo", p)
case p == ".git" || strings.HasPrefix(p, ".git/") || strings.Contains(p, "/.git/"):
return fmt.Errorf("%q is inside .git", p)
case !repo.IsEnv(p):
return fmt.Errorf("%q isn't an env file: only those can be shared", p)
}
return nil
}
// DefaultKey is the key for a repo's files: sharing.key in the repo's
// .lazysecrets.yaml, else general (sharing.key in the config). ok is false
// when neither names one.
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
link, err := repo.ShareKey(root)
if err != nil {
return ref.Ref{}, false, err
}
where := root + "/.lazysecrets.yaml"
if link == "" {
link, where = general, "the config"
}
if link == "" {
return ref.Ref{}, false, nil
}
key, err = ParseKey(link)
if err != nil {
return ref.Ref{}, false, fmt.Errorf("sharing.key in %s: %w", where, err)
}
return key, true, nil
}
// ParseKey reads which secret is the key: its lazysecrets:// link, Key
// Vault URL or @Microsoft.KeyVault(...) reference.
func ParseKey(s string) (ref.Ref, error) {
key, err := ref.Parse(s)
if err != nil {
return ref.Ref{}, err
}
switch {
case key.IsShare() || key.IsSource() || key.IsContainer() || (key.Type != "" && key.Type != provider.TypeSecret):
return ref.Ref{}, fmt.Errorf("%s isn't a secret", s)
case key.Kind == provider.KindDatabricks && key.Host == "":
return ref.Ref{}, fmt.Errorf("%s names no workspace: use its lazysecrets:// link", s)
}
return key, nil
}
// Pin is key at version, the version its value was read at, for Azure,
// whose old versions stay readable: a link keeps working when the key
// is rotated, until that version is disabled.
func Pin(key ref.Ref, version string) ref.Ref {
if key.Kind == provider.KindAzure && key.Version == "" {
key.Version = version
}
return key
}
-258
View File
@@ -1,258 +0,0 @@
package share
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
func init() { workFactor = 10 } // fast; opening accepts any up to maxWorkFactor
var (
key = ref.Ref{Kind: provider.KindAzure, Host: "kv-team.vault.azure.net", Type: provider.TypeSecret, Name: "env-share", Version: "91d3e0"}
pass = []byte("0123456789abcdef0123456789abcdef")
)
func TestSealOpen(t *testing.T) {
files := []File{
{Path: ".env", Data: []byte("DB_PASSWORD=hunter2\n")},
{Path: "apps/api/.env.local", Data: []byte("TOKEN=\"a b\" # quoted\n")},
}
link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files, Rules{})
if err != nil {
t.Fatal(err)
}
if strings.Contains(link.String(), "hunter2") || strings.Contains(link.String(), "apps") {
t.Errorf("link shows what it shares: %s", link)
}
// It goes through ref.Parse like any link.
r, err := ref.Parse(link.String())
if err != nil || !r.IsShare() {
t.Fatalf("parse: %v, %+v", err, r)
}
s, err := Read(r)
if err != nil {
t.Fatal(err)
}
if s.Key != key || s.Repo != "git.example.com/team/app" || s.Files != 2 {
t.Errorf("header %+v", s.Header)
}
got, err := s.Open(pass)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) {
t.Errorf("opened %+v", got)
}
if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "env-share at version 91d3e0 doesn't decrypt it") {
t.Errorf("wrong key: %v", err)
}
}
func TestWrongKeySaysWhy(t *testing.T) {
files := []File{{Path: ".env", Data: []byte("A=1\n")}}
for link, want := range map[string]string{
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share": "env-share has changed since the link was made",
"lazysecrets://repo/git.example.com%2Fteam%2Fapp/secrets.yaml/share": "share has changed since the link was made",
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share": "env-share's current version isn't the one the link was made with",
} {
k, err := ParseKey(link)
if err != nil {
t.Fatal(err)
}
r, err := Seal(Header{Key: k}, pass, files, Rules{})
if err != nil {
t.Fatal(err)
}
s, _ := Read(r)
if _, err := s.Open([]byte("rotated since, and long enough to be a key")); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", link, err, want)
}
}
}
func TestSealRefuses(t *testing.T) {
ok := []File{{Path: ".env", Data: []byte("A=1")}}
for name, c := range map[string]struct {
h Header
pass []byte
files []File
want string
}{
"short key": {Header{Key: key}, []byte("short"), ok, "needs 32 (minLength)"},
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"},
"no files": {Header{Key: key}, pass, nil, "no files"},
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
"binary": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte{0xff, 0xfe}}}, "isn't text"},
"too big": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte(random(2 * MaxLink))}}, "more than the 16 KB"},
} {
if _, err := Seal(c.h, c.pass, c.files, Rules{}); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want %q", name, err, c.want)
}
}
}
// random is text that doesn't compress.
func random(n int) string {
var b strings.Builder
x := uint32(1)
for b.Len() < n {
x ^= x << 13
x ^= x >> 17
x ^= x << 5
b.WriteByte("abcdefghijklmnopqrstuvwxyz0123456789"[x%36])
}
return b.String()
}
func TestRules(t *testing.T) {
for _, c := range []struct {
r Rules
key string
pass bool
}{
{Rules{}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", true}, // base64-like, 32
{Rules{}, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", true}, // hex
{Rules{}, "correct-horse-battery-staple", false}, // 28: short
{Rules{}, strings.Repeat("a", 40), false}, // one kind
{Rules{MinLength: 16, MinClasses: 1}, strings.Repeat("a", 16), true},
{Rules{MinLength: 4}, "abc123", false}, // never under MinKey
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", false},
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5U+", true},
} {
if err := c.r.Check([]byte(c.key)); (err == nil) != c.pass {
t.Errorf("%+v %q: %v", c.r, c.key, err)
} else if err != nil && strings.Contains(err.Error(), c.key) {
t.Errorf("the error shows the key: %v", err)
}
}
}
func TestReadGarbled(t *testing.T) {
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}}, Rules{})
if err != nil {
t.Fatal(err)
}
cut := ref.Ref{Share: link.Share[:len(link.Share)/2]}
s, err := Read(cut)
if err == nil {
_, err = s.Open(pass)
}
if err == nil {
t.Error("a link cut in half opened")
}
if _, err := Read(ref.Ref{Share: "AgAAAAAAAAAAAAAAAAAAAA"}); err == nil || !strings.Contains(err.Error(), "newer lazysecrets") {
t.Errorf("version 2: %v", err)
}
}
func TestCheckPath(t *testing.T) {
for _, p := range []string{".env", "apps/api/.env.local", "prod.env"} {
if err := CheckPath(p); err != nil {
t.Errorf("%s: %v", p, err)
}
}
for _, p := range []string{"", "/etc/.env", "../.env", "a/../.env", ".git/.env", "a/.git/x.env", `a\.env`, ".env.example", "node_modules/x/.env", "Makefile"} {
if err := CheckPath(p); err == nil {
t.Errorf("%q accepted", p)
}
}
}
func TestPlanWrite(t *testing.T) {
dir := t.TempDir()
if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil {
t.Skip("git:", string(out))
}
os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".env\n"), 0o644)
os.WriteFile(filepath.Join(dir, ".env"), []byte("OLD=1\n"), 0o644)
targets, err := Plan(dir, []File{
{Path: ".env", Data: []byte("NEW=1\n")},
{Path: "apps/api/.env.local", Data: []byte("B=2\n")},
})
if err != nil {
t.Fatal(err)
}
if !targets[0].Exists || targets[0].Exposed {
t.Errorf(".env: %+v", targets[0])
}
if targets[1].Exists || !targets[1].Exposed {
t.Errorf("apps/api/.env.local: %+v", targets[1])
}
for _, tg := range targets {
if err := tg.Write(); err != nil {
t.Fatal(err)
}
}
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "NEW=1\n" {
t.Errorf(".env is %q", b)
}
info, err := os.Stat(filepath.Join(dir, "apps", "api", ".env.local"))
if err != nil || info.Mode().Perm() != 0o600 {
t.Errorf("new file: %v, %v", info.Mode(), err)
}
}
func TestPlanSymlinks(t *testing.T) {
dir, outside := t.TempDir(), t.TempDir()
if err := os.Symlink(outside, filepath.Join(dir, "apps")); err != nil {
t.Skip(err)
}
if _, err := Plan(dir, []File{{Path: "apps/.env"}}); err == nil || !strings.Contains(err.Error(), "out of the folder") {
t.Errorf("through a linked folder: %v", err)
}
os.Symlink(filepath.Join(outside, "x"), filepath.Join(dir, ".env"))
if _, err := Plan(dir, []File{{Path: ".env"}}); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Errorf("onto a symlink: %v", err)
}
}
func TestParseKey(t *testing.T) {
for _, s := range []string{
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share",
"https://kv-team.vault.azure.net/secrets/env-share",
"@Microsoft.KeyVault(VaultName=kv-team;SecretName=env-share)",
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share",
} {
if k, err := ParseKey(s); err != nil || k.Name != "env-share" {
t.Errorf("%s: %+v, %v", s, k, err)
}
}
for s, want := range map[string]string{
"{{secrets/team/env-share}}": "names no workspace",
"https://kv-team.vault.azure.net/keys/sops": "isn't a secret",
"https://kv-team.vault.azure.net/": "isn't a secret",
"lazysecrets://azure/subscriptions/0000-1111": "isn't a secret",
} {
if _, err := ParseKey(s); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", s, err, want)
}
}
}
func TestDefaultKey(t *testing.T) {
dir := t.TempDir()
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
k, ok, err := DefaultKey(dir, general)
if err != nil || !ok || k.Name != "env-share" {
t.Errorf("general: %+v %v %v", k, ok, err)
}
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("sharing:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
t.Errorf("the repo's should win: %+v", k)
}
if _, ok, _ := DefaultKey(t.TempDir(), ""); ok {
t.Error("a key with none set")
}
unpinned := key
unpinned.Version = ""
if p := Pin(unpinned, "abc"); p.Version != "abc" {
t.Errorf("pin: %+v", p)
}
}
-87
View File
@@ -1,87 +0,0 @@
package share
import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Target is where an opened file goes.
type Target struct {
File
Abs string
Exists bool
// Exposed is set when dir is a git repo that doesn't ignore the file:
// its values could end up in a commit.
Exposed bool
}
// Plan says where files go under dir, without writing anything. A path
// that leads out of dir through a symlink, or is one, is an error.
func Plan(dir string, files []File) ([]Target, error) {
root, err := filepath.EvalSymlinks(dir)
if err != nil {
return nil, err
}
out := make([]Target, len(files))
for i, f := range files {
if err := CheckPath(f.Path); err != nil {
return nil, err
}
t := Target{File: f, Abs: filepath.Join(root, filepath.FromSlash(f.Path))}
if err := inside(root, filepath.Dir(t.Abs)); err != nil {
return nil, fmt.Errorf("%s: %w", f.Path, err)
}
switch info, err := os.Lstat(t.Abs); {
case err == nil && info.Mode()&fs.ModeSymlink != 0:
return nil, fmt.Errorf("%s is a symlink: not writing through it", f.Path)
case err == nil && !info.Mode().IsRegular():
return nil, fmt.Errorf("%s isn't a file", f.Path)
case err == nil:
t.Exists = true
case !errors.Is(err, fs.ErrNotExist):
return nil, err
}
t.Exposed = exposed(root, f.Path)
out[i] = t
}
return out, nil
}
// inside checks that dir, or the part of it that exists, is under root
// once symlinks are followed.
func inside(root, dir string) error {
for {
real, err := filepath.EvalSymlinks(dir)
if errors.Is(err, fs.ErrNotExist) && dir != root {
dir = filepath.Dir(dir) // created when writing
continue
}
if err != nil {
return err
}
if real != root && !strings.HasPrefix(real, root+string(filepath.Separator)) {
return errors.New("leads out of the folder through a symlink")
}
return nil
}
}
// exposed reports whether root is in a git repo that doesn't ignore p.
func exposed(root, p string) bool {
err := exec.Command("git", "-C", root, "check-ignore", "-q", "--", p).Run()
var exit *exec.ExitError
return errors.As(err, &exit) && exit.ExitCode() == 1 // 0: ignored; 128: no repo
}
// Write writes the file, readable by the user only when it's new.
func (t Target) Write() error {
if err := os.MkdirAll(filepath.Dir(t.Abs), 0o755); err != nil {
return err
}
return os.WriteFile(t.Abs, t.Data, 0o600)
}
+6 -13
View File
@@ -145,23 +145,16 @@ type Store struct {
data file data file
} }
// ErrNewer is a state file written by a newer lazysecrets.
var ErrNewer = errors.New("written by a newer lazysecrets")
// Load reads path; a missing file is an empty store. path "" gives a store // Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo. A file from a newer lazysecrets // that never saves, for tests and --demo.
// is read but never saved, so nothing it keeps is lost: Load returns the
// store with an ErrNewer error.
func Load(path string) (*Store, error) { func Load(path string) (*Store, error) {
s := &Store{path: path} s := &Store{path: path}
d, err := read(path) d, err := read(path)
if errors.Is(err, ErrNewer) { if err != nil {
s.path = ""
} else if err != nil {
return nil, err return nil, err
} }
s.data = d s.data = d
return s, err return s, nil
} }
func read(path string) (file, error) { func read(path string) (file, error) {
@@ -179,12 +172,12 @@ func read(path string) (file, error) {
if err := json.Unmarshal(b, &d); err != nil { if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err) return d, fmt.Errorf("%s: %w", path, err)
} }
if d.Version > version {
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
}
if d.Nodes == nil { if d.Nodes == nil {
d.Nodes = map[string]Node{} d.Nodes = map[string]Node{}
} }
if d.Version > version {
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
}
return d, nil return d, nil
} }
+4 -11
View File
@@ -1,7 +1,6 @@
package store package store
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -66,17 +65,11 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
} }
} }
func TestNewerVersionReadOnly(t *testing.T) { func TestNewerVersionRefused(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json") path := filepath.Join(t.TempDir(), "state.json")
newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}` _ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
_ = os.WriteFile(path, []byte(newer), 0o600) if _, err := Load(path); err == nil {
s, err := Load(path) t.Error("a newer file was accepted")
if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" {
t.Fatalf("Load = %v, %v", s, err)
}
_ = s.SetFlag("x", true)
if b, _ := os.ReadFile(path); string(b) != newer {
t.Errorf("the newer file was overwritten: %s", b)
} }
} }
+16 -66
View File
@@ -24,7 +24,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
@@ -65,8 +64,6 @@ const (
type Options struct { type Options struct {
// Timeout bounds every provider call. Defaults to 30s. // Timeout bounds every provider call. Defaults to 30s.
Timeout time.Duration Timeout time.Duration
// Timeouts override Timeout for the providers of a kind.
Timeouts map[provider.Kind]time.Duration
// Icons shows Nerd Font glyphs next to backends. // Icons shows Nerd Font glyphs next to backends.
Icons bool Icons bool
// Clipboard defaults to the system clipboard, or OSC52 over SSH. // Clipboard defaults to the system clipboard, or OSC52 over SSH.
@@ -79,19 +76,11 @@ type Options struct {
// and keeps its state, for the status pane. // and keeps its state, for the status pane.
ConfigFiles []string ConfigFiles []string
StatePath string StatePath string
// Warning shows when the window opens: what lazysecrets skipped
// instead of failing to start.
Warning string
// Changelog is CHANGELOG.md, for the status pane. // Changelog is CHANGELOG.md, for the status pane.
Changelog string Changelog string
// CacheFile keeps what was loaded between runs; "" keeps it for this // CacheFile keeps what was loaded between runs; "" keeps it for this
// run only. // run only.
CacheFile string CacheFile string
// ShareKey is the config's sharing.key: the secret that encrypts shared
// env files, for repos whose .lazysecrets.yaml names none.
ShareKey string
// ShareRules are what a key's value must be to share with it.
ShareRules share.Rules
// Dir is the folder lazysecrets runs in: it goes back to where it was // Dir is the folder lazysecrets runs in: it goes back to where it was
// left there, unless it was started on a link. "" doesn't. // left there, unless it was started on a link. "" doesn't.
Dir string Dir string
@@ -122,11 +111,9 @@ type Options struct {
// Keys, if set, replaces the default keymap (keys.Map.Apply). // Keys, if set, replaces the default keymap (keys.Map.Apply).
Keys *keys.Map Keys *keys.Map
// Updater, if set, finds and installs newer releases (U); with // Updater, if set, finds and installs newer releases (U); with
// UpdateCheck, lazysecrets also looks on its own at startup, at most // UpdateCheck, lazysecrets also looks once a day on its own.
// once per UpdateEvery (0 for a day).
Updater *Updater Updater *Updater
UpdateCheck bool UpdateCheck bool
UpdateEvery time.Duration
// HandlerSetup, if set, offers to set up the link handler at startup. // HandlerSetup, if set, offers to set up the link handler at startup.
HandlerSetup *HandlerSetup HandlerSetup *HandlerSetup
// NoHistory stops counting uses of items for the Recent list. // NoHistory stops counting uses of items for the Recent list.
@@ -151,7 +138,7 @@ type Clipboard interface {
type Model struct { type Model struct {
providers []provider.Provider providers []provider.Provider
byID map[string]provider.Provider byID map[string]provider.Provider
timeout timeouts timeout time.Duration
keys keys.Map keys keys.Map
st styles.Styles st styles.Styles
@@ -243,7 +230,6 @@ type Model struct {
updater *Updater updater *Updater
updateCheck bool updateCheck bool
updateEvery time.Duration
fetchOnStart bool // look for a newer release in Init fetchOnStart bool // look for a newer release in Init
newer *update.Release // a newer release to offer newer *update.Release // a newer release to offer
updated string // the release installed, waiting for a restart updated string // the release installed, waiting for a restart
@@ -284,9 +270,6 @@ type Model struct {
linked []provider.Container // vaults opened from links, this session linked []provider.Container // vaults opened from links, this session
open *ref.Ref // reference to open at startup open *ref.Ref // reference to open at startup
shareKey string // the config's sharing.key
shareRules share.Rules // and sharing.keyRules
// A lazysecrets:// link found on the clipboard, offered in the footer. // A lazysecrets:// link found on the clipboard, offered in the footer.
dismissedLink string // the last link offered: not offered again dismissedLink string // the last link offered: not offered again
clipChecked time.Time clipChecked time.Time
@@ -383,7 +366,7 @@ func New(providers []provider.Provider, opts Options) Model {
m := Model{ m := Model{
providers: providers, providers: providers,
byID: map[string]provider.Provider{}, byID: map[string]provider.Provider{},
timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts}, timeout: opts.Timeout,
keys: keys.Default(), keys: keys.Default(),
st: styles.Default(), st: styles.Default(),
spin: spinner.New(spinner.WithSpinner(spinner.Dot)), spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
@@ -435,10 +418,7 @@ func New(providers []provider.Provider, opts Options) Model {
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
m.expiryWarning = opts.ExpiryWarning m.expiryWarning = opts.ExpiryWarning
m.handlerSetup = opts.HandlerSetup m.handlerSetup = opts.HandlerSetup
m.updater, m.updateCheck, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck
if m.updateEvery == 0 {
m.updateEvery = updateEvery
}
m.startUpdateCheck() m.startUpdateCheck()
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt) m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
m.settle = opts.LoadDelay m.settle = opts.LoadDelay
@@ -475,11 +455,7 @@ func New(providers []provider.Provider, opts Options) Model {
m.spinning = true m.spinning = true
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if opts.Warning != "" {
m.flash = warning(opts.Warning)
}
if m.cachePath != "" { if m.cachePath != "" {
m.loadCacheFile() m.loadCacheFile()
if len(m.sourceResults) > 0 { if len(m.sourceResults) > 0 {
@@ -594,15 +570,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case resolvedMsg: case resolvedMsg:
m.handleResolved(msg) m.handleResolved(msg)
case shareFilesMsg:
cmds = append(cmds, m.handleShareFiles(msg))
case sealedMsg:
cmds = append(cmds, m.handleSealed(msg))
case shareOpenedMsg:
cmds = append(cmds, m.handleShareOpened(msg))
case tea.PasteMsg: case tea.PasteMsg:
cmds = append(cmds, m.handlePaste(msg)) cmds = append(cmds, m.handlePaste(msg))
@@ -693,14 +660,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.bound != nil { if msg.bound != nil {
msg.key = m.rebind(*msg.bound) msg.key = m.rebind(*msg.bound)
} }
before := m.cache.objects[msg.key].value
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
m.cacheDirty = true m.cacheDirty = true
if m.versionsOf != nil { if m.versionsOf != nil {
m.setVersions(msg.items) m.setVersions(msg.items)
} else { } else {
m.secrets.SetItems(m.starredFirst(msg.items)) m.secrets.SetItems(m.starredFirst(msg.items))
cmds = append(cmds, m.refreshShown(before))
} }
} }
@@ -1254,7 +1219,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
} }
m.secrets.SetLoading() m.secrets.SetLoading()
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotObjects, o.Container.Source.Kind) ctx, req := m.begin(slotObjects)
return func() tea.Msg { return func() tea.Msg {
items, err := p.Versions(ctx, o) items, err := p.Versions(ctx, o)
for i := range items { for i := range items {
@@ -1284,7 +1249,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
} }
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotVersions, o.Container.Source.Kind) ctx, req := m.begin(slotVersions)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
items, err := p.Versions(ctx, o) items, err := p.Versions(ctx, o)
for i := range items { for i := range items {
@@ -1435,26 +1400,11 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
return m.crawlStart() return m.crawlStart()
} }
// timeouts bound each provider request: its kind's, else the default. // begin starts a cancellable request for pane p, cancelling any in flight.
type timeouts struct { func (m *Model) begin(p slot) (context.Context, int) {
def time.Duration
byKind map[provider.Kind]time.Duration
}
// of is how long a request to a provider of kind k may take.
func (t timeouts) of(k provider.Kind) time.Duration {
if d, ok := t.byKind[k]; ok {
return d
}
return t.def
}
// begin starts a cancellable request for pane p to a provider of kind k,
// cancelling any in flight.
func (m *Model) begin(p slot, k provider.Kind) (context.Context, int) {
m.cancelReq(p) m.cancelReq(p)
m.seq++ m.seq++
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k)) ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
m.reqID[p], m.cancel[p] = m.seq, cancel m.reqID[p], m.cancel[p] = m.seq, cancel
return ctx, m.seq return ctx, m.seq
} }
@@ -1508,7 +1458,7 @@ func (m Model) sourcesCmd() tea.Cmd {
gen := m.sourcesGen gen := m.sourcesGen
var cmds []tea.Cmd var cmds []tea.Cmd
for _, p := range m.providers { for _, p := range m.providers {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind())) ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
defer cancel() defer cancel()
s, err := p.Sources(ctx) s, err := p.Sources(ctx)
@@ -1519,7 +1469,7 @@ func (m Model) sourcesCmd() tea.Cmd {
}) })
// The source it started in, quickly, on its own. // The source it started in, quickly, on its own.
if c, ok := p.(provider.Current); ok { if c, ok := p.(provider.Current); ok {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind())) ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
defer cancel() defer cancel()
src, ok := c.Current(ctx) src, ok := c.Current(ctx)
@@ -1536,7 +1486,7 @@ func (m Model) sourcesCmd() tea.Cmd {
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd { func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
p := m.providerFor(src) p := m.providerFor(src)
ctx, req := m.begin(slotContainers, src.Kind) ctx, req := m.begin(slotContainers)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
items, err := p.Containers(ctx, src) items, err := p.Containers(ctx, src)
// Carry the source as given, with its provider ID, so later // Carry the source as given, with its provider ID, so later
@@ -1555,7 +1505,7 @@ func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd { func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
cands := m.candidates(c.Source) cands := m.candidates(c.Source)
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
ctx, req := m.begin(slotObjects, c.Source.Kind) ctx, req := m.begin(slotObjects)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
var errs []error var errs []error
for _, p := range cands { for _, p := range cands {
@@ -1583,7 +1533,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
func (m *Model) fetchValue(s provider.Object) tea.Cmd { func (m *Model) fetchValue(s provider.Object) tea.Cmd {
p := m.providerFor(s.Container.Source) p := m.providerFor(s.Container.Source)
ctx, req := m.begin(slotValue, s.Container.Source.Kind) ctx, req := m.begin(slotValue)
m.detail.SetLoading() m.detail.SetLoading()
return func() tea.Msg { return func() tea.Msg {
v, err := p.Value(ctx, s) v, err := p.Value(ctx, s)
@@ -1602,7 +1552,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
m.detail.SetDescribing() m.detail.SetDescribing()
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotDescribe, o.Container.Source.Kind) ctx, req := m.begin(slotDescribe)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
obj, err := p.Describe(ctx, o) obj, err := p.Describe(ctx, o)
return describeMsg{req: req, key: key, obj: obj, err: err} return describeMsg{req: req, key: key, obj: obj, err: err}
@@ -1630,7 +1580,7 @@ func (m *Model) copyValue() tea.Cmd {
return m.copyText(o.Name, v.Reveal(), true) return m.copyText(o.Name, v.Reveal(), true)
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotCopy, o.Container.Source.Kind) ctx, req := m.begin(slotCopy)
m.flash = flash{text: "copying " + o.Name + "…"} m.flash = flash{text: "copying " + o.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
v, err := p.Value(ctx, o) v, err := p.Value(ctx, o)
-32
View File
@@ -1,12 +1,8 @@
package app package app
import ( import (
"maps"
"slices"
"time" "time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -52,31 +48,3 @@ func (c cache) rememberCursor(parent, child string) {
c.cursor[parent] = child c.cursor[parent] = child
} }
} }
// refreshShown shows the selected secret's newer metadata when its list
// reloaded with it changed from before (a cached list refreshed in the
// background), and describes it afresh: its cached details came from
// the old listing.
func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
s, ok := m.secrets.Selected()
if !ok || m.secrets.SelectedID() != m.selSecret {
return nil // a new selection is shown as usual
}
i := slices.IndexFunc(before, func(o provider.Object) bool { return o.ID == s.ID })
if i < 0 || sameListing(before[i], s) {
return nil
}
m.detail.Refresh(s)
delete(m.cache.details, detailKey(s))
return m.describe(s)
}
// sameListing reports whether a and b list the same: what a list
// says of an object, not what describing it adds.
func sameListing(a, b provider.Object) bool {
enabled := func(e *bool) bool { return e == nil || *e }
return a.Name == b.Name && a.Version == b.Version && enabled(a.Enabled) == enabled(b.Enabled) &&
a.Updated.Equal(b.Updated) && a.Expires.Equal(b.Expires) && a.ContentType == b.ContentType &&
maps.Equal(a.Tags, b.Tags) && a.Reference == b.Reference && a.URL == b.URL &&
slices.Equal(a.Attrs, b.Attrs)
}
-31
View File
@@ -2,7 +2,6 @@ package app
import ( import (
"context" "context"
"slices"
"testing" "testing"
"time" "time"
@@ -160,33 +159,3 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) {
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before) t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
} }
} }
func TestReloadedListRefreshesShownSecret(t *testing.T) {
t.Parallel()
d, _, clock := newCachingDriver(t)
d.keys("3") // kv-app-prod's secrets, db-password selected
shown, _ := d.m.detail.Secret()
// What an older lazysecrets listed and cached for it.
const old = "https://portal.example/old"
key := d.m.selContainer
e := d.m.cache.objects[key]
items := slices.Clone(e.value)
for i := range items {
if items[i].ID == shown.ID {
items[i].URL = old
}
}
d.m.cache.objects[key] = entry[[]provider.Object]{items, e.at}
stale := shown
stale.URL = old
d.m.cache.details[detailKey(shown)] = entry[provider.Object]{stale, *clock}
// Away and back once the list is stale: it's shown from the cache,
// then reloaded, and the details pane follows.
d.keys("1", "j")
*clock = clock.Add(cacheTTL + time.Second)
d.keys("k", "3")
if got, _ := d.m.detail.Secret(); got.ID != shown.ID || got.URL == old {
t.Errorf("shown %s with %q after the reload", got.Name, got.URL)
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
o := *obj o := *obj
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"} m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout.of(o.Container.Source.Kind) timeout := m.timeout
m.usedObject(o) m.usedObject(o)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
+4 -40
View File
@@ -3,7 +3,6 @@ package app
import ( import (
"cmp" "cmp"
"errors" "errors"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -14,20 +13,18 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/browser" "git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "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 // them for the editor and encrypts them again after. Other files, inside
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or // 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 // $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. // secret, editors that can open at a line start on its key.
type editDoneMsg struct { type editDoneMsg struct {
c provider.Container c provider.Container
name string name string
config bool // the config file: read at startup err error
err error
} }
// sopsUnchanged is sops edit's exit code when the file wasn't changed. // 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 c provider.Container
var o *provider.Object var o *provider.Object
switch m.focus { switch m.focus {
case paneStatus: // like lazygit's status panel
return m.editConfig()
case paneSources: case paneSources:
m.flash = warning("select a file to open it") m.flash = warning("select a file to open it")
return nil return nil
@@ -98,33 +93,6 @@ func (m *Model) edit() tea.Cmd {
return m.openInEditor(f, finished) return m.openInEditor(f, finished)
} }
// configHeader starts a config file made to be edited.
const configHeader = "# lazysecrets config: " + update.DefaultRepo + "/src/branch/main/docs/config.md\n"
// editConfig opens the user's config file (the first of them), as e
// opens a file, making it if it isn't there yet: e on the status pane.
func (m *Model) editConfig() tea.Cmd {
if len(m.configFiles) == 0 {
m.flash = warning("no config file")
return nil
}
path := m.configFiles[0]
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
err := os.MkdirAll(filepath.Dir(path), 0o755)
if err == nil {
err = os.WriteFile(path, []byte(configHeader), 0o644)
}
if err != nil {
m.flash = flash{text: "edit config: " + err.Error(), isErr: true}
return nil
}
}
name := filepath.Base(path)
return m.openInEditor(provider.File{Path: path}, func(err error) tea.Msg {
return editDoneMsg{name: name, config: true, err: err}
})
}
// plusLine are editors that open at a line with +N before the file. // plusLine are editors that open at a line with +N before the file.
var plusLine = map[string]bool{ var plusLine = map[string]bool{
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true, "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} m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
return nil return nil
} }
if msg.config {
m.flash = done("closed " + msg.name + ": restart lazysecrets to use it")
return nil
}
m.flash = done("closed " + msg.name) m.flash = done("closed " + msg.name)
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) { if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
m.hideValue() m.hideValue()
+1 -1
View File
@@ -196,7 +196,7 @@ func (m *Model) loadDetails(c provider.Container) tea.Cmd {
} }
} }
m.details[key] = detailsState{loading: true} m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails, c.Source.Kind) ctx, req := m.begin(slotDetails)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c) attrs, err := d.Details(ctx, c)
if ctx.Err() != nil { if ctx.Err() != nil {
+1 -2
View File
@@ -54,8 +54,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
switch { switch {
case c.focus == paneStatus: case c.focus == paneStatus:
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details")) 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, k.Update, k.Help, k.Quit)
return append(b, details, config, k.Update, k.Help, k.Quit)
case c.focus == paneDetail && c.account && c.of == paneStatus: 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")) 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) return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
+1 -26
View File
@@ -47,7 +47,6 @@ func (m *Model) storeEntries() (favs, recent []option) {
out = append(out, entry{depth, sort, option{ out = append(out, entry{depth, sort, option{
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ", label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct), id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
plain: m.inPlainEnv(r),
run: func(m *Model) tea.Cmd { run: func(m *Model) tea.Cmd {
m.used(key) m.used(key)
return run(m) return run(m)
@@ -139,30 +138,6 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
return label return label
} }
// inPlainEnv reports whether r is a key in a plain env file, as far as
// the files loaded tell.
func (m Model) inPlainEnv(r ref.Ref) bool {
if r.Kind != provider.KindRepo || r.Name == "" {
return false
}
file := r
file.Name, file.Type = "", ""
for _, e := range m.cache.containers {
for _, c := range e.value {
if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
return true
}
}
}
return false
}
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
// icon and colour, "app / apps/api/.env".
func (m Model) repoLabel(repo string, file ...string) string {
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "")
}
// sourceMatches reports whether src is the subscription or workspace r // sourceMatches reports whether src is the subscription or workspace r
// links to. // links to.
func sourceMatches(r ref.Ref, src provider.Source) bool { func sourceMatches(r ref.Ref, src provider.Source) bool {
@@ -217,7 +192,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
case 1: case 1:
m.startNav(r, actGo, choices[0].sourceRow) m.startNav(r, actGo, choices[0].sourceRow)
default: default:
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices, false) m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
} }
return nil return nil
} }
+6 -39
View File
@@ -46,9 +46,6 @@ type nav struct {
// quiet gives up without a word: going back to where lazysecrets // quiet gives up without a word: going back to where lazysecrets
// was left, which may be gone. // was left, which may be gone.
quiet bool quiet bool
// link ends on the object's details, as enter on it would: opening
// a link to it opens it, rather than just finding it in its list.
link bool
} }
type resolvedMsg struct { type resolvedMsg struct {
@@ -81,9 +78,6 @@ const (
// just open it. // just open it.
// With ask false, vault and scope links open straight away. // With ask false, vault and scope links open straight away.
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd { func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
if r.IsShare() {
return m.offerShare(r)
}
if r.IsContainer() || r.IsSource() { if r.IsContainer() || r.IsSource() {
if !ask { if !ask {
return m.openRef(r, actGo) return m.openRef(r, actGo)
@@ -158,7 +152,7 @@ func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true} m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
return nil return nil
} }
ctx, req := m.begin(slotCopy, r.Kind) ctx, req := m.begin(slotCopy)
m.flash = flash{text: "copying " + r.Name + "…"} m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
var errs []error var errs []error
@@ -404,30 +398,23 @@ func (m *Model) handleResolved(msg resolvedMsg) {
for i := range msg.choices { for i := range msg.choices {
msg.choices[i].label = "Open in " + msg.choices[i].label msg.choices[i].label = "Open in " + msg.choices[i].label
} }
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true) m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices)
case len(msg.others) > 0: case len(msg.others) > 0:
var choices []choice var choices []choice
for _, row := range append([]string{msg.sourceRow}, msg.others...) { for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
} }
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true) m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices)
case msg.linked != nil: case msg.linked != nil:
m.addLinked(*msg.linked) m.addLinked(*msg.linked)
m.startLinkNav(msg.ref, msg.act, linksRowID) m.startNav(msg.ref, msg.act, linksRowID)
default: default:
m.startLinkNav(msg.ref, msg.act, msg.sourceRow) m.startNav(msg.ref, msg.act, msg.sourceRow)
} }
} }
// startLinkNav opens a link: like startNav, ending on its object's
// details.
func (m *Model) startLinkNav(r ref.Ref, act linkAction, sourceRow string) {
m.startNav(r, act, sourceRow)
m.nav.link = true
}
// chooseSource asks where to open r. // chooseSource asks where to open r.
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice, link bool) { func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) {
var opts []option var opts []option
for i, c := range choices { for i, c := range choices {
key := "" key := ""
@@ -437,7 +424,6 @@ func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []
row := c.sourceRow row := c.sourceRow
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd { opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
m.startNav(r, act, row) m.startNav(r, act, row)
m.nav.link = link
return nil return nil
}}) }})
} }
@@ -588,9 +574,6 @@ func (m *Model) advanceNav() bool {
m.pendingCmd = m.toggleReveal() m.pendingCmd = m.toggleReveal()
} }
} }
if n.link {
m.focus = paneDetail // after: copying acts on the focused pane
}
return true return true
} }
return false return false
@@ -624,9 +607,6 @@ func (m *Model) share(menu bool) tea.Cmd {
if s.URL != "" { if s.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")}) opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
} }
if m.canShareFiles(s) {
opts = append(opts, option{key: "f", label: "Encrypted link to its env files", run: func(m *Model) tea.Cmd { return m.shareFiles(s, nil) }})
}
m.showMenu("Share "+s.Name, nil, opts) m.showMenu("Share "+s.Name, nil, opts)
case paneContainers: case paneContainers:
c, ok := m.containers.Selected() c, ok := m.containers.Selected()
@@ -649,11 +629,6 @@ func (m *Model) share(menu bool) tea.Cmd {
if c.URL != "" { if c.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")}) opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
} }
if c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
opts = append(opts, option{key: "f", label: "Encrypted link to the file", run: func(m *Model) tea.Cmd {
return m.shareFiles(c.Source, []provider.Container{c})
}})
}
m.showMenu("Share "+c.Name, nil, opts) m.showMenu("Share "+c.Name, nil, opts)
default: default:
o, ok := m.detail.Secret() o, ok := m.detail.Secret()
@@ -678,11 +653,6 @@ func (m *Model) share(menu bool) tea.Cmd {
if o.URL != "" { if o.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")}) opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
} }
if c := o.Container; c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
opts = append(opts, option{key: "f", label: "Encrypted link to its file", run: func(m *Model) tea.Cmd {
return m.shareFiles(c.Source, []provider.Container{c})
}})
}
m.showMenu("Share "+o.Name, nil, opts) m.showMenu("Share "+o.Name, nil, opts)
} }
return nil return nil
@@ -832,9 +802,6 @@ func (m *Model) openText(s string) tea.Cmd {
// handlePaste opens a pasted reference, unless a text input has focus. // handlePaste opens a pasted reference, unless a text input has focus.
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd { func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
if m.search != nil && m.search.picker != nil {
return m.pickPasted(msg.Content) // a reference to the secret
}
if p := m.prompt; p != nil && p.input != nil { if p := m.prompt; p != nil && p.input != nil {
in, cmd := p.input.Update(msg) in, cmd := p.input.Update(msg)
p.input = &in p.input = &in
+1 -2
View File
@@ -40,8 +40,7 @@ func TestOpenSecretLink(t *testing.T) {
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" { if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Fatalf("opened %s/%s/%s", src, c, s) t.Fatalf("opened %s/%s/%s", src, c, s)
} }
// Opened: on its details, as enter on it would; its value hidden. if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden {
if d.m.focus != paneDetail || d.m.detail.State() != panes.ValueHidden {
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State()) t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
} }
} }
+4 -20
View File
@@ -1,11 +1,9 @@
package app package app
import ( import (
"cmp"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"unicode"
"unicode/utf8" "unicode/utf8"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
@@ -42,7 +40,6 @@ type option struct {
group string // the source row it's in (search) group string // the source row it's in (search)
kind provider.Kind // the kind of provider it's from (search) kind provider.Kind // the kind of provider it's from (search)
hits []int // characters of the label the filter matched (Go to) hits []int // characters of the label the filter matched (Go to)
plain bool // a key in a plain env file: no key to encrypt with (Go to)
run func(m *Model) tea.Cmd run func(m *Model) tea.Cmd
// header makes this a section title: not selectable, and hidden // header makes this a section title: not selectable, and hidden
// while a filter narrows the list. // while a filter narrows the list.
@@ -56,13 +53,9 @@ type modal struct {
options []option options []option
cursor int cursor int
// A text input instead of options; submit runs on enter (named // A text input instead of options; submit runs on enter.
// submitLabel in the footer; "open" if empty). err says why the last input *textinput.Model
// one wasn't taken, under the input. submit func(m *Model, text string) tea.Cmd
input *textinput.Model
submit func(m *Model, text string) tea.Cmd
submitLabel string
err string
// A filter over the options: typing narrows them (all holds them // A filter over the options: typing narrows them (all holds them
// all), so moving uses arrows or ctrl+j/k. // all), so moving uses arrows or ctrl+j/k.
@@ -266,10 +259,7 @@ func (m Model) modalView() string {
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss")) foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
if p.input != nil { if p.input != nil {
body = append(body, " "+p.input.View()) body = append(body, " "+p.input.View())
if p.err != "" { foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel"))
body = append(body, "", " "+m.st.Error.Render("✗ "+capitalize(p.err)))
}
foot = footer(hint(km.PopupSelect, cmp.Or(p.submitLabel, "open")), hint(km.PopupClose, "cancel"))
} }
// Size from all options, not the ones shown or the cursor's, so the // Size from all options, not the ones shown or the cursor's, so the
@@ -416,9 +406,3 @@ func (m Model) overlay(base string) string {
lipgloss.NewLayer(box).X(x).Y(y).Z(1), lipgloss.NewLayer(box).X(x).Y(y).Z(1),
).Render() ).Render()
} }
// capitalize upper-cases the first letter of a message.
func capitalize(s string) string {
r, n := utf8.DecodeRuneInString(s)
return string(unicode.ToUpper(r)) + s[n:]
}
-50
View File
@@ -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())
}
}
+3 -6
View File
@@ -61,14 +61,13 @@ func (m *Model) searchEntries() (sources, items []option) {
var es []entry var es []entry
// key is the store key, so ctrl+f stars the entry; mark shows how // key is the store key, so ctrl+f stars the entry; mark shows how
// much is loaded, reload (ctrl+r) loads it again. // much is loaded, reload (ctrl+r) loads it again.
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) *option { add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) {
n := m.store.Node(key) n := m.store.Node(key)
sort := strings.ToLower(strings.Join(path, "/")) + " " + account sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort, es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key, option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)), starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt
} }
for _, p := range m.providers { for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
@@ -99,11 +98,10 @@ func (m *Model) searchEntries() (sources, items []option) {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key" typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·") suffix = strings.Trim(typ+" · "+suffix, " ·")
} }
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd { add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
m.startNav(r, actGo, row) m.startNav(r, actGo, row)
return nil return nil
}, reload) }, reload)
e.plain = c.Detail == provider.DetailPlainEnv
} }
} }
} }
@@ -295,10 +293,10 @@ func (m *Model) crawlNext() tea.Cmd {
} }
func (m *Model) crawlCmd(job crawlJob) tea.Cmd { func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
timeout := m.timeout
if job.src != nil { if job.src != nil {
p := m.providerFor(*job.src) p := m.providerFor(*job.src)
src := *job.src src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
@@ -311,7 +309,6 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
} }
c := *job.container c := *job.container
p := m.providerFor(c.Source) p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
+11 -106
View File
@@ -14,7 +14,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/match" "git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -53,9 +52,6 @@ type searchView struct {
res int // cursor in shown res int // cursor in shown
here string // the scope of the source Go to was opened from, if any here string // the scope of the source Go to was opened from, if any
// picker, if set, makes Go to pick a secret instead of going to it.
picker *picker
// loaded are the scopes a search has loaded since the popup opened: // loaded are the scopes a search has loaded since the popup opened:
// typing loads the scope once, so lists that fail aren't asked for // typing loads the scope once, so lists that fail aren't asked for
// again on every key. // again on every key.
@@ -271,9 +267,6 @@ func (v *searchView) match() []option {
} }
} }
} }
if v.picker != nil {
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
}
q := v.input.Value() q := v.input.Value()
if q == "" { if q == "" {
return slices.Clone(cand) return slices.Clone(cand)
@@ -430,14 +423,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil return nil
case key.Matches(msg, k.sel): case key.Matches(msg, k.sel):
switch { switch {
case v.results && v.res < len(v.shown) && v.picker != nil:
r, _ := pickable(v.shown[v.res])
return v.picker.choose(m, r)
case v.results && v.res < len(v.shown): case v.results && v.res < len(v.shown):
m.search = nil m.search = nil
return v.shown[v.res].run(m) return v.shown[v.res].run(m)
case !v.results && v.picker != nil:
v.results = true // a picker only narrows to the scope
case !v.results && v.onSource(): case !v.results && v.onSource():
m.search = nil m.search = nil
return v.scope().run(m) return v.scope().run(m)
@@ -653,16 +641,7 @@ func (m Model) searchViewBox() string {
v := m.search v := m.search
focused := !m.termBlurred focused := !m.termBlurred
w := searchWidth(m.w) w := searchWidth(m.w)
popupW := w
if v.picker != nil {
w -= 4 // inside the picker's border, in the popup
}
listH := max(5, min(m.h-10, 14)) listH := max(5, min(m.h-10, 14))
if v.picker != nil {
// What it's for, the help, a line or two on the key picked, the
// tabs and a border more.
listH = max(5, min(m.h-10-len(v.picker.lines)-len(v.picker.help)-8, 14))
}
srcW := 10 srcW := 10
for _, o := range v.scopes { for _, o := range v.scopes {
@@ -754,24 +733,7 @@ func (m Model) searchViewBox() string {
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused) lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
} }
ts := panes.TitleStyle(m.st, focused) body := []string{" " + v.input.View(), ""}
var body []string
if p := v.picker; p != nil {
for _, l := range p.help {
body = append(body, " "+l)
}
switch {
case p.busy != "":
body = append(body, "", " "+m.st.Notice.Render(p.busy))
case p.err != "":
body = append(body, "")
for _, l := range strings.Split(ansi.Wordwrap(p.err, w-4, " "), "\n") {
body = append(body, " "+m.st.Error.Render(l))
}
}
body = append(body, "", " "+m.goToTabs(ts))
}
body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") { for _, l := range strings.Split(lists, "\n") {
body = append(body, " "+l) body = append(body, " "+l)
} }
@@ -791,83 +753,26 @@ func (m Model) searchViewBox() string {
if v.tab == tabSearch { if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load")) hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
} }
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, v.selectLabel()), hint(km.PopupClose, "stop typing"))...) foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...)
default: default:
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}} hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
if v.tab == tabSearch { if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all")) hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
} }
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, v.selectLabel()), hint(km.GoClose, "close"))...) foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...)
} }
// Tabs in the title, the current one underlined, like the panes; a // Tabs in the title, the current one underlined, like the panes.
// picker has its own title, and the tabs over the input. ts := panes.TitleStyle(m.st, focused)
if v.picker == nil { head := ts.Render(goToTitle + " ")
head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
}
// A picker: in its own border, under what it's for.
var outer []string
for _, l := range v.picker.lines {
outer = append(outer, " "+l)
}
outer = append(outer, "")
for _, l := range strings.Split(panes.Box(m.st, ts.Render(v.picker.heading), "", body, w, len(body)+2, focused), "\n") {
outer = append(outer, " "+l)
}
return panes.Box(m.st, ts.Render(v.picker.title), foot, outer, popupW, len(outer)+2, focused)
}
// picker is Go to picking a secret for something (the key to share
// with): it lists secrets only, and enter hands the selected one's link
// to pick, which closes Go to when it's done with it.
type picker struct {
pick func(m *Model, r ref.Ref) tea.Cmd
title string // the popup's
lines []string // what picking is for, above the picker
heading string // the picker's title, on its border
help []string // inside it, above the tabs
busy string // what the pick is doing (picks wait meanwhile)
err string // why the secret picked can't be used
}
// choose hands r to the picker, unless it's still busy with one.
func (p *picker) choose(m *Model, r ref.Ref) tea.Cmd {
if p.busy != "" {
return nil
}
return p.pick(m, r)
}
// selectLabel names what enter does with the selected entry.
func (v *searchView) selectLabel() string {
if v.picker != nil {
return "pick"
}
return "go"
}
// pickable is the link of a Go to entry that a picker can pick: a
// secret (not a key or certificate, nor a key in a plain env file).
func pickable(o option) (ref.Ref, bool) {
r, err := ref.Parse(o.star)
if err != nil || o.plain || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
return ref.Ref{}, false
}
return r, true
}
// goToTabs draws Go to's tabs, the current one underlined.
func (m Model) goToTabs(ts lipgloss.Style) string {
var out string
for i, name := range tabNames { for i, name := range tabNames {
if i > 0 { if i > 0 {
out += ts.Render(" - ") head += ts.Render(" - ")
} }
if i == m.search.tab { if i == v.tab {
out += ts.Underline(true).Render(name) head += ts.Underline(true).Render(name)
} else { } else {
out += m.st.Muted.Render(name) head += m.st.Muted.Render(name)
} }
} }
return out return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
} }
-471
View File
@@ -1,471 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"slices"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/share"
)
// Sharing env files as an encrypted lazysecrets://share link (package
// share), and opening one: the files are encrypted with a secret's value,
// so whoever can read that secret can open the link.
// sharing is a share being made: the files read, and the key to use.
type sharing struct {
repo provider.Source
files []share.File
key ref.Ref // zero until chosen
}
type shareFilesMsg struct {
sharing
err error
}
type sealedMsg struct {
link ref.Ref
n int
// from is the picker the key was picked in; nil for sharing.key's.
from *picker
err error
}
// canShareFiles reports whether src's env files can be shared: it's a
// repo whose files can be read.
func (m Model) canShareFiles(src provider.Source) bool {
_, ok := m.providerFor(src).(provider.Contents)
return src.Kind == provider.KindRepo && ok
}
// shareFiles shares env files of a repo: cs, or with cs nil all its
// plain ones.
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
p := m.providerFor(src)
files, ok := p.(provider.Contents)
if !ok {
m.flash = warning("these files can't be shared")
return nil
}
general, timeout := m.shareKey, m.timeout.of(src.Kind)
m.flash = flash{text: "reading the files…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
msg := shareFilesMsg{sharing: sharing{repo: src}}
if cs == nil {
all, err := p.Containers(ctx, src)
if err != nil {
msg.err = err
return msg
}
for _, c := range all {
if c.Detail == provider.DetailPlainEnv {
cs = append(cs, c)
}
}
if len(cs) == 0 {
msg.err = errors.New(src.Name + " has no plain env files")
return msg
}
}
for _, c := range cs {
b, err := files.Contents(ctx, c)
if err != nil {
msg.err = err
return msg
}
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
}
key, ok, err := share.DefaultKey(src.ID, general)
if ok {
msg.key = key
}
msg.err = err
return msg
}
}
func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
switch {
case msg.err != nil:
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
case msg.key == (ref.Ref{}):
return m.pickShareKey(msg.sharing)
default:
m.confirmShare(msg.sharing)
}
return nil
}
// shareTitle names the action: "Share .env", "Share 2 env files".
func shareTitle(s sharing) string {
if len(s.files) == 1 {
return "Share " + path.Base(s.files[0].Path)
}
return "Share " + plural(len(s.files), "env file")
}
// sharedFiles lists the files being shared, under what's happening.
func (m *Model) sharedFiles(s sharing) []string {
lines := []string{"Sharing " + plural(len(s.files), "env file") + " as an encrypted link:"}
for _, f := range s.files {
lines = append(lines, " "+m.repoLabel(s.repo.Name, f.Path))
}
return lines
}
// confirmShare says what the link will hold before making it.
func (m *Model) confirmShare(s sharing) {
lines := append(m.sharedFiles(s), "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
m.showMenu(shareTitle(s), lines, []option{
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s, nil) }},
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }},
})
}
// pickShareKey asks which secret to encrypt with, in Go to, then shares
// with it: the picker showed what's shared, so there's no asking again.
// It stays open until the link is made, to say why a secret can't be
// used.
func (m *Model) pickShareKey(s sharing) tea.Cmd {
cmd := m.openGoTo(tabSearch, true)
p := &picker{
title: shareTitle(s),
lines: m.sharedFiles(s),
heading: "Pick a secret to encrypt with",
help: []string{
m.st.Muted.Render("The recipient needs read access to it to decrypt the files."),
m.st.Muted.Render("Its value isn't shown or put in the link."),
},
}
p.pick = func(m *Model, key ref.Ref) tea.Cmd {
s.key = key
p.err, p.busy = "", "encrypting with "+key.Name+"…"
cmd := m.seal(s, p)
m.flash = flash{}
return cmd
}
m.search.picker = p
m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border
m.fillSearch(false)
return tea.Batch(cmd, m.searchLoads())
}
// pickPasted picks a pasted reference to a secret, while picking one.
func (m *Model) pickPasted(text string) tea.Cmd {
key, err := share.ParseKey(strings.TrimSpace(text))
if err != nil {
m.search.picker.err = "✗ " + capitalize(err.Error())
return nil
}
return m.search.picker.choose(m, key)
}
// seal reads the key and encrypts the files with it, for the picker
// from (nil: the key wasn't picked).
func (m *Model) seal(s sharing, from *picker) tea.Cmd {
read, rules := m.keyReader(), m.shareRules
m.flash = flash{text: "encrypting with " + s.key.Name + "…"}
return func() tea.Msg {
v, version, err := read(s.key)
if err != nil {
return sealedMsg{from: from, err: &share.KeyError{Name: s.key.Name, Err: err}}
}
link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files, rules)
return sealedMsg{link: link, n: len(s.files), from: from, err: err}
}
}
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
if p := msg.from; p != nil {
if m.search == nil || m.search.picker != p {
return nil // the picker was closed: cancelled
}
p.busy = ""
var bad *share.KeyError
if errors.As(msg.err, &bad) {
p.err = "✗ " + capitalize(bad.Error()) // to pick another
return nil
}
m.search = nil
}
if msg.err != nil {
m.noteErr(msg.err)
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
return nil
}
m.dismissedLink = msg.link.String() // not offered back on focus
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
}
// keyReader reads a key's value with the providers, the account that
// last read it first, off the UI goroutine.
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
providers, st, timeout := m.providers, m.store, m.timeout
return func(key ref.Ref) (provider.SecretValue, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout.of(key.Kind))
defer cancel()
k := key
k.Version = ""
node := st.Node(k.String())
return ref.Read(ctx, providers, key, node.Account, node.Source)
}
}
// --- opening ---
type shareOpenedMsg struct {
sealed share.Sealed
files []share.File
dir string // the repo's clone; "" when it isn't here
err error
}
// offerShare says what a share link holds before decrypting it: links
// can come from anywhere, so nothing is read or written unasked.
func (m *Model) offerShare(r ref.Ref) tea.Cmd {
s, err := share.Read(r)
if err != nil {
m.showMenu("Share link can't be read", []string{m.st.Error.Render(err.Error())}, nil)
return nil
}
from := ""
if s.Repo != "" {
from = m.st.Muted.Render(" from ") + m.repoLabel(s.Repo)
}
m.showMenu("Shared env files", []string{
m.st.Muted.Render(plural(s.Files, "env file")) + from,
m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key),
}, []option{
{key: "o", label: "Decrypt…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }},
})
return nil
}
// openShare reads the key, decrypts the files and finds the repo's clone.
func (m *Model) openShare(s share.Sealed) tea.Cmd {
read := m.keyReader()
providers, timeout := m.providers, m.timeout.of(provider.KindRepo)
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
return func() tea.Msg {
msg := shareOpenedMsg{sealed: s}
v, _, err := read(s.Key)
if err != nil {
msg.err = &share.KeyError{Name: s.Key.Name, Err: err}
return msg
}
if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil {
return msg
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, p := range providers {
if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" {
// Its clone, if it's on disk (the demo's aren't).
if root, found, _ := loc.Locate(ctx, s.Repo); found && isDir(root) {
msg.dir = root
break
}
}
}
return msg
}
}
func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
switch {
case msg.err != nil:
m.noteErr(msg.err)
m.openFailed(msg.sealed, msg.err, false)
case msg.dir == "":
return m.askShareDir(msg)
default:
targets, err := planSave(msg.files, msg.dir)
if err != nil {
m.flash = flash{text: "save: " + err.Error(), isErr: true}
return nil
}
m.offerSave(targets, msg.dir)
}
return nil
}
// openFailed says why a share link couldn't be decrypted, in a popup
// where its offer was: its first line (wrapped), or with whole all of it
// (Key Vault's refusals say what to check on later lines). From there,
// the key's secret can be gone to: its version, which may be disabled.
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
width := max(30, min(m.w-12, 76))
text := capitalize(err.Error())
first, rest, _ := strings.Cut(text, "\n")
cut := !whole && rest != "" // more than the reason: what to check
if !whole {
text = first
}
var lines []string
for i, l := range strings.Split(ansi.Wordwrap(text, width-2, " "), "\n") {
if i == 0 {
l = "✗ " + l
} else if l != "" {
l = " " + l
}
lines = append(lines, m.st.Error.Render(l))
}
var opts []option
if cut {
opts = append(opts, option{key: "r", label: "Show the whole message", run: func(m *Model) tea.Cmd {
m.openFailed(s, err, true)
return nil
}})
}
opts = append(opts, option{key: "g", label: "Go to " + s.Key.Name, run: func(m *Model) tea.Cmd { return m.openRef(s.Key, actGo) }})
m.showMenu("Can't decrypt the shared files", lines, opts)
}
// askShareDir asks where to save files from a repo that isn't here. It
// stays until they're saved, saying why when they can't be.
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
in := textinput.New()
in.Prompt = "folder: "
in.SetWidth(max(20, min(m.w-12, 64)))
dir := m.placeDir
if dir == "" {
dir, _ = os.Getwd()
}
in.SetValue(dir)
in.CursorEnd()
lines := []string{m.st.Muted.Render("Paths are from the repo's root.")}
if msg.sealed.Repo != "" {
lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...)
}
p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"}
p.submit = func(m *Model, text string) tea.Cmd {
dir := expandHome(strings.TrimSpace(text))
targets, err := planSave(msg.files, dir)
switch {
case err == nil && slices.ContainsFunc(targets, func(t share.Target) bool { return t.Exists }):
m.offerSave(targets, dir) // ask before replacing
return nil
case err == nil:
// Chosen, with nothing to replace: saved at once.
if err = m.writeShared(targets, dir, false); err == nil {
return nil
}
}
p.err = err.Error()
m.prompt = p // again, to fix the folder
return p.input.Focus()
}
m.prompt = p
return p.input.Focus()
}
// planSave says where each file goes in dir, which must be a folder.
func planSave(files []share.File, dir string) ([]share.Target, error) {
if !isDir(dir) {
return nil, fmt.Errorf("no folder %s", dir)
}
return share.Plan(dir, files)
}
// offerSave shows where each file goes, then saves them as chosen:
// files there already are replaced only when asked.
func (m *Model) offerSave(targets []share.Target, dir string) {
var lines []string
exist := 0
for _, t := range targets {
file := " " + m.repoLabel(filepath.Base(dir), t.Path)
line := file + m.st.Muted.Render(" · new")
if t.Exists {
exist++
line = file + m.st.Muted.Render(" · replaces the one there")
}
if t.Exposed {
line += m.st.Error.Render(" · not ignored by git")
}
lines = append(lines, line)
}
lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...)
write := func(onlyNew bool) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd {
if err := m.writeShared(targets, dir, onlyNew); err != nil {
m.flash = flash{text: "save: " + err.Error(), isErr: true}
}
return nil
}
}
var opts []option
switch {
case exist == 0:
opts = append(opts, option{key: "w", label: "Save", run: write(false)})
case exist == len(targets):
opts = append(opts, option{key: "w", label: "Replace", run: write(false)})
default:
opts = append(opts,
option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)},
option{key: "n", label: onlyNew(len(targets) - exist), run: write(true)},
)
}
m.showMenu("Save the shared files", lines, opts)
}
// writeShared writes the files (with onlyNew, those not there yet) and
// says where, and which ones git doesn't ignore.
func (m *Model) writeShared(targets []share.Target, dir string, onlyNew bool) error {
n := 0
var exposed []string
for _, t := range targets {
if onlyNew && t.Exists {
continue
}
if err := t.Write(); err != nil {
return fmt.Errorf("%s: %w", t.Path, err)
}
n++
if t.Exposed {
exposed = append(exposed, t.Path)
}
}
m.flash = done("saved " + plural(n, "file") + " in " + dir)
if len(exposed) > 0 {
m.flash = warning("saved " + plural(n, "file") + " in " + dir + "; git doesn't ignore " + strings.Join(exposed, ", "))
}
return nil
}
// isDir reports whether dir is a folder on disk.
func isDir(dir string) bool {
info, err := os.Stat(dir)
return err == nil && info.IsDir()
}
// expandHome expands a leading ~/ to the home folder.
func expandHome(dir string) string {
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, rest)
}
}
return dir
}
// onlyNew labels saving only the n files not there yet.
func onlyNew(n int) string {
if n == 1 {
return "Save only the new one"
}
return fmt.Sprintf("Save only the %d new ones", n)
}
-355
View File
@@ -1,355 +0,0 @@
package app
import (
"context"
"errors"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// scryptWait is how long the driver waits for encrypting and decrypting:
// tens of milliseconds, more than its usual wait, and more under load.
const scryptWait = 20 * time.Second
// demoRules take the demo key, made up to be remembered.
var demoRules = share.Rules{MinLength: 16, MinClasses: 1}
// cloneWith makes a clone of git.example.com/team/app holding files.
func cloneWith(t *testing.T, files map[string]string) string {
t.Helper()
root := gitRepoWith(t, "https://git.example.com/team/app.git")
os.WriteFile(filepath.Join(root, ".gitignore"), []byte(".env\n"), 0o644)
for name, data := range files {
os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0o755)
os.WriteFile(filepath.Join(root, name), []byte(data), 0o600)
}
return root
}
// shareFrom shares a repo's env files from lazysecrets started in it,
// returning the link copied.
func shareFrom(t *testing.T, root string, paste bool) string {
t.Helper()
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
d.wait = scryptWait
if src, ok := d.m.selectedSource(); !ok || src.ID != root {
t.Fatalf("selected %v, want the repo", src.ID)
}
d.keys("S", "f")
// No sharing.key set: it asks which secret, in Go to, which lists
// only secrets.
v := d.m.search
if v == nil || v.picker == nil {
t.Fatalf("no secret picker: %s", d.m.flash.text)
}
for _, o := range v.shown {
if o.plain || strings.Contains(o.label, ".env") {
t.Errorf("a plain env value offered as the key: %s", o.label)
}
}
if !strings.Contains(d.screen(), "as an encrypted link") {
t.Errorf("doesn't say what picking does:\n%s", d.screen())
}
if paste {
// A reference pasted is picked as it is.
d.send(tea.PasteMsg{Content: "https://" + kv + "/secrets/db-password"})
} else {
for _, r := range "app-prod db-password" {
d.keys(string(r))
}
if len(v.shown) == 0 {
t.Fatalf("no secrets to pick:\n%s", d.screen())
}
for _, o := range v.shown {
if _, ok := pickable(o); !ok {
t.Errorf("not a secret: %s", o.label)
}
}
if !strings.Contains(d.screen(), "Share 2 env files") && !strings.Contains(d.screen(), "Share .env") {
t.Errorf("not titled as a picker:\n%s", d.screen())
}
d.keys("enter")
}
// Picking it shares: the picker said what.
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
}
return clip.content
}
func TestShareFilesRoundTrip(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
link := shareFrom(t, cloneWith(t, map[string]string{
".env": "DB_PASSWORD=hunter2\n",
"prod.env": "TOKEN=abc\n",
}), false)
// A colleague's clone: .env is there already, prod.env isn't.
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: theirs}))
d.wait = scryptWait
d.send(tea.PasteMsg{Content: link})
if d.m.prompt == nil || d.m.prompt.title != "Shared env files" || !strings.Contains(d.screen(), "2 env files from git.example.com/team/app") {
t.Fatalf("not offered:\n%s", d.screen())
}
d.keys("o")
if d.m.prompt == nil || d.m.prompt.title != "Save the shared files" {
t.Fatalf("no save menu (%s)", d.m.flash.text)
}
if s := d.screen(); !strings.Contains(s, "replaces the one there") || !strings.Contains(s, "not ignored by git") {
t.Errorf("save menu doesn't say what happens:\n%s", s)
}
d.keys("n") // only the new one
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=old\n" {
t.Errorf(".env replaced: %q", b)
}
if b, _ := os.ReadFile(filepath.Join(theirs, "prod.env")); string(b) != "TOKEN=abc\n" {
t.Errorf("prod.env: %q", b)
}
// Again, replacing.
d.send(tea.PasteMsg{Content: link})
d.keys("o", "w")
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=hunter2\n" {
t.Errorf(".env not replaced: %q", b)
}
}
func TestShareWithShareKeyConfirms(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := cloneWith(t, map[string]string{
".env": "A=1\n",
".lazysecrets.yaml": "sharing:\n key: https://" + kv + "/secrets/db-password\n",
})
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
d.wait = scryptWait
d.keys("S", "f")
// The key wasn't picked: it shows which, before sharing.
if d.m.search != nil || d.m.prompt == nil || d.m.prompt.title != "Share .env" || !strings.Contains(d.screen(), "db-password") {
t.Fatalf("no confirmation:\n%s", d.screen())
}
d.keys("c")
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
t.Errorf("copied %q (%s)", clip.content, d.m.flash.text)
}
}
func TestShareLinkElsewhereAsksWhere(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}), true)
dir, _ := filepath.EvalSymlinks(t.TempDir())
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
d.wait = scryptWait
d.send(tea.PasteMsg{Content: link})
d.keys("o")
if d.m.prompt == nil || d.m.prompt.input == nil || d.m.prompt.input.Value() != dir {
t.Fatalf("doesn't ask where, starting from where it runs:\n%s", d.screen())
}
// A folder that isn't there: the prompt stays, saying so.
d.m.prompt.input.SetValue(filepath.Join(dir, "nope"))
d.keys("enter")
if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "✗ No folder") {
t.Fatalf("a missing folder isn't said in the prompt:\n%s", d.screen())
}
if !strings.Contains(d.screen(), "enter save") {
t.Errorf("the footer doesn't say enter saves:\n%s", d.screen())
}
d.m.prompt.input.SetValue(dir)
d.keys("enter") // saved at once: nothing there to replace
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" {
t.Errorf(".env: %q (%s)", b, d.m.flash.text)
}
if d.m.prompt != nil || !strings.Contains(d.m.flash.text, "saved 1 file in "+dir) {
t.Errorf("not closed with where it saved: %q", d.m.flash.text)
}
}
func TestShareLinkGarbled(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.send(tea.PasteMsg{Content: "lazysecrets://share/AQ" + strings.Repeat("A", 30)})
if d.m.prompt == nil || !strings.Contains(d.screen(), "garbled") {
t.Errorf("not reported:\n%s", d.screen())
}
}
// TestShareInDemo shares a demo repo's env files with the demo key and
// opens the link, as lazysecrets --demo does: the demo repos aren't on
// disk, so it asks where to save.
func TestShareInDemo(t *testing.T) {
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip},
fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos())
d.wait = scryptWait
var src provider.Source
for _, s := range d.m.sourceResults["Repos"].sources {
if s.Here {
src = s
}
}
if src.ID == "" {
t.Fatal("no demo repo")
}
src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil))
if d.m.search == nil || d.m.search.picker == nil {
t.Fatalf("no picker: %s", d.m.flash.text)
}
// Too short a key: the picker stays, saying why.
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"})
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; sharing.keyRules needs 32 (minLength)") {
t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
}
if !strings.Contains(d.screen(), "28 characters") {
t.Errorf("not shown:\n%s", d.screen())
}
d.send(tea.PasteMsg{Content: "https://kv-shared-prod.vault.azure.net/secrets/env-share"})
if !strings.HasPrefix(clip.content, "lazysecrets://share/") || d.m.search != nil {
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
}
link := clip.content
// A value in a demo repo's SOPS file can be the key too, found by
// the repo's remote.
clip.content = ""
d.run(d.m.shareFiles(src, nil))
d.send(tea.PasteMsg{Content: "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/config%2Foauth.enc.json/github.client_secret"})
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
t.Errorf("with the demo repo's key: copied %q (%s)", clip.content, d.m.flash.text)
}
dir, _ := filepath.EvalSymlinks(t.TempDir())
d.m.placeDir = dir
d.send(tea.PasteMsg{Content: link})
d.keys("o")
if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "isn't among your repos") {
t.Fatalf("doesn't ask where:\n%s", d.screen())
}
d.keys("enter") // saved at once: nothing there to replace
entries, _ := os.ReadDir(dir)
if len(entries) == 0 {
t.Fatalf("nothing saved (%s)", d.m.flash.text)
}
}
func TestPickerCancelAndBusy(t *testing.T) {
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip},
fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos())
var src provider.Source
for _, s := range d.m.sourceResults["Repos"].sources {
if s.Here {
src = s
}
}
src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil))
p := d.m.search.picker
key, _ := share.ParseKey("https://kv-shared-prod.vault.azure.net/secrets/env-share")
// Picking starts sealing; another pick meanwhile is ignored.
cmd := p.choose(&d.m, key)
if cmd == nil || p.busy == "" {
t.Fatal("picking didn't start")
}
if again := p.choose(&d.m, key); again != nil {
t.Error("a second pick while busy wasn't ignored")
}
// Closed before the link is made: cancelled, nothing copied.
d.m.search = nil
d.wait = scryptWait
d.run(cmd)
if clip.content != "" {
t.Errorf("copied after the picker was closed: %q", clip.content)
}
}
func TestPlainEnvNotPickableFromFavourites(t *testing.T) {
mem, _ := store.Load("")
d := newDriverOpts(t, Options{Store: mem}, fake.Azure(0), fake.Repos())
var src provider.Source
for _, s := range d.m.sourceResults["Repos"].sources {
if s.Here {
src = s
}
}
// A key in the demo repo's plain .env, starred.
key := "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/.env/DATABASE_URL"
mem.Update(key, func(n *store.Node) {
n.StarredBy = []string{"Repos"}
n.Path = []string{src.Name, ".env"}
n.Source = src.ID
})
src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil))
favs, _ := d.m.storeEntries()
for _, o := range favs {
if o.star == key {
if _, ok := pickable(o); ok {
t.Error("a plain .env value is offered as a key in Favourites")
}
return
}
}
t.Fatalf("the starred key isn't among the favourites: %d", len(favs))
}
// refusing is a provider whose values can't be read, like a disabled
// version in Key Vault.
type refusing struct{ provider.Provider }
func (refusing) Value(context.Context, provider.Object) (provider.SecretValue, error) {
return provider.SecretValue{}, &provider.Denied{
Err: errors.New("Operation get is not allowed on a disabled secret."),
Checks: []string{"the secret's version is enabled"},
}
}
func TestOpenFailsInAPopup(t *testing.T) {
key, _ := share.ParseKey("https://" + kv + "/secrets/db-password")
link, err := share.Seal(share.Header{Key: key, Repo: "git.example.com/team/app"},
[]byte("correct-horse-battery-staple"), []share.File{{Path: ".env", Data: []byte("A=1\n")}}, demoRules)
if err != nil {
t.Fatal(err)
}
d := newDriverOpts(t, Options{}, refusing{fake.Azure(0)})
d.wait = scryptWait
d.send(tea.PasteMsg{Content: link.String()})
d.keys("o")
p := d.m.prompt
if p == nil || p.title != "Can't decrypt the shared files" || !strings.Contains(d.screen(), "Can't use db-password: Operation get is not allowed") {
t.Fatalf("not in a popup:\n%s", d.screen())
}
if strings.Contains(d.screen(), "version is enabled") {
t.Error("the whole message is shown before asking")
}
d.keys("r")
if !strings.Contains(d.screen(), "version is enabled") {
t.Errorf("r doesn't show the whole message:\n%s", d.screen())
}
d.keys("g")
if _, _, sel := d.selected(); d.m.prompt != nil || (sel != "db-password" && (d.m.nav == nil || d.m.nav.ref.Name != "db-password")) {
t.Errorf("g doesn't go to the key: %q, %q\n%s", sel, d.m.flash.text, d.screen())
}
}
-25
View File
@@ -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)
}
}
+4 -12
View File
@@ -20,8 +20,7 @@ type Updater struct {
} }
// updateEvery is how often lazysecrets looks for a newer release on its // updateEvery is how often lazysecrets looks for a newer release on its
// own unless the config says otherwise; within that, the last answer (in // own; within that, the last answer (in the state file) is used.
// the state file) is used.
const updateEvery = 24 * time.Hour const updateEvery = 24 * time.Hour
type updateMsg struct { type updateMsg struct {
@@ -36,14 +35,14 @@ type installedMsg struct {
} }
// startUpdateCheck sets up the startup look for a newer release: the // startUpdateCheck sets up the startup look for a newer release: the
// state file's answer when it looked within updateEvery, else a quiet // state file's answer when it looked less than a day ago, else a quiet
// look at the server (fetchOnStart, for Init). // look at the server (fetchOnStart, for Init).
func (m *Model) startUpdateCheck() { func (m *Model) startUpdateCheck() {
if m.updater == nil || !m.updateCheck { if m.updater == nil || !m.updateCheck {
return return
} }
last := m.store.UpdateCheck() last := m.store.UpdateCheck()
if m.now().Sub(last.At) < m.updateEvery { if m.now().Sub(last.At) < updateEvery {
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL}) m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
return return
} }
@@ -110,22 +109,15 @@ func (m *Model) update() tea.Cmd {
func (m *Model) showUpdate() { func (m *Model) showUpdate() {
r := *m.newer r := *m.newer
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")} lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
if notes := r.Notes(m.version); notes != "" { if notes := r.Notes(); notes != "" {
lines = append(lines, "") lines = append(lines, "")
for _, l := range strings.Split(notes, "\n") { for _, l := range strings.Split(notes, "\n") {
if h, ok := strings.CutPrefix(l, "### "); ok { if h, ok := strings.CutPrefix(l, "### "); ok {
l = m.st.Label.Render(h) l = m.st.Label.Render(h)
} else if h, ok := strings.CutPrefix(l, "## "); ok { // a release, when several are new
l = m.st.TitleFocused.Render(h)
} }
lines = append(lines, l) lines = append(lines, l)
} }
} }
// A popup doesn't scroll: several releases behind, the rest is on
// the release pages.
if most := max(6, m.h-12); len(lines) > most {
lines = append(lines[:most-1], m.st.Muted.Render("… and more on the release pages"))
}
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.", opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
run: func(m *Model) tea.Cmd { return m.install(r) }}} run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" { if r.URL != "" {
-14
View File
@@ -83,12 +83,6 @@ func TestUpdate(t *testing.T) {
if f2.asked != 1 { if f2.asked != 1 {
t.Errorf("asked %d times a day later", f2.asked) t.Errorf("asked %d times a day later", f2.asked)
} }
// With autoCheckInterval: 1h, two hours on is long enough.
mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-2 * time.Hour), Tag: "v0.2.0"})
newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, UpdateEvery: time.Hour, Store: mem})
if f2.asked != 2 {
t.Errorf("asked %d times, want a look after the hour", f2.asked)
}
} }
func TestUpdateAsked(t *testing.T) { func TestUpdateAsked(t *testing.T) {
@@ -122,11 +116,3 @@ func TestUpdateAsked(t *testing.T) {
t.Errorf("U on a dev build:\n%s", dev.screen()) t.Errorf("U on a dev build:\n%s", dev.screen())
} }
} }
func TestStartupWarning(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Warning: "theme: skipped unknown activeBorder"})
if !strings.Contains(d.screen(), "theme: skipped unknown activeBorder") {
t.Errorf("no warning:\n%s", d.screen())
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// Usage: revealing, copying, opening in the browser, sharing and jumping // Usage: revealing, copying, opening in the browser, sharing and jumping
// to an item count as using it. The store keeps when each item was last // to an item count as using it. The store keeps when each item was last
// used and how often, for the Recent section of the jump list. The config's // used and how often, for the Recent section of the jump list. The config's
// storage.history: false turns this off. // history: false turns this off.
// used counts a use of the remembered item key, keeping its context. // used counts a use of the remembered item key, keeping its context.
func (m *Model) used(key string) { func (m *Model) used(key string) {
+8 -8
View File
@@ -95,7 +95,7 @@ func (m *Map) Actions() []Action {
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."), a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."), a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."), a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
a(Lists, "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, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."), a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
@@ -103,7 +103,7 @@ func (m *Map) Actions() []Action {
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "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, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."), a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."), a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
@@ -114,10 +114,10 @@ func (m *Map) Actions() []Action {
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."), a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."), a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."), a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."), a(Lists, "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, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."), a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."), a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
@@ -184,13 +184,13 @@ var defaults = map[string][]string{
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"}, "lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, "lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"}, "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.open": {"enter"}, "lists.back": {"esc", "backspace"}, "lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"}, "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
"lists.commands": {"x"}, "lists.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
"lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"}, "lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.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.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"}, "lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="}, "lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
-10
View File
@@ -60,16 +60,6 @@ func (d *Detail) ShowSecret(s provider.Object) {
d.container = &s.Container d.container = &s.Container
} }
// Refresh shows s's newer metadata if s is the secret shown, keeping
// its account and any value shown or loading.
func (d *Detail) Refresh(s provider.Object) {
if d.secret == nil || d.secret.ID != s.ID {
return
}
s.Container = d.secret.Container
d.secret = &s
}
// ShowContainer shows a container summary when no secret is selected. // ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) { func (d *Detail) ShowContainer(c provider.Container) {
d.Clear() d.Clear()
+8 -17
View File
@@ -45,9 +45,7 @@ var Roles = map[string]string{
} }
// FromTheme returns the default styles with the theme's roles applied, and // FromTheme returns the default styles with the theme's roles applied, and
// the default text colour if the theme sets one. Roles it doesn't know are // the default text colour if the theme sets one.
// skipped (see Unknown): a theme file made for a newer lazysecrets still
// works.
func FromTheme(theme map[string][]string) (Styles, color.Color, error) { func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default() st := Default()
var fg color.Color var fg color.Color
@@ -58,7 +56,7 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
sort.Strings(roles) sort.Strings(roles)
for _, role := range roles { for _, role := range roles {
if _, ok := Roles[role]; !ok { if _, ok := Roles[role]; !ok {
continue return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
} }
v, err := parse(theme[role]) v, err := parse(theme[role])
if err != nil { if err != nil {
@@ -109,20 +107,13 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
return st, fg, nil return st, fg, nil
} }
// Unknown says which of the theme's roles aren't known, for a warning, or func sortedRoles() []string {
// is empty. var out []string
func Unknown(theme map[string][]string) string { for r := range Roles {
var unknown []string out = append(out, r)
for r := range theme {
if _, ok := Roles[r]; !ok {
unknown = append(unknown, r)
}
} }
if len(unknown) == 0 { slices.Sort(out)
return "" return out
}
slices.Sort(unknown)
return "theme: skipped unknown " + strings.Join(unknown, ", ") + " (a typo, or from a newer lazysecrets)"
} }
// value is a parsed role: a colour (nil for default) and attributes. // value is a parsed role: a colour (nil for default) and attributes.
+3 -13
View File
@@ -44,29 +44,19 @@ func TestFromTheme(t *testing.T) {
func TestFromThemeRejectsMistakes(t *testing.T) { func TestFromThemeRejectsMistakes(t *testing.T) {
for name, theme := range map[string]map[string][]string{ for name, theme := range map[string]map[string][]string{
"role": {"activeBorder": {"red"}},
"colour": {"errorColor": {"redish"}}, "colour": {"errorColor": {"redish"}},
"hex": {"errorColor": {"#fff"}}, "hex": {"errorColor": {"#fff"}},
"palette": {"errorColor": {"300"}}, "palette": {"errorColor": {"300"}},
} { } {
if _, _, err := FromTheme(theme); err == nil { if _, _, err := FromTheme(theme); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
t.Errorf("unknown role error should list the known ones: %v", err)
} }
} }
} }
func TestUnknownRolesSkipped(t *testing.T) {
theme := map[string][]string{"activeBorder": {"red"}, "errorColor": {"red"}}
if _, _, err := FromTheme(theme); err != nil {
t.Errorf("unknown role failed the theme: %v", err)
}
if w := Unknown(theme); !strings.Contains(w, "unknown activeBorder ") || strings.Contains(w, "errorColor") {
t.Errorf("warning = %q", w)
}
if w := Unknown(map[string][]string{"errorColor": {"red"}}); w != "" {
t.Errorf("warning for known roles: %q", w)
}
}
func TestEmptyThemeIsDefault(t *testing.T) { func TestEmptyThemeIsDefault(t *testing.T) {
st, fg, err := FromTheme(nil) st, fg, err := FromTheme(nil)
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack { if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
+14 -52
View File
@@ -28,7 +28,6 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strings" "strings"
"time" "time"
@@ -54,44 +53,18 @@ const maxDownload = 200 << 20
// Release is a published release. // Release is a published release.
type Release struct { type Release struct {
Tag string `json:"tag_name"` Tag string `json:"tag_name"`
// Body is the changelog section, then install notes. From Latest, Body string `json:"body"` // the changelog section, then install notes
// it's the changelogs of the recent releases, each under a "## v…" URL string `json:"html_url"`
// 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"`
} }
// Notes is the changelog since the version current: of each release // Notes is the release's changelog: its body without the install notes
// newer than it (just this one for a dev build), without the install // every release ends with.
// notes every release ends with. func (r Release) Notes() string {
func (r Release) Notes(current string) string { notes, _, _ := strings.Cut(r.Body, "## Install")
body, _, _ := strings.Cut(r.Body, "## Install")
var keep, headings []string
in := true // before any heading: a single release's notes
for _, l := range strings.Split(body, "\n") {
if tag, ok := strings.CutPrefix(l, "## "); ok && semver.IsValid(tag) {
in = Newer(current, tag) || tag == r.Tag
if in {
headings = append(headings, l)
}
}
if in {
keep = append(keep, l)
}
}
notes := strings.Join(keep, "\n")
if len(headings) == 1 { // the title names it
notes = strings.Replace(notes, headings[0], "", 1)
}
return PlainLinks(strings.TrimSpace(notes)) return PlainLinks(strings.TrimSpace(notes))
} }
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
// PlainLinks shows Markdown links as their text, for a terminal. // PlainLinks shows Markdown links as their text, for a terminal.
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") } func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
@@ -155,29 +128,18 @@ func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
} }
ctx, cancel := context.WithTimeout(ctx, latestTimeout) ctx, cancel := context.WithTimeout(ctx, latestTimeout)
defer cancel() defer cancel()
// The recent releases in one request (per_page on GitHub, limit on b, err := c.get(ctx, api+"/releases/latest", 1<<20)
// Gitea), for their changelogs.
b, err := c.get(ctx, fmt.Sprintf("%s/releases?per_page=%d&limit=%[2]d", api, recentReleases), 1<<20)
if err != nil { if err != nil {
return Release{}, err return Release{}, err
} }
var all []Release var r Release
if err := json.Unmarshal(b, &all); err != nil { if err := json.Unmarshal(b, &r); err != nil {
return Release{}, fmt.Errorf("reading the releases from %s: %w", repo, err) 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 !semver.IsValid(r.Tag) {
if len(all) == 0 { return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo)
return Release{}, fmt.Errorf("no releases on %s", repo)
} }
slices.SortFunc(all, func(a, b Release) int { return semver.Compare(b.Tag, a.Tag) }) return r, nil
var notes strings.Builder
for _, r := range all {
section, _, _ := strings.Cut(r.Body, "## Install")
fmt.Fprintf(&notes, "## %s\n\n%s\n\n", r.Tag, strings.TrimSpace(section))
}
latest := all[0]
latest.Body = strings.TrimSpace(notes.String())
return latest, nil
} }
// Newer reports whether latest is newer than current. A build that isn't // Newer reports whether latest is newer than current. A build that isn't
+4 -35
View File
@@ -39,42 +39,11 @@ func TestNewer(t *testing.T) {
func TestNotes(t *testing.T) { func TestNotes(t *testing.T) {
r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"} r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"}
if got := r.Notes("0.1.1"); got != "### Added\n\n- Install with a one-liner (#7)" { if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" {
t.Errorf("notes %q", got) t.Errorf("notes %q", got)
} }
} }
// Latest reads the recent releases, so the notes cover every version
// since the one running.
func TestNotesSince(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/keb/lazysecrets/releases" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `[
{"tag_name":"v0.4.0-rc1","body":"### Added\n\n- rc","prerelease":true},
{"tag_name":"v0.2.0","body":"### Added\n\n- two\n\n## Install\n\ncurl"},
{"tag_name":"v0.3.0","body":"### Fixed\n\n- three\n\n## Install\n\ncurl","html_url":"https://example.com/v0.3.0"},
{"tag_name":"v0.1.1","body":"### Added\n\n- one"},
{"tag_name":"v9.0.0","body":"draft","draft":true}]`)
}))
t.Cleanup(srv.Close)
r, err := (&Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}).Latest(context.Background())
if err != nil || r.Tag != "v0.3.0" || r.URL != "https://example.com/v0.3.0" {
t.Fatalf("latest %+v %v", r, err)
}
for current, want := range map[string]string{
"0.1.1": "## v0.3.0\n\n### Fixed\n\n- three\n\n## v0.2.0\n\n### Added\n\n- two",
"0.2.0": "### Fixed\n\n- three",
"dev-2ba762c": "### Fixed\n\n- three",
} {
if got := r.Notes(current); got != want {
t.Errorf("notes since %s: %q, want %q", current, got, want)
}
}
}
func TestManaged(t *testing.T) { func TestManaged(t *testing.T) {
if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" { if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" {
t.Error("package managers not recognised") t.Error("package managers not recognised")
@@ -101,8 +70,8 @@ func server(t *testing.T, tamper bool) *httptest.Server {
sum[0] ^= 0xff sum[0] ^= 0xff
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `[{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}]`) fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
}) })
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name) fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
@@ -204,7 +173,7 @@ func TestInstallRefusesATamperedArchive(t *testing.T) {
func TestNotesShowLinksAsText(t *testing.T) { func TestNotesShowLinksAsText(t *testing.T) {
r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"} r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"}
if got, want := r.Notes("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) t.Errorf("notes %q, want %q", got, want)
} }
} }