commit c8e223d3b535b11622a4580a814de885c2e04909
parent 356828e0b6ebc18edad4e933c71b61d577b3f83c
Author: Sean Enck <sean@ttypty.com>
Date: Sun, 2 Oct 2022 09:43:43 -0400
support tree structure
Diffstat:
7 files changed, 227 insertions(+), 108 deletions(-)
diff --git a/cmd/main.go b/cmd/main.go
@@ -132,7 +132,7 @@ func run() *programError {
return newError("invalid input", err)
}
p := strings.TrimSpace(string(password))
- if err := t.Insert(entry, p, existing, len(strings.Split(p, "\n")) > 1); err != nil {
+ if err := t.Insert(entry, p, len(strings.Split(p, "\n")) > 1); err != nil {
return newError("failed to insert", err)
}
fmt.Println("")
diff --git a/internal/backend/actions.go b/internal/backend/actions.go
@@ -37,7 +37,7 @@ func (t *Transaction) act(cb action) error {
return err
}
if len(db.Content.Root.Groups) != 1 {
- return errors.New("kdbx must only have ONE root group")
+ return errors.New("kdbx must have ONE root group")
}
err = cb(Context{db: db})
if err != nil {
@@ -70,70 +70,139 @@ func (t *Transaction) change(cb action) error {
})
}
+func (c Context) insertEntity(offset []string, title, name string, entity gokeepasslib.Entry) bool {
+ return c.alterEntities(true, offset, title, name, &entity)
+}
+
+func (c Context) alterEntities(isAdd bool, offset []string, title, name string, entity *gokeepasslib.Entry) bool {
+ g, e, ok := findAndDo(isAdd, NewPath(title, name), offset, entity, c.db.Content.Root.Groups[0].Groups, c.db.Content.Root.Groups[0].Entries)
+ c.db.Content.Root.Groups[0].Groups = g
+ c.db.Content.Root.Groups[0].Entries = e
+ return ok
+}
+
+func (c Context) removeEntity(offset []string, title, name string) bool {
+ return c.alterEntities(false, offset, title, name, nil)
+}
+
+func findAndDo(isAdd bool, entityName string, offset []string, opEntity *gokeepasslib.Entry, g []gokeepasslib.Group, e []gokeepasslib.Entry) ([]gokeepasslib.Group, []gokeepasslib.Entry, bool) {
+ done := false
+ if len(offset) == 0 {
+ if isAdd {
+ e = append(e, *opEntity)
+
+ } else {
+ var entries []gokeepasslib.Entry
+ for _, entry := range e {
+ if getPathName(entry) == entityName {
+ continue
+ }
+ entries = append(entries, entry)
+ }
+ e = entries
+ }
+ done = true
+ } else {
+ name := offset[0]
+ remaining := []string{}
+ if len(offset) > 1 {
+ remaining = offset[1:]
+ }
+ if isAdd {
+ match := false
+ for _, group := range g {
+ if group.Name == name {
+ match = true
+ }
+ }
+ if !match {
+ newGroup := gokeepasslib.NewGroup()
+ newGroup.Name = name
+ g = append(g, newGroup)
+ }
+ }
+ var updateGroups []gokeepasslib.Group
+ for _, group := range g {
+ if !done && group.Name == name {
+
+ groups, entries, ok := findAndDo(isAdd, entityName, remaining, opEntity, group.Groups, group.Entries)
+ group.Entries = entries
+ group.Groups = groups
+ if ok {
+ done = true
+ }
+ }
+ updateGroups = append(updateGroups, group)
+ }
+ g = updateGroups
+ if !isAdd {
+ var groups []gokeepasslib.Group
+ for _, group := range g {
+ if group.Name == name {
+ if len(group.Entries) == 0 {
+ continue
+ }
+ }
+ groups = append(groups, group)
+ }
+ g = groups
+ }
+ }
+ return g, e, done
+}
+
+func splitComponents(path string) ([]string, string, string, error) {
+ name := filepath.Base(path)
+ dir := filepath.Dir(path)
+ parts := strings.Split(dir, string(os.PathSeparator))
+ if len(parts) < 2 {
+ return nil, "", "", errors.New("invalid component path")
+ }
+ return parts[:len(parts)-1], parts[len(parts)-1], name, nil
+}
+
// Insert handles inserting a new element
-func (t *Transaction) Insert(path, val string, entity *QueryEntity, multi bool) error {
+func (t *Transaction) Insert(path, val string, multi bool) error {
if strings.TrimSpace(path) == "" {
return errors.New("empty path not allowed")
}
if strings.TrimSpace(val) == "" {
return errors.New("empty secret not allowed")
}
+ offset, title, name, err := splitComponents(path)
+ if err != nil {
+ return err
+ }
return t.change(func(c Context) error {
- if entity != nil {
- if _, err := remove(entity, c, false); err != nil {
- return err
- }
- } else {
- idx, _ := remove(&QueryEntity{Path: path}, c, true)
- if idx >= 0 {
- return errors.New("trying to insert over existing entity")
- }
- }
+ c.removeEntity(offset, title, name)
e := gokeepasslib.NewEntry()
- e.Values = append(e.Values, value(titleKey, filepath.Dir(path)))
- e.Values = append(e.Values, value(userNameKey, filepath.Base(path)))
+ e.Values = append(e.Values, value(titleKey, title))
+ e.Values = append(e.Values, value(userNameKey, name))
field := passKey
if multi {
field = notesKey
}
e.Values = append(e.Values, protectedValue(field, val))
- c.db.Content.Root.Groups[0].Entries = append(c.db.Content.Root.Groups[0].Entries, e)
+ c.insertEntity(offset, title, name, e)
return nil
})
}
-func remove(entity *QueryEntity, c Context, dryRun bool) (int, error) {
- entries := c.db.Content.Root.Groups[0].Entries
- idx := -1
- for i, e := range entries {
- if entity.Path == getPathName(e) {
- idx = i
- }
- }
- if idx < 0 {
- return idx, errors.New("unable to select entity for deletion")
- }
- if dryRun {
- return idx, nil
- }
- switch len(entries) {
- case 1:
- c.db.Content.Root.Groups[0].Entries = []gokeepasslib.Entry{}
- default:
- c.db.Content.Root.Groups[0].Entries = append(entries[:idx], entries[idx+1:]...)
- }
- return idx, nil
-}
-
// Remove handles remove an element
func (t *Transaction) Remove(entity *QueryEntity) error {
if entity == nil {
return errors.New("entity is empty/invalid")
}
- return t.change(func(c Context) error {
- _, err := remove(entity, c, false)
+ offset, title, name, err := splitComponents(entity.Path)
+ if err != nil {
return err
+ }
+ return t.change(func(c Context) error {
+ if ok := c.removeEntity(offset, title, name); !ok {
+ return errors.New("failed to remove entity")
+ }
+ return nil
})
}
diff --git a/internal/backend/actions_test.go b/internal/backend/actions_test.go
@@ -1,7 +1,9 @@
package backend_test
import (
+ "fmt"
"os"
+ "path/filepath"
"testing"
"github.com/enckse/lockbox/internal/backend"
@@ -27,38 +29,41 @@ func setup(t *testing.T) *backend.Transaction {
func TestBadAction(t *testing.T) {
tr := &backend.Transaction{}
- if err := tr.Insert("a", "a", nil, false); err.Error() != "invalid transaction" {
+ if err := tr.Insert("a/a/a", "a", false); err.Error() != "invalid transaction" {
t.Errorf("wrong error: %v", err)
}
}
func TestInserts(t *testing.T) {
- if err := setup(t).Insert("", "", nil, false); err.Error() != "empty path not allowed" {
+ if err := setup(t).Insert("", "", false); err.Error() != "empty path not allowed" {
t.Errorf("wrong error: %v", err)
}
- if err := setup(t).Insert("a", "", nil, false); err.Error() != "empty secret not allowed" {
+ if err := setup(t).Insert(filepath.Join("test", "offset"), "test", false); err.Error() != "invalid component path" {
t.Errorf("wrong error: %v", err)
}
- if err := setup(t).Insert("value", "pass", nil, false); err != nil {
- t.Errorf("no error: %v", err)
+ if err := setup(t).Insert("test", "test", false); err.Error() != "invalid component path" {
+ t.Errorf("wrong error: %v", err)
}
- if err := fullSetup(t, true).Insert("value", "pass", nil, false); err.Error() != "trying to insert over existing entity" {
+ if err := setup(t).Insert("a", "", false); err.Error() != "empty secret not allowed" {
t.Errorf("wrong error: %v", err)
}
- if err := fullSetup(t, true).Insert("value", "pass2", &backend.QueryEntity{Path: "value"}, false); err != nil {
+ if err := setup(t).Insert(filepath.Join("test", "offset", "value"), "pass", false); err != nil {
t.Errorf("no error: %v", err)
}
- if err := fullSetup(t, true).Insert("value2", "pass", nil, true); err != nil {
+ if err := fullSetup(t, true).Insert(filepath.Join("test", "offset", "value"), "pass2", false); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := fullSetup(t, true).Insert(filepath.Join("test", "offset", "value2"), "pass", true); err != nil {
t.Errorf("no error: %v", err)
}
- q, err := fullSetup(t, true).Get("value", backend.SecretValue)
+ q, err := fullSetup(t, true).Get(filepath.Join("test", "offset", "value"), backend.SecretValue)
if err != nil {
t.Errorf("no error: %v", err)
}
if q.Value != "pass2" {
t.Errorf("invalid retrieval")
}
- q, err = fullSetup(t, true).Get("value2", backend.SecretValue)
+ q, err = fullSetup(t, true).Get(filepath.Join("test", "offset", "value2"), backend.SecretValue)
if err != nil {
t.Errorf("no error: %v", err)
}
@@ -71,43 +76,68 @@ func TestRemoves(t *testing.T) {
if err := setup(t).Remove(nil); err.Error() != "entity is empty/invalid" {
t.Errorf("wrong error: %v", err)
}
- if err := setup(t).Remove(&backend.QueryEntity{}); err.Error() != "unable to select entity for deletion" {
+ if err := setup(t).Remove(&backend.QueryEntity{}); err.Error() != "invalid component path" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Remove(&backend.QueryEntity{Path: filepath.Join("test1", "test2", "test3")}); err.Error() != "failed to remove entity" {
t.Errorf("wrong error: %v", err)
}
setup(t)
- for _, i := range []string{"test1", "test2", "test3", "test4", "test5"} {
- fullSetup(t, true).Insert(i, "pass", nil, false)
+ for _, i := range []string{"test1", "test2"} {
+ fullSetup(t, true).Insert(filepath.Join(i, i, i), "pass", false)
}
- if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test3"}); err != nil {
+ if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: filepath.Join("test1", "test1", "test1")}); err != nil {
t.Errorf("wrong error: %v", err)
}
- check(t, []string{"test1", "test2", "test4", "test5"})
- if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test1"}); err != nil {
+ if err := check(t, filepath.Join("test2", "test2", "test2")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: filepath.Join("test2", "test2", "test2")}); err != nil {
t.Errorf("wrong error: %v", err)
}
- check(t, []string{"test2", "test4", "test5"})
- if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test5"}); err != nil {
+ setup(t)
+ for _, i := range []string{filepath.Join("test", "test", "test1"), filepath.Join("test", "test", "test2"), filepath.Join("test", "test", "test3"), filepath.Join("test", "test1", "test2"), filepath.Join("test", "test1", "test5")} {
+ fullSetup(t, true).Insert(i, "pass", false)
+ }
+ if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test/test3"}); err != nil {
t.Errorf("wrong error: %v", err)
}
- check(t, []string{"test2", "test4"})
- if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test4"}); err != nil {
+ if err := check(t, filepath.Join("test", "test", "test2"), filepath.Join("test", "test", "test1"), filepath.Join("test", "test1", "test2"), filepath.Join("test", "test1", "test5")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test/test1"}); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := check(t, filepath.Join("test", "test", "test2"), filepath.Join("test", "test1", "test2"), filepath.Join("test", "test1", "test5")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test1/test5"}); err != nil {
t.Errorf("wrong error: %v", err)
}
- check(t, []string{"test2"})
- if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test2"}); err != nil {
+ if err := check(t, filepath.Join("test", "test", "test2"), filepath.Join("test", "test1", "test2")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test1/test2"}); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := check(t, filepath.Join("test", "test", "test2")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test/test2"}); err != nil {
t.Errorf("wrong error: %v", err)
}
}
-func check(t *testing.T, checks []string) {
+func check(t *testing.T, checks ...string) error {
tr := fullSetup(t, true)
for _, c := range checks {
q, err := tr.Get(c, backend.BlankValue)
if err != nil {
- t.Errorf("unexpected error: %v", err)
+ return err
}
if q == nil {
- t.Errorf("failed to find entity: %s", c)
+ return fmt.Errorf("failed to find entity: %s", c)
}
}
+ return nil
}
diff --git a/internal/backend/query.go b/internal/backend/query.go
@@ -9,6 +9,8 @@ import (
"path/filepath"
"sort"
"strings"
+
+ "github.com/tobischo/gokeepasslib/v3"
)
// Get will request a singular entity
@@ -27,6 +29,21 @@ func (t *Transaction) Get(path string, mode ValueMode) (*QueryEntity, error) {
}
}
+func forEach(offset string, groups []gokeepasslib.Group, entries []gokeepasslib.Entry, cb func(string, gokeepasslib.Entry)) {
+ for _, g := range groups {
+ o := ""
+ if offset == "" {
+ o = g.Name
+ } else {
+ o = filepath.Join(offset, g.Name)
+ }
+ forEach(o, g.Groups, g.Entries, cb)
+ }
+ for _, e := range entries {
+ cb(offset, e)
+ }
+}
+
// QueryCallback will retrieve a query based on setting
func (t *Transaction) QueryCallback(args QueryOptions) ([]QueryEntity, error) {
if args.Mode == noneMode {
@@ -37,30 +54,33 @@ func (t *Transaction) QueryCallback(args QueryOptions) ([]QueryEntity, error) {
isSort := args.Mode != ExactMode
decrypt := args.Values != BlankValue
err := t.act(func(ctx Context) error {
- for _, entry := range ctx.db.Content.Root.Groups[0].Entries {
+ forEach("", ctx.db.Content.Root.Groups[0].Groups, ctx.db.Content.Root.Groups[0].Entries, func(offset string, entry gokeepasslib.Entry) {
path := getPathName(entry)
+ if offset != "" {
+ path = filepath.Join(offset, path)
+ }
if isSort {
switch args.Mode {
case FindMode:
if !strings.Contains(path, args.Criteria) {
- continue
+ return
}
case SuffixMode:
if !strings.HasSuffix(path, args.Criteria) {
- continue
+ return
}
}
} else {
if args.Mode == ExactMode {
if path != args.Criteria {
- continue
+ return
}
}
}
keys = append(keys, path)
entities[path] = QueryEntity{backing: entry}
- }
+ })
if decrypt {
return ctx.db.UnlockProtectedEntries()
}
diff --git a/internal/backend/query_test.go b/internal/backend/query_test.go
@@ -9,19 +9,19 @@ import (
func setupInserts(t *testing.T) {
setup(t)
- fullSetup(t, true).Insert("abc", "tedst", nil, false)
- fullSetup(t, true).Insert("abcx", "tedst", nil, false)
- fullSetup(t, true).Insert("ab11c", "tdest", nil, true)
- fullSetup(t, true).Insert("abc1ak", "atest", nil, false)
+ fullSetup(t, true).Insert("test/test/abc", "tedst", false)
+ fullSetup(t, true).Insert("test/test/abcx", "tedst", false)
+ fullSetup(t, true).Insert("test/test/ab11c", "tdest", true)
+ fullSetup(t, true).Insert("test/test/abc1ak", "atest", false)
}
func TestGet(t *testing.T) {
setupInserts(t)
- q, err := fullSetup(t, true).Get("abc", backend.BlankValue)
+ q, err := fullSetup(t, true).Get("test/test/abc", backend.BlankValue)
if err != nil {
t.Errorf("no error: %v", err)
}
- if q.Path != "abc" || q.Value != "" {
+ if q.Path != "test/test/abc" || q.Value != "" {
t.Error("invalid query result")
}
q, err = fullSetup(t, true).Get("aaaa", backend.BlankValue)
@@ -32,21 +32,21 @@ func TestGet(t *testing.T) {
func TestValueModes(t *testing.T) {
setupInserts(t)
- q, err := fullSetup(t, true).Get("abc", backend.BlankValue)
+ q, err := fullSetup(t, true).Get("test/test/abc", backend.BlankValue)
if err != nil {
t.Errorf("no error: %v", err)
}
if q.Value != "" {
t.Errorf("invalid result value: %s", q.Value)
}
- q, err = fullSetup(t, true).Get("abc", backend.HashedValue)
+ q, err = fullSetup(t, true).Get("test/test/abc", backend.HashedValue)
if err != nil {
t.Errorf("no error: %v", err)
}
if q.Value != "44276ba24db13df5568aa6db81e0190ab9d35d2168dce43dca61e628f5c666b1d8b091f1dda59c2359c86e7d393d59723a421d58496d279031e7f858c11d893e" {
t.Errorf("invalid result value: %s", q.Value)
}
- q, err = fullSetup(t, true).Get("ab11c", backend.SecretValue)
+ q, err = fullSetup(t, true).Get("test/test/ab11c", backend.SecretValue)
if err != nil {
t.Errorf("no error: %v", err)
}
@@ -67,7 +67,7 @@ func TestQueryCallback(t *testing.T) {
if len(res) != 4 {
t.Error("invalid results: not enough")
}
- if res[0].Path != "ab11c" || res[1].Path != "abc" || res[2].Path != "abc1ak" || res[3].Path != "abcx" {
+ if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc" || res[2].Path != "test/test/abc1ak" || res[3].Path != "test/test/abcx" {
t.Errorf("invalid results: %v", res)
}
res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.FindMode, Criteria: "1"})
@@ -77,7 +77,7 @@ func TestQueryCallback(t *testing.T) {
if len(res) != 2 {
t.Error("invalid results: not enough")
}
- if res[0].Path != "ab11c" || res[1].Path != "abc1ak" {
+ if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc1ak" {
t.Errorf("invalid results: %v", res)
}
res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.SuffixMode, Criteria: "c"})
@@ -87,17 +87,17 @@ func TestQueryCallback(t *testing.T) {
if len(res) != 2 {
t.Error("invalid results: not enough")
}
- if res[0].Path != "ab11c" || res[1].Path != "abc" {
+ if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc" {
t.Errorf("invalid results: %v", res)
}
- res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "abc"})
+ res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "test/test/abc"})
if err != nil {
t.Errorf("no error: %v", err)
}
if len(res) != 1 {
t.Error("invalid results: not enough")
}
- if res[0].Path != "abc" {
+ if res[0].Path != "test/test/abc" {
t.Errorf("invalid results: %v", res)
}
res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "abczzz"})
diff --git a/tests/expected.log b/tests/expected.log
@@ -2,25 +2,25 @@
-keys/one
-keys/one2
-keys2/three
+keys/k/one
+keys/k/one2
+keys2/k/three
delete entry? (y/N)
-keys/one2
-keys2/three
-keys/one2
-keys2/three
+keys/k/one2
+keys2/k/three
+keys/k/one2
+keys2/k/three
test2
test3
test4
-test
+test/k
XXXXXX
-keys/one2:
+keys/k/one2:
hash:6d201beeefb589b08ef0672dac82353d0cbd9ad99e1642c83a1601f3d647bcca003257b5e8f31bdc1d73fbec84fb085c79d6e2677b7ff927e823a54e789140d9
-keys2/three:
+keys2/k/three:
hash:132ab0244293c495a027cec12d0050598616daca888449920fc652719be0987830827d069ef78cc613e348de37c9b592d3406e2fb8d99a6961bf0c58da8a334f
-test/totp:
+test/k/totp:
hash:7ef183065ba70aaa417b87ea0a96b7e550a938a52440c640a07537f7794d8a89e50078eca6a7cbcfacabd97a2db06d11e82ddf7556ca909c4df9fc0d006013b1
delete entry? (y/N)
delete entry? (y/N) delete entry? (y/N) unable to remove entry (entity is empty/invalid)
diff --git a/tests/run.sh b/tests/run.sh
@@ -12,25 +12,25 @@ rm -rf $TESTS
mkdir -p $TESTS
_run() {
- echo "test" | "$BIN/lb" insert keys/one
- echo "test2" | "$BIN/lb" insert keys/one2
- echo "test" | "$BIN/lb" insert keys/one
- echo -e "test3\ntest4" | "$BIN/lb" insert keys2/three
+ echo "test" | "$BIN/lb" insert keys/k/one
+ echo "test2" | "$BIN/lb" insert keys/k/one2
+ echo "test" | "$BIN/lb" insert keys/k/one
+ echo -e "test3\ntest4" | "$BIN/lb" insert keys2/k/three
"$BIN/lb" ls
- yes 2>/dev/null | "$BIN/lb" rm keys/one
+ yes 2>/dev/null | "$BIN/lb" rm keys/k/one
echo
"$BIN/lb" ls
"$BIN/lb" find e
- "$BIN/lb" show keys/one2
- "$BIN/lb" show keys2/three
- echo "5ae472abqdekjqykoyxk7hvc2leklq5n" | "$BIN/lb" insert test/totp
+ "$BIN/lb" show keys/k/one2
+ "$BIN/lb" show keys2/k/three
+ echo "5ae472abqdekjqykoyxk7hvc2leklq5n" | "$BIN/lb" insert test/k/totp
"$BIN/lb" "totp" -list
- "$BIN/lb" "totp" test | tr '[:digit:]' 'X'
+ "$BIN/lb" "totp" test/k | tr '[:digit:]' 'X'
"$BIN/lb" "hash" $LOCKBOX_STORE
- yes 2>/dev/null | "$BIN/lb" rm keys2/three
+ yes 2>/dev/null | "$BIN/lb" rm keys2/k/three
echo
- yes 2>/dev/null | "$BIN/lb" rm test/totp
- yes 2>/dev/null | "$BIN/lb" rm test/one
+ yes 2>/dev/null | "$BIN/lb" rm test/k/totp
+ yes 2>/dev/null | "$BIN/lb" rm test/k/one
}
_run 2>&1 | sed "s#$LOCKBOX_STORE##g" > $TESTS/actual.log