Compare commits
16
Commits
4d601bb82f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f5248d8f6 | ||
|
|
580c7ef985 | ||
|
|
4bac06ae5b | ||
|
|
913b3db6a9 | ||
|
|
17da9eaa32 | ||
|
|
96395cb6a2 | ||
|
|
39cbac9aab | ||
|
|
637bf6a253
|
||
|
|
29b0c76f40
|
||
|
|
df429002c9 | ||
|
|
0ff5204d4d | ||
|
|
6842c72662 | ||
|
|
fae8e1cbb4 | ||
|
|
1753630c73 | ||
|
|
b773dfe296 | ||
|
|
bb41767cba |
No files matched your search
+8
-28
@@ -1,33 +1,13 @@
|
|||||||
# ---> Go
|
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
|
||||||
#
|
|
||||||
# Binaries for programs and plugins
|
|
||||||
*.exe
|
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
|
||||||
*.test
|
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
|
||||||
*.out
|
|
||||||
|
|
||||||
# Dependency directories (remove the comment below to include it)
|
|
||||||
# vendor/
|
|
||||||
|
|
||||||
# Go workspace file
|
|
||||||
go.work
|
|
||||||
go.work.sum
|
|
||||||
|
|
||||||
# env file
|
|
||||||
.env
|
|
||||||
|
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
/lazysecrets
|
/lazysecrets
|
||||||
|
|
||||||
# Release builds (GoReleaser, see .goreleaser.yaml)
|
# Release builds (GoReleaser, see .goreleaser.yaml)
|
||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
|
# go test -c, coverage profiles
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
.env
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Every release of lazysecrets, newest first.
|
Every release of lazysecrets, newest first.
|
||||||
|
|
||||||
|
## v0.2.1 – 2026-10-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A link opens lazysecrets with your cache and next to your running instances ([#71](https://git.kebcloud.no/keb/lazysecrets/pulls/71)) ([bb41767](https://git.kebcloud.no/keb/lazysecrets/commit/bb41767cbad00bab4f1824f4e0754cc7abd98c59))
|
||||||
|
|
||||||
## v0.2.0 – 2026-10-14
|
## v0.2.0 – 2026-10-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -7,24 +7,23 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
|||||||
## Main features
|
## Main features
|
||||||
|
|
||||||
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
|
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
|
||||||
That's by design and may stay that way.
|
|
||||||
- **Zero setup**: uses the logins you already have (`az`, the Databricks
|
- **Zero setup**: uses the logins you already have (`az`, the Databricks
|
||||||
CLI, `sops`, `git`). No config file needed.
|
CLI, `sops`, `git`). No config file needed.
|
||||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||||
fzf-style matching.
|
fzf-style matching.
|
||||||
- **Share links**: Share `lazysecrets://` links with colleagues.
|
- **Share links**: Share `lazysecrets://` links with colleagues.
|
||||||
- **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.
|
||||||
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
- **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor
|
||||||
pipes.
|
pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
||||||
|
for Neovim.
|
||||||
- **Favourites**: star the secrets you use most for easy access.
|
- **Favourites**: star the secrets you use most for easy access.
|
||||||
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
||||||
marked, listed together in the status pane.
|
marked, listed together in the status pane.
|
||||||
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||||
on a secret.
|
on a secret.
|
||||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
||||||
- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
|
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env`, Spring Boot and Docker Compose files in your git repos.
|
||||||
|
|
||||||
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
|
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
|
||||||
|
|
||||||
@@ -45,9 +44,8 @@ Both approaches will install the latest release from [GitHub](https://github.com
|
|||||||
`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
|
`U` (or `lazysecrets update`) updates in place when there's a newer
|
||||||
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
|
release.
|
||||||
`lazysecrets update`).
|
|
||||||
|
|
||||||
Or build it yourself:
|
Or build it yourself:
|
||||||
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
|
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
|
||||||
@@ -70,36 +68,26 @@ use most:
|
|||||||
| `l` / `h` | the details of what's selected / back |
|
| `l` / `h` | the details of what's selected / back |
|
||||||
| `/` | filter the list you're in |
|
| `/` | filter the list you're in |
|
||||||
| `space` | reveal / hide the value |
|
| `space` | reveal / hide the value |
|
||||||
| `y` | copy the value (cleared from the clipboard after 30s) |
|
| `y` | copy the value |
|
||||||
| `ctrl+s` | Go to: search everything and jump there |
|
| `ctrl+s` | Go to: search everything and jump there |
|
||||||
| `f` / `F` | star / your favourites |
|
| `f` / `F` | star / your favourites |
|
||||||
| `s` | copy a link to share |
|
| `s` | copy a link to share |
|
||||||
| `?` | every key, searchable |
|
| `?` | every key, searchable |
|
||||||
| `q` | quit |
|
| `q` | quit |
|
||||||
|
|
||||||
|
|
||||||
## Integration
|
## Integration
|
||||||
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
|
|
||||||
|
|
||||||
Here is an example of implementing a picker in `fzf`:
|
`lazysecrets list`, `get` and `open` plug lazysecrets into fzf, editor
|
||||||
```sh
|
pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
|
||||||
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
|
|
||||||
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
|
|
||||||
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
|
|
||||||
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
|
|
||||||
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
|
||||||
```
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
More defails in the [Integration](docs/integration.md) docs
|
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
||||||
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
||||||
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
||||||
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
|
- [SOPS, .env, Spring and compose files](docs/repos.md): which repos and files, decrypting
|
||||||
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
|
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
|
||||||
opening links, also from the browser and chat
|
opening links, also from the browser and chat
|
||||||
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
|
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
|
||||||
|
|||||||
+4
-10
@@ -18,7 +18,7 @@ import (
|
|||||||
// It ends with a newline only when printing to a terminal, so command
|
// It ends with a newline only when printing to a terminal, so command
|
||||||
// substitution and pipes get the value exactly.
|
// substitution and pipes get the value exactly.
|
||||||
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
|
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
|
||||||
v, err := value(ctx, providers, st, r)
|
v, _, err := readKey(ctx, providers, st, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -30,15 +30,9 @@ 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
|
// readKey reads what r points at (ref.Read), and the version read. The
|
||||||
// read it first.
|
// account asked for (--account) goes first, else the one that last read
|
||||||
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
|
// it.
|
||||||
v, _, err := readKey(ctx, providers, st, r)
|
|
||||||
return v, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// readKey is value, also returning the version read. The account asked
|
|
||||||
// for (--account) goes first, else the one that last read it.
|
|
||||||
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
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 = ""
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
|
|||||||
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
|
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
|
||||||
} {
|
} {
|
||||||
r, _ := ref.Parse(link)
|
r, _ := ref.Parse(link)
|
||||||
v, err := value(context.Background(), providers, st, r)
|
v, _, err := readKey(context.Background(), providers, st, r)
|
||||||
if err != nil || v.Reveal() != want {
|
if err != nil || v.Reveal() != want {
|
||||||
t.Errorf("%s: %q, %v", link, v.Reveal(), err)
|
t.Errorf("%s: %q, %v", link, v.Reveal(), err)
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
|
|||||||
"{{secrets/app/storage-key}}": "names no workspace",
|
"{{secrets/app/storage-key}}": "names no workspace",
|
||||||
} {
|
} {
|
||||||
r, _ := ref.Parse(link)
|
r, _ := ref.Parse(link)
|
||||||
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
|
if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||||
t.Errorf("%s: %v, want %q", link, err, wantErr)
|
t.Errorf("%s: %v, want %q", link, err, wantErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,11 +69,7 @@ func installHandler(cfg config.Config, configPath, term string) (string, string,
|
|||||||
if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
|
if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
launch := []string{exe}
|
launch := append(append([]string{exe}, configArgs(configPath)...), "launch")
|
||||||
if configPath != defaultConfigPath() {
|
|
||||||
launch = append(launch, "--config", configPath)
|
|
||||||
}
|
|
||||||
launch = append(launch, "launch")
|
|
||||||
if term != "" {
|
if term != "" {
|
||||||
launch = append(launch, "--terminal", term)
|
launch = append(launch, "--terminal", term)
|
||||||
}
|
}
|
||||||
@@ -188,8 +184,14 @@ func launchCommand(cfg config.Config, configPath, term, exe, link string) ([]str
|
|||||||
// innerCommand runs lazysecrets on link, with the environment it needs.
|
// innerCommand runs lazysecrets on link, with the environment it needs.
|
||||||
func innerCommand(configPath, exe, link string) []string {
|
func innerCommand(configPath, exe, link string) []string {
|
||||||
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe)
|
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe)
|
||||||
if configPath != defaultConfigPath() {
|
inner = append(inner, configArgs(configPath)...)
|
||||||
inner = append(inner, "--config", configPath)
|
|
||||||
}
|
|
||||||
return append(inner, "open", link)
|
return append(inner, "open", link)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configArgs passes the config files on, unless they're the default.
|
||||||
|
func configArgs(configPath string) []string {
|
||||||
|
if configPath == defaultConfigPath() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"--config", configPath}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
|
|||||||
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
|
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
|
||||||
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
|
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
|
||||||
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
|
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
|
||||||
|
hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: Spring and compose keys that don't look secret, example files, and files and repos with only those (json: \"hidden\")")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -129,7 +130,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
|
|||||||
f = cachefile.Read(cachePath)
|
f = cachefile.Read(cachePath)
|
||||||
}
|
}
|
||||||
f, loaded := listing.Run(context.Background(), listing.Options{
|
f, loaded := listing.Run(context.Background(), listing.Options{
|
||||||
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
|
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, Hidden: *hidden,
|
||||||
Mark: func(link, account string) listing.Marks {
|
Mark: func(link, account string) listing.Marks {
|
||||||
n := st.Node(link)
|
n := st.Node(link)
|
||||||
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
|
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
|
||||||
|
|||||||
+51
-73
@@ -68,27 +68,20 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
|
|||||||
if c.Key == "" {
|
if c.Key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
|
name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
|
||||||
if err := km.CheckExtra(c.Key, name); err != nil {
|
if err := km.CheckExtra(c.Key, name); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, d := range cmds[:i] {
|
for _, d := range cmds[:i] {
|
||||||
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
|
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
|
||||||
if d.Key == c.Key && overlap {
|
if d.Key == c.Key && overlap {
|
||||||
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
|
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmpOr(a, b string) string {
|
|
||||||
if a != "" {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// refArg parses get's and open's arguments: a reference, and optionally
|
// refArg parses get's and open's arguments: a reference, and optionally
|
||||||
// the account to use when several could (lazysecrets list's column) and,
|
// the account to use when several could (lazysecrets list's column) and,
|
||||||
// for open, what to do with it rather than asking (--action).
|
// for open, what to do with it rather than asking (--action).
|
||||||
@@ -150,6 +143,7 @@ func run() error {
|
|||||||
for scripts: VAR=$(lazysecrets get <ref>)
|
for scripts: VAR=$(lazysecrets get <ref>)
|
||||||
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
|
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
|
||||||
[--color always|never] [--icons=false] [--errors=false]
|
[--color always|never] [--icons=false] [--errors=false]
|
||||||
|
[--stars=false] [--notes=false] [--recent] [--expiry=false]
|
||||||
print every source, vault and secret it can
|
print every source, vault and secret it can
|
||||||
find, one per line, for fzf and pickers:
|
find, one per line, for fzf and pickers:
|
||||||
what's cached at once, then what loads
|
what's cached at once, then what loads
|
||||||
@@ -177,26 +171,22 @@ Flags:
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var open, getRef *ref.Ref
|
sub := "" // the subcommand; "" browses
|
||||||
|
var subArgs []string
|
||||||
|
var open *ref.Ref
|
||||||
|
var getRef ref.Ref
|
||||||
var openAction string
|
var openAction string
|
||||||
var shareArgs, listArgs []string
|
|
||||||
printKeys := false
|
|
||||||
switch args := flag.Args(); {
|
switch args := flag.Args(); {
|
||||||
case len(args) > 0 && args[0] == "share":
|
case len(args) > 0 && (args[0] == "share" || args[0] == "list"):
|
||||||
shareArgs = args[1:]
|
sub, subArgs = args[0], args[1:]
|
||||||
if len(shareArgs) == 0 {
|
|
||||||
shareArgs = []string{} // still sharing: says how
|
|
||||||
}
|
|
||||||
case len(args) > 0 && args[0] == "list":
|
|
||||||
listArgs = append([]string{}, args[1:]...) // not nil: listing
|
|
||||||
case len(args) == 1 && args[0] == "keybindings":
|
case len(args) == 1 && args[0] == "keybindings":
|
||||||
printKeys = true
|
sub = "keybindings"
|
||||||
case len(args) > 0 && args[0] == "get":
|
case len(args) > 0 && args[0] == "get":
|
||||||
r, _, err := refArg("get", args[1:])
|
r, _, err := refArg("get", args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
getRef = &r
|
sub, getRef = "get", r
|
||||||
case len(args) > 0 && args[0] == "handler":
|
case len(args) > 0 && args[0] == "handler":
|
||||||
return handler(*configPath, args[1:])
|
return handler(*configPath, args[1:])
|
||||||
case len(args) > 0 && args[0] == "launch":
|
case len(args) > 0 && args[0] == "launch":
|
||||||
@@ -242,7 +232,7 @@ Flags:
|
|||||||
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
|
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if printKeys {
|
if sub == "keybindings" {
|
||||||
fmt.Print(keymap.YAML())
|
fmt.Print(keymap.YAML())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -270,25 +260,13 @@ Flags:
|
|||||||
}
|
}
|
||||||
// timeoutFor is how long a request to a provider of kind k may take.
|
// 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) }
|
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
|
||||||
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
|
if !set["clipboard-clear"] {
|
||||||
*clipClear = *cfg.Values.ClipboardClear
|
*clipClear = valueOr(cfg.Values.ClipboardClear, *clipClear)
|
||||||
}
|
|
||||||
expiryDays := 30
|
|
||||||
if cfg.UI.ExpiryWarningDays != nil {
|
|
||||||
expiryDays = *cfg.UI.ExpiryWarningDays
|
|
||||||
}
|
|
||||||
revealTimeout := 60 * time.Second
|
|
||||||
if cfg.Values.RevealTimeout != nil {
|
|
||||||
revealTimeout = *cfg.Values.RevealTimeout
|
|
||||||
}
|
|
||||||
loadDelay := 150 * time.Millisecond
|
|
||||||
if cfg.UI.LoadDelay != nil {
|
|
||||||
loadDelay = *cfg.UI.LoadDelay
|
|
||||||
}
|
|
||||||
var updateEvery time.Duration // 0: the app's default, a day
|
|
||||||
if cfg.Updates.AutoCheckInterval != nil {
|
|
||||||
updateEvery = *cfg.Updates.AutoCheckInterval
|
|
||||||
}
|
}
|
||||||
|
expiryWarning := time.Duration(valueOr(cfg.UI.ExpiryWarningDays, 30)) * 24 * time.Hour
|
||||||
|
revealTimeout := valueOr(cfg.Values.RevealTimeout, 60*time.Second)
|
||||||
|
loadDelay := valueOr(cfg.UI.LoadDelay, 150*time.Millisecond)
|
||||||
|
updateEvery := valueOr(cfg.Updates.AutoCheckInterval, 0) // 0: the app's default, a day
|
||||||
if !set["icons"] {
|
if !set["icons"] {
|
||||||
switch {
|
switch {
|
||||||
case os.Getenv("LAZYSECRETS_ICONS") != "":
|
case os.Getenv("LAZYSECRETS_ICONS") != "":
|
||||||
@@ -298,7 +276,17 @@ Flags:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon
|
recentIcon := valueOr(cfg.UI.RecentIcon, false)
|
||||||
|
|
||||||
|
startDir := *dir
|
||||||
|
if startDir == "" {
|
||||||
|
startDir, _ = os.Getwd()
|
||||||
|
}
|
||||||
|
// What was loaded last time.
|
||||||
|
cacheFile := ""
|
||||||
|
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
|
||||||
|
cacheFile = filepath.Join(d, "cache.json")
|
||||||
|
}
|
||||||
|
|
||||||
// Stars, notes and the layout. Demo mode keeps them in memory, so
|
// Stars, notes and the layout. Demo mode keeps them in memory, so
|
||||||
// sample vaults never end up in the real file.
|
// sample vaults never end up in the real file.
|
||||||
@@ -356,17 +344,10 @@ Flags:
|
|||||||
providers = append(providers, dbx)
|
providers = append(providers, dbx)
|
||||||
}
|
}
|
||||||
if config.On(pc.Repos.Enabled) {
|
if config.On(pc.Repos.Enabled) {
|
||||||
start := *dir
|
|
||||||
if start == "" {
|
|
||||||
start, _ = os.Getwd()
|
|
||||||
}
|
|
||||||
roots := repo.DefaultRoots
|
|
||||||
if pc.Repos.Roots != nil {
|
|
||||||
roots = *pc.Repos.Roots
|
|
||||||
}
|
|
||||||
repos := repo.New(repo.Settings{
|
repos := repo.New(repo.Settings{
|
||||||
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
|
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
|
||||||
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
|
Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
|
||||||
|
SecretPatterns: pc.Repos.SecretPatterns, Types: map[string]repo.FileType{"spring": pc.Repos.Spring, "compose": pc.Repos.Compose},
|
||||||
})
|
})
|
||||||
// The repo it started in (shown on top as Repo) and the
|
// The repo it started in (shown on top as Repo) and the
|
||||||
// others, checked for secret files in the background like
|
// others, checked for secret files in the background like
|
||||||
@@ -382,19 +363,14 @@ Flags:
|
|||||||
// the standard logger, which would draw over the TUI.
|
// the standard logger, which would draw over the TUI.
|
||||||
log.SetOutput(io.Discard)
|
log.SetOutput(io.Discard)
|
||||||
|
|
||||||
if getRef != nil {
|
switch sub {
|
||||||
|
case "get":
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
|
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return get(ctx, providers, st, *getRef)
|
return get(ctx, providers, st, getRef)
|
||||||
}
|
case "list":
|
||||||
if listArgs != nil {
|
return listCmd(providers, st, cacheFile, timeoutFor, theme, *icons, recentIcon, expiryWarning, subArgs, os.Stdout)
|
||||||
cachePath := ""
|
case "share":
|
||||||
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
|
|
||||||
cachePath = filepath.Join(d, "cache.json")
|
|
||||||
}
|
|
||||||
return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout)
|
|
||||||
}
|
|
||||||
if shareArgs != nil {
|
|
||||||
// The files from a repo, the key from whichever provider it's
|
// The files from a repo, the key from whichever provider it's
|
||||||
// in: the longest timeout.
|
// in: the longest timeout.
|
||||||
d := *timeout
|
d := *timeout
|
||||||
@@ -403,7 +379,7 @@ Flags:
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), d)
|
ctx, cancel := context.WithTimeout(context.Background(), d)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
|
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), subArgs, os.Stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||||
@@ -437,16 +413,10 @@ Flags:
|
|||||||
onActive = srv.Touch
|
onActive = srv.Touch
|
||||||
}
|
}
|
||||||
|
|
||||||
// What was loaded last time, and where it was left in this folder.
|
// Where it was left in this folder.
|
||||||
var cacheFile, placeDir string
|
placeDir := ""
|
||||||
if !*demo {
|
if !*demo {
|
||||||
if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
|
placeDir = startDir
|
||||||
cacheFile = filepath.Join(d, "cache.json")
|
|
||||||
}
|
|
||||||
placeDir = *dir
|
|
||||||
if placeDir == "" {
|
|
||||||
placeDir, _ = os.Getwd()
|
|
||||||
}
|
|
||||||
if abs, err := filepath.Abs(placeDir); err == nil {
|
if abs, err := filepath.Abs(placeDir); err == nil {
|
||||||
placeDir = abs
|
placeDir = abs
|
||||||
}
|
}
|
||||||
@@ -463,7 +433,7 @@ Flags:
|
|||||||
NoHistory: !config.On(cfg.Storage.History),
|
NoHistory: !config.On(cfg.Storage.History),
|
||||||
OnActive: onActive,
|
OnActive: onActive,
|
||||||
RevealTimeout: revealTimeout,
|
RevealTimeout: revealTimeout,
|
||||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
ExpiryWarning: expiryWarning,
|
||||||
HideOnBlur: config.On(cfg.Values.HideOnBlur),
|
HideOnBlur: config.On(cfg.Values.HideOnBlur),
|
||||||
HandlerSetup: setup,
|
HandlerSetup: setup,
|
||||||
Updater: updater(cfg.Updates.Sources),
|
Updater: updater(cfg.Updates.Sources),
|
||||||
@@ -496,6 +466,14 @@ Flags:
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// valueOr is *p, or def when p is nil (not set in the config).
|
||||||
|
func valueOr[T any](p *T, def T) T {
|
||||||
|
if p == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
// configFiles are the config files given, for the status pane: those
|
// configFiles are the config files given, for the status pane: those
|
||||||
// that exist, or the first if none does (where to create one).
|
// that exist, or the first if none does (where to create one).
|
||||||
func configFiles(list string) []string {
|
func configFiles(list string) []string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,8 +33,12 @@ func updater(servers []string) *app.Updater {
|
|||||||
// updateCmd is lazysecrets update [--check]: the newer release's
|
// updateCmd is lazysecrets update [--check]: the newer release's
|
||||||
// changelog, then (unless --check) install it.
|
// changelog, then (unless --check) install it.
|
||||||
func updateCmd(configPath string, args []string) error {
|
func updateCmd(configPath string, args []string) error {
|
||||||
check := len(args) == 1 && args[0] == "--check"
|
fs := flag.NewFlagSet("update", flag.ContinueOnError)
|
||||||
if len(args) > 0 && !check {
|
check := fs.Bool("check", false, "just say if there's a newer release")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fs.NArg() != 0 {
|
||||||
return errors.New("usage: lazysecrets update [--check]")
|
return errors.New("usage: lazysecrets update [--check]")
|
||||||
}
|
}
|
||||||
cfg, err := config.LoadAll(strings.Split(configPath, ","))
|
cfg, err := config.LoadAll(strings.Split(configPath, ","))
|
||||||
@@ -49,14 +54,14 @@ func updateCmd(configPath string, args []string) error {
|
|||||||
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
||||||
}
|
}
|
||||||
if !update.Newer(current, r.Tag) {
|
if !update.Newer(current, r.Tag) {
|
||||||
if !update.Newer(current, "v999.0.0") {
|
if strings.HasPrefix(current, "dev") {
|
||||||
return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag)
|
return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag)
|
||||||
}
|
}
|
||||||
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
|
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(current)))
|
||||||
if check {
|
if *check {
|
||||||
fmt.Println("lazysecrets update installs it.")
|
fmt.Println("lazysecrets update installs it.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is set by release builds:
|
// version is set by release builds:
|
||||||
@@ -11,13 +10,13 @@ import (
|
|||||||
// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets
|
// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets
|
||||||
var version string
|
var version string
|
||||||
|
|
||||||
// versionString is the release version, or for other builds what Go
|
|
||||||
// recorded: the module version (go install …@v1.2.3), else the commit
|
|
||||||
// (dev-c1a4f90, -dirty with changes not committed).
|
|
||||||
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
|
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
|
||||||
// maybe +dirty): its commit.
|
// maybe +dirty): its commit.
|
||||||
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
|
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
|
||||||
|
|
||||||
|
// versionString is the release version, or for other builds what Go
|
||||||
|
// recorded: the module version (go install …@v1.2.3), else the commit
|
||||||
|
// (dev-c1a4f90, -dirty with changes not committed).
|
||||||
func versionString() string {
|
func versionString() string {
|
||||||
if version != "" {
|
if version != "" {
|
||||||
return version
|
return version
|
||||||
@@ -31,7 +30,7 @@ func versionString() string {
|
|||||||
// go install …@<commit>.
|
// go install …@<commit>.
|
||||||
if m := pseudo.FindStringSubmatch(v); m != nil {
|
if m := pseudo.FindStringSubmatch(v); m != nil {
|
||||||
dirty := ""
|
dirty := ""
|
||||||
if strings.HasSuffix(v, "+dirty") {
|
if m[2] != "" {
|
||||||
dirty = "-dirty"
|
dirty = "-dirty"
|
||||||
}
|
}
|
||||||
return "dev-" + m[1][:7] + dirty
|
return "dev-" + m[1][:7] + dirty
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 113 KiB |
+3
-4
@@ -16,7 +16,7 @@ A service principal is used instead when `AZURE_CLIENT_ID`,
|
|||||||
On an account's group row, the fourth pane shows who it signs in as: name,
|
On an account's group row, the fourth pane shows who it signs in as: name,
|
||||||
user, tenant, object ID, whether the session used MFA, directory roles
|
user, tenant, object ID, whether the session used MFA, directory roles
|
||||||
(built-in ones by name) and its subscriptions, read from az's account list
|
(built-in ones by name) and its subscriptions, read from az's account list
|
||||||
and the token lazysecrets already has: no requests of their own. `y` on
|
and the token lazysecrets already has. `y` on
|
||||||
the row copies the object ID, the one access policies and role
|
the row copies the object ID, the one access policies and role
|
||||||
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
|
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
|
||||||
field, `/` filters them (`/tenant`) and `y` copies the selected one.
|
field, `/` filters them (`/tenant`) and `y` copies the selected one.
|
||||||
@@ -67,9 +67,8 @@ fetched. `v` lists an item's versions. Items expiring within
|
|||||||
|
|
||||||
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`
|
||||||
on one goes to it. That makes no requests of its own: expiry dates come
|
on one goes to it. Expiry dates come with the lists. It says how many
|
||||||
with the lists. It says how many vaults it covers; `ctrl+a` in search
|
vaults it covers; `ctrl+a` in search loads the rest.
|
||||||
loads the rest.
|
|
||||||
|
|
||||||
On a vault, the right pane shows its settings: resource group, location,
|
On a vault, the right pane shows its settings: resource group, location,
|
||||||
SKU, whether access is by Azure roles or access policies, soft delete and
|
SKU, whether access is by Azure roles or access policies, soft delete and
|
||||||
|
|||||||
+22
-37
@@ -1,10 +1,11 @@
|
|||||||
# Config
|
# Config
|
||||||
|
|
||||||
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 (noted
|
||||||
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the
|
below) override it. Unknown keys are an error, so typos don't go
|
||||||
status pane (`0`) opens it in your editor, as in lazygit (making it if
|
unnoticed. `e` on the status pane (`0`) opens it in your editor, as in
|
||||||
it isn't there yet); changes apply when lazysecrets starts again.
|
lazygit (making it if it isn't there yet); changes apply when lazysecrets
|
||||||
|
starts again.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ui:
|
ui:
|
||||||
@@ -17,7 +18,7 @@ ui:
|
|||||||
values:
|
values:
|
||||||
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
||||||
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
||||||
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
|
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables; also --clipboard-clear
|
||||||
storage:
|
storage:
|
||||||
cache: true # keep what was loaded (never values) for the next start
|
cache: true # keep what was loaded (never values) for the next start
|
||||||
history: true # count uses of items for Recent (R)
|
history: true # count uses of items for Recent (R)
|
||||||
@@ -28,32 +29,15 @@ updates:
|
|||||||
- https://github.com/karleinarb/lazysecrets
|
- https://github.com/karleinarb/lazysecrets
|
||||||
- https://git.kebcloud.no/keb/lazysecrets
|
- https://git.kebcloud.no/keb/lazysecrets
|
||||||
providers:
|
providers:
|
||||||
timeout: 30s # per backend request; a provider's own timeout overrides it
|
timeout: 30s # per backend request; a provider's own timeout overrides it; also --timeout
|
||||||
azure: # applies to every az account
|
azure: # likewise databricks and repos; azure covers every az account
|
||||||
enabled: true # default
|
enabled: true # default
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
hideSubscriptions: [platform-mgmt] # by name or ID
|
|
||||||
vaults: # listed under "Pinned vaults", for vaults
|
|
||||||
- https://kv-foo.vault.azure.net/ # you can read but not discover
|
|
||||||
databricks:
|
|
||||||
enabled: true
|
|
||||||
timeout: 30s
|
|
||||||
hideProfiles: [test]
|
|
||||||
repos:
|
|
||||||
enabled: true
|
|
||||||
timeout: 30s
|
|
||||||
roots: [~/git]
|
|
||||||
paths: [~/work/infra]
|
|
||||||
include: ["secrets/**"]
|
|
||||||
exclude: ["**/testdata/**"]
|
|
||||||
sharing: # sharing env files as encrypted links
|
|
||||||
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: [Azure](azure.md#vaults-you-cant-discover)
|
||||||
|
(`providers.azure`), [Databricks](databricks.md) (`providers.databricks`),
|
||||||
|
[repos](repos.md#which-files) (`providers.repos`),
|
||||||
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the
|
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the
|
||||||
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
|
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
|
||||||
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
||||||
@@ -90,6 +74,8 @@ theme:
|
|||||||
azureColor: ["#82aaff"]
|
azureColor: ["#82aaff"]
|
||||||
databricksColor: ["#ff757f"]
|
databricksColor: ["#ff757f"]
|
||||||
repoColor: ["#c099ff"]
|
repoColor: ["#c099ff"]
|
||||||
|
springColor: ["#c3e88d"] # Spring Boot config files
|
||||||
|
composeColor: ["#82aaff"] # Docker Compose files
|
||||||
```
|
```
|
||||||
|
|
||||||
A role lazysecrets doesn't know, a typo or one from a newer version, is
|
A role lazysecrets doesn't know, a typo or one from a newer version, is
|
||||||
@@ -102,9 +88,8 @@ as boxes, turn them off with `ui.icons: false`.
|
|||||||
|
|
||||||
Several config files can be layered, later ones winning (theme roles and
|
Several config files can be layered, later ones winning (theme roles and
|
||||||
keybindings merge one by one), with a comma-separated `--config` or
|
keybindings merge one by one), with a comma-separated `--config` or
|
||||||
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor
|
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your
|
||||||
plugin can so add a generated theme file to your own config, the way
|
config plus an editor-generated theme:
|
||||||
snacks.nvim themes lazygit from Neovim's highlight groups:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
|
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
|
||||||
@@ -117,11 +102,10 @@ Stars, notes, usage and the layout are kept in
|
|||||||
by you: one record per source, vault or secret, keyed by its
|
by you: one record per source, vault or secret, keyed by its
|
||||||
`lazysecrets://` link, never with values. A star belongs to the account you
|
`lazysecrets://` link, never with values. A star belongs to the account you
|
||||||
starred the item under (a vault two of your accounts can see can be starred
|
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.
|
||||||
JSON so an editor plugin can read it with a built-in parser; several
|
Several lazysecrets windows can save to it without losing each other's
|
||||||
lazysecrets windows can save to it without losing each other's changes.
|
changes. One written by a newer lazysecrets is read but not saved to,
|
||||||
One written by a newer lazysecrets is read but not saved to, with a
|
with a warning, until you update.
|
||||||
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,
|
||||||
@@ -157,8 +141,9 @@ until then it reuses the answer from the state file. Releases are
|
|||||||
published on GitHub and on git.kebcloud.no, and it asks them in that
|
published on GitHub and on git.kebcloud.no, and it asks them in that
|
||||||
order, the next when one doesn't answer within 5 seconds;
|
order, the next when one doesn't answer within 5 seconds;
|
||||||
`updates.sources` lists others, such as a mirror of your own. Offline, or with
|
`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 status
|
||||||
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
|
pane, `↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what
|
||||||
|
changed since your version, across the last 10 releases.
|
||||||
|
|
||||||
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
||||||
your OS and CPU from the first server that has it, checks it against
|
your OS and CPU from the first server that has it, checks it against
|
||||||
|
|||||||
+3
-5
@@ -12,7 +12,7 @@ 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
|
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.
|
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 a third smaller:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
|
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
|
||||||
@@ -35,10 +35,8 @@ subject, checked by `pr-title.yml`:
|
|||||||
| `fix` | a bug fix | Fixed |
|
| `fix` | a bug fix | Fixed |
|
||||||
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no |
|
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no |
|
||||||
|
|
||||||
`feat!:` marks a breaking change, `feat(scope):` names a part. Brackets
|
`feat!:` marks a breaking change, `feat(scope):` names a part.
|
||||||
and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all
|
`[skip changelog]` in the description leaves a pull request out.
|
||||||
become `feat: x` in the changelog. `[skip changelog]` in the description
|
|
||||||
leaves a pull request out.
|
|
||||||
|
|
||||||
CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each
|
CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each
|
||||||
platform on pull requests into `main`. `main` is protected: a pull request
|
platform on pull requests into `main`. `main` is protected: a pull request
|
||||||
|
|||||||
+7
-6
@@ -34,7 +34,7 @@ tab-separated:
|
|||||||
| name | `platform-prod / kv-app-prod / db-password` |
|
| name | `platform-prod / kv-app-prod / db-password` |
|
||||||
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
|
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
|
||||||
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
|
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
|
||||||
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
|
| account | what it's listed through: `Azure` (`Azure:<user>/<tenant>` with several az accounts), `Databricks` or `Repos` |
|
||||||
| link | its `lazysecrets://` link |
|
| link | its `lazysecrets://` link |
|
||||||
|
|
||||||
What's cached prints at once, then what loads follows as it arrives: read
|
What's cached prints at once, then what loads follows as it arrives: read
|
||||||
@@ -47,10 +47,11 @@ so `^prod` finds names starting with prod.
|
|||||||
| Flag | |
|
| Flag | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--format json` | an object per line instead, for editor plugins (below) |
|
| `--format json` | an object per line instead, for editor plugins (below) |
|
||||||
| `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded |
|
| `--kind secret,vault` | only these kinds: `source`, `vault` (also scopes and repo files), `secret`, `key`, `certificate`; lists they don't need aren't loaded |
|
||||||
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
|
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
|
||||||
| `--max-age 2m` | load cached lists older than this again |
|
| `--max-age 2m` | load cached lists older than this again |
|
||||||
| `--errors=false` | leave out the vaults and sources it couldn't read |
|
| `--errors=false` | leave out the vaults and sources it couldn't read |
|
||||||
|
| `--hidden` | list what's [hidden](repos.md#spring-boot-config) too, as `H` shows it (json: `"hidden": true`) |
|
||||||
| `--color never` | no colours, for scripts |
|
| `--color never` | no colours, for scripts |
|
||||||
| `--icons=false` | no provider icons |
|
| `--icons=false` | no provider icons |
|
||||||
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
|
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
|
||||||
@@ -77,12 +78,11 @@ may be added:
|
|||||||
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
|
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
|
||||||
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
|
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
|
||||||
|
|
||||||
`lazysecrets open --action go --account <account> <link>` opens one in
|
[`lazysecrets open --action go --account <account> <link>`](sharing.md#opening-a-link)
|
||||||
lazysecrets without asking what to do or which account, ending in the
|
opens one in lazysecrets without asking what to do or which account.
|
||||||
secrets list, like Go to.
|
|
||||||
|
|
||||||
|
### An fzf picker
|
||||||
|
|
||||||
### Example usage with fzf
|
|
||||||
```sh
|
```sh
|
||||||
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
|
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
|
||||||
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
|
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
|
||||||
@@ -90,5 +90,6 @@ lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --
|
|||||||
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
|
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
|
||||||
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
+18
-20
@@ -20,6 +20,7 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
|
|||||||
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
|
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
|
||||||
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
|
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
|
||||||
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
|
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
|
||||||
|
| `H` | show / hide what's hidden: [Spring and compose keys](repos.md#spring-boot-config) that don't look secret, example files, and files and repos with only those; shown dimmed |
|
||||||
| `r` | refresh the focused pane |
|
| `r` | refresh the focused pane |
|
||||||
| `q`, `ctrl+c` | quit |
|
| `q`, `ctrl+c` | quit |
|
||||||
|
|
||||||
@@ -49,11 +50,11 @@ match case exactly.
|
|||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
||||||
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
|
| `y` | copy the value without revealing it. On an Azure account's row: its object ID; in its pane, the selected field |
|
||||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
||||||
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
| `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, `.env`, Spring or compose file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
||||||
| `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
|
| `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
|
||||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||||
| `x` | [custom commands](custom-commands.md) |
|
| `x` | [custom commands](custom-commands.md) |
|
||||||
@@ -63,10 +64,8 @@ match case exactly.
|
|||||||
A revealed value is shown as it reads best: JSON indented, and PEM
|
A revealed value is shown as it reads best: JSON indented, and PEM
|
||||||
certificates as their subject, issuer, dates, names and thumbprint, with
|
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. It hides again after a while, and when the terminal
|
||||||
|
loses focus ([config](config.md)).
|
||||||
A revealed value hides again after `values.revealTimeout` (60s) and
|
|
||||||
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
|
|
||||||
|
|
||||||
## Stars and notes
|
## Stars and notes
|
||||||
|
|
||||||
@@ -75,8 +74,7 @@ when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
|
|||||||
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
|
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
|
||||||
| `n` | write a short note on it, shown dimmed on its row and in the detail pane |
|
| `n` | write a short note on it, shown dimmed on its row and in the detail pane |
|
||||||
|
|
||||||
A star belongs to the account you starred the item under; a note shows
|
Both are kept in the [state file](config.md#state).
|
||||||
under every account. Both are kept in the [state file](config.md#state).
|
|
||||||
|
|
||||||
## Go to
|
## Go to
|
||||||
|
|
||||||
@@ -124,15 +122,16 @@ 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; `e` there opens your [config](config.md) in your
|
||||||
editor, as in lazygit. 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; `y` copies any row:
|
||||||
(`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
|
- the version and updates (`U`)
|
||||||
footer flashed before you moved on; not kept after quitting), the config,
|
- each account (`L` on one that needs it signs in)
|
||||||
state and cache files (`e` opens one in your editor), and links to the
|
- what's expiring (`enter` goes to it)
|
||||||
docs, source, issues, releases and changelog (`o` opens one; on the
|
- this session's errors, newest first (not kept after quitting)
|
||||||
version or update, its release), then the changelog of every release up
|
- the config, state and cache files (`e` opens one in your editor)
|
||||||
to yours, built in so it needs no network: `/` filters it (`/fixed`,
|
- links to the docs, source, issues, releases and changelog (`o` opens one)
|
||||||
`/search`). `y` copies any row.
|
- the changelog of every release up to yours, built in: `/` filters it
|
||||||
|
(`/fixed`, `/search`)
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -141,9 +140,8 @@ to yours, built in so it needs no network: `/` filters it (`/fixed`,
|
|||||||
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
|
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
|
||||||
| `=` | reset the layout (divider, heights, zoom) |
|
| `=` | reset the layout (divider, heights, zoom) |
|
||||||
|
|
||||||
The divider and pane heights are remembered (zoom isn't). macOS uses
|
The divider and pane heights are remembered (zoom isn't). `<>{}` are for
|
||||||
ctrl+arrows for Mission Control and switching desktops by default, hence
|
macOS, where ctrl+arrows switch desktops.
|
||||||
the `<` `>` `{` `}` alternatives.
|
|
||||||
|
|
||||||
## Rebinding keys
|
## Rebinding keys
|
||||||
|
|
||||||
|
|||||||
+100
-21
@@ -1,9 +1,9 @@
|
|||||||
# SOPS and .env files in git repos
|
# SOPS, .env, Spring and compose files in git repos
|
||||||
|
|
||||||
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), plain env files, [Spring Boot config](#spring-boot-config)
|
||||||
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
|
and [Docker Compose files](#docker-compose) as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
|
||||||
escaped, `app\.name`, so it doesn't read as nesting.
|
escaped, `app\.name`, so it doesn't read as nesting.
|
||||||
|
|
||||||
## Which repos
|
## Which repos
|
||||||
@@ -29,8 +29,9 @@ anything by hand.
|
|||||||
## Which files
|
## Which files
|
||||||
|
|
||||||
**Env files**: `.env`, `.env.local`, `prod.env` and the like, also when
|
**Env files**: `.env`, `.env.local`, `prod.env` and the like, also when
|
||||||
git-ignored (as they usually are), in the folders git tracks files in;
|
git-ignored (as they usually are), in the folders git tracks files in.
|
||||||
not `.env.example`, `.sample`, `.template` and the like. They're read
|
`.env.example`, `.sample`, `.template` and the like are listed hidden
|
||||||
|
(`H` shows them): someone may have put a real value in one. They're read
|
||||||
directly; a file with SOPS metadata is always treated as SOPS, whatever
|
directly; a file with SOPS metadata is always treated as SOPS, whatever
|
||||||
its name.
|
its name.
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ Folders of dependencies and caches are never looked in, tracked or not:
|
|||||||
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
|
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
|
||||||
4. the whole repo.
|
4. the whole repo.
|
||||||
|
|
||||||
`include`/`exclude` apply to env files too.
|
`include`/`exclude` apply to env, Spring and compose files too.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
providers:
|
providers:
|
||||||
@@ -65,13 +66,81 @@ sharing:
|
|||||||
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Spring Boot config
|
||||||
|
|
||||||
|
`application.yml`, `application-prod.properties`, `bootstrap.yaml` and the
|
||||||
|
like, tracked by git (not those in a Helm chart's `templates/`). Most of
|
||||||
|
their keys aren't secret, so only those that look like one are listed; the
|
||||||
|
rest are hidden:
|
||||||
|
|
||||||
|
- **keys** named like a secret: ending in `password`, `pwd`, `secret`,
|
||||||
|
`token`, `key`, `credentials`, `salt`, `connection-string` and the
|
||||||
|
like (`spring.datasource.password`, `client-secret`, `checkoutKey`),
|
||||||
|
- **values** that look like one: Jasypt's `ENC(...)`, a URL with a
|
||||||
|
password (`jdbc:postgresql://app:pw@db/app`), or a connection string
|
||||||
|
with `AccountKey=`, `SharedAccessKey=` or `Password=`.
|
||||||
|
|
||||||
|
A file with none is hidden too, and a repo with only such files. `H` shows
|
||||||
|
what's hidden, dimmed, in the panes and in Go to (`lazysecrets list
|
||||||
|
--hidden` lists it); a pane's counter says how many it hides. A file's
|
||||||
|
details count its hidden keys, and `e` opens it whole. A `${DB_PASSWORD}`
|
||||||
|
value is marked as a reference: the secret is set elsewhere. A tracked
|
||||||
|
file is marked in red only when a listed key has its value in plain text.
|
||||||
|
|
||||||
|
The patterns are regexps, for all of a repo's config files and per type of
|
||||||
|
file; each list replaces the one it falls back to, and `keys: [".*"]`
|
||||||
|
lists every key:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
providers:
|
||||||
|
repos:
|
||||||
|
secretPatterns: # all config files
|
||||||
|
keys: ["(?i)(password|secret|token|key)$"] # matched against the key's name
|
||||||
|
values: ["^ENC\\(.+\\)$"] # and against its value
|
||||||
|
spring:
|
||||||
|
secretPatterns: # Spring's: only what differs
|
||||||
|
keys: ["(?i)(password|secret)$", "bearer$"]
|
||||||
|
compose:
|
||||||
|
secretPatterns: # and compose's
|
||||||
|
values: []
|
||||||
|
```
|
||||||
|
|
||||||
|
A repo's `.lazysecrets.yaml` can set them too, the same way
|
||||||
|
(`secretPatterns:`, `spring: secretPatterns:`, `compose:
|
||||||
|
secretPatterns:`), for everyone. Each list
|
||||||
|
comes from the first that sets it: the repo's for the type, the repo's,
|
||||||
|
the config's for the type, the config's, the defaults.
|
||||||
|
|
||||||
|
Keys are named as Spring names them, `spring.datasource.password`; in a
|
||||||
|
file with several documents (`---`, or `#---` in `.properties`), a later
|
||||||
|
document's keys get its profile, `spring.datasource.password (prod)`.
|
||||||
|
Values Maven fills in at build time (`@project.version@`) are read as
|
||||||
|
they are.
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
`compose.yaml`, `docker-compose.yml` and their variants
|
||||||
|
(`docker-compose.test.yml`), tracked by git; `docker-compose.example.yml`
|
||||||
|
and the like are hidden. Their secrets are the services' `environment`
|
||||||
|
variables, as a map (`DB_PASSWORD: x`) or a list (`- DB_PASSWORD=x`),
|
||||||
|
named `service.VAR` (`api.DB_PASSWORD`); a `<<: *anchor` shared between
|
||||||
|
services is read too. The same [secret patterns](#spring-boot-config)
|
||||||
|
pick them, the rest hidden, with `compose: secretPatterns:` for compose
|
||||||
|
files' own.
|
||||||
|
|
||||||
|
`${DB_PASSWORD}`, `$DB_PASSWORD` and `${DB_PASSWORD:?…}`, and a variable
|
||||||
|
without a value (`DB_PASSWORD:`, `- DB_PASSWORD`), taken from where
|
||||||
|
compose runs, are marked as references; `${DB_PASSWORD:-x}` has its
|
||||||
|
default in the file.
|
||||||
|
|
||||||
## Decrypting
|
## Decrypting
|
||||||
|
|
||||||
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and
|
||||||
(encryption, last modified) are read straight from the files. Revealing,
|
metadata (encryption, last modified) are read straight from the files.
|
||||||
copying or `lazysecrets get` decrypts one value with your `sops` binary, so
|
Revealing, copying or `lazysecrets get` decrypts one value; SOPS's with
|
||||||
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
|
your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault
|
||||||
KMS, PGP). When sops can't decrypt, its whole error message is shown.
|
through az, AWS/GCP KMS, PGP). When sops can't decrypt, its whole error
|
||||||
|
message is shown.
|
||||||
|
|
||||||
On a file, the right pane shows how it's encrypted and the keys it's
|
On a file, the right pane shows how it's encrypted and the keys it's
|
||||||
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
|
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
|
||||||
@@ -80,6 +149,24 @@ tracked, ignored or neither, and its last commit. A plain `.env` file that
|
|||||||
git tracks, or that isn't ignored, is marked in red: its values are in the
|
git tracks, or that isn't ignored, is marked in red: its values are in the
|
||||||
repo's history, or easily could be.
|
repo's history, or easily could be.
|
||||||
|
|
||||||
|
### Jasypt
|
||||||
|
|
||||||
|
A value like `ENC(...)` in a plain `.env`, Spring or compose file is
|
||||||
|
[Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied
|
||||||
|
with jasypt-spring-boot's default algorithm (3.0+:
|
||||||
|
`PBEWITHHMACSHA512ANDAES_256`). The password is, as in Spring:
|
||||||
|
|
||||||
|
1. `$JASYPT_ENCRYPTOR_PASSWORD`,
|
||||||
|
2. the file's own: `jasypt.encryptor.password` in a Spring file, the
|
||||||
|
service's `JASYPT_ENCRYPTOR_PASSWORD` in a compose file,
|
||||||
|
`JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` in it (with a
|
||||||
|
default: `${VAR:default}` in Spring, `${VAR:-default}` elsewhere) is
|
||||||
|
read from the environment.
|
||||||
|
|
||||||
|
Environment variables are lazysecrets' own: a link opened from the browser
|
||||||
|
starts it without your shell's. A `.env` whose values are all Jasypt's
|
||||||
|
isn't marked in red.
|
||||||
|
|
||||||
## Opening files
|
## Opening files
|
||||||
|
|
||||||
`e` opens the selected file, or on a secret, its file at that key's line:
|
`e` opens the selected file, or on a secret, its file at that key's line:
|
||||||
@@ -100,13 +187,5 @@ sops 3.8 or later.
|
|||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
`S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files).
|
Repo links name the repo by its git remote, so they open in a colleague's
|
||||||
|
clone wherever it is ([sharing](sharing.md)).
|
||||||
Links name the repo by its git remote, so they open in a colleague's clone
|
|
||||||
wherever it is:
|
|
||||||
|
|
||||||
```
|
|
||||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
|
||||||
```
|
|
||||||
|
|
||||||
See [sharing](sharing.md).
|
|
||||||
+9
-5
@@ -7,10 +7,13 @@ permissions: the link names the item, never its value. Env files can
|
|||||||
also be [shared encrypted](#sharing-env-files), in the link itself.
|
also be [shared encrypted](#sharing-env-files), in the link itself.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
lazysecrets://azure/subscriptions/<subscription-id> subscription
|
||||||
lazysecrets://azure/kv-app-prod.vault.azure.net vault
|
lazysecrets://azure/kv-app-prod.vault.azure.net vault
|
||||||
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab
|
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab
|
||||||
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret
|
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret
|
||||||
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version
|
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version
|
||||||
|
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net workspace
|
||||||
|
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app scope
|
||||||
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
|
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
|
||||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||||
```
|
```
|
||||||
@@ -102,8 +105,8 @@ it now and then: new links use the newest version.
|
|||||||
## Opening a link
|
## Opening a link
|
||||||
|
|
||||||
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
|
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
|
||||||
and fills in any reference it finds there. Key Vault URLs,
|
and fills in any reference it finds there. Key Vault URLs, Azure portal
|
||||||
`@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
|
links, `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
|
||||||
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
|
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
|
||||||
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
|
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
|
||||||
set up.
|
set up.
|
||||||
@@ -143,9 +146,10 @@ turns the question off too.
|
|||||||
link then opens a new window of the terminal you ran it in, with
|
link then opens a new window of the terminal you ran it in, with
|
||||||
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
|
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
|
||||||
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
|
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
|
||||||
tmux; `--terminal NAME` picks another. It records your `PATH` and config
|
tmux; `--terminal NAME` picks another. It records your `PATH`, config
|
||||||
locations, since apps start without your shell's environment (and so
|
locations and XDG directories, since apps start without your shell's
|
||||||
without `az`): run it again after moving lazysecrets or changing those.
|
environment (and so without `az`): run it again after moving lazysecrets
|
||||||
|
or changing those.
|
||||||
|
|
||||||
- macOS: a small background app, `~/Applications/Lazysecrets Links.app`
|
- macOS: a small background app, `~/Applications/Lazysecrets Links.app`
|
||||||
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
|
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ func New() Clipboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native reports whether the system clipboard is used rather than OSC52.
|
|
||||||
func (c Clipboard) Native() bool { return c.native }
|
|
||||||
|
|
||||||
// Write puts s on the clipboard.
|
// Write puts s on the clipboard.
|
||||||
func (c Clipboard) Write(s string) tea.Cmd {
|
func (c Clipboard) Write(s string) tea.Cmd {
|
||||||
if !c.native {
|
if !c.native {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -60,10 +61,7 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|||||||
|
|
||||||
// Validate checks a command from the config.
|
// Validate checks a command from the config.
|
||||||
func (c Command) Validate() error {
|
func (c Command) Validate() error {
|
||||||
name := c.Description
|
name := cmp.Or(c.Description, c.Command)
|
||||||
if name == "" {
|
|
||||||
name = c.Command
|
|
||||||
}
|
|
||||||
switch {
|
switch {
|
||||||
case c.Command == "":
|
case c.Command == "":
|
||||||
return fmt.Errorf("custom command %q has no command", c.Description)
|
return fmt.Errorf("custom command %q has no command", c.Description)
|
||||||
|
|||||||
+17
-11
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +15,7 @@ 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/provider/repo"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||||
)
|
)
|
||||||
@@ -47,7 +49,8 @@ type UI struct {
|
|||||||
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
|
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
|
||||||
// and in lazysecrets list. Defaults to false.
|
// and in lazysecrets list. Defaults to false.
|
||||||
RecentIcon *bool `yaml:"recentIcon"`
|
RecentIcon *bool `yaml:"recentIcon"`
|
||||||
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
|
// SourcesView is "grouped" (default) or "flat"; the backtick key
|
||||||
|
// toggles it.
|
||||||
SourcesView string `yaml:"sourcesView"`
|
SourcesView string `yaml:"sourcesView"`
|
||||||
// CollapseGroups starts the Sources pane with its groups folded;
|
// CollapseGroups starts the Sources pane with its groups folded;
|
||||||
// defaults to true.
|
// defaults to true.
|
||||||
@@ -151,6 +154,11 @@ type Repos struct {
|
|||||||
// a repo's .sops.yaml creation_rules say where to look.
|
// a repo's .sops.yaml creation_rules say where to look.
|
||||||
Include []string `yaml:"include"`
|
Include []string `yaml:"include"`
|
||||||
Exclude []string `yaml:"exclude"`
|
Exclude []string `yaml:"exclude"`
|
||||||
|
// SecretPatterns say which keys of config files are secrets; Spring
|
||||||
|
// and Compose override them for their files, list by list.
|
||||||
|
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
|
||||||
|
Spring repo.FileType `yaml:"spring"`
|
||||||
|
Compose repo.FileType `yaml:"compose"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Azure struct {
|
type Azure struct {
|
||||||
@@ -203,17 +211,12 @@ func xdgDir(env, fallback string) (string, error) {
|
|||||||
return filepath.Join(base, "lazysecrets"), nil
|
return filepath.Join(base, "lazysecrets"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads path. A missing file is an empty config. Unknown keys are an
|
|
||||||
// error, so typos don't silently do nothing.
|
|
||||||
func Load(path string) (Config, error) {
|
|
||||||
return LoadAll([]string{path})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAll reads several config files in order, each on top of the ones
|
// LoadAll reads several config files in order, each on top of the ones
|
||||||
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
|
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
|
||||||
// settings it sets, and theme roles merge one by one. That lets an editor
|
// settings it sets, and theme roles merge one by one. That lets an editor
|
||||||
// plugin add a generated theme file to the user's own config. Missing
|
// plugin add a generated theme file to the user's own config. Missing
|
||||||
// files are skipped.
|
// files are skipped. Unknown keys are an error, so typos don't silently do
|
||||||
|
// nothing.
|
||||||
func LoadAll(paths []string) (Config, error) {
|
func LoadAll(paths []string) (Config, error) {
|
||||||
var c Config
|
var c Config
|
||||||
keybinding := map[string]map[string][]string{}
|
keybinding := map[string]map[string][]string{}
|
||||||
@@ -228,9 +231,7 @@ func LoadAll(paths []string) (Config, error) {
|
|||||||
if keybinding[ctx] == nil {
|
if keybinding[ctx] == nil {
|
||||||
keybinding[ctx] = map[string][]string{}
|
keybinding[ctx] = map[string][]string{}
|
||||||
}
|
}
|
||||||
for name, keys := range actions {
|
maps.Copy(keybinding[ctx], actions)
|
||||||
keybinding[ctx][name] = keys
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.Keybinding = keybinding
|
c.Keybinding = keybinding
|
||||||
@@ -297,6 +298,11 @@ func (c Config) validate(path string) error {
|
|||||||
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
|
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := errors.Join(c.Providers.Repos.SecretPatterns.Check("providers.repos.secretPatterns"),
|
||||||
|
c.Providers.Repos.Spring.SecretPatterns.Check("providers.repos.spring.secretPatterns"),
|
||||||
|
c.Providers.Repos.Compose.SecretPatterns.Check("providers.repos.compose.secretPatterns")); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
for i, v := range c.Providers.Azure.Vaults {
|
for i, v := range c.Providers.Azure.Vaults {
|
||||||
if !strings.HasPrefix(v, "https://") {
|
if !strings.HasPrefix(v, "https://") {
|
||||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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 := LoadAll([]string{write(t, `
|
||||||
ui:
|
ui:
|
||||||
icons: false
|
icons: false
|
||||||
sourcesView: flat
|
sourcesView: flat
|
||||||
@@ -36,7 +36,7 @@ providers:
|
|||||||
databricks:
|
databricks:
|
||||||
enabled: false
|
enabled: false
|
||||||
hideProfiles: [test]
|
hideProfiles: [test]
|
||||||
`))
|
`)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
|
|||||||
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
|
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
|
||||||
"empty": write(t, ""),
|
"empty": write(t, ""),
|
||||||
} {
|
} {
|
||||||
c, err := Load(path)
|
c, err := LoadAll([]string{path})
|
||||||
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
|
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
|
||||||
t.Errorf("%s: %+v, %v", name, c, err)
|
t.Errorf("%s: %+v, %v", name, c, err)
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,10 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
|||||||
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
|
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
|
||||||
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
|
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
|
||||||
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
|
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
|
||||||
|
"regexp": "providers:\n repos:\n secretPatterns:\n keys: [\"(pass\"]\n",
|
||||||
|
"spring re": "providers:\n repos:\n spring:\n secretPatterns:\n values: [\"(\"]\n",
|
||||||
} {
|
} {
|
||||||
if _, err := Load(write(t, content)); err == nil {
|
if _, err := LoadAll([]string{write(t, content)}); err == nil {
|
||||||
t.Errorf("%s: no error", name)
|
t.Errorf("%s: no error", name)
|
||||||
} else if !strings.Contains(err.Error(), "config.yaml") {
|
} else if !strings.Contains(err.Error(), "config.yaml") {
|
||||||
t.Errorf("%s: error doesn't name the file: %v", name, err)
|
t.Errorf("%s: error doesn't name the file: %v", name, err)
|
||||||
@@ -132,18 +134,18 @@ theme:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdates(t *testing.T) {
|
func TestUpdates(t *testing.T) {
|
||||||
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n"))
|
c, err := LoadAll([]string{write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")})
|
||||||
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
|
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
|
||||||
t.Errorf("updates = %+v, %v", c.Updates, err)
|
t.Errorf("updates = %+v, %v", c.Updates, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadDelay(t *testing.T) {
|
func TestLoadDelay(t *testing.T) {
|
||||||
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
|
c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
|
||||||
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
|
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
|
||||||
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
|
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
|
||||||
}
|
}
|
||||||
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
|
if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
|
||||||
t.Error("a minute's delay accepted")
|
t.Error("a minute's delay accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
package instance
|
package instance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -36,15 +35,18 @@ func Dir(stateDir string) string {
|
|||||||
type Status struct {
|
type Status struct {
|
||||||
PID int `json:"pid"`
|
PID int `json:"pid"`
|
||||||
LastActive time.Time `json:"lastActive"` // last key press or focus
|
LastActive time.Time `json:"lastActive"` // last key press or focus
|
||||||
// Reach is how readily it can be shown: 2 in view (outside tmux, or
|
Reach int `json:"reach"` // how readily it can be shown: unreachable, switchable or inView
|
||||||
// in a tmux session a client shows), 1 in a tmux session that a tmux
|
|
||||||
// client can be switched to, 0 not at all.
|
|
||||||
Reach int `json:"reach"`
|
|
||||||
// App is the terminal app it would show in (macOS), preferring the
|
// App is the terminal app it would show in (macOS), preferring the
|
||||||
// app the link was clicked in.
|
// app the link was clicked in.
|
||||||
App string `json:"app,omitempty"`
|
App string `json:"app,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
unreachable = 0
|
||||||
|
switchable = 1 // in a tmux session that a tmux client can be switched to
|
||||||
|
inView = 2 // outside tmux, or in a tmux session a client shows
|
||||||
|
)
|
||||||
|
|
||||||
type request struct {
|
type request struct {
|
||||||
Op string `json:"op"` // "status" or "open"
|
Op string `json:"op"` // "status" or "open"
|
||||||
Link string `json:"link,omitempty"`
|
Link string `json:"link,omitempty"`
|
||||||
@@ -114,7 +116,7 @@ func (s *Server) handle(c net.Conn) {
|
|||||||
defer c.Close()
|
defer c.Close()
|
||||||
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
var req request
|
var req request
|
||||||
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
|
if err := json.NewDecoder(c).Decode(&req); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var resp response
|
var resp response
|
||||||
@@ -188,7 +190,7 @@ func Best(found []Found, front string, terminal bool) (Found, bool) {
|
|||||||
best = f
|
best = f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best, best.Reach > 0
|
return best, best.Reach > unreachable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open hands link, clicked in front, to f.
|
// Open hands link, clicked in front, to f.
|
||||||
|
|||||||
+22
-23
@@ -1,6 +1,7 @@
|
|||||||
package instance
|
package instance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -25,7 +26,8 @@ func tmux(args ...string) (string, error) {
|
|||||||
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
|
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
|
||||||
|
|
||||||
type client struct {
|
type client struct {
|
||||||
name, session, activity string // activity: Unix seconds
|
name, session string
|
||||||
|
activity int // Unix seconds
|
||||||
pid int
|
pid int
|
||||||
app string
|
app string
|
||||||
}
|
}
|
||||||
@@ -42,30 +44,31 @@ func clients() []client {
|
|||||||
if len(f) < 4 {
|
if len(f) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pid, err := strconv.Atoi(f[1])
|
activity, err1 := strconv.Atoi(f[0])
|
||||||
if err != nil {
|
pid, err2 := strconv.Atoi(f[1])
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)})
|
cs = append(cs, client{name: f[2], session: f[3], activity: activity, pid: pid, app: linkhandler.HostApp(pid)})
|
||||||
}
|
}
|
||||||
// Activity has the same width for centuries: strings compare like
|
slices.SortStableFunc(cs, func(a, b client) int { return cmp.Compare(b.activity, a.activity) })
|
||||||
// numbers.
|
|
||||||
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
|
|
||||||
return cs
|
return cs
|
||||||
}
|
}
|
||||||
|
|
||||||
// place is where this instance would show: the tmux client (zero outside
|
// place is where this instance would show: the tmux client (zero outside
|
||||||
// tmux), the app, and reach: 2 already on screen there (outside tmux, or
|
// tmux), the app, and its reach.
|
||||||
// a client on its session), 1 after switching a client, 0 nowhere.
|
|
||||||
func place(front string) (client, string, int) {
|
func place(front string) (client, string, int) {
|
||||||
if !inTmux() {
|
if !inTmux() {
|
||||||
return client{}, linkhandler.HostApp(os.Getpid()), 2
|
return client{}, linkhandler.HostApp(os.Getpid()), inView
|
||||||
}
|
}
|
||||||
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
|
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return client{}, "", 0
|
return client{}, "", unreachable
|
||||||
}
|
}
|
||||||
cs := clients()
|
cs := clients()
|
||||||
|
if len(cs) == 0 {
|
||||||
|
return client{}, "", unreachable
|
||||||
|
}
|
||||||
// Best first: in the front app on its session, in the front app,
|
// Best first: in the front app on its session, in the front app,
|
||||||
// on its session, any.
|
// on its session, any.
|
||||||
rank := func(c client) int {
|
rank := func(c client) int {
|
||||||
@@ -78,20 +81,16 @@ func place(front string) (client, string, int) {
|
|||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
var best client
|
best := cs[0]
|
||||||
found := false
|
for _, c := range cs[1:] {
|
||||||
for _, c := range cs {
|
if rank(c) > rank(best) {
|
||||||
if !found || rank(c) > rank(best) {
|
best = c
|
||||||
best, found = c, true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
|
||||||
return client{}, "", 0
|
|
||||||
}
|
|
||||||
if best.session == session {
|
if best.session == session {
|
||||||
return best, best.app, 2
|
return best, best.app, inView
|
||||||
}
|
}
|
||||||
return best, best.app, 1
|
return best, best.app, switchable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raise brings this instance into view for a link clicked in front: in
|
// Raise brings this instance into view for a link clicked in front: in
|
||||||
@@ -99,14 +98,14 @@ func place(front string) (client, string, int) {
|
|||||||
// then its app to the front, showing a hidden dropdown terminal.
|
// then its app to the front, showing a hidden dropdown terminal.
|
||||||
func Raise(front string) {
|
func Raise(front string) {
|
||||||
c, app, reach := place(front)
|
c, app, reach := place(front)
|
||||||
if reach == 0 {
|
if reach == unreachable {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if inTmux() {
|
if inTmux() {
|
||||||
pane := os.Getenv("TMUX_PANE")
|
pane := os.Getenv("TMUX_PANE")
|
||||||
_, _ = tmux("select-window", "-t", pane)
|
_, _ = tmux("select-window", "-t", pane)
|
||||||
_, _ = tmux("select-pane", "-t", pane)
|
_, _ = tmux("select-pane", "-t", pane)
|
||||||
if reach == 1 {
|
if reach == switchable {
|
||||||
_, _ = tmux("switch-client", "-c", c.name, "-t", pane)
|
_, _ = tmux("switch-client", "-c", c.name, "-t", pane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Package jasypt decrypts values encrypted with Jasypt the way
|
||||||
|
// jasypt-spring-boot 3.0 and later does by default: ENC(...) around the
|
||||||
|
// base64 of a random salt, a random IV and the ciphertext, with
|
||||||
|
// PBEWITHHMACSHA512ANDAES_256 (PBKDF2-HMAC-SHA512, 1000 iterations,
|
||||||
|
// AES-256-CBC).
|
||||||
|
package jasypt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/pbkdf2"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const iterations = 1000
|
||||||
|
|
||||||
|
// ErrDecrypt is a value that doesn't decrypt with the password: a wrong
|
||||||
|
// password, or one encrypted with another algorithm.
|
||||||
|
var ErrDecrypt = errors.New("doesn't decrypt: a wrong password, or not Jasypt's default algorithm (PBEWITHHMACSHA512ANDAES_256)")
|
||||||
|
|
||||||
|
// Is reports whether v is a Jasypt value, ENC(...).
|
||||||
|
func Is(v string) bool {
|
||||||
|
return len(v) > len("ENC()") && strings.HasPrefix(v, "ENC(") && strings.HasSuffix(v, ")")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts an ENC(...) value.
|
||||||
|
func Decrypt(v, password string) (string, error) {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(v, "ENC("), ")"))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("not Jasypt: ENC(...) isn't base64")
|
||||||
|
}
|
||||||
|
const size = aes.BlockSize // the salt's and the IV's too
|
||||||
|
if len(b) < 3*size || len(b)%size != 0 {
|
||||||
|
return "", ErrDecrypt
|
||||||
|
}
|
||||||
|
salt, iv, data := b[:size], b[size:2*size], b[2*size:]
|
||||||
|
key, err := pbkdf2.Key(sha512.New, password, salt, iterations, 32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
plain := make([]byte, len(data))
|
||||||
|
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data)
|
||||||
|
// A wrong password decrypts to noise: its padding is almost never
|
||||||
|
// valid, and the rest rarely UTF-8.
|
||||||
|
n := int(plain[len(plain)-1])
|
||||||
|
if n == 0 || n > size {
|
||||||
|
return "", ErrDecrypt
|
||||||
|
}
|
||||||
|
for _, c := range plain[len(plain)-n:] {
|
||||||
|
if int(c) != n {
|
||||||
|
return "", ErrDecrypt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plain = plain[:len(plain)-n]
|
||||||
|
if !utf8.Valid(plain) {
|
||||||
|
return "", ErrDecrypt
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package jasypt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encrypted by Jasypt 1.9.3 itself, set up as jasypt-spring-boot's default
|
||||||
|
// encryptor, with the password "test-password".
|
||||||
|
var vectors = map[string]string{
|
||||||
|
"ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)": "hunter2",
|
||||||
|
"ENC(w4pm2JVZcbG5R+Ud9uKHg8VhJ/0aAK7sFXtNBv+Vo450SAchTQtfhw0TnIJ9gN+D0VFbwqJzfXU369coTJz2rw==)": "blåbærsyltetøy ✓",
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecrypt(t *testing.T) {
|
||||||
|
for enc, want := range vectors {
|
||||||
|
if !Is(enc) {
|
||||||
|
t.Errorf("%s: not Jasypt", enc)
|
||||||
|
}
|
||||||
|
if got, err := Decrypt(enc, "test-password"); err != nil || got != want {
|
||||||
|
t.Errorf("%s: %q %v, want %q", enc, got, err, want)
|
||||||
|
}
|
||||||
|
if _, err := Decrypt(enc, "wrong"); !errors.Is(err, ErrDecrypt) {
|
||||||
|
t.Errorf("%s with a wrong password: %v", enc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range []string{"ENC(not base64!)", "ENC(aGVsbG8=)"} {
|
||||||
|
if _, err := Decrypt(v, "test-password"); err == nil {
|
||||||
|
t.Errorf("%s: no error", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range []string{"ENC()", "hunter2", "ENC[AES256_GCM,data:x]", "${DB_PASSWORD}"} {
|
||||||
|
if Is(v) {
|
||||||
|
t.Errorf("%s: Jasypt", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,19 +114,15 @@ func Check() string {
|
|||||||
}
|
}
|
||||||
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
|
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "" // installed before the key was recorded: assume it works
|
return "" // can't tell: assume it works
|
||||||
}
|
}
|
||||||
if exe := strings.TrimSpace(string(out)); !exists(exe) {
|
exe := strings.TrimSpace(string(out))
|
||||||
|
if _, err := os.Stat(exe); err != nil {
|
||||||
return "lazysecrets:// links run " + exe + ", which no longer exists"
|
return "lazysecrets:// links run " + exe + ", which no longer exists"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func exists(path string) bool {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uninstall removes the applet and its registration.
|
// Uninstall removes the applet and its registration.
|
||||||
func Uninstall() (string, error) {
|
func Uninstall() (string, error) {
|
||||||
app, err := AppPath()
|
app, err := AppPath()
|
||||||
|
|||||||
@@ -140,11 +140,10 @@ func appleString(s string) string {
|
|||||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch opens a new terminal window running inner, without waiting for
|
// Launch opens a new terminal window running argv, without waiting for
|
||||||
// it.
|
// it.
|
||||||
func Launch(argv []string) error {
|
func Launch(argv []string) error {
|
||||||
cmd := exec.Command(argv[0], argv[1:]...)
|
cmd := exec.Command(argv[0], argv[1:]...)
|
||||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -166,9 +165,11 @@ func Executable() (string, error) {
|
|||||||
|
|
||||||
// passEnv lists the variables carried from the install's environment to
|
// passEnv lists the variables carried from the install's environment to
|
||||||
// lazysecrets in the new window: apps start with a minimal environment,
|
// lazysecrets in the new window: apps start with a minimal environment,
|
||||||
// without Homebrew's PATH (so no az) or the user's config locations.
|
// without Homebrew's PATH (so no az) or the user's XDG directories, so
|
||||||
|
// the window would use another cache and miss running instances' sockets.
|
||||||
var passEnv = []string{
|
var passEnv = []string{
|
||||||
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
|
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS",
|
||||||
|
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
|
||||||
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
|
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ type Entry struct {
|
|||||||
// web page (portal, workspace), if any.
|
// web page (portal, workspace), if any.
|
||||||
Reference string `json:"reference,omitempty"`
|
Reference string `json:"reference,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
|
// Hidden is left out by its provider's rules (a Spring file's key
|
||||||
|
// that doesn't look secret), listed only with Options.Hidden.
|
||||||
|
Hidden bool `json:"hidden,omitempty"`
|
||||||
// Error says why a source's vaults or a vault's secrets couldn't be
|
// Error says why a source's vaults or a vault's secrets couldn't be
|
||||||
// listed. The entry is then that source or vault, again, or with an
|
// listed. The entry is then that source or vault, again, or with an
|
||||||
// account's sources unreadable, the account without a link.
|
// account's sources unreadable, the account without a link.
|
||||||
@@ -70,6 +73,8 @@ type Options struct {
|
|||||||
MaxAge time.Duration
|
MaxAge time.Duration
|
||||||
// Cached lists only what's cached (and repos): nothing is loaded.
|
// Cached lists only what's cached (and repos): nothing is loaded.
|
||||||
Cached bool
|
Cached bool
|
||||||
|
// Hidden lists what providers hide too, as H does in the TUI.
|
||||||
|
Hidden bool
|
||||||
// Kinds are the kinds of entry to list (Kinds' names); none is all.
|
// Kinds are the kinds of entry to list (Kinds' names); none is all.
|
||||||
// Lists only needed for other kinds aren't loaded: sources and vaults
|
// Lists only needed for other kinds aren't loaded: sources and vaults
|
||||||
// alone don't load any secrets. Lists that fail are always listed.
|
// alone don't load any secrets. Lists that fail are always listed.
|
||||||
@@ -180,6 +185,9 @@ func (l *lister) provider(p provider.Provider) {
|
|||||||
|
|
||||||
// source emits src, and what's cached under it, and queues what's not.
|
// source emits src, and what's cached under it, and queues what's not.
|
||||||
func (l *lister) source(p provider.Provider, src provider.Source) {
|
func (l *lister) source(p provider.Provider, src provider.Source) {
|
||||||
|
if src.Hidden && !l.o.Hidden {
|
||||||
|
return
|
||||||
|
}
|
||||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||||
l.put(p, sourceEntry(src))
|
l.put(p, sourceEntry(src))
|
||||||
row := cachefile.Row(p.ID(), src.ID)
|
row := cachefile.Row(p.ID(), src.ID)
|
||||||
@@ -196,6 +204,9 @@ func (l *lister) source(p provider.Provider, src provider.Source) {
|
|||||||
|
|
||||||
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
|
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
|
||||||
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
|
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
|
||||||
|
if c.Hidden && !l.o.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
c.Source = src
|
c.Source = src
|
||||||
l.put(p, containerEntry(c))
|
l.put(p, containerEntry(c))
|
||||||
for _, t := range c.ObjectTypes() {
|
for _, t := range c.ObjectTypes() {
|
||||||
@@ -221,17 +232,17 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
|
|||||||
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
|
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
|
||||||
o.Container = c
|
o.Container = c
|
||||||
r, err := ref.ForObject(o, "")
|
r, err := ref.ForObject(o, "")
|
||||||
if err != nil {
|
if err != nil || (o.Hidden && !l.o.Hidden) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
|
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
|
||||||
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
|
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
|
||||||
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL})
|
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL, Hidden: o.Hidden})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceEntry(src provider.Source) Entry {
|
func sourceEntry(src provider.Source) Entry {
|
||||||
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
|
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL, Hidden: src.Hidden}
|
||||||
if r, err := ref.ForSource(src); err == nil {
|
if r, err := ref.ForSource(src); err == nil {
|
||||||
e.Link = r.String()
|
e.Link = r.String()
|
||||||
}
|
}
|
||||||
@@ -239,7 +250,7 @@ func sourceEntry(src provider.Source) Entry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func containerEntry(c provider.Container) Entry {
|
func containerEntry(c provider.Container) Entry {
|
||||||
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL}
|
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL, Hidden: c.Hidden}
|
||||||
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
||||||
e.Link = r.String()
|
e.Link = r.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,9 +107,6 @@ func parseTerm(tok string) (term, bool) {
|
|||||||
return t, true
|
return t, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty reports whether the query has no terms: everything matches.
|
|
||||||
func (q Query) Empty() bool { return len(q.groups) == 0 }
|
|
||||||
|
|
||||||
// Item is a row to match: Text is everything searched, and runes
|
// Item is a row to match: Text is everything searched, and runes
|
||||||
// NameStart up to NameEnd of it are the row's name (the rest is context,
|
// NameStart up to NameEnd of it are the row's name (the rest is context,
|
||||||
// like a path or a note). Without a name, nothing gets the name bonus.
|
// like a path or a note). Without a name, nothing gets the name bonus.
|
||||||
|
|||||||
@@ -99,9 +99,3 @@ func TestAnchorsOnTheName(t *testing.T) {
|
|||||||
t.Errorf("password$: %v", got)
|
t.Errorf("password$: %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEmpty(t *testing.T) {
|
|
||||||
if !Parse(" | ").Empty() || Parse("x").Empty() {
|
|
||||||
t.Error("Empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||||
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
|
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
|
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
|
||||||
@@ -99,13 +100,7 @@ func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindA
|
|||||||
// Sources lists the subscriptions the signed-in identity can see, skipping
|
// Sources lists the subscriptions the signed-in identity can see, skipping
|
||||||
// disabled and deleted ones.
|
// disabled and deleted ones.
|
||||||
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||||
var out []provider.Source
|
out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []provider.Source) {
|
||||||
pager := p.subs.NewListPager(nil)
|
|
||||||
for pager.More() {
|
|
||||||
page, err := pager.NextPage(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, cleanError(err)
|
|
||||||
}
|
|
||||||
for _, s := range page.Value {
|
for _, s := range page.Value {
|
||||||
switch deref(s.State) {
|
switch deref(s.State) {
|
||||||
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
|
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
|
||||||
@@ -118,7 +113,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
|||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.tenants[id] = tenant
|
p.tenants[id] = tenant
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
out = append(out, provider.Source{
|
srcs = append(srcs, provider.Source{
|
||||||
Kind: provider.KindAzure,
|
Kind: provider.KindAzure,
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: deref(s.DisplayName),
|
Name: deref(s.DisplayName),
|
||||||
@@ -126,6 +121,10 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
|||||||
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
|
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return srcs
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||||
if len(p.opts.Vaults) > 0 {
|
if len(p.opts.Vaults) > 0 {
|
||||||
@@ -230,22 +229,29 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var out []provider.Container
|
return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) {
|
||||||
pager := client.NewListBySubscriptionPager(nil)
|
for _, v := range page.Value {
|
||||||
|
if v.Properties == nil || v.Properties.VaultURI == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vaults = append(vaults, provider.Container{
|
||||||
|
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||||
|
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return vaults
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// all lists every page, items picking out what to keep of each.
|
||||||
|
func all[P, T any](ctx context.Context, pager *runtime.Pager[P], items func(P) []T) ([]T, error) {
|
||||||
|
var out []T
|
||||||
for pager.More() {
|
for pager.More() {
|
||||||
page, err := pager.NextPage(ctx)
|
page, err := pager.NextPage(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cleanError(err)
|
return nil, cleanError(err)
|
||||||
}
|
}
|
||||||
for _, v := range page.Value {
|
out = append(out, items(page)...)
|
||||||
if v.Properties == nil || v.Properties.VaultURI == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, provider.Container{
|
|
||||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
|
||||||
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -288,51 +294,40 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
|
|||||||
var out []provider.Object
|
var out []provider.Object
|
||||||
switch t {
|
switch t {
|
||||||
case provider.TypeSecret:
|
case provider.TypeSecret:
|
||||||
pager := vc.secrets.NewListSecretPropertiesPager(nil)
|
tenant := p.tenant(c.Source.ID)
|
||||||
for pager.More() {
|
out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) {
|
||||||
page, err := pager.NextPage(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, cleanError(err)
|
|
||||||
}
|
|
||||||
for _, s := range page.Value {
|
for _, s := range page.Value {
|
||||||
// Certificate-backing secrets appear under Certificates.
|
// Certificate-backing secrets appear under Certificates.
|
||||||
if s.ID == nil || deref(s.Managed) {
|
if s.ID != nil && !deref(s.Managed) {
|
||||||
continue
|
objs = append(objs, secretObject(c, s, tenant))
|
||||||
}
|
|
||||||
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return objs
|
||||||
|
})
|
||||||
case provider.TypeKey:
|
case provider.TypeKey:
|
||||||
pager := vc.keys.NewListKeyPropertiesPager(nil)
|
out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) {
|
||||||
for pager.More() {
|
|
||||||
page, err := pager.NextPage(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, cleanError(err)
|
|
||||||
}
|
|
||||||
for _, k := range page.Value {
|
for _, k := range page.Value {
|
||||||
if k.KID == nil || deref(k.Managed) {
|
if k.KID != nil && !deref(k.Managed) {
|
||||||
continue
|
objs = append(objs, keyObject(c, k))
|
||||||
}
|
|
||||||
out = append(out, keyObject(c, k))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return objs
|
||||||
|
})
|
||||||
case provider.TypeCertificate:
|
case provider.TypeCertificate:
|
||||||
pager := vc.certs.NewListCertificatePropertiesPager(nil)
|
out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) {
|
||||||
for pager.More() {
|
|
||||||
page, err := pager.NextPage(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, cleanError(err)
|
|
||||||
}
|
|
||||||
for _, cp := range page.Value {
|
for _, cp := range page.Value {
|
||||||
if cp.ID == nil {
|
if cp.ID != nil {
|
||||||
continue
|
objs = append(objs, certObject(c, cp))
|
||||||
}
|
|
||||||
out = append(out, certObject(c, cp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return objs
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
return nil, provider.ErrUnsupported
|
return nil, provider.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -353,7 +348,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
|
|||||||
if resp.Key != nil {
|
if resp.Key != nil {
|
||||||
if resp.Key.KID != nil {
|
if resp.Key.KID != nil {
|
||||||
o.Version = resp.Key.KID.Version()
|
o.Version = resp.Key.KID.Version()
|
||||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
|
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID))
|
||||||
}
|
}
|
||||||
o.Attrs = keyAttrs(resp.Key)
|
o.Attrs = keyAttrs(resp.Key)
|
||||||
}
|
}
|
||||||
@@ -364,7 +359,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
|
|||||||
}
|
}
|
||||||
if resp.ID != nil {
|
if resp.ID != nil {
|
||||||
o.Version = resp.ID.Version()
|
o.Version = resp.ID.Version()
|
||||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
|
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.ID))
|
||||||
}
|
}
|
||||||
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
|
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
|
||||||
o.ContentType = deref(pol.SecretProperties.ContentType)
|
o.ContentType = deref(pol.SecretProperties.ContentType)
|
||||||
@@ -381,56 +376,46 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
tenant := p.tenant(o.Container.Source.ID)
|
||||||
var out []provider.Object
|
var out []provider.Object
|
||||||
switch o.Type {
|
switch o.Type {
|
||||||
case provider.TypeSecret:
|
case provider.TypeSecret:
|
||||||
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
|
out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (objs []provider.Object) {
|
||||||
for pager.More() {
|
|
||||||
page, err := pager.NextPage(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, cleanError(err)
|
|
||||||
}
|
|
||||||
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)))
|
objs = append(objs, secretObject(o.Container, s, tenant))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return objs
|
||||||
|
})
|
||||||
case provider.TypeKey:
|
case provider.TypeKey:
|
||||||
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
|
out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) {
|
||||||
for pager.More() {
|
|
||||||
page, err := pager.NextPage(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, cleanError(err)
|
|
||||||
}
|
|
||||||
for _, k := range page.Value {
|
for _, k := range page.Value {
|
||||||
if k.KID != nil {
|
if k.KID != nil {
|
||||||
out = append(out, keyObject(o.Container, k))
|
objs = append(objs, keyObject(o.Container, k))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return objs
|
||||||
|
})
|
||||||
case provider.TypeCertificate:
|
case provider.TypeCertificate:
|
||||||
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
|
out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) {
|
||||||
for pager.More() {
|
|
||||||
page, err := pager.NextPage(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, cleanError(err)
|
|
||||||
}
|
|
||||||
for _, cp := range page.Value {
|
for _, cp := range page.Value {
|
||||||
if cp.ID != nil {
|
if cp.ID != nil {
|
||||||
out = append(out, certObject(o.Container, cp))
|
objs = append(objs, certObject(o.Container, cp))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return objs
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
return nil, provider.ErrUnsupported
|
return nil, provider.ErrUnsupported
|
||||||
}
|
}
|
||||||
tenant := p.tenant(o.Container.Source.ID)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
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
|
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, o.Type, out[i].ID+"/"+out[i].Version) // the version's page
|
||||||
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) })
|
||||||
@@ -452,47 +437,19 @@ func versionedReference(o provider.Object) string {
|
|||||||
// Value returns a secret's value, a key's public key or a certificate's
|
// Value returns a secret's value, a key's public key or a certificate's
|
||||||
// public certificate, as of o.Version (latest if empty).
|
// public certificate, as of o.Version (latest if empty).
|
||||||
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) {
|
||||||
vc, err := p.clients(o.Container.Detail)
|
|
||||||
if err != nil {
|
|
||||||
return provider.SecretValue{}, err
|
|
||||||
}
|
|
||||||
switch o.Type {
|
|
||||||
case provider.TypeSecret:
|
|
||||||
v, _, err := p.VersionedValue(ctx, o)
|
v, _, err := p.VersionedValue(ctx, o)
|
||||||
return v, err
|
return v, err
|
||||||
case provider.TypeKey:
|
|
||||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
|
||||||
if err != nil {
|
|
||||||
return provider.SecretValue{}, cleanError(err)
|
|
||||||
}
|
|
||||||
pemKey, err := publicKeyPEM(resp.Key)
|
|
||||||
if err != nil {
|
|
||||||
return provider.SecretValue{}, err
|
|
||||||
}
|
|
||||||
return provider.NewSecretValue([]byte(pemKey)), nil
|
|
||||||
case provider.TypeCertificate:
|
|
||||||
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
|
|
||||||
if err != nil {
|
|
||||||
return provider.SecretValue{}, cleanError(err)
|
|
||||||
}
|
|
||||||
if len(resp.CER) == 0 {
|
|
||||||
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
|
|
||||||
}
|
|
||||||
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
|
|
||||||
}
|
|
||||||
return provider.SecretValue{}, provider.ErrUnsupported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VersionedValue is Value, with the version read.
|
// VersionedValue is Value, with the version read.
|
||||||
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
|
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)
|
vc, err := p.clients(o.Container.Detail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return provider.SecretValue{}, "", err
|
return provider.SecretValue{}, "", err
|
||||||
}
|
}
|
||||||
|
var text string
|
||||||
|
switch o.Type {
|
||||||
|
case provider.TypeSecret:
|
||||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return provider.SecretValue{}, "", cleanError(err)
|
return provider.SecretValue{}, "", cleanError(err)
|
||||||
@@ -502,6 +459,27 @@ func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provi
|
|||||||
version = resp.ID.Version()
|
version = resp.ID.Version()
|
||||||
}
|
}
|
||||||
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
|
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
|
||||||
|
case provider.TypeKey:
|
||||||
|
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||||
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, "", cleanError(err)
|
||||||
|
}
|
||||||
|
if text, err = publicKeyPEM(resp.Key); err != nil {
|
||||||
|
return provider.SecretValue{}, "", err
|
||||||
|
}
|
||||||
|
case provider.TypeCertificate:
|
||||||
|
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
|
||||||
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, "", cleanError(err)
|
||||||
|
}
|
||||||
|
if len(resp.CER) == 0 {
|
||||||
|
return provider.SecretValue{}, "", errors.New("certificate has no public certificate yet (pending issuance?)")
|
||||||
|
}
|
||||||
|
text = certPEM(resp.CER)
|
||||||
|
default:
|
||||||
|
return provider.SecretValue{}, "", provider.ErrUnsupported
|
||||||
|
}
|
||||||
|
return provider.NewSecretValue([]byte(text)), o.Version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
|
|||||||
|
|
||||||
// portalAssetURL links to a Key Vault object's page: a version's with
|
// portalAssetURL links to a Key Vault object's page: a version's with
|
||||||
// one in objectID; a secret's own (its versions) without.
|
// one in objectID; a secret's own (its versions) without.
|
||||||
func portalAssetURL(tenant, kind, objectID string) string {
|
func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string {
|
||||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
|
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID
|
||||||
}
|
}
|
||||||
|
|
||||||
func tenantPrefix(tenant string) string {
|
func tenantPrefix(tenant string) string {
|
||||||
@@ -73,19 +73,13 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
switch t {
|
return base + "/" + t.Collection()
|
||||||
case provider.TypeKey:
|
|
||||||
return base + "/keys"
|
|
||||||
case provider.TypeCertificate:
|
|
||||||
return base + "/certificates"
|
|
||||||
}
|
|
||||||
return base + "/secrets"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// objectURL is the data-plane URL of an object, e.g.
|
// objectURL is the data-plane URL of an object, e.g.
|
||||||
// https://kv.vault.azure.net/secrets/name.
|
// https://kv.vault.azure.net/secrets/name.
|
||||||
func objectURL(c provider.Container, collection, name string) string {
|
func objectURL(c provider.Container, t provider.ObjectType, name string) string {
|
||||||
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
|
return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name
|
||||||
}
|
}
|
||||||
|
|
||||||
// secretObject is a secret, or a version of one, from a listing. Its URL
|
// secretObject is a secret, or a version of one, from a listing. Its URL
|
||||||
@@ -93,7 +87,7 @@ func objectURL(c provider.Container, collection, name string) string {
|
|||||||
// a version, that version's.
|
// a version, that version's.
|
||||||
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
|
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, provider.TypeSecret, name)
|
||||||
o := provider.Object{
|
o := provider.Object{
|
||||||
Container: c,
|
Container: c,
|
||||||
Type: provider.TypeSecret,
|
Type: provider.TypeSecret,
|
||||||
@@ -103,7 +97,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: portalAssetURL(tenant, provider.TypeSecret, strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -113,7 +107,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
|
|||||||
|
|
||||||
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
|
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
|
||||||
name := p.KID.Name()
|
name := p.KID.Name()
|
||||||
url := objectURL(c, "keys", name)
|
url := objectURL(c, provider.TypeKey, name)
|
||||||
o := provider.Object{
|
o := provider.Object{
|
||||||
Container: c,
|
Container: c,
|
||||||
Type: provider.TypeKey,
|
Type: provider.TypeKey,
|
||||||
@@ -132,7 +126,7 @@ func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
|
|||||||
|
|
||||||
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
|
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
|
||||||
name := p.ID.Name()
|
name := p.ID.Name()
|
||||||
url := objectURL(c, "certificates", name)
|
url := objectURL(c, provider.TypeCertificate, name)
|
||||||
o := provider.Object{
|
o := provider.Object{
|
||||||
Container: c,
|
Container: c,
|
||||||
Type: provider.TypeCertificate,
|
Type: provider.TypeCertificate,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package azure
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -70,13 +71,10 @@ func graphInfo(m map[string]any) vaultInfo {
|
|||||||
|
|
||||||
// armInfo reads a vault's settings from the management API.
|
// armInfo reads a vault's settings from the management API.
|
||||||
func armInfo(v *armkeyvault.Vault) vaultInfo {
|
func armInfo(v *armkeyvault.Vault) vaultInfo {
|
||||||
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
|
info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)}
|
||||||
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
|
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
|
||||||
info.group = id.ResourceGroupName
|
info.group = id.ResourceGroupName
|
||||||
}
|
}
|
||||||
for k, t := range v.Tags {
|
|
||||||
info.tags[k] = deref(t)
|
|
||||||
}
|
|
||||||
pr := v.Properties
|
pr := v.Properties
|
||||||
if pr == nil {
|
if pr == nil {
|
||||||
return info
|
return info
|
||||||
@@ -122,7 +120,11 @@ func (v vaultInfo) attrs() []provider.Attr {
|
|||||||
} else {
|
} else {
|
||||||
add("soft delete", "off")
|
add("soft delete", "off")
|
||||||
}
|
}
|
||||||
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
|
if on(v.purge, false) {
|
||||||
|
add("purge prot.", "on")
|
||||||
|
} else {
|
||||||
|
add("purge prot.", "off")
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case strings.EqualFold(v.publicAccess, "disabled"):
|
case strings.EqualFold(v.publicAccess, "disabled"):
|
||||||
add("network", "private endpoints only")
|
add("network", "private endpoints only")
|
||||||
@@ -131,15 +133,13 @@ func (v vaultInfo) attrs() []provider.Attr {
|
|||||||
default:
|
default:
|
||||||
add("network", "all networks")
|
add("network", "all networks")
|
||||||
}
|
}
|
||||||
if v.endpoints > 0 {
|
switch {
|
||||||
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
|
case v.endpoints == 1:
|
||||||
|
add("private", "1 endpoint")
|
||||||
|
case v.endpoints > 1:
|
||||||
|
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
|
||||||
}
|
}
|
||||||
keys := make([]string, 0, len(v.tags))
|
for i, k := range slices.Sorted(maps.Keys(v.tags)) {
|
||||||
for k := range v.tags {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
slices.Sort(keys)
|
|
||||||
for i, k := range keys {
|
|
||||||
label := ""
|
label := ""
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
label = "tags"
|
label = "tags"
|
||||||
|
|||||||
@@ -203,17 +203,20 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
|
// fallbackVault is the backing vault of an AKV-backed scope, to read it
|
||||||
// back to the scope's Detail, so a scope remembered from an earlier session
|
// from when Databricks fails, or "" when there's none to fall back to. It
|
||||||
// (a favourite) works before its workspace's scopes are listed.
|
// falls back to the scope's Detail, so a scope remembered from an earlier
|
||||||
func (p *Provider) vaultURL(c provider.Container) string {
|
// session (a favourite) works before its workspace's scopes are listed.
|
||||||
|
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
|
||||||
|
if p.keyVault == nil || ctx.Err() != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
url := p.vaults[c.ID]
|
url := p.vaults[c.ID]
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if url == "" {
|
if url == "" {
|
||||||
url, _ = strings.CutPrefix(c.Detail, akvDetail)
|
if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok {
|
||||||
if url == c.Detail {
|
url = detail
|
||||||
url = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return url
|
return url
|
||||||
@@ -264,8 +267,8 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
|
|||||||
|
|
||||||
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
|
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
|
||||||
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
|
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
|
||||||
url := p.vaultURL(c)
|
url := p.fallbackVault(ctx, c)
|
||||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
if url == "" {
|
||||||
return nil, errNoFallback
|
return nil, errNoFallback
|
||||||
}
|
}
|
||||||
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
|
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
|
||||||
@@ -302,8 +305,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
}
|
}
|
||||||
dbxErr := classify(pr, err)
|
dbxErr := classify(pr, err)
|
||||||
|
|
||||||
url := p.vaultURL(o.Container)
|
url := p.fallbackVault(ctx, o.Container)
|
||||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
if url == "" {
|
||||||
return provider.SecretValue{}, dbxErr
|
return provider.SecretValue{}, dbxErr
|
||||||
}
|
}
|
||||||
v, kvErr := p.keyVault.Value(ctx, provider.Object{
|
v, kvErr := p.keyVault.Value(ctx, provider.Object{
|
||||||
|
|||||||
@@ -48,19 +48,8 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
|
|||||||
|
|
||||||
func (p *Provider) Kind() provider.Kind { return p.kind }
|
func (p *Provider) Kind() provider.Kind { return p.kind }
|
||||||
|
|
||||||
func (p *Provider) ID() string {
|
func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
|
||||||
if p.id != "" {
|
func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
|
||||||
return p.id
|
|
||||||
}
|
|
||||||
return string(p.kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Provider) Label() string {
|
|
||||||
if p.label != "" {
|
|
||||||
return p.label
|
|
||||||
}
|
|
||||||
return string(p.kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
// As renames the provider, to simulate several accounts of one kind.
|
// As renames the provider, to simulate several accounts of one kind.
|
||||||
func (p *Provider) As(id, label string) *Provider {
|
func (p *Provider) As(id, label string) *Provider {
|
||||||
@@ -236,9 +225,7 @@ func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.O
|
|||||||
o.Type = t
|
o.Type = t
|
||||||
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
|
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
|
||||||
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
|
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
|
||||||
o.URL = base + map[provider.ObjectType]string{
|
o.URL = base + "/" + t.Collection()
|
||||||
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
|
|
||||||
}[t]
|
|
||||||
}
|
}
|
||||||
k := listKey{c.ID, t}
|
k := listKey{c.ID, t}
|
||||||
p.objects[k] = append(p.objects[k], o)
|
p.objects[k] = append(p.objects[k], o)
|
||||||
@@ -252,11 +239,7 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
||||||
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
|
||||||
yes = true
|
|
||||||
no = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrForbidden mimics a data-plane permission error from a backend.
|
// ErrForbidden mimics a data-plane permission error from a backend.
|
||||||
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
|
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
|
||||||
@@ -271,12 +254,13 @@ const (
|
|||||||
|
|
||||||
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
||||||
|
|
||||||
|
func secretRef(vault, name string) string {
|
||||||
|
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||||
|
}
|
||||||
|
|
||||||
// Azure returns a fake Azure provider with two subscriptions.
|
// Azure returns a fake Azure provider with two subscriptions.
|
||||||
func Azure(delay time.Duration) *Provider {
|
func Azure(delay time.Duration) *Provider {
|
||||||
p := newProvider(provider.KindAzure, delay)
|
p := newProvider(provider.KindAzure, delay)
|
||||||
secretRef := func(vault, name string) string {
|
|
||||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
|
||||||
}
|
|
||||||
objectURL := func(vault, kind, name string) string {
|
objectURL := func(vault, kind, name string) string {
|
||||||
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
|
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
|
||||||
}
|
}
|
||||||
@@ -284,32 +268,32 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||||
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
|
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
|
||||||
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
|
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
|
Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
|
||||||
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
|
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
|
||||||
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
|
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
|
||||||
}, "correct-horse-battery-staple")
|
}, "correct-horse-battery-staple")
|
||||||
p.versions[dbPassword.ID] = []provider.Object{
|
p.versions[dbPassword.ID] = []provider.Object{
|
||||||
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
{Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
||||||
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
{Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
||||||
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
|
{Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
|
||||||
}
|
}
|
||||||
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
|
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
|
||||||
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
|
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
|
||||||
Reference: secretRef("kv-app-prod", "api-key"),
|
Reference: secretRef("kv-app-prod", "api-key"),
|
||||||
}, "sk_live_51HxDEMO0000000000000000")
|
}, "sk_live_51HxDEMO0000000000000000")
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
|
||||||
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
|
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||||
}, "deprecated")
|
}, "deprecated")
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
|
Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0),
|
||||||
Reference: secretRef("kv-app-prod", "smtp-password"),
|
Reference: secretRef("kv-app-prod", "smtp-password"),
|
||||||
}, "smtp-demo-password")
|
}, "smtp-demo-password")
|
||||||
|
|
||||||
p.add(kv, provider.TypeKey, provider.Object{
|
p.add(kv, provider.TypeKey, provider.Object{
|
||||||
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
|
||||||
Attrs: []provider.Attr{
|
Attrs: []provider.Attr{
|
||||||
{Label: "key type", Value: "RSA 2048"},
|
{Label: "key type", Value: "RSA 2048"},
|
||||||
{Label: "ops", Value: "sign, verify"},
|
{Label: "ops", Value: "sign, verify"},
|
||||||
@@ -317,7 +301,7 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
|
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
|
||||||
}, demoPublicKey)
|
}, demoPublicKey)
|
||||||
p.add(kv, provider.TypeKey, provider.Object{
|
p.add(kv, provider.TypeKey, provider.Object{
|
||||||
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
|
Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
|
||||||
Attrs: []provider.Attr{
|
Attrs: []provider.Attr{
|
||||||
{Label: "key type", Value: "EC P-256"},
|
{Label: "key type", Value: "EC P-256"},
|
||||||
{Label: "ops", Value: "wrapKey, unwrapKey"},
|
{Label: "ops", Value: "wrapKey, unwrapKey"},
|
||||||
@@ -326,7 +310,7 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
}, demoPublicKey)
|
}, demoPublicKey)
|
||||||
|
|
||||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||||
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
|
Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
|
||||||
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
|
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
|
||||||
Attrs: []provider.Attr{
|
Attrs: []provider.Attr{
|
||||||
{Label: "subject", Value: "CN=app.example.com"},
|
{Label: "subject", Value: "CN=app.example.com"},
|
||||||
@@ -338,7 +322,7 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
|
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
|
||||||
}, demoCert)
|
}, demoCert)
|
||||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||||
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
|
||||||
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
|
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
|
||||||
Attrs: []provider.Attr{
|
Attrs: []provider.Attr{
|
||||||
{Label: "subject", Value: "CN=old.example.com"},
|
{Label: "subject", Value: "CN=old.example.com"},
|
||||||
@@ -351,10 +335,10 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
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.
|
// A key to share env files with (S → f): random, as sharing.keyRules wants.
|
||||||
p.add(shared, provider.TypeSecret, provider.Object{
|
p.add(shared, provider.TypeSecret, provider.Object{
|
||||||
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
|
Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
|
||||||
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
|
}, "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: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||||
}, "smtp-demo-password")
|
}, "smtp-demo-password")
|
||||||
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
|
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
|
||||||
|
|
||||||
@@ -364,19 +348,19 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
|
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
|
||||||
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
|
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
|
||||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
||||||
}, "dev-password")
|
}, "dev-password")
|
||||||
// Left behind, as dev vaults are: expired, disabled, or both.
|
// Left behind, as dev vaults are: expired, disabled, or both.
|
||||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
|
||||||
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
|
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
|
||||||
}, "whsec_demo_expired")
|
}, "whsec_demo_expired")
|
||||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0),
|
Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0),
|
||||||
Reference: secretRef("kv-app-dev", "test-user-password"),
|
Reference: secretRef("kv-app-dev", "test-user-password"),
|
||||||
}, "disabled-demo-password")
|
}, "disabled-demo-password")
|
||||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0),
|
Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0),
|
||||||
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
|
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
|
||||||
}, "expired-and-disabled")
|
}, "expired-and-disabled")
|
||||||
|
|
||||||
@@ -388,24 +372,21 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
// its own, like an admin account next to a personal one.
|
// its own, like an admin account next to a personal one.
|
||||||
func AzureOps(delay time.Duration) *Provider {
|
func AzureOps(delay time.Duration) *Provider {
|
||||||
p := newProvider(provider.KindAzure, delay)
|
p := newProvider(provider.KindAzure, delay)
|
||||||
secretRef := func(vault, name string) string {
|
|
||||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||||
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
||||||
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
||||||
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0),
|
||||||
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
|
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
|
||||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||||
}, "break-glass-demo-password")
|
}, "break-glass-demo-password")
|
||||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||||
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: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||||
}, "smtp-demo-password")
|
}, "smtp-demo-password")
|
||||||
// Unlike the first account, ops may read keys here.
|
// Unlike the first account, ops may read keys here.
|
||||||
p.add(shared, provider.TypeKey, provider.Object{
|
p.add(shared, provider.TypeKey, provider.Object{
|
||||||
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
|
||||||
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
|
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
|
||||||
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
|
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
|
||||||
}, demoPublicKey)
|
}, demoPublicKey)
|
||||||
@@ -413,12 +394,12 @@ func AzureOps(delay time.Duration) *Provider {
|
|||||||
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
|
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
|
||||||
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
|
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
||||||
}, "pd-demo-token")
|
}, "pd-demo-token")
|
||||||
// Expiring soon whenever the demo runs: the other dates are fixed,
|
// Expiring soon whenever the demo runs: the other dates are fixed,
|
||||||
// and would have passed. Tests use the first account only.
|
// and would have passed. Tests use the first account only.
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0),
|
Name: "grafana-api-key", Enabled: new(true), Updated: base.AddDate(0, -11, 0),
|
||||||
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
|
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
|
||||||
}, "glsa_demo_expiring")
|
}, "glsa_demo_expiring")
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ type Source struct {
|
|||||||
// Here marks the source lazysecrets was started in (the current git
|
// Here marks the source lazysecrets was started in (the current git
|
||||||
// repo): listed first, starred or not, and labelled.
|
// repo): listed first, starred or not, and labelled.
|
||||||
Here bool
|
Here bool
|
||||||
|
// Hidden marks a source, container or object the provider's rules
|
||||||
|
// left out (a Spring file with no secret-looking keys): listed only
|
||||||
|
// while the app shows hidden ones.
|
||||||
|
Hidden bool `json:",omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObjectType is the kind of object stored in a container.
|
// ObjectType is the kind of object stored in a container.
|
||||||
@@ -84,6 +88,20 @@ const (
|
|||||||
TypeCertificate ObjectType = "Certificate"
|
TypeCertificate ObjectType = "Certificate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Collection is the type's path segment in Key Vault URLs and links, e.g.
|
||||||
|
// "secrets".
|
||||||
|
func (t ObjectType) Collection() string {
|
||||||
|
switch t {
|
||||||
|
case TypeSecret:
|
||||||
|
return "secrets"
|
||||||
|
case TypeKey:
|
||||||
|
return "keys"
|
||||||
|
case TypeCertificate:
|
||||||
|
return "certificates"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Container holds objects: a Key Vault or a Databricks secret scope.
|
// Container holds objects: a Key Vault or a Databricks secret scope.
|
||||||
type Container struct {
|
type Container struct {
|
||||||
Source Source
|
Source Source
|
||||||
@@ -96,6 +114,7 @@ type Container struct {
|
|||||||
URL string // web page for the container, if any
|
URL string // web page for the container, if any
|
||||||
// Attrs describe it further, from the listing (a vault's settings).
|
// Attrs describe it further, from the listing (a vault's settings).
|
||||||
Attrs []Attr `json:",omitempty"`
|
Attrs []Attr `json:",omitempty"`
|
||||||
|
Hidden bool `json:",omitempty"` // see Source.Hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObjectTypes returns the container's object types, defaulting to secrets.
|
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||||
@@ -119,6 +138,8 @@ type Object struct {
|
|||||||
Type ObjectType
|
Type ObjectType
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
|
Detail string // shown after the name, e.g. "jasypt" for how its value is kept
|
||||||
|
Hidden bool `json:",omitempty"` // see Source.Hidden
|
||||||
Version string
|
Version string
|
||||||
Enabled *bool
|
Enabled *bool
|
||||||
Updated time.Time
|
Updated time.Time
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Docker Compose files: compose.yaml, docker-compose.yml and their
|
||||||
|
// variants (docker-compose.test.yml). Their keys are the services'
|
||||||
|
// environment variables, named service.VAR.
|
||||||
|
|
||||||
|
var composeType = configType{
|
||||||
|
name: "compose", is: IsCompose, format: func(string) string { return "yaml" }, parse: parseCompose,
|
||||||
|
jasyptSetting: func(name string) string {
|
||||||
|
service, _, _ := strings.Cut(name, ".")
|
||||||
|
return service + "." + jasyptEnv
|
||||||
|
},
|
||||||
|
placeholder: composePlaceholder,
|
||||||
|
}
|
||||||
|
|
||||||
|
var composeName = regexp.MustCompile(`^(docker-)?compose([.-][^/]+)?\.ya?ml$`)
|
||||||
|
|
||||||
|
// IsCompose reports whether f is named like a Docker Compose file.
|
||||||
|
func IsCompose(f string) bool {
|
||||||
|
return composeName.MatchString(strings.ToLower(path.Base(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// composePlaceholderRe is compose's ${VAR}, ${VAR:-default}, ${VAR-default},
|
||||||
|
// ${VAR:?error}, ${VAR?error} or $VAR.
|
||||||
|
var composePlaceholderRe = regexp.MustCompile(`^\$(?:\{(\w+)(?:(:?-)([^}]*)|:?\?[^}]*)?\}|(\w+))$`)
|
||||||
|
|
||||||
|
func composePlaceholder(v string) (name, def string, hasDef, ok bool) {
|
||||||
|
m := composePlaceholderRe.FindStringSubmatch(v)
|
||||||
|
if m == nil {
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
return m[1] + m[4], m[3], m[2] != "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCompose(_ string, b []byte) ([]configKey, error) {
|
||||||
|
var doc yaml.Node
|
||||||
|
if err := yaml.Unmarshal(b, &doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(doc.Content) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []configKey
|
||||||
|
for _, service := range pairs(field(doc.Content[0], "services")) {
|
||||||
|
for _, k := range environment(field(service.value, "environment")) {
|
||||||
|
k.name = service.key.Value + "." + k.key
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// environment reads a service's environment, a map (KEY: value) or a list
|
||||||
|
// (- KEY=value). A variable without a value (KEY:, - KEY) is taken from
|
||||||
|
// where compose runs: a reference.
|
||||||
|
func environment(n *yaml.Node) []configKey {
|
||||||
|
var out []configKey
|
||||||
|
switch n = deref(n); {
|
||||||
|
case n == nil:
|
||||||
|
case n.Kind == yaml.MappingNode:
|
||||||
|
for _, p := range pairs(n) {
|
||||||
|
v := deref(p.value)
|
||||||
|
ref := v.Tag == "!!null" || reference(composePlaceholder, v.Value)
|
||||||
|
out = append(out, configKey{key: p.key.Value, value: v.Value, ref: ref, line: p.key.Line})
|
||||||
|
}
|
||||||
|
case n.Kind == yaml.SequenceNode:
|
||||||
|
for _, item := range n.Content {
|
||||||
|
item = deref(item)
|
||||||
|
k, v, ok := strings.Cut(item.Value, "=")
|
||||||
|
out = append(out, configKey{key: k, value: v, ref: !ok || reference(composePlaceholder, v), line: item.Line})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type pair struct{ key, value *yaml.Node }
|
||||||
|
|
||||||
|
// pairs are a mapping's entries, with those merged in by << (anchors
|
||||||
|
// shared between services) first and its own over them.
|
||||||
|
func pairs(n *yaml.Node) []pair {
|
||||||
|
n = deref(n)
|
||||||
|
if n == nil || n.Kind != yaml.MappingNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var merged, own []pair
|
||||||
|
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||||
|
k, v := n.Content[i], n.Content[i+1]
|
||||||
|
if k.Value != "<<" {
|
||||||
|
own = append(own, pair{k, v})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
from := []*yaml.Node{v}
|
||||||
|
if v.Kind == yaml.SequenceNode {
|
||||||
|
from = v.Content
|
||||||
|
}
|
||||||
|
for _, m := range from {
|
||||||
|
merged = append(merged, pairs(m)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []pair
|
||||||
|
for _, p := range append(merged, own...) {
|
||||||
|
if i := indexOf(out, p.key.Value); i >= 0 {
|
||||||
|
out[i] = p
|
||||||
|
} else {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(ps []pair, key string) int {
|
||||||
|
for i, p := range ps {
|
||||||
|
if p.key.Value == key {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func field(n *yaml.Node, key string) *yaml.Node {
|
||||||
|
ps := pairs(n)
|
||||||
|
if i := indexOf(ps, key); i >= 0 {
|
||||||
|
return ps[i].value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(n *yaml.Node) *yaml.Node {
|
||||||
|
for n != nil && n.Kind == yaml.AliasNode {
|
||||||
|
n = n.Alias
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsCompose(t *testing.T) {
|
||||||
|
for f, want := range map[string]bool{
|
||||||
|
"docker-compose.yml": true,
|
||||||
|
"deploy/compose.yaml": true,
|
||||||
|
"docker-compose.test.yml": true,
|
||||||
|
"docker-compose-dev.yaml": true,
|
||||||
|
"docker-compose.example.yml": true, // listed hidden
|
||||||
|
"applications.yaml": false,
|
||||||
|
"composer.json": false,
|
||||||
|
"compose.yml.bak": false,
|
||||||
|
} {
|
||||||
|
if IsCompose(f) != want {
|
||||||
|
t.Errorf("%s: %t", f, !want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposePlaceholder(t *testing.T) {
|
||||||
|
for v, want := range map[string]bool{
|
||||||
|
"${DB_PASSWORD}": true, "$DB_PASSWORD": true, "${DB_PASSWORD:?set it}": true, "${DB_PASSWORD?}": true,
|
||||||
|
"${DB_PASSWORD:-hunter2}": false, "${DB_PASSWORD-hunter2}": false, // the default is in the file
|
||||||
|
"hunter2": false, "pre-${DB_PASSWORD}": false, "$": false,
|
||||||
|
} {
|
||||||
|
if got := reference(composePlaceholder, v); got != want {
|
||||||
|
t.Errorf("%s: reference %t", v, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name, def, hasDef, _ := composePlaceholder("${PW:-x}"); name != "PW" || def != "x" || !hasDef {
|
||||||
|
t.Errorf("default: %s %s %t", name, def, hasDef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCompose(t *testing.T) {
|
||||||
|
keys, err := parseCompose("", []byte(`x-db: &db
|
||||||
|
DB_USER: app
|
||||||
|
DB_PASSWORD: shared
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: api
|
||||||
|
environment:
|
||||||
|
<<: *db
|
||||||
|
DB_PASSWORD: own
|
||||||
|
API_KEY: ${API_KEY}
|
||||||
|
FROM_HOST:
|
||||||
|
worker:
|
||||||
|
environment:
|
||||||
|
- QUEUE_PASSWORD=hunter2
|
||||||
|
- TOKEN
|
||||||
|
- EMPTY=
|
||||||
|
web:
|
||||||
|
image: web
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, k := range keys {
|
||||||
|
ref := ""
|
||||||
|
if k.ref {
|
||||||
|
ref = " (ref)"
|
||||||
|
}
|
||||||
|
got[k.name] = k.value + ref
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"api.DB_USER": "app", "api.DB_PASSWORD": "own", "api.API_KEY": "${API_KEY} (ref)", "api.FROM_HOST": " (ref)",
|
||||||
|
"worker.QUEUE_PASSWORD": "hunter2", "worker.TOKEN": " (ref)", "worker.EMPTY": "",
|
||||||
|
}
|
||||||
|
if !maps.Equal(got, want) {
|
||||||
|
t.Errorf("got %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.name == "worker.QUEUE_PASSWORD" && (k.line != 14 || k.key != "QUEUE_PASSWORD") {
|
||||||
|
t.Errorf("%s: line %d, key %s", k.name, k.line, k.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeFiles(t *testing.T) {
|
||||||
|
root := gitRepo(t, map[string]string{
|
||||||
|
"docker-compose.yml": `services:
|
||||||
|
api:
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
JASYPT_ENCRYPTOR_PASSWORD: test-password
|
||||||
|
API_TOKEN: ` + jasyptHunter2 + `
|
||||||
|
`,
|
||||||
|
"docker-compose.example.yml": "services:\n api:\n environment:\n DB_PASSWORD: changeme\n",
|
||||||
|
})
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||||
|
ctx := context.Background()
|
||||||
|
p := New(Settings{Dir: root})
|
||||||
|
srcs, _ := p.Sources(ctx)
|
||||||
|
cs, err := p.Containers(ctx, srcs[0])
|
||||||
|
if err != nil || len(cs) != 2 {
|
||||||
|
t.Fatalf("files: %+v %v", cs, err)
|
||||||
|
}
|
||||||
|
for _, c := range cs {
|
||||||
|
if c.Detail != "compose · yaml" || c.Hidden != (c.Name == "docker-compose.example.yml") {
|
||||||
|
t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose := cs[1] // after docker-compose.example.yml
|
||||||
|
|
||||||
|
keys, _ := p.Objects(ctx, compose, provider.TypeSecret)
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, k := range shown(keys) {
|
||||||
|
got[k.Name] = k.Detail
|
||||||
|
}
|
||||||
|
if want := map[string]string{"api.DB_PASSWORD": "reference", "api.JASYPT_ENCRYPTOR_PASSWORD": "", "api.API_TOKEN": "jasypt"}; !maps.Equal(got, want) {
|
||||||
|
t.Errorf("keys %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
// The service's JASYPT_ENCRYPTOR_PASSWORD decrypts its values.
|
||||||
|
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "docker-compose.yml"}, Name: "api.API_TOKEN"}
|
||||||
|
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "hunter2" {
|
||||||
|
t.Errorf("jasypt: %q %v", v.Reveal(), err)
|
||||||
|
}
|
||||||
|
if f, _ := p.File(compose, &link); f.Line != 7 {
|
||||||
|
t.Errorf("line %d", f.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compose.secretPatterns over the shared ones.
|
||||||
|
os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte("secretPatterns:\n keys: [\"^nothing$\"]\ncompose:\n secretPatterns:\n keys: [\"^PORT$\"]\n values: []\n"), 0o644)
|
||||||
|
keys, _ = p.Objects(ctx, compose, provider.TypeSecret)
|
||||||
|
if s := shown(keys); len(s) != 1 || s[0].Name != "api.PORT" {
|
||||||
|
t.Errorf("compose's patterns: %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config files (Spring's, compose's) are read directly, like env files,
|
||||||
|
// but most of their keys aren't secret: those the secret patterns
|
||||||
|
// (patterns.go) don't match are hidden, and a file with none of them is.
|
||||||
|
|
||||||
|
// configType is a type of config file: what it's called (in settings, as
|
||||||
|
// spring:, and on the file's row) and how its keys are read.
|
||||||
|
type configType struct {
|
||||||
|
name string
|
||||||
|
is func(file string) bool
|
||||||
|
format func(file string) string // yaml, properties
|
||||||
|
parse func(file string, b []byte) ([]configKey, error)
|
||||||
|
// jasyptSetting is the key that holds the Jasypt password for the
|
||||||
|
// key named name.
|
||||||
|
jasyptSetting func(name string) string
|
||||||
|
// placeholder reads a value that only refers to another,
|
||||||
|
// ${DB_PASSWORD}: what it names, and its default if it has one.
|
||||||
|
placeholder func(v string) (name, def string, hasDef, ok bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var configTypes = []configType{springType, composeType}
|
||||||
|
|
||||||
|
func isConfig(f string) bool {
|
||||||
|
_, ok := configTypeOf(f)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// configTypeOf is the type of config file f is, if it's one.
|
||||||
|
func configTypeOf(f string) (configType, bool) {
|
||||||
|
for _, t := range configTypes {
|
||||||
|
if t.is(f) {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configType{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detail is the file's row: "spring · yaml".
|
||||||
|
func (t configType) detail(file string) string { return t.name + " · " + t.format(file) }
|
||||||
|
|
||||||
|
// configKey is a key of a config file, with its value.
|
||||||
|
type configKey struct {
|
||||||
|
key string // as the app sees it: spring.datasource.password, DB_PASSWORD
|
||||||
|
name string // one key in the file: spring.datasource.password (prod), api.DB_PASSWORD
|
||||||
|
value string
|
||||||
|
ref bool // the value only refers to the secret, set elsewhere: ${DB_PASSWORD}
|
||||||
|
line int // 1-based
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k configKey) jasypt() bool { return jasypt.Is(k.value) }
|
||||||
|
|
||||||
|
// plain reports whether the file holds the value itself, readable.
|
||||||
|
func (k configKey) plain() bool { return k.value != "" && !k.ref && !k.jasypt() }
|
||||||
|
|
||||||
|
// reference reports whether v is only a placeholder without a default:
|
||||||
|
// one with a default has the default in the file.
|
||||||
|
func reference(placeholder func(string) (string, string, bool, bool), v string) bool {
|
||||||
|
_, _, hasDef, ok := placeholder(v)
|
||||||
|
return ok && !hasDef
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t configType) read(file string) ([]configKey, error) {
|
||||||
|
b, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t.parse(file, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t configType) keyNamed(file, name string) (configKey, error) {
|
||||||
|
keys, err := t.read(file)
|
||||||
|
if err != nil {
|
||||||
|
return configKey{}, err
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.name == name {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t configType) value(file, name string) (provider.SecretValue, error) {
|
||||||
|
k, err := t.keyNamed(file, name)
|
||||||
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, err
|
||||||
|
}
|
||||||
|
return provider.NewSecretValue([]byte(k.value)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configHidden reports whether a config file has no secret keys. One that
|
||||||
|
// can't be read isn't hidden: its error is worth seeing.
|
||||||
|
func (p *Provider) configHidden(t configType, root, file string) bool {
|
||||||
|
keys, err := t.read(file)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
filter, err := p.filterFor(root, t.name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !slices.ContainsFunc(keys, func(k configKey) bool { return filter.secret(k.key, k.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// configObjects lists the file's keys, those that don't look like secrets
|
||||||
|
// hidden.
|
||||||
|
func (p *Provider) configObjects(t configType, c provider.Container, b []byte) ([]provider.Object, error) {
|
||||||
|
keys, err := t.parse(c.ID, b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
||||||
|
}
|
||||||
|
filter, err := p.filterFor(c.Source.ID, t.name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
format := provider.Attr{Label: "format", Value: t.name + " " + t.format(c.ID)}
|
||||||
|
var out []provider.Object
|
||||||
|
for _, k := range keys {
|
||||||
|
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
|
||||||
|
switch {
|
||||||
|
case k.ref:
|
||||||
|
state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference"
|
||||||
|
case k.jasypt():
|
||||||
|
state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt"
|
||||||
|
}
|
||||||
|
out = append(out, provider.Object{
|
||||||
|
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail,
|
||||||
|
Hidden: !filter.secret(k.key, k.value), Attrs: []provider.Attr{format, state},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configDetails says how many of the file's keys aren't listed, and
|
||||||
|
// whether any listed one is in plain text.
|
||||||
|
func (p *Provider) configDetails(t configType, c provider.Container) (attrs []provider.Attr, plain bool) {
|
||||||
|
keys, err := t.read(c.ID)
|
||||||
|
if err != nil {
|
||||||
|
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
|
||||||
|
}
|
||||||
|
filter, err := p.filterFor(c.Source.ID, t.name)
|
||||||
|
if err != nil {
|
||||||
|
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
|
||||||
|
}
|
||||||
|
secrets, jasypt := 0, 0
|
||||||
|
for _, k := range keys {
|
||||||
|
if filter.secret(k.key, k.value) {
|
||||||
|
secrets++
|
||||||
|
plain = plain || k.plain()
|
||||||
|
}
|
||||||
|
if k.jasypt() {
|
||||||
|
jasypt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []provider.Attr{
|
||||||
|
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
|
||||||
|
{Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
|
||||||
|
}, plain
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package repo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,7 +24,12 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
}
|
}
|
||||||
var out []provider.Attr
|
var out []provider.Attr
|
||||||
meta, isSops := readMeta(c.ID)
|
meta, isSops := readMeta(c.ID)
|
||||||
if isSops {
|
// A plain file's values are at risk in git, unless they're all Jasypt;
|
||||||
|
// a config file's only when one of its secrets is in plain text.
|
||||||
|
plain := !isSops
|
||||||
|
if t, ok := configTypeOf(c.ID); ok && !isSops {
|
||||||
|
out, plain = p.configDetails(t, c)
|
||||||
|
} else if isSops {
|
||||||
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
|
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
|
||||||
for i, k := range meta.recipients {
|
for i, k := range meta.recipients {
|
||||||
label := ""
|
label := ""
|
||||||
@@ -35,7 +42,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
|
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
|
out, plain = envDetails(c.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
git := func(args ...string) (string, bool) {
|
git := func(args ...string) (string, bool) {
|
||||||
@@ -43,7 +50,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
return strings.TrimSpace(string(b)), err == nil
|
return strings.TrimSpace(string(b)), err == nil
|
||||||
}
|
}
|
||||||
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
|
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
|
||||||
case tracked && !isSops:
|
case tracked && plain:
|
||||||
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||||
case tracked:
|
case tracked:
|
||||||
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
|
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
|
||||||
@@ -51,7 +58,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
|
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
|
||||||
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
|
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
|
||||||
} else {
|
} else {
|
||||||
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
|
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: plain})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -60,3 +67,28 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envDetails says how a plain env file's values are encrypted, and whether
|
||||||
|
// any is in plain text.
|
||||||
|
func envDetails(file string) (attrs []provider.Attr, plain bool) {
|
||||||
|
b, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return []provider.Attr{{Label: "encryption", Value: err.Error(), Warn: true}}, true
|
||||||
|
}
|
||||||
|
keys, _ := parseKeys(file, b)
|
||||||
|
n := 0
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.jasypt {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []provider.Attr{{Label: "encryption", Value: jasyptShare(n, len(keys))}}, n < len(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jasyptShare is how many of a plain file's values are Jasypt's.
|
||||||
|
func jasyptShare(n, of int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "none: plain text"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("jasypt: %d of %d values", n, of)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jasyptEnv is where Spring looks for the password first, before the
|
||||||
|
// config's jasypt.encryptor.password, and so does lazysecrets.
|
||||||
|
const jasyptEnv = "JASYPT_ENCRYPTOR_PASSWORD"
|
||||||
|
|
||||||
|
// jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain
|
||||||
|
// file; other values are as they are.
|
||||||
|
func jasyptValue(file, name string, v provider.SecretValue) (provider.SecretValue, error) {
|
||||||
|
s := v.Reveal()
|
||||||
|
if !jasypt.Is(s) {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
password, from, err := jasyptPassword(file, name)
|
||||||
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, err
|
||||||
|
}
|
||||||
|
plain, err := jasypt.Decrypt(s, password)
|
||||||
|
if errors.Is(err, jasypt.ErrDecrypt) {
|
||||||
|
return provider.SecretValue{}, fmt.Errorf("jasypt: with the password from %s: %w", from, err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, fmt.Errorf("jasypt: %w", err)
|
||||||
|
}
|
||||||
|
return provider.NewSecretValue([]byte(plain)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jasyptPassword is the password for the Jasypt value of the key named
|
||||||
|
// name, and where it's from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's
|
||||||
|
// own setting (jasypt.encryptor.password in a Spring file; the service's
|
||||||
|
// JASYPT_ENCRYPTOR_PASSWORD in a compose file, the file's in an env file),
|
||||||
|
// with a ${VAR} in it read from the environment.
|
||||||
|
func jasyptPassword(file, name string) (password, from string, err error) {
|
||||||
|
if v := os.Getenv(jasyptEnv); v != "" {
|
||||||
|
return v, "$" + jasyptEnv, nil
|
||||||
|
}
|
||||||
|
setting, read, placeholder := jasyptEnv, plainValue, composePlaceholder // env files interpolate like compose
|
||||||
|
if t, ok := configTypeOf(file); ok {
|
||||||
|
setting, read, placeholder = t.jasyptSetting(name), t.value, t.placeholder
|
||||||
|
}
|
||||||
|
v, err := read(file, setting)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file))
|
||||||
|
}
|
||||||
|
from = setting + " in " + filepath.Base(file)
|
||||||
|
env, def, hasDef, ok := placeholder(v.Reveal())
|
||||||
|
switch {
|
||||||
|
case !ok:
|
||||||
|
return v.Reveal(), from, nil
|
||||||
|
case os.Getenv(env) != "":
|
||||||
|
return os.Getenv(env), "$" + env + " (" + from + ")", nil
|
||||||
|
case hasDef:
|
||||||
|
return def, "the default in " + from, nil
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v.Reveal(), env)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hunter2, encrypted by Jasypt with the password "test-password".
|
||||||
|
const jasyptHunter2 = "ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)"
|
||||||
|
|
||||||
|
func TestJasyptPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
value := func(name, body string) (string, error) {
|
||||||
|
file := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(file, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := "app.token"
|
||||||
|
if !IsSpring(name) {
|
||||||
|
key = "TOKEN"
|
||||||
|
}
|
||||||
|
v, err := (&Provider{}).Value(context.Background(), provider.Object{ID: file + "\x00" + key, Name: key})
|
||||||
|
return v.Reveal(), err
|
||||||
|
}
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||||
|
t.Setenv("MY_PASSWORD", "")
|
||||||
|
spring := func(password string) string {
|
||||||
|
return "jasypt.encryptor.password: " + password + "\napp.token: " + jasyptHunter2 + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct{ name, file, body, env, want string }{
|
||||||
|
{"the file's own", "application.yml", spring("test-password"), "", "hunter2"},
|
||||||
|
{"a ${VAR}", "application.yml", spring("${MY_PASSWORD}"), "test-password", "hunter2"},
|
||||||
|
{"a ${VAR}'s default", "application.yml", spring("${MY_PASSWORD:test-password}"), "", "hunter2"},
|
||||||
|
{"properties", "application-local.properties", "jasypt.encryptor.password=test-password\napp.token=" + jasyptHunter2 + "\n", "", "hunter2"},
|
||||||
|
{"env file", ".env", "JASYPT_ENCRYPTOR_PASSWORD=test-password\nTOKEN=" + jasyptHunter2 + "\n", "", "hunter2"},
|
||||||
|
{"not jasypt", ".env", "TOKEN=hunter2\n", "", "hunter2"},
|
||||||
|
} {
|
||||||
|
t.Setenv("MY_PASSWORD", c.env)
|
||||||
|
if got, err := value(c.file, c.body); err != nil || got != c.want {
|
||||||
|
t.Errorf("%s: %q %v", c.name, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// $JASYPT_ENCRYPTOR_PASSWORD wins over the file's, as in Spring.
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
|
||||||
|
if got, err := value("application.yml", spring("wrong")); err != nil || got != "hunter2" {
|
||||||
|
t.Errorf("environment first: %q %v", got, err)
|
||||||
|
}
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "wrong")
|
||||||
|
if _, err := value("application.yml", spring("test-password")); err == nil || !strings.Contains(err.Error(), "the password from $JASYPT_ENCRYPTOR_PASSWORD") {
|
||||||
|
t.Errorf("a wrong one says where it's from: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||||
|
t.Setenv("MY_PASSWORD", "")
|
||||||
|
for body, want := range map[string]string{
|
||||||
|
spring("${MY_PASSWORD}"): "$MY_PASSWORD isn't set",
|
||||||
|
"app.token: " + jasyptHunter2: "set $JASYPT_ENCRYPTOR_PASSWORD, or jasypt.encryptor.password in application.yml",
|
||||||
|
} {
|
||||||
|
if _, err := value("application.yml", body); err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("no password: %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJasyptEnvFile(t *testing.T) {
|
||||||
|
root := gitRepo(t, map[string]string{
|
||||||
|
"jasypt.env": "TOKEN=" + jasyptHunter2 + "\n",
|
||||||
|
"mixed.env": "TOKEN=" + jasyptHunter2 + "\nURL=https://example.com\n",
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
p := New(Settings{Dir: root})
|
||||||
|
srcs, _ := p.Sources(ctx)
|
||||||
|
cs, _ := p.Containers(ctx, srcs[0])
|
||||||
|
for _, c := range cs {
|
||||||
|
attrs, _ := p.Details(ctx, c)
|
||||||
|
got := map[string]provider.Attr{}
|
||||||
|
for _, a := range attrs {
|
||||||
|
got[a.Label] = a
|
||||||
|
}
|
||||||
|
switch c.Name {
|
||||||
|
case "jasypt.env":
|
||||||
|
if got["encryption"].Value != "jasypt: 1 of 1 values" || got["git"].Warn {
|
||||||
|
t.Errorf("all jasypt: %+v", got)
|
||||||
|
}
|
||||||
|
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
|
||||||
|
if len(keys) != 1 || keys[0].Attrs[1].Value != "jasypt" || keys[0].Detail != "jasypt" {
|
||||||
|
t.Errorf("keys: %+v", keys)
|
||||||
|
}
|
||||||
|
case "mixed.env":
|
||||||
|
if got["encryption"].Value != "jasypt: 1 of 2 values" || !got["git"].Warn {
|
||||||
|
t.Errorf("mixed: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config files (Spring's) hold all of an app's config, most of it not
|
||||||
|
// secret: of their keys, those named like a secret or with a value that
|
||||||
|
// looks like one are listed, the rest hidden (H shows them). The patterns
|
||||||
|
// can be set for all of a repo's files and per file type, in the config
|
||||||
|
// and in a repo's .lazysecrets.yaml.
|
||||||
|
|
||||||
|
// DefaultSecretKeys and DefaultSecretValues are the patterns used when
|
||||||
|
// nothing sets them. The key pattern is anchored at the end of the name:
|
||||||
|
// spring.datasource.password, client-secret, DB_PASSWORD and checkoutKey,
|
||||||
|
// not the oauth2.*, keycloak.* and token-uri keys every Spring app has.
|
||||||
|
// passord is Norwegian. They err on listing too much: a row too many is
|
||||||
|
// cheap, a secret missed isn't.
|
||||||
|
var (
|
||||||
|
DefaultSecretKeys = []string{
|
||||||
|
`(?i)(pass(word|wd|phrase|ord)?|pwd|secret|token|key|credentials?|salt|connection-?string)$`,
|
||||||
|
}
|
||||||
|
DefaultSecretValues = []string{
|
||||||
|
`^ENC\(.+\)$`, // Jasypt
|
||||||
|
`://[^/\s:@]*:[^/\s@]+@`, // a URL with a password: jdbc:postgresql://user:pass@host
|
||||||
|
`(?i)(AccountKey|SharedAccessKey|Password|Pwd)=`, // a connection string with one
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecretPatterns are regexps matched against a key's name and its value:
|
||||||
|
// a key is a secret when either matches. A list that isn't set is taken
|
||||||
|
// from the next place that sets it (see filterFor).
|
||||||
|
type SecretPatterns struct {
|
||||||
|
Keys *[]string `yaml:"keys"`
|
||||||
|
Values *[]string `yaml:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileType holds the settings of one type of file (spring:), for now its
|
||||||
|
// own secret patterns.
|
||||||
|
type FileType struct {
|
||||||
|
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check says which pattern doesn't compile; field is where they're set
|
||||||
|
// (spring.secretPatterns), for the message.
|
||||||
|
func (p SecretPatterns) Check(field string) error {
|
||||||
|
var errs []error
|
||||||
|
for name, list := range map[string]*[]string{"keys": p.Keys, "values": p.Values} {
|
||||||
|
if list == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, s := range *list {
|
||||||
|
if _, err := regexp.Compile(s); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%s.%s[%d]: %w", field, name, i, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type secretFilter struct{ keys, values []*regexp.Regexp }
|
||||||
|
|
||||||
|
func (f secretFilter) secret(key, value string) bool {
|
||||||
|
return anyMatch(f.keys, key) || anyMatch(f.values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterFor is the patterns for a repo's files of one type (spring), each
|
||||||
|
// list from the first that sets it: the repo's .lazysecrets.yaml for the
|
||||||
|
// type, then for all its files, the config for the type, then for all
|
||||||
|
// repos, then the defaults.
|
||||||
|
func (p *Provider) filterFor(root, fileType string) (secretFilter, error) {
|
||||||
|
f, err := readLazysecretsFile(root)
|
||||||
|
if err != nil {
|
||||||
|
return secretFilter{}, err
|
||||||
|
}
|
||||||
|
own := filepath.Join(root, ".lazysecrets.yaml") + ": "
|
||||||
|
type source struct {
|
||||||
|
field string
|
||||||
|
patterns SecretPatterns
|
||||||
|
}
|
||||||
|
sources := []source{
|
||||||
|
{own + fileType + ".secretPatterns", f.types()[fileType].SecretPatterns},
|
||||||
|
{own + "secretPatterns", f.SecretPatterns},
|
||||||
|
{"providers.repos." + fileType + ".secretPatterns", p.s.Types[fileType].SecretPatterns},
|
||||||
|
{"providers.repos.secretPatterns", p.s.SecretPatterns},
|
||||||
|
}
|
||||||
|
pick := func(name string, list func(SecretPatterns) *[]string, defaults []string) ([]*regexp.Regexp, error) {
|
||||||
|
for _, s := range sources {
|
||||||
|
if l := list(s.patterns); l != nil {
|
||||||
|
return compileAll(*l, s.field+"."+name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compileAll(defaults, "default "+name)
|
||||||
|
}
|
||||||
|
keys, kerr := pick("keys", func(p SecretPatterns) *[]string { return p.Keys }, DefaultSecretKeys)
|
||||||
|
values, verr := pick("values", func(p SecretPatterns) *[]string { return p.Values }, DefaultSecretValues)
|
||||||
|
return secretFilter{keys, values}, errors.Join(kerr, verr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileAll(patterns []string, field string) ([]*regexp.Regexp, error) {
|
||||||
|
out := make([]*regexp.Regexp, 0, len(patterns))
|
||||||
|
for i, s := range patterns {
|
||||||
|
re, err := regexp.Compile(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s[%d]: %w", field, i, err)
|
||||||
|
}
|
||||||
|
out = append(out, re)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each list comes from the first that sets it: the repo's spring, the
|
||||||
|
// repo's, the config's spring, the config's, the defaults.
|
||||||
|
func TestSecretPatternsOrder(t *testing.T) {
|
||||||
|
list := func(s ...string) *[]string { return &s }
|
||||||
|
root := t.TempDir()
|
||||||
|
write := func(body string) {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := New(Settings{
|
||||||
|
SecretPatterns: SecretPatterns{Keys: list("^config$"), Values: list("^config$")},
|
||||||
|
Types: map[string]FileType{"spring": {SecretPatterns: SecretPatterns{Keys: list("^config-spring$")}}},
|
||||||
|
})
|
||||||
|
secret := func(key, value string) bool {
|
||||||
|
t.Helper()
|
||||||
|
f, err := p.filterFor(root, "spring")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f.secret(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !secret("config-spring", "") || secret("config", "") || !secret("", "config") {
|
||||||
|
t.Error("the config's spring keys, its shared values")
|
||||||
|
}
|
||||||
|
write("secretPatterns:\n keys: [\"^repo$\"]\n")
|
||||||
|
if !secret("repo", "") || secret("config-spring", "") || !secret("", "config") {
|
||||||
|
t.Error("the repo's shared keys over the config's")
|
||||||
|
}
|
||||||
|
write("secretPatterns:\n keys: [\"^repo$\"]\nspring:\n secretPatterns:\n keys: [\"^repo-spring$\"]\n values: []\n")
|
||||||
|
if !secret("repo-spring", "") || secret("repo", "") || secret("", "config") {
|
||||||
|
t.Error("the repo's spring over everything; an empty list matches nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
p = New(Settings{})
|
||||||
|
write("")
|
||||||
|
if !secret("spring.datasource.password", "") || secret("server.port", "") {
|
||||||
|
t.Error("the defaults")
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
-97
@@ -1,6 +1,7 @@
|
|||||||
// Package repo browses the encrypted secret files in git repositories:
|
// Package repo browses the encrypted secret files in git repositories:
|
||||||
// a repo is a source, its secret files the containers (vault level), and
|
// a repo is a source, its secret files the containers (vault level), and
|
||||||
// their keys the secrets. SOPS files are the one type so far.
|
// their keys the secrets. Secret files are SOPS files, plain env files and
|
||||||
|
// config files: Spring Boot's and Docker Compose's (configfile.go).
|
||||||
//
|
//
|
||||||
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
|
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
|
||||||
// metadata are read directly. A value is decrypted when it's revealed or
|
// metadata are read directly. A value is decrypted when it's revealed or
|
||||||
@@ -31,6 +32,7 @@ import (
|
|||||||
|
|
||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +51,11 @@ type Settings struct {
|
|||||||
// Include and Exclude are globs relative to a repo's root (** matches
|
// Include and Exclude are globs relative to a repo's root (** matches
|
||||||
// any depth), for repos without their own .lazysecrets.yaml.
|
// any depth), for repos without their own .lazysecrets.yaml.
|
||||||
Include, Exclude []string
|
Include, Exclude []string
|
||||||
|
// SecretPatterns say which keys of config files are secrets, Types
|
||||||
|
// per type of file (spring), for repos whose .lazysecrets.yaml
|
||||||
|
// doesn't.
|
||||||
|
SecretPatterns SecretPatterns
|
||||||
|
Types map[string]FileType
|
||||||
// Sops is the sops binary; "sops" (on PATH) when empty.
|
// Sops is the sops binary; "sops" (on PATH) when empty.
|
||||||
Sops string
|
Sops string
|
||||||
}
|
}
|
||||||
@@ -106,8 +113,6 @@ func (p *Provider) load() {
|
|||||||
roots = append(roots, root)
|
roots = append(roots, root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The repo lazysecrets started in, if any: marked, so the UI can
|
|
||||||
// put it on top, with its files under Dir first.
|
|
||||||
here, inRepo := p.current()
|
here, inRepo := p.current()
|
||||||
if inRepo {
|
if inRepo {
|
||||||
seen[here.root] = true
|
seen[here.root] = true
|
||||||
@@ -137,7 +142,6 @@ func (p *Provider) load() {
|
|||||||
add(root)
|
add(root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Repos you starred something in, wherever they are.
|
|
||||||
for _, dir := range p.s.Starred {
|
for _, dir := range p.s.Starred {
|
||||||
if root, err := gitRoot(dir); err == nil {
|
if root, err := gitRoot(dir); err == nil {
|
||||||
add(root)
|
add(root)
|
||||||
@@ -188,12 +192,19 @@ func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
|
|||||||
return provider.Source{}, false
|
return provider.Source{}, false
|
||||||
}
|
}
|
||||||
src := p.source(r)
|
src := p.source(r)
|
||||||
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 {
|
files, err := p.Containers(ctx, src)
|
||||||
|
if err == nil && len(files) == 0 {
|
||||||
return provider.Source{}, false
|
return provider.Source{}, false
|
||||||
}
|
}
|
||||||
|
src.Hidden = err == nil && allHidden(files)
|
||||||
return src, true
|
return src, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allHidden reports whether a repo's files are all hidden: the repo is too.
|
||||||
|
func allHidden(files []provider.Container) bool {
|
||||||
|
return !slices.ContainsFunc(files, func(c provider.Container) bool { return !c.Hidden })
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Provider) source(r repoInfo) provider.Source {
|
func (p *Provider) source(r repoInfo) provider.Source {
|
||||||
return provider.Source{
|
return provider.Source{
|
||||||
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
|
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
|
||||||
@@ -206,13 +217,11 @@ func parallel(n int, f func(i int)) {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
sem := make(chan struct{}, max(8, runtime.NumCPU()))
|
sem := make(chan struct{}, max(8, runtime.NumCPU()))
|
||||||
for i := range n {
|
for i := range n {
|
||||||
wg.Add(1)
|
wg.Go(func() {
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
sem <- struct{}{}
|
sem <- struct{}{}
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
f(i)
|
f(i)
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
@@ -264,6 +273,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
|||||||
parallel(len(srcs), func(i int) {
|
parallel(len(srcs), func(i int) {
|
||||||
files, err := p.Containers(ctx, srcs[i])
|
files, err := p.Containers(ctx, srcs[i])
|
||||||
keep[i] = err != nil || len(files) > 0
|
keep[i] = err != nil || len(files) > 0
|
||||||
|
srcs[i].Hidden = err == nil && allHidden(files)
|
||||||
})
|
})
|
||||||
var out []provider.Source
|
var out []provider.Source
|
||||||
for i, src := range srcs {
|
for i, src := range srcs {
|
||||||
@@ -323,8 +333,13 @@ 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 isEnvNamed(f):
|
||||||
c.Detail = provider.DetailPlainEnv
|
c.Detail = provider.DetailPlainEnv
|
||||||
|
c.Hidden = isExample(f)
|
||||||
|
case isConfig(f):
|
||||||
|
t, _ := configTypeOf(f)
|
||||||
|
c.Detail = t.detail(f)
|
||||||
|
c.Hidden = isExample(f) || p.configHidden(t, r.root, path)
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -361,30 +376,29 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
meta, isSops := readMeta(c.ID)
|
meta, isSops := parseMeta(c.ID, b)
|
||||||
|
if t, ok := configTypeOf(c.ID); ok && !isSops {
|
||||||
|
return p.configObjects(t, c, b)
|
||||||
|
}
|
||||||
keys, err := parseKeys(c.ID, b)
|
keys, err := parseKeys(c.ID, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
||||||
}
|
}
|
||||||
if !isSops { // a plain env file
|
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
|
||||||
out := make([]provider.Object, 0, len(keys))
|
if isSops {
|
||||||
for _, k := range keys {
|
attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}
|
||||||
out = append(out, provider.Object{
|
|
||||||
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
|
|
||||||
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
out := make([]provider.Object, 0, len(keys))
|
out := make([]provider.Object, 0, len(keys))
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
o := provider.Object{
|
o := provider.Object{
|
||||||
Container: c, Type: provider.TypeSecret,
|
Container: c, Type: provider.TypeSecret,
|
||||||
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
|
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
|
||||||
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
|
|
||||||
}
|
}
|
||||||
if !k.encrypted {
|
switch {
|
||||||
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
case isSops && !k.encrypted:
|
||||||
|
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
||||||
|
case !isSops && k.jasypt:
|
||||||
|
o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt"
|
||||||
}
|
}
|
||||||
out = append(out, o)
|
out = append(out, o)
|
||||||
}
|
}
|
||||||
@@ -399,18 +413,29 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
|
|||||||
return nil, provider.ErrUnsupported
|
return nil, provider.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value decrypts one key with sops.
|
// Value reads one key: from a plain file, decrypting a Jasypt value; from
|
||||||
|
// a SOPS file, with sops.
|
||||||
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 = filePath(o.Container)
|
||||||
if file == "" {
|
|
||||||
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)
|
read := plainValue
|
||||||
|
if t, ok := configTypeOf(file); ok {
|
||||||
|
read = t.value
|
||||||
|
}
|
||||||
|
v, err := read(file, o.Name)
|
||||||
|
if err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
return jasyptValue(file, o.Name, v)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
var err error
|
||||||
|
if extract, err = extractOf(file, o.Name); err != nil {
|
||||||
|
return provider.SecretValue{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
|
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
|
||||||
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
|
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
|
||||||
@@ -434,12 +459,18 @@ func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, er
|
|||||||
return os.ReadFile(f.Path)
|
return os.ReadFile(f.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filePath is the file c is. A container made from a link has no ID: the
|
||||||
|
// file is its path in the repo.
|
||||||
|
func filePath(c provider.Container) string {
|
||||||
|
if c.ID != "" {
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
|
return filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
|
||||||
|
}
|
||||||
|
|
||||||
// File is the file c is, and the line o's key is on.
|
// 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 := filePath(c)
|
||||||
if path == "" { // made from a link
|
|
||||||
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
|
|
||||||
}
|
|
||||||
f := provider.File{Path: path}
|
f := provider.File{Path: path}
|
||||||
if _, isSops := readMeta(path); isSops {
|
if _, isSops := readMeta(path); isSops {
|
||||||
f.Sops = p.s.Sops
|
f.Sops = p.s.Sops
|
||||||
@@ -447,6 +478,12 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File
|
|||||||
if o == nil {
|
if o == nil {
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
if t, ok := configTypeOf(path); ok && f.Sops == "" {
|
||||||
|
if k, err := t.keyNamed(path, o.Name); err == nil {
|
||||||
|
f.Line = k.line
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return f, err
|
return f, err
|
||||||
@@ -600,6 +637,9 @@ 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"`
|
||||||
|
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
|
||||||
|
Spring FileType `yaml:"spring"`
|
||||||
|
Compose FileType `yaml:"compose"`
|
||||||
Sharing struct {
|
Sharing struct {
|
||||||
// Key is the link to the secret that encrypts the repo's
|
// Key is the link to the secret that encrypts the repo's
|
||||||
// shared env files (package share).
|
// shared env files (package share).
|
||||||
@@ -607,36 +647,45 @@ type lazysecretsFile struct {
|
|||||||
} `yaml:"sharing"`
|
} `yaml:"sharing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f lazysecretsFile) types() map[string]FileType {
|
||||||
|
return map[string]FileType{"spring": f.Spring, "compose": f.Compose}
|
||||||
|
}
|
||||||
|
|
||||||
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
|
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
|
||||||
func ShareKey(root string) (string, error) {
|
func ShareKey(root string) (string, error) {
|
||||||
|
f, err := readLazysecretsFile(root)
|
||||||
|
return f.Sharing.Key, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLazysecretsFile reads the repo's .lazysecrets.yaml; without one, it's
|
||||||
|
// empty.
|
||||||
|
func readLazysecretsFile(root string) (lazysecretsFile, error) {
|
||||||
|
var f lazysecretsFile
|
||||||
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
|
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return "", nil
|
return f, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return f, err
|
||||||
}
|
}
|
||||||
var f lazysecretsFile
|
|
||||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
if err := yaml.Unmarshal(b, &f); err != nil {
|
||||||
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
return f, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
||||||
}
|
}
|
||||||
return f.Sharing.Key, nil
|
return f, 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
|
||||||
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
|
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
|
||||||
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
|
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
|
||||||
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
|
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
|
||||||
include, exclude := p.s.Include, p.s.Exclude
|
f, err := readLazysecretsFile(root)
|
||||||
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
|
if err != nil {
|
||||||
var f lazysecretsFile
|
return nil, nil, err
|
||||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
|
||||||
}
|
}
|
||||||
|
include, exclude := p.s.Include, p.s.Exclude
|
||||||
if len(f.Include) > 0 || len(f.Exclude) > 0 {
|
if len(f.Include) > 0 || len(f.Exclude) > 0 {
|
||||||
include, exclude = f.Include, f.Exclude
|
include, exclude = f.Include, f.Exclude
|
||||||
}
|
}
|
||||||
}
|
|
||||||
inc, err := globs(include)
|
inc, err := globs(include)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -671,7 +720,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 isEnvNamed(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 +734,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] && isEnvNamed(f) {
|
||||||
out = append(out, f)
|
out = append(out, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -720,17 +769,30 @@ func inPackages(f string) bool {
|
|||||||
// .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) {
|
return isEnvNamed(f) && !isExample(f)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isEnvNamed reports whether a file outside dependencies is named like an
|
||||||
|
// env file, examples too.
|
||||||
|
func isEnvNamed(f string) bool {
|
||||||
base := strings.ToLower(path.Base(f))
|
base := strings.ToLower(path.Base(f))
|
||||||
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} {
|
return !inPackages(f) && (base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env"))
|
||||||
if strings.HasSuffix(base, ext) {
|
}
|
||||||
|
|
||||||
|
var exampleWords = []string{"example", "sample", "template", "dist", "tmpl"}
|
||||||
|
|
||||||
|
// isExample reports whether a file is an example or template of another:
|
||||||
|
// .env.example, docker-compose.example.yml. They're listed hidden: someone
|
||||||
|
// may have put a real value in one.
|
||||||
|
func isExample(f string) bool {
|
||||||
|
base := strings.ToLower(path.Base(f))
|
||||||
|
for _, w := range exampleWords {
|
||||||
|
if strings.HasSuffix(base, "."+w) || strings.Contains(base, "."+w+".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
|
|
||||||
}
|
|
||||||
|
|
||||||
// plainValue reads a key from a plain env file.
|
// plainValue reads a key from a plain env file.
|
||||||
func plainValue(file, name string) (provider.SecretValue, error) {
|
func plainValue(file, name string) (provider.SecretValue, error) {
|
||||||
@@ -907,12 +969,21 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
return sopsMeta{}, false // not a format sops writes: skip reading
|
return sopsMeta{}, false // not a format sops writes: skip reading
|
||||||
}
|
}
|
||||||
b, err := os.ReadFile(file)
|
b, err := os.ReadFile(file)
|
||||||
if err != nil || !bytes.Contains(b, []byte("sops")) {
|
if err != nil {
|
||||||
|
return sopsMeta{}, false
|
||||||
|
}
|
||||||
|
return parseMeta(file, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMeta is readMeta, of the file's contents b.
|
||||||
|
func parseMeta(file string, b []byte) (sopsMeta, bool) {
|
||||||
|
format := formatOf(file)
|
||||||
|
if len(b) > maxFileSize || format == "" || !bytes.Contains(b, []byte("sops")) {
|
||||||
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
|
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
|
||||||
}
|
}
|
||||||
var m sopsMeta
|
var m sopsMeta
|
||||||
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
|
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
|
||||||
switch formatOf(file) {
|
switch format {
|
||||||
case "yaml", "json":
|
case "yaml", "json":
|
||||||
var doc struct {
|
var doc struct {
|
||||||
Sops map[string]any `yaml:"sops"`
|
Sops map[string]any `yaml:"sops"`
|
||||||
@@ -1016,6 +1087,7 @@ type key struct {
|
|||||||
name string // db.password, list[0], with . [ ] \ in keys escaped
|
name string // db.password, list[0], with . [ ] \ in keys escaped
|
||||||
extract string // sops --extract: ["db"]["password"]
|
extract string // sops --extract: ["db"]["password"]
|
||||||
encrypted bool
|
encrypted bool
|
||||||
|
jasypt bool // a plain env file's ENC(...) value
|
||||||
line int // 1-based
|
line int // 1-based
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,7 +1112,8 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
|||||||
if !ok || strings.HasPrefix(k, "sops_") {
|
if !ok || strings.HasPrefix(k, "sops_") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
|
||||||
|
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), jasypt: jasypt.Is(v), line: i + 1})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
case "ini":
|
case "ini":
|
||||||
@@ -1105,48 +1178,23 @@ func escape(k string) string {
|
|||||||
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
|
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractFor rebuilds the extract path from a key name, for objects made
|
// extractOf is the sops --extract path of the key named name, for objects
|
||||||
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
|
// made from a link.
|
||||||
// its names are the keys as they are.
|
func extractOf(file, name string) (string, error) {
|
||||||
func extractFor(file, name string) string {
|
b, err := os.ReadFile(file)
|
||||||
if formatOf(file) == "dotenv" {
|
if err != nil {
|
||||||
return quote(name)
|
return "", err
|
||||||
}
|
}
|
||||||
var b, part strings.Builder
|
keys, err := parseKeys(file, b)
|
||||||
flush := func() {
|
if err != nil {
|
||||||
if part.Len() > 0 {
|
return "", err
|
||||||
b.WriteString(quote(part.String()))
|
}
|
||||||
part.Reset()
|
for _, k := range keys {
|
||||||
|
if k.name == name {
|
||||||
|
return k.extract, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := 0; i < len(name); i++ {
|
return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
|
||||||
switch c := name[i]; c {
|
|
||||||
case '\\':
|
|
||||||
if i+1 < len(name) {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
part.WriteByte(name[i])
|
|
||||||
case '.':
|
|
||||||
flush()
|
|
||||||
case '[':
|
|
||||||
end := strings.IndexByte(name[i:], ']')
|
|
||||||
if end < 0 || !digits(name[i+1:i+end]) {
|
|
||||||
part.WriteByte(c)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
b.WriteString(name[i : i+end+1])
|
|
||||||
i += end
|
|
||||||
default:
|
|
||||||
part.WriteByte(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func digits(s string) bool {
|
|
||||||
return s != "" && strings.Trim(s, "0123456789") == ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func absPath(p string) string {
|
func absPath(p string) string {
|
||||||
|
|||||||
@@ -237,6 +237,25 @@ func TestDecrypt(t *testing.T) {
|
|||||||
t.Errorf("from a link: %q %v", v.Reveal(), err)
|
t.Errorf("from a link: %q %v", v.Reveal(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A dotenv file's export lines: sops keeps export in the key.
|
||||||
|
os.WriteFile(plain, []byte("export TOKEN=s3cret\n"), 0o600)
|
||||||
|
enc, err = exec.Command("sops", "encrypt", "--age", recipient, "--input-type", "dotenv", "--output-type", "dotenv", plain).Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
envFile := filepath.Join(root, "prod.env")
|
||||||
|
os.WriteFile(envFile, enc, 0o600)
|
||||||
|
envC := provider.Container{Source: srcs[0], ID: envFile, Name: "prod.env"}
|
||||||
|
envKeys, _ := p.Objects(ctx, envC, provider.TypeSecret)
|
||||||
|
if len(envKeys) != 1 {
|
||||||
|
t.Fatalf("env keys: %v", envKeys)
|
||||||
|
}
|
||||||
|
for _, o := range []provider.Object{envKeys[0], {Container: provider.Container{Source: srcs[0], Name: "prod.env"}, Name: "TOKEN"}} {
|
||||||
|
if v, err := p.Value(ctx, o); err != nil || v.Reveal() != "s3cret" {
|
||||||
|
t.Errorf("export line (link: %t): %q %v", o.ID == "", v.Reveal(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Without the key: sops' own error.
|
// Without the key: sops' own error.
|
||||||
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
|
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
|
||||||
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
|
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
|
||||||
@@ -273,9 +292,12 @@ func TestPlainEnvFiles(t *testing.T) {
|
|||||||
got := map[string]string{}
|
got := map[string]string{}
|
||||||
for _, c := range files {
|
for _, c := range files {
|
||||||
got[c.Name] = c.Detail
|
got[c.Name] = c.Detail
|
||||||
|
if c.Hidden != (c.Name == ".env.example") {
|
||||||
|
t.Errorf("%s: hidden %t", c.Name, c.Hidden)
|
||||||
}
|
}
|
||||||
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
|
}
|
||||||
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
|
if len(got) != 3 || got[".env"] != "env · plain text" || got[".env.example"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
|
||||||
|
t.Fatalf("files %v (want .env, the example, hidden, and the sops file; not node_modules)", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
var env provider.Container
|
var env provider.Container
|
||||||
@@ -300,7 +322,7 @@ func TestPlainEnvFiles(t *testing.T) {
|
|||||||
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
|
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
|
||||||
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
|
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
|
||||||
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
|
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
|
||||||
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
|
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env", ".env.example"}) {
|
||||||
t.Errorf("with rules elsewhere: %v", names)
|
t.Errorf("with rules elsewhere: %v", names)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,15 +576,22 @@ func TestKeyNamesEscaped(t *testing.T) {
|
|||||||
var got []string
|
var got []string
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
got = append(got, k.name+" "+k.extract)
|
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]"]`
|
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 {
|
if strings.Join(got, "|") != want {
|
||||||
t.Errorf("keys = %s\nwant %s", 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)
|
keys, err = parseKeys(".env", []byte("spring.url=a\nexport TOKEN=b\nSPACED = c\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got = nil
|
||||||
|
for _, k := range keys {
|
||||||
|
got = append(got, k.name+" "+k.extract)
|
||||||
|
}
|
||||||
|
want = `spring.url ["spring.url"]|TOKEN ["export TOKEN"]|SPACED ["SPACED "]`
|
||||||
|
if strings.Join(got, "|") != want {
|
||||||
|
t.Errorf("dotenv keys = %s\nwant %s", strings.Join(got, "|"), want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spring Boot config files: application*.properties/.yml and bootstrap*,
|
||||||
|
// their keys filtered by the secret patterns (patterns.go).
|
||||||
|
|
||||||
|
// springName is application.yml, application-prod.properties, bootstrap.yaml
|
||||||
|
// and the like; not applications.yaml (Argo CD's) or application.yml.example.
|
||||||
|
var springName = regexp.MustCompile(`^(application|bootstrap)(-[^.]+)?\.(properties|ya?ml)$`)
|
||||||
|
|
||||||
|
// IsSpring reports whether f is named like a Spring Boot config file, and
|
||||||
|
// isn't a Helm chart's template of one.
|
||||||
|
func IsSpring(f string) bool {
|
||||||
|
return springName.MatchString(strings.ToLower(path.Base(f))) && path.Base(path.Dir(f)) != "templates"
|
||||||
|
}
|
||||||
|
|
||||||
|
func springFormat(f string) string {
|
||||||
|
if strings.ToLower(filepath.Ext(f)) == ".properties" {
|
||||||
|
return "properties"
|
||||||
|
}
|
||||||
|
return "yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
var springType = configType{
|
||||||
|
name: "spring", is: IsSpring, format: springFormat, parse: parseSpring,
|
||||||
|
jasyptSetting: func(string) string { return "jasypt.encryptor.password" },
|
||||||
|
placeholder: springPlaceholder,
|
||||||
|
}
|
||||||
|
|
||||||
|
// springPlaceholderRe is Spring's ${name} or ${name:default}.
|
||||||
|
var springPlaceholderRe = regexp.MustCompile(`^\$\{([^:}]+)(:([^}]*))?\}$`)
|
||||||
|
|
||||||
|
func springPlaceholder(v string) (name, def string, hasDef, ok bool) {
|
||||||
|
m := springPlaceholderRe.FindStringSubmatch(v)
|
||||||
|
if m == nil {
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
return m[1], m[3], m[2] != "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSpring(file string, b []byte) ([]configKey, error) {
|
||||||
|
var keys []configKey
|
||||||
|
var err error
|
||||||
|
if springFormat(file) == "properties" {
|
||||||
|
keys = parseProperties(b)
|
||||||
|
} else {
|
||||||
|
keys, err = parseSpringYAML(b)
|
||||||
|
}
|
||||||
|
for i := range keys {
|
||||||
|
keys[i].ref = reference(springPlaceholder, keys[i].value)
|
||||||
|
}
|
||||||
|
return keys, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// springDocs names the keys of a multi-document file's later documents
|
||||||
|
// after the profile they're for, so each name is one key:
|
||||||
|
// spring.datasource.password (prod).
|
||||||
|
func springDocs(docs [][]configKey) []configKey {
|
||||||
|
var out []configKey
|
||||||
|
for i, doc := range docs {
|
||||||
|
suffix := ""
|
||||||
|
if i > 0 {
|
||||||
|
suffix = fmt.Sprintf(" (document %d)", i+1)
|
||||||
|
for _, k := range doc {
|
||||||
|
if k.key == "spring.config.activate.on-profile" || k.key == "spring.profiles" {
|
||||||
|
suffix = " (" + k.value + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range doc {
|
||||||
|
k.name = k.key + suffix
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mavenFiltered is a value Maven fills in at build time, @project.version@:
|
||||||
|
// not YAML until it's quoted.
|
||||||
|
var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`)
|
||||||
|
|
||||||
|
func parseSpringYAML(b []byte) ([]configKey, error) {
|
||||||
|
b = mavenFiltered.ReplaceAllFunc(b, func(m []byte) []byte {
|
||||||
|
sub := mavenFiltered.FindSubmatch(m)
|
||||||
|
return fmt.Appendf(nil, "%s'%s'", sub[1], bytes.ReplaceAll(sub[2], []byte("'"), []byte("''")))
|
||||||
|
})
|
||||||
|
dec := yaml.NewDecoder(bytes.NewReader(b))
|
||||||
|
var docs [][]configKey
|
||||||
|
for {
|
||||||
|
var doc yaml.Node
|
||||||
|
err := dec.Decode(&doc)
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var keys []configKey
|
||||||
|
if len(doc.Content) > 0 {
|
||||||
|
springWalk(doc.Content[0], "", &keys)
|
||||||
|
}
|
||||||
|
docs = append(docs, keys)
|
||||||
|
}
|
||||||
|
return springDocs(docs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// springWalk names keys the way Spring does: nested keys joined by dots,
|
||||||
|
// list items by index (servers[0]).
|
||||||
|
func springWalk(n *yaml.Node, name string, out *[]configKey) {
|
||||||
|
switch n.Kind {
|
||||||
|
case yaml.MappingNode:
|
||||||
|
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||||
|
child := n.Content[i].Value
|
||||||
|
if name != "" {
|
||||||
|
child = name + "." + child
|
||||||
|
}
|
||||||
|
springWalk(n.Content[i+1], child, out)
|
||||||
|
}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
for i, c := range n.Content {
|
||||||
|
springWalk(c, fmt.Sprintf("%s[%d]", name, i), out)
|
||||||
|
}
|
||||||
|
case yaml.AliasNode:
|
||||||
|
if n.Alias != nil {
|
||||||
|
springWalk(n.Alias, name, out)
|
||||||
|
}
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
if name != "" {
|
||||||
|
*out = append(*out, configKey{key: name, value: n.Value, line: n.Line})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProperties reads a Java .properties file: key=value, key: value or
|
||||||
|
// key value, lines continued with a trailing backslash, # and ! comments,
|
||||||
|
// and #--- between documents (Spring Boot 2.4+).
|
||||||
|
func parseProperties(b []byte) []configKey {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
|
||||||
|
var docs [][]configKey
|
||||||
|
var doc []configKey
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
start := i + 1
|
||||||
|
line := strings.TrimLeftFunc(lines[i], unicode.IsSpace)
|
||||||
|
if line == "#---" || line == "!---" {
|
||||||
|
docs, doc = append(docs, doc), nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line == "" || line[0] == '#' || line[0] == '!' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for continued(line) && i+1 < len(lines) {
|
||||||
|
i++
|
||||||
|
line = line[:len(line)-1] + strings.TrimLeftFunc(lines[i], unicode.IsSpace)
|
||||||
|
}
|
||||||
|
k, v := splitProperty(line)
|
||||||
|
doc = append(doc, configKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start})
|
||||||
|
}
|
||||||
|
return springDocs(append(docs, doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
// continued reports whether the line ends in an odd number of backslashes.
|
||||||
|
func continued(line string) bool {
|
||||||
|
n := len(line) - len(strings.TrimRight(line, `\`))
|
||||||
|
return n%2 == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitProperty cuts a logical line at its first unescaped =, : or
|
||||||
|
// whitespace; whitespace around the separator isn't part of either side.
|
||||||
|
func splitProperty(line string) (key, value string) {
|
||||||
|
const space = " \t\f"
|
||||||
|
for i := 0; i < len(line); i++ {
|
||||||
|
switch c := line[i]; {
|
||||||
|
case c == '\\':
|
||||||
|
i++
|
||||||
|
case c == '=' || c == ':':
|
||||||
|
return line[:i], strings.TrimLeft(line[i+1:], space)
|
||||||
|
case strings.IndexByte(space, c) >= 0:
|
||||||
|
rest := strings.TrimLeft(line[i:], space)
|
||||||
|
if rest != "" && (rest[0] == '=' || rest[0] == ':') {
|
||||||
|
rest = strings.TrimLeft(rest[1:], space)
|
||||||
|
}
|
||||||
|
return line[:i], rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeProperty(s string) string {
|
||||||
|
if !strings.Contains(s, `\`) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] != '\\' || i+1 == len(s) {
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
switch s[i] {
|
||||||
|
case 't':
|
||||||
|
b.WriteByte('\t')
|
||||||
|
case 'n':
|
||||||
|
b.WriteByte('\n')
|
||||||
|
case 'r':
|
||||||
|
b.WriteByte('\r')
|
||||||
|
case 'f':
|
||||||
|
b.WriteByte('\f')
|
||||||
|
case 'u':
|
||||||
|
if i+5 <= len(s) {
|
||||||
|
if r, err := strconv.ParseUint(s[i+1:i+5], 16, 32); err == nil {
|
||||||
|
b.WriteRune(rune(r))
|
||||||
|
i += 4
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte('u')
|
||||||
|
default:
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsSpring(t *testing.T) {
|
||||||
|
for f, want := range map[string]bool{
|
||||||
|
"src/main/resources/application.yml": true,
|
||||||
|
"application-prod.properties": true,
|
||||||
|
"config/application-local.yaml": true,
|
||||||
|
"bootstrap.yml": true,
|
||||||
|
"applicationset/dev/applications.yaml": false, // Argo CD's
|
||||||
|
"chart/templates/application.yaml": false, // Helm's
|
||||||
|
"application.yml.example": false,
|
||||||
|
"application.json": false,
|
||||||
|
"src/main/resources/applicationContext.xml": false,
|
||||||
|
} {
|
||||||
|
if IsSpring(f) != want {
|
||||||
|
t.Errorf("%s: %t", f, !want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func springNames(keys []configKey) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, k := range keys {
|
||||||
|
out[k.name] = k.value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseProperties(t *testing.T) {
|
||||||
|
got := springNames(parseProperties([]byte(`# a comment
|
||||||
|
! another
|
||||||
|
a=1
|
||||||
|
b : 2
|
||||||
|
c 3
|
||||||
|
d\:e=4
|
||||||
|
long=one, \
|
||||||
|
two
|
||||||
|
unicode=café
|
||||||
|
empty=
|
||||||
|
#---
|
||||||
|
spring.config.activate.on-profile=prod
|
||||||
|
a=prod-1
|
||||||
|
`)))
|
||||||
|
want := map[string]string{
|
||||||
|
"a": "1", "b": "2", "c": "3", "d:e": "4", "long": "one, two", "unicode": "café", "empty": "",
|
||||||
|
"spring.config.activate.on-profile (prod)": "prod", "a (prod)": "prod-1",
|
||||||
|
}
|
||||||
|
if !maps.Equal(got, want) {
|
||||||
|
t.Errorf("got %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSpringYAML(t *testing.T) {
|
||||||
|
keys, err := parseSpringYAML([]byte(`info.version: @project.version@
|
||||||
|
artifacts:
|
||||||
|
- @project.artifactId@
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
password: ${DB_PASSWORD}
|
||||||
|
servers:
|
||||||
|
- name: a
|
||||||
|
secret: x
|
||||||
|
logging.level:
|
||||||
|
com.example: DEBUG
|
||||||
|
---
|
||||||
|
spring:
|
||||||
|
config.activate.on-profile: prod
|
||||||
|
datasource:
|
||||||
|
password: hunter2
|
||||||
|
---
|
||||||
|
other: y
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"info.version": "@project.version@", "artifacts[0]": "@project.artifactId@",
|
||||||
|
"spring.datasource.password": "${DB_PASSWORD}", "servers[0].name": "a", "servers[0].secret": "x",
|
||||||
|
"logging.level.com.example": "DEBUG",
|
||||||
|
"spring.config.activate.on-profile (prod)": "prod", "spring.datasource.password (prod)": "hunter2",
|
||||||
|
"other (document 3)": "y",
|
||||||
|
}
|
||||||
|
if got := springNames(keys); !maps.Equal(got, want) {
|
||||||
|
t.Errorf("got %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpringDefaults(t *testing.T) {
|
||||||
|
f, err := (&Provider{}).filterFor(t.TempDir(), "spring")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"spring.datasource.password": "x",
|
||||||
|
"spring.security.oauth2.client.registration.a.client-secret": "x",
|
||||||
|
"profapp.payment.checkoutKey": "x",
|
||||||
|
"app.clients.sgig.sgig-passord": "x",
|
||||||
|
"jasypt.encryptor.password": "${JASYPT_ENCRYPTOR_PASSWORD}",
|
||||||
|
"spring.datasource.url": "jdbc:postgresql://app:hunter2@db:5432/app",
|
||||||
|
"app.servicebus.connection": "Endpoint=sb://x/;SharedAccessKeyName=a;SharedAccessKey=b",
|
||||||
|
"app.name": "ENC(abc==)",
|
||||||
|
} {
|
||||||
|
if !f.secret(key, value) {
|
||||||
|
t.Errorf("%s=%s isn't listed", key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"spring.security.oauth2.client.provider.a.token-uri": "https://idp/token",
|
||||||
|
"spring.security.oauth2.client.registration.a.authorization-grant-type": "client_credentials",
|
||||||
|
"app.frontend.keycloak.url": "https://keycloak",
|
||||||
|
"server.port": "8080",
|
||||||
|
"spring.datasource.url": "jdbc:postgresql://db:5432/app",
|
||||||
|
} {
|
||||||
|
if f.secret(key, value) {
|
||||||
|
t.Errorf("%s=%s is listed", key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpringFiles(t *testing.T) {
|
||||||
|
root := gitRepo(t, map[string]string{
|
||||||
|
"src/main/resources/application.yml": `server:
|
||||||
|
port: 8080
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://db/app
|
||||||
|
password: ${DB_PASSWORD}
|
||||||
|
jasypt:
|
||||||
|
encryptor:
|
||||||
|
password: ${JASYPT_ENCRYPTOR_PASSWORD}
|
||||||
|
app:
|
||||||
|
api-key: ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)
|
||||||
|
`,
|
||||||
|
"src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n",
|
||||||
|
"applicationset/applications.yaml": "kind: ApplicationSet\n",
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
details := func(p *Provider, c provider.Container) map[string]provider.Attr {
|
||||||
|
attrs, err := p.Details(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := map[string]provider.Attr{}
|
||||||
|
for _, a := range attrs {
|
||||||
|
out[a.Label] = a
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
p := New(Settings{Dir: root})
|
||||||
|
srcs, _ := p.Sources(ctx)
|
||||||
|
cs, err := p.Containers(ctx, srcs[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, c := range cs {
|
||||||
|
files = append(files, c.Name+" · "+c.Detail)
|
||||||
|
}
|
||||||
|
if want := []string{
|
||||||
|
"src/main/resources/application-local.properties · spring · properties",
|
||||||
|
"src/main/resources/application.yml · spring · yaml",
|
||||||
|
}; !slices.Equal(files, want) {
|
||||||
|
t.Fatalf("files %v\nwant %v", files, want)
|
||||||
|
}
|
||||||
|
local, yml := cs[0], cs[1]
|
||||||
|
|
||||||
|
listed, _ := p.Objects(ctx, yml, provider.TypeSecret)
|
||||||
|
keys := shown(listed)
|
||||||
|
var names []string
|
||||||
|
for _, k := range keys {
|
||||||
|
names = append(names, strings.TrimSpace(k.Name+" "+k.Detail))
|
||||||
|
}
|
||||||
|
if len(listed) != 5 || !listed[0].Hidden || listed[0].Name != "server.port" {
|
||||||
|
t.Errorf("hidden keys: %+v", listed)
|
||||||
|
}
|
||||||
|
if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) {
|
||||||
|
t.Errorf("keys %v, want %v", names, want)
|
||||||
|
}
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
|
||||||
|
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "hunter2" {
|
||||||
|
t.Errorf("value: %q %v", v.Reveal(), err)
|
||||||
|
}
|
||||||
|
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"}
|
||||||
|
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "${DB_PASSWORD}" {
|
||||||
|
t.Errorf("from a link: %q %v", v.Reveal(), err)
|
||||||
|
}
|
||||||
|
if f, _ := p.File(yml, &keys[1]); f.Line != 9 || f.Sops != "" {
|
||||||
|
t.Errorf("file: %+v", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// References and Jasypt aren't plain text in git; a password is.
|
||||||
|
if d := details(p, yml); d["hidden keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" {
|
||||||
|
t.Errorf("yml: %+v", d)
|
||||||
|
}
|
||||||
|
if d := details(p, local); !d["git"].Warn {
|
||||||
|
t.Errorf("local: %+v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The config's patterns, and the repo's over them.
|
||||||
|
all := []string{".*"}
|
||||||
|
p = New(Settings{Dir: root, SecretPatterns: SecretPatterns{Keys: &all}})
|
||||||
|
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 5 {
|
||||||
|
t.Errorf("every key: %d", len(shown(keys)))
|
||||||
|
}
|
||||||
|
lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
|
||||||
|
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"url$\"]\n values: []\n"), 0o644)
|
||||||
|
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 1 || shown(keys)[0].Name != "spring.datasource.url" {
|
||||||
|
t.Errorf("repo's patterns: %v", keys)
|
||||||
|
}
|
||||||
|
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"(\"]\n"), 0o644)
|
||||||
|
if _, err := p.Objects(ctx, yml, provider.TypeSecret); err == nil || !strings.Contains(err.Error(), ".lazysecrets.yaml: spring.secretPatterns.keys[0]") {
|
||||||
|
t.Errorf("bad pattern: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shown(objects []provider.Object) []provider.Object {
|
||||||
|
return slices.DeleteFunc(slices.Clone(objects), func(o provider.Object) bool { return o.Hidden })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHiddenFiles(t *testing.T) {
|
||||||
|
root := gitRepo(t, map[string]string{
|
||||||
|
"a/application.yml": "server:\n port: 8080\n",
|
||||||
|
"b/application.yml": "spring.datasource.password: x\n",
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
p := New(Settings{Dir: root})
|
||||||
|
srcs, _ := p.Sources(ctx)
|
||||||
|
if len(srcs) != 1 || srcs[0].Hidden {
|
||||||
|
t.Fatalf("sources: %+v", srcs)
|
||||||
|
}
|
||||||
|
cs, _ := p.Containers(ctx, srcs[0])
|
||||||
|
if len(cs) != 2 || !cs[0].Hidden || cs[1].Hidden {
|
||||||
|
t.Errorf("files: %+v", cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A repo with only hidden files is hidden too.
|
||||||
|
root = gitRepo(t, map[string]string{"application.yml": "server:\n port: 8080\n"})
|
||||||
|
srcs, _ = New(Settings{Dir: root}).Sources(ctx)
|
||||||
|
if len(srcs) != 1 || !srcs[0].Hidden {
|
||||||
|
t.Errorf("hidden repo: %+v", srcs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,6 @@ func NewSecretValue(b []byte) SecretValue {
|
|||||||
// Reveal returns the plaintext. Call it only to display or copy the value.
|
// Reveal returns the plaintext. Call it only to display or copy the value.
|
||||||
func (v SecretValue) Reveal() string { return string(v.b) }
|
func (v SecretValue) Reveal() string { return string(v.b) }
|
||||||
|
|
||||||
func (v SecretValue) Len() int { return len(v.b) }
|
|
||||||
|
|
||||||
func (v SecretValue) String() string { return masked }
|
func (v SecretValue) String() string { return masked }
|
||||||
func (v SecretValue) GoString() string { return masked }
|
func (v SecretValue) GoString() string { return masked }
|
||||||
|
|
||||||
|
|||||||
+7
-13
@@ -92,15 +92,9 @@ func (r Ref) ContainerName() string {
|
|||||||
// Container.Detail.
|
// Container.Detail.
|
||||||
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
|
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
|
||||||
|
|
||||||
var collections = map[provider.ObjectType]string{
|
|
||||||
provider.TypeSecret: "secrets",
|
|
||||||
provider.TypeKey: "keys",
|
|
||||||
provider.TypeCertificate: "certificates",
|
|
||||||
}
|
|
||||||
|
|
||||||
func typeOf(collection string) (provider.ObjectType, bool) {
|
func typeOf(collection string) (provider.ObjectType, bool) {
|
||||||
for t, c := range collections {
|
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||||
if strings.EqualFold(c, collection) {
|
if strings.EqualFold(t.Collection(), collection) {
|
||||||
return t, true
|
return t, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +112,7 @@ func (r Ref) IdentifierURL() string {
|
|||||||
if r.Type == "" {
|
if r.Type == "" {
|
||||||
return u + "/"
|
return u + "/"
|
||||||
}
|
}
|
||||||
for _, seg := range []string{collections[r.Type], r.Name, r.Version} {
|
for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} {
|
||||||
if seg != "" {
|
if seg != "" {
|
||||||
u += "/" + url.PathEscape(seg)
|
u += "/" + url.PathEscape(seg)
|
||||||
}
|
}
|
||||||
@@ -140,7 +134,7 @@ func (r Ref) String() string {
|
|||||||
segs = append(segs, r.Scope, r.Name)
|
segs = append(segs, r.Scope, r.Name)
|
||||||
default:
|
default:
|
||||||
if r.Type != "" {
|
if r.Type != "" {
|
||||||
segs = append(segs, collections[r.Type])
|
segs = append(segs, r.Type.Collection())
|
||||||
}
|
}
|
||||||
segs = append(segs, r.Name, r.Version)
|
segs = append(segs, r.Name, r.Version)
|
||||||
}
|
}
|
||||||
@@ -273,7 +267,7 @@ func parseLink(s string) (Ref, error) {
|
|||||||
r.Host = strings.ToLower(segs[0])
|
r.Host = strings.ToLower(segs[0])
|
||||||
rest := segs[1:]
|
rest := segs[1:]
|
||||||
|
|
||||||
switch strings.ToLower(u.Host) {
|
switch host := strings.ToLower(u.Host); host {
|
||||||
case "azure":
|
case "azure":
|
||||||
r.Kind = provider.KindAzure
|
r.Kind = provider.KindAzure
|
||||||
if r.Host == "subscriptions" {
|
if r.Host == "subscriptions" {
|
||||||
@@ -300,11 +294,11 @@ func parseLink(s string) (Ref, error) {
|
|||||||
}
|
}
|
||||||
case "databricks", "repo":
|
case "databricks", "repo":
|
||||||
r.Kind = provider.KindDatabricks
|
r.Kind = provider.KindDatabricks
|
||||||
if strings.EqualFold(u.Host, "repo") {
|
if host == "repo" {
|
||||||
r.Kind = provider.KindRepo
|
r.Kind = provider.KindRepo
|
||||||
}
|
}
|
||||||
if len(rest) > 2 {
|
if len(rest) > 2 {
|
||||||
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host))
|
return Ref{}, fmt.Errorf("%s link has too many parts", host)
|
||||||
}
|
}
|
||||||
if len(rest) > 0 {
|
if len(rest) > 0 {
|
||||||
r.Scope = rest[0]
|
r.Scope = rest[0]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rules are what a key's value must be to share with it: random enough
|
// Rules are what a key's value must be to share with it: random enough
|
||||||
@@ -40,14 +41,18 @@ func (r Rules) Check(key []byte) error {
|
|||||||
kinds++
|
kinds++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
n := len([]rune(s))
|
n := utf8.RuneCountInString(s)
|
||||||
var has, needs []string
|
var has, needs []string
|
||||||
if n < r.MinLength {
|
if n < r.MinLength {
|
||||||
has = append(has, fmt.Sprintf("%d characters", n))
|
has = append(has, fmt.Sprintf("%d characters", n))
|
||||||
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
|
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
|
||||||
}
|
}
|
||||||
if kinds < r.MinClasses {
|
if kinds < r.MinClasses {
|
||||||
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
|
kind := "kinds"
|
||||||
|
if kinds == 1 {
|
||||||
|
kind = "kind"
|
||||||
|
}
|
||||||
|
has = append(has, fmt.Sprintf("%d %s of character", kinds, kind))
|
||||||
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
|
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
|
||||||
}
|
}
|
||||||
if len(has) == 0 {
|
if len(has) == 0 {
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func Read(r ref.Ref) (Sealed, error) {
|
|||||||
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
|
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
|
||||||
}
|
}
|
||||||
n, size := binary.Uvarint(b[1:])
|
n, size := binary.Uvarint(b[1:])
|
||||||
if size <= 0 || n > uint64(len(b)) {
|
if size <= 0 {
|
||||||
return Sealed{}, bad
|
return Sealed{}, bad
|
||||||
}
|
}
|
||||||
rest := b[1+size:]
|
rest := b[1+size:]
|
||||||
|
|||||||
+16
-17
@@ -3,6 +3,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -231,6 +232,7 @@ type Model struct {
|
|||||||
// provider finds it (provider.Current), before Sources lists them all.
|
// provider finds it (provider.Current), before Sources lists them all.
|
||||||
current *provider.Source
|
current *provider.Source
|
||||||
flatSources bool // Sources pane without provider groups
|
flatSources bool // Sources pane without provider groups
|
||||||
|
showHidden bool // H: what providers hide is listed too, dimmed
|
||||||
sourcesFavs bool // Sources pane on its Favourites tab
|
sourcesFavs bool // Sources pane on its Favourites tab
|
||||||
history bool // count uses of items (usage.go)
|
history bool // count uses of items (usage.go)
|
||||||
onActive func()
|
onActive func()
|
||||||
@@ -468,6 +470,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
Filter: func(c provider.Container) string { return c.Name },
|
Filter: func(c provider.Container) string { return c.Name },
|
||||||
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
|
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
|
||||||
Render: m.containerRender(false),
|
Render: m.containerRender(false),
|
||||||
|
Hidden: func(c provider.Container) bool { return c.Hidden },
|
||||||
})
|
})
|
||||||
|
|
||||||
m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{
|
m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{
|
||||||
@@ -477,6 +480,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
// Typing "expir" finds what has expired or soon will.
|
// Typing "expir" finds what has expired or soon will.
|
||||||
FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
|
FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
|
||||||
Render: m.objectRender(),
|
Render: m.objectRender(),
|
||||||
|
Hidden: func(o provider.Object) bool { return o.Hidden },
|
||||||
})
|
})
|
||||||
m.secrets.Empty = "no secrets"
|
m.secrets.Empty = "no secrets"
|
||||||
m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret
|
m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret
|
||||||
@@ -653,7 +657,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case detailsMsg:
|
case detailsMsg:
|
||||||
if msg.req == m.reqID[slotDetails] {
|
if msg.req == m.reqID[slotDetails] {
|
||||||
m.done(slotDetails)
|
m.cancelReq(slotDetails)
|
||||||
}
|
}
|
||||||
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
|
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
|
||||||
delete(m.details, msg.key) // tried again when shown again
|
delete(m.details, msg.key) // tried again when shown again
|
||||||
@@ -684,7 +688,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotContainers] {
|
if msg.req != m.reqID[slotContainers] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotContainers)
|
m.cancelReq(slotContainers)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
if src, ok := m.selectedSource(); ok {
|
if src, ok := m.selectedSource(); ok {
|
||||||
@@ -702,7 +706,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotObjects] {
|
if msg.req != m.reqID[slotObjects] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotObjects)
|
m.cancelReq(slotObjects)
|
||||||
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
|
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
|
||||||
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
|
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
|
||||||
m.closeVersions()
|
m.closeVersions()
|
||||||
@@ -732,7 +736,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotValue] {
|
if msg.req != m.reqID[slotValue] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotValue)
|
m.cancelReq(slotValue)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||||
@@ -747,7 +751,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotVersions] {
|
if msg.req != m.reqID[slotVersions] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotVersions)
|
m.cancelReq(slotVersions)
|
||||||
// Errors and unsupported providers just leave the count out.
|
// Errors and unsupported providers just leave the count out.
|
||||||
if msg.err == nil {
|
if msg.err == nil {
|
||||||
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||||
@@ -759,7 +763,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotDescribe] {
|
if msg.req != m.reqID[slotDescribe] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotDescribe)
|
m.cancelReq(slotDescribe)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||||
@@ -774,7 +778,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotCopy] {
|
if msg.req != m.reqID[slotCopy] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotCopy)
|
m.cancelReq(slotCopy)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
|
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
|
||||||
@@ -886,7 +890,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
|||||||
case key.Matches(msg, k.Quit):
|
case key.Matches(msg, k.Quit):
|
||||||
return nil, true
|
return nil, true
|
||||||
case key.Matches(msg, k.Help):
|
case key.Matches(msg, k.Help):
|
||||||
return m.showKeybindings(), false
|
return m.showKeybindingsFor(""), false
|
||||||
case key.Matches(msg, k.Up):
|
case key.Matches(msg, k.Up):
|
||||||
m.move(-1)
|
m.move(-1)
|
||||||
case key.Matches(msg, k.Down):
|
case key.Matches(msg, k.Down):
|
||||||
@@ -978,6 +982,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
|||||||
m.cycleZoom(-1)
|
m.cycleZoom(-1)
|
||||||
case key.Matches(msg, k.SourcesView):
|
case key.Matches(msg, k.SourcesView):
|
||||||
m.toggleSourcesView()
|
m.toggleSourcesView()
|
||||||
|
case key.Matches(msg, k.ToggleHidden):
|
||||||
|
m.toggleHidden()
|
||||||
case key.Matches(msg, k.Versions):
|
case key.Matches(msg, k.Versions):
|
||||||
if m.versionsOf != nil {
|
if m.versionsOf != nil {
|
||||||
m.closeVersions()
|
m.closeVersions()
|
||||||
@@ -1166,14 +1172,11 @@ func (m *Model) syncSelection() tea.Cmd {
|
|||||||
if m.onLinks() {
|
if m.onLinks() {
|
||||||
m.showLinkedContainers()
|
m.showLinkedContainers()
|
||||||
} else if src, ok := m.selectedSource(); ok {
|
} else if src, ok := m.selectedSource(); ok {
|
||||||
m.containers.Title = "[2] Vaults"
|
|
||||||
m.containers.Empty = "no vaults found"
|
m.containers.Empty = "no vaults found"
|
||||||
switch src.Kind {
|
switch src.Kind {
|
||||||
case provider.KindDatabricks:
|
case provider.KindDatabricks:
|
||||||
m.containers.Title = "[2] Scopes"
|
|
||||||
m.containers.Empty = "no secret scopes"
|
m.containers.Empty = "no secret scopes"
|
||||||
case provider.KindRepo:
|
case provider.KindRepo:
|
||||||
m.containers.Title = "[2] Files"
|
|
||||||
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
|
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
|
||||||
}
|
}
|
||||||
cmds = append(cmds, m.showContainers(src, id))
|
cmds = append(cmds, m.showContainers(src, id))
|
||||||
@@ -1318,8 +1321,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeVersions returns the Secrets pane to the object list, with the
|
// closeVersions returns the Secrets pane to the object list.
|
||||||
// cursor on the object whose versions were shown.
|
|
||||||
func (m *Model) closeVersions() {
|
func (m *Model) closeVersions() {
|
||||||
m.versionsOf = nil
|
m.versionsOf = nil
|
||||||
}
|
}
|
||||||
@@ -1516,9 +1518,6 @@ func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// done releases the context of a completed request.
|
|
||||||
func (m *Model) done(p slot) { m.cancelReq(p) }
|
|
||||||
|
|
||||||
// loadSources starts a new generation of source loads, superseding any in
|
// loadSources starts a new generation of source loads, superseding any in
|
||||||
// flight.
|
// flight.
|
||||||
func (m *Model) loadSources() tea.Cmd {
|
func (m *Model) loadSources() tea.Cmd {
|
||||||
@@ -1638,7 +1637,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
|||||||
func (m *Model) copyValue() tea.Cmd {
|
func (m *Model) copyValue() tea.Cmd {
|
||||||
if m.focus == paneDetail && m.fields.Shown() {
|
if m.focus == paneDetail && m.fields.Shown() {
|
||||||
if f, ok := m.fields.Selected(); ok {
|
if f, ok := m.fields.Selected(); ok {
|
||||||
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
|
return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
|
|||||||
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
|
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
|
||||||
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
||||||
}
|
}
|
||||||
if !strings.Contains(lastLine(scr), "z show") {
|
if !strings.Contains(lastLines(scr, 1), "z show") {
|
||||||
t.Errorf("help bar: %q", lastLine(scr))
|
t.Errorf("help bar: %q", lastLines(scr, 1))
|
||||||
}
|
}
|
||||||
d.keys("z")
|
d.keys("z")
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
|
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
|
||||||
t.Errorf("help bar with the value shown: %q", bar)
|
t.Errorf("help bar with the value shown: %q", bar)
|
||||||
}
|
}
|
||||||
d.keys("z")
|
d.keys("z")
|
||||||
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
|
|||||||
if !strings.Contains(scr, "Alice Example") {
|
if !strings.Contains(scr, "Alice Example") {
|
||||||
t.Errorf("account not shown:\n%s", scr)
|
t.Errorf("account not shown:\n%s", scr)
|
||||||
}
|
}
|
||||||
bar := lastLine(scr)
|
bar := lastLines(scr, 1)
|
||||||
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
|
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
|
||||||
t.Errorf("help bar: %q", bar)
|
t.Errorf("help bar: %q", bar)
|
||||||
}
|
}
|
||||||
@@ -1344,3 +1344,34 @@ func TestErrorLog(t *testing.T) {
|
|||||||
t.Errorf("log %+v", d.m.errLog)
|
t.Errorf("log %+v", d.m.errLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hiding marks the secret db-password hidden, as the repo provider does
|
||||||
|
// a Spring key that doesn't look secret.
|
||||||
|
type hiding struct{ provider.Provider }
|
||||||
|
|
||||||
|
func (h hiding) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||||
|
objs, err := h.Provider.Objects(ctx, c, t)
|
||||||
|
for i := range objs {
|
||||||
|
objs[i].Hidden = objs[i].Name == "db-password"
|
||||||
|
}
|
||||||
|
return objs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleHidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
d := newDriver(t, hiding{fake.Azure(0)})
|
||||||
|
if _, _, s := d.selected(); s == "db-password" || s == "" {
|
||||||
|
t.Fatalf("selected %q: the hidden one, or none", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "1 hidden") {
|
||||||
|
t.Fatalf("hidden one listed:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
d.keys("H")
|
||||||
|
if !strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "showing hidden") {
|
||||||
|
t.Fatalf("H doesn't show it:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
d.keys("H")
|
||||||
|
if strings.Contains(d.screen(), "db-password") {
|
||||||
|
t.Fatalf("H again doesn't hide it:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
|
|||||||
if i < 0 || sameListing(before[i], s) {
|
if i < 0 || sameListing(before[i], s) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.detail.Refresh(s)
|
m.detail.SetDescribed(s)
|
||||||
uncache(m, m.cache.details, detailKey(s))
|
uncache(m, m.cache.details, detailKey(s))
|
||||||
return m.describe(s)
|
return m.describe(s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -84,10 +85,7 @@ func (m *Model) showCommands() tea.Cmd {
|
|||||||
}
|
}
|
||||||
var opts []option
|
var opts []option
|
||||||
for _, c := range cmds {
|
for _, c := range cmds {
|
||||||
label := c.Description
|
label := cmp.Or(c.Description, c.Command)
|
||||||
if label == "" {
|
|
||||||
label = c.Command
|
|
||||||
}
|
|
||||||
if c.Key != "" {
|
if c.Key != "" {
|
||||||
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
|
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
|
||||||
}
|
}
|
||||||
@@ -122,7 +120,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
|
|||||||
in.SetValue(p.Default)
|
in.SetValue(p.Default)
|
||||||
in.CursorEnd()
|
in.CursorEnd()
|
||||||
m.prompt = &modal{
|
m.prompt = &modal{
|
||||||
title: cmpOr(c.Description, "Command"), input: &in,
|
title: cmp.Or(c.Description, "Command"), input: &in,
|
||||||
submit: func(m *Model, text string) tea.Cmd {
|
submit: func(m *Model, text string) tea.Cmd {
|
||||||
it.Prompt[p.Name] = commands.Q(text)
|
it.Prompt[p.Name] = commands.Q(text)
|
||||||
return m.askPrompt(c, it, obj, i+1)
|
return m.askPrompt(c, it, obj, i+1)
|
||||||
@@ -151,7 +149,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
|
|||||||
case strings.HasPrefix(c.Value, "env:"):
|
case strings.HasPrefix(c.Value, "env:"):
|
||||||
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
|
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
|
||||||
}
|
}
|
||||||
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
|
m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
|
||||||
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
|
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
@@ -177,7 +175,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
|
|||||||
|
|
||||||
// execCommand runs the command as its output mode says.
|
// execCommand runs the command as its output mode says.
|
||||||
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
|
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
|
||||||
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
|
shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
|
||||||
setValue := func(cmd *exec.Cmd) {
|
setValue := func(cmd *exec.Cmd) {
|
||||||
switch {
|
switch {
|
||||||
case c.Value == "stdin":
|
case c.Value == "stdin":
|
||||||
@@ -195,7 +193,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
|
|||||||
m.flash = flash{}
|
m.flash = flash{}
|
||||||
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
|
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
|
||||||
}
|
}
|
||||||
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
|
m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -219,7 +217,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
|
|||||||
const maxOutputLines = 30
|
const maxOutputLines = 30
|
||||||
|
|
||||||
func (m *Model) handleCommandDone(msg commandDoneMsg) {
|
func (m *Model) handleCommandDone(msg commandDoneMsg) {
|
||||||
name := cmpOr(msg.cmd.Description, "command")
|
name := cmp.Or(msg.cmd.Description, "command")
|
||||||
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
|
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
|
||||||
switch {
|
switch {
|
||||||
case msg.err != nil:
|
case msg.err != nil:
|
||||||
@@ -298,11 +296,7 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
|
|||||||
if r, err := ref.ForObject(o, version); err == nil {
|
if r, err := ref.ForObject(o, version); err == nil {
|
||||||
it.Link = commands.Q(r.String())
|
it.Link = commands.Q(r.String())
|
||||||
}
|
}
|
||||||
if version != "" {
|
o.Version = version // "": the current value, like copying it
|
||||||
o.Version = version
|
|
||||||
} else {
|
|
||||||
o.Version = "" // the current value, like copying it
|
|
||||||
}
|
|
||||||
return it, &o, true
|
return it, &o, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,10 +309,3 @@ func newTextInput(prompt string, width int) textinput.Model {
|
|||||||
|
|
||||||
// pathInputWidth fits a link or a folder path in a prompt, in the window.
|
// pathInputWidth fits a link or a folder path in a prompt, in the window.
|
||||||
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
|
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
|
||||||
|
|
||||||
func cmpOr(a, b string) string {
|
|
||||||
if a != "" {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
|
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
|
||||||
@@ -34,7 +35,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
|
|||||||
// temporary path's length.
|
// temporary path's length.
|
||||||
shown := ""
|
shown := ""
|
||||||
if d.m.prompt != nil {
|
if d.m.prompt != nil {
|
||||||
shown = ansiStrip(strings.Join(d.m.prompt.lines, ""))
|
shown = ansi.Strip(strings.Join(d.m.prompt.lines, ""))
|
||||||
}
|
}
|
||||||
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
|
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
|
||||||
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
|
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
|
|||||||
return tea.QuitMsg{}
|
return tea.QuitMsg{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
||||||
// Through the shell, so EDITOR can carry arguments ("code -w").
|
// Through the shell, so EDITOR can carry arguments ("code -w").
|
||||||
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
||||||
m.flash = flash{}
|
m.flash = flash{}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
@@ -30,7 +31,7 @@ func (m Model) expiringItems() []expiringItem {
|
|||||||
for _, c := range m.cache.containers[row].value {
|
for _, c := range m.cache.containers[row].value {
|
||||||
for _, t := range c.ObjectTypes() {
|
for _, t := range c.ObjectTypes() {
|
||||||
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||||
if state, _ := m.expiry(o); state != notExpiring {
|
if state, _ := m.expiry(o); state != expiry.None {
|
||||||
out = append(out, expiringItem{o, row})
|
out = append(out, expiringItem{o, row})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,11 +52,11 @@ func (m Model) expiringFields() []panes.Field {
|
|||||||
var out []panes.Field
|
var out []panes.Field
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
state, left := m.expiry(it.o)
|
state, left := m.expiry(it.o)
|
||||||
mark := "⚠ " + daysLeft(left)
|
mark := "⚠ " + expiry.Left(left)
|
||||||
if state == expired {
|
if state == expiry.Expired {
|
||||||
mark = "✗ expired"
|
mark = "✗ expired"
|
||||||
}
|
}
|
||||||
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
|
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
|
||||||
if r, err := ref.ForObject(it.o, ""); err == nil {
|
if r, err := ref.ForObject(it.o, ""); err == nil {
|
||||||
f.Link = r.String()
|
f.Link = r.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,30 +11,19 @@ import (
|
|||||||
// list once they have expired (✗ expired) or will within the configured
|
// list once they have expired (✗ expired) or will within the configured
|
||||||
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
||||||
|
|
||||||
type expiryState = expiry.State
|
|
||||||
|
|
||||||
const (
|
|
||||||
notExpiring = expiry.None
|
|
||||||
expiresSoon = expiry.Soon
|
|
||||||
expired = expiry.Expired
|
|
||||||
)
|
|
||||||
|
|
||||||
// expiry tells whether o has expired or will within the warning period,
|
// expiry tells whether o has expired or will within the warning period,
|
||||||
// and how long it has left.
|
// and how long it has left.
|
||||||
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
|
func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
|
||||||
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
|
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
|
||||||
}
|
}
|
||||||
|
|
||||||
// expiryWord is matched by the filter: "expired" or "expiring".
|
// expiryWord is matched by the filter: "expired" or "expiring".
|
||||||
func (m Model) expiryWord(o provider.Object) string {
|
func (m Model) expiryWord(o provider.Object) string {
|
||||||
switch state, _ := m.expiry(o); state {
|
switch state, _ := m.expiry(o); state {
|
||||||
case expired:
|
case expiry.Expired:
|
||||||
return "expired"
|
return "expired"
|
||||||
case expiresSoon:
|
case expiry.Soon:
|
||||||
return "expiring"
|
return "expiring"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// daysLeft is a short "12d", or "5h" on the last day.
|
|
||||||
func daysLeft(d time.Duration) string { return expiry.Left(d) }
|
|
||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
@@ -100,11 +102,17 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
|
|||||||
n := st.Node(storeKey(c))
|
n := st.Node(storeKey(c))
|
||||||
line := c.Name
|
line := c.Name
|
||||||
if c.Source.Kind == provider.KindRepo {
|
if c.Source.Kind == provider.KindRepo {
|
||||||
// A secret file: a lock for an encrypted one, a file in the
|
// A secret file: a lock for an encrypted one, a leaf for
|
||||||
// warning colour for plain text, and its type after it
|
// Spring config, a whale for compose, a file in the warning
|
||||||
// ("sops · age", "env · plain text").
|
// colour for plain text, and its type after it ("sops ·
|
||||||
|
// age", "env · plain text").
|
||||||
icon, style := iconSet.Encrypted, sty.KindRepo
|
icon, style := iconSet.Encrypted, sty.KindRepo
|
||||||
if !strings.HasPrefix(c.Detail, "sops") {
|
switch {
|
||||||
|
case strings.HasPrefix(c.Detail, "spring"):
|
||||||
|
icon, style = iconSet.Spring, sty.Spring
|
||||||
|
case strings.HasPrefix(c.Detail, "compose"):
|
||||||
|
icon, style = iconSet.Compose, sty.Compose
|
||||||
|
case !strings.HasPrefix(c.Detail, "sops"):
|
||||||
icon, style = iconSet.PlainFile, sty.Warning
|
icon, style = iconSet.PlainFile, sty.Warning
|
||||||
}
|
}
|
||||||
if icon != "" {
|
if icon != "" {
|
||||||
@@ -112,7 +120,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
|
|||||||
}
|
}
|
||||||
line += sty.Muted.Render(" " + c.Detail)
|
line += sty.Muted.Render(" " + c.Detail)
|
||||||
}
|
}
|
||||||
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
|
line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line)
|
||||||
if withSource {
|
if withSource {
|
||||||
src := c.Source.Name
|
src := c.Source.Name
|
||||||
if account := accountOf(c.Source); account != "" {
|
if account := accountOf(c.Source); account != "" {
|
||||||
@@ -133,16 +141,29 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
|||||||
line = sty.Muted.Render(o.Name + " (disabled)")
|
line = sty.Muted.Render(o.Name + " (disabled)")
|
||||||
}
|
}
|
||||||
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
||||||
|
if o.Detail != "" {
|
||||||
|
line += sty.Muted.Render(" " + o.Detail)
|
||||||
|
}
|
||||||
|
line = dimIf(sty, o.Hidden, line)
|
||||||
switch state, left := m.expiry(o); state {
|
switch state, left := m.expiry(o); state {
|
||||||
case expired:
|
case expiry.Expired:
|
||||||
line += " " + sty.Error.Render("✗ expired")
|
line += " " + sty.Error.Render("✗ expired")
|
||||||
case expiresSoon:
|
case expiry.Soon:
|
||||||
line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
|
line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
|
||||||
}
|
}
|
||||||
return withNote(sty, line, n.Note, width)
|
return withNote(sty, line, n.Note, width)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dimIf draws a hidden item's row (shown with H) in the muted colour,
|
||||||
|
// whole, so it stands apart from what's listed by the provider's rules.
|
||||||
|
func dimIf(sty styles.Styles, hidden bool, line string) string {
|
||||||
|
if !hidden {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return sty.Muted.Render(ansi.Strip(line))
|
||||||
|
}
|
||||||
|
|
||||||
// starredFirst moves starred items to the front, keeping the order
|
// starredFirst moves starred items to the front, keeping the order
|
||||||
// otherwise. It doesn't modify items, which may be cached.
|
// otherwise. It doesn't modify items, which may be cached.
|
||||||
func starredFirst[T any](items []T, starred func(T) bool) []T {
|
func starredFirst[T any](items []T, starred func(T) bool) []T {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// jump opens the favourites list, filters it and jumps to the first match.
|
// jump opens the favourites list, filters it and jumps to the first match.
|
||||||
@@ -429,7 +430,7 @@ func TestStarIsPerAccount(t *testing.T) {
|
|||||||
d.keys("F")
|
d.keys("F")
|
||||||
var labels []string
|
var labels []string
|
||||||
for _, o := range d.m.search.shown {
|
for _, o := range d.m.search.shown {
|
||||||
labels = append(labels, ansiStrip(o.label))
|
labels = append(labels, ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
|
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
|
||||||
t.Errorf("favourites should list alice's star only: %q", labels)
|
t.Errorf("favourites should list alice's star only: %q", labels)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -61,7 +62,7 @@ func (m *Model) syncFields() tea.Cmd {
|
|||||||
case !ok:
|
case !ok:
|
||||||
m.fields.Show("", nil)
|
m.fields.Show("", nil)
|
||||||
case m.selectedProblem() != "":
|
case m.selectedProblem() != "":
|
||||||
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
||||||
case r.kind == rowGroup:
|
case r.kind == rowGroup:
|
||||||
m.showAccount(r)
|
m.showAccount(r)
|
||||||
case r.kind == rowSource:
|
case r.kind == rowSource:
|
||||||
@@ -134,7 +135,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
|
|||||||
add("web", src.URL)
|
add("web", src.URL)
|
||||||
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
|
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
|
||||||
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
|
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
|
||||||
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
|
add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
|
|||||||
case provider.KindRepo:
|
case provider.KindRepo:
|
||||||
add("format", c.Detail)
|
add("format", c.Detail)
|
||||||
}
|
}
|
||||||
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||||
add("web", c.URL)
|
add("web", c.URL)
|
||||||
for _, t := range c.ObjectTypes() {
|
for _, t := range c.ObjectTypes() {
|
||||||
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
|
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
|
||||||
@@ -221,5 +222,5 @@ func (m Model) problemFields(problem string) []panes.Field {
|
|||||||
// fieldName names a copied field: its label, or a row of several under
|
// fieldName names a copied field: its label, or a row of several under
|
||||||
// one label (a tag), its value.
|
// one label (a tag), its value.
|
||||||
func fieldName(f panes.Field) string {
|
func fieldName(f panes.Field) string {
|
||||||
return cmpOr(f.Label, f.Value)
|
return cmp.Or(f.Label, f.Value)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The help bar shows the keys that matter in the focused pane; ? lists all
|
// The help bar shows the keys that matter in the focused pane; ? lists all
|
||||||
// of them in a menu (showKeybindings).
|
// of them in a menu (showKeybindingsFor).
|
||||||
|
|
||||||
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
|
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
|
||||||
// descriptions in the normal text colour, only the separators dimmed. The
|
// descriptions in the normal text colour, only the separators dimmed. The
|
||||||
@@ -83,11 +84,6 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
|
|||||||
|
|
||||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
||||||
|
|
||||||
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
|
||||||
// what it does, the focused pane's first, filterable by key, description
|
|
||||||
// and group. Enter runs the selected one.
|
|
||||||
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
|
|
||||||
|
|
||||||
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
|
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
|
||||||
// what it does, filterable by key, description and group; enter runs the
|
// what it does, filterable by key, description and group; enter runs the
|
||||||
// selected one. Opened from the panes, it lists the focused pane's keys
|
// selected one. Opened from the panes, it lists the focused pane's keys
|
||||||
@@ -153,7 +149,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
|
|||||||
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
|
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
|
||||||
opts = append(opts, option{label: "Custom commands", header: true})
|
opts = append(opts, option{label: "Custom commands", header: true})
|
||||||
for _, c := range cmds {
|
for _, c := range cmds {
|
||||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
|
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom")
|
||||||
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var out []entry
|
var out []entry
|
||||||
|
detail := m.objectDetail(key)
|
||||||
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
|
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
|
||||||
account := ""
|
account := ""
|
||||||
if p, ok := m.byID[acct]; ok {
|
if p, ok := m.byID[acct]; ok {
|
||||||
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
|
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
|
||||||
}
|
}
|
||||||
|
account = strings.Trim(detail+" · "+account, " ·")
|
||||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||||
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: " ",
|
||||||
@@ -157,6 +159,19 @@ func (m Model) inPlainEnv(r ref.Ref) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// objectDetail is the Detail of the secret with the store key key, as far
|
||||||
|
// as the lists loaded tell.
|
||||||
|
func (m Model) objectDetail(key string) string {
|
||||||
|
for _, e := range m.cache.objects {
|
||||||
|
for _, o := range e.value {
|
||||||
|
if o.Detail != "" && objectKey(o) == key {
|
||||||
|
return o.Detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
|
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
|
||||||
// icon and colour, "app / apps/api/.env".
|
// icon and colour, "app / apps/api/.env".
|
||||||
func (m Model) repoLabel(repo string, file ...string) string {
|
func (m Model) repoLabel(repo string, file ...string) string {
|
||||||
|
|||||||
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
|
|||||||
func TestContextHelpBar(t *testing.T) {
|
func TestContextHelpBar(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
||||||
t.Errorf("Sources help bar: %q", bar)
|
t.Errorf("Sources help bar: %q", bar)
|
||||||
}
|
}
|
||||||
d.keys("3")
|
d.keys("3")
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
||||||
t.Errorf("Secrets help bar: %q", bar)
|
t.Errorf("Secrets help bar: %q", bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,11 +225,6 @@ func TestKeyPress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastLine(s string) string {
|
|
||||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
|
||||||
return lines[len(lines)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
@@ -264,7 +259,7 @@ func TestVersionInStatus(t *testing.T) {
|
|||||||
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
||||||
t.Errorf("no version in the status pane:\n%s", d.screen())
|
t.Errorf("no version in the status pane:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
||||||
t.Errorf("help line %q", bar)
|
t.Errorf("help line %q", bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
@@ -439,7 +440,7 @@ func (m *Model) handleResolved(msg resolvedMsg) {
|
|||||||
// (ref.Ref.Account), if it's one of them.
|
// (ref.Ref.Account), if it's one of them.
|
||||||
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
|
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
|
||||||
for _, c := range choices {
|
for _, c := range choices {
|
||||||
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
|
if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account {
|
||||||
return c.sourceRow, true
|
return c.sourceRow, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,7 +693,7 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
share := func(k string) func(*Model) tea.Cmd {
|
share := func(k string) func(*Model) tea.Cmd {
|
||||||
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
|
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
|
||||||
}
|
}
|
||||||
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
|
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
|
||||||
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
|
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
|
||||||
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
|
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
|
||||||
}
|
}
|
||||||
@@ -720,7 +721,7 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
|
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
|
||||||
}
|
}
|
||||||
opts := []option{
|
opts := []option{
|
||||||
option{key: "o", label: "Lazysecrets link", run: share("o")},
|
{key: "o", label: "Lazysecrets link", run: share("o")},
|
||||||
}
|
}
|
||||||
if o.Container.Source.Kind == provider.KindAzure {
|
if o.Container.Source.Kind == provider.KindAzure {
|
||||||
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
|
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
|
||||||
|
|||||||
@@ -77,17 +77,15 @@ func TestNotesOnRows(t *testing.T) {
|
|||||||
func TestWithNoteGivesWay(t *testing.T) {
|
func TestWithNoteGivesWay(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
st := styles.Default()
|
st := styles.Default()
|
||||||
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
|
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 ||
|
||||||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
|
!strings.HasSuffix(ansi.Strip(got), "prod SQL admin") {
|
||||||
t.Errorf("fits: %q", stripANSI(got))
|
t.Errorf("fits: %q", ansi.Strip(got))
|
||||||
}
|
}
|
||||||
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
|
if got := ansi.Strip(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
|
||||||
!strings.HasSuffix(got, "…") {
|
!strings.HasSuffix(got, "…") {
|
||||||
t.Errorf("tight: %q", got)
|
t.Errorf("tight: %q", got)
|
||||||
}
|
}
|
||||||
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
|
if got := ansi.Strip(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
|
||||||
t.Errorf("no room: %q", got)
|
t.Errorf("no room: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripANSI(s string) string { return ansi.Strip(s) }
|
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
const sopsFile = `db:
|
const sopsFile = `db:
|
||||||
@@ -225,9 +226,9 @@ func TestSearchAllRepos(t *testing.T) {
|
|||||||
var got []string
|
var got []string
|
||||||
for _, o := range d.m.search.shown {
|
for _, o := range d.m.search.shown {
|
||||||
if o.kind != provider.KindRepo {
|
if o.kind != provider.KindRepo {
|
||||||
t.Errorf("not a repo: %s", ansiStrip(o.label))
|
t.Errorf("not a repo: %s", ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
got = append(got, ansiStrip(o.label))
|
got = append(got, ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Errorf("want db.password in both repos: %v", got)
|
t.Errorf("want db.password in both repos: %v", got)
|
||||||
|
|||||||
+28
-29
@@ -6,9 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
@@ -46,10 +46,6 @@ type crawlMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
|
||||||
return context.WithTimeout(context.Background(), d)
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchEntries lists everything loaded: the sources (with how much of
|
// searchEntries lists everything loaded: the sources (with how much of
|
||||||
// each is loaded) and the vaults, scopes and secrets in them, each with
|
// each is loaded) and the vaults, scopes and secrets in them, each with
|
||||||
// its source's row key as group.
|
// its source's row key as group.
|
||||||
@@ -67,35 +63,48 @@ func (m *Model) searchEntries() (sources, items []option) {
|
|||||||
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: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.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
|
return &es[len(es)-1].opt
|
||||||
}
|
}
|
||||||
|
// What providers hide is left out, unless H shows it, dimmed.
|
||||||
|
skip := func(hidden bool) bool { return hidden && !m.showHidden }
|
||||||
|
dim := func(hidden bool, e *option) {
|
||||||
|
if hidden {
|
||||||
|
e.label = m.st.Muted.Render(ansi.Strip(e.label))
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {
|
||||||
|
if skip(src.Hidden) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||||
row := sourceRowKey(p.ID(), src.ID)
|
row := sourceRowKey(p.ID(), src.ID)
|
||||||
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
|
dim(src.Hidden, add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
|
||||||
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
||||||
return nil
|
return nil
|
||||||
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
|
}, func(m *Model) tea.Cmd { return m.loadSource(src) }))
|
||||||
for _, c := range m.cache.containers[row].value {
|
for _, c := range m.cache.containers[row].value {
|
||||||
|
if skip(c.Hidden) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
r, err := ref.ForContainer(c, provider.TypeSecret)
|
r, err := ref.ForContainer(c, provider.TypeSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
|
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
|
||||||
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
|
dim(c.Hidden, add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
|
||||||
m.startNav(r, actGo, row)
|
m.startNav(r, actGo, row)
|
||||||
return nil
|
return nil
|
||||||
}, reload)
|
}, reload))
|
||||||
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||||
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||||
r, err := ref.ForObject(o, "")
|
r, err := ref.ForObject(o, "")
|
||||||
if err != nil {
|
if err != nil || skip(o.Hidden) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
suffix := accountOf(src)
|
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·")
|
||||||
if t != provider.TypeSecret {
|
if t != provider.TypeSecret {
|
||||||
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, " ·")
|
||||||
@@ -105,6 +114,7 @@ func (m *Model) searchEntries() (sources, items []option) {
|
|||||||
return nil
|
return nil
|
||||||
}, reload)
|
}, reload)
|
||||||
e.plain = c.Detail == provider.DetailPlainEnv
|
e.plain = c.Detail == provider.DetailPlainEnv
|
||||||
|
dim(o.Hidden, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,8 +144,6 @@ func (c coverage) complete() bool {
|
|||||||
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
|
|
||||||
|
|
||||||
// providerFilter picks the providers a search or a load covers; nil is
|
// providerFilter picks the providers a search or a load covers; nil is
|
||||||
// all of them.
|
// all of them.
|
||||||
type providerFilter func(provider.Provider) bool
|
type providerFilter func(provider.Provider) bool
|
||||||
@@ -148,7 +156,7 @@ func ofProvider(id string) providerFilter {
|
|||||||
return func(p provider.Provider) bool { return p.ID() == id }
|
return func(p provider.Provider) bool { return p.ID() == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
// coverageOf is searchCoverage for the providers in.
|
// coverageOf is the coverage of the providers in.
|
||||||
func (m *Model) coverageOf(in providerFilter) coverage {
|
func (m *Model) coverageOf(in providerFilter) coverage {
|
||||||
var c coverage
|
var c coverage
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
@@ -183,7 +191,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
|
|||||||
|
|
||||||
// searchLines says what the search covers.
|
// searchLines says what the search covers.
|
||||||
func (m *Model) searchLines() []string {
|
func (m *Model) searchLines() []string {
|
||||||
c := m.searchCoverage()
|
c := m.coverageOf(nil)
|
||||||
switch {
|
switch {
|
||||||
case m.crawl.running:
|
case m.crawl.running:
|
||||||
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
|
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
|
||||||
@@ -210,11 +218,8 @@ func plural(n int, word string) string {
|
|||||||
return fmt.Sprintf("%d %ss", n, word)
|
return fmt.Sprintf("%d %ss", n, word)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCrawl loads every unlisted subscription's vaults and every vault's
|
// startCrawlOf loads, for the providers in, every unlisted subscription's
|
||||||
// secrets not loaded yet.
|
// vaults and every vault's secrets not loaded yet.
|
||||||
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
|
|
||||||
|
|
||||||
// startCrawlOf is startCrawl for the providers in.
|
|
||||||
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if in != nil && !in(p) {
|
if in != nil && !in(p) {
|
||||||
@@ -301,7 +306,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
|||||||
src := *job.src
|
src := *job.src
|
||||||
timeout := m.timeout.of(src.Kind)
|
timeout := m.timeout.of(src.Kind)
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := contextWithTimeout(timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
items, err := p.Containers(ctx, src)
|
items, err := p.Containers(ctx, src)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
@@ -314,7 +319,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
|||||||
p := m.providerFor(c.Source)
|
p := m.providerFor(c.Source)
|
||||||
timeout := m.timeout.of(c.Source.Kind)
|
timeout := m.timeout.of(c.Source.Kind)
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := contextWithTimeout(timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
items, err := p.Objects(ctx, c, provider.TypeSecret)
|
items, err := p.Objects(ctx, c, provider.TypeSecret)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
@@ -402,9 +407,3 @@ func (m *Model) vaultMark(c provider.Container) string {
|
|||||||
}
|
}
|
||||||
return " "
|
return " "
|
||||||
}
|
}
|
||||||
|
|
||||||
// providerOf is the provider ID in a source row key.
|
|
||||||
func providerOf(row string) string {
|
|
||||||
p, _ := splitSourceRow(row)
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
@@ -115,19 +116,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
|
|||||||
t.Fatal("platform-dev not in sources")
|
t.Fatal("platform-dev not in sources")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") {
|
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "○") {
|
||||||
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
||||||
}
|
}
|
||||||
if len(d.m.search.shown) != 0 {
|
if len(d.m.search.shown) != 0 {
|
||||||
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
|
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
|
||||||
}
|
}
|
||||||
d.send(ctrl('r'))
|
d.send(ctrl('r'))
|
||||||
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") {
|
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "●") {
|
||||||
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
||||||
}
|
}
|
||||||
for _, o := range d.m.search.shown {
|
for _, o := range d.m.search.shown {
|
||||||
if !strings.Contains(ansiStrip(o.label), "platform-dev") {
|
if !strings.Contains(ansi.Strip(o.label), "platform-dev") {
|
||||||
t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
|
t.Errorf("result from elsewhere: %q", ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(d.m.search.shown) == 0 {
|
if len(d.m.search.shown) == 0 {
|
||||||
@@ -177,19 +178,17 @@ func TestStarFromSearch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ansiStrip(s string) string { return ansi.Strip(s) }
|
|
||||||
|
|
||||||
func TestSearchStartsWhereYouAre(t *testing.T) {
|
func TestSearchStartsWhereYouAre(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("2", "j") // platform-prod / kv-shared-prod
|
d.keys("2", "j") // platform-prod / kv-shared-prod
|
||||||
d.openSearch()
|
d.openSearch()
|
||||||
v := d.m.search
|
v := d.m.search
|
||||||
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
|
scope := func() string { return ansi.Strip(v.scopes[v.src].label) }
|
||||||
if scope() != "All" {
|
if scope() != "All" {
|
||||||
t.Errorf("opened on %q, want All", scope())
|
t.Errorf("opened on %q, want All", scope())
|
||||||
}
|
}
|
||||||
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
|
if v.res >= len(v.shown) || !strings.Contains(ansi.Strip(v.shown[v.res].label), "kv-shared-prod") {
|
||||||
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
d.send(ctrl('s')) // narrows to the source, and back
|
d.send(ctrl('s')) // narrows to the source, and back
|
||||||
@@ -233,13 +232,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
|
|||||||
t.Errorf("no results in the account:\n%s", d.screen())
|
t.Errorf("no results in the account:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
for _, o := range v.shown {
|
for _, o := range v.shown {
|
||||||
if providerOf(o.group) != header.provider {
|
if cachefile.ProviderOf(o.group) != header.provider {
|
||||||
t.Errorf("result from another account: %q", ansiStrip(o.label))
|
t.Errorf("result from another account: %q", ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The account's sources are under it, indented.
|
// The account's sources are under it, indented.
|
||||||
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
|
if next := v.scopes[v.src+1]; next.group == "" || cachefile.ProviderOf(next.group) != header.provider || !strings.HasPrefix(ansi.Strip(next.label), " ") {
|
||||||
t.Errorf("after the account: %q", ansiStrip(next.label))
|
t.Errorf("after the account: %q", ansi.Strip(next.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +326,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
|
|||||||
t.Fatal("no results")
|
t.Fatal("no results")
|
||||||
}
|
}
|
||||||
o := d.m.search.shown[0]
|
o := d.m.search.shown[0]
|
||||||
plain := []rune(ansiStrip(o.label))
|
plain := []rune(ansi.Strip(o.label))
|
||||||
var got string
|
var got string
|
||||||
for _, h := range o.hits {
|
for _, h := range o.hits {
|
||||||
got += string(plain[h])
|
got += string(plain[h])
|
||||||
@@ -348,14 +347,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("F")
|
d.keys("F")
|
||||||
bar := lastLine(d.screen())
|
bar := lastLines(d.screen(), 1)
|
||||||
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
|
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
|
||||||
if !strings.Contains(bar, want) {
|
if !strings.Contains(bar, want) {
|
||||||
t.Errorf("browsing: help bar %q lacks %q", bar, want)
|
t.Errorf("browsing: help bar %q lacks %q", bar, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.keys("/")
|
d.keys("/")
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
|
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
|
||||||
t.Errorf("typing: help bar %q", bar)
|
t.Errorf("typing: help bar %q", bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,8 +374,8 @@ func TestHelpFromPopups(t *testing.T) {
|
|||||||
if o.header {
|
if o.header {
|
||||||
sections = append(sections, o.label)
|
sections = append(sections, o.label)
|
||||||
}
|
}
|
||||||
if strings.Contains(ansiStrip(o.label), "copy value") {
|
if strings.Contains(ansi.Strip(o.label), "copy value") {
|
||||||
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
|
t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Join(sections, ",") != "Go to,While typing, after /" {
|
if strings.Join(sections, ",") != "Go to,While typing, after /" {
|
||||||
@@ -428,7 +427,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t) // 100 columns: Go to's keys don't all fit
|
d := newDriver(t) // 100 columns: Go to's keys don't all fit
|
||||||
d.keys("ctrl+s", "esc")
|
d.keys("ctrl+s", "esc")
|
||||||
bar := strings.TrimSpace(lastLine(d.screen()))
|
bar := strings.TrimSpace(lastLines(d.screen(), 1))
|
||||||
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
|
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
|
||||||
t.Errorf("help bar %q", bar)
|
t.Errorf("help bar %q", bar)
|
||||||
}
|
}
|
||||||
@@ -443,11 +442,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
|
|||||||
for d.m.search.src > 0 {
|
for d.m.search.src > 0 {
|
||||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||||
}
|
}
|
||||||
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
|
for i := 0; !strings.Contains(ansi.Strip(d.m.search.scope().label), "platform-dev"); i++ {
|
||||||
if i > len(d.m.search.scopes) {
|
if i > len(d.m.search.scopes) {
|
||||||
var labels []string
|
var labels []string
|
||||||
for _, o := range d.m.search.scopes {
|
for _, o := range d.m.search.scopes {
|
||||||
labels = append(labels, ansiStrip(o.label))
|
labels = append(labels, ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
|
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
"git.kebcloud.no/keb/lazysecrets/internal/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/ref"
|
||||||
@@ -200,7 +201,7 @@ func (m *Model) fillSearch(keep bool) {
|
|||||||
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
|
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
|
||||||
})
|
})
|
||||||
for _, o := range sources {
|
for _, o := range sources {
|
||||||
if providerOf(o.group) == p.ID() {
|
if cachefile.ProviderOf(o.group) == p.ID() {
|
||||||
o.label = " " + o.label
|
o.label = " " + o.label
|
||||||
scopes = append(scopes, o)
|
scopes = append(scopes, o)
|
||||||
}
|
}
|
||||||
@@ -264,7 +265,7 @@ func (v *searchView) match() []option {
|
|||||||
default:
|
default:
|
||||||
account, ofAccount := v.scopeAccount()
|
account, ofAccount := v.scopeAccount()
|
||||||
for _, o := range v.items {
|
for _, o := range v.items {
|
||||||
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
|
if sc.id == allSources || ofAccount && cachefile.ProviderOf(o.group) == account || sc.group != "" && o.group == sc.group {
|
||||||
cand = append(cand, o)
|
cand = append(cand, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,7 +521,7 @@ func (m *Model) searchLoads() tea.Cmd {
|
|||||||
return m.startCrawlOf(v.scopeFilter())
|
return m.startCrawlOf(v.scopeFilter())
|
||||||
}
|
}
|
||||||
if !v.onSource() {
|
if !v.onSource() {
|
||||||
return m.startCrawl()
|
return m.startCrawlOf(nil)
|
||||||
}
|
}
|
||||||
providerID, sourceID := splitSourceRow(sc.group)
|
providerID, sourceID := splitSourceRow(sc.group)
|
||||||
for _, src := range m.sourceResults[providerID].sources {
|
for _, src := range m.sourceResults[providerID].sources {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
for _, c := range all {
|
for _, c := range all {
|
||||||
if c.Detail == provider.DetailPlainEnv {
|
if c.Detail == provider.DetailPlainEnv && !c.Hidden { // not .env.example
|
||||||
cs = append(cs, c)
|
cs = append(cs, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// openFailed says why a share link couldn't be decrypted, in a popup
|
// openFailed says why a share link couldn't be decrypted, in a popup
|
||||||
// where its offer was: its first line (wrapped), or with whole all of it
|
// where its offer was: its first line (wrapped), or, with whole, all of it
|
||||||
// (Key Vault's refusals say what to check on later lines). From there,
|
// (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.
|
// 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) {
|
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func sourceRowID(r sourceRow) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
|
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
|
||||||
|
render := renderSourceRow(iconSet, mem, recent)
|
||||||
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
|
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
|
||||||
ID: sourceRowID,
|
ID: sourceRowID,
|
||||||
// Headers have no filter text, so filtering shows just sources.
|
// Headers have no filter text, so filtering shows just sources.
|
||||||
@@ -77,6 +78,15 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
|
|||||||
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
|
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
|
||||||
},
|
},
|
||||||
Render: func(st styles.Styles, r sourceRow, width int) string {
|
Render: func(st styles.Styles, r sourceRow, width int) string {
|
||||||
|
return dimIf(st, r.kind == rowSource && r.source.Hidden, render(st, r, width))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSourceRow draws a source, a provider's header or the Opened links
|
||||||
|
// row.
|
||||||
|
func renderSourceRow(iconSet icons.Set, mem *store.Store, recent bool) func(styles.Styles, sourceRow, int) string {
|
||||||
|
return func(st styles.Styles, r sourceRow, width int) string {
|
||||||
node := mem.Node(sourceKey(r.source))
|
node := mem.Node(sourceKey(r.source))
|
||||||
kind := st.Kind(r.group)
|
kind := st.Kind(r.group)
|
||||||
starred := ""
|
starred := ""
|
||||||
@@ -107,8 +117,7 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
|
|||||||
status = st.Error.Render("(!)")
|
status = st.Error.Render("(!)")
|
||||||
}
|
}
|
||||||
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
|
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
|
||||||
},
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebuildSources lays out the Sources pane from the per-provider results.
|
// rebuildSources lays out the Sources pane from the per-provider results.
|
||||||
@@ -129,17 +138,16 @@ func (m *Model) rebuildSources() {
|
|||||||
}
|
}
|
||||||
// The repo lazysecrets started in gets a row of its own on top, so it
|
// The repo lazysecrets started in gets a row of its own on top, so it
|
||||||
// opens on it; flat, it sorts first instead.
|
// opens on it; flat, it sorts first instead.
|
||||||
if src, ok := m.currentSource(); ok && !flat {
|
if src, ok := m.currentSource(); ok && !flat && (!src.Hidden || m.showHidden) {
|
||||||
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
|
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
|
||||||
}
|
}
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
kind, id := p.Kind(), p.ID()
|
kind, id := p.Kind(), p.ID()
|
||||||
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
|
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
|
||||||
r, done := m.sourceResults[id]
|
r, done := m.sourceResults[id]
|
||||||
sources := r.sources
|
sources := slices.DeleteFunc(slices.Clone(r.sources), func(s provider.Source) bool {
|
||||||
if !flat {
|
return (s.Here && !flat) || (s.Hidden && !m.showHidden)
|
||||||
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here })
|
})
|
||||||
}
|
|
||||||
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
|
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
|
||||||
continue // repos show once some are found to have secret files
|
continue // repos show once some are found to have secret files
|
||||||
}
|
}
|
||||||
@@ -239,6 +247,18 @@ func (m Model) onLinks() bool {
|
|||||||
return m.sources.SelectedID() == linksRowID
|
return m.sources.SelectedID() == linksRowID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toggleHidden lists what providers hide, dimmed, or leaves it out again.
|
||||||
|
func (m *Model) toggleHidden() {
|
||||||
|
m.showHidden = !m.showHidden
|
||||||
|
m.containers.SetShowHidden(m.showHidden)
|
||||||
|
m.secrets.SetShowHidden(m.showHidden)
|
||||||
|
m.rebuildSources()
|
||||||
|
m.flash = flash{text: "hidden items hidden"}
|
||||||
|
if m.showHidden {
|
||||||
|
m.flash = flash{text: "showing hidden items, dimmed"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// toggleSourcesView switches between grouped and flat, keeping the cursor
|
// toggleSourcesView switches between grouped and flat, keeping the cursor
|
||||||
// on the same source.
|
// on the same source.
|
||||||
func (m *Model) toggleSourcesView() {
|
func (m *Model) toggleSourcesView() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -83,7 +84,7 @@ func (m Model) statusFields() []panes.Field {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A release's rows open its release page.
|
// A release's rows open its release page.
|
||||||
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
|
version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")}
|
||||||
if strings.HasPrefix(version.Value, "v") {
|
if strings.HasPrefix(version.Value, "v") {
|
||||||
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
|
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
|
||||||
}
|
}
|
||||||
@@ -159,7 +160,7 @@ func (m Model) accountStatus(p provider.Provider) string {
|
|||||||
return r.err.Error()
|
return r.err.Error()
|
||||||
}
|
}
|
||||||
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
|
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
|
||||||
return plural(len(r.sources), cmpOr(noun, "source"))
|
return plural(len(r.sources), cmp.Or(noun, "source"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
|
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecentScope(t *testing.T) {
|
func TestRecentScope(t *testing.T) {
|
||||||
@@ -21,7 +22,7 @@ func TestRecentScope(t *testing.T) {
|
|||||||
d.keys("R")
|
d.keys("R")
|
||||||
var recent []string
|
var recent []string
|
||||||
for _, o := range d.m.search.shown {
|
for _, o := range d.m.search.shown {
|
||||||
recent = append(recent, ansiStrip(o.label))
|
recent = append(recent, ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
|
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
|
||||||
t.Fatalf("want newest first: %q", recent)
|
t.Fatalf("want newest first: %q", recent)
|
||||||
@@ -31,7 +32,7 @@ func TestRecentScope(t *testing.T) {
|
|||||||
t.Errorf("jumped to %q", s)
|
t.Errorf("jumped to %q", s)
|
||||||
}
|
}
|
||||||
d.keys("F")
|
d.keys("F")
|
||||||
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") {
|
if len(d.m.search.shown) != 1 || !strings.Contains(ansi.Strip(d.m.search.shown[0].label), "kv-shared-prod") {
|
||||||
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
|
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type Set struct {
|
|||||||
Repo string
|
Repo string
|
||||||
Encrypted string // an encrypted file (SOPS)
|
Encrypted string // an encrypted file (SOPS)
|
||||||
PlainFile string // a plain text secrets file (.env)
|
PlainFile string // a plain text secrets file (.env)
|
||||||
|
Spring string // a Spring Boot config file
|
||||||
|
Compose string // a Docker Compose file
|
||||||
Note string // has a note (lazysecrets list)
|
Note string // has a note (lazysecrets list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ var NerdFont = Set{
|
|||||||
Repo: "\U000F02A2", // md-git
|
Repo: "\U000F02A2", // md-git
|
||||||
Encrypted: "\U000F033E", // md-lock
|
Encrypted: "\U000F033E", // md-lock
|
||||||
PlainFile: "\U000F0214", // md-file
|
PlainFile: "\U000F0214", // md-file
|
||||||
|
Spring: "\U000F032A", // md-leaf
|
||||||
|
Compose: "\U000F0868", // md-docker
|
||||||
Note: "\U000F09ED",
|
Note: "\U000F09ED",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ package keys
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
@@ -45,6 +45,7 @@ type Map struct {
|
|||||||
Recent key.Binding
|
Recent key.Binding
|
||||||
Note, Share, ShareMenu, OpenRef key.Binding
|
Note, Share, ShareMenu, OpenRef key.Binding
|
||||||
Search, SourcesView, Commands key.Binding
|
Search, SourcesView, Commands key.Binding
|
||||||
|
ToggleHidden key.Binding
|
||||||
SplitLeft, SplitRight, SplitReset key.Binding
|
SplitLeft, SplitRight, SplitReset key.Binding
|
||||||
Taller, Shorter, ZoomIn, ZoomOut key.Binding
|
Taller, Shorter, ZoomIn, ZoomOut key.Binding
|
||||||
Login, Refresh, Help, Quit key.Binding
|
Login, Refresh, Help, Quit key.Binding
|
||||||
@@ -102,6 +103,7 @@ func (m *Map) Actions() []Action {
|
|||||||
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
|
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
|
||||||
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, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: Spring and compose keys that don't look like secrets, example files (.env.example), and files and repos with only those. Shown, they're dimmed; Go to finds them too."),
|
||||||
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, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
|
||||||
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
||||||
@@ -185,7 +187,7 @@ var defaults = map[string][]string{
|
|||||||
"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.open": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.toggleHidden": {"H"},
|
||||||
"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.goTo": {"ctrl+s"}, "lists.note": {"n"},
|
||||||
"lists.commands": {"x"},
|
"lists.commands": {"x"},
|
||||||
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
|
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
|
||||||
@@ -266,11 +268,11 @@ func (m *Map) Apply(cfg map[string]map[string][]string) error {
|
|||||||
byName[a.Context+"."+a.Name] = a
|
byName[a.Context+"."+a.Name] = a
|
||||||
contexts[a.Context] = true
|
contexts[a.Context] = true
|
||||||
}
|
}
|
||||||
for _, ctx := range sortedKeys(cfg) {
|
for _, ctx := range slices.Sorted(maps.Keys(cfg)) {
|
||||||
if !contexts[ctx] {
|
if !contexts[ctx] {
|
||||||
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
|
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(slices.Sorted(maps.Keys(contexts)), ", "))
|
||||||
}
|
}
|
||||||
for _, name := range sortedKeys(cfg[ctx]) {
|
for _, name := range slices.Sorted(maps.Keys(cfg[ctx])) {
|
||||||
a, ok := byName[ctx+"."+name]
|
a, ok := byName[ctx+"."+name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
|
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
|
||||||
@@ -289,16 +291,7 @@ func (m *Map) names(ctx string) []string {
|
|||||||
out = append(out, a.Name)
|
out = append(out, a.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
slices.Sort(out)
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedKeys[V any](set map[string]V) []string {
|
|
||||||
out := make([]string, 0, len(set))
|
|
||||||
for k := range set {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -345,6 +335,12 @@ func (d Detail) lines(st styles.Styles) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ValueShown:
|
case ValueShown:
|
||||||
|
if d.value.Reveal() == "" {
|
||||||
|
// Not in the value's colour, nor a word a value could be.
|
||||||
|
field(label, st.Muted.Italic(true).Render("∅ no value (an empty string)"))
|
||||||
|
out = append(out, valueIndent+hints("hide"))
|
||||||
|
break
|
||||||
|
}
|
||||||
// Where the dots were, continuing under itself; the keys below.
|
// Where the dots were, continuing under itself; the keys below.
|
||||||
first := true
|
first := true
|
||||||
lines, note := valueView(d.value.Reveal())
|
lines, note := valueView(d.value.Reveal())
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ package panes
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWrappedValueKeepsItsStyle(t *testing.T) {
|
func TestWrappedValueKeepsItsStyle(t *testing.T) {
|
||||||
@@ -15,3 +21,17 @@ func TestWrappedValueKeepsItsStyle(t *testing.T) {
|
|||||||
t.Errorf("the style went on past its reset: %q", got[2])
|
t.Errorf("the style went on past its reset: %q", got[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEmptyValue(t *testing.T) {
|
||||||
|
d := NewDetail(icons.None)
|
||||||
|
d.SetSize(80, 20)
|
||||||
|
d.ShowSecret(provider.Object{Name: "spring.datasource.password", Type: provider.TypeSecret})
|
||||||
|
d.SetValue(provider.NewSecretValue(nil))
|
||||||
|
if view := ansi.Strip(d.View(styles.Default(), true, "")); !strings.Contains(view, "∅ no value (an empty string)") {
|
||||||
|
t.Errorf("empty value:\n%s", view)
|
||||||
|
}
|
||||||
|
d.SetValue(provider.NewSecretValue([]byte("empty")))
|
||||||
|
if view := ansi.Strip(d.View(styles.Default(), true, "")); strings.Contains(view, "∅") || !strings.Contains(view, "empty") {
|
||||||
|
t.Errorf("the word empty:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sgr matches SGR escape sequences.
|
|
||||||
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
|
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
|
||||||
|
|
||||||
// Highlight draws a cursor row: line, padded to width, on style's
|
// Highlight draws a cursor row: line, padded to width, on style's
|
||||||
|
|||||||
+34
-19
@@ -22,6 +22,9 @@ type Item[T any] struct {
|
|||||||
// text the row doesn't show (e.g. the provider of a grouped source).
|
// text the row doesn't show (e.g. the provider of a grouped source).
|
||||||
FilterExtra func(T) string
|
FilterExtra func(T) string
|
||||||
Render func(st styles.Styles, v T, width int) string
|
Render func(st styles.Styles, v T, width int) string
|
||||||
|
// Hidden, if set, says which items are listed only while the list
|
||||||
|
// shows hidden ones (SetShowHidden).
|
||||||
|
Hidden func(T) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// List is a scrollable, filterable list pane with loading and error states.
|
// List is a scrollable, filterable list pane with loading and error states.
|
||||||
@@ -53,6 +56,9 @@ type List[T any] struct {
|
|||||||
|
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
filtering bool
|
filtering bool
|
||||||
|
|
||||||
|
showHidden bool
|
||||||
|
hidden int // items left out for being hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewList[T any](title string, item Item[T]) List[T] {
|
func NewList[T any](title string, item Item[T]) List[T] {
|
||||||
@@ -77,6 +83,12 @@ func (l *List[T]) SetItems(items []T) {
|
|||||||
l.applyFilter(prev)
|
l.applyFilter(prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetShowHidden lists hidden items too, or leaves them out again.
|
||||||
|
func (l *List[T]) SetShowHidden(on bool) {
|
||||||
|
l.showHidden = on
|
||||||
|
l.applyFilter(l.SelectedID())
|
||||||
|
}
|
||||||
|
|
||||||
// Reset clears items, error, filter and cursor.
|
// Reset clears items, error, filter and cursor.
|
||||||
func (l *List[T]) Reset() {
|
func (l *List[T]) Reset() {
|
||||||
l.items, l.visible = nil, nil
|
l.items, l.visible = nil, nil
|
||||||
@@ -237,21 +249,28 @@ func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd {
|
|||||||
// applyFilter recomputes the visible items and puts the cursor back on the
|
// applyFilter recomputes the visible items and puts the cursor back on the
|
||||||
// item with ID keep if it is still visible.
|
// item with ID keep if it is still visible.
|
||||||
func (l *List[T]) applyFilter(keep string) {
|
func (l *List[T]) applyFilter(keep string) {
|
||||||
l.visible, l.hits = l.visible[:0], nil
|
l.visible, l.hits, l.hidden = l.visible[:0], nil, 0
|
||||||
if q := l.input.Value(); q == "" {
|
shown := make([]int, 0, len(l.items))
|
||||||
for i := range l.items {
|
for i, v := range l.items {
|
||||||
l.visible = append(l.visible, i)
|
if !l.showHidden && l.item.Hidden != nil && l.item.Hidden(v) {
|
||||||
|
l.hidden++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
shown = append(shown, i)
|
||||||
|
}
|
||||||
|
if q := l.input.Value(); q == "" {
|
||||||
|
l.visible = append(l.visible, shown...)
|
||||||
} else {
|
} else {
|
||||||
// The row's own text is the name; FilterExtra (a note, a provider)
|
// The row's own text is the name; FilterExtra (a note, a provider)
|
||||||
// is context, matched too but ranked lower.
|
// is context, matched too but ranked lower.
|
||||||
items := make([]match.Item, len(l.items))
|
items := make([]match.Item, len(shown))
|
||||||
for i, v := range l.items {
|
for j, i := range shown {
|
||||||
|
v := l.items[i]
|
||||||
text := l.item.Filter(v)
|
text := l.item.Filter(v)
|
||||||
items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
|
items[j] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
|
||||||
if l.item.FilterExtra != nil {
|
if l.item.FilterExtra != nil {
|
||||||
if extra := l.item.FilterExtra(v); extra != "" {
|
if extra := l.item.FilterExtra(v); extra != "" {
|
||||||
items[i].Text += " " + extra
|
items[j].Text += " " + extra
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +281,7 @@ func (l *List[T]) applyFilter(keep string) {
|
|||||||
hits = append(hits, p)
|
hits = append(hits, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
l.visible = append(l.visible, h.Index)
|
l.visible = append(l.visible, shown[h.Index])
|
||||||
l.hits = append(l.hits, hits)
|
l.hits = append(l.hits, hits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,15 +320,6 @@ func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Rang
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuneIndexes converts byte offsets in s to rune offsets.
|
|
||||||
func RuneIndexes(s string, bytes []int) []int {
|
|
||||||
out := make([]int, 0, len(bytes))
|
|
||||||
for _, b := range bytes {
|
|
||||||
out = append(out, utf8.RuneCountInString(s[:b]))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// highlight returns the rune indexes to highlight in a row's plain text:
|
// highlight returns the rune indexes to highlight in a row's plain text:
|
||||||
// the filter hits, shifted to where the Filter text appears in the row.
|
// the filter hits, shifted to where the Filter text appears in the row.
|
||||||
// It returns nil if the row doesn't contain the Filter text verbatim.
|
// It returns nil if the row doesn't contain the Filter text verbatim.
|
||||||
@@ -362,8 +372,10 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
|||||||
for _, line := range wrapLines(l.Err.Error(), inner-1) {
|
for _, line := range wrapLines(l.Err.Error(), inner-1) {
|
||||||
body = append(body, st.Error.Render(" "+line))
|
body = append(body, st.Error.Render(" "+line))
|
||||||
}
|
}
|
||||||
case len(l.visible) == 0 && len(l.items) > 0:
|
case len(l.visible) == 0 && len(l.items) > l.hidden:
|
||||||
body = append(body, st.Muted.Render(" no matches"))
|
body = append(body, st.Muted.Render(" no matches"))
|
||||||
|
case len(l.visible) == 0 && l.hidden > 0:
|
||||||
|
body = append(body, st.Muted.Render(fmt.Sprintf(" %s (%d hidden)", l.Empty, l.hidden)))
|
||||||
case len(l.visible) == 0:
|
case len(l.visible) == 0:
|
||||||
body = append(body, st.Muted.Render(" "+l.Empty))
|
body = append(body, st.Muted.Render(" "+l.Empty))
|
||||||
default:
|
default:
|
||||||
@@ -385,6 +397,9 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
|||||||
if len(l.visible) > 0 {
|
if len(l.visible) > 0 {
|
||||||
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
||||||
}
|
}
|
||||||
|
if l.hidden > 0 {
|
||||||
|
counter = strings.Trim(counter+fmt.Sprintf(" · %d hidden", l.hidden), " ·")
|
||||||
|
}
|
||||||
if l.Loading && l.Age != "" && len(l.items) > 0 {
|
if l.Loading && l.Age != "" && len(l.items) > 0 {
|
||||||
counter = "cached " + l.Age + " · " + counter
|
counter = "cached " + l.Age + " · " + counter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,3 +201,41 @@ func TestListHeader(t *testing.T) {
|
|||||||
t.Errorf("no room, the text:\n%s", v)
|
t.Errorf("no room, the text:\n%s", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListHidden(t *testing.T) {
|
||||||
|
l := NewList("Items", Item[string]{
|
||||||
|
ID: func(s string) string { return s },
|
||||||
|
Filter: func(s string) string { return s },
|
||||||
|
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||||
|
Hidden: func(s string) bool { return strings.HasPrefix(s, "hidden") },
|
||||||
|
})
|
||||||
|
l.SetSize(40, 7)
|
||||||
|
l.SetItems([]string{"shown-a", "hidden-b", "shown-c"})
|
||||||
|
view := ansi.Strip(l.View(styles.Default(), true, ""))
|
||||||
|
if l.Len() != 2 || strings.Contains(view, "hidden-b") || !strings.Contains(view, "1 hidden") {
|
||||||
|
t.Fatalf("hidden listed:\n%s", view)
|
||||||
|
}
|
||||||
|
l.Move(1)
|
||||||
|
l.SetShowHidden(true)
|
||||||
|
if l.Len() != 3 || l.SelectedID() != "shown-c" {
|
||||||
|
t.Fatalf("shown: %d, selected %q", l.Len(), l.SelectedID())
|
||||||
|
}
|
||||||
|
l.StartFilter()
|
||||||
|
l.input.SetValue("hid")
|
||||||
|
l.applyFilter("")
|
||||||
|
if l.Len() != 1 || l.SelectedID() != "hidden-b" {
|
||||||
|
t.Fatalf("filtered with hidden shown: %d %q", l.Len(), l.SelectedID())
|
||||||
|
}
|
||||||
|
|
||||||
|
l = NewList("Items", Item[string]{
|
||||||
|
ID: func(s string) string { return s }, Filter: func(s string) string { return s },
|
||||||
|
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||||
|
Hidden: func(string) bool { return true },
|
||||||
|
})
|
||||||
|
l.Empty = "no secrets"
|
||||||
|
l.SetSize(40, 7)
|
||||||
|
l.SetItems([]string{"a", "b"})
|
||||||
|
if view := ansi.Strip(l.View(styles.Default(), true, "")); !strings.Contains(view, "no secrets (2 hidden)") {
|
||||||
|
t.Fatalf("all hidden:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ type Styles struct {
|
|||||||
KindAzure lipgloss.Style
|
KindAzure lipgloss.Style
|
||||||
KindDatabricks lipgloss.Style
|
KindDatabricks lipgloss.Style
|
||||||
KindRepo lipgloss.Style
|
KindRepo lipgloss.Style
|
||||||
|
Spring lipgloss.Style // Spring Boot config files
|
||||||
|
Compose lipgloss.Style // Docker Compose files
|
||||||
|
|
||||||
// themed marks the selection colours as set by the theme, so
|
// themed marks the selection colours as set by the theme, so
|
||||||
// OnBackground leaves them.
|
// OnBackground leaves them.
|
||||||
@@ -70,6 +72,8 @@ func Default() Styles {
|
|||||||
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||||
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||||
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
|
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
|
||||||
|
Spring: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||||
|
Compose: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package styles
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@ var Roles = map[string]string{
|
|||||||
"azureColor": "Azure sources and vaults",
|
"azureColor": "Azure sources and vaults",
|
||||||
"databricksColor": "Databricks workspaces and scopes",
|
"databricksColor": "Databricks workspaces and scopes",
|
||||||
"repoColor": "repos and their secret files (SOPS)",
|
"repoColor": "repos and their secret files (SOPS)",
|
||||||
|
"springColor": "Spring Boot config files",
|
||||||
|
"composeColor": "Docker Compose files",
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromTheme returns the default styles with the theme's roles applied, and
|
// FromTheme returns the default styles with the theme's roles applied, and
|
||||||
@@ -51,12 +53,7 @@ var Roles = map[string]string{
|
|||||||
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
|
||||||
roles := make([]string, 0, len(theme))
|
for _, role := range slices.Sorted(maps.Keys(theme)) {
|
||||||
for r := range theme {
|
|
||||||
roles = append(roles, r)
|
|
||||||
}
|
|
||||||
sort.Strings(roles)
|
|
||||||
for _, role := range roles {
|
|
||||||
if _, ok := Roles[role]; !ok {
|
if _, ok := Roles[role]; !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -104,6 +101,10 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
|||||||
st.KindDatabricks = v.fg(lipgloss.NewStyle())
|
st.KindDatabricks = v.fg(lipgloss.NewStyle())
|
||||||
case "repoColor":
|
case "repoColor":
|
||||||
st.KindRepo = v.fg(lipgloss.NewStyle())
|
st.KindRepo = v.fg(lipgloss.NewStyle())
|
||||||
|
case "springColor":
|
||||||
|
st.Spring = v.fg(lipgloss.NewStyle())
|
||||||
|
case "composeColor":
|
||||||
|
st.Compose = v.fg(lipgloss.NewStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return st, fg, nil
|
return st, fg, nil
|
||||||
@@ -131,7 +132,6 @@ type value struct {
|
|||||||
bold, italic, underline, reverse, faint bool
|
bold, italic, underline, reverse, faint bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// fg styles s with the value as foreground, bg as background.
|
|
||||||
func (v value) fg(s lipgloss.Style) lipgloss.Style {
|
func (v value) fg(s lipgloss.Style) lipgloss.Style {
|
||||||
if v.color != nil {
|
if v.color != nil {
|
||||||
s = s.Foreground(v.color)
|
s = s.Foreground(v.color)
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ const (
|
|||||||
// DefaultServers are where releases are looked for, in order.
|
// DefaultServers are where releases are looked for, in order.
|
||||||
var DefaultServers = []string{GitHubRepo, DefaultRepo}
|
var DefaultServers = []string{GitHubRepo, DefaultRepo}
|
||||||
|
|
||||||
// latestTimeout bounds asking one server for the latest release, so a
|
// serverTimeout bounds asking one server for the latest release or its
|
||||||
// server that's down leaves time for the next.
|
// checksums, so a server that's down leaves time for the next.
|
||||||
const latestTimeout = 5 * time.Second
|
const serverTimeout = 5 * time.Second
|
||||||
|
|
||||||
// maxDownload bounds what's read from the server.
|
// maxDownload bounds what's read from the server.
|
||||||
const maxDownload = 200 << 20
|
const maxDownload = 200 << 20
|
||||||
@@ -89,14 +89,9 @@ func (r Release) Notes(current string) string {
|
|||||||
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") }
|
||||||
|
|
||||||
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
|
|
||||||
// and a terminal shows them as their text.
|
|
||||||
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
||||||
|
|
||||||
// Client talks to the release servers.
|
// Client talks to the release servers.
|
||||||
@@ -148,12 +143,15 @@ func (c *Client) Latest(ctx context.Context) (Release, error) {
|
|||||||
return Release{}, errors.Join(errs...)
|
return Release{}, errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recentReleases is how many releases Latest reads the changelogs of.
|
||||||
|
const recentReleases = 10
|
||||||
|
|
||||||
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
||||||
api, err := API(repo)
|
api, err := API(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Release{}, err
|
return Release{}, err
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
ctx, cancel := context.WithTimeout(ctx, serverTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
// The recent releases in one request (per_page on GitHub, limit on
|
// The recent releases in one request (per_page on GitHub, limit on
|
||||||
// Gitea), for their changelogs.
|
// Gitea), for their changelogs.
|
||||||
@@ -222,7 +220,7 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
|||||||
if m := Managed(exe); m != "" {
|
if m := Managed(exe); m != "" {
|
||||||
return fmt.Errorf("%s is managed by %s: update it there", exe, m)
|
return fmt.Errorf("%s is managed by %s: update it there", exe, m)
|
||||||
}
|
}
|
||||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" || runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
|
if (runtime.GOOS != "darwin" && runtime.GOOS != "linux") || (runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64") {
|
||||||
return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH)
|
return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||||
}
|
}
|
||||||
version := strings.TrimPrefix(r.Tag, "v")
|
version := strings.TrimPrefix(r.Tag, "v")
|
||||||
@@ -272,7 +270,7 @@ func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error)
|
|||||||
var errs []error
|
var errs []error
|
||||||
for _, repo := range c.servers() {
|
for _, repo := range c.servers() {
|
||||||
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
|
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
|
||||||
sctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
sctx, cancel := context.WithTimeout(ctx, serverTimeout)
|
||||||
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
|
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in new issue
Block a user