lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 9b797902a3b75b080e876cc6024ca7b157860ce9
parent 1fab48c8ae8366012d7606dc41cc3e5d0815cf2b
Author: Sean Enck <sean@ttypty.com>
Date:   Tue, 11 Jul 2023 18:37:31 -0400

move supports globbing

Diffstat:
Minternal/app/move.go | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Minternal/app/move_test.go | 20++++++++++++++++++--
Minternal/backend/core.go | 4++--
Minternal/backend/query.go | 16+++++++++++++---
Minternal/backend/query_test.go | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/cli/core_test.go | 2+-
Minternal/cli/doc.txt | 17+++++++++++++----
Mtests/expected.log | 20+++++++++++++++++++-
Mtests/run.sh | 12++++++++++++
9 files changed, 199 insertions(+), 18 deletions(-)

diff --git a/internal/app/move.go b/internal/app/move.go @@ -2,10 +2,20 @@ package app import ( "errors" + "fmt" "github.com/enckse/lockbox/internal/backend" ) +type ( + moveRequest struct { + cmd CommandOptions + src string + dst string + overwrite bool + } +) + // Move is the CLI command to move entries func Move(cmd CommandOptions) error { args := cmd.Args() @@ -15,21 +25,73 @@ func Move(cmd CommandOptions) error { t := cmd.Transaction() src := args[0] dst := args[1] - srcExists, err := t.Get(src, backend.SecretValue) + m, err := t.MatchPath(src) + if err != nil { + return err + } + var requests []moveRequest + switch len(m) { + case 1: + requests = append(requests, moveRequest{cmd: cmd, src: m[0].Path, dst: dst, overwrite: true}) + case 0: + break + default: + if !backend.IsDirectory(dst) { + return fmt.Errorf("%s must be a path, not an entry", dst) + } + dir := backend.Directory(dst) + for _, e := range m { + r := moveRequest{cmd: cmd, src: e.Path, dst: backend.NewPath(dir, backend.Base(e.Path)), overwrite: false} + if err := r.do(true, true); err != nil { + return err + } + requests = append(requests, r) + } + } + rCount := len(requests) + if rCount == 0 { + return errors.New("no source entries matched") + } + for _, r := range requests { + if err := r.do(false, rCount == 1); err != nil { + return err + } + } + return nil +} + +func (r moveRequest) do(dryRun, useCommandTransaction bool) error { + tx := r.cmd.Transaction() + if !dryRun { + use, err := backend.NewTransaction() + if err != nil { + return err + } + tx = use + + } + srcExists, err := tx.Get(r.src, backend.SecretValue) if err != nil { return errors.New("unable to get source entry") } if srcExists == nil { return errors.New("no source object found") } - dstExists, err := t.Get(dst, backend.BlankValue) + dstExists, err := tx.Get(r.dst, backend.BlankValue) if err != nil { return errors.New("unable to get destination object") } if dstExists != nil { - if !cmd.Confirm("overwrite destination") { - return nil + if r.overwrite { + if !r.cmd.Confirm("overwrite destination") { + return nil + } + } else { + return errors.New("unable to overwrite entries when moving multiple items") } } - return t.Move(*srcExists, dst) + if dryRun { + return nil + } + return tx.Move(*srcExists, r.dst) } diff --git a/internal/app/move_test.go b/internal/app/move_test.go @@ -22,7 +22,11 @@ type ( func newMockCommand(t *testing.T) *mockCommand { setup(t) fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), "pass") + fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test2"), "pass") fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test3"), "pass") + fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test1"), "pass") + fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test2"), "pass") + fullSetup(t, true).Insert(backend.NewPath("test", "test4", "test5"), "pass") return &mockCommand{t: t, confirmed: false, confirm: true} } @@ -48,8 +52,8 @@ func TestMove(t *testing.T) { if err := app.Move(m); err.Error() != "src/dst required for move" { t.Errorf("invalid error: %v", err) } - m.args = []string{"a", "b"} - if err := app.Move(m); err.Error() != "unable to get source entry" { + m.args = []string{"a/c", "b"} + if err := app.Move(m); err.Error() != "no source entries matched" { t.Errorf("invalid error: %v", err) } m.confirmed = false @@ -60,4 +64,16 @@ func TestMove(t *testing.T) { if !m.confirmed { t.Error("no move") } + m.args = []string{"test/test3/*", "test/test2/test3"} + if err := app.Move(m); err.Error() != "test/test2/test3 must be a path, not an entry" { + t.Errorf("invalid error: %v", err) + } + m.args = []string{"test/test3/*", "test/test2/"} + if err := app.Move(m); err.Error() != "unable to overwrite entries when moving multiple items" { + t.Errorf("invalid error: %v", err) + } + m.args = []string{"test/test3/*", "test/test4/"} + if err := app.Move(m); err != nil { + t.Errorf("invalid error: %v", err) + } } diff --git a/internal/backend/core.go b/internal/backend/core.go @@ -54,8 +54,8 @@ func splitComponents(path string) ([]string, string, error) { 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) + title := Base(path) + parts := strings.Split(Directory(path), pathSep) return parts, title, nil } diff --git a/internal/backend/query.go b/internal/backend/query.go @@ -175,15 +175,20 @@ func NewPath(segments ...string) string { // Directory gets the offset location of the entry without the 'name' func (e QueryEntity) Directory() string { - return directory(e.Path) + return Directory(e.Path) } -func base(s string) string { +// 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] } -func directory(s string) string { +// 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]...) } @@ -195,3 +200,8 @@ func getValue(e gokeepasslib.Entry, key string) string { } 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) +} diff --git a/internal/backend/query_test.go b/internal/backend/query_test.go @@ -261,3 +261,57 @@ func TestSetModTime(t *testing.T) { 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") + } +} diff --git a/internal/cli/core_test.go b/internal/cli/core_test.go @@ -14,7 +14,7 @@ func TestUsage(t *testing.T) { t.Errorf("invalid usage, out of date? %d", len(u)) } u, _ = cli.Usage(true) - if len(u) != 83 { + if len(u) != 92 { t.Errorf("invalid verbose usage, out of date? %d", len(u)) } for _, usage := range u { diff --git a/internal/cli/doc.txt b/internal/cli/doc.txt @@ -8,15 +8,24 @@ changing it outside of 'lb' usage. If a database not normally used by 'lb' is to be used by 'lb', try using the various readonly settings to control interactions. -[remove] -The 'rm' command can handle a simplistic glob if it is at the END of the path. -This allows for bulk-removal of entries at multiple levels. Confirmation will -still be required for removal (matching entries will be listed) +[globs] +The 'rm' and 'mv' command can handle a simplistic glob if it is at the END +of the path. This allows for bulk-removal of entries at multiple levels. +Confirmation will still be required for removal (matching entries will be +listed) + +For 'mv' the destination must NOT be an entry but the final destination +location for all matched entries. Overwriting is not allowed by moving +via glob. + +Examples: lb rm path/to/leaf/dir/* lb rm path/to/* +lb mv path/to/* new/path/ + [clipboard] By default clipboard commands are detected via determing the platform and utilizing default commands to interact with (copy to/paste to) the clipboard. diff --git a/tests/expected.log b/tests/expected.log @@ -62,9 +62,27 @@ delete entry? (y/N) delete entry? (y/N) unable to remove: no entities given +unable to get destination object +unable to overwrite entries when moving multiple items keys/k/one2 keyx/d/e -delete entry? (y/N) +move/ma/ka2/zzz +move/ma/ka3/yyy +move/ma/ka3/zzz +move/mac/abc +move/mac/xyz +move/mac/yyy +move/mac/zzz +selected entities: + move/ma/ka2/zzz + move/ma/ka3/yyy + move/ma/ka3/zzz + move/mac/abc + move/mac/xyz + move/mac/yyy + move/mac/zzz + +delete entries? (y/N) delete entry? (y/N) keys/k/one2 pre insert keys/k2/t2/one CALLED diff --git a/tests/run.sh b/tests/run.sh @@ -48,8 +48,20 @@ _execute() { echo y |${LB_BINARY} rm test/k/one echo echo + echo test2 |${LB_BINARY} insert move/m/ka/abc + echo test |${LB_BINARY} insert move/m/ka/xyz + echo test2 |${LB_BINARY} insert move/ma/ka/yyy + echo test |${LB_BINARY} insert move/ma/ka/zzz + echo test |${LB_BINARY} insert move/ma/ka2/zzz + echo test |${LB_BINARY} insert move/ma/ka3/yyy + echo test |${LB_BINARY} insert move/ma/ka3/zzz + ${LB_BINARY} mv move/m/* move/mac/ + ${LB_BINARY} mv move/ma/ka/* move/mac/ + ${LB_BINARY} mv move/ma/ka2/* move/mac/ + ${LB_BINARY} mv move/ma/ka3/* move/mac/ ${LB_BINARY} mv key/a/one keyx/d/e ${LB_BINARY} ls + echo y |${LB_BINARY} rm move/* echo y |${LB_BINARY} rm keyx/d/e echo ${LB_BINARY} ls