Normalize vault storage root on open and create

This commit is contained in:
Joe Julian
2026-04-13 07:29:51 -07:00
parent 6790399e24
commit eccfb886ee
7 changed files with 252 additions and 56 deletions
-7
View File
@@ -6,13 +6,6 @@ These segments are intended to be independently executable wherever possible.
Each segment has its own local exit criteria. Each segment has its own local exit criteria.
The product is not complete until the global exit criteria at the end of this file are also met. The product is not complete until the global exit criteria at the end of this file are also met.
## Priority Bugs
- Vault root view bug:
ensure vault create/open maintains the physical `keepass` storage root,
lazily creates recycle-bin structure when needed, and warns when opening a
legacy vault that had to be normalized.
## UI Review Follow-Ups ## UI Review Follow-Ups
These items came from a hands-on emulator and desktop walkthrough. These items came from a hands-on emulator and desktop walkthrough.
+17 -1
View File
@@ -101,6 +101,10 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error OpenRemote(webdav.Client, string, vault.MasterKey) error
} }
type WarningSession interface {
ConsumeWarning() string
}
type SecurityConfigurableSession interface { type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings SecuritySettings() vault.SecuritySettings
@@ -841,7 +845,13 @@ func (s *State) Unlock(key vault.MasterKey) error {
return fmt.Errorf("session is not lockable") return fmt.Errorf("session is not lockable")
} }
return session.Unlock(key) if err := session.Unlock(key); err != nil {
return err
}
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
} }
func (s *State) ChangeMasterKey(key vault.MasterKey) error { func (s *State) ChangeMasterKey(key vault.MasterKey) error {
@@ -1003,6 +1013,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
@@ -1033,6 +1046,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
+44 -11
View File
@@ -2441,8 +2441,8 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Session.Current() error = %v", err) t.Fatalf("Session.Current() error = %v", err)
} }
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
} }
} }
@@ -2675,8 +2675,8 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Session.Current() error = %v", err) t.Fatalf("Session.Current() error = %v", err)
} }
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
} }
} }
@@ -3180,8 +3180,8 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3241,8 +3241,8 @@ func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *te
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3406,8 +3406,8 @@ func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -5136,6 +5136,39 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
} }
} }
func TestUIOpenVaultShowsLegacyRootNormalizationWarning(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBX(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Crew", "Internet"}},
},
Groups: [][]string{
{"Root"},
{"Root", "Crew"},
{"Root", "Crew", "Internet"},
},
}, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(legacy-root.kdbx) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.vaultPath.SetText(path)
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := u.state.StatusMessage; !strings.Contains(got, "legacy vault root") {
t.Fatalf("StatusMessage = %q, want legacy vault root normalization warning", got)
}
}
func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) { func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
t.Parallel() t.Parallel()
@@ -8480,7 +8513,7 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
if err := reopened.openVaultAction(); err != nil { if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction(imported) error = %v", err) t.Fatalf("openVaultAction(imported) error = %v", err)
} }
reopened.state.NavigateToPath([]string{"Crew", "Internet"}) reopened.state.NavigateToPath([]string{"Root", "Crew", "Internet"})
reopened.filter() reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
+72 -25
View File
@@ -12,6 +12,7 @@ import (
"strings" "strings"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
@@ -31,6 +32,7 @@ type Manager struct {
remoteClient *webdav.Client remoteClient *webdav.Client
remotePath string remotePath string
remoteVersion webdav.Version remoteVersion webdav.Version
warning string
} }
type PreparedLocalOpen struct { type PreparedLocalOpen struct {
@@ -40,6 +42,7 @@ type PreparedLocalOpen struct {
Key vault.MasterKey Key vault.MasterKey
Encoded []byte Encoded []byte
VaultRoot string VaultRoot string
Warning string
} }
type PreparedRemoteOpen struct { type PreparedRemoteOpen struct {
@@ -51,6 +54,7 @@ type PreparedRemoteOpen struct {
Encoded []byte Encoded []byte
VaultRoot string VaultRoot string
RemoteVersion webdav.Version RemoteVersion webdav.Version
Warning string
} }
type PreparedUnlock struct { type PreparedUnlock struct {
@@ -58,6 +62,7 @@ type PreparedUnlock struct {
Config *vault.KDBXConfig Config *vault.KDBXConfig
Key vault.MasterKey Key vault.MasterKey
VaultRoot string VaultRoot string
Warning string
} }
func (m *Manager) SecuritySettings() vault.SecuritySettings { func (m *Manager) SecuritySettings() vault.SecuritySettings {
@@ -74,7 +79,7 @@ func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
} }
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error { func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
root := detectSingleVaultRoot(model) root := vaultview.KeepassRoot
model = normalizeUnderRoot(model, root) model = normalizeUnderRoot(model, root)
var encoded bytes.Buffer var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil { if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
@@ -86,6 +91,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
m.vaultRoot = root m.vaultRoot = root
m.encoded = encoded.Bytes() m.encoded = encoded.Bytes()
m.locked = false m.locked = false
m.warning = ""
return nil return nil
} }
@@ -118,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
return nil return nil
} }
func (m *Manager) ConsumeWarning() string {
warning := strings.TrimSpace(m.warning)
m.warning = ""
return warning
}
func (m *Manager) Save() error { func (m *Manager) Save() error {
if m.remoteClient != nil && m.remotePath != "" { if m.remoteClient != nil && m.remotePath != "" {
return m.SaveRemote() return m.SaveRemote()
@@ -254,7 +266,7 @@ func (m *Manager) SaveAs(path string) error {
func (m *Manager) Replace(model vault.Model) { func (m *Manager) Replace(model vault.Model) {
root := m.vaultRoot root := m.vaultRoot
if root == "" { if root == "" {
root = detectSingleVaultRoot(model) root = vaultview.KeepassRoot
} }
m.model = normalizeUnderRoot(model, root) m.model = normalizeUnderRoot(model, root)
m.vaultRoot = root m.vaultRoot = root
@@ -305,12 +317,13 @@ func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, erro
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err) return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
} }
return PreparedLocalOpen{ return PreparedLocalOpen{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Path: path, Path: path,
Key: key, Key: key,
Encoded: content, Encoded: content,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -324,14 +337,15 @@ func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err) return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
} }
return PreparedRemoteOpen{ return PreparedRemoteOpen{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Client: client, Client: client,
Path: path, Path: path,
Key: key, Key: key,
Encoded: content, Encoded: content,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
RemoteVersion: version, RemoteVersion: version,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -341,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error)
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err) return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
} }
return PreparedUnlock{ return PreparedUnlock{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Key: key, Key: key,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -359,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.remoteClient = nil m.remoteClient = nil
m.remotePath = "" m.remotePath = ""
m.remoteVersion = webdav.Version{} m.remoteVersion = webdav.Version{}
m.warning = prepared.Warning
} }
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) { func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
@@ -372,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.remotePath = prepared.Path m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion m.remoteVersion = prepared.RemoteVersion
m.path = "" m.path = ""
m.warning = prepared.Warning
} }
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) { func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
@@ -380,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.key = prepared.Key m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot m.vaultRoot = prepared.VaultRoot
m.locked = false m.locked = false
m.warning = prepared.Warning
} }
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error { func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
@@ -584,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
return err return err
} }
m.model = merged m.model = merged
if root := detectSingleVaultRoot(merged); root != "" { m.vaultRoot = vaultview.KeepassRoot
m.vaultRoot = root
}
m.encoded = encoded m.encoded = encoded
m.locked = false m.locked = false
return nil return nil
@@ -603,9 +619,7 @@ func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err) return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
} }
m.model = merged m.model = merged
if root := detectSingleVaultRoot(merged); root != "" { m.vaultRoot = vaultview.KeepassRoot
m.vaultRoot = root
}
m.encoded = encoded m.encoded = encoded
m.remoteVersion = version m.remoteVersion = version
m.locked = false m.locked = false
@@ -867,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
return out return out
} }
func detectSingleVaultRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
}
func normalizeUnderRoot(model vault.Model, root string) vault.Model { func normalizeUnderRoot(model vault.Model, root string) vault.Model {
if root == "" { if root == "" {
return model return model
@@ -888,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
switch { switch {
case len(path) == 0: case len(path) == 0:
return []string{root} return []string{root}
case path[0] == "Root":
if len(path) == 1 {
return []string{root}
}
return append([]string{root}, path[1:]...)
case path[0] == root: case path[0] == root:
return path return path
case path[0] == "Templates":
return path
default: default:
return append([]string{root}, path...) return append([]string{root}, path...)
} }
@@ -907,12 +917,49 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path) out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
} }
} }
for i := range out.Templates {
out.Templates[i].Path = normalizePath(out.Templates[i].Path)
for j := range out.Templates[i].History {
out.Templates[i].History[j].Path = normalizePath(out.Templates[i].History[j].Path)
}
}
for i := range out.Groups { for i := range out.Groups {
out.Groups[i] = normalizePath(out.Groups[i]) out.Groups[i] = normalizePath(out.Groups[i])
} }
return out return out
} }
func normalizationWarning(model vault.Model) string {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return ""
}
if usesKeepassStorageRoot(model) {
return ""
}
return "Opened legacy vault root layout and normalized it under keepass."
}
func usesKeepassStorageRoot(model vault.Model) bool {
if len(model.Entries) != 0 || len(model.RecycleBin) != 0 {
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
+63 -12
View File
@@ -64,7 +64,7 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
t.Fatalf("Current() after Unlock() error = %v", err) t.Fatalf("Current() after Unlock() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" { if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got) t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
} }
@@ -110,12 +110,63 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Home Assistant"}) got := current.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("Current() entries = %#v, want Home Assistant entry", got) t.Fatalf("Current() entries = %#v, want Home Assistant entry", got)
} }
} }
func TestOpenNormalizesLegacyVaultRootToKeepassAndReportsWarning(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
Groups: [][]string{
{"Root"},
{"Root", "Home Assistant"},
},
}
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create(legacy path) error = %v", err)
}
if err := vault.SaveKDBXWithKey(file, model, key); err != nil {
file.Close()
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close(legacy path) error = %v", err)
}
var sess Manager
if err := sess.Open(path, key); err != nil {
t.Fatalf("Open() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
if got := current.EntriesInPath([]string{"keepass", "Home Assistant"}); len(got) != 1 || got[0].ID != "entry-1" {
t.Fatalf("Current().EntriesInPath([keepass Home Assistant]) = %#v, want normalized legacy entry", got)
}
if got := sess.ConsumeWarning(); got == "" {
t.Fatal("ConsumeWarning() = empty, want legacy root normalization warning")
}
}
func TestSavePersistsEditsBackToCurrentPath(t *testing.T) { func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Parallel() t.Parallel()
@@ -169,7 +220,7 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := loaded.EntriesInPath([]string{"Root", "Internet"}) got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded entries = %#v, want updated password token-2", got) t.Fatalf("loaded entries = %#v, want updated password token-2", got)
} }
@@ -307,7 +358,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got) t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
} }
@@ -392,7 +443,7 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err) t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err)
} }
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) got := loaded.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got) t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
} }
@@ -513,7 +564,7 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" { if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got) t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
} }
@@ -720,7 +771,7 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
t.Fatalf("Current() after reopen error = %v", err) t.Fatalf("Current() after reopen error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
} }
@@ -879,7 +930,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
} }
@@ -947,7 +998,7 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1004,7 +1055,7 @@ func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1063,7 +1114,7 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1148,7 +1199,7 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
+5
View File
@@ -26,6 +26,7 @@ var ErrInvalidMasterKey = errors.New("invalid master key")
const ( const (
templatesRoot = "Templates" templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin" recycleBinRoot = "Recycle Bin"
keepassRoot = "keepass"
keepassGOIDField = "KeePassGO-ID" keepassGOIDField = "KeePassGO-ID"
remoteProfilesKey = "keepassgo.remoteProfiles" remoteProfilesKey = "keepassgo.remoteProfiles"
) )
@@ -502,6 +503,10 @@ func compareGroupNames(a, b string) int {
return -1 return -1
case b == "Root": case b == "Root":
return 1 return 1
case a == keepassRoot:
return -1
case b == keepassRoot:
return 1
case a == templatesRoot: case a == templatesRoot:
return -1 return -1
case b == templatesRoot: case b == templatesRoot:
+51
View File
@@ -755,6 +755,57 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
} }
} }
func TestKDBXKeepassRootEntriesPreserveAttachmentsWithTemplates(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
},
},
Templates: []Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
Groups: [][]string{
{"keepass", "Internet"},
{"Templates", "Web"},
},
}
var encoded bytes.Buffer
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX() error = %v", err)
}
got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment contents = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
}
func mustGroup(name string, children ...any) gokeepasslib.Group { func mustGroup(name string, children ...any) gokeepasslib.Group {
group := gokeepasslib.NewGroup() group := gokeepasslib.NewGroup()
group.Name = name group.Name = name