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

This commit is contained in:
keb authored and Karl-Einar Bergstrøm committed 2026-10-13 23:27:52 +02:00
1 parent e28328ef82
commit ed8ac04b35
16 files changed
+189 -58

No files matched your search

+14 -4
View File
@@ -82,10 +82,10 @@ type Updates struct {
AutoCheck *bool `yaml:"autoCheck"`
// AutoCheckInterval defaults to 24h.
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,
// then git.kebcloud.no (update.DefaultServers).
UpdateSources []string `yaml:"updateSources"`
Sources []string `yaml:"sources"`
}
type LinkHandler struct {
@@ -268,9 +268,9 @@ func (c Config) validate(path string) error {
if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
}
for i, r := range c.Updates.UpdateSources {
for i, r := range c.Updates.Sources {
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 != "" {
@@ -284,6 +284,16 @@ func (c Config) validate(path string) error {
if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
}
for name, d := range map[string]time.Duration{
"providers.timeout": c.Providers.Timeout,
"providers.azure.timeout": c.Providers.Azure.Timeout,
"providers.databricks.timeout": c.Providers.Databricks.Timeout,
"providers.repos.timeout": c.Providers.Repos.Timeout,
} {
if d < 0 {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
}
}
for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") {
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",
"flat keys": "icons: false\n",
"duration": "providers:\n timeout: soon\n",
"negative": "providers:\n repos:\n timeout: -1s\n",
"view": "ui:\n sourcesView: tree\n",
"sources": "updates:\n updateSources: [git.example.com/team/lazysecrets]\n",
"http": "updates:\n updateSources: [http://git.example.com/team/lazysecrets]\n",
"sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n",
"http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n",
"interval": "updates:\n autoCheckInterval: 0s\n",
"share key": "sharing:\n key: https://kv.vault.azure.net/\n",
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
@@ -131,8 +132,8 @@ theme:
}
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"))
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.UpdateSources) != 1 {
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n"))
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
t.Errorf("updates = %+v, %v", c.Updates, err)
}
}
+50 -16
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) {
file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key
file, extract = o.Container.ID, extractFor(o.Name)
file = o.Container.ID
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
extract = extractFor(file, o.Name)
}
if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name)
@@ -1012,7 +1013,7 @@ func formatOf(file string) string {
}
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"]
encrypted bool
line int // 1-based
@@ -1057,7 +1058,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue
}
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
@@ -1074,9 +1075,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
if top && k == "sops" {
continue
}
child := k
child := escape(k)
if name != "" {
child = name + "." + k
child = name + "." + child
}
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) + `"]`
}
// escape keeps a key's dots and brackets from reading as nesting in a
// name: app\.name is one key, app.name two.
func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0]). Names with dots in keys can't be
// told apart from nesting: those come from the listing instead.
func extractFor(name string) string {
var b strings.Builder
for _, part := range strings.Split(name, ".") {
base, idx, _ := strings.Cut(part, "[")
if base != "" {
b.WriteString(quote(base))
}
if idx != "" {
b.WriteString("[" + idx)
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
// its names are the keys as they are.
func extractFor(file, name string) string {
if formatOf(file) == "dotenv" {
return quote(name)
}
var b, part strings.Builder
flush := func() {
if part.Len() > 0 {
b.WriteString(quote(part.String()))
part.Reset()
}
}
for i := 0; i < len(name); i++ {
switch c := name[i]; c {
case '\\':
if i+1 < len(name) {
i++
}
part.WriteByte(name[i])
case '.':
flush()
case '[':
end := strings.IndexByte(name[i:], ']')
if end < 0 || !digits(name[i+1:i+end]) {
part.WriteByte(c)
continue
}
flush()
b.WriteString(name[i : i+end+1])
i += end
default:
part.WriteByte(c)
}
}
flush()
return b.String()
}
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
}
func absPath(p string) string {
if a, err := filepath.Abs(p); err == nil {
return a
+21
View File
@@ -545,3 +545,24 @@ func TestFileDetails(t *testing.T) {
t.Errorf("untracked env: %+v", d)
}
}
func TestKeyNamesEscaped(t *testing.T) {
keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n"))
if err != nil {
t.Fatal(err)
}
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
+1 -1
View File
@@ -310,7 +310,7 @@ func parseLink(s string) (Ref, error) {
}
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
}
+13 -6
View File
@@ -145,16 +145,23 @@ type Store struct {
data file
}
// ErrNewer is a state file written by a newer lazysecrets.
var ErrNewer = errors.New("written by a newer lazysecrets")
// Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo.
// 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) {
s := &Store{path: path}
d, err := read(path)
if err != nil {
if errors.Is(err, ErrNewer) {
s.path = ""
} else if err != nil {
return nil, err
}
s.data = d
return s, nil
return s, err
}
func read(path string) (file, error) {
@@ -172,12 +179,12 @@ func read(path string) (file, error) {
if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err)
}
if d.Version > version {
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
}
if d.Nodes == nil {
d.Nodes = map[string]Node{}
}
if d.Version > version {
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
}
return d, nil
}
+11 -4
View File
@@ -1,6 +1,7 @@
package store
import (
"errors"
"fmt"
"os"
"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")
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
if _, err := Load(path); err == nil {
t.Error("a newer file was accepted")
newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
_ = os.WriteFile(path, []byte(newer), 0o600)
s, err := Load(path)
if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" {
t.Fatalf("Load = %v, %v", s, err)
}
_ = s.SetFlag("x", true)
if b, _ := os.ReadFile(path); string(b) != newer {
t.Errorf("the newer file was overwritten: %s", b)
}
}
+6
View File
@@ -79,6 +79,9 @@ type Options struct {
// and keeps its state, for the status pane.
ConfigFiles []string
StatePath string
// Warning shows when the window opens: what lazysecrets skipped
// instead of failing to start.
Warning string
// Changelog is CHANGELOG.md, for the status pane.
Changelog string
// CacheFile keeps what was loaded between runs; "" keeps it for this
@@ -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.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if opts.Warning != "" {
m.flash = warning(opts.Warning)
}
if m.cachePath != "" {
m.loadCacheFile()
if len(m.sourceResults) > 0 {
+8
View File
@@ -122,3 +122,11 @@ func TestUpdateAsked(t *testing.T) {
t.Errorf("U on a dev build:\n%s", dev.screen())
}
}
func TestStartupWarning(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Warning: "theme: skipped unknown activeBorder"})
if !strings.Contains(d.screen(), "theme: skipped unknown activeBorder") {
t.Errorf("no warning:\n%s", d.screen())
}
}
+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, "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, "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, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
@@ -103,7 +103,7 @@ func (m *Map) Actions() []Action {
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "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, "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."),
@@ -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, "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, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
@@ -184,13 +184,13 @@ var defaults = map[string][]string{
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.open": {"enter"}, "lists.back": {"esc", "backspace"},
"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.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
"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.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
"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
// 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) {
st := Default()
var fg color.Color
@@ -56,7 +58,7 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
sort.Strings(roles)
for _, role := range roles {
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])
if err != nil {
@@ -107,13 +109,20 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
return st, fg, nil
}
func sortedRoles() []string {
var out []string
for r := range Roles {
out = append(out, r)
// Unknown says which of the theme's roles aren't known, for a warning, or
// is empty.
func Unknown(theme map[string][]string) string {
var unknown []string
for r := range theme {
if _, ok := Roles[r]; !ok {
unknown = append(unknown, r)
}
}
slices.Sort(out)
return out
if len(unknown) == 0 {
return ""
}
slices.Sort(unknown)
return "theme: skipped unknown " + strings.Join(unknown, ", ") + " (a typo, or from a newer lazysecrets)"
}
// value is a parsed role: a colour (nil for default) and attributes.
+13 -3
View File
@@ -44,19 +44,29 @@ func TestFromTheme(t *testing.T) {
func TestFromThemeRejectsMistakes(t *testing.T) {
for name, theme := range map[string]map[string][]string{
"role": {"activeBorder": {"red"}},
"colour": {"errorColor": {"redish"}},
"hex": {"errorColor": {"#fff"}},
"palette": {"errorColor": {"300"}},
} {
if _, _, err := FromTheme(theme); err == nil {
t.Errorf("%s: no error", name)
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
t.Errorf("unknown role error should list the known ones: %v", err)
}
}
}
func TestUnknownRolesSkipped(t *testing.T) {
theme := map[string][]string{"activeBorder": {"red"}, "errorColor": {"red"}}
if _, _, err := FromTheme(theme); err != nil {
t.Errorf("unknown role failed the theme: %v", err)
}
if w := Unknown(theme); !strings.Contains(w, "unknown activeBorder ") || strings.Contains(w, "errorColor") {
t.Errorf("warning = %q", w)
}
if w := Unknown(map[string][]string{"errorColor": {"red"}}); w != "" {
t.Errorf("warning for known roles: %q", w)
}
}
func TestEmptyThemeIsDefault(t *testing.T) {
st, fg, err := FromTheme(nil)
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {