commit 539bb4bbaf05c7e03b8adfbec1506a5407ffc866
parent 2075ff7e2ce084d66512f5d31bea87d3761a50af
Author: Sean Enck <sean@ttypty.com>
Date: Sat, 7 Jun 2025 17:10:24 -0400
rename backend -> kdbx
Diffstat:
28 files changed, 1690 insertions(+), 1690 deletions(-)
diff --git a/internal/app/conv.go b/internal/app/conv.go
@@ -8,7 +8,7 @@ import (
"io"
"strings"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
// Conv will convert 1-N files
@@ -19,7 +19,7 @@ func Conv(cmd CommandOptions) error {
}
w := cmd.Writer()
for _, a := range args {
- t, err := backend.Load(a)
+ t, err := kdbx.Load(a)
if err != nil {
return err
}
@@ -30,8 +30,8 @@ func Conv(cmd CommandOptions) error {
return nil
}
-func serialize(w io.Writer, tx *backend.Transaction, isJSON bool, filter string) error {
- e, err := tx.QueryCallback(backend.QueryOptions{Mode: backend.ListMode, Values: backend.JSONValue, PathFilter: filter})
+func serialize(w io.Writer, tx *kdbx.Transaction, isJSON bool, filter string) error {
+ e, err := tx.QueryCallback(kdbx.QueryOptions{Mode: kdbx.ListMode, Values: kdbx.JSONValue, PathFilter: filter})
if err != nil {
return err
}
@@ -51,7 +51,7 @@ func serialize(w io.Writer, tx *backend.Transaction, isJSON bool, filter string)
if isJSON {
fmt.Fprint(w, "\n")
}
- b, err := json.MarshalIndent(map[string]backend.EntityValues{item.Path: item.Values}, "", " ")
+ b, err := json.MarshalIndent(map[string]kdbx.EntityValues{item.Path: item.Values}, "", " ")
if err != nil {
return err
}
diff --git a/internal/app/core.go b/internal/app/core.go
@@ -6,7 +6,7 @@ import (
"io"
"os"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
"git.sr.ht/~enckse/lockbox/internal/platform"
)
@@ -15,7 +15,7 @@ type (
CommandOptions interface {
Confirm(string) bool
Args() []string
- Transaction() *backend.Transaction
+ Transaction() *kdbx.Transaction
Writer() io.Writer
}
@@ -28,14 +28,14 @@ type (
// DefaultCommand is the default CLI app type for actual execution
DefaultCommand struct {
- tx *backend.Transaction
+ tx *kdbx.Transaction
args []string
}
)
// NewDefaultCommand creates a new app command
func NewDefaultCommand(args []string) (*DefaultCommand, error) {
- t, err := backend.NewTransaction()
+ t, err := kdbx.NewTransaction()
if err != nil {
return nil, err
}
@@ -53,7 +53,7 @@ func (a *DefaultCommand) Writer() io.Writer {
}
// Transaction will return the backend transaction
-func (a *DefaultCommand) Transaction() *backend.Transaction {
+func (a *DefaultCommand) Transaction() *kdbx.Transaction {
return a.tx
}
diff --git a/internal/app/help/core.go b/internal/app/help/core.go
@@ -11,7 +11,7 @@ import (
"text/template"
"git.sr.ht/~enckse/lockbox/internal/app/commands"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
"git.sr.ht/~enckse/lockbox/internal/config"
"git.sr.ht/~enckse/lockbox/internal/output"
)
@@ -118,7 +118,7 @@ func Usage(verbose bool, exe string) ([]string, error) {
document.ReKey.KeyFile = setDocFlag(commands.ReKeyFlags.KeyFile)
document.ReKey.NoKey = commands.ReKeyFlags.NoKey
var fields []string
- for _, field := range backend.AllowedFields {
+ for _, field := range kdbx.AllowedFields {
fields = append(fields, strings.ToLower(field))
}
sort.Strings(fields)
diff --git a/internal/app/insert.go b/internal/app/insert.go
@@ -7,7 +7,7 @@ import (
"slices"
"strings"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
// Insert will execute an insert
@@ -18,15 +18,15 @@ func Insert(cmd UserInputOptions) error {
return errors.New("invalid insert, no entry given")
}
entry := args[0]
- base := backend.Base(entry)
- if !slices.ContainsFunc(backend.AllowedFields, func(v string) bool {
+ base := kdbx.Base(entry)
+ if !slices.ContainsFunc(kdbx.AllowedFields, func(v string) bool {
return base == strings.ToLower(v)
}) {
return fmt.Errorf("'%s' is not an allowed field name", base)
}
- dir := backend.Directory(entry)
- existing, err := t.Get(dir, backend.SecretValue)
+ dir := kdbx.Directory(entry)
+ existing, err := t.Get(dir, kdbx.SecretValue)
if err != nil {
return err
}
@@ -40,11 +40,11 @@ func Insert(cmd UserInputOptions) error {
}
}
}
- password, err := cmd.Input(!isPipe && !strings.EqualFold(base, backend.NotesField))
+ password, err := cmd.Input(!isPipe && !strings.EqualFold(base, kdbx.NotesField))
if err != nil {
return fmt.Errorf("invalid input: %w", err)
}
- vals := make(backend.EntityValues)
+ vals := make(kdbx.EntityValues)
if existing != nil {
vals = existing.Values
}
diff --git a/internal/app/insert_test.go b/internal/app/insert_test.go
@@ -7,7 +7,7 @@ import (
"testing"
"git.sr.ht/~enckse/lockbox/internal/app"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
type (
@@ -56,7 +56,7 @@ func (m *mockInsert) IsNoTOTP() (bool, error) {
return m.noTOTP()
}
-func (m *mockInsert) Transaction() *backend.Transaction {
+func (m *mockInsert) Transaction() *kdbx.Transaction {
return m.command.Transaction()
}
diff --git a/internal/app/list.go b/internal/app/list.go
@@ -7,7 +7,7 @@ import (
"sort"
"strings"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
// List will list/find entries
@@ -27,8 +27,8 @@ func List(cmd CommandOptions, groups bool) error {
}
func doList(attr, filter string, cmd CommandOptions, groups bool) error {
- opts := backend.QueryOptions{}
- opts.Mode = backend.ListMode
+ opts := kdbx.QueryOptions{}
+ opts.Mode = kdbx.ListMode
opts.PathFilter = filter
e, err := cmd.Transaction().QueryCallback(opts)
if err != nil {
@@ -54,7 +54,7 @@ func doList(attr, filter string, cmd CommandOptions, groups bool) error {
continue
}
}
- results = append(results, backend.NewPath(f.Path, k))
+ results = append(results, kdbx.NewPath(f.Path, k))
}
if len(results) == 0 {
continue
diff --git a/internal/app/list_test.go b/internal/app/list_test.go
@@ -7,7 +7,7 @@ import (
"testing"
"git.sr.ht/~enckse/lockbox/internal/app"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
"git.sr.ht/~enckse/lockbox/internal/config/store"
"git.sr.ht/~enckse/lockbox/internal/platform"
)
@@ -21,7 +21,7 @@ func testFile() string {
return file
}
-func fullSetup(t *testing.T, keep bool) *backend.Transaction {
+func fullSetup(t *testing.T, keep bool) *kdbx.Transaction {
file := testFile()
if !keep {
os.Remove(file)
@@ -30,14 +30,14 @@ func fullSetup(t *testing.T, keep bool) *backend.Transaction {
store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"})
store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
store.SetString("LOCKBOX_TOTP_ENTRY", "totp")
- tr, err := backend.NewTransaction()
+ tr, err := kdbx.NewTransaction()
if err != nil {
t.Errorf("failed: %v", err)
}
return tr
}
-func setup(t *testing.T) *backend.Transaction {
+func setup(t *testing.T) *kdbx.Transaction {
return fullSetup(t, false)
}
diff --git a/internal/app/move.go b/internal/app/move.go
@@ -5,7 +5,7 @@ import (
"errors"
"fmt"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
type (
@@ -37,17 +37,17 @@ func Move(cmd CommandOptions) error {
case 0:
break
default:
- if !backend.IsDirectory(dst) {
+ if !kdbx.IsDirectory(dst) {
return fmt.Errorf("%s must be a path, not an entry", dst)
}
- srcDir := backend.Directory(src)
- dir := backend.Directory(dst)
+ srcDir := kdbx.Directory(src)
+ dir := kdbx.Directory(dst)
for _, e := range m {
- srcPath := backend.Directory(e.Path)
+ srcPath := kdbx.Directory(e.Path)
if srcPath != srcDir {
return fmt.Errorf("multiple moves can only be done at a leaf level")
}
- r := moveRequest{cmd: cmd, src: e.Path, dst: backend.NewPath(dir, backend.Base(e.Path)), overwrite: false}
+ r := moveRequest{cmd: cmd, src: e.Path, dst: kdbx.NewPath(dir, kdbx.Base(e.Path)), overwrite: false}
if err := r.do(true); err != nil {
return err
}
@@ -69,21 +69,21 @@ func Move(cmd CommandOptions) error {
func (r moveRequest) do(dryRun bool) error {
tx := r.cmd.Transaction()
if !dryRun {
- use, err := backend.NewTransaction()
+ use, err := kdbx.NewTransaction()
if err != nil {
return err
}
tx = use
}
- srcExists, err := tx.Get(r.src, backend.SecretValue)
+ srcExists, err := tx.Get(r.src, kdbx.SecretValue)
if err != nil {
return errors.New("unable to get source entry")
}
if srcExists == nil {
return errors.New("no source object found")
}
- dstExists, err := tx.Get(r.dst, backend.BlankValue)
+ dstExists, err := tx.Get(r.dst, kdbx.BlankValue)
if err != nil {
return errors.New("unable to get destination object")
}
diff --git a/internal/app/move_test.go b/internal/app/move_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"git.sr.ht/~enckse/lockbox/internal/app"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
type (
@@ -21,12 +21,12 @@ type (
func newMockCommand(t *testing.T) *mockCommand {
setup(t)
- fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), map[string]string{"notes": "something", "password": "pass"})
- fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test2"), map[string]string{"notes": "something", "password": "pass"})
- fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test3"), map[string]string{"notes": "something", "password": "pass"})
- fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test1"), map[string]string{"notes": "something", "password": "pass"})
- fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test2"), map[string]string{"notes": "something", "password": "pass"})
- fullSetup(t, true).Insert(backend.NewPath("test", "test4", "test5"), map[string]string{"notes": "something", "password": "pass"})
+ fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test1"), map[string]string{"notes": "something", "password": "pass"})
+ fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test2"), map[string]string{"notes": "something", "password": "pass"})
+ fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test3"), map[string]string{"notes": "something", "password": "pass"})
+ fullSetup(t, true).Insert(kdbx.NewPath("test", "test3", "test1"), map[string]string{"notes": "something", "password": "pass"})
+ fullSetup(t, true).Insert(kdbx.NewPath("test", "test3", "test2"), map[string]string{"notes": "something", "password": "pass"})
+ fullSetup(t, true).Insert(kdbx.NewPath("test", "test4", "test5"), map[string]string{"notes": "something", "password": "pass"})
return &mockCommand{t: t, confirmed: false, confirm: true}
}
@@ -35,7 +35,7 @@ func (m *mockCommand) Confirm(string) bool {
return m.confirm
}
-func (m *mockCommand) Transaction() *backend.Transaction {
+func (m *mockCommand) Transaction() *kdbx.Transaction {
return fullSetup(m.t, true)
}
diff --git a/internal/app/rekey_test.go b/internal/app/rekey_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"git.sr.ht/~enckse/lockbox/internal/app"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
type (
@@ -24,7 +24,7 @@ func (m *mockKeyer) Confirm(string) bool {
return m.confirm
}
-func (m *mockKeyer) Transaction() *backend.Transaction {
+func (m *mockKeyer) Transaction() *kdbx.Transaction {
return fullSetup(m.t, true)
}
diff --git a/internal/app/remove.go b/internal/app/remove.go
@@ -6,7 +6,7 @@ import (
"fmt"
"io"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
// Remove will remove an entry
@@ -18,7 +18,7 @@ func Remove(cmd CommandOptions) error {
return remove(cmd.Transaction(), cmd.Writer(), args[0], cmd)
}
-func remove(t *backend.Transaction, w io.Writer, entry string, cmd CommandOptions) error {
+func remove(t *kdbx.Transaction, w io.Writer, entry string, cmd CommandOptions) error {
deleting := entry
postfixRemove := "y"
existings, err := t.MatchPath(deleting)
diff --git a/internal/app/showclip.go b/internal/app/showclip.go
@@ -5,7 +5,7 @@ import (
"errors"
"fmt"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
"git.sr.ht/~enckse/lockbox/internal/platform/clip"
)
@@ -39,9 +39,9 @@ func ShowClip(cmd CommandOptions, isShow bool) error {
}
func getEntity(entry string, cmd CommandOptions) (string, error) {
- base := backend.Base(entry)
- dir := backend.Directory(entry)
- existing, err := cmd.Transaction().Get(dir, backend.SecretValue)
+ base := kdbx.Base(entry)
+ dir := kdbx.Directory(entry)
+ existing, err := cmd.Transaction().Get(dir, kdbx.SecretValue)
if err != nil {
return "", err
}
diff --git a/internal/app/totp.go b/internal/app/totp.go
@@ -13,7 +13,7 @@ import (
otp "github.com/pquerna/otp/totp"
"git.sr.ht/~enckse/lockbox/internal/app/commands"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
"git.sr.ht/~enckse/lockbox/internal/config"
"git.sr.ht/~enckse/lockbox/internal/platform/clip"
)
@@ -97,7 +97,7 @@ func (args *TOTPArguments) display(opts TOTPOptions) error {
if !interactive && clipMode {
return errors.New("clipboard not available in non-interactive mode")
}
- if !backend.IsLeafAttribute(args.Entry, backend.OTPField) {
+ if !kdbx.IsLeafAttribute(args.Entry, kdbx.OTPField) {
return fmt.Errorf("'%s' is not a TOTP entry", args.Entry)
}
entity, err := getEntity(args.Entry, opts.app)
@@ -219,7 +219,7 @@ func (args *TOTPArguments) Do(opts TOTPOptions) error {
return ErrNoTOTP
}
if args.Mode == ListTOTPMode {
- return doList(backend.OTPField, args.Entry, opts.app, false)
+ return doList(kdbx.OTPField, args.Entry, opts.app, false)
}
return args.display(opts)
}
diff --git a/internal/app/totp_test.go b/internal/app/totp_test.go
@@ -8,20 +8,20 @@ import (
"testing"
"git.sr.ht/~enckse/lockbox/internal/app"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
"git.sr.ht/~enckse/lockbox/internal/config/store"
)
type (
mockOptions struct {
- tx *backend.Transaction
+ tx *kdbx.Transaction
buf bytes.Buffer
}
)
func newMock(t *testing.T) (*mockOptions, app.TOTPOptions) {
- fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test3", "totp"), map[string]string{"password": "pass", "otp": "5ae472abqdekjqykoyxk7hvc2leklq5n"})
- fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test2", "totp"), map[string]string{"password": "pass", "otp": "5ae472abqdekjqykoyxk7hvc2leklq5n"})
+ fullTOTPSetup(t, true).Insert(kdbx.NewPath("test", "test3", "totp"), map[string]string{"password": "pass", "otp": "5ae472abqdekjqykoyxk7hvc2leklq5n"})
+ fullTOTPSetup(t, true).Insert(kdbx.NewPath("test", "test2", "totp"), map[string]string{"password": "pass", "otp": "5ae472abqdekjqykoyxk7hvc2leklq5n"})
m := &mockOptions{
buf: bytes.Buffer{},
tx: fullTOTPSetup(t, true),
@@ -38,7 +38,7 @@ func newMock(t *testing.T) (*mockOptions, app.TOTPOptions) {
return m, opts
}
-func fullTOTPSetup(t *testing.T, keep bool) *backend.Transaction {
+func fullTOTPSetup(t *testing.T, keep bool) *kdbx.Transaction {
store.Clear()
file := testFile()
if !keep {
@@ -49,7 +49,7 @@ func fullTOTPSetup(t *testing.T, keep bool) *backend.Transaction {
store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
store.SetString("LOCKBOX_TOTP_ENTRY", "totp")
store.SetInt64("LOCKBOX_TOTP_TIMEOUT", 1)
- tr, err := backend.NewTransaction()
+ tr, err := kdbx.NewTransaction()
if err != nil {
t.Errorf("failed: %v", err)
}
@@ -64,7 +64,7 @@ func (m *mockOptions) Args() []string {
return nil
}
-func (m *mockOptions) Transaction() *backend.Transaction {
+func (m *mockOptions) Transaction() *kdbx.Transaction {
return m.tx
}
@@ -72,7 +72,7 @@ func (m *mockOptions) Writer() io.Writer {
return &m.buf
}
-func setupTOTP(t *testing.T) *backend.Transaction {
+func setupTOTP(t *testing.T) *kdbx.Transaction {
return fullTOTPSetup(t, false)
}
diff --git a/internal/app/unset.go b/internal/app/unset.go
@@ -5,7 +5,7 @@ import (
"errors"
"fmt"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
// Unset enables clearing an entry
@@ -16,9 +16,9 @@ func Unset(cmd CommandOptions) error {
return errors.New("invalid unset, no entry given")
}
entry := args[0]
- base := backend.Base(entry)
- dir := backend.Directory(entry)
- existing, err := t.Get(dir, backend.SecretValue)
+ base := kdbx.Base(entry)
+ dir := kdbx.Directory(entry)
+ existing, err := t.Get(dir, kdbx.SecretValue)
if err != nil {
return err
}
@@ -26,7 +26,7 @@ func Unset(cmd CommandOptions) error {
return fmt.Errorf("%s does not exist", entry)
}
w := cmd.Writer()
- unsetRemove := func(v backend.EntityValues) (bool, error) {
+ unsetRemove := func(v kdbx.EntityValues) (bool, error) {
if len(v) == 0 {
fmt.Fprintf(w, "removing empty group: %s\n", dir)
return true, remove(t, w, dir, cmd)
diff --git a/internal/app/unset_test.go b/internal/app/unset_test.go
@@ -4,12 +4,12 @@ import (
"testing"
"git.sr.ht/~enckse/lockbox/internal/app"
- "git.sr.ht/~enckse/lockbox/internal/backend"
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
)
func TestUnset(t *testing.T) {
m := newMockCommand(t)
- fullSetup(t, true).Insert(backend.NewPath("test", "test2", "testz"), map[string]string{"notes": "something"})
+ fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "testz"), map[string]string{"notes": "something"})
if err := app.Unset(m); err == nil || err.Error() != "invalid unset, no entry given" {
t.Errorf("invalid error: %v", err)
}
diff --git a/internal/backend/actions.go b/internal/backend/actions.go
@@ -1,281 +0,0 @@
-// Package backend handles kdbx interactions
-package backend
-
-import (
- "errors"
- "fmt"
- "os"
- "strings"
- "time"
-
- "git.sr.ht/~enckse/lockbox/internal/config"
- "git.sr.ht/~enckse/lockbox/internal/platform"
- "github.com/tobischo/gokeepasslib/v3"
-)
-
-type (
- action func(t Context) error
-)
-
-func (t *Transaction) act(cb action) error {
- if !t.valid {
- return errors.New("invalid transaction")
- }
- key, err := config.NewKey(config.DefaultKeyMode)
- if err != nil {
- return err
- }
- k, err := key.Read(platform.ReadInteractivePassword)
- if err != nil {
- return err
- }
- file := config.EnvKeyFile.Get()
- if !t.exists {
- if err := create(t.file, k, file); err != nil {
- return err
- }
- }
- f, err := os.Open(t.file)
- if err != nil {
- return err
- }
- defer f.Close()
- db := gokeepasslib.NewDatabase()
- creds, err := getCredentials(k, file)
- if err != nil {
- return err
- }
- db.Credentials = creds
- if err := gokeepasslib.NewDecoder(f).Decode(db); err != nil {
- return err
- }
- if len(db.Content.Root.Groups) != 1 {
- return errors.New("kdbx must have ONE root group")
- }
- err = cb(Context{db: db})
- if err != nil {
- return err
- }
- if t.write {
- if err := db.LockProtectedEntries(); err != nil {
- return err
- }
- if err := f.Close(); err != nil {
- return err
- }
- f, err = os.Create(t.file)
- if err != nil {
- return err
- }
- defer f.Close()
- return encode(f, db)
- }
- return err
-}
-
-// ReKey will change the credentials on a database
-func (t *Transaction) ReKey(pass, keyFile string) error {
- creds, err := getCredentials(pass, keyFile)
- if err != nil {
- return err
- }
- return t.change(func(c Context) error {
- c.db.Credentials = creds
- return nil
- })
-}
-
-func (t *Transaction) change(cb action) error {
- if t.readonly {
- return errors.New("unable to alter database in readonly mode")
- }
- return t.act(func(c Context) error {
- if err := c.db.UnlockProtectedEntries(); err != nil {
- return err
- }
- t.write = true
- return cb(c)
- })
-}
-
-func (c Context) alterEntities(isAdd bool, offset []string, title string, entity *gokeepasslib.Entry) bool {
- g, e, ok := findAndDo(isAdd, title, 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 string) bool {
- return c.alterEntities(false, offset, title, 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 && len(group.Entries) == 0 && len(group.Groups) == 0 {
- continue
- }
- groups = append(groups, group)
- }
- g = groups
- }
- }
- return g, e, done
-}
-
-// Move will move a src object to a dst location
-func (t *Transaction) Move(src *Entity, dst string) error {
- if src == nil {
- return errors.New("source entity is not set")
- }
- if strings.TrimSpace(src.Path) == "" {
- return errors.New("empty path not allowed")
- }
- if len(src.Values) == 0 {
- return errors.New("empty secrets not allowed")
- }
- values := make(map[string]string)
- for k, v := range src.Values {
- found := false
- for _, mapping := range AllowedFields {
- if strings.EqualFold(k, mapping) {
- values[mapping] = v
- found = true
- break
- }
- }
- if !found {
- return fmt.Errorf("unknown entity field: %s", k)
- }
- }
- mod := config.EnvDefaultModTime.Get()
- modTime := time.Now()
- if mod != "" {
- p, err := time.Parse(config.ModTimeFormat, mod)
- if err != nil {
- return err
- }
- modTime = p
- }
- dOffset, dTitle, err := splitComponents(dst)
- if err != nil {
- return err
- }
- sOffset, sTitle, err := splitComponents(src.Path)
- if err != nil {
- return err
- }
- isMove := dst != src.Path
- return t.change(func(c Context) error {
- c.removeEntity(sOffset, sTitle)
- if isMove {
- c.removeEntity(dOffset, dTitle)
- }
- e := gokeepasslib.NewEntry()
- e.Values = append(e.Values, value(titleKey, dTitle))
- e.Values = append(e.Values, value(modTimeKey, modTime.Format(time.RFC3339)))
- for k, v := range values {
- val := v
- switch k {
- case OTPField, PasswordField:
- if strings.Contains(val, "\n") {
- return fmt.Errorf("%s can NOT be multi-line", strings.ToLower(k))
- }
- if k == OTPField {
- val = config.EnvTOTPFormat.Get(v)
- }
- }
- e.Values = append(e.Values, protectedValue(k, val))
- }
- c.alterEntities(true, dOffset, dTitle, &e)
- return nil
- })
-}
-
-// Insert is a move to the same location
-func (t *Transaction) Insert(path string, val EntityValues) error {
- return t.Move(&Entity{Path: path, Values: val}, path)
-}
-
-// Remove will remove a single entity
-func (t *Transaction) Remove(entity *Entity) error {
- if entity == nil {
- return errors.New("entity is empty/invalid")
- }
- return t.RemoveAll([]Entity{*entity})
-}
-
-// RemoveAll handles removing elements
-func (t *Transaction) RemoveAll(entities []Entity) error {
- if len(entities) == 0 {
- return errors.New("no entities given")
- }
- type removal struct {
- title string
- parts []string
- }
- removals := []removal{}
- for _, entity := range entities {
- offset, title, err := splitComponents(entity.Path)
- if err != nil {
- return err
- }
- removals = append(removals, removal{parts: offset, title: title})
- }
- return t.change(func(c Context) error {
- for _, entity := range removals {
- if ok := c.removeEntity(entity.parts, entity.title); !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,337 +0,0 @@
-package backend_test
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "testing"
-
- "git.sr.ht/~enckse/lockbox/internal/backend"
- "git.sr.ht/~enckse/lockbox/internal/config/store"
- "git.sr.ht/~enckse/lockbox/internal/platform"
-)
-
-const (
- testDir = "testdata"
-)
-
-func testFile(name string) string {
- file := filepath.Join(testDir, name)
- if !platform.PathExists(testDir) {
- os.Mkdir(testDir, 0o755)
- }
- return file
-}
-
-func fullSetup(t *testing.T, keep bool) *backend.Transaction {
- file := testFile("test.kdbx")
- if !keep {
- os.Remove(file)
- }
- store.SetBool("LOCKBOX_READONLY", false)
- store.SetString("LOCKBOX_STORE", file)
- store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"})
- store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
- store.SetString("LOCKBOX_TOTP_ENTRY", "totp")
- tr, err := backend.NewTransaction()
- if err != nil {
- t.Errorf("failed: %v", err)
- }
- return tr
-}
-
-func TestKeyFile(t *testing.T) {
- store.Clear()
- defer store.Clear()
- file := testFile("keyfile_test.kdbx")
- keyFile := testFile("file.key")
- os.Remove(file)
- store.SetString("LOCKBOX_STORE", file)
- store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"})
- store.SetString("LOCKBOX_CREDENTIALS_KEY_FILE", keyFile)
- store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
- store.SetString("LOCKBOX_TOTP_ENTRY", "totp")
- os.WriteFile(keyFile, []byte("test"), 0o644)
- tr, err := backend.NewTransaction()
- if err != nil {
- t.Errorf("failed: %v", err)
- }
- if err := tr.Insert(backend.NewPath("a", "b"), map[string]string{"password": "t"}); err != nil {
- t.Errorf("no error: %v", err)
- }
-}
-
-func setup(t *testing.T) *backend.Transaction {
- return fullSetup(t, false)
-}
-
-func TestNoWriteOnRO(t *testing.T) {
- setup(t)
- store.SetBool("LOCKBOX_READONLY", true)
- tr, _ := backend.NewTransaction()
- if err := tr.Insert("a/a/a", map[string]string{"password": "xyz"}); err.Error() != "unable to alter database in readonly mode" {
- t.Errorf("wrong error: %v", err)
- }
-}
-
-func TestBadAction(t *testing.T) {
- tr := &backend.Transaction{}
- if err := tr.Insert("a/a/a", map[string]string{"notes": "xyz"}); err.Error() != "invalid transaction" {
- t.Errorf("wrong error: %v", err)
- }
-}
-
-func TestMove(t *testing.T) {
- setup(t)
- fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), map[string]string{"passworD": "pass"})
- fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test3"), map[string]string{"NoTES": "pass", "password": "xxx"})
- if err := fullSetup(t, true).Move(nil, ""); err == nil || err.Error() != "source entity is not set" {
- t.Errorf("no error: %v", err)
- }
- if err := fullSetup(t, true).Move(&backend.Entity{Path: backend.NewPath("test", "test2", "test3"), Values: map[string]string{"Notes": "abc"}}, backend.NewPath("test1", "test2", "test3")); err != nil {
- t.Errorf("no error: %v", err)
- }
- q, err := fullSetup(t, true).Get(backend.NewPath("test1", "test2", "test3"), backend.SecretValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if val, ok := q.Value("notes"); !ok || val != "abc" {
- t.Errorf("invalid retrieval")
- }
- if err := fullSetup(t, true).Move(&backend.Entity{Path: backend.NewPath("test", "test2", "test1"), Values: map[string]string{"password": "test"}}, backend.NewPath("test1", "test2", "test3")); err != nil {
- t.Errorf("no error: %v", err)
- }
- q, err = fullSetup(t, true).Get(backend.NewPath("test1", "test2", "test3"), backend.SecretValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if val, ok := q.Value("password"); !ok || val != "test" {
- t.Errorf("invalid retrieval")
- }
-}
-
-func TestInserts(t *testing.T) {
- if err := setup(t).Insert("", nil); err.Error() != "empty path not allowed" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).Insert("a", map[string]string{"randomfield": "1"}); err.Error() != "unknown entity field: randomfield" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).Insert("tests", map[string]string{"notes": "1"}); err.Error() != "input paths must contain at LEAST 2 components" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).Insert("tests//l", map[string]string{"notes": "test"}); err.Error() != "unwilling to operate on path with empty segment" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).Insert("tests/", map[string]string{"password": "test"}); err.Error() != "path can NOT end with separator" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).Insert("/tests", map[string]string{"password": "test"}); err.Error() != "path can NOT be rooted" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).Insert("test", map[string]string{"otp": "test"}); err.Error() != "input paths must contain at LEAST 2 components" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).Insert("a", nil); err.Error() != "empty secrets not allowed" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).Insert("a", make(map[string]string)); err.Error() != "empty secrets not allowed" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).Insert(backend.NewPath("test", "offset", "value"), map[string]string{"password": "pass"}); err != nil {
- t.Errorf("no error: %v", err)
- }
- if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset", "value"), map[string]string{"NoTes": "pass2"}); err != nil {
- t.Errorf("wrong error: %v", err)
- }
- if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset", "value2"), map[string]string{"NOTES": "pass\npass", "password": "xxx", "otP": "zzz"}); err != nil {
- t.Errorf("no error: %v", err)
- }
- q, err := fullSetup(t, true).Get(backend.NewPath("test", "offset", "value"), backend.SecretValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if val, ok := q.Value("notes"); !ok || val != "pass2" {
- t.Errorf("invalid retrieval")
- }
- q, err = fullSetup(t, true).Get(backend.NewPath("test", "offset", "value2"), backend.SecretValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if val, ok := q.Value("notes"); !ok || val != "pass\npass" {
- t.Errorf("invalid retrieval: %s", val)
- }
- if val, ok := q.Value("password"); !ok || val != "xxx" {
- t.Errorf("invalid retrieval: %s", val)
- }
- if val, ok := q.Value("otp"); !ok || val != "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1&digits=6&issuer=lbissuer&period=30&secret=zzz" {
- t.Errorf("invalid retrieval: %s", val)
- }
- if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset"), map[string]string{"otp": "5ae472sabqdekjqykoyxk7hvc2leklq5n"}); err != nil {
- t.Errorf("no error: %v", err)
- }
- if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset"), map[string]string{"OTP": "ljaf\n5ae472abqdekjqykoyxk7hvc2leklq5n"}); err == nil || err.Error() != "otp can NOT be multi-line" {
- t.Errorf("wrong error: %v", err)
- }
- if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset"), map[string]string{"password": "ljaf\n5ae472abqdekjqykoyxk7hvc2leklq5n"}); err == nil || err.Error() != "password can NOT be multi-line" {
- t.Errorf("wrong error: %v", err)
- }
-}
-
-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.Entity{}); err.Error() != "input paths must contain at LEAST 2 components" {
- t.Errorf("wrong error: %v", err)
- }
- tx := backend.Entity{Path: backend.NewPath("test1", "test2", "test3")}
- if err := setup(t).Remove(&tx); err.Error() != "failed to remove entity" {
- t.Errorf("wrong error: %v", err)
- }
- setup(t)
- for _, i := range []string{"test1", "test2"} {
- fullSetup(t, true).Insert(backend.NewPath(i, i, i), map[string]string{"PASSWORD": "pass"})
- }
- tx = backend.Entity{Path: backend.NewPath("test1", "test1", "test1")}
- if err := fullSetup(t, true).Remove(&tx); err != nil {
- t.Errorf("wrong error: %v", err)
- }
- if err := check(t, backend.NewPath("test2", "test2", "test2")); err != nil {
- t.Errorf("invalid check: %v", err)
- }
- tx = backend.Entity{Path: backend.NewPath("test2", "test2", "test2")}
- if err := fullSetup(t, true).Remove(&tx); err != nil {
- t.Errorf("wrong error: %v", err)
- }
- setup(t)
- for _, i := range []string{backend.NewPath("test", "test", "test1"), backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test", "test3"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")} {
- fullSetup(t, true).Insert(i, map[string]string{"password": "pass"})
- }
- tx = backend.Entity{Path: "test/test/test3"}
- if err := fullSetup(t, true).Remove(&tx); err != nil {
- t.Errorf("wrong error: %v", err)
- }
- if err := check(t, backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test", "test1"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")); err != nil {
- t.Errorf("invalid check: %v", err)
- }
- tx = backend.Entity{Path: "test/test/test1"}
- if err := fullSetup(t, true).Remove(&tx); err != nil {
- t.Errorf("wrong error: %v", err)
- }
- if err := check(t, backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")); err != nil {
- t.Errorf("invalid check: %v", err)
- }
- tx = backend.Entity{Path: "test/test1/test5"}
- if err := fullSetup(t, true).Remove(&tx); err != nil {
- t.Errorf("wrong error: %v", err)
- }
- if err := check(t, backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test1", "test2")); err != nil {
- t.Errorf("invalid check: %v", err)
- }
- tx = backend.Entity{Path: "test/test1/test2"}
- if err := fullSetup(t, true).Remove(&tx); err != nil {
- t.Errorf("wrong error: %v", err)
- }
- if err := check(t, backend.NewPath("test", "test", "test2")); err != nil {
- t.Errorf("invalid check: %v", err)
- }
- tx = backend.Entity{Path: "test/test/test2"}
- if err := fullSetup(t, true).Remove(&tx); err != nil {
- t.Errorf("wrong error: %v", err)
- }
-}
-
-func TestRemoveAlls(t *testing.T) {
- if err := setup(t).RemoveAll(nil); err.Error() != "no entities given" {
- t.Errorf("wrong error: %v", err)
- }
- if err := setup(t).RemoveAll([]backend.Entity{}); err.Error() != "no entities given" {
- t.Errorf("wrong error: %v", err)
- }
- setup(t)
- for _, i := range []string{backend.NewPath("test", "test", "test1"), backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test", "test3"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")} {
- fullSetup(t, true).Insert(i, map[string]string{"PaSsWoRd": "pass"})
- }
- if err := fullSetup(t, true).RemoveAll([]backend.Entity{{Path: "test/test/test3"}, {Path: "test/test/test1"}}); err != nil {
- t.Errorf("wrong error: %v", err)
- }
- if err := check(t, backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")); err != nil {
- t.Errorf("invalid check: %v", err)
- }
-}
-
-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 {
- return err
- }
- if q == nil {
- return fmt.Errorf("failed to find entity: %s", c)
- }
- }
- return nil
-}
-
-func TestKeyAndOrKeyFile(t *testing.T) {
- keyAndOrKeyFile(t, true, true)
- keyAndOrKeyFile(t, false, true)
- keyAndOrKeyFile(t, true, false)
- keyAndOrKeyFile(t, false, false)
-}
-
-func keyAndOrKeyFile(t *testing.T, key, keyFile bool) {
- store.Clear()
- file := testFile("keyorkeyfile.kdbx")
- os.Remove(file)
- store.SetString("LOCKBOX_STORE", file)
- if key {
- store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"})
- store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
- } else {
- store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "none")
- }
- if keyFile {
- key := testFile("keyfileor.key")
- store.SetString("LOCKBOX_CREDENTIALS_KEY_FILE", key)
- os.WriteFile(key, []byte("test"), 0o644)
- }
- tr, err := backend.NewTransaction()
- if err != nil {
- t.Errorf("failed: %v", err)
- }
- invalid := !key && !keyFile
- err = tr.Insert(backend.NewPath("a", "b"), map[string]string{"password": "t"})
- if invalid {
- if err == nil || err.Error() != "key and/or keyfile must be set" {
- t.Errorf("invalid error: %v", err)
- }
- } else {
- if err != nil {
- t.Errorf("no error allowed: %v", err)
- }
- }
-}
-
-func TestReKey(t *testing.T) {
- store.Clear()
- f := "rekey_test.kdbx"
- file := testFile(f)
- defer os.Remove(filepath.Join(testDir, f))
- store.SetString("LOCKBOX_STORE", file)
- store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"})
- store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
- store.SetString("LOCKBOX_TOTP_ENTRY", "totp")
- tr, err := backend.NewTransaction()
- if err != nil {
- t.Errorf("failed: %v", err)
- }
- if err := tr.ReKey("", ""); err == nil || err.Error() != "key and/or keyfile must be set" {
- t.Errorf("no error: %v", err)
- }
- if err := tr.ReKey("abc", ""); err != nil {
- t.Errorf("no error: %v", err)
- }
-}
diff --git a/internal/backend/core.go b/internal/backend/core.go
@@ -1,228 +0,0 @@
-// Package backend handles kdbx interactions
-package backend
-
-import (
- "errors"
- "fmt"
- "iter"
- "os"
- "strings"
-
- "git.sr.ht/~enckse/lockbox/internal/config"
- "git.sr.ht/~enckse/lockbox/internal/platform"
- "github.com/tobischo/gokeepasslib/v3"
- "github.com/tobischo/gokeepasslib/v3/wrappers"
-)
-
-var (
- errPath = errors.New("input paths must contain at LEAST 2 components")
- // AllowedFields are the same of allowed names for storing in a kdbx entry
- AllowedFields = []string{NotesField, OTPField, PasswordField}
-)
-
-const (
- titleKey = "Title"
- pathSep = "/"
- isGlob = pathSep + "*"
- modTimeKey = "ModTime"
- // OTPField is the totp storage attribute
- OTPField = "otp"
- // NotesField is the multiline notes key
- NotesField = "Notes"
- // PasswordField is where the password is stored
- PasswordField = "Password"
-)
-
-type (
- // EntityValues are what is stored, from an entity, into kdbx backing store
- EntityValues map[string]string
- // QuerySeq2 wraps the iteration for query entities
- QuerySeq2 iter.Seq2[Entity, error]
- // Transaction handles the overall operation of the transaction
- Transaction struct {
- file string
- valid bool
- exists bool
- write bool
- readonly bool
- }
- // Context handles operating on the underlying database
- Context struct {
- db *gokeepasslib.Database
- }
- // Entity are database objects from results and transactional changes
- Entity struct {
- Values EntityValues
- Path string
- }
-)
-
-// Load will load a kdbx file for transactions
-func Load(file string) (*Transaction, error) {
- return loadFile(file, true)
-}
-
-func loadFile(file string, must bool) (*Transaction, error) {
- if strings.TrimSpace(file) == "" {
- return nil, errors.New("no store set")
- }
- if !strings.HasSuffix(file, ".kdbx") {
- return nil, errors.New("should use a .kdbx extension")
- }
- exists := platform.PathExists(file)
- if must {
- if !exists {
- return nil, errors.New("invalid file, does not exist")
- }
- }
- ro := config.EnvReadOnly.Get()
- return &Transaction{valid: true, file: file, exists: exists, readonly: ro}, nil
-}
-
-// NewTransaction will use the underlying environment data store location
-func NewTransaction() (*Transaction, error) {
- return loadFile(config.EnvStore.Get(), false)
-}
-
-func splitComponents(path string) ([]string, string, error) {
- if len(strings.Split(path, pathSep)) < 2 {
- return nil, "", errPath
- }
- if strings.HasPrefix(path, pathSep) {
- return nil, "", errors.New("path can NOT be rooted")
- }
- if strings.HasSuffix(path, pathSep) {
- return nil, "", errors.New("path can NOT end with separator")
- }
- if strings.Contains(path, pathSep+pathSep) {
- return nil, "", errors.New("unwilling to operate on path with empty segment")
- }
- title := Base(path)
- parts := strings.Split(Directory(path), pathSep)
- return parts, title, nil
-}
-
-func getCredentials(key, keyFile string) (*gokeepasslib.DBCredentials, error) {
- hasKey := len(key) > 0
- hasKeyFile := len(keyFile) > 0
- if !hasKey && !hasKeyFile {
- return nil, errors.New("key and/or keyfile must be set")
- }
- if hasKeyFile {
- if !platform.PathExists(keyFile) {
- return nil, errors.New("no keyfile found on disk")
- }
- if !hasKey {
- return gokeepasslib.NewKeyCredentials(keyFile)
- }
- return gokeepasslib.NewPasswordAndKeyCredentials(key, keyFile)
- }
- return gokeepasslib.NewPasswordCredentials(key), nil
-}
-
-func create(file, key, keyFile string) error {
- root := gokeepasslib.NewGroup()
- root.Name = "root"
- db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
- creds, err := getCredentials(key, keyFile)
- if err != nil {
- return err
- }
- db.Credentials = creds
- db.Content.Root = &gokeepasslib.RootData{
- Groups: []gokeepasslib.Group{root},
- }
- if err := db.LockProtectedEntries(); err != nil {
- return err
- }
-
- f, err := os.Create(file)
- if err != nil {
- return err
- }
- defer f.Close()
- return encode(f, db)
-}
-
-func encode(f *os.File, db *gokeepasslib.Database) error {
- return gokeepasslib.NewEncoder(f).Encode(db)
-}
-
-func getPathName(entry gokeepasslib.Entry) string {
- return entry.GetTitle()
-}
-
-func value(key, value string) gokeepasslib.ValueData {
- return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: value}}
-}
-
-func protectedValue(key, value string) gokeepasslib.ValueData {
- return gokeepasslib.ValueData{
- Key: key,
- Value: gokeepasslib.V{Content: value, Protected: wrappers.NewBoolWrapper(true)},
- }
-}
-
-// NewSuffix creates a new user 'name' suffix
-func NewSuffix(name string) string {
- return fmt.Sprintf("%s%s", pathSep, name)
-}
-
-// NewPath creates a new storage location path.
-func NewPath(segments ...string) string {
- return strings.Join(segments, pathSep)
-}
-
-// Value will read an entity value
-func (e Entity) Value(key string) (string, bool) {
- if e.Values == nil {
- return "", false
- }
- val, ok := e.Values[key]
- return val, ok
-}
-
-// Base will get the base name of input path
-func Base(s string) string {
- parts := strings.Split(s, pathSep)
- if len(parts) == 0 {
- return s
- }
- return parts[len(parts)-1]
-}
-
-// Directory will get the directory/group for the given path
-func Directory(s string) string {
- parts := strings.Split(s, pathSep)
- return NewPath(parts[0 : len(parts)-1]...)
-}
-
-func getValue(e gokeepasslib.Entry, key string) string {
- v := e.Get(key)
- if v == nil {
- return ""
- }
- return v.Value.Content
-}
-
-// IsDirectory will indicate if a path looks like a group/directory
-func IsDirectory(path string) bool {
- return strings.HasSuffix(path, pathSep)
-}
-
-// IsLeafAttribute indicates if a path is leaved with a certain name
-func IsLeafAttribute(path, attr string) bool {
- return strings.HasSuffix(path, pathSep+attr)
-}
-
-// Collect will create a slice from an iterable set of query sequence results
-func (s QuerySeq2) Collect() ([]Entity, error) {
- var entities []Entity
- for entity, err := range s {
- if err != nil {
- return nil, err
- }
- entities = append(entities, entity)
- }
- return entities, nil
-}
diff --git a/internal/backend/core_test.go b/internal/backend/core_test.go
@@ -1,155 +0,0 @@
-package backend_test
-
-import (
- "errors"
- "testing"
-
- "git.sr.ht/~enckse/lockbox/internal/backend"
-)
-
-func TestLoad(t *testing.T) {
- if _, err := backend.Load(" "); err.Error() != "no store set" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := backend.Load("garbage"); err.Error() != "should use a .kdbx extension" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := backend.Load("garbage.kdbx"); err.Error() != "invalid file, does not exist" {
- t.Errorf("invalid error: %v", err)
- }
-}
-
-func TestIsDirectory(t *testing.T) {
- if backend.IsDirectory("") {
- t.Error("invalid directory detection")
- }
- if !backend.IsDirectory("/") {
- t.Error("invalid directory detection")
- }
- if backend.IsDirectory("/a") {
- t.Error("invalid directory detection")
- }
-}
-
-func TestBase(t *testing.T) {
- b := backend.Base("")
- if b != "" {
- t.Error("invalid base")
- }
- b = backend.Base("aaa")
- if b != "aaa" {
- t.Error("invalid base")
- }
- b = backend.Base("aaa/")
- if b != "" {
- t.Error("invalid base")
- }
- b = backend.Base("aaa/a")
- if b != "a" {
- t.Error("invalid base")
- }
-}
-
-func TestDirectory(t *testing.T) {
- b := backend.Directory("")
- if b != "" {
- t.Error("invalid directory")
- }
- b = backend.Directory("/")
- if b != "" {
- t.Error("invalid directory")
- }
- b = backend.Directory("/a")
- if b != "" {
- t.Error("invalid directory")
- }
- b = backend.Directory("a")
- if b != "" {
- t.Error("invalid directory")
- }
- b = backend.Directory("b/a")
- if b != "b" {
- t.Error("invalid directory")
- }
-}
-
-func TestIsLeafAttr(t *testing.T) {
- if backend.IsLeafAttribute("axyz", "z") {
- t.Error("invalid result")
- }
- if !backend.IsLeafAttribute("axy/z", "z") {
- t.Error("invalid result")
- }
-}
-
-func TestNewPath(t *testing.T) {
- p := backend.NewPath("abc", "xyz")
- if p != backend.NewPath("abc", "xyz") {
- t.Error("invalid new path")
- }
-}
-
-func TestNewSuffix(t *testing.T) {
- if backend.NewSuffix("test") != "/test" {
- t.Error("invalid suffix")
- }
-}
-
-func generateTestSeq(hasError, extra bool) backend.QuerySeq2 {
- return func(yield func(backend.Entity, error) bool) {
- if !yield(backend.Entity{}, nil) {
- return
- }
- if !yield(backend.Entity{}, nil) {
- return
- }
- if hasError {
- if !yield(backend.Entity{}, errors.New("test collect error")) {
- return
- }
- }
- if !yield(backend.Entity{}, nil) {
- return
- }
- if extra {
- if !yield(backend.Entity{}, nil) {
- return
- }
- }
- }
-}
-
-func TestQuerySeq2Collect(t *testing.T) {
- seq := generateTestSeq(true, true)
- if _, err := seq.Collect(); err == nil || err.Error() != "test collect error" {
- t.Errorf("invalid error: %v", err)
- }
- seq = generateTestSeq(false, false)
- c, err := seq.Collect()
- if err != nil || len(c) != 3 {
- t.Errorf("invalid collect: %v %v %d", c, err, len(c))
- }
- seq = generateTestSeq(false, true)
- c, err = seq.Collect()
- if err != nil || len(c) != 4 {
- t.Errorf("invalid collect: %v %v %d", c, err, len(c))
- }
-}
-
-func TestEntityValue(t *testing.T) {
- e := backend.Entity{}
- if _, ok := e.Value("key"); ok {
- t.Error("values are nil")
- }
- e.Values = make(map[string]string)
- if _, ok := e.Value("key"); ok {
- t.Error("values are not set")
- }
- e.Values["key2"] = "1"
- if _, ok := e.Value("key"); ok {
- t.Error("values are not matching")
- }
- if val, ok := e.Value("key2"); !ok || val != "1" {
- t.Error("values are not set")
- }
-}
diff --git a/internal/backend/query.go b/internal/backend/query.go
@@ -1,241 +0,0 @@
-// Package backend handles querying a store
-package backend
-
-import (
- "crypto/sha512"
- "errors"
- "fmt"
- "regexp"
- "slices"
- "strings"
-
- "git.sr.ht/~enckse/lockbox/internal/config"
- "git.sr.ht/~enckse/lockbox/internal/output"
- "github.com/tobischo/gokeepasslib/v3"
-)
-
-type (
- // QueryOptions indicates how to find entities
- QueryOptions struct {
- PathFilter string
- Criteria string
- Mode QueryMode
- Values ValueMode
- }
- // QueryMode indicates HOW an entity will be found
- QueryMode int
- // ValueMode indicates what to do with the store value of the entity
- ValueMode int
-)
-
-const (
- // BlankValue will not decrypt secrets, empty value
- BlankValue ValueMode = iota
- // SecretValue will have the raw secret onboard
- SecretValue
- // JSONValue will show entries as a JSON payload
- JSONValue
-)
-
-const (
- noneMode QueryMode = iota
- // ListMode indicates ALL entities will be listed
- ListMode
- // FindMode indicates a _contains_ search for an entity
- FindMode
- // ExactMode means an entity must MATCH the string exactly
- ExactMode
- // PrefixMode allows for entities starting with a specific value
- PrefixMode
-)
-
-// 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})
-}
-
-// Get will request a singular entity
-func (t *Transaction) Get(path string, mode ValueMode) (*Entity, error) {
- _, _, err := splitComponents(path)
- if err != nil {
- return nil, err
- }
- e, err := t.queryCollect(QueryOptions{Mode: ExactMode, Criteria: path, Values: mode})
- if err != nil {
- return nil, err
- }
- switch len(e) {
- case 0:
- return nil, nil
- case 1:
- return &e[0], nil
- default:
- return nil, errors.New("too many entities matched")
- }
-}
-
-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 = NewPath(offset, g.Name)
- }
- forEach(o, g.Groups, g.Entries, cb)
- }
- for _, e := range entries {
- cb(offset, e)
- }
-}
-
-func (t *Transaction) queryCollect(args QueryOptions) ([]Entity, error) {
- e, err := t.QueryCallback(args)
- if err != nil {
- return nil, err
- }
- return e.Collect()
-}
-
-// QueryCallback will retrieve a query based on setting
-func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) {
- if args.Mode == noneMode {
- return nil, errors.New("no query mode specified")
- }
- type entity struct {
- path string
- backing gokeepasslib.Entry
- }
- var entities []entity
- isSort := args.Mode != ExactMode
- decrypt := args.Values != BlankValue
- hasPathFilter := args.PathFilter != ""
- var pathFilter *regexp.Regexp
- if hasPathFilter {
- var err error
- pathFilter, err = regexp.Compile(args.PathFilter)
- if err != nil {
- return nil, err
- }
- }
- 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) {
- path := getPathName(entry)
- if offset != "" {
- path = NewPath(offset, path)
- }
- if isSort {
- switch args.Mode {
- case FindMode:
- if !strings.Contains(path, args.Criteria) {
- return
- }
- case PrefixMode:
- if !strings.HasPrefix(path, args.Criteria) {
- return
- }
- }
- } else {
- if args.Mode == ExactMode {
- if path != args.Criteria {
- return
- }
- }
- }
- if hasPathFilter {
- if !pathFilter.MatchString(path) {
- return
- }
- }
- obj := entity{backing: entry, path: path}
- if isSort && len(entities) > 0 {
- i, _ := slices.BinarySearchFunc(entities, obj, func(i, j entity) int {
- return strings.Compare(i.path, j.path)
- })
- entities = slices.Insert(entities, i, obj)
- } else {
- entities = append(entities, obj)
- }
- })
- if decrypt {
- return ctx.db.UnlockProtectedEntries()
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- jsonMode := output.JSONModes.Blank
- if args.Values == JSONValue {
- m, err := output.ParseJSONMode(config.EnvJSONMode.Get())
- if err != nil {
- return nil, err
- }
- jsonMode = m
- }
- jsonHasher := func(string) string {
- return ""
- }
- switch jsonMode {
- case output.JSONModes.Raw:
- jsonHasher = func(val string) string {
- return val
- }
- case output.JSONModes.Hash:
- hashLength, err := config.EnvJSONHashLength.Get()
- if err != nil {
- return nil, err
- }
- l := int(hashLength)
- jsonHasher = func(val string) string {
- data := fmt.Sprintf("%x", sha512.Sum512([]byte(val)))
- if hashLength > 0 && len(data) > l {
- data = data[0:hashLength]
- }
- return data
- }
- }
- return func(yield func(Entity, error) bool) {
- for _, item := range entities {
- entity := Entity{Path: item.path}
- var err error
- values := make(EntityValues)
- for _, v := range item.backing.Values {
- val := ""
- key := v.Key
- if args.Values != BlankValue {
- if args.Values == JSONValue {
- values["modtime"] = getValue(item.backing, modTimeKey)
- }
- val = v.Value.Content
- switch args.Values {
- case JSONValue:
- val = jsonHasher(val)
- }
- }
- if key == modTimeKey || key == titleKey {
- continue
- }
- values[strings.ToLower(key)] = val
- }
- entity.Values = values
- if !yield(entity, err) {
- return
- }
- }
- }, nil
-}
diff --git a/internal/backend/query_test.go b/internal/backend/query_test.go
@@ -1,376 +0,0 @@
-package backend_test
-
-import (
- "errors"
- "fmt"
- "strings"
- "testing"
-
- "git.sr.ht/~enckse/lockbox/internal/backend"
- "git.sr.ht/~enckse/lockbox/internal/config/store"
-)
-
-func compareEntity(actual *backend.Entity, expect backend.Entity) bool {
- if err := compareToEntity(actual, expect); err != nil {
- return false
- }
- return true
-}
-
-func compareToEntity(actual *backend.Entity, expect backend.Entity) error {
- if actual == nil || actual.Values == nil {
- return errors.New("invalid actual")
- }
- if actual.Path == "" || actual.Path != expect.Path {
- return errors.New("invalid actual, no path")
- }
- for k, v := range actual.Values {
- isMod := k == "modtime"
- if isMod {
- if len(v) < 20 {
- return fmt.Errorf("%s invalid mod time", k)
- }
- }
- e, ok := expect.Value(k)
- if !ok {
- if !isMod {
- return fmt.Errorf("%s is missing from expected", k)
- }
- }
- if e != v {
- if isMod {
- if e == "" {
- continue
- }
- }
- return fmt.Errorf("mismatch %s: (%s != %s)", k, e, v)
- }
- }
- return nil
-}
-
-func setupInserts(t *testing.T) {
- setup(t)
- fullSetup(t, true).Insert("test/test/abc", map[string]string{"password": "tedst", "notes": "xxx"})
- fullSetup(t, true).Insert("test/test/abcx", map[string]string{"password": "tedst"})
- fullSetup(t, true).Insert("test/test/ab11c", map[string]string{"password": "tedst", "notes": "tdest\ntest"})
- fullSetup(t, true).Insert("test/test/abc1ak", map[string]string{"password": "atest", "notes": "atest"})
-}
-
-func TestMatchPath(t *testing.T) {
- store.Clear()
- setupInserts(t)
- q, err := fullSetup(t, true).MatchPath("test/test/abc")
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if len(q) != 1 {
- t.Error("invalid entity result")
- }
- if q[0].Path != "test/test/abc" {
- t.Error("invalid query result")
- }
- for _, k := range []string{"notes", "password"} {
- if val, ok := q[0].Value(k); !ok || val != "" {
- t.Errorf("invalid result value: %s", k)
- }
- }
- q, err = fullSetup(t, true).MatchPath("test/test/abcxxx")
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if len(q) != 0 {
- t.Error("invalid entity result")
- }
- q, err = fullSetup(t, true).MatchPath("test/test/*")
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- 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)
- }
- if len(q) != 0 {
- t.Error("invalid entity result")
- }
-}
-
-func TestGet(t *testing.T) {
- setupInserts(t)
- q, err := fullSetup(t, true).Get("test/test/abc", backend.BlankValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if q.Path != "test/test/abc" {
- t.Error("invalid query result")
- }
- for _, k := range []string{"notes", "password"} {
- if val, ok := q.Value(k); !ok || val != "" {
- t.Errorf("invalid result value: %s", k)
- }
- }
- q, err = fullSetup(t, true).Get("a/b/aaaa", backend.BlankValue)
- if err != nil || q != nil {
- t.Error("invalid result, should be empty")
- }
- if _, err := fullSetup(t, true).Get("aaaa", backend.BlankValue); err.Error() != "input paths must contain at LEAST 2 components" {
- t.Errorf("invalid error: %v", err)
- }
-}
-
-func TestValueModes(t *testing.T) {
- store.Clear()
- setupInserts(t)
- q, err := fullSetup(t, true).Get("test/test/abc", backend.BlankValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- for _, k := range []string{"notes", "password"} {
- if val, ok := q.Value(k); !ok || val != "" {
- t.Errorf("invalid result value: %s", k)
- }
- }
- q, err = fullSetup(t, true).Get("test/test/abc", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/abc",
- Values: map[string]string{
- "notes": "9057ff1aa9509b2a0af624d687461d2bbeb07e2f37d953b1ce4a9dc921a7f19c45dc35d7c5363b373792add57d0d7dc41596e1c585d6ef7844cdf8ae87af443f",
- "password": "44276ba24db13df5568aa6db81e0190ab9d35d2168dce43dca61e628f5c666b1d8b091f1dda59c2359c86e7d393d59723a421d58496d279031e7f858c11d893e",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
- store.SetInt64("LOCKBOX_JSON_HASH_LENGTH", 10)
- q, err = fullSetup(t, true).Get("test/test/abc", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/abc",
- Values: map[string]string{
- "notes": "9057ff1aa9",
- "password": "44276ba24d",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
- q, err = fullSetup(t, true).Get("test/test/ab11c", backend.SecretValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/ab11c",
- Values: map[string]string{
- "notes": "tdest\ntest",
- "password": "tedst",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
- store.SetString("LOCKBOX_JSON_MODE", "plAINtExt")
- q, err = fullSetup(t, true).Get("test/test/abc", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/abc",
- Values: map[string]string{
- "notes": "xxx",
- "password": "tedst",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
- store.SetString("LOCKBOX_JSON_MODE", "emPTY")
- q, err = fullSetup(t, true).Get("test/test/abc", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/abc",
- Values: map[string]string{
- "notes": "",
- "password": "",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
-}
-
-func testCollect(t *testing.T, count int, seq backend.QuerySeq2) []backend.Entity {
- collected, err := seq.Collect()
- if err != nil {
- t.Errorf("invalid collect error: %v", err)
- }
- if len(collected) != count {
- t.Errorf("unexpected entity count: %d", count)
- }
- return collected
-}
-
-func TestQueryCallback(t *testing.T) {
- setupInserts(t)
- if _, err := fullSetup(t, true).QueryCallback(backend.QueryOptions{}); err.Error() != "no query mode specified" {
- t.Errorf("wrong error: %v", err)
- }
- seq, err := fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ListMode})
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- res := testCollect(t, 4, seq)
- 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)
- }
- seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.FindMode, Criteria: "1"})
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- res = testCollect(t, 2, seq)
- if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc1ak" {
- t.Errorf("invalid results: %v", res)
- }
- seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "test/test/abc"})
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- res = testCollect(t, 1, seq)
- if res[0].Path != "test/test/abc" {
- t.Errorf("invalid results: %v", res)
- }
- seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "test/test/abc", PathFilter: "abc"})
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- res = testCollect(t, 1, seq)
- if res[0].Path != "test/test/abc" {
- t.Errorf("invalid results: %v", res)
- }
- seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "test/test/abc", PathFilter: "abz"})
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- testCollect(t, 0, seq)
- seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "abczzz"})
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- testCollect(t, 0, seq)
-}
-
-func TestSetModTime(t *testing.T) {
- store.Clear()
- testDateTime := "2022-12-30T12:34:56-05:00"
- tr := fullSetup(t, false)
- store.SetString("LOCKBOX_DEFAULTS_MODTIME", testDateTime)
- tr.Insert("test/xyz", map[string]string{"password": "test"})
- q, err := fullSetup(t, true).Get("test/xyz", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/xyz",
- Values: map[string]string{
- "password": "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff",
- "modtime": testDateTime,
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
-
- store.Clear()
- tr = fullSetup(t, false)
- tr.Insert("test/xyz", map[string]string{"password": "test"})
- q, err = fullSetup(t, true).Get("test/xyz", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
-
- if val, ok := q.Value("modtime"); !ok || len(val) < 20 || val == testDateTime {
- t.Errorf("invalid mod: %s", val)
- }
-
- tr = fullSetup(t, false)
- store.SetString("LOCKBOX_DEFAULTS_MODTIME", "garbage")
- err = tr.Insert("test/xyz", map[string]string{"password": "test"})
- if err == nil || !strings.Contains(err.Error(), "parsing time") {
- t.Errorf("invalid error: %v", err)
- }
-}
-
-func TestAttributeModes(t *testing.T) {
- store.Clear()
- setupInserts(t)
- fullSetup(t, true).Insert("test/test/totp", map[string]string{"otp": "atest"})
- q, err := fullSetup(t, true).Get("test/test/totp", backend.BlankValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/totp",
- Values: map[string]string{
- "otp": "",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
- q, err = fullSetup(t, true).Get("test/test/totp", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/totp",
- Values: map[string]string{
- "otp": "7f8fd0e1a714f63da75206748d0ea1dd601fc8f92498bc87c9579b403c3004a0eefdd7ead976f7dbd6e5143c9aa7a569e24322d870ec7745a4605a154557458e",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
- store.SetInt64("LOCKBOX_JSON_HASH_LENGTH", 10)
- q, err = fullSetup(t, true).Get("test/test/totp", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/totp",
- Values: map[string]string{
- "otp": "7f8fd0e1a7",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
- store.SetString("LOCKBOX_JSON_MODE", "PlAINtExt")
- q, err = fullSetup(t, true).Get("test/test/totp", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/totp",
- Values: map[string]string{
- "otp": "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1&digits=6&issuer=lbissuer&period=30&secret=atest",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
- store.SetString("LOCKBOX_JSON_MODE", "emPty")
- q, err = fullSetup(t, true).Get("test/test/totp", backend.JSONValue)
- if err != nil {
- t.Errorf("no error: %v", err)
- }
- if !compareEntity(q, backend.Entity{
- Path: "test/test/totp",
- Values: map[string]string{
- "otp": "",
- },
- }) {
- t.Errorf("invalid entity: %v", q)
- }
-}
diff --git a/internal/kdbx/actions.go b/internal/kdbx/actions.go
@@ -0,0 +1,281 @@
+// Package backend handles kdbx interactions
+package kdbx
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "git.sr.ht/~enckse/lockbox/internal/config"
+ "git.sr.ht/~enckse/lockbox/internal/platform"
+ "github.com/tobischo/gokeepasslib/v3"
+)
+
+type (
+ action func(t Context) error
+)
+
+func (t *Transaction) act(cb action) error {
+ if !t.valid {
+ return errors.New("invalid transaction")
+ }
+ key, err := config.NewKey(config.DefaultKeyMode)
+ if err != nil {
+ return err
+ }
+ k, err := key.Read(platform.ReadInteractivePassword)
+ if err != nil {
+ return err
+ }
+ file := config.EnvKeyFile.Get()
+ if !t.exists {
+ if err := create(t.file, k, file); err != nil {
+ return err
+ }
+ }
+ f, err := os.Open(t.file)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ db := gokeepasslib.NewDatabase()
+ creds, err := getCredentials(k, file)
+ if err != nil {
+ return err
+ }
+ db.Credentials = creds
+ if err := gokeepasslib.NewDecoder(f).Decode(db); err != nil {
+ return err
+ }
+ if len(db.Content.Root.Groups) != 1 {
+ return errors.New("kdbx must have ONE root group")
+ }
+ err = cb(Context{db: db})
+ if err != nil {
+ return err
+ }
+ if t.write {
+ if err := db.LockProtectedEntries(); err != nil {
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ f, err = os.Create(t.file)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ return encode(f, db)
+ }
+ return err
+}
+
+// ReKey will change the credentials on a database
+func (t *Transaction) ReKey(pass, keyFile string) error {
+ creds, err := getCredentials(pass, keyFile)
+ if err != nil {
+ return err
+ }
+ return t.change(func(c Context) error {
+ c.db.Credentials = creds
+ return nil
+ })
+}
+
+func (t *Transaction) change(cb action) error {
+ if t.readonly {
+ return errors.New("unable to alter database in readonly mode")
+ }
+ return t.act(func(c Context) error {
+ if err := c.db.UnlockProtectedEntries(); err != nil {
+ return err
+ }
+ t.write = true
+ return cb(c)
+ })
+}
+
+func (c Context) alterEntities(isAdd bool, offset []string, title string, entity *gokeepasslib.Entry) bool {
+ g, e, ok := findAndDo(isAdd, title, 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 string) bool {
+ return c.alterEntities(false, offset, title, 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 && len(group.Entries) == 0 && len(group.Groups) == 0 {
+ continue
+ }
+ groups = append(groups, group)
+ }
+ g = groups
+ }
+ }
+ return g, e, done
+}
+
+// Move will move a src object to a dst location
+func (t *Transaction) Move(src *Entity, dst string) error {
+ if src == nil {
+ return errors.New("source entity is not set")
+ }
+ if strings.TrimSpace(src.Path) == "" {
+ return errors.New("empty path not allowed")
+ }
+ if len(src.Values) == 0 {
+ return errors.New("empty secrets not allowed")
+ }
+ values := make(map[string]string)
+ for k, v := range src.Values {
+ found := false
+ for _, mapping := range AllowedFields {
+ if strings.EqualFold(k, mapping) {
+ values[mapping] = v
+ found = true
+ break
+ }
+ }
+ if !found {
+ return fmt.Errorf("unknown entity field: %s", k)
+ }
+ }
+ mod := config.EnvDefaultModTime.Get()
+ modTime := time.Now()
+ if mod != "" {
+ p, err := time.Parse(config.ModTimeFormat, mod)
+ if err != nil {
+ return err
+ }
+ modTime = p
+ }
+ dOffset, dTitle, err := splitComponents(dst)
+ if err != nil {
+ return err
+ }
+ sOffset, sTitle, err := splitComponents(src.Path)
+ if err != nil {
+ return err
+ }
+ isMove := dst != src.Path
+ return t.change(func(c Context) error {
+ c.removeEntity(sOffset, sTitle)
+ if isMove {
+ c.removeEntity(dOffset, dTitle)
+ }
+ e := gokeepasslib.NewEntry()
+ e.Values = append(e.Values, value(titleKey, dTitle))
+ e.Values = append(e.Values, value(modTimeKey, modTime.Format(time.RFC3339)))
+ for k, v := range values {
+ val := v
+ switch k {
+ case OTPField, PasswordField:
+ if strings.Contains(val, "\n") {
+ return fmt.Errorf("%s can NOT be multi-line", strings.ToLower(k))
+ }
+ if k == OTPField {
+ val = config.EnvTOTPFormat.Get(v)
+ }
+ }
+ e.Values = append(e.Values, protectedValue(k, val))
+ }
+ c.alterEntities(true, dOffset, dTitle, &e)
+ return nil
+ })
+}
+
+// Insert is a move to the same location
+func (t *Transaction) Insert(path string, val EntityValues) error {
+ return t.Move(&Entity{Path: path, Values: val}, path)
+}
+
+// Remove will remove a single entity
+func (t *Transaction) Remove(entity *Entity) error {
+ if entity == nil {
+ return errors.New("entity is empty/invalid")
+ }
+ return t.RemoveAll([]Entity{*entity})
+}
+
+// RemoveAll handles removing elements
+func (t *Transaction) RemoveAll(entities []Entity) error {
+ if len(entities) == 0 {
+ return errors.New("no entities given")
+ }
+ type removal struct {
+ title string
+ parts []string
+ }
+ removals := []removal{}
+ for _, entity := range entities {
+ offset, title, err := splitComponents(entity.Path)
+ if err != nil {
+ return err
+ }
+ removals = append(removals, removal{parts: offset, title: title})
+ }
+ return t.change(func(c Context) error {
+ for _, entity := range removals {
+ if ok := c.removeEntity(entity.parts, entity.title); !ok {
+ return errors.New("failed to remove entity")
+ }
+ }
+ return nil
+ })
+}
diff --git a/internal/kdbx/actions_test.go b/internal/kdbx/actions_test.go
@@ -0,0 +1,337 @@
+package kdbx_test
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
+ "git.sr.ht/~enckse/lockbox/internal/config/store"
+ "git.sr.ht/~enckse/lockbox/internal/platform"
+)
+
+const (
+ testDir = "testdata"
+)
+
+func testFile(name string) string {
+ file := filepath.Join(testDir, name)
+ if !platform.PathExists(testDir) {
+ os.Mkdir(testDir, 0o755)
+ }
+ return file
+}
+
+func fullSetup(t *testing.T, keep bool) *kdbx.Transaction {
+ file := testFile("test.kdbx")
+ if !keep {
+ os.Remove(file)
+ }
+ store.SetBool("LOCKBOX_READONLY", false)
+ store.SetString("LOCKBOX_STORE", file)
+ store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"})
+ store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
+ store.SetString("LOCKBOX_TOTP_ENTRY", "totp")
+ tr, err := kdbx.NewTransaction()
+ if err != nil {
+ t.Errorf("failed: %v", err)
+ }
+ return tr
+}
+
+func TestKeyFile(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ file := testFile("keyfile_test.kdbx")
+ keyFile := testFile("file.key")
+ os.Remove(file)
+ store.SetString("LOCKBOX_STORE", file)
+ store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"})
+ store.SetString("LOCKBOX_CREDENTIALS_KEY_FILE", keyFile)
+ store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
+ store.SetString("LOCKBOX_TOTP_ENTRY", "totp")
+ os.WriteFile(keyFile, []byte("test"), 0o644)
+ tr, err := kdbx.NewTransaction()
+ if err != nil {
+ t.Errorf("failed: %v", err)
+ }
+ if err := tr.Insert(kdbx.NewPath("a", "b"), map[string]string{"password": "t"}); err != nil {
+ t.Errorf("no error: %v", err)
+ }
+}
+
+func setup(t *testing.T) *kdbx.Transaction {
+ return fullSetup(t, false)
+}
+
+func TestNoWriteOnRO(t *testing.T) {
+ setup(t)
+ store.SetBool("LOCKBOX_READONLY", true)
+ tr, _ := kdbx.NewTransaction()
+ if err := tr.Insert("a/a/a", map[string]string{"password": "xyz"}); err.Error() != "unable to alter database in readonly mode" {
+ t.Errorf("wrong error: %v", err)
+ }
+}
+
+func TestBadAction(t *testing.T) {
+ tr := &kdbx.Transaction{}
+ if err := tr.Insert("a/a/a", map[string]string{"notes": "xyz"}); err.Error() != "invalid transaction" {
+ t.Errorf("wrong error: %v", err)
+ }
+}
+
+func TestMove(t *testing.T) {
+ setup(t)
+ fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test1"), map[string]string{"passworD": "pass"})
+ fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test3"), map[string]string{"NoTES": "pass", "password": "xxx"})
+ if err := fullSetup(t, true).Move(nil, ""); err == nil || err.Error() != "source entity is not set" {
+ t.Errorf("no error: %v", err)
+ }
+ if err := fullSetup(t, true).Move(&kdbx.Entity{Path: kdbx.NewPath("test", "test2", "test3"), Values: map[string]string{"Notes": "abc"}}, kdbx.NewPath("test1", "test2", "test3")); err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ q, err := fullSetup(t, true).Get(kdbx.NewPath("test1", "test2", "test3"), kdbx.SecretValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if val, ok := q.Value("notes"); !ok || val != "abc" {
+ t.Errorf("invalid retrieval")
+ }
+ if err := fullSetup(t, true).Move(&kdbx.Entity{Path: kdbx.NewPath("test", "test2", "test1"), Values: map[string]string{"password": "test"}}, kdbx.NewPath("test1", "test2", "test3")); err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ q, err = fullSetup(t, true).Get(kdbx.NewPath("test1", "test2", "test3"), kdbx.SecretValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if val, ok := q.Value("password"); !ok || val != "test" {
+ t.Errorf("invalid retrieval")
+ }
+}
+
+func TestInserts(t *testing.T) {
+ if err := setup(t).Insert("", nil); err.Error() != "empty path not allowed" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Insert("a", map[string]string{"randomfield": "1"}); err.Error() != "unknown entity field: randomfield" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Insert("tests", map[string]string{"notes": "1"}); err.Error() != "input paths must contain at LEAST 2 components" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Insert("tests//l", map[string]string{"notes": "test"}); err.Error() != "unwilling to operate on path with empty segment" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Insert("tests/", map[string]string{"password": "test"}); err.Error() != "path can NOT end with separator" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Insert("/tests", map[string]string{"password": "test"}); err.Error() != "path can NOT be rooted" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Insert("test", map[string]string{"otp": "test"}); err.Error() != "input paths must contain at LEAST 2 components" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Insert("a", nil); err.Error() != "empty secrets not allowed" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Insert("a", make(map[string]string)); err.Error() != "empty secrets not allowed" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).Insert(kdbx.NewPath("test", "offset", "value"), map[string]string{"password": "pass"}); err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset", "value"), map[string]string{"NoTes": "pass2"}); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset", "value2"), map[string]string{"NOTES": "pass\npass", "password": "xxx", "otP": "zzz"}); err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ q, err := fullSetup(t, true).Get(kdbx.NewPath("test", "offset", "value"), kdbx.SecretValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if val, ok := q.Value("notes"); !ok || val != "pass2" {
+ t.Errorf("invalid retrieval")
+ }
+ q, err = fullSetup(t, true).Get(kdbx.NewPath("test", "offset", "value2"), kdbx.SecretValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if val, ok := q.Value("notes"); !ok || val != "pass\npass" {
+ t.Errorf("invalid retrieval: %s", val)
+ }
+ if val, ok := q.Value("password"); !ok || val != "xxx" {
+ t.Errorf("invalid retrieval: %s", val)
+ }
+ if val, ok := q.Value("otp"); !ok || val != "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1&digits=6&issuer=lbissuer&period=30&secret=zzz" {
+ t.Errorf("invalid retrieval: %s", val)
+ }
+ if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset"), map[string]string{"otp": "5ae472sabqdekjqykoyxk7hvc2leklq5n"}); err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset"), map[string]string{"OTP": "ljaf\n5ae472abqdekjqykoyxk7hvc2leklq5n"}); err == nil || err.Error() != "otp can NOT be multi-line" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset"), map[string]string{"password": "ljaf\n5ae472abqdekjqykoyxk7hvc2leklq5n"}); err == nil || err.Error() != "password can NOT be multi-line" {
+ t.Errorf("wrong error: %v", err)
+ }
+}
+
+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(&kdbx.Entity{}); err.Error() != "input paths must contain at LEAST 2 components" {
+ t.Errorf("wrong error: %v", err)
+ }
+ tx := kdbx.Entity{Path: kdbx.NewPath("test1", "test2", "test3")}
+ if err := setup(t).Remove(&tx); err.Error() != "failed to remove entity" {
+ t.Errorf("wrong error: %v", err)
+ }
+ setup(t)
+ for _, i := range []string{"test1", "test2"} {
+ fullSetup(t, true).Insert(kdbx.NewPath(i, i, i), map[string]string{"PASSWORD": "pass"})
+ }
+ tx = kdbx.Entity{Path: kdbx.NewPath("test1", "test1", "test1")}
+ if err := fullSetup(t, true).Remove(&tx); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := check(t, kdbx.NewPath("test2", "test2", "test2")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ tx = kdbx.Entity{Path: kdbx.NewPath("test2", "test2", "test2")}
+ if err := fullSetup(t, true).Remove(&tx); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ setup(t)
+ for _, i := range []string{kdbx.NewPath("test", "test", "test1"), kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test", "test3"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")} {
+ fullSetup(t, true).Insert(i, map[string]string{"password": "pass"})
+ }
+ tx = kdbx.Entity{Path: "test/test/test3"}
+ if err := fullSetup(t, true).Remove(&tx); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := check(t, kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test", "test1"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ tx = kdbx.Entity{Path: "test/test/test1"}
+ if err := fullSetup(t, true).Remove(&tx); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := check(t, kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ tx = kdbx.Entity{Path: "test/test1/test5"}
+ if err := fullSetup(t, true).Remove(&tx); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := check(t, kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test1", "test2")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ tx = kdbx.Entity{Path: "test/test1/test2"}
+ if err := fullSetup(t, true).Remove(&tx); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := check(t, kdbx.NewPath("test", "test", "test2")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+ tx = kdbx.Entity{Path: "test/test/test2"}
+ if err := fullSetup(t, true).Remove(&tx); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+}
+
+func TestRemoveAlls(t *testing.T) {
+ if err := setup(t).RemoveAll(nil); err.Error() != "no entities given" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := setup(t).RemoveAll([]kdbx.Entity{}); err.Error() != "no entities given" {
+ t.Errorf("wrong error: %v", err)
+ }
+ setup(t)
+ for _, i := range []string{kdbx.NewPath("test", "test", "test1"), kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test", "test3"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")} {
+ fullSetup(t, true).Insert(i, map[string]string{"PaSsWoRd": "pass"})
+ }
+ if err := fullSetup(t, true).RemoveAll([]kdbx.Entity{{Path: "test/test/test3"}, {Path: "test/test/test1"}}); err != nil {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := check(t, kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")); err != nil {
+ t.Errorf("invalid check: %v", err)
+ }
+}
+
+func check(t *testing.T, checks ...string) error {
+ tr := fullSetup(t, true)
+ for _, c := range checks {
+ q, err := tr.Get(c, kdbx.BlankValue)
+ if err != nil {
+ return err
+ }
+ if q == nil {
+ return fmt.Errorf("failed to find entity: %s", c)
+ }
+ }
+ return nil
+}
+
+func TestKeyAndOrKeyFile(t *testing.T) {
+ keyAndOrKeyFile(t, true, true)
+ keyAndOrKeyFile(t, false, true)
+ keyAndOrKeyFile(t, true, false)
+ keyAndOrKeyFile(t, false, false)
+}
+
+func keyAndOrKeyFile(t *testing.T, key, keyFile bool) {
+ store.Clear()
+ file := testFile("keyorkeyfile.kdbx")
+ os.Remove(file)
+ store.SetString("LOCKBOX_STORE", file)
+ if key {
+ store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"})
+ store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
+ } else {
+ store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "none")
+ }
+ if keyFile {
+ key := testFile("keyfileor.key")
+ store.SetString("LOCKBOX_CREDENTIALS_KEY_FILE", key)
+ os.WriteFile(key, []byte("test"), 0o644)
+ }
+ tr, err := kdbx.NewTransaction()
+ if err != nil {
+ t.Errorf("failed: %v", err)
+ }
+ invalid := !key && !keyFile
+ err = tr.Insert(kdbx.NewPath("a", "b"), map[string]string{"password": "t"})
+ if invalid {
+ if err == nil || err.Error() != "key and/or keyfile must be set" {
+ t.Errorf("invalid error: %v", err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("no error allowed: %v", err)
+ }
+ }
+}
+
+func TestReKey(t *testing.T) {
+ store.Clear()
+ f := "rekey_test.kdbx"
+ file := testFile(f)
+ defer os.Remove(filepath.Join(testDir, f))
+ store.SetString("LOCKBOX_STORE", file)
+ store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"})
+ store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext")
+ store.SetString("LOCKBOX_TOTP_ENTRY", "totp")
+ tr, err := kdbx.NewTransaction()
+ if err != nil {
+ t.Errorf("failed: %v", err)
+ }
+ if err := tr.ReKey("", ""); err == nil || err.Error() != "key and/or keyfile must be set" {
+ t.Errorf("no error: %v", err)
+ }
+ if err := tr.ReKey("abc", ""); err != nil {
+ t.Errorf("no error: %v", err)
+ }
+}
diff --git a/internal/kdbx/core.go b/internal/kdbx/core.go
@@ -0,0 +1,228 @@
+// Package backend handles kdbx interactions
+package kdbx
+
+import (
+ "errors"
+ "fmt"
+ "iter"
+ "os"
+ "strings"
+
+ "git.sr.ht/~enckse/lockbox/internal/config"
+ "git.sr.ht/~enckse/lockbox/internal/platform"
+ "github.com/tobischo/gokeepasslib/v3"
+ "github.com/tobischo/gokeepasslib/v3/wrappers"
+)
+
+var (
+ errPath = errors.New("input paths must contain at LEAST 2 components")
+ // AllowedFields are the same of allowed names for storing in a kdbx entry
+ AllowedFields = []string{NotesField, OTPField, PasswordField}
+)
+
+const (
+ titleKey = "Title"
+ pathSep = "/"
+ isGlob = pathSep + "*"
+ modTimeKey = "ModTime"
+ // OTPField is the totp storage attribute
+ OTPField = "otp"
+ // NotesField is the multiline notes key
+ NotesField = "Notes"
+ // PasswordField is where the password is stored
+ PasswordField = "Password"
+)
+
+type (
+ // EntityValues are what is stored, from an entity, into kdbx backing store
+ EntityValues map[string]string
+ // QuerySeq2 wraps the iteration for query entities
+ QuerySeq2 iter.Seq2[Entity, error]
+ // Transaction handles the overall operation of the transaction
+ Transaction struct {
+ file string
+ valid bool
+ exists bool
+ write bool
+ readonly bool
+ }
+ // Context handles operating on the underlying database
+ Context struct {
+ db *gokeepasslib.Database
+ }
+ // Entity are database objects from results and transactional changes
+ Entity struct {
+ Values EntityValues
+ Path string
+ }
+)
+
+// Load will load a kdbx file for transactions
+func Load(file string) (*Transaction, error) {
+ return loadFile(file, true)
+}
+
+func loadFile(file string, must bool) (*Transaction, error) {
+ if strings.TrimSpace(file) == "" {
+ return nil, errors.New("no store set")
+ }
+ if !strings.HasSuffix(file, ".kdbx") {
+ return nil, errors.New("should use a .kdbx extension")
+ }
+ exists := platform.PathExists(file)
+ if must {
+ if !exists {
+ return nil, errors.New("invalid file, does not exist")
+ }
+ }
+ ro := config.EnvReadOnly.Get()
+ return &Transaction{valid: true, file: file, exists: exists, readonly: ro}, nil
+}
+
+// NewTransaction will use the underlying environment data store location
+func NewTransaction() (*Transaction, error) {
+ return loadFile(config.EnvStore.Get(), false)
+}
+
+func splitComponents(path string) ([]string, string, error) {
+ if len(strings.Split(path, pathSep)) < 2 {
+ return nil, "", errPath
+ }
+ if strings.HasPrefix(path, pathSep) {
+ return nil, "", errors.New("path can NOT be rooted")
+ }
+ if strings.HasSuffix(path, pathSep) {
+ return nil, "", errors.New("path can NOT end with separator")
+ }
+ if strings.Contains(path, pathSep+pathSep) {
+ return nil, "", errors.New("unwilling to operate on path with empty segment")
+ }
+ title := Base(path)
+ parts := strings.Split(Directory(path), pathSep)
+ return parts, title, nil
+}
+
+func getCredentials(key, keyFile string) (*gokeepasslib.DBCredentials, error) {
+ hasKey := len(key) > 0
+ hasKeyFile := len(keyFile) > 0
+ if !hasKey && !hasKeyFile {
+ return nil, errors.New("key and/or keyfile must be set")
+ }
+ if hasKeyFile {
+ if !platform.PathExists(keyFile) {
+ return nil, errors.New("no keyfile found on disk")
+ }
+ if !hasKey {
+ return gokeepasslib.NewKeyCredentials(keyFile)
+ }
+ return gokeepasslib.NewPasswordAndKeyCredentials(key, keyFile)
+ }
+ return gokeepasslib.NewPasswordCredentials(key), nil
+}
+
+func create(file, key, keyFile string) error {
+ root := gokeepasslib.NewGroup()
+ root.Name = "root"
+ db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
+ creds, err := getCredentials(key, keyFile)
+ if err != nil {
+ return err
+ }
+ db.Credentials = creds
+ db.Content.Root = &gokeepasslib.RootData{
+ Groups: []gokeepasslib.Group{root},
+ }
+ if err := db.LockProtectedEntries(); err != nil {
+ return err
+ }
+
+ f, err := os.Create(file)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ return encode(f, db)
+}
+
+func encode(f *os.File, db *gokeepasslib.Database) error {
+ return gokeepasslib.NewEncoder(f).Encode(db)
+}
+
+func getPathName(entry gokeepasslib.Entry) string {
+ return entry.GetTitle()
+}
+
+func value(key, value string) gokeepasslib.ValueData {
+ return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: value}}
+}
+
+func protectedValue(key, value string) gokeepasslib.ValueData {
+ return gokeepasslib.ValueData{
+ Key: key,
+ Value: gokeepasslib.V{Content: value, Protected: wrappers.NewBoolWrapper(true)},
+ }
+}
+
+// NewSuffix creates a new user 'name' suffix
+func NewSuffix(name string) string {
+ return fmt.Sprintf("%s%s", pathSep, name)
+}
+
+// NewPath creates a new storage location path.
+func NewPath(segments ...string) string {
+ return strings.Join(segments, pathSep)
+}
+
+// Value will read an entity value
+func (e Entity) Value(key string) (string, bool) {
+ if e.Values == nil {
+ return "", false
+ }
+ val, ok := e.Values[key]
+ return val, ok
+}
+
+// Base will get the base name of input path
+func Base(s string) string {
+ parts := strings.Split(s, pathSep)
+ if len(parts) == 0 {
+ return s
+ }
+ return parts[len(parts)-1]
+}
+
+// Directory will get the directory/group for the given path
+func Directory(s string) string {
+ parts := strings.Split(s, pathSep)
+ return NewPath(parts[0 : len(parts)-1]...)
+}
+
+func getValue(e gokeepasslib.Entry, key string) string {
+ v := e.Get(key)
+ if v == nil {
+ return ""
+ }
+ return v.Value.Content
+}
+
+// IsDirectory will indicate if a path looks like a group/directory
+func IsDirectory(path string) bool {
+ return strings.HasSuffix(path, pathSep)
+}
+
+// IsLeafAttribute indicates if a path is leaved with a certain name
+func IsLeafAttribute(path, attr string) bool {
+ return strings.HasSuffix(path, pathSep+attr)
+}
+
+// Collect will create a slice from an iterable set of query sequence results
+func (s QuerySeq2) Collect() ([]Entity, error) {
+ var entities []Entity
+ for entity, err := range s {
+ if err != nil {
+ return nil, err
+ }
+ entities = append(entities, entity)
+ }
+ return entities, nil
+}
diff --git a/internal/kdbx/core_test.go b/internal/kdbx/core_test.go
@@ -0,0 +1,155 @@
+package kdbx_test
+
+import (
+ "errors"
+ "testing"
+
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
+)
+
+func TestLoad(t *testing.T) {
+ if _, err := kdbx.Load(" "); err.Error() != "no store set" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := kdbx.Load("garbage"); err.Error() != "should use a .kdbx extension" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := kdbx.Load("garbage.kdbx"); err.Error() != "invalid file, does not exist" {
+ t.Errorf("invalid error: %v", err)
+ }
+}
+
+func TestIsDirectory(t *testing.T) {
+ if kdbx.IsDirectory("") {
+ t.Error("invalid directory detection")
+ }
+ if !kdbx.IsDirectory("/") {
+ t.Error("invalid directory detection")
+ }
+ if kdbx.IsDirectory("/a") {
+ t.Error("invalid directory detection")
+ }
+}
+
+func TestBase(t *testing.T) {
+ b := kdbx.Base("")
+ if b != "" {
+ t.Error("invalid base")
+ }
+ b = kdbx.Base("aaa")
+ if b != "aaa" {
+ t.Error("invalid base")
+ }
+ b = kdbx.Base("aaa/")
+ if b != "" {
+ t.Error("invalid base")
+ }
+ b = kdbx.Base("aaa/a")
+ if b != "a" {
+ t.Error("invalid base")
+ }
+}
+
+func TestDirectory(t *testing.T) {
+ b := kdbx.Directory("")
+ if b != "" {
+ t.Error("invalid directory")
+ }
+ b = kdbx.Directory("/")
+ if b != "" {
+ t.Error("invalid directory")
+ }
+ b = kdbx.Directory("/a")
+ if b != "" {
+ t.Error("invalid directory")
+ }
+ b = kdbx.Directory("a")
+ if b != "" {
+ t.Error("invalid directory")
+ }
+ b = kdbx.Directory("b/a")
+ if b != "b" {
+ t.Error("invalid directory")
+ }
+}
+
+func TestIsLeafAttr(t *testing.T) {
+ if kdbx.IsLeafAttribute("axyz", "z") {
+ t.Error("invalid result")
+ }
+ if !kdbx.IsLeafAttribute("axy/z", "z") {
+ t.Error("invalid result")
+ }
+}
+
+func TestNewPath(t *testing.T) {
+ p := kdbx.NewPath("abc", "xyz")
+ if p != kdbx.NewPath("abc", "xyz") {
+ t.Error("invalid new path")
+ }
+}
+
+func TestNewSuffix(t *testing.T) {
+ if kdbx.NewSuffix("test") != "/test" {
+ t.Error("invalid suffix")
+ }
+}
+
+func generateTestSeq(hasError, extra bool) kdbx.QuerySeq2 {
+ return func(yield func(kdbx.Entity, error) bool) {
+ if !yield(kdbx.Entity{}, nil) {
+ return
+ }
+ if !yield(kdbx.Entity{}, nil) {
+ return
+ }
+ if hasError {
+ if !yield(kdbx.Entity{}, errors.New("test collect error")) {
+ return
+ }
+ }
+ if !yield(kdbx.Entity{}, nil) {
+ return
+ }
+ if extra {
+ if !yield(kdbx.Entity{}, nil) {
+ return
+ }
+ }
+ }
+}
+
+func TestQuerySeq2Collect(t *testing.T) {
+ seq := generateTestSeq(true, true)
+ if _, err := seq.Collect(); err == nil || err.Error() != "test collect error" {
+ t.Errorf("invalid error: %v", err)
+ }
+ seq = generateTestSeq(false, false)
+ c, err := seq.Collect()
+ if err != nil || len(c) != 3 {
+ t.Errorf("invalid collect: %v %v %d", c, err, len(c))
+ }
+ seq = generateTestSeq(false, true)
+ c, err = seq.Collect()
+ if err != nil || len(c) != 4 {
+ t.Errorf("invalid collect: %v %v %d", c, err, len(c))
+ }
+}
+
+func TestEntityValue(t *testing.T) {
+ e := kdbx.Entity{}
+ if _, ok := e.Value("key"); ok {
+ t.Error("values are nil")
+ }
+ e.Values = make(map[string]string)
+ if _, ok := e.Value("key"); ok {
+ t.Error("values are not set")
+ }
+ e.Values["key2"] = "1"
+ if _, ok := e.Value("key"); ok {
+ t.Error("values are not matching")
+ }
+ if val, ok := e.Value("key2"); !ok || val != "1" {
+ t.Error("values are not set")
+ }
+}
diff --git a/internal/kdbx/query.go b/internal/kdbx/query.go
@@ -0,0 +1,241 @@
+// Package backend handles querying a store
+package kdbx
+
+import (
+ "crypto/sha512"
+ "errors"
+ "fmt"
+ "regexp"
+ "slices"
+ "strings"
+
+ "git.sr.ht/~enckse/lockbox/internal/config"
+ "git.sr.ht/~enckse/lockbox/internal/output"
+ "github.com/tobischo/gokeepasslib/v3"
+)
+
+type (
+ // QueryOptions indicates how to find entities
+ QueryOptions struct {
+ PathFilter string
+ Criteria string
+ Mode QueryMode
+ Values ValueMode
+ }
+ // QueryMode indicates HOW an entity will be found
+ QueryMode int
+ // ValueMode indicates what to do with the store value of the entity
+ ValueMode int
+)
+
+const (
+ // BlankValue will not decrypt secrets, empty value
+ BlankValue ValueMode = iota
+ // SecretValue will have the raw secret onboard
+ SecretValue
+ // JSONValue will show entries as a JSON payload
+ JSONValue
+)
+
+const (
+ noneMode QueryMode = iota
+ // ListMode indicates ALL entities will be listed
+ ListMode
+ // FindMode indicates a _contains_ search for an entity
+ FindMode
+ // ExactMode means an entity must MATCH the string exactly
+ ExactMode
+ // PrefixMode allows for entities starting with a specific value
+ PrefixMode
+)
+
+// 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})
+}
+
+// Get will request a singular entity
+func (t *Transaction) Get(path string, mode ValueMode) (*Entity, error) {
+ _, _, err := splitComponents(path)
+ if err != nil {
+ return nil, err
+ }
+ e, err := t.queryCollect(QueryOptions{Mode: ExactMode, Criteria: path, Values: mode})
+ if err != nil {
+ return nil, err
+ }
+ switch len(e) {
+ case 0:
+ return nil, nil
+ case 1:
+ return &e[0], nil
+ default:
+ return nil, errors.New("too many entities matched")
+ }
+}
+
+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 = NewPath(offset, g.Name)
+ }
+ forEach(o, g.Groups, g.Entries, cb)
+ }
+ for _, e := range entries {
+ cb(offset, e)
+ }
+}
+
+func (t *Transaction) queryCollect(args QueryOptions) ([]Entity, error) {
+ e, err := t.QueryCallback(args)
+ if err != nil {
+ return nil, err
+ }
+ return e.Collect()
+}
+
+// QueryCallback will retrieve a query based on setting
+func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) {
+ if args.Mode == noneMode {
+ return nil, errors.New("no query mode specified")
+ }
+ type entity struct {
+ path string
+ backing gokeepasslib.Entry
+ }
+ var entities []entity
+ isSort := args.Mode != ExactMode
+ decrypt := args.Values != BlankValue
+ hasPathFilter := args.PathFilter != ""
+ var pathFilter *regexp.Regexp
+ if hasPathFilter {
+ var err error
+ pathFilter, err = regexp.Compile(args.PathFilter)
+ if err != nil {
+ return nil, err
+ }
+ }
+ 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) {
+ path := getPathName(entry)
+ if offset != "" {
+ path = NewPath(offset, path)
+ }
+ if isSort {
+ switch args.Mode {
+ case FindMode:
+ if !strings.Contains(path, args.Criteria) {
+ return
+ }
+ case PrefixMode:
+ if !strings.HasPrefix(path, args.Criteria) {
+ return
+ }
+ }
+ } else {
+ if args.Mode == ExactMode {
+ if path != args.Criteria {
+ return
+ }
+ }
+ }
+ if hasPathFilter {
+ if !pathFilter.MatchString(path) {
+ return
+ }
+ }
+ obj := entity{backing: entry, path: path}
+ if isSort && len(entities) > 0 {
+ i, _ := slices.BinarySearchFunc(entities, obj, func(i, j entity) int {
+ return strings.Compare(i.path, j.path)
+ })
+ entities = slices.Insert(entities, i, obj)
+ } else {
+ entities = append(entities, obj)
+ }
+ })
+ if decrypt {
+ return ctx.db.UnlockProtectedEntries()
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ jsonMode := output.JSONModes.Blank
+ if args.Values == JSONValue {
+ m, err := output.ParseJSONMode(config.EnvJSONMode.Get())
+ if err != nil {
+ return nil, err
+ }
+ jsonMode = m
+ }
+ jsonHasher := func(string) string {
+ return ""
+ }
+ switch jsonMode {
+ case output.JSONModes.Raw:
+ jsonHasher = func(val string) string {
+ return val
+ }
+ case output.JSONModes.Hash:
+ hashLength, err := config.EnvJSONHashLength.Get()
+ if err != nil {
+ return nil, err
+ }
+ l := int(hashLength)
+ jsonHasher = func(val string) string {
+ data := fmt.Sprintf("%x", sha512.Sum512([]byte(val)))
+ if hashLength > 0 && len(data) > l {
+ data = data[0:hashLength]
+ }
+ return data
+ }
+ }
+ return func(yield func(Entity, error) bool) {
+ for _, item := range entities {
+ entity := Entity{Path: item.path}
+ var err error
+ values := make(EntityValues)
+ for _, v := range item.backing.Values {
+ val := ""
+ key := v.Key
+ if args.Values != BlankValue {
+ if args.Values == JSONValue {
+ values["modtime"] = getValue(item.backing, modTimeKey)
+ }
+ val = v.Value.Content
+ switch args.Values {
+ case JSONValue:
+ val = jsonHasher(val)
+ }
+ }
+ if key == modTimeKey || key == titleKey {
+ continue
+ }
+ values[strings.ToLower(key)] = val
+ }
+ entity.Values = values
+ if !yield(entity, err) {
+ return
+ }
+ }
+ }, nil
+}
diff --git a/internal/kdbx/query_test.go b/internal/kdbx/query_test.go
@@ -0,0 +1,376 @@
+package kdbx_test
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~enckse/lockbox/internal/kdbx"
+ "git.sr.ht/~enckse/lockbox/internal/config/store"
+)
+
+func compareEntity(actual *kdbx.Entity, expect kdbx.Entity) bool {
+ if err := compareToEntity(actual, expect); err != nil {
+ return false
+ }
+ return true
+}
+
+func compareToEntity(actual *kdbx.Entity, expect kdbx.Entity) error {
+ if actual == nil || actual.Values == nil {
+ return errors.New("invalid actual")
+ }
+ if actual.Path == "" || actual.Path != expect.Path {
+ return errors.New("invalid actual, no path")
+ }
+ for k, v := range actual.Values {
+ isMod := k == "modtime"
+ if isMod {
+ if len(v) < 20 {
+ return fmt.Errorf("%s invalid mod time", k)
+ }
+ }
+ e, ok := expect.Value(k)
+ if !ok {
+ if !isMod {
+ return fmt.Errorf("%s is missing from expected", k)
+ }
+ }
+ if e != v {
+ if isMod {
+ if e == "" {
+ continue
+ }
+ }
+ return fmt.Errorf("mismatch %s: (%s != %s)", k, e, v)
+ }
+ }
+ return nil
+}
+
+func setupInserts(t *testing.T) {
+ setup(t)
+ fullSetup(t, true).Insert("test/test/abc", map[string]string{"password": "tedst", "notes": "xxx"})
+ fullSetup(t, true).Insert("test/test/abcx", map[string]string{"password": "tedst"})
+ fullSetup(t, true).Insert("test/test/ab11c", map[string]string{"password": "tedst", "notes": "tdest\ntest"})
+ fullSetup(t, true).Insert("test/test/abc1ak", map[string]string{"password": "atest", "notes": "atest"})
+}
+
+func TestMatchPath(t *testing.T) {
+ store.Clear()
+ setupInserts(t)
+ q, err := fullSetup(t, true).MatchPath("test/test/abc")
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if len(q) != 1 {
+ t.Error("invalid entity result")
+ }
+ if q[0].Path != "test/test/abc" {
+ t.Error("invalid query result")
+ }
+ for _, k := range []string{"notes", "password"} {
+ if val, ok := q[0].Value(k); !ok || val != "" {
+ t.Errorf("invalid result value: %s", k)
+ }
+ }
+ q, err = fullSetup(t, true).MatchPath("test/test/abcxxx")
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if len(q) != 0 {
+ t.Error("invalid entity result")
+ }
+ q, err = fullSetup(t, true).MatchPath("test/test/*")
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ 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)
+ }
+ if len(q) != 0 {
+ t.Error("invalid entity result")
+ }
+}
+
+func TestGet(t *testing.T) {
+ setupInserts(t)
+ q, err := fullSetup(t, true).Get("test/test/abc", kdbx.BlankValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if q.Path != "test/test/abc" {
+ t.Error("invalid query result")
+ }
+ for _, k := range []string{"notes", "password"} {
+ if val, ok := q.Value(k); !ok || val != "" {
+ t.Errorf("invalid result value: %s", k)
+ }
+ }
+ q, err = fullSetup(t, true).Get("a/b/aaaa", kdbx.BlankValue)
+ if err != nil || q != nil {
+ t.Error("invalid result, should be empty")
+ }
+ if _, err := fullSetup(t, true).Get("aaaa", kdbx.BlankValue); err.Error() != "input paths must contain at LEAST 2 components" {
+ t.Errorf("invalid error: %v", err)
+ }
+}
+
+func TestValueModes(t *testing.T) {
+ store.Clear()
+ setupInserts(t)
+ q, err := fullSetup(t, true).Get("test/test/abc", kdbx.BlankValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ for _, k := range []string{"notes", "password"} {
+ if val, ok := q.Value(k); !ok || val != "" {
+ t.Errorf("invalid result value: %s", k)
+ }
+ }
+ q, err = fullSetup(t, true).Get("test/test/abc", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/abc",
+ Values: map[string]string{
+ "notes": "9057ff1aa9509b2a0af624d687461d2bbeb07e2f37d953b1ce4a9dc921a7f19c45dc35d7c5363b373792add57d0d7dc41596e1c585d6ef7844cdf8ae87af443f",
+ "password": "44276ba24db13df5568aa6db81e0190ab9d35d2168dce43dca61e628f5c666b1d8b091f1dda59c2359c86e7d393d59723a421d58496d279031e7f858c11d893e",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+ store.SetInt64("LOCKBOX_JSON_HASH_LENGTH", 10)
+ q, err = fullSetup(t, true).Get("test/test/abc", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/abc",
+ Values: map[string]string{
+ "notes": "9057ff1aa9",
+ "password": "44276ba24d",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+ q, err = fullSetup(t, true).Get("test/test/ab11c", kdbx.SecretValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/ab11c",
+ Values: map[string]string{
+ "notes": "tdest\ntest",
+ "password": "tedst",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+ store.SetString("LOCKBOX_JSON_MODE", "plAINtExt")
+ q, err = fullSetup(t, true).Get("test/test/abc", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/abc",
+ Values: map[string]string{
+ "notes": "xxx",
+ "password": "tedst",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+ store.SetString("LOCKBOX_JSON_MODE", "emPTY")
+ q, err = fullSetup(t, true).Get("test/test/abc", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/abc",
+ Values: map[string]string{
+ "notes": "",
+ "password": "",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+}
+
+func testCollect(t *testing.T, count int, seq kdbx.QuerySeq2) []kdbx.Entity {
+ collected, err := seq.Collect()
+ if err != nil {
+ t.Errorf("invalid collect error: %v", err)
+ }
+ if len(collected) != count {
+ t.Errorf("unexpected entity count: %d", count)
+ }
+ return collected
+}
+
+func TestQueryCallback(t *testing.T) {
+ setupInserts(t)
+ if _, err := fullSetup(t, true).QueryCallback(kdbx.QueryOptions{}); err.Error() != "no query mode specified" {
+ t.Errorf("wrong error: %v", err)
+ }
+ seq, err := fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ListMode})
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ res := testCollect(t, 4, seq)
+ 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)
+ }
+ seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.FindMode, Criteria: "1"})
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ res = testCollect(t, 2, seq)
+ if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc1ak" {
+ t.Errorf("invalid results: %v", res)
+ }
+ seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ExactMode, Criteria: "test/test/abc"})
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ res = testCollect(t, 1, seq)
+ if res[0].Path != "test/test/abc" {
+ t.Errorf("invalid results: %v", res)
+ }
+ seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ExactMode, Criteria: "test/test/abc", PathFilter: "abc"})
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ res = testCollect(t, 1, seq)
+ if res[0].Path != "test/test/abc" {
+ t.Errorf("invalid results: %v", res)
+ }
+ seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ExactMode, Criteria: "test/test/abc", PathFilter: "abz"})
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ testCollect(t, 0, seq)
+ seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ExactMode, Criteria: "abczzz"})
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ testCollect(t, 0, seq)
+}
+
+func TestSetModTime(t *testing.T) {
+ store.Clear()
+ testDateTime := "2022-12-30T12:34:56-05:00"
+ tr := fullSetup(t, false)
+ store.SetString("LOCKBOX_DEFAULTS_MODTIME", testDateTime)
+ tr.Insert("test/xyz", map[string]string{"password": "test"})
+ q, err := fullSetup(t, true).Get("test/xyz", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/xyz",
+ Values: map[string]string{
+ "password": "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff",
+ "modtime": testDateTime,
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+
+ store.Clear()
+ tr = fullSetup(t, false)
+ tr.Insert("test/xyz", map[string]string{"password": "test"})
+ q, err = fullSetup(t, true).Get("test/xyz", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+
+ if val, ok := q.Value("modtime"); !ok || len(val) < 20 || val == testDateTime {
+ t.Errorf("invalid mod: %s", val)
+ }
+
+ tr = fullSetup(t, false)
+ store.SetString("LOCKBOX_DEFAULTS_MODTIME", "garbage")
+ err = tr.Insert("test/xyz", map[string]string{"password": "test"})
+ if err == nil || !strings.Contains(err.Error(), "parsing time") {
+ t.Errorf("invalid error: %v", err)
+ }
+}
+
+func TestAttributeModes(t *testing.T) {
+ store.Clear()
+ setupInserts(t)
+ fullSetup(t, true).Insert("test/test/totp", map[string]string{"otp": "atest"})
+ q, err := fullSetup(t, true).Get("test/test/totp", kdbx.BlankValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/totp",
+ Values: map[string]string{
+ "otp": "",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+ q, err = fullSetup(t, true).Get("test/test/totp", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/totp",
+ Values: map[string]string{
+ "otp": "7f8fd0e1a714f63da75206748d0ea1dd601fc8f92498bc87c9579b403c3004a0eefdd7ead976f7dbd6e5143c9aa7a569e24322d870ec7745a4605a154557458e",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+ store.SetInt64("LOCKBOX_JSON_HASH_LENGTH", 10)
+ q, err = fullSetup(t, true).Get("test/test/totp", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/totp",
+ Values: map[string]string{
+ "otp": "7f8fd0e1a7",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+ store.SetString("LOCKBOX_JSON_MODE", "PlAINtExt")
+ q, err = fullSetup(t, true).Get("test/test/totp", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/totp",
+ Values: map[string]string{
+ "otp": "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1&digits=6&issuer=lbissuer&period=30&secret=atest",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+ store.SetString("LOCKBOX_JSON_MODE", "emPty")
+ q, err = fullSetup(t, true).Get("test/test/totp", kdbx.JSONValue)
+ if err != nil {
+ t.Errorf("no error: %v", err)
+ }
+ if !compareEntity(q, kdbx.Entity{
+ Path: "test/test/totp",
+ Values: map[string]string{
+ "otp": "",
+ },
+ }) {
+ t.Errorf("invalid entity: %v", q)
+ }
+}