lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 241f959b3a6eb571b9e80448eee02f48c27f37c5
parent cb0c458a294196bfe84f5312361ded8c9da50d56
Author: Sean Enck <sean@ttypty.com>
Date:   Sat, 26 Jul 2025 13:22:09 -0400

requesting moves now does this all as a single action

Diffstat:
Minternal/app/move.go | 29+++++++++++++++++------------
Minternal/kdbx/actions.go | 133+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Minternal/kdbx/actions_test.go | 9++++++---
3 files changed, 106 insertions(+), 65 deletions(-)

diff --git a/internal/app/move.go b/internal/app/move.go @@ -48,7 +48,7 @@ func Move(cmd CommandOptions) error { return fmt.Errorf("multiple moves can only be done at a leaf level") } r := moveRequest{cmd: cmd, src: e.Path, dst: kdbx.NewPath(dir, kdbx.Base(e.Path)), overwrite: false} - if err := r.do(true); err != nil { + if _, err := r.do(true); err != nil { return err } requests = append(requests, r) @@ -58,46 +58,51 @@ func Move(cmd CommandOptions) error { if rCount == 0 { return errors.New("no source entries matched") } + var moving []kdbx.MoveRequest for _, r := range requests { - if err := r.do(false); err != nil { + req, err := r.do(false) + if err != nil { return err } + if req != nil { + moving = append(moving, *req) + } } - return nil + return t.Move(moving...) } -func (r moveRequest) do(dryRun bool) error { +func (r moveRequest) do(dryRun bool) (*kdbx.MoveRequest, error) { tx := r.cmd.Transaction() if !dryRun { use, err := kdbx.NewTransaction() if err != nil { - return err + return nil, err } tx = use } srcExists, err := tx.Get(r.src, kdbx.SecretValue) if err != nil { - return errors.New("unable to get source entry") + return nil, errors.New("unable to get source entry") } if srcExists == nil { - return errors.New("no source object found") + return nil, errors.New("no source object found") } dstExists, err := tx.Get(r.dst, kdbx.BlankValue) if err != nil { - return errors.New("unable to get destination object") + return nil, errors.New("unable to get destination object") } if dstExists != nil { if r.overwrite { if !r.cmd.Confirm("overwrite destination") { - return nil + return nil, nil } } else { - return errors.New("unable to overwrite entries when moving multiple items") + return nil, errors.New("unable to overwrite entries when moving multiple items") } } if dryRun { - return nil + return nil, nil } - return tx.Move(srcExists, r.dst) + return &kdbx.MoveRequest{Source: srcExists, Destination: r.dst}, nil } diff --git a/internal/kdbx/actions.go b/internal/kdbx/actions.go @@ -14,6 +14,25 @@ import ( type ( action func(t Context) error + + // MoveRequest allow for moving (or inserting) entities as actions + MoveRequest struct { + Source *Entity + Destination string + } + + moveData struct { + src moveEntity + dst moveEntity + move bool + modTime time.Time + values map[string]string + } + + moveEntity struct { + title string + offset []string + } ) func (t *Transaction) act(cb action) error { @@ -170,74 +189,88 @@ func findAndDo(isAdd bool, entityName string, offset []string, opEntity *gokeepa 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") +// Move will move (one or more) source objects to destination location +func (t *Transaction) Move(moves ...MoveRequest) error { + if len(moves) == 0 { + return nil } - 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 + var requests []moveData + for _, move := range moves { + if move.Source == nil { + return errors.New("source entity is not set") + } + if strings.TrimSpace(move.Source.Path) == "" { + return errors.New("empty path not allowed") + } + if len(move.Source.Values) == 0 { + return errors.New("empty secrets not allowed") + } + values := make(map[string]string) + for k, v := range move.Source.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 } - if !found { - return fmt.Errorf("unknown entity field: %s", k) + dOffset, dTitle, err := splitComponents(move.Destination) + if err != nil { + return err } - } - mod := config.EnvDefaultModTime.Get() - modTime := time.Now() - if mod != "" { - p, err := time.Parse(config.ModTimeFormat, mod) + sOffset, sTitle, err := splitComponents(move.Source.Path) if err != nil { return err } - modTime = p + sourceData := moveEntity{offset: sOffset, title: sTitle} + destData := moveEntity{offset: dOffset, title: dTitle} + requests = append(requests, moveData{src: sourceData, dst: destData, move: move.Destination != move.Source.Path, modTime: modTime, values: values}) } - 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.doMoves(requests) +} + +func (t *Transaction) doMoves(requests []moveData) error { 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 { - if k != NotesField && strings.Contains(v, "\n") { - return fmt.Errorf("%s can NOT be multi-line", strings.ToLower(k)) + for _, req := range requests { + c.removeEntity(req.src.offset, req.src.title) + if req.move { + c.removeEntity(req.dst.offset, req.dst.title) } - if k == OTPField { - v = config.EnvTOTPFormat.Get(v) + e := gokeepasslib.NewEntry() + e.Values = append(e.Values, value(titleKey, req.dst.title)) + e.Values = append(e.Values, value(modTimeKey, req.modTime.Format(time.RFC3339))) + for k, v := range req.values { + if k != NotesField && strings.Contains(v, "\n") { + return fmt.Errorf("%s can NOT be multi-line", strings.ToLower(k)) + } + if k == OTPField { + v = config.EnvTOTPFormat.Get(v) + } + e.Values = append(e.Values, protectedValue(k, v)) } - e.Values = append(e.Values, protectedValue(k, v)) + c.alterEntities(true, req.dst.offset, req.dst.title, &e) } - 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) + return t.Move(MoveRequest{Source: &Entity{Path: path, Values: val}, Destination: path}) } // Remove will remove a single entity diff --git a/internal/kdbx/actions_test.go b/internal/kdbx/actions_test.go @@ -83,10 +83,13 @@ 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" { + if err := fullSetup(t, true).Move(); err != nil { + t.Errorf("invalid error: %v", err) + } + if err := fullSetup(t, true).Move(kdbx.MoveRequest{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 { + if err := fullSetup(t, true).Move(kdbx.MoveRequest{&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) @@ -96,7 +99,7 @@ func TestMove(t *testing.T) { 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 { + if err := fullSetup(t, true).Move(kdbx.MoveRequest{&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)