From eccfb886ee65826e32ce3f7b2ee9bc193fc2bc70 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 07:29:51 -0700 Subject: [PATCH] Normalize vault storage root on open and create --- TODO.md | 7 --- internal/appstate/state.go | 18 +++++- internal/appui/main_test.go | 55 ++++++++++++++---- internal/session/session.go | 97 ++++++++++++++++++++++++-------- internal/session/session_test.go | 75 ++++++++++++++++++++---- internal/vault/kdbx.go | 5 ++ internal/vault/kdbx_test.go | 51 +++++++++++++++++ 7 files changed, 252 insertions(+), 56 deletions(-) diff --git a/TODO.md b/TODO.md index d5bd714..bd73621 100644 --- a/TODO.md +++ b/TODO.md @@ -6,13 +6,6 @@ These segments are intended to be independently executable wherever possible. 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. -## 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 These items came from a hands-on emulator and desktop walkthrough. diff --git a/internal/appstate/state.go b/internal/appstate/state.go index 78319bf..9c886ad 100644 --- a/internal/appstate/state.go +++ b/internal/appstate/state.go @@ -101,6 +101,10 @@ type RemoteOpenableSession interface { OpenRemote(webdav.Client, string, vault.MasterKey) error } +type WarningSession interface { + ConsumeWarning() string +} + type SecurityConfigurableSession interface { ConfigureSecurity(vault.SecuritySettings) error SecuritySettings() vault.SecuritySettings @@ -841,7 +845,13 @@ func (s *State) Unlock(key vault.MasterKey) error { 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 { @@ -1003,6 +1013,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error { s.CurrentPath = nil s.SelectedEntryID = "" s.Dirty = false + if warningSession, ok := s.Session.(WarningSession); ok { + s.StatusMessage = warningSession.ConsumeWarning() + } return nil } @@ -1033,6 +1046,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas s.CurrentPath = nil s.SelectedEntryID = "" s.Dirty = false + if warningSession, ok := s.Session.(WarningSession); ok { + s.StatusMessage = warningSession.ConsumeWarning() + } return nil } diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index 59e5212..66ce854 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -2441,8 +2441,8 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { if err != nil { t.Fatalf("Session.Current() error = %v", err) } - if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { - t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) + if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { + t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got) } } @@ -2675,8 +2675,8 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { if err != nil { t.Fatalf("Session.Current() error = %v", err) } - if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { - t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) + if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { + t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got) } } @@ -3180,8 +3180,8 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) { if err != nil { t.Fatalf("reopened Current() error = %v", err) } - if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { - t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) + if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 { + t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got) } } @@ -3241,8 +3241,8 @@ func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *te if err != nil { t.Fatalf("reopened Current() error = %v", err) } - if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { - t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) + if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 { + t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got) } } @@ -3406,8 +3406,8 @@ func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) { if err != nil { t.Fatalf("reopened Current() error = %v", err) } - if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { - t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) + if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 { + 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) { t.Parallel() @@ -8480,7 +8513,7 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) { if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction(imported) error = %v", err) } - reopened.state.NavigateToPath([]string{"Crew", "Internet"}) + reopened.state.NavigateToPath([]string{"Root", "Crew", "Internet"}) reopened.filter() if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) diff --git a/internal/session/session.go b/internal/session/session.go index b443cc3..95fc9a5 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -12,6 +12,7 @@ import ( "strings" "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/vaultview" "git.julianfamily.org/keepassgo/internal/webdav" ) @@ -31,6 +32,7 @@ type Manager struct { remoteClient *webdav.Client remotePath string remoteVersion webdav.Version + warning string } type PreparedLocalOpen struct { @@ -40,6 +42,7 @@ type PreparedLocalOpen struct { Key vault.MasterKey Encoded []byte VaultRoot string + Warning string } type PreparedRemoteOpen struct { @@ -51,6 +54,7 @@ type PreparedRemoteOpen struct { Encoded []byte VaultRoot string RemoteVersion webdav.Version + Warning string } type PreparedUnlock struct { @@ -58,6 +62,7 @@ type PreparedUnlock struct { Config *vault.KDBXConfig Key vault.MasterKey VaultRoot string + Warning string } 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 { - root := detectSingleVaultRoot(model) + root := vaultview.KeepassRoot model = normalizeUnderRoot(model, root) var encoded bytes.Buffer 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.encoded = encoded.Bytes() m.locked = false + m.warning = "" return nil } @@ -118,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error { return nil } +func (m *Manager) ConsumeWarning() string { + warning := strings.TrimSpace(m.warning) + m.warning = "" + return warning +} + func (m *Manager) Save() error { if m.remoteClient != nil && m.remotePath != "" { return m.SaveRemote() @@ -254,7 +266,7 @@ func (m *Manager) SaveAs(path string) error { func (m *Manager) Replace(model vault.Model) { root := m.vaultRoot if root == "" { - root = detectSingleVaultRoot(model) + root = vaultview.KeepassRoot } m.model = normalizeUnderRoot(model, 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{ - Model: model, + Model: normalizeUnderRoot(model, vaultview.KeepassRoot), Config: config, Path: path, Key: key, Encoded: content, - VaultRoot: detectSingleVaultRoot(model), + VaultRoot: vaultview.KeepassRoot, + Warning: normalizationWarning(model), }, 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{ - Model: model, + Model: normalizeUnderRoot(model, vaultview.KeepassRoot), Config: config, Client: client, Path: path, Key: key, Encoded: content, - VaultRoot: detectSingleVaultRoot(model), + VaultRoot: vaultview.KeepassRoot, RemoteVersion: version, + Warning: normalizationWarning(model), }, nil } @@ -341,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error) return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err) } return PreparedUnlock{ - Model: model, + Model: normalizeUnderRoot(model, vaultview.KeepassRoot), Config: config, Key: key, - VaultRoot: detectSingleVaultRoot(model), + VaultRoot: vaultview.KeepassRoot, + Warning: normalizationWarning(model), }, nil } @@ -359,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) { m.remoteClient = nil m.remotePath = "" m.remoteVersion = webdav.Version{} + m.warning = prepared.Warning } func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) { @@ -372,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) { m.remotePath = prepared.Path m.remoteVersion = prepared.RemoteVersion m.path = "" + m.warning = prepared.Warning } func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) { @@ -380,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) { m.key = prepared.Key m.vaultRoot = prepared.VaultRoot m.locked = false + m.warning = prepared.Warning } func (m *Manager) ChangeMasterKey(key vault.MasterKey) error { @@ -584,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error { return err } m.model = merged - if root := detectSingleVaultRoot(merged); root != "" { - m.vaultRoot = root - } + m.vaultRoot = vaultview.KeepassRoot m.encoded = encoded m.locked = false 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) } m.model = merged - if root := detectSingleVaultRoot(merged); root != "" { - m.vaultRoot = root - } + m.vaultRoot = vaultview.KeepassRoot m.encoded = encoded m.remoteVersion = version m.locked = false @@ -867,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string { 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 { if root == "" { return model @@ -888,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model { switch { case len(path) == 0: return []string{root} + case path[0] == "Root": + if len(path) == 1 { + return []string{root} + } + return append([]string{root}, path[1:]...) case path[0] == root: return path + case path[0] == "Templates": + return path default: 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) } } + 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 { out.Groups[i] = normalizePath(out.Groups[i]) } 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) { content, err := os.ReadFile(path) if err != nil { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index d2f38ab..7d2a23d 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -64,7 +64,7 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) { 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" { 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) } - got := current.EntriesInPath([]string{"Root", "Home Assistant"}) + got := current.EntriesInPath([]string{"keepass", "Home Assistant"}) if len(got) != 1 || got[0].Password != "token-2" { 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) { t.Parallel() @@ -169,7 +220,7 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) { 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" { 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) } - got := current.EntriesInPath([]string{"Root", "Internet"}) + got := current.EntriesInPath([]string{"keepass", "Internet"}) if len(got) != 1 || got[0].Password != "token-1" { 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) } - got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) + got := loaded.EntriesInPath([]string{"keepass", "Home Assistant"}) if len(got) != 1 || got[0].Password != "token-2" { t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got) } @@ -513,7 +564,7 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) { if err != nil { 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" { 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) } - got := current.EntriesInPath([]string{"Root", "Internet"}) + got := current.EntriesInPath([]string{"keepass", "Internet"}) if len(got) != 1 { 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) } - got := current.EntriesInPath([]string{"Root", "Internet"}) + got := current.EntriesInPath([]string{"keepass", "Internet"}) if len(got) != 1 { 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) } - got := current.EntriesInPath([]string{"Root", "Internet"}) + got := current.EntriesInPath([]string{"keepass", "Internet"}) if len(got) != 2 { 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) } - got := current.EntriesInPath([]string{"Root", "Internet"}) + got := current.EntriesInPath([]string{"keepass", "Internet"}) if len(got) != 2 { 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) } - got := current.EntriesInPath([]string{"Root", "Internet"}) + got := current.EntriesInPath([]string{"keepass", "Internet"}) if len(got) != 2 { 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) } - got := current.EntriesInPath([]string{"Root", "Internet"}) + got := current.EntriesInPath([]string{"keepass", "Internet"}) if len(got) != 2 { t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) } diff --git a/internal/vault/kdbx.go b/internal/vault/kdbx.go index b4e5850..962f258 100644 --- a/internal/vault/kdbx.go +++ b/internal/vault/kdbx.go @@ -26,6 +26,7 @@ var ErrInvalidMasterKey = errors.New("invalid master key") const ( templatesRoot = "Templates" recycleBinRoot = "Recycle Bin" + keepassRoot = "keepass" keepassGOIDField = "KeePassGO-ID" remoteProfilesKey = "keepassgo.remoteProfiles" ) @@ -502,6 +503,10 @@ func compareGroupNames(a, b string) int { return -1 case b == "Root": return 1 + case a == keepassRoot: + return -1 + case b == keepassRoot: + return 1 case a == templatesRoot: return -1 case b == templatesRoot: diff --git a/internal/vault/kdbx_test.go b/internal/vault/kdbx_test.go index 5a41a89..0077df2 100644 --- a/internal/vault/kdbx_test.go +++ b/internal/vault/kdbx_test.go @@ -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 { group := gokeepasslib.NewGroup() group.Name = name