Use vault bindings for remote opens

This commit is contained in:
Joe Julian
2026-04-06 15:54:52 -07:00
parent 4da9c07b32
commit dc03d6a0a5
2 changed files with 464 additions and 265 deletions
+59
View File
@@ -430,6 +430,8 @@ type ui struct {
syncMenuOpen bool syncMenuOpen bool
mainMenuOpen bool mainMenuOpen bool
selectedRemoteConnection bool selectedRemoteConnection bool
selectedVaultRemoteProfileID string
selectedVaultRemoteCredentialEntryID string
securityDialogOpen bool securityDialogOpen bool
remotePrefsDialogOpen bool remotePrefsDialogOpen bool
showSyncPassword bool showSyncPassword bool
@@ -1069,6 +1071,22 @@ func (u *ui) openRemoteAction() error {
if err != nil { if err != nil {
return err return err
} }
if binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); 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
}
client := webdav.Client{ client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()), Username: strings.TrimSpace(u.remoteUsername.Text()),
@@ -1108,6 +1126,20 @@ func (u *ui) startOpenRemoteAction() {
Password: u.remotePassword.Text(), Password: u.remotePassword.Text(),
} }
remotePath := strings.TrimSpace(u.remotePath.Text()) remotePath := strings.TrimSpace(u.remotePath.Text())
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{
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)
}
u.lastLifecycleAction = "open remote vault" u.lastLifecycleAction = "open remote vault"
u.runBackgroundAction("open remote vault", func() (func() error, error) { u.runBackgroundAction("open remote vault", func() (func() error, error) {
prepared, err := session.PrepareRemoteOpen(client, remotePath, key) prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
@@ -1904,6 +1936,33 @@ func (u *ui) availableRemoteCredentialEntries() []vault.Entry {
return entries return entries
} }
func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) {
if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" || strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" {
return appstate.RemoteBinding{}, false
}
return appstate.RemoteBinding{
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
RemoteProfileID: strings.TrimSpace(u.selectedVaultRemoteProfileID),
CredentialEntryID: strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID),
}, true
}
func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
binding, ok := u.selectedVaultRemoteBinding()
if !ok {
return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
}
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) noteCurrentRemotePath() { func (u *ui) noteCurrentRemotePath() {
status, ok := u.state.Session.(sessionStatus) status, ok := u.state.Session.(sessionStatus)
if !ok || !status.IsRemote() || status.IsLocked() { if !ok || !status.IsRemote() || status.IsLocked() {
+140
View File
@@ -83,6 +83,22 @@ func (s summarySession) HasVault() bool
func (s summarySession) IsLocked() bool { return s.locked } func (s summarySession) IsLocked() bool { return s.locked }
func (s summarySession) IsRemote() bool { return s.remote } func (s summarySession) IsRemote() bool { return s.remote }
type remoteOpenCaptureSession struct {
model vault.Model
remoteClient webdav.Client
remotePath string
}
func (s *remoteOpenCaptureSession) Current() (vault.Model, error) {
return s.model, nil
}
func (s *remoteOpenCaptureSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
s.remoteClient = client
s.remotePath = path
return nil
}
func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1658,6 +1674,130 @@ func TestUIOpenRemoteReportsTransportFailure(t *testing.T) {
} }
} }
func TestUIOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) {
t.Parallel()
sess := &remoteOpenCaptureSession{
model: vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "WebDAV Sign-In",
Username: "tjulian",
Password: "token-1",
Path: []string{"Crew", "Internet"},
}},
RemoteProfiles: []vault.RemoteProfile{{
ID: "family-webdav",
Name: "Family Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/family/keepass.kdbx",
}},
},
}
u := newUIWithSession("desktop", sess)
u.masterPassword.SetText("correct horse battery staple")
u.selectedVaultRemoteProfileID = "family-webdav"
u.selectedVaultRemoteCredentialEntryID = "remote-creds-1"
if err := u.openRemoteAction(); err != nil {
t.Fatalf("openRemoteAction() error = %v", err)
}
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("remoteClient.BaseURL = %q, want remote.php/dav URL", got)
}
if got := sess.remoteClient.Username; got != "tjulian" {
t.Fatalf("remoteClient.Username = %q, want tjulian", got)
}
if got := sess.remoteClient.Password; got != "token-1" {
t.Fatalf("remoteClient.Password = %q, want token-1", got)
}
if got := sess.remotePath; got != "files/family/keepass.kdbx" {
t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got)
}
if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got)
}
if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
t.Fatalf("remotePath editor = %q, want resolved profile path", got)
}
}
func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) {
t.Parallel()
localKey := vault.MasterKey{Password: "correct horse battery staple"}
localModel := vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "WebDAV Sign-In",
Username: "tjulian",
Password: "token-1",
Path: []string{"Crew", "Internet"},
}},
RemoteProfiles: []vault.RemoteProfile{{
ID: "family-webdav",
Name: "Family Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "",
Path: "files/family/keepass.kdbx",
}},
}
remoteModel := 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"},
}},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("unexpected method %s", r.Method)
}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, remoteModel, localKey); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
w.Header().Set("ETag", "\"v1\"")
_, _ = w.Write(encoded.Bytes())
}))
defer server.Close()
localModel.RemoteProfiles[0].BaseURL = server.URL
manager := &session.Manager{}
if err := manager.Create(localModel, localKey); err != nil {
t.Fatalf("manager.Create() error = %v", err)
}
u := newUIWithSession("desktop", manager)
u.masterPassword.SetText(localKey.Password)
u.selectedVaultRemoteProfileID = "family-webdav"
u.selectedVaultRemoteCredentialEntryID = "remote-creds-1"
u.startOpenRemoteAction()
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.remoteBaseURL.Text(); got != server.URL {
t.Fatalf("remoteBaseURL = %q, want server URL from selected profile", got)
}
if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
t.Fatalf("remotePath = %q, want selected profile path", got)
}
}
func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
t.Parallel() t.Parallel()