From db74a0a1980f745e989d2cc12de08fd858233b0f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 16:12:53 -0700 Subject: [PATCH] Bootstrap remote opens from local cache vaults --- main.go | 114 ++++++++++++++++++++++++-------- main_test.go | 179 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 258 insertions(+), 35 deletions(-) diff --git a/main.go b/main.go index 1391f42..36761cb 100644 --- a/main.go +++ b/main.go @@ -1080,23 +1080,21 @@ func (u *ui) openRemoteAction() error { if err != nil { return err } - if u.hasOpenVault() { - if binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil { + if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil { + return err + } else if ok { + if err := u.state.OpenBoundRemoteVault(binding, key); err != nil { return err - } else if ok { - if err := u.state.OpenBoundRemoteVault(binding, key); err != nil { - return err - } - u.remoteBaseURL.SetText(resolved.Profile.BaseURL) - u.remotePath.SetText(resolved.Profile.Path) - u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) - u.resetPasswordPeek() - u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil } + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil } client := webdav.Client{ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), @@ -1137,24 +1135,57 @@ func (u *ui) startOpenRemoteAction() { Password: u.remotePassword.Text(), } remotePath := strings.TrimSpace(u.remotePath.Text()) - if u.hasOpenVault() { - if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil { - u.state.ErrorMessage = u.describeActionError("open remote vault", err) - u.requestMasterPassFocus = true - return - } else if ok { - client = webdav.Client{ + u.lastLifecycleAction = "open remote vault" + u.runBackgroundAction("open remote vault", func() (func() error, error) { + binding, bindingOK := u.selectedVaultRemoteBinding() + if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" { + preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key) + if err != nil { + return nil, err + } + resolved, err := binding.Resolve(preparedLocal.Model) + if err != nil { + return nil, err + } + preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{ BaseURL: resolved.Profile.BaseURL, Username: resolved.Credentials.Username, Password: resolved.Credentials.Password, + }, resolved.Profile.Path, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedLocalOpen(preparedLocal) + u.vaultPath.SetText(binding.LocalVaultPath) + u.noteRecentVault(binding.LocalVaultPath) + u.restoreRecentVaultGroup(binding.LocalVaultPath) + manager.ApplyPreparedRemoteOpen(preparedRemote) + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + }, nil + } + if u.hasOpenVault() { + if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil { + return nil, err + } else if ok { + client = webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + remotePath = resolved.Profile.Path + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) } - remotePath = resolved.Profile.Path - u.remoteBaseURL.SetText(resolved.Profile.BaseURL) - u.remotePath.SetText(resolved.Profile.Path) } - } - u.lastLifecycleAction = "open remote vault" - u.runBackgroundAction("open remote vault", func() (func() error, error) { prepared, err := session.PrepareRemoteOpen(client, remotePath, key) if err != nil { return nil, err @@ -2066,6 +2097,33 @@ func (u *ui) saveCurrentRemoteBindingAction() error { return nil } +func (u *ui) bootstrapSelectedVaultRemoteBinding(key vault.MasterKey) (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { + if u.hasOpenVault() { + return u.resolvedSelectedVaultRemoteBinding() + } + + binding, ok := u.selectedVaultRemoteBinding() + if !ok || strings.TrimSpace(binding.LocalVaultPath) == "" { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil + } + if err := u.state.OpenVault(binding.LocalVaultPath, key); err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + u.vaultPath.SetText(binding.LocalVaultPath) + u.noteRecentVault(binding.LocalVaultPath) + u.restoreRecentVaultGroup(binding.LocalVaultPath) + + model, err := u.state.Session.Current() + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + resolved, err := binding.Resolve(model) + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + return binding, resolved, true, nil +} + func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { binding, ok := u.selectedVaultRemoteBinding() if !ok { diff --git a/main_test.go b/main_test.go index 34df635..b32fed7 100644 --- a/main_test.go +++ b/main_test.go @@ -1725,6 +1725,83 @@ func TestUIOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { } } +func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "remote-token", + Path: []string{"Root", "Internet"}, + }}, + } + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "tjulian" || password != "token-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (tjulian, token-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(remoteBytes.Bytes()) + })) + defer server.Close() + + localModel := vault.Model{} + if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + RemoteProfileName: "family.kdbx · dav.example.invalid", + BaseURL: server.URL, + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "WebDAV Sign-In · tjulian", + Username: "tjulian", + Password: "token-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: appstate.SyncModeAutomaticOnOpenSave, + }); err != nil { + t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err) + } + writeKDBXMainTestFile(t, localPath, localModel, key) + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText(key.Password) + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }) + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := u.vaultPath.Text(); got != localPath { + t.Fatalf("vaultPath = %q, want %q", got, localPath) + } + current, err := u.state.Session.Current() + 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) + } +} + func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { t.Parallel() @@ -1798,6 +1875,92 @@ func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { } } +func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "remote-token", + Path: []string{"Root", "Internet"}, + }}, + } + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "tjulian" || password != "token-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (tjulian, token-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(remoteBytes.Bytes()) + })) + defer server.Close() + + localModel := vault.Model{} + if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + RemoteProfileName: "family.kdbx · dav.example.invalid", + BaseURL: server.URL, + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "WebDAV Sign-In · tjulian", + Username: "tjulian", + Password: "token-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: appstate.SyncModeAutomaticOnOpenSave, + }); err != nil { + t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err) + } + writeKDBXMainTestFile(t, localPath, localModel, key) + + manager := &session.Manager{} + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(key.Password) + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }) + + u.startOpenRemoteAction() + + if got := u.loadingMessage; got != "Open remote vault..." { + t.Fatalf("loadingMessage after start = %q, want %q", got, "Open remote vault...") + } + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.vaultPath.Text(); got != localPath { + t.Fatalf("vaultPath = %q, want %q", got, localPath) + } + current, err := u.state.Session.Current() + 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) + } +} + func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { t.Parallel() @@ -5134,9 +5297,14 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) })) defer server.Close() - first := newUIWithSession("desktop", &session.Manager{}) - first.recentRemotesPath = statePath - first.recentRemotes = nil + paths := statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: statePath, + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + } + + first := newUIWithState("desktop", &session.Manager{}, paths) first.lifecycleMode = "remote" first.masterPassword.SetText("correct horse battery staple") first.remoteBaseURL.SetText(server.URL) @@ -5149,10 +5317,7 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) first.syncedPath = []string{"Root", "Internet"} first.noteCurrentRemotePath() - reopened := newUIWithSession("desktop", &session.Manager{}) - reopened.recentRemotesPath = statePath - reopened.recentRemotes = nil - reopened.loadRecentRemotes() + reopened := newUIWithState("desktop", &session.Manager{}, paths) reopened.lifecycleMode = "remote" reopened.masterPassword.SetText("correct horse battery staple") reopened.remoteBaseURL.SetText(server.URL)