3 Commits
Author SHA1 Message Date
keb ed8ac04b35 fix: unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 2s
2026-10-13 23:27:52 +02:00
keb e28328ef82 docs: SECURITY.md says how to report a vulnerability (#65)
SECURITY.md asks for vulnerabilities to be reported privately, through GitHub's private vulnerability reporting or by email to karl-einarb@outlook.com.

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

Reviewed-on: #65
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 23:27:43 +02:00
keb 6b3edd45d8 fix: help and errors name sharing.key and sharing.keyRules, not share.* (#64)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 22:57:49 +02:00
25 changed files with 219 additions and 77 deletions

No files matched your search

+1 -1
View File
@@ -92,7 +92,7 @@ use most:
secret secret
- [Config](docs/config.md): every option, themes, layered configs, the - [Config](docs/config.md): every option, themes, layered configs, the
state file state file
- [Development](docs/development.md): building, tests, pull requests and - [Development](docs/development.md): building, tests, how changes land and
releasing releasing
## Privacy ## Privacy
+11
View File
@@ -0,0 +1,11 @@
# Security
Please report a vulnerability privately, not in a public issue:
- on GitHub, with **Report a vulnerability** under the
[Security tab](https://github.com/karleinarb/lazysecrets/security), or
- by email to karl-einarb@outlook.com.
Say what's affected, how to reproduce it and the version
(`lazysecrets --version`). Fixes go into the next release, and only the
latest release gets them.
+15 -3
View File
@@ -5,6 +5,7 @@ package main
import ( import (
"cmp" "cmp"
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -122,7 +123,7 @@ func run() error {
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] share [--key <secret>] <env file>... lazysecrets [flags] share [--key <secret>] <env file>...
print a link to env files of a repo, encrypted print a link to env files of a repo, encrypted
with a secret's value (default: share.key) with a secret's value (default: sharing.key)
lazysecrets [flags] keybindings print every action and its keys (with your lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME] lazysecrets handler install [--terminal NAME]
@@ -194,6 +195,11 @@ Flags:
if err != nil { if err != nil {
return err return err
} }
// Warnings show when the window opens.
var warnings []string
if w := styles.Unknown(cfg.Theme); w != "" {
warnings = append(warnings, w)
}
keymap := keys.Default() keymap := keys.Default()
if err := keymap.Apply(cfg.Keybinding); err != nil { if err := keymap.Apply(cfg.Keybinding); err != nil {
return err return err
@@ -208,6 +214,9 @@ Flags:
// Precedence: flag > environment > config file > default. // Precedence: flag > environment > config file > default.
set := map[string]bool{} set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true }) flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if *timeout <= 0 {
return fmt.Errorf("--timeout: %s is not positive", *timeout)
}
// Each provider's own timeout, unless --timeout sets them all. // Each provider's own timeout, unless --timeout sets them all.
timeouts := map[provider.Kind]time.Duration{} timeouts := map[provider.Kind]time.Duration{}
if !set["timeout"] { if !set["timeout"] {
@@ -265,7 +274,9 @@ Flags:
statePath = filepath.Join(stateDir, "state.json") statePath = filepath.Join(stateDir, "state.json")
} }
st, err := store.Load(statePath) st, err := store.Load(statePath)
if err != nil { if errors.Is(err, store.ErrNewer) {
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
} else if err != nil {
return err return err
} }
@@ -406,13 +417,14 @@ Flags:
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.Values.HideOnBlur), HideOnBlur: config.On(cfg.Values.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(cfg.Updates.UpdateSources), Updater: updater(cfg.Updates.Sources),
UpdateCheck: config.On(cfg.Updates.AutoCheck), UpdateCheck: config.On(cfg.Updates.AutoCheck),
UpdateEvery: updateEvery, UpdateEvery: updateEvery,
Keys: &keymap, Keys: &keymap,
Commands: cfg.CustomCommands, Commands: cfg.CustomCommands,
Version: versionString(), Version: versionString(),
Store: st, Store: st,
Warning: strings.Join(warnings, " · "),
CacheFile: cacheFile, CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath), ConfigFiles: configFiles(*configPath),
StatePath: statePath, StatePath: statePath,
+4 -4
View File
@@ -17,12 +17,12 @@ import (
) )
// shareFiles prints a share link to env files of one repo, encrypted with // shareFiles prints a share link to env files of one repo, encrypted with
// the --key secret's value, else the repo's or the config's share.key: // the --key secret's value, else the repo's or the config's sharing.key:
// //
// lazysecrets share .env apps/api/.env // lazysecrets share .env apps/api/.env
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error { func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error {
fs := flag.NewFlagSet("share", flag.ContinueOnError) fs := flag.NewFlagSet("share", flag.ContinueOnError)
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: share.key)") keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: sharing.key)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
@@ -73,7 +73,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
return err return err
} }
if !ok { if !ok {
return errors.New("which secret to encrypt with? --key <secret>, or share.key in the repo's .lazysecrets.yaml or the config") return errors.New("which secret to encrypt with? --key <secret>, or sharing.key in the repo's .lazysecrets.yaml or the config")
} }
v, version, err := readKey(ctx, providers, st, key) v, version, err := readKey(ctx, providers, st, key)
if err != nil { if err != nil {
@@ -87,7 +87,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
return err return err
} }
// shareRules are the config's share.keyRules. // shareRules are the config's sharing.keyRules.
func shareRules(cfg config.Config) share.Rules { func shareRules(cfg config.Config) share.Rules {
return share.Rules{MinLength: cfg.Sharing.KeyRules.MinLength, MinClasses: cfg.Sharing.KeyRules.MinClasses} return share.Rules{MinLength: cfg.Sharing.KeyRules.MinLength, MinClasses: cfg.Sharing.KeyRules.MinClasses}
} }
+1 -1
View File
@@ -43,7 +43,7 @@ func updateCmd(configPath string, args []string) error {
current := versionString() current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
c := &update.Client{Servers: cfg.Updates.UpdateSources} c := &update.Client{Servers: cfg.Updates.Sources}
r, err := c.Latest(ctx) r, err := c.Latest(ctx)
if err != nil { if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
+7 -2
View File
@@ -23,7 +23,7 @@ storage:
updates: updates:
autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way) autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way)
autoCheckInterval: 24h # at most this often autoCheckInterval: 24h # at most this often
updateSources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server) sources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets - https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets - https://git.kebcloud.no/keb/lazysecrets
providers: providers:
@@ -91,6 +91,9 @@ theme:
repoColor: ["#c099ff"] repoColor: ["#c099ff"]
``` ```
A role lazysecrets doesn't know, a typo or one from a newer version, is
skipped with a warning.
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
as boxes, turn them off with `ui.icons: false`. as boxes, turn them off with `ui.icons: false`.
@@ -116,6 +119,8 @@ starred the item under (a vault two of your accounts can see can be starred
for one); a note describes the item, so it shows under every account. It's for one); a note describes the item, so it shows under every account. It's
JSON so an editor plugin can read it with a built-in parser; several JSON so an editor plugin can read it with a built-in parser; several
lazysecrets windows can save to it without losing each other's changes. lazysecrets windows can save to it without losing each other's changes.
One written by a newer lazysecrets is read but not saved to, with a
warning, until you update.
Revealing, copying, opening in the browser, sharing and jumping to an item Revealing, copying, opening in the browser, sharing and jumping to an item
count as using it: its record keeps when it was last used and how often, count as using it: its record keeps when it was last used and how often,
@@ -149,7 +154,7 @@ At startup, in the background, lazysecrets asks for the latest release
until then it reuses the answer from the state file. Releases are 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.updateSources` lists others, such as a mirror of your own. Offline, or with `updates.sources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases. `lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
+1 -1
View File
@@ -22,7 +22,7 @@ Development builds show `dev-<commit>` as their version (`-dirty` with
uncommitted changes); `go install …@v0.2.0` shows the tag, releases the uncommitted changes); `go install …@v0.2.0` shows the tag, releases the
version they were built for. version they were built for.
## Pull requests ## How changes land
Pull requests into `main` are squash-merged, so each is one commit titled Pull requests into `main` are squash-merged, so each is one commit titled
like the PR. The title is a [conventional commit](https://www.conventionalcommits.org) like the PR. The title is a [conventional commit](https://www.conventionalcommits.org)
+2 -1
View File
@@ -3,7 +3,8 @@
lazysecrets shows git repositories under **Repos**: their lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, [SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI) and plain env files as vaults, and the keys in them `.env`, INI) and plain env files as vaults, and the keys in them
(`db.password`, `list[0]`) as secrets. (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
escaped, `app\.name`, so it doesn't read as nesting.
## Which repos ## Which repos
+14 -4
View File
@@ -82,10 +82,10 @@ type Updates struct {
AutoCheck *bool `yaml:"autoCheck"` AutoCheck *bool `yaml:"autoCheck"`
// AutoCheckInterval defaults to 24h. // AutoCheckInterval defaults to 24h.
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"` AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
// UpdateSources are the repositories updates come from, tried in // Sources are the repositories updates come from, tried in
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, // order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
// then git.kebcloud.no (update.DefaultServers). // then git.kebcloud.no (update.DefaultServers).
UpdateSources []string `yaml:"updateSources"` Sources []string `yaml:"sources"`
} }
type LinkHandler struct { type LinkHandler struct {
@@ -268,9 +268,9 @@ func (c Config) validate(path string) error {
if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 { if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d) return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
} }
for i, r := range c.Updates.UpdateSources { for i, r := range c.Updates.Sources {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") { if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: updates.updateSources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) return fmt.Errorf("%s: updates.sources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
} }
} }
if k := c.Sharing.Key; k != "" { if k := c.Sharing.Key; k != "" {
@@ -284,6 +284,16 @@ func (c Config) validate(path string) error {
if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 { if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n) return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
} }
for name, d := range map[string]time.Duration{
"providers.timeout": c.Providers.Timeout,
"providers.azure.timeout": c.Providers.Azure.Timeout,
"providers.databricks.timeout": c.Providers.Databricks.Timeout,
"providers.repos.timeout": c.Providers.Repos.Timeout,
} {
if d < 0 {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
}
}
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
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)
+5 -4
View File
@@ -75,9 +75,10 @@ func TestLoadRejectsMistakes(t *testing.T) {
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"flat keys": "icons: false\n", "flat keys": "icons: false\n",
"duration": "providers:\n timeout: soon\n", "duration": "providers:\n timeout: soon\n",
"negative": "providers:\n repos:\n timeout: -1s\n",
"view": "ui:\n sourcesView: tree\n", "view": "ui:\n sourcesView: tree\n",
"sources": "updates:\n updateSources: [git.example.com/team/lazysecrets]\n", "sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n",
"http": "updates:\n updateSources: [http://git.example.com/team/lazysecrets]\n", "http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n",
"interval": "updates:\n autoCheckInterval: 0s\n", "interval": "updates:\n autoCheckInterval: 0s\n",
"share key": "sharing:\n key: https://kv.vault.azure.net/\n", "share key": "sharing:\n key: https://kv.vault.azure.net/\n",
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n", "workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
@@ -131,8 +132,8 @@ theme:
} }
func TestUpdates(t *testing.T) { func TestUpdates(t *testing.T) {
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n updateSources: [https://git.example.com/team/lazysecrets]\n")) c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n"))
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.UpdateSources) != 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)
} }
} }
+1 -1
View File
@@ -332,7 +332,7 @@ func Azure(delay time.Duration) *Provider {
}, demoCert) }, demoCert)
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as share.keyRules wants. // A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"), Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=") }, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
+51 -17
View File
@@ -403,10 +403,11 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00") file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key if !ok { // made from a link: the repo's root, the file's path, the key
file, extract = o.Container.ID, extractFor(o.Name) file = o.Container.ID
if file == "" { if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name)) file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
} }
extract = extractFor(file, o.Name)
} }
if _, isSops := readMeta(file); !isSops { if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name) return plainValue(file, o.Name)
@@ -606,7 +607,7 @@ type lazysecretsFile struct {
} `yaml:"sharing"` } `yaml:"sharing"`
} }
// ShareKey is the share.key of the repo's .lazysecrets.yaml, if any. // ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) { func ShareKey(root string) (string, error) {
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")) b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@@ -1012,7 +1013,7 @@ func formatOf(file string) string {
} }
type key struct { type key struct {
name string // db.password, list[0] name string // db.password, list[0], with . [ ] \ in keys escaped
extract string // sops --extract: ["db"]["password"] extract string // sops --extract: ["db"]["password"]
encrypted bool encrypted bool
line int // 1-based line int // 1-based
@@ -1057,7 +1058,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue continue
} }
k, v = strings.TrimSpace(k), strings.TrimSpace(v) k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
} }
} }
return out, nil return out, nil
@@ -1074,9 +1075,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
if top && k == "sops" { if top && k == "sops" {
continue continue
} }
child := k child := escape(k)
if name != "" { if name != "" {
child = name + "." + k child = name + "." + child
} }
walk(n.Content[i+1], child, extract+quote(k), false, out) walk(n.Content[i+1], child, extract+quote(k), false, out)
} }
@@ -1098,23 +1099,56 @@ func quote(k string) string {
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]` return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
} }
// escape keeps a key's dots and brackets from reading as nesting in a
// name: app\.name is one key, app.name two.
func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractFor rebuilds the extract path from a key name, for objects made // extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0]). Names with dots in keys can't be // from a link (db.password, list[0], app\.name). A dotenv file is flat:
// told apart from nesting: those come from the listing instead. // its names are the keys as they are.
func extractFor(name string) string { func extractFor(file, name string) string {
var b strings.Builder if formatOf(file) == "dotenv" {
for _, part := range strings.Split(name, ".") { return quote(name)
base, idx, _ := strings.Cut(part, "[") }
if base != "" { var b, part strings.Builder
b.WriteString(quote(base)) flush := func() {
} if part.Len() > 0 {
if idx != "" { b.WriteString(quote(part.String()))
b.WriteString("[" + idx) part.Reset()
} }
} }
for i := 0; i < len(name); i++ {
switch c := name[i]; c {
case '\\':
if i+1 < len(name) {
i++
}
part.WriteByte(name[i])
case '.':
flush()
case '[':
end := strings.IndexByte(name[i:], ']')
if end < 0 || !digits(name[i+1:i+end]) {
part.WriteByte(c)
continue
}
flush()
b.WriteString(name[i : i+end+1])
i += end
default:
part.WriteByte(c)
}
}
flush()
return b.String() return b.String()
} }
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
}
func absPath(p string) string { func absPath(p string) string {
if a, err := filepath.Abs(p); err == nil { if a, err := filepath.Abs(p); err == nil {
return a return a
+21
View File
@@ -545,3 +545,24 @@ func TestFileDetails(t *testing.T) {
t.Errorf("untracked env: %+v", d) t.Errorf("untracked env: %+v", d)
} }
} }
func TestKeyNamesEscaped(t *testing.T) {
keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n"))
if err != nil {
t.Fatal(err)
}
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
+1 -1
View File
@@ -310,7 +310,7 @@ func parseLink(s string) (Ref, error) {
} }
default: default:
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host) return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host)
} }
return r, nil return r, nil
} }
+1 -1
View File
@@ -53,7 +53,7 @@ func (r Rules) Check(key []byte) error {
if len(has) == 0 { if len(has) == 0 {
return nil return nil
} }
return fmt.Errorf("its value has %s; share.keyRules needs %s", return fmt.Errorf("its value has %s; sharing.keyRules needs %s",
strings.Join(has, " and "), strings.Join(needs, " and ")) strings.Join(has, " and "), strings.Join(needs, " and "))
} }
+3 -3
View File
@@ -259,8 +259,8 @@ func CheckPath(p string) error {
return nil return nil
} }
// DefaultKey is the key for a repo's files: share.key in the repo's // DefaultKey is the key for a repo's files: sharing.key in the repo's
// .lazysecrets.yaml, else general (share.key in the config). ok is false // .lazysecrets.yaml, else general (sharing.key in the config). ok is false
// when neither names one. // when neither names one.
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) { func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
link, err := repo.ShareKey(root) link, err := repo.ShareKey(root)
@@ -276,7 +276,7 @@ func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
} }
key, err = ParseKey(link) key, err = ParseKey(link)
if err != nil { if err != nil {
return ref.Ref{}, false, fmt.Errorf("share.key in %s: %w", where, err) return ref.Ref{}, false, fmt.Errorf("sharing.key in %s: %w", where, err)
} }
return key, true, nil return key, true, nil
} }
+13 -6
View File
@@ -145,16 +145,23 @@ type Store struct {
data file data file
} }
// ErrNewer is a state file written by a newer lazysecrets.
var ErrNewer = errors.New("written by a newer lazysecrets")
// Load reads path; a missing file is an empty store. path "" gives a store // Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo. // that never saves, for tests and --demo. A file from a newer lazysecrets
// is read but never saved, so nothing it keeps is lost: Load returns the
// store with an ErrNewer error.
func Load(path string) (*Store, error) { func Load(path string) (*Store, error) {
s := &Store{path: path} s := &Store{path: path}
d, err := read(path) d, err := read(path)
if err != nil { if errors.Is(err, ErrNewer) {
s.path = ""
} else if err != nil {
return nil, err return nil, err
} }
s.data = d s.data = d
return s, nil return s, err
} }
func read(path string) (file, error) { func read(path string) (file, error) {
@@ -172,12 +179,12 @@ func read(path string) (file, error) {
if err := json.Unmarshal(b, &d); err != nil { if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err) return d, fmt.Errorf("%s: %w", path, err)
} }
if d.Version > version {
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
}
if d.Nodes == nil { if d.Nodes == nil {
d.Nodes = map[string]Node{} d.Nodes = map[string]Node{}
} }
if d.Version > version {
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
}
return d, nil return d, nil
} }
+11 -4
View File
@@ -1,6 +1,7 @@
package store package store
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -65,11 +66,17 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
} }
} }
func TestNewerVersionRefused(t *testing.T) { func TestNewerVersionReadOnly(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json") path := filepath.Join(t.TempDir(), "state.json")
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600) newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
if _, err := Load(path); err == nil { _ = os.WriteFile(path, []byte(newer), 0o600)
t.Error("a newer file was accepted") s, err := Load(path)
if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" {
t.Fatalf("Load = %v, %v", s, err)
}
_ = s.SetFlag("x", true)
if b, _ := os.ReadFile(path); string(b) != newer {
t.Errorf("the newer file was overwritten: %s", b)
} }
} }
+9 -3
View File
@@ -79,12 +79,15 @@ type Options struct {
// and keeps its state, for the status pane. // and keeps its state, for the status pane.
ConfigFiles []string ConfigFiles []string
StatePath string StatePath string
// Warning shows when the window opens: what lazysecrets skipped
// instead of failing to start.
Warning string
// Changelog is CHANGELOG.md, for the status pane. // Changelog is CHANGELOG.md, for the status pane.
Changelog string Changelog string
// CacheFile keeps what was loaded between runs; "" keeps it for this // CacheFile keeps what was loaded between runs; "" keeps it for this
// run only. // run only.
CacheFile string CacheFile string
// ShareKey is the config's share.key: the secret that encrypts shared // ShareKey is the config's sharing.key: the secret that encrypts shared
// env files, for repos whose .lazysecrets.yaml names none. // env files, for repos whose .lazysecrets.yaml names none.
ShareKey string ShareKey string
// ShareRules are what a key's value must be to share with it. // ShareRules are what a key's value must be to share with it.
@@ -281,8 +284,8 @@ type Model struct {
linked []provider.Container // vaults opened from links, this session linked []provider.Container // vaults opened from links, this session
open *ref.Ref // reference to open at startup open *ref.Ref // reference to open at startup
shareKey string // the config's share.key shareKey string // the config's sharing.key
shareRules share.Rules // and share.keyRules shareRules share.Rules // and sharing.keyRules
// A lazysecrets:// link found on the clipboard, offered in the footer. // A lazysecrets:// link found on the clipboard, offered in the footer.
dismissedLink string // the last link offered: not offered again dismissedLink string // the last link offered: not offered again
@@ -474,6 +477,9 @@ func New(providers []provider.Provider, opts Options) Model {
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if opts.Warning != "" {
m.flash = warning(opts.Warning)
}
if m.cachePath != "" { if m.cachePath != "" {
m.loadCacheFile() m.loadCacheFile()
if len(m.sourceResults) > 0 { if len(m.sourceResults) > 0 {
+1 -1
View File
@@ -38,7 +38,7 @@ type shareFilesMsg struct {
type sealedMsg struct { type sealedMsg struct {
link ref.Ref link ref.Ref
n int n int
// from is the picker the key was picked in; nil for share.key's. // from is the picker the key was picked in; nil for sharing.key's.
from *picker from *picker
err error err error
} }
+2 -2
View File
@@ -50,7 +50,7 @@ func shareFrom(t *testing.T, root string, paste bool) string {
t.Fatalf("selected %v, want the repo", src.ID) t.Fatalf("selected %v, want the repo", src.ID)
} }
d.keys("S", "f") d.keys("S", "f")
// No share.key set: it asks which secret, in Go to, which lists // No sharing.key set: it asks which secret, in Go to, which lists
// only secrets. // only secrets.
v := d.m.search v := d.m.search
if v == nil || v.picker == nil { if v == nil || v.picker == nil {
@@ -218,7 +218,7 @@ func TestShareInDemo(t *testing.T) {
} }
// Too short a key: the picker stays, saying why. // Too short a key: the picker stays, saying why.
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"}) d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"})
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") { if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; sharing.keyRules needs 32 (minLength)") {
t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen()) t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
} }
if !strings.Contains(d.screen(), "28 characters") { if !strings.Contains(d.screen(), "28 characters") {
+8
View File
@@ -122,3 +122,11 @@ func TestUpdateAsked(t *testing.T) {
t.Errorf("U on a dev build:\n%s", dev.screen()) t.Errorf("U on a dev build:\n%s", dev.screen())
} }
} }
func TestStartupWarning(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Warning: "theme: skipped unknown activeBorder"})
if !strings.Contains(d.screen(), "theme: skipped unknown activeBorder") {
t.Errorf("no warning:\n%s", d.screen())
}
}
+6 -6
View File
@@ -95,7 +95,7 @@ func (m *Map) Actions() []Action {
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."), a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."), a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."), a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."), a(Lists, "open", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."), a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."), a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
@@ -103,7 +103,7 @@ func (m *Map) Actions() []Action {
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."), a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."), a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
@@ -116,7 +116,7 @@ func (m *Map) Actions() []Action {
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."), a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."), a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), a(Lists, "copyLink", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."), a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."),
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."), a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
@@ -184,13 +184,13 @@ var defaults = map[string][]string{
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"}, "lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, "lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"}, "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"}, "lists.open": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"}, "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"},
"lists.commands": {"x"}, "lists.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
"lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"}, "lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"},
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"}, "lists.copyLink": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"}, "lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"}, "lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="}, "lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
+17 -8
View File
@@ -45,7 +45,9 @@ var Roles = map[string]string{
} }
// FromTheme returns the default styles with the theme's roles applied, and // FromTheme returns the default styles with the theme's roles applied, and
// the default text colour if the theme sets one. // the default text colour if the theme sets one. Roles it doesn't know are
// skipped (see Unknown): a theme file made for a newer lazysecrets still
// works.
func FromTheme(theme map[string][]string) (Styles, color.Color, error) { func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default() st := Default()
var fg color.Color var fg color.Color
@@ -56,7 +58,7 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
sort.Strings(roles) sort.Strings(roles)
for _, role := range roles { for _, role := range roles {
if _, ok := Roles[role]; !ok { if _, ok := Roles[role]; !ok {
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", ")) continue
} }
v, err := parse(theme[role]) v, err := parse(theme[role])
if err != nil { if err != nil {
@@ -107,13 +109,20 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
return st, fg, nil return st, fg, nil
} }
func sortedRoles() []string { // Unknown says which of the theme's roles aren't known, for a warning, or
var out []string // is empty.
for r := range Roles { func Unknown(theme map[string][]string) string {
out = append(out, r) var unknown []string
for r := range theme {
if _, ok := Roles[r]; !ok {
unknown = append(unknown, r)
}
} }
slices.Sort(out) if len(unknown) == 0 {
return out return ""
}
slices.Sort(unknown)
return "theme: skipped unknown " + strings.Join(unknown, ", ") + " (a typo, or from a newer lazysecrets)"
} }
// value is a parsed role: a colour (nil for default) and attributes. // value is a parsed role: a colour (nil for default) and attributes.
+13 -3
View File
@@ -44,19 +44,29 @@ func TestFromTheme(t *testing.T) {
func TestFromThemeRejectsMistakes(t *testing.T) { func TestFromThemeRejectsMistakes(t *testing.T) {
for name, theme := range map[string]map[string][]string{ for name, theme := range map[string]map[string][]string{
"role": {"activeBorder": {"red"}},
"colour": {"errorColor": {"redish"}}, "colour": {"errorColor": {"redish"}},
"hex": {"errorColor": {"#fff"}}, "hex": {"errorColor": {"#fff"}},
"palette": {"errorColor": {"300"}}, "palette": {"errorColor": {"300"}},
} { } {
if _, _, err := FromTheme(theme); err == nil { if _, _, err := FromTheme(theme); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
t.Errorf("unknown role error should list the known ones: %v", err)
} }
} }
} }
func TestUnknownRolesSkipped(t *testing.T) {
theme := map[string][]string{"activeBorder": {"red"}, "errorColor": {"red"}}
if _, _, err := FromTheme(theme); err != nil {
t.Errorf("unknown role failed the theme: %v", err)
}
if w := Unknown(theme); !strings.Contains(w, "unknown activeBorder ") || strings.Contains(w, "errorColor") {
t.Errorf("warning = %q", w)
}
if w := Unknown(map[string][]string{"errorColor": {"red"}}); w != "" {
t.Errorf("warning for known roles: %q", w)
}
}
func TestEmptyThemeIsDefault(t *testing.T) { func TestEmptyThemeIsDefault(t *testing.T) {
st, fg, err := FromTheme(nil) st, fg, err := FromTheme(nil)
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack { if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {