lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 8ac00388a1d93e9537a81d6652d1286ffef2bef1
parent 49c7044f7bd6718b84e980c2fe1a30a260fa4659
Author: Sean Enck <sean@ttypty.com>
Date:   Tue, 10 Jun 2025 09:53:17 -0400

all filters should use filepath.Match

Diffstat:
Mcmd/lb/main_test.go | 18+++++++++---------
Mcmd/lb/tests/expected.log | 6++----
Minternal/app/conv.go | 17++++++-----------
Minternal/app/json_test.go | 2+-
Minternal/app/list.go | 36+++++++++++++++++++++---------------
Minternal/app/list_test.go | 6+++---
Minternal/app/move_test.go | 2+-
Minternal/app/totp_test.go | 2+-
Minternal/kdbx/core.go | 2+-
Minternal/kdbx/query.go | 54++++++++++++++++++++++++++++--------------------------
Minternal/kdbx/query_test.go | 18+++++++++++++++---
11 files changed, 88 insertions(+), 75 deletions(-)

diff --git a/cmd/lb/main_test.go b/cmd/lb/main_test.go @@ -212,15 +212,15 @@ func test(profile string) error { r.run("echo y |", "rm test2/key1") r.logAppend("echo") r.run("", "ls") - r.run("", "ls multiline") + r.run("", "ls */multiline/*") r.run("", "ls url") r.run("", "json") - r.run("", "json 'multiline'") + r.run("", "json '*/multiline'") r.logAppend("echo") r.run("echo 5ae472abqdekjqykoyxk7hvc2leklq5n |", "insert test6/multiline/otp") r.run(`printf "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1&digits=6&issuer=lbissuer&period=30&secret=5ae472abqdekjqykoyxk7hvc2leklq5n" |`, "insert test10/key1/otp") r.run("", "totp ls") - r.run("", "totp ls rooted") + r.run("", "totp ls '**/**/rooted/*") const grepTOTP = "| sed 's/^[[:space:]]*//g' | grep '^[0-9][0-9][0-9][0-9][0-9][0-9]$'" r.run("", fmt.Sprintf("totp show test6/multiline/otp %s", grepTOTP)) r.run("", fmt.Sprintf("totp show test10/key1/otp %s", grepTOTP)) @@ -233,8 +233,8 @@ func test(profile string) error { r.run("", fmt.Sprintf("conv \"%s\"", r.store)) r.run("echo y |", "rm test10/*") r.logAppend("echo") - r.run("echo y |", "rm test7/deeper") - r.run("echo y |", "rm test7/deeper/ro") + r.run("echo y |", "rm test7/deeper/**/*") + r.run("echo y |", "rm test7/deeper/ro**/*") r.run("echo y |", "rm test1/key1/password") r.run("echo y |", "rm test1/key1") r.logAppend("echo") @@ -242,7 +242,7 @@ func test(profile string) error { r.logAppend("echo") r.run("", "ls") r.run("", "groups") - r.run("", "groups test9") + r.run("", "groups 'test9/**/*'") r.run("echo y |", "unset test8/unset/password") r.logAppend("echo") r.run("", "ls") @@ -283,14 +283,14 @@ func test(profile string) error { // test json modes c["json.mode"] = c.quoteString("plaintext") r.writeConfig(c) - r.run("", "json test6") + r.run("", "json test6/*") c["json.mode"] = c.quoteString("empty") r.writeConfig(c) - r.run("", "json test6") + r.run("", "json test6/*") c["json.mode"] = c.quoteString("hash") c["json.hash_length"] = "3" r.writeConfig(c) - r.run("", "json test6") + r.run("", "json test6/*") // clipboard copyFile := filepath.Join(r.testDir, "clip.copy") diff --git a/cmd/lb/tests/expected.log b/cmd/lb/tests/expected.log @@ -58,7 +58,6 @@ test4/multiline/notes test5/multiline/notes test6/multiline/notes test6/multiline/password -test7/deeper/root/url { "test1/key1": { "modtime": "XXXX-XX-XX", @@ -123,7 +122,6 @@ test7/deeper/root/url test10/key1/otp test6/multiline/otp test7/deeper/rooted/otp -test7/deeper/rooted/otp XXXXXX XXXXXX XXXXXX @@ -189,8 +187,8 @@ period: 30 "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" } delete entry? (y/N) -no entities matching: test7/deeper -no entities matching: test7/deeper/ro +no entities matching: test7/deeper/**/* +no entities matching: test7/deeper/ro**/* no entities matching: test1/key1/password delete entry? (y/N) selected entities: diff --git a/internal/app/conv.go b/internal/app/conv.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "regexp" "strings" "git.sr.ht/~enckse/lockbox/internal/kdbx" @@ -32,15 +31,7 @@ func Conv(cmd CommandOptions) error { } func serialize(w io.Writer, tx *kdbx.Transaction, isJSON bool, filter string) error { - var re *regexp.Regexp - hasFilter := filter != "" - if hasFilter { - var err error - re, err = regexp.Compile(filter) - if err != nil { - return err - } - } + hasFilter, selector := createFilter(filter) e, err := tx.QueryCallback(kdbx.QueryOptions{Mode: kdbx.ListMode, Values: kdbx.JSONValue}) if err != nil { return err @@ -54,7 +45,11 @@ func serialize(w io.Writer, tx *kdbx.Transaction, isJSON bool, filter string) er return err } if hasFilter { - if !re.MatchString(item.Path) { + ok, err := selector(filter, item.Path) + if err != nil { + return err + } + if !ok { continue } } diff --git a/internal/app/json_test.go b/internal/app/json_test.go @@ -24,7 +24,7 @@ func TestJSON(t *testing.T) { t.Error("no data") } m.buf = bytes.Buffer{} - m.args = []string{"test2/test1"} + m.args = []string{"test/test2/*"} if err := app.JSON(m); err != nil { t.Errorf("invalid error: %v", err) } diff --git a/internal/app/list.go b/internal/app/list.go @@ -4,7 +4,6 @@ package app import ( "errors" "fmt" - "regexp" "sort" "strings" @@ -28,26 +27,18 @@ func List(cmd CommandOptions, groups bool) error { } func doList(attr, filter string, cmd CommandOptions, groups bool) error { - var re *regexp.Regexp - hasFilter := filter != "" - if hasFilter { - var err error - re, err = regexp.Compile(filter) - if err != nil { - return err - } - } + hasFilter, selector := createFilter(filter) opts := kdbx.QueryOptions{} opts.Mode = kdbx.ListMode e, err := cmd.Transaction().QueryCallback(opts) if err != nil { return err } - allowed := func(p string) bool { + allowed := func(p string) (bool, error) { if hasFilter { - return re.MatchString(p) + return selector(filter, p) } - return true + return true, nil } w := cmd.Writer() attrFilter := attr != "" @@ -56,7 +47,11 @@ func doList(attr, filter string, cmd CommandOptions, groups bool) error { return err } if groups { - if allowed(f.Path) { + ok, err := allowed(f.Path) + if err != nil { + return err + } + if ok { fmt.Fprintf(w, "%s\n", f.Path) } continue @@ -72,7 +67,11 @@ func doList(attr, filter string, cmd CommandOptions, groups bool) error { } } path := kdbx.NewPath(f.Path, k) - if allowed(path) { + ok, err := allowed(path) + if err != nil { + return err + } + if ok { results = append(results, path) } } @@ -84,3 +83,10 @@ func doList(attr, filter string, cmd CommandOptions, groups bool) error { } return nil } + +func createFilter(filter string) (bool, func(string, string) (bool, error)) { + if filter == "" { + return false, nil + } + return true, kdbx.Glob +} diff --git a/internal/app/list_test.go b/internal/app/list_test.go @@ -57,7 +57,7 @@ func TestList(t *testing.T) { func TestFind(t *testing.T) { m := newMockCommand(t) m.args = []string{"["} - if err := app.List(m, false); err == nil || !strings.Contains(err.Error(), "missing closing") { + if err := app.List(m, false); err == nil || !strings.Contains(err.Error(), "syntax error in pattern") { t.Errorf("invalid error: %v", err) } if m.buf.String() != "" { @@ -72,7 +72,7 @@ func TestFind(t *testing.T) { t.Error("something listed") } m.buf.Reset() - m.args = []string{"test"} + m.args = []string{"test/test2/**/*"} if err := app.List(m, false); err != nil { t.Errorf("invalid error: %v", err) } @@ -80,7 +80,7 @@ func TestFind(t *testing.T) { t.Error("nothing listed") } m.buf.Reset() - m.args = []string{"test"} + m.args = []string{"test/**/*"} if err := app.List(m, true); err != nil { t.Errorf("invalid error: %v", err) } diff --git a/internal/app/move_test.go b/internal/app/move_test.go @@ -68,7 +68,7 @@ func TestMove(t *testing.T) { if err := app.Move(m); err.Error() != "test/test2/test3 must be a path, not an entry" { t.Errorf("invalid error: %v", err) } - m.args = []string{"test/*", "test/test2/"} + m.args = []string{"test/**/*", "test/test2/"} if err := app.Move(m); err.Error() != "multiple moves can only be done at a leaf level" { t.Errorf("invalid error: %v", err) } diff --git a/internal/app/totp_test.go b/internal/app/totp_test.go @@ -257,7 +257,7 @@ func TestParseWindows(t *testing.T) { func TestTOTPListFilter(t *testing.T) { setupTOTP(t) - args, _ := app.NewTOTPArguments([]string{"ls", "test"}) + args, _ := app.NewTOTPArguments([]string{"ls", "test/**/**/*"}) m, opts := newMock(t) if err := args.Do(opts); err != nil { t.Errorf("invalid error: %v", err) diff --git a/internal/kdbx/core.go b/internal/kdbx/core.go @@ -23,7 +23,7 @@ var ( const ( titleKey = "Title" pathSep = "/" - isGlob = pathSep + "*" + isGlob = "*" modTimeKey = "ModTime" // OTPField is the totp storage attribute OTPField = "otp" diff --git a/internal/kdbx/query.go b/internal/kdbx/query.go @@ -5,6 +5,7 @@ import ( "crypto/sha512" "errors" "fmt" + "path/filepath" "slices" "strings" @@ -43,27 +44,13 @@ const ( FindMode // ExactMode means an entity must MATCH the string exactly ExactMode - // PrefixMode allows for entities starting with a specific value - PrefixMode + // GlobMode indicates use a glob/match to find results + GlobMode ) // MatchPath will try to match 1 or more elements (more elements when globbing) func (t *Transaction) MatchPath(path string) ([]Entity, error) { - if !strings.HasSuffix(path, isGlob) { - e, err := t.Get(path, BlankValue) - if err != nil { - return nil, err - } - if e == nil { - return nil, nil - } - return []Entity{*e}, nil - } - prefix := strings.TrimSuffix(path, isGlob) - if strings.HasSuffix(prefix, pathSep) { - return nil, errors.New("invalid match criteria, too many path separators") - } - return t.queryCollect(QueryOptions{Mode: PrefixMode, Criteria: prefix + pathSep, Values: BlankValue}) + return t.queryCollect(QueryOptions{Mode: GlobMode, Criteria: path, Values: BlankValue}) } // Get will request a singular entity @@ -86,7 +73,7 @@ func (t *Transaction) Get(path string, mode ValueMode) (*Entity, error) { } } -func forEach(offset string, groups []gokeepasslib.Group, entries []gokeepasslib.Entry, cb func(string, gokeepasslib.Entry)) { +func forEach(offset string, groups []gokeepasslib.Group, entries []gokeepasslib.Entry, cb func(string, gokeepasslib.Entry) error) error { for _, g := range groups { o := "" if offset == "" { @@ -94,11 +81,16 @@ func forEach(offset string, groups []gokeepasslib.Group, entries []gokeepasslib. } else { o = NewPath(offset, g.Name) } - forEach(o, g.Groups, g.Entries, cb) + if err := forEach(o, g.Groups, g.Entries, cb); err != nil { + return err + } } for _, e := range entries { - cb(offset, e) + if err := cb(offset, e); err != nil { + return err + } } + return nil } func (t *Transaction) queryCollect(args QueryOptions) ([]Entity, error) { @@ -109,6 +101,11 @@ func (t *Transaction) queryCollect(args QueryOptions) ([]Entity, error) { return e.Collect() } +// Glob is the baseline query for globbing for results +func Glob(criteria, path string) (bool, error) { + return filepath.Match(criteria, path) +} + // QueryCallback will retrieve a query based on setting func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { if args.Mode == noneMode { @@ -122,7 +119,7 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { isSort := args.Mode != ExactMode decrypt := args.Values != BlankValue err := t.act(func(ctx Context) error { - forEach("", ctx.db.Content.Root.Groups[0].Groups, ctx.db.Content.Root.Groups[0].Entries, func(offset string, entry gokeepasslib.Entry) { + forEach("", ctx.db.Content.Root.Groups[0].Groups, ctx.db.Content.Root.Groups[0].Entries, func(offset string, entry gokeepasslib.Entry) error { path := getPathName(entry) if offset != "" { path = NewPath(offset, path) @@ -131,17 +128,21 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { switch args.Mode { case FindMode: if !strings.Contains(path, args.Criteria) { - return + return nil + } + case GlobMode: + ok, err := Glob(args.Criteria, path) + if err != nil { + return err } - case PrefixMode: - if !strings.HasPrefix(path, args.Criteria) { - return + if !ok { + return nil } } } else { if args.Mode == ExactMode { if path != args.Criteria { - return + return nil } } } @@ -154,6 +155,7 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { } else { entities = append(entities, obj) } + return nil }) if decrypt { return ctx.db.UnlockProtectedEntries() diff --git a/internal/kdbx/query_test.go b/internal/kdbx/query_test.go @@ -89,9 +89,6 @@ func TestMatchPath(t *testing.T) { if len(q) != 4 { t.Error("invalid entity result") } - if _, err := fullSetup(t, true).MatchPath("test/test//*"); err.Error() != "invalid match criteria, too many path separators" { - t.Errorf("wrong error: %v", err) - } q, err = fullSetup(t, true).MatchPath("test/test*") if err != nil { t.Errorf("no error: %v", err) @@ -101,6 +98,21 @@ func TestMatchPath(t *testing.T) { } } +func TestGlob(t *testing.T) { + _, err := kdbx.Glob("[", "x") + if err == nil { + t.Errorf("invalid error: %v", err) + } + ok, err := kdbx.Glob("a", "b") + if ok || err != nil { + t.Errorf("invalid result/error: %v", err) + } + ok, err = kdbx.Glob("a/*", "a/b") + if !ok || err != nil { + t.Errorf("invalid result/error: %v", err) + } +} + func TestGet(t *testing.T) { setupInserts(t) q, err := fullSetup(t, true).Get("test/test/abc", kdbx.BlankValue)