lockbox

password manager
Log | Files | Refs | README | LICENSE

commit a447ba980ef18e7176344067fe0932f86f69eea2
parent 73db5099d11ea637afb80b55196a2b0a308ca6be
Author: Sean Enck <sean@ttypty.com>
Date:   Sun,  7 Sep 2025 09:04:44 -0400

add 'fields' command which gets groups+allowed fields - improves insert completion

Diffstat:
Mcmd/lb/main.go | 10++++++++--
Mcmd/lb/main_test.go | 3+++
Mcmd/lb/tests/expected.log | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/app/commands/core.go | 1+
Minternal/app/completions/core.go | 2++
Minternal/app/completions/shell/bash.sh | 5++++-
Minternal/app/completions/shell/zsh.sh | 7++++++-
Minternal/app/help/core.go | 1+
Minternal/app/help/core_test.go | 4++--
Minternal/app/list.go | 36+++++++++++++++++++++++++++++++-----
Minternal/app/list_test.go | 52+++++++++++++++++++++++++++++++++++++++++-----------
Minternal/app/totp.go | 2+-
12 files changed, 180 insertions(+), 23 deletions(-)

diff --git a/cmd/lb/main.go b/cmd/lb/main.go @@ -82,8 +82,14 @@ func run() error { return app.Health(p) case commands.ReKey: return app.ReKey(p) - case commands.List, commands.Groups: - return app.List(p, command == commands.Groups) + case commands.List, commands.Groups, commands.Fields: + mode := app.ListEntriesMode + if command == commands.Groups { + mode = app.ListGroupsMode + } else if command == commands.Fields { + mode = app.ListFieldsMode + } + return app.List(p, mode) case commands.Unset: return app.Unset(p) case commands.Move: diff --git a/cmd/lb/main_test.go b/cmd/lb/main_test.go @@ -217,6 +217,7 @@ func test(profile string) error { r.section("listing/outputs") r.run("", "ls") r.run("", "groups") + r.run("", "fields") r.run("echo y |", "rm test2/key1") r.logAppend("echo") r.run("", "ls") @@ -254,6 +255,7 @@ func test(profile string) error { r.run("", "ls") r.run("", "groups") r.run("", "groups 'test9/**/*'") + r.run("", "fields 'test9/**/*'") r.run("echo y |", "unset test8/unset/password") r.logAppend("echo") r.run("", "ls") @@ -267,6 +269,7 @@ func test(profile string) error { r.run("", "mv test9/key2/sub1 test9/sub3") r.run("", "ls") r.run("", "groups") + r.run("", "fields") r.run("echo y |", "rm test9/*") r.logAppend("echo") diff --git a/cmd/lb/tests/expected.log b/cmd/lb/tests/expected.log @@ -43,6 +43,50 @@ test8/unset test9/key1/sub1 test9/key1/sub2 test9/key2/sub1 +test1/key1/notes +test1/key1/otp +test1/key1/password +test1/key1/url +test2/key1/notes +test2/key1/otp +test2/key1/password +test2/key1/url +test4/multiline/notes +test4/multiline/otp +test4/multiline/password +test4/multiline/url +test5/multiline/notes +test5/multiline/otp +test5/multiline/password +test5/multiline/url +test6/multiline/notes +test6/multiline/otp +test6/multiline/password +test6/multiline/url +test7/deeper/root/notes +test7/deeper/root/otp +test7/deeper/root/password +test7/deeper/root/url +test7/deeper/rooted/notes +test7/deeper/rooted/otp +test7/deeper/rooted/password +test7/deeper/rooted/url +test8/unset/notes +test8/unset/otp +test8/unset/password +test8/unset/url +test9/key1/sub1/notes +test9/key1/sub1/otp +test9/key1/sub1/password +test9/key1/sub1/url +test9/key1/sub2/notes +test9/key1/sub2/otp +test9/key1/sub2/password +test9/key1/sub2/url +test9/key2/sub1/notes +test9/key2/sub1/otp +test9/key2/sub1/password +test9/key2/sub1/url delete entry? (y/N) test1/key1/password test4/multiline/notes @@ -224,6 +268,18 @@ test9/key2/sub1 test9/key1/sub1 test9/key1/sub2 test9/key2/sub1 +test9/key1/sub1/notes +test9/key1/sub1/otp +test9/key1/sub1/password +test9/key1/sub1/url +test9/key1/sub2/notes +test9/key1/sub2/otp +test9/key1/sub2/password +test9/key1/sub2/url +test9/key2/sub1/notes +test9/key2/sub1/otp +test9/key2/sub1/password +test9/key2/sub1/url unset: test8/unset/password? (y/N) clearing value from: test8/unset/password test4/multiline/notes @@ -273,6 +329,30 @@ test6/multiline test9/sub1 test9/sub2 test9/sub3 +test4/multiline/notes +test4/multiline/otp +test4/multiline/password +test4/multiline/url +test5/multiline/notes +test5/multiline/otp +test5/multiline/password +test5/multiline/url +test6/multiline/notes +test6/multiline/otp +test6/multiline/password +test6/multiline/url +test9/sub1/notes +test9/sub1/otp +test9/sub1/password +test9/sub1/url +test9/sub2/notes +test9/sub2/otp +test9/sub2/password +test9/sub2/url +test9/sub3/notes +test9/sub3/otp +test9/sub3/password +test9/sub3/url selected entities: test9/sub1 test9/sub2 diff --git a/internal/app/commands/core.go b/internal/app/commands/core.go @@ -66,6 +66,7 @@ const ( TOTPSeed = "seed" // Health will show health information (for debugging/troubleshooting) Health = "health" + Fields = "fields" ) var ( diff --git a/internal/app/completions/core.go b/internal/app/completions/core.go @@ -28,6 +28,7 @@ type ( TOTPCommand string DoTOTPList string DoList string + DoFields string DoGroups string Executable string JSONCommand string @@ -66,6 +67,7 @@ func Generate(completionType, exe string) ([]string, error) { DoGroups: fmt.Sprintf("%s %s", exe, commands.Groups), DoTOTPList: fmt.Sprintf("%s %s %s", exe, commands.TOTP, commands.TOTPList), ExportCommand: fmt.Sprintf("%s %s %s", exe, commands.Env, commands.Completions), + DoFields: fmt.Sprintf("%s %s", exe, commands.Fields), } c.Options = commands.AllowedInReadOnly(commands.Help, commands.List, commands.Show, commands.Version, commands.JSON, commands.Groups, commands.Move, commands.Remove, commands.Insert, commands.Unset) diff --git a/internal/app/completions/shell/bash.sh b/internal/app/completions/shell/bash.sh @@ -31,7 +31,10 @@ _{{ $.Executable }}() { "{{ $.MoveCommand }}" | "{{ $.RemoveCommand }}") opts="$opts $({{ $.DoGroups }})" ;; - "{{ $.UnsetCommand }}" | "{{ $.InsertCommand }}") + "{{ $.InsertCommand }}") + opts="$opts $({{ $.DoFields }})" + ;; + "{{ $.UnsetCommand }}") opts="$opts $({{ $.DoList }})" ;; "{{ $.TOTPCommand }}") diff --git a/internal/app/completions/shell/zsh.sh b/internal/app/completions/shell/zsh.sh @@ -45,7 +45,12 @@ _{{ $.Executable }}() { compadd "$@" $({{ $.DoGroups }}) fi ;; - "{{ $.UnsetCommand }}" | "{{ $.InsertCommand }}") + "{{ $.InsertCommand }}") + if [ "$len" -eq 3 ]; then + compadd "$@" $({{ $.DoFields }}) + fi + ;; + "{{ $.UnsetCommand }}") if [ "$len" -eq 3 ]; then compadd "$@" $({{ $.DoList }}) fi diff --git a/internal/app/help/core.go b/internal/app/help/core.go @@ -96,6 +96,7 @@ func Usage(verbose bool, exe string) ([]string, error) { results = append(results, command(commands.JSON, isFilter, "display detailed information")) results = append(results, command(commands.List, isFilter, "list entries")) results = append(results, command(commands.Groups, isFilter, "list groups")) + results = append(results, command(commands.Fields, isFilter, "list groups with all allowed field names")) results = append(results, command(commands.Show, isEntry, "show the entry's value")) results = append(results, command(commands.TOTP, "<command>", "display an updating totp generated code")) results = append(results, subCommand(commands.TOTP, commands.TOTPClip, isEntry, "copy totp code to clipboard")) diff --git a/internal/app/help/core_test.go b/internal/app/help/core_test.go @@ -9,11 +9,11 @@ import ( func TestUsage(t *testing.T) { u, _ := help.Usage(false, "lb") - if len(u) != 28 { + if len(u) != 29 { t.Errorf("invalid usage, out of date? %d", len(u)) } u, _ = help.Usage(true, "lb") - if len(u) != 129 { + if len(u) != 130 { t.Errorf("invalid verbose usage, out of date? %d", len(u)) } for _, usage := range u { diff --git a/internal/app/list.go b/internal/app/list.go @@ -10,8 +10,16 @@ import ( "github.com/enckse/lockbox/internal/kdbx" ) +type ListMode int + +const ( + ListEntriesMode ListMode = iota + ListGroupsMode + ListFieldsMode +) + // List will list/find entries -func List(cmd CommandOptions, groups bool) error { +func List(cmd CommandOptions, mode ListMode) error { args := cmd.Args() filter := "" switch len(args) { @@ -23,10 +31,10 @@ func List(cmd CommandOptions, groups bool) error { return errors.New("too many arguments (none or filter)") } - return doList("", filter, cmd, groups) + return doList("", filter, cmd, mode) } -func doList(attr, filter string, cmd CommandOptions, groups bool) error { +func doList(attr, filter string, cmd CommandOptions, mode ListMode) error { hasFilter, selector := createFilter(filter) opts := kdbx.QueryOptions{} opts.Mode = kdbx.ListMode @@ -42,17 +50,35 @@ func doList(attr, filter string, cmd CommandOptions, groups bool) error { } w := cmd.Writer() attrFilter := attr != "" + isFields := mode == ListFieldsMode + allowedFields := []string{} + if isFields { + for _, allowed := range kdbx.AllowedFields { + allowedFields = append(allowedFields, strings.ToLower(allowed)) + } + sort.Strings(allowedFields) + } + isGroups := mode == ListGroupsMode || isFields for f, err := range e { if err != nil { return err } - if groups { + if isGroups { ok, err := allowed(f.Path) if err != nil { return err } if ok { - fmt.Fprintf(w, "%s\n", f.Path) + output := []string{f.Path} + if isFields { + output = []string{} + for _, allowed := range allowedFields { + output = append(output, kdbx.NewPath(f.Path, allowed)) + } + } + for _, out := range output { + fmt.Fprintf(w, "%s\n", out) + } } continue } diff --git a/internal/app/list_test.go b/internal/app/list_test.go @@ -42,14 +42,14 @@ func setup(t *testing.T) *kdbx.Transaction { func TestList(t *testing.T) { m := newMockCommand(t) - if err := app.List(m, false); err != nil { + if err := app.List(m, app.ListEntriesMode); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() == "" { t.Error("nothing listed") } m.args = []string{"test", "test2"} - if err := app.List(m, false); err == nil || err.Error() != "too many arguments (none or filter)" { + if err := app.List(m, app.ListEntriesMode); err == nil || err.Error() != "too many arguments (none or filter)" { t.Errorf("invalid error: %v", err) } } @@ -57,7 +57,7 @@ func TestList(t *testing.T) { func TestFind(t *testing.T) { m := newMockCommand(t) m.args = []string{"a/["} - if err := app.List(m, false); err == nil || !strings.Contains(err.Error(), "syntax error in pattern") { + if err := app.List(m, app.ListEntriesMode); err == nil || !strings.Contains(err.Error(), "syntax error in pattern") { t.Errorf("invalid error: %v", err) } if m.buf.String() != "" { @@ -65,7 +65,7 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"[zzzzzz]"} - if err := app.List(m, false); err != nil { + if err := app.List(m, app.ListEntriesMode); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() != "" { @@ -73,7 +73,7 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"test"} - if err := app.List(m, false); err != nil { + if err := app.List(m, app.ListEntriesMode); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() == "" { @@ -81,7 +81,7 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"test"} - if err := app.List(m, true); err != nil { + if err := app.List(m, app.ListGroupsMode); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() == "" { @@ -89,7 +89,7 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"test/**/**/*"} - if err := app.List(m, false); err != nil { + if err := app.List(m, app.ListEntriesMode); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() == "" { @@ -97,7 +97,7 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"test/**/*"} - if err := app.List(m, true); err != nil { + if err := app.List(m, app.ListGroupsMode); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() == "" { @@ -105,7 +105,23 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"[zzzz]"} - if err := app.List(m, true); err != nil { + if err := app.List(m, app.ListGroupsMode); err != nil { + t.Errorf("invalid error: %v", err) + } + if m.buf.String() != "" { + t.Error("something listed") + } + m.buf.Reset() + m.args = []string{"test/**/*"} + if err := app.List(m, app.ListFieldsMode); err != nil { + t.Errorf("invalid error: %v", err) + } + if m.buf.String() == "" { + t.Error("nothing listed") + } + m.buf.Reset() + m.args = []string{"[zzzz]"} + if err := app.List(m, app.ListFieldsMode); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() != "" { @@ -115,14 +131,28 @@ func TestFind(t *testing.T) { func TestGroups(t *testing.T) { m := newMockCommand(t) - if err := app.List(m, true); err != nil { + if err := app.List(m, app.ListGroupsMode); err != nil { + t.Errorf("invalid error: %v", err) + } + if m.buf.String() == "" { + t.Errorf("nothing listed: %s", m.buf.String()) + } + m.args = []string{"test", "test2"} + if err := app.List(m, app.ListGroupsMode); err == nil || err.Error() != "too many arguments (none or filter)" { + t.Errorf("invalid error: %v", err) + } +} + +func TestFields(t *testing.T) { + m := newMockCommand(t) + if err := app.List(m, app.ListFieldsMode); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() == "" { t.Errorf("nothing listed: %s", m.buf.String()) } m.args = []string{"test", "test2"} - if err := app.List(m, true); err == nil || err.Error() != "too many arguments (none or filter)" { + if err := app.List(m, app.ListFieldsMode); err == nil || err.Error() != "too many arguments (none or filter)" { t.Errorf("invalid error: %v", err) } } diff --git a/internal/app/totp.go b/internal/app/totp.go @@ -178,7 +178,7 @@ func (args *TOTPArguments) Do(opts TOTPOptions) error { return errors.New("invalid option functions") } if args.Mode == commands.TOTPList { - return doList(kdbx.OTPField, args.Entry, opts.app, false) + return doList(kdbx.OTPField, args.Entry, opts.app, ListEntriesMode) } return args.display(opts) }