Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal

This commit is contained in:
Joe Julian
2026-03-29 13:35:12 -07:00
17 changed files with 2221 additions and 340 deletions
+79 -14
View File
@@ -4,15 +4,17 @@ import (
"context"
"errors"
"maps"
"os"
"slices"
"sync"
"strings"
"sync"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/webdav"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
@@ -36,6 +38,8 @@ type lifecycleBackend interface {
Open(string, vault.MasterKey) error
OpenRemote(webdav.Client, string, vault.MasterKey) error
Save() error
Lock() error
Unlock(vault.MasterKey) error
}
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
@@ -57,8 +61,8 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt
defer s.mu.RUnlock()
return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked,
Dirty: s.dirty,
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
}, nil
}
@@ -70,12 +74,12 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
return nil, status.Errorf(codes.Internal, "open vault: %v", err)
return nil, mapLifecycleError("open vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, status.Errorf(codes.Internal, "load opened vault: %v", err)
return nil, mapLifecycleError("load opened vault", err)
}
s.mu.Lock()
@@ -99,12 +103,12 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
}
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
return nil, status.Errorf(codes.Internal, "open remote vault: %v", err)
return nil, mapLifecycleError("open remote vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err)
return nil, mapLifecycleError("load opened remote vault", err)
}
s.mu.Lock()
@@ -122,7 +126,7 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
}
if err := s.lifecycle.Save(); err != nil {
return nil, status.Errorf(codes.Internal, "save vault: %v", err)
return nil, mapLifecycleError("save vault", err)
}
s.mu.Lock()
@@ -133,23 +137,59 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
}
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
if err := s.lifecycle.Lock(); err != nil {
return nil, mapLifecycleError("lock vault", err)
}
s.mu.Lock()
s.locked = true
s.mu.Unlock()
return &keepassgov1.LockVaultResponse{}, nil
}
func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Unlock(key); err != nil {
return nil, mapLifecycleError("unlock vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, mapLifecycleError("load unlocked vault", err)
}
s.mu.Lock()
s.model = model
s.locked = false
s.mu.Unlock()
return &keepassgov1.UnlockVaultResponse{}, nil
}
func mapLifecycleError(operation string, err error) error {
switch {
case errors.Is(err, os.ErrNotExist):
return status.Errorf(codes.NotFound, "%s: %v", operation, err)
case errors.Is(err, vault.ErrInvalidMasterKey):
return status.Errorf(codes.InvalidArgument, "%s: %v", operation, err)
case errors.Is(err, session.ErrLocked), errors.Is(err, session.ErrNoPath):
return status.Errorf(codes.FailedPrecondition, "%s: %v", operation, err)
case errors.Is(err, webdav.ErrConflict):
return status.Errorf(codes.Aborted, "%s: %v", operation, err)
default:
return status.Errorf(codes.Internal, "%s: %v", operation, err)
}
}
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -224,6 +264,29 @@ func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequ
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteGroup(req.GetPath()); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, vault.ErrGroupNotEmpty):
return nil, status.Error(codes.FailedPrecondition, err.Error())
default:
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
}
}
s.dirty = true
return &keepassgov1.DeleteGroupResponse{}, nil
}
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
if req.GetEntry() == nil {
return nil, status.Error(codes.InvalidArgument, "missing entry")
@@ -565,6 +628,7 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry {
Notes: entry.Notes,
Tags: append([]string(nil), entry.Tags...),
Path: append([]string(nil), entry.Path...),
Fields: maps.Clone(entry.Fields),
}
}
@@ -578,6 +642,7 @@ func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
Fields: maps.Clone(entry.GetFields()),
}
}
+358 -15
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"net"
"os"
"testing"
"git.julianfamily.org/keepassgo/passwords"
@@ -34,7 +35,21 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
lifecycle := &stubLifecycle{
model: vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
},
}
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
@@ -78,6 +93,82 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
}
}
func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) {
t.Parallel()
lifecycle := &stubLifecycle{
model: vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}},
},
},
unlockPassword: "correct horse battery staple",
unlockKeyFile: []byte("key-material"),
}
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
Path: "/tmp/test.kdbx",
Password: lifecycle.unlockPassword,
KeyFileData: lifecycle.unlockKeyFile,
}); err != nil {
t.Fatalf("OpenVault() error = %v", err)
}
if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil {
t.Fatalf("LockVault() error = %v", err)
}
if !lifecycle.locked {
t.Fatal("LockVault() did not lock lifecycle backend")
}
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after lock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after lock")
}
if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{
Password: "wrong password",
KeyFileData: lifecycle.unlockKeyFile,
}); status.Code(err) != codes.InvalidArgument {
t.Fatalf("UnlockVault(wrong password) code = %v, want %v", status.Code(err), codes.InvalidArgument)
}
statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after failed unlock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after failed unlock")
}
if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{
Password: lifecycle.unlockPassword,
KeyFileData: lifecycle.unlockKeyFile,
}); err != nil {
t.Fatalf("UnlockVault() error = %v", err)
}
if lifecycle.lastUnlockKey.Password != lifecycle.unlockPassword {
t.Fatalf("UnlockVault() password = %q, want %q", lifecycle.lastUnlockKey.Password, lifecycle.unlockPassword)
}
if !bytes.Equal(lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile) {
t.Fatalf("UnlockVault() key data = %q, want %q", lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile)
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() after unlock error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" {
t.Fatalf("ListEntries().Entries = %#v, want Remote Git after unlock", listed.Entries)
}
}
func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
t.Parallel()
@@ -131,6 +222,143 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
}
}
func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
testCases := []struct {
name string
call func() error
}{
{
name: "open",
call: func() error {
_, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"})
return err
},
},
{
name: "open_remote",
call: func() error {
_, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx",
})
return err
},
},
{
name: "save",
call: func() error {
_, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{})
return err
},
},
{
name: "lock",
call: func() error {
_, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{})
return err
},
},
{
name: "unlock",
call: func() error {
_, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{})
return err
},
},
}
for _, tt := range testCases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
err := tt.call()
if status.Code(err) != codes.FailedPrecondition {
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), codes.FailedPrecondition)
}
})
}
}
func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
call func(keepassgov1.VaultServiceClient, context.Context) error
err error
want codes.Code
}{
{
name: "open not found",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/missing.kdbx"})
return err
},
err: os.ErrNotExist,
want: codes.NotFound,
},
{
name: "open invalid master key",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"})
return err
},
err: vault.ErrInvalidMasterKey,
want: codes.InvalidArgument,
},
{
name: "save no path",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{})
return err
},
err: session.ErrNoPath,
want: codes.FailedPrecondition,
},
{
name: "lock already locked",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{})
return err
},
err: session.ErrLocked,
want: codes.FailedPrecondition,
},
{
name: "unlock invalid master key",
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
_, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{Password: "wrong"})
return err
},
err: vault.ErrInvalidMasterKey,
want: codes.InvalidArgument,
},
}
for _, tt := range testCases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
lifecycle := &stubLifecycle{err: tt.err}
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
err := tt.call(client, ctx)
if status.Code(err) != tt.want {
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want)
}
})
}
}
func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
@@ -150,6 +378,9 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
if resp.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
}
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
}
}
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
@@ -199,6 +430,42 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing
}
}
func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
ParentPath: []string{"Root"},
Name: "Finance",
}); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if _, err := client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{
Path: []string{"Root", "Finance"},
}); err != nil {
t.Fatalf("DeleteGroup() error = %v, want success for empty group", err)
}
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" {
t.Fatalf("ListGroups().Names = %#v, want empty Finance group removed", listed.Names)
}
_, err = client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{
Path: []string{"Root", "Internet"},
})
if status.Code(err) != codes.FailedPrecondition {
t.Fatalf("DeleteGroup() code = %v, want %v for non-empty group", status.Code(err), codes.FailedPrecondition)
}
}
func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
t.Parallel()
@@ -249,7 +516,10 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
Username: "codex",
Password: "token-2",
Url: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
Fields: map[string]string{
"X-Role": "lights-admin",
},
Path: []string{"Root", "Home Assistant"},
},
})
if err != nil {
@@ -259,6 +529,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
}
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}})
if err != nil {
@@ -268,6 +541,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries)
}
if got := listed.Entries[0].Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "lights-admin")
}
}
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
@@ -324,6 +600,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates)
}
if got := templates.Templates[0].Fields["Environment"]; got != "prod" {
t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "prod")
}
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login",
@@ -333,8 +612,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
Username: "rustyryan",
Password: "hunter2",
Url: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
Tags: []string{"dns"},
Fields: map[string]string{
"Environment": "staging",
},
Path: []string{"Root", "Internet"},
Tags: []string{"dns"},
},
})
if err != nil {
@@ -344,6 +626,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
}
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
@@ -368,7 +653,10 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
Title: "Website Login Updated",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
Fields: map[string]string{
"Environment": "dev",
},
Path: []string{"Templates", "Web"},
},
})
if err != nil {
@@ -377,6 +665,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
if upserted.Template.Title != "Website Login Updated" {
t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title)
}
if got := upserted.Template.Fields["Environment"]; got != "dev" {
t.Fatalf("UpsertTemplate().Template.Fields[Environment] = %q, want %q", got, "dev")
}
listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
@@ -385,6 +676,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates)
}
if got := listed.Templates[0].Fields["Environment"]; got != "dev" {
t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "dev")
}
if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil {
t.Fatalf("DeleteTemplate() error = %v", err)
@@ -493,6 +787,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Fields: map[string]string{
"X-Role": "automation",
},
History: []vault.Entry{
{
ID: "vault-console-h1",
@@ -503,7 +800,7 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Path: []string{"Root", "Internet"},
},
},
Path: []string{"Root", "Internet"},
Path: []string{"Root", "Internet"},
},
{
ID: "surveillance-console",
@@ -522,8 +819,11 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Password: "template-password",
URL: "https://example.com",
Notes: "Reusable template for website accounts.",
Tags: []string{"template", "web"},
Path: []string{"Templates"},
Fields: map[string]string{
"Environment": "prod",
},
Tags: []string{"template", "web"},
Path: []string{"Templates"},
},
},
},
@@ -560,7 +860,7 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
vault.Model{},
lifecycle.model,
passwords.DefaultProfiles(),
clipboardWriter,
lifecycle,
@@ -598,12 +898,16 @@ func (w *memoryClipboardWriter) WriteText(text string) error {
}
type stubLifecycle struct {
model vault.Model
openPath string
remoteBaseURL string
remotePath string
saved bool
locked bool
model vault.Model
openPath string
remoteBaseURL string
remotePath string
saved bool
locked bool
err error
unlockPassword string
unlockKeyFile []byte
lastUnlockKey vault.MasterKey
}
func (s *stubLifecycle) Current() (vault.Model, error) {
@@ -614,17 +918,56 @@ func (s *stubLifecycle) Current() (vault.Model, error) {
}
func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error {
if s.err != nil {
return s.err
}
s.openPath = path
s.locked = false
return nil
}
func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
if s.err != nil {
return s.err
}
s.remoteBaseURL = client.BaseURL
s.remotePath = path
s.locked = false
return nil
}
func (s *stubLifecycle) Save() error {
if s.err != nil {
return s.err
}
s.saved = true
return nil
}
func (s *stubLifecycle) Lock() error {
if s.err != nil {
return s.err
}
s.locked = true
return nil
}
func (s *stubLifecycle) Unlock(key vault.MasterKey) error {
if s.err != nil {
return s.err
}
if s.unlockPassword != "" && key.Password != s.unlockPassword {
return vault.ErrInvalidMasterKey
}
if s.unlockKeyFile != nil && !bytes.Equal(key.KeyFileData, s.unlockKeyFile) {
return vault.ErrInvalidMasterKey
}
s.lastUnlockKey = vault.MasterKey{
Password: key.Password,
KeyFileData: append([]byte(nil), key.KeyFileData...),
}
s.locked = false
return nil
}
+13
View File
@@ -161,6 +161,19 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
}
}
func (s State) SearchPathContext(entry vault.Entry) string {
path := slices.Clone(entry.Path)
switch s.Section {
case SectionTemplates:
if len(path) == 0 || path[0] != "Templates" {
path = append([]string{"Templates"}, path...)
}
case SectionRecycleBin:
path = append([]string{"Recycle Bin"}, path...)
}
return strings.Join(path, " / ")
}
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range entries {
+169
View File
@@ -117,6 +117,142 @@ func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) {
}
}
func TestVisibleEntriesUsesGlobalSearchWithinTemplateSection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
CurrentPath: []string{"Templates", "Web"},
SearchQuery: "infra",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "tpl-2" {
t.Fatalf("VisibleEntries() = %#v, want global template search result tpl-2", got)
}
}
func TestVisibleEntriesResetToCurrentTemplatePathAfterClearingSearch(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
CurrentPath: []string{"Templates", "Web"},
SearchQuery: "ssh",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() with search error = %v", err)
}
if len(got) != 1 || got[0].ID != "tpl-3" {
t.Fatalf("VisibleEntries() with search = %#v, want tpl-3", got)
}
state.SearchQuery = ""
got, err = state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 2", len(got))
}
if titles := []string{got[0].Title, got[1].Title}; !slices.Equal(titles, []string{"Email Login", "Website Login"}) {
t.Fatalf("VisibleEntries() after clearing search titles = %v, want [Email Login Website Login]", titles)
}
}
func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
},
},
Section: SectionRecycleBin,
CurrentPath: []string{"Root", "Internet"},
SearchQuery: "climate",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "deleted-2" {
t.Fatalf("VisibleEntries() = %#v, want global recycle-bin search result deleted-2", got)
}
}
func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
t.Parallel()
tests := []struct {
name string
section Section
entry vault.Entry
want string
}{
{
name: "entries use direct path",
section: SectionEntries,
entry: vault.Entry{Path: []string{"Root", "Internet"}},
want: "Root / Internet",
},
{
name: "templates retain templates root",
section: SectionTemplates,
entry: vault.Entry{Path: []string{"Templates", "Web"}},
want: "Templates / Web",
},
{
name: "recycle bin prefixes root label",
section: SectionRecycleBin,
entry: vault.Entry{Path: []string{"Root", "Internet"}},
want: "Recycle Bin / Root / Internet",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
state := State{Section: tt.section}
if got := state.SearchPathContext(tt.entry); got != tt.want {
t.Fatalf("SearchPathContext(%v) = %q, want %q", tt.entry.Path, got, tt.want)
}
})
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel()
@@ -473,6 +609,39 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
}
}
func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
}
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
t.Fatalf("MoveSelectedEntry() error = %v", err)
}
oldPath := sess.model.EntriesInPath([]string{"Root", "Internet"})
if len(oldPath) != 0 {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want empty after move", oldPath)
}
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after move")
}
}
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
t.Parallel()
+35 -18
View File
@@ -138,6 +138,7 @@ type ui struct {
loadingMessage string
statusMessage string
errorMessage string
keyboardFocus focusID
}
var (
@@ -213,6 +214,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui {
u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff)
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
u.passwordProfile.SetText("strong")
u.keyboardFocus = focusSearch
u.filter()
return u
}
@@ -267,6 +269,14 @@ func (u *ui) filteredTitles() []string {
return titles
}
func (u *ui) visiblePathContexts() []string {
paths := make([]string, 0, len(u.visible))
for _, item := range u.visible {
paths = append(paths, u.state.SearchPathContext(item))
}
return paths
}
func (u *ui) selectedEntry() (entry, bool) {
for _, item := range u.visible {
if item.ID == u.state.SelectedEntryID {
@@ -772,7 +782,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
}
return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, "Search vault")
editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor
@@ -860,7 +870,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
if strings.TrimSpace(u.search.Text()) == "" {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / "))
lbl := material.Label(u.theme, unit.Sp(11), u.state.SearchPathContext(item))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
@@ -876,7 +886,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
)
})
}
if item.ID == u.state.SelectedEntryID {
if item.ID == u.state.SelectedEntryID || u.isFocused(listFocusID(idx)) {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
size := gtx.Constraints.Min
@@ -886,8 +896,14 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
if size.Y == 0 {
size.Y = gtx.Constraints.Max.Y
}
paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op())
paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
fillColor := selectedColor
edgeColor := selectedEdge
if u.isFocused(listFocusID(idx)) && item.ID != u.state.SelectedEntryID {
fillColor = color.NRGBA{R: 235, G: 241, B: 238, A: 255}
edgeColor = accentColor
}
paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op())
paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
return layout.Dimensions{Size: size}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
@@ -1101,8 +1117,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
u.filter()
}
btn := material.Button(u.theme, &u.breadcrumbs[index], label)
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Background, btn.Color = buttonFocusColors(u.isFocused(breadcrumbFocusID(index)))
btn.TextSize = unit.Sp(12)
btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10}
return btn.Layout(gtx)
@@ -1221,14 +1236,14 @@ func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
})
}
func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions {
border := color.NRGBA{R: 202, G: 194, B: 180, A: 255}
func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions {
appearance := fieldFocusAppearance(gtx.Metric, focused)
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Dp(unit.Dp(44))
size.Y = appearance.MinHeight
}
gtx.Constraints.Min = size
return layout.Stack{}.Layout(gtx,
@@ -1237,10 +1252,13 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions {
return layout.Dimensions{Size: size}
}),
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(size.X, 1)}.Op())
paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(1, size.Y)}.Op())
paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op())
return drawFocusOutline(gtx, appearance, size)
}),
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op())
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op())
return layout.Dimensions{Size: size}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
@@ -1253,8 +1271,8 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions {
if dims.Size.Y < min.Y {
dims.Size.Y = min.Y
}
if dims.Size.Y < gtx.Dp(unit.Dp(44)) {
dims.Size.Y = gtx.Dp(unit.Dp(44))
if dims.Size.Y < appearance.MinHeight {
dims.Size.Y = appearance.MinHeight
}
return dims
}),
@@ -1263,8 +1281,7 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions {
func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
btn := material.Button(th, click, label)
btn.Background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
btn.Color = accentColor
btn.Background, btn.Color = buttonFocusColors(false)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(15)
return btn.Layout(gtx)
+349
View File
@@ -11,6 +11,9 @@ import (
"strings"
"testing"
"gioui.org/io/key"
"gioui.org/unit"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
@@ -41,6 +44,88 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) {
}
}
func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) {
t.Parallel()
modes := []string{"desktop", "phone"}
for _, mode := range modes {
mode := mode
t.Run(mode, func(t *testing.T) {
t.Parallel()
u := newUIWithModel(mode, vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}},
{ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
},
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.com", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.search.SetText("climate")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) {
t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got)
}
u.showTemplatesSection()
u.currentPath = []string{"Templates", "Web"}
u.search.SetText("infra")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
t.Fatalf("templates filteredTitles() = %v, want [SSH Login]", got)
}
if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Templates / Infra"}) {
t.Fatalf("templates visiblePathContexts() = %v, want [Templates / Infra]", got)
}
u.showRecycleBinSection()
u.search.SetText("climate")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) {
t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got)
}
if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) {
t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got)
}
})
}
}
func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
})
u.showTemplatesSection()
u.currentPath = []string{"Templates", "Web"}
u.search.SetText("ssh")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
t.Fatalf("filteredTitles() with search = %v, want [SSH Login]", got)
}
u.search.SetText("")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Email Login", "Website Login"}) {
t.Fatalf("filteredTitles() after clearing search = %v, want [Email Login Website Login]", got)
}
}
func TestUIChildGroupsComeFromVaultModel(t *testing.T) {
t.Parallel()
@@ -593,6 +678,98 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) {
}
}
func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.entryID.SetText("bellagio")
u.entryTitle.SetText("Bellagio")
u.entryUsername.SetText("rustyryan")
u.entryPassword.SetText("token-1")
u.entryURL.SetText("https://bellagio.example.invalid")
u.entryNotes.SetText("Registrar account")
u.entryTags.SetText("dns, registrar")
u.entryPath.SetText("Root / Internet")
u.entryFields.SetText("Environment=prod\nAccount ID=12345")
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() create error = %v", err)
}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
}
item, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want created entry")
}
if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "token-1" || item.URL != "https://bellagio.example.invalid" {
t.Fatalf("selectedEntry() = %#v, want created Bellagio credentials", item)
}
if item.Notes != "Registrar account" {
t.Fatalf("selectedEntry().Notes = %q, want %q", item.Notes, "Registrar account")
}
if !slices.Equal(item.Tags, []string{"dns", "registrar"}) {
t.Fatalf("selectedEntry().Tags = %v, want [dns registrar]", item.Tags)
}
if item.Fields["Environment"] != "prod" || item.Fields["Account ID"] != "12345" {
t.Fatalf("selectedEntry().Fields = %#v, want parsed custom fields", item.Fields)
}
}
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText("Root / Infrastructure")
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() move error = %v", err)
}
u.currentPath = []string{"Root", "Internet"}
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("filteredTitles() in old path = %v, want empty after move", got)
}
u.currentPath = []string{"Root", "Infrastructure"}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("filteredTitles() in new path = %v, want [Vault Console]", got)
}
item, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want moved entry")
}
if !slices.Equal(item.Path, []string{"Root", "Infrastructure"}) {
t.Fatalf("selectedEntry().Path = %v, want [Root Infrastructure]", item.Path)
}
}
func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) {
t.Parallel()
@@ -758,6 +935,178 @@ func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
}
}
func TestUIKeyboardNavigationMovesAcrossBreadcrumbsListAndDetail(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Path: []string{"Root", "Internet"},
},
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
if got := u.keyboardFocus; got != focusSearch {
t.Fatalf("keyboardFocus = %q, want %q", got, focusSearch)
}
u.handleKeyPress(key.NameTab, 0)
if got := u.keyboardFocus; got != breadcrumbFocusID(0) {
t.Fatalf("keyboardFocus after Tab = %q, want %q", got, breadcrumbFocusID(0))
}
u.handleKeyPress(key.NameTab, 0)
if got := u.keyboardFocus; got != listFocusID(0) {
t.Fatalf("keyboardFocus after second Tab = %q, want %q", got, listFocusID(0))
}
if got := u.state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID after list focus = %q, want %q", got, "bellagio")
}
u.handleKeyPress(key.NameDownArrow, 0)
if got := u.keyboardFocus; got != listFocusID(1) {
t.Fatalf("keyboardFocus after Down = %q, want %q", got, listFocusID(1))
}
if got := u.state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID after Down = %q, want %q", got, "vault-console")
}
u.handleKeyPress(key.NameTab, 0)
if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) {
t.Fatalf("keyboardFocus after detail Tab = %q, want %q", got, detailFocusID(detailFieldTitle))
}
}
func TestUIKeyboardNavigationActivatesBreadcrumbs(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.keyboardFocus = breadcrumbFocusID(0)
u.handleKeyPress(key.NameRightArrow, 0)
if got := u.keyboardFocus; got != breadcrumbFocusID(1) {
t.Fatalf("keyboardFocus after Right = %q, want %q", got, breadcrumbFocusID(1))
}
u.handleKeyPress(key.NameReturn, 0)
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after breadcrumb activation = %v, want [Root]", got)
}
}
func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.keyboardFocus = listFocusID(0)
u.handleKeyPress("F", key.ModShortcut)
if got := u.keyboardFocus; got != focusSearch {
t.Fatalf("keyboardFocus after shortcut search = %q, want %q", got, focusSearch)
}
u.handleKeyPress("N", key.ModShortcut)
if got := u.state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID after shortcut new-entry = %q, want empty", got)
}
if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) {
t.Fatalf("keyboardFocus after shortcut new-entry = %q, want %q", got, detailFocusID(detailFieldTitle))
}
}
func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
if got := u.accessibilityLabel(focusSearch); got != "Search vault" {
t.Fatalf("accessibilityLabel(search) = %q, want %q", got, "Search vault")
}
if got := u.accessibilityLabel(breadcrumbFocusID(1)); got != "Navigate to Root" {
t.Fatalf("accessibilityLabel(breadcrumb) = %q, want %q", got, "Navigate to Root")
}
if got := u.accessibilityLabel(listFocusID(0)); got != "Select entry Vault Console" {
t.Fatalf("accessibilityLabel(list) = %q, want %q", got, "Select entry Vault Console")
}
if got := u.accessibilityLabel(detailFocusID(detailFieldPassword)); got != "Edit Password" {
t.Fatalf("accessibilityLabel(detail password) = %q, want %q", got, "Edit Password")
}
}
func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) {
t.Parallel()
lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, true)
hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, true)
unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, false)
if got := lo.MinHeight; got != 44 {
t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got)
}
if got := hi.MinHeight; got != 110 {
t.Fatalf("fieldFocusAppearance(high).MinHeight = %d, want 110", got)
}
if got := lo.OutlineWidth; got < 2 {
t.Fatalf("fieldFocusAppearance(low).OutlineWidth = %d, want >= 2", got)
}
if hi.OutlineWidth <= lo.OutlineWidth {
t.Fatalf("fieldFocusAppearance(high).OutlineWidth = %d, want > %d", hi.OutlineWidth, lo.OutlineWidth)
}
if lo.OutlineColor == unfocused.OutlineColor {
t.Fatalf("fieldFocusAppearance().OutlineColor focused = %#v, want distinct from unfocused %#v", lo.OutlineColor, unfocused.OutlineColor)
}
}
func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
t.Parallel()
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -15,6 +15,7 @@ service VaultService {
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse);
rpc DeleteGroup(DeleteGroupRequest) returns (DeleteGroupResponse);
rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse);
rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse);
@@ -67,7 +68,10 @@ message LockVaultRequest {}
message LockVaultResponse {}
message UnlockVaultRequest {}
message UnlockVaultRequest {
string password = 1;
bytes key_file_data = 2;
}
message UnlockVaultResponse {}
@@ -85,6 +89,7 @@ message Entry {
string notes = 6;
repeated string tags = 7;
repeated string path = 8;
map<string, string> fields = 9;
}
message ListEntriesResponse {
@@ -113,6 +118,12 @@ message RenameGroupRequest {
message RenameGroupResponse {}
message DeleteGroupRequest {
repeated string path = 1;
}
message DeleteGroupResponse {}
message UpsertEntryRequest {
Entry entry = 1;
}
+38
View File
@@ -29,6 +29,7 @@ const (
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
@@ -60,6 +61,7 @@ type VaultServiceClient interface {
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, error)
DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error)
UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error)
DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error)
RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, error)
@@ -185,6 +187,16 @@ func (c *vaultServiceClient) RenameGroup(ctx context.Context, in *RenameGroupReq
return out, nil
}
func (c *vaultServiceClient) DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteGroupResponse)
err := c.cc.Invoke(ctx, VaultService_DeleteGroup_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *vaultServiceClient) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertEntryResponse)
@@ -349,6 +361,7 @@ type VaultServiceServer interface {
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error)
DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error)
UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error)
DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error)
RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, error)
@@ -404,6 +417,9 @@ func (UnimplementedVaultServiceServer) CreateGroup(context.Context, *CreateGroup
func (UnimplementedVaultServiceServer) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RenameGroup not implemented")
}
func (UnimplementedVaultServiceServer) DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteGroup not implemented")
}
func (UnimplementedVaultServiceServer) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertEntry not implemented")
}
@@ -650,6 +666,24 @@ func _VaultService_RenameGroup_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _VaultService_DeleteGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteGroupRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VaultServiceServer).DeleteGroup(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VaultService_DeleteGroup_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VaultServiceServer).DeleteGroup(ctx, req.(*DeleteGroupRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VaultService_UpsertEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertEntryRequest)
if err := dec(in); err != nil {
@@ -967,6 +1001,10 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RenameGroup",
Handler: _VaultService_RenameGroup_Handler,
},
{
MethodName: "DeleteGroup",
Handler: _VaultService_DeleteGroup_Handler,
},
{
MethodName: "UpsertEntry",
Handler: _VaultService_UpsertEntry_Handler,
+129
View File
@@ -537,3 +537,132 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID)
}
}
func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
History: []vault.Entry{
{
ID: "entry-1-history-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
},
},
Templates: []vault.Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
RecycleBin: []vault.Entry{
{
ID: "deleted-1",
Title: "Retired Entry",
Username: "archived-user",
Password: "retired-token",
Path: []string{"Root", "Archive"},
},
},
Groups: [][]string{
{"Root", "Archive"},
{"Root", "Empty Group"},
{"Templates", "Web"},
},
}
var remoteBytes bytes.Buffer
if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err)
}
etag := "\"v1\""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("ETag", etag)
_, _ = w.Write(remoteBytes.Bytes())
case http.MethodPut:
if got := r.Header.Get("If-Match"); got != etag {
t.Fatalf("If-Match header = %q, want %q", got, etag)
}
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll(PUT body) error = %v", err)
}
remoteBytes.Reset()
if _, err := remoteBytes.Write(payload); err != nil {
t.Fatalf("Write(remoteBytes) error = %v", err)
}
etag = "\"v2\""
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
client := webdav.Client{BaseURL: server.URL}
var sess Manager
if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("OpenRemote() error = %v", err)
}
if err := sess.SaveRemote(); err != nil {
t.Fatalf("SaveRemote() error = %v", err)
}
var reopened Manager
if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("reopen OpenRemote() error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("Current() after reopen error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
}
if got[0].ID != "entry-1" {
t.Fatalf("entry ID after remote reopen = %q, want %q", got[0].ID, "entry-1")
}
if len(got[0].History) != 1 || got[0].History[0].ID != "entry-1-history-1" {
t.Fatalf("History after remote reopen = %#v, want stable history ID entry-1-history-1", got[0].History)
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment after remote reopen = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
if len(current.Templates) != 1 || current.Templates[0].Path[1] != "Web" {
t.Fatalf("Templates after remote reopen = %#v, want Website Login in Templates/Web", current.Templates)
}
if len(current.RecycleBin) != 1 || current.RecycleBin[0].Path[1] != "Archive" {
t.Fatalf("RecycleBin after remote reopen = %#v, want retired entry in Root/Archive", current.RecycleBin)
}
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"fmt"
"image"
"image/color"
"strings"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
)
type focusAppearance struct {
BorderColor color.NRGBA
OutlineColor color.NRGBA
OutlineWidth int
MinHeight int
}
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)),
}
if focused {
appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
}
return appearance
}
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
}
return background, text
}
func (u *ui) accessibilityLabel(id focusID) string {
switch {
case id == focusSearch:
return "Search vault"
case strings.HasPrefix(string(id), "breadcrumb:"):
index := focusIndex(id)
crumbs := u.breadcrumbLabels()
if index >= 0 && index < len(crumbs) {
return fmt.Sprintf("Navigate to %s", crumbs[index])
}
case strings.HasPrefix(string(id), "list:"):
index := focusIndex(id)
if index >= 0 && index < len(u.visible) {
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
}
case strings.HasPrefix(string(id), "detail:"):
name := strings.TrimPrefix(string(id), "detail:")
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
}
return ""
}
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
return layout.Dimensions{Size: size}
}
width := appearance.OutlineWidth
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
return layout.Dimensions{Size: size}
}
func (u *ui) isFocused(id focusID) bool {
return u.keyboardFocus == id
}
func detailFieldLabel(field detailField) string {
switch field {
case detailFieldID:
return "ID"
case detailFieldTitle:
return "Title"
case detailFieldUsername:
return "Username"
case detailFieldPassword:
return "Password"
case detailFieldURL:
return "URL"
case detailFieldPath:
return "Path"
case detailFieldTags:
return "Tags"
case detailFieldPasswordProfile:
return "Password Profile"
case detailFieldNotes:
return "Notes"
case detailFieldFields:
return "Custom Fields"
case detailFieldHistoryIndex:
return "History Index"
default:
return strings.ReplaceAll(string(field), "-", " ")
}
}
+22 -12
View File
@@ -111,27 +111,27 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(u.theme, "ID", &u.entryID, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "ID", &u.entryID, false, u.isFocused(detailFocusID(detailFieldID)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Title", &u.entryTitle, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Username", &u.entryUsername, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Password", &u.entryPassword, true)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "URL", &u.entryURL, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Path", &u.entryPath, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Tags", &u.entryTags, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Password Profile", &u.passwordProfile, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Notes", &u.entryNotes, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Custom Fields (key=value)", &u.entryFields, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "Custom Fields (key=value)", &u.entryFields, false, u.isFocused(detailFocusID(detailFieldFields)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(u.theme, "History Index", &u.historyIndex, false)),
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
switch u.state.Section {
@@ -211,6 +211,16 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
}
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorWithFocus(th, label, editor, sensitive, false)
}
func labeledEditorWithFocus(
th *material.Theme,
label string,
editor *widget.Editor,
sensitive bool,
focused bool,
) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -219,7 +229,7 @@ func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sens
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask
if sensitive {
editor.Mask = '•'
+361
View File
@@ -0,0 +1,361 @@
package main
import (
"fmt"
"strconv"
"strings"
"gioui.org/io/key"
"git.julianfamily.org/keepassgo/appstate"
)
type focusID string
type detailField string
const (
focusSearch focusID = "search"
detailFieldID detailField = "id"
detailFieldTitle detailField = "title"
detailFieldUsername detailField = "username"
detailFieldPassword detailField = "password"
detailFieldURL detailField = "url"
detailFieldPath detailField = "path"
detailFieldTags detailField = "tags"
detailFieldPasswordProfile detailField = "password-profile"
detailFieldNotes detailField = "notes"
detailFieldFields detailField = "fields"
detailFieldHistoryIndex detailField = "history-index"
)
func breadcrumbFocusID(index int) focusID {
return focusID(fmt.Sprintf("breadcrumb:%d", index))
}
func listFocusID(index int) focusID {
return focusID(fmt.Sprintf("list:%d", index))
}
func detailFocusID(field detailField) focusID {
return focusID("detail:" + string(field))
}
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
if u.handleShortcutKey(name, modifiers) {
return true
}
switch name {
case key.NameTab:
delta := 1
if modifiers.Contain(key.ModShift) {
delta = -1
}
u.moveKeyboardFocus(delta)
return true
case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn:
return u.handleFocusedKey(name)
default:
return false
}
}
func (u *ui) moveKeyboardFocus(delta int) {
order := u.focusOrder()
if len(order) == 0 {
return
}
current := canonicalFocusID(u.keyboardFocus)
index := 0
for i, item := range order {
if canonicalFocusID(item) == current {
index = i
break
}
}
index += delta
if index < 0 {
index = len(order) - 1
}
if index >= len(order) {
index = 0
}
u.setKeyboardFocus(order[index])
}
func (u *ui) focusOrder() []focusID {
order := []focusID{focusSearch}
if u.state.Section != appstate.SectionRecycleBin {
order = append(order, breadcrumbFocusID(0))
}
if len(u.visible) > 0 {
order = append(order, listFocusID(u.focusedListIndexOrZero()))
}
order = append(order, detailFocusID(u.focusedDetailFieldOrDefault()))
return order
}
func (u *ui) setKeyboardFocus(id focusID) {
u.keyboardFocus = id
if strings.HasPrefix(string(id), "list:") {
u.focusListIndex(focusIndex(id))
}
}
func (u *ui) handleFocusedKey(name key.Name) bool {
switch {
case u.keyboardFocus == focusSearch:
if name == key.NameDownArrow && len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
return true
}
case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"):
return u.handleBreadcrumbKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "list:"):
return u.handleListKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "detail:"):
return u.handleDetailKey(name)
}
return false
}
func (u *ui) handleBreadcrumbKey(name key.Name) bool {
crumbs := u.breadcrumbLabels()
if len(crumbs) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameLeftArrow:
if index > 0 {
u.keyboardFocus = breadcrumbFocusID(index - 1)
}
return true
case key.NameRightArrow:
if index < len(crumbs)-1 {
u.keyboardFocus = breadcrumbFocusID(index + 1)
}
return true
case key.NameDownArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
case key.NameReturn:
u.activateBreadcrumb(index)
return true
default:
return false
}
}
func (u *ui) handleListKey(name key.Name) bool {
if len(u.visible) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameUpArrow:
if index > 0 {
u.setKeyboardFocus(listFocusID(index - 1))
}
return true
case key.NameDownArrow:
if index < len(u.visible)-1 {
u.setKeyboardFocus(listFocusID(index + 1))
}
return true
case key.NameLeftArrow:
u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1)
return true
case key.NameRightArrow, key.NameReturn:
u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault())
return true
default:
return false
}
}
func (u *ui) handleDetailKey(name key.Name) bool {
fields := detailFocusOrder()
index := u.focusedDetailIndex()
switch name {
case key.NameUpArrow:
if index > 0 {
u.keyboardFocus = detailFocusID(fields[index-1])
}
return true
case key.NameDownArrow:
if index < len(fields)-1 {
u.keyboardFocus = detailFocusID(fields[index+1])
}
return true
case key.NameLeftArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
default:
return false
}
}
func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool {
if !modifiers.Contain(key.ModShortcut) {
return false
}
switch name {
case "F":
_ = u.performShortcut(shortcutSearch)
case "S":
_ = u.performShortcut(shortcutSave)
case "L":
_ = u.performShortcut(shortcutLock)
case "N":
_ = u.performShortcut(shortcutNewEntry)
case "U":
_ = u.performShortcut(shortcutCopyUser)
case "P":
_ = u.performShortcut(shortcutCopyPassword)
case "O":
_ = u.performShortcut(shortcutCopyURL)
default:
return false
}
return true
}
func (u *ui) activateBreadcrumb(index int) {
if index <= 0 {
u.currentPath = nil
} else {
crumbs := u.breadcrumbLabels()
u.currentPath = append([]string{}, crumbs[1:index+1]...)
}
u.filter()
if index >= len(u.breadcrumbLabels()) {
index = len(u.breadcrumbLabels()) - 1
}
if index < 0 {
index = 0
}
u.keyboardFocus = breadcrumbFocusID(index)
}
func (u *ui) breadcrumbLabels() []string {
if u.state.Section == appstate.SectionRecycleBin {
return nil
}
labels := append([]string{"Vault"}, u.currentPath...)
if u.state.Section == appstate.SectionTemplates {
labels = append([]string{"Templates"}, u.currentPath...)
}
return labels
}
func (u *ui) focusListIndex(index int) {
if len(u.visible) == 0 {
return
}
if index < 0 {
index = 0
}
if index >= len(u.visible) {
index = len(u.visible) - 1
}
u.keyboardFocus = listFocusID(index)
u.state.SelectedEntryID = u.visible[index].ID
u.loadSelectedEntryIntoEditor()
}
func (u *ui) focusedListIndexOrZero() int {
if strings.HasPrefix(string(u.keyboardFocus), "list:") {
index := focusIndex(u.keyboardFocus)
if index >= 0 && index < len(u.visible) {
return index
}
}
for i, item := range u.visible {
if item.ID == u.state.SelectedEntryID {
return i
}
}
return 0
}
func (u *ui) focusedDetailFieldOrDefault() detailField {
if strings.HasPrefix(string(u.keyboardFocus), "detail:") {
name := strings.TrimPrefix(string(u.keyboardFocus), "detail:")
for _, field := range detailFocusOrder() {
if string(field) == name {
return field
}
}
}
return detailFieldTitle
}
func (u *ui) focusedDetailIndex() int {
current := u.focusedDetailFieldOrDefault()
for i, field := range detailFocusOrder() {
if field == current {
return i
}
}
return 0
}
func detailFocusOrder() []detailField {
return []detailField{
detailFieldID,
detailFieldTitle,
detailFieldUsername,
detailFieldPassword,
detailFieldURL,
detailFieldPath,
detailFieldTags,
detailFieldPasswordProfile,
detailFieldNotes,
detailFieldFields,
detailFieldHistoryIndex,
}
}
func canonicalFocusID(id focusID) focusID {
switch {
case strings.HasPrefix(string(id), "breadcrumb:"):
return breadcrumbFocusID(0)
case strings.HasPrefix(string(id), "list:"):
return listFocusID(0)
case strings.HasPrefix(string(id), "detail:"):
return detailFocusID(detailFieldTitle)
default:
return id
}
}
func focusIndex(id focusID) int {
_, value, ok := strings.Cut(string(id), ":")
if !ok {
return 0
}
index, err := strconv.Atoi(value)
if err != nil {
return 0
}
return index
}
+16 -23
View File
@@ -24,13 +24,19 @@ func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Focus: u, Name: "F", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "S", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "L", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "N", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "U", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "P", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "O", Required: key.ModShortcut},
key.Filter{Name: "F", Required: key.ModShortcut},
key.Filter{Name: "S", Required: key.ModShortcut},
key.Filter{Name: "L", Required: key.ModShortcut},
key.Filter{Name: "N", Required: key.ModShortcut},
key.Filter{Name: "U", Required: key.ModShortcut},
key.Filter{Name: "P", Required: key.ModShortcut},
key.Filter{Name: "O", Required: key.ModShortcut},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameLeftArrow},
key.Filter{Name: key.NameRightArrow},
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
)
if !ok {
break
@@ -41,28 +47,14 @@ func (u *ui) processShortcuts(gtx layout.Context) {
continue
}
switch ke.Name {
case "F":
_ = u.performShortcut(shortcutSearch)
case "S":
_ = u.performShortcut(shortcutSave)
case "L":
_ = u.performShortcut(shortcutLock)
case "N":
_ = u.performShortcut(shortcutNewEntry)
case "U":
_ = u.performShortcut(shortcutCopyUser)
case "P":
_ = u.performShortcut(shortcutCopyPassword)
case "O":
_ = u.performShortcut(shortcutCopyURL)
}
u.handleKeyPress(ke.Name, ke.Modifiers)
}
}
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
u.keyboardFocus = focusSearch
return nil
case shortcutSave:
return u.saveAction()
@@ -72,6 +64,7 @@ func (u *ui) performShortcut(name string) error {
u.state.SelectedEntryID = ""
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
u.keyboardFocus = detailFocusID(detailFieldTitle)
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
+92 -51
View File
@@ -23,8 +23,8 @@ type KDBXConfig struct {
var ErrInvalidMasterKey = errors.New("invalid master key")
const (
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
keepassGOIDField = "KeePassGO-ID"
)
@@ -46,33 +46,29 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
return err
}
header := gokeepasslib.NewHeader()
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
db.Credentials = credentials
db.Content.Meta = gokeepasslib.NewMetaData()
db.Content.Root = &gokeepasslib.RootData{}
if config != nil && config.Header != nil {
header = cloneHeader(config.Header)
db.Header = cloneHeader(config.Header)
db.Hashes = gokeepasslib.NewHashes(db.Header)
}
content := &gokeepasslib.DBContent{
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{},
}
if header.IsKdbx4() {
if db.Header.IsKdbx4() {
if config != nil && config.InnerHeader != nil {
content.InnerHeader = cloneInnerHeader(config.InnerHeader)
} else {
content.InnerHeader = &gokeepasslib.InnerHeader{
db.Content.InnerHeader = cloneInnerHeader(config.InnerHeader)
db.Content.InnerHeader.Binaries = nil
} else if db.Content.InnerHeader == nil {
db.Content.InnerHeader = &gokeepasslib.InnerHeader{
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
InnerRandomStreamKey: randomBytes(64),
}
}
} else {
db.Content.InnerHeader = nil
}
db := &gokeepasslib.Database{
Header: header,
Credentials: credentials,
Content: content,
Hashes: gokeepasslib.NewHashes(header),
}
db.Content.Root.Groups = buildGroupTree(db, entriesForPersistence(model))
db.Content.Root.DeletedObjects = marshalDeletedObjects(model.RecycleBin)
db.Content.Root.Groups = buildGroupTree(db, model)
db.Content.Root.DeletedObjects = nil
if err := db.LockProtectedEntries(); err != nil {
return fmt.Errorf("lock protected entries: %w", err)
@@ -87,20 +83,21 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) {
path = append(clonePath(path), group.Name)
model.CreateGroup(path[:len(path)-1], group.Name)
for _, entry := range group.Entries {
appendModelEntry(model, Entry{
ID: extractEntryID(entry),
Title: entry.GetTitle(),
Username: entry.GetContent("UserName"),
Password: entry.GetPassword(),
URL: entry.GetContent("URL"),
Notes: entry.GetContent("Notes"),
Tags: splitTags(entry.Tags),
Fields: extractCustomFields(entry),
ID: extractEntryID(entry),
Title: entry.GetTitle(),
Username: entry.GetContent("UserName"),
Password: entry.GetPassword(),
URL: entry.GetContent("URL"),
Notes: entry.GetContent("Notes"),
Tags: splitTags(entry.Tags),
Fields: extractCustomFields(entry),
Attachments: extractAttachments(db, entry),
History: extractHistory(db, entry, path),
Path: clonePath(path),
History: extractHistory(db, entry, path),
Path: clonePath(path),
})
}
@@ -207,7 +204,7 @@ func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path []
for _, item := range entry.Histories {
for _, historical := range item.Entries {
history = append(history, Entry{
ID: marshalUUID(historical.UUID),
ID: extractEntryID(historical),
Title: historical.GetTitle(),
Username: historical.GetContent("UserName"),
Password: historical.GetPassword(),
@@ -235,7 +232,8 @@ type MasterKey struct {
KeyFileData []byte
}
func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Group {
func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group {
entries := entriesForPersistence(model)
root := &groupNode{children: map[string]*groupNode{}}
for _, entry := range entries {
node := root
@@ -250,6 +248,18 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
}
node.entries = append(node.entries, entry)
}
for _, path := range groupPathsForPersistence(model, entries) {
node := root
for _, segment := range path {
if node.children[segment] == nil {
node.children[segment] = &groupNode{
name: segment,
children: map[string]*groupNode{},
}
}
node = node.children[segment]
}
}
groups := marshalGroups(db, root)
if len(groups) > 0 {
@@ -261,6 +271,31 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
return []gokeepasslib.Group{group}
}
func groupPathsForPersistence(model Model, entries []Entry) [][]string {
seen := map[string]bool{}
var groups [][]string
appendPath := func(path []string) {
key := strings.Join(path, "\x00")
if seen[key] {
return
}
seen[key] = true
groups = append(groups, slices.Clone(path))
}
for _, entry := range entries {
for i := 1; i <= len(entry.Path); i++ {
appendPath(entry.Path[:i])
}
}
for _, path := range model.Groups {
for i := 1; i <= len(path); i++ {
appendPath(path[:i])
}
}
return groups
}
func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) {
model, _, err := LoadKDBXWithConfig(r, key)
return model, err
@@ -407,7 +442,7 @@ func isInvalidCredentialError(err error) bool {
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
names := slices.Collect(maps.Keys(node.children))
slices.Sort(names)
slices.SortFunc(names, compareGroupNames)
var groups []gokeepasslib.Group
for _, name := range names {
@@ -422,6 +457,29 @@ func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Gr
return groups
}
func compareGroupNames(a, b string) int {
switch {
case a == b:
return 0
case a == "Root":
return -1
case b == "Root":
return 1
case a == templatesRoot:
return -1
case b == templatesRoot:
return 1
case a == recycleBinRoot:
return 1
case b == recycleBinRoot:
return -1
case a < b:
return -1
default:
return 1
}
}
func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry {
slices.SortFunc(entries, func(a, b Entry) int {
switch {
@@ -477,23 +535,6 @@ func marshalEntry(db *gokeepasslib.Database, entry Entry) gokeepasslib.Entry {
return item
}
func marshalDeletedObjects(entries []Entry) []gokeepasslib.DeletedObjectData {
if len(entries) == 0 {
return nil
}
deletionTime := w.Now()
out := make([]gokeepasslib.DeletedObjectData, 0, len(entries))
for _, entry := range entries {
out = append(out, gokeepasslib.DeletedObjectData{
UUID: uuidForEntryID(entry.ID),
DeletionTime: &deletionTime,
})
}
return out
}
func uuidForEntryID(id string) gokeepasslib.UUID {
if id != "" {
var uuid gokeepasslib.UUID
+109
View File
@@ -3,6 +3,7 @@ package vault
import (
"bytes"
"errors"
"slices"
"testing"
"github.com/tobischo/gokeepasslib/v3"
@@ -602,6 +603,114 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
}
}
func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Notes: "Current credential",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
History: []Entry{
{
ID: "entry-1-history-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Notes: "Original credential",
Path: []string{"Root", "Internet"},
},
},
},
},
Templates: []Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
RecycleBin: []Entry{
{
ID: "deleted-1",
Title: "Retired Entry",
Username: "archived-user",
Password: "retired-token",
Path: []string{"Root", "Archive"},
},
},
Groups: [][]string{
{"Root", "Archive"},
{"Root", "Empty Group"},
{"Templates", "Web"},
},
}
var encoded bytes.Buffer
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX(first cycle) error = %v", err)
}
reopened, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX(first cycle) error = %v", err)
}
encoded.Reset()
if err := SaveKDBX(&encoded, reopened, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX(second cycle) error = %v", err)
}
reopened, err = LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX(second cycle) error = %v", err)
}
got := reopened.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
}
if got[0].ID != "entry-1" {
t.Fatalf("entry ID after reopen cycles = %q, want %q", got[0].ID, "entry-1")
}
if len(got[0].History) != 1 {
t.Fatalf("len(History) after reopen cycles = %d, want 1", len(got[0].History))
}
if got[0].History[0].ID != "entry-1-history-1" {
t.Fatalf("history ID after reopen cycles = %q, want %q", got[0].History[0].ID, "entry-1-history-1")
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment after reopen cycles = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
if len(reopened.Templates) != 1 || reopened.Templates[0].Path[1] != "Web" {
t.Fatalf("Templates after reopen cycles = %#v, want Website Login in Templates/Web", reopened.Templates)
}
if len(reopened.RecycleBin) != 1 || reopened.RecycleBin[0].Path[1] != "Archive" {
t.Fatalf("RecycleBin after reopen cycles = %#v, want recycled entry in Root/Archive", reopened.RecycleBin)
}
rootGroups := reopened.ChildGroups([]string{"Root"})
if !slices.Equal(rootGroups, []string{"Archive", "Empty Group", "Internet"}) {
t.Fatalf("ChildGroups(Root) after reopen cycles = %v, want [Archive Empty Group Internet]", rootGroups)
}
templateGroups := reopened.ChildGroups([]string{"Templates"})
if !slices.Equal(templateGroups, []string{"Web"}) {
t.Fatalf("ChildGroups(Templates) after reopen cycles = %v, want [Web]", templateGroups)
}
}
func mustGroup(name string, children ...any) gokeepasslib.Group {
group := gokeepasslib.NewGroup()
group.Name = name
+13 -12
View File
@@ -7,19 +7,20 @@ import (
)
var ErrEntryNotFound = errors.New("entry not found")
var ErrGroupNotEmpty = errors.New("group is not empty")
type Entry struct {
ID string
Title string
Username string
Password string
URL string
Notes string
Tags []string
Fields map[string]string
ID string
Title string
Username string
Password string
URL string
Notes string
Tags []string
Fields map[string]string
Attachments map[string][]byte
History []Entry
Path []string
History []Entry
Path []string
}
type SearchResult struct {
@@ -323,12 +324,12 @@ func (m *Model) MoveTemplate(id string, path []string) error {
func (m *Model) DeleteGroup(path []string) error {
for _, entry := range m.Entries {
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
return errors.New("group is not empty")
return ErrGroupNotEmpty
}
}
for _, entry := range m.Templates {
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
return errors.New("group is not empty")
return ErrGroupNotEmpty
}
}