Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal
This commit is contained in:
+79
-14
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
+314
-194
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user