From 5e1e9f82fb7aa641a58e0bcffbca3233fbb24055 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 11:32:04 -0700 Subject: [PATCH] Store remote sync bindings in vault data --- .gitignore | 4 ++ appstate/remote_binding.go | 83 ++++++++++++++++++++++++++++ appstate/remote_binding_test.go | 95 +++++++++++++++++++++++++++++++++ main_test.go | 16 +++++- 4 files changed, 196 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 125a61f..94aff83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ build/ *.apk +packaging/archlinux/keepassgo-git/*.pkg.tar.zst +packaging/archlinux/keepassgo-git/pkg/ +packaging/archlinux/keepassgo-git/src/ +packaging/archlinux/keepassgo-git/keepassgo/ diff --git a/appstate/remote_binding.go b/appstate/remote_binding.go index 20e2366..dacb4ef 100644 --- a/appstate/remote_binding.go +++ b/appstate/remote_binding.go @@ -2,6 +2,7 @@ package appstate import ( "fmt" + "strings" "git.julianfamily.org/keepassgo/vault" ) @@ -25,6 +26,20 @@ type ResolvedRemoteBinding struct { Credentials vault.Entry } +type RemoteBindingInput struct { + LocalVaultPath string + RemoteProfileID string + RemoteProfileName string + BaseURL string + RemotePath string + CredentialEntryID string + CredentialTitle string + Username string + Password string + CredentialPath []string + SyncMode SyncMode +} + func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) { profile, err := model.RemoteProfileByID(b.RemoteProfileID) if err != nil { @@ -39,3 +54,71 @@ func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) Credentials: credentials, }, nil } + +func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) { + if model == nil { + return RemoteBinding{}, fmt.Errorf("model is required") + } + + input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath) + input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID) + input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName) + input.BaseURL = strings.TrimSpace(input.BaseURL) + input.RemotePath = strings.TrimSpace(input.RemotePath) + input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID) + input.CredentialTitle = strings.TrimSpace(input.CredentialTitle) + input.Username = strings.TrimSpace(input.Username) + + switch { + case input.LocalVaultPath == "": + return RemoteBinding{}, fmt.Errorf("local vault path is required") + case input.RemoteProfileID == "": + return RemoteBinding{}, fmt.Errorf("remote profile id is required") + case input.BaseURL == "": + return RemoteBinding{}, fmt.Errorf("remote base URL is required") + case input.RemotePath == "": + return RemoteBinding{}, fmt.Errorf("remote path is required") + case input.CredentialEntryID == "": + return RemoteBinding{}, fmt.Errorf("credential entry id is required") + case input.Password == "": + return RemoteBinding{}, fmt.Errorf("credential password is required") + } + + if input.RemoteProfileName == "" { + input.RemoteProfileName = input.RemoteProfileID + } + if input.CredentialTitle == "" { + input.CredentialTitle = "Remote Sign-In" + } + + model.UpsertRemoteProfile(vault.RemoteProfile{ + ID: input.RemoteProfileID, + Name: input.RemoteProfileName, + Backend: vault.RemoteBackendWebDAV, + BaseURL: input.BaseURL, + Path: input.RemotePath, + }) + model.UpsertEntry(vault.Entry{ + ID: input.CredentialEntryID, + Title: input.CredentialTitle, + Username: input.Username, + Password: input.Password, + Path: append([]string(nil), input.CredentialPath...), + }) + + return RemoteBinding{ + LocalVaultPath: input.LocalVaultPath, + RemoteProfileID: input.RemoteProfileID, + CredentialEntryID: input.CredentialEntryID, + SyncMode: normalizeSyncMode(input.SyncMode), + }, nil +} + +func normalizeSyncMode(mode SyncMode) SyncMode { + switch mode { + case SyncModeAutomaticOnOpenSave: + return SyncModeAutomaticOnOpenSave + default: + return SyncModeManual + } +} diff --git a/appstate/remote_binding_test.go b/appstate/remote_binding_test.go index bc9e036..31a5703 100644 --- a/appstate/remote_binding_test.go +++ b/appstate/remote_binding_test.go @@ -114,3 +114,98 @@ func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) { } } } + +func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) { + t.Parallel() + + var model vault.Model + + binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + RemoteProfileName: "Family Vault", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "WebDAV Sign-In", + Username: "tjulian", + Password: "token-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: SyncModeAutomaticOnOpenSave, + }) + if err != nil { + t.Fatalf("ConfigureRemoteBinding() error = %v", err) + } + + if len(model.RemoteProfiles) != 1 { + t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles)) + } + if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got) + } + + credentials, err := model.EntryByID("remote-creds-1") + if err != nil { + t.Fatalf("EntryByID(remote-creds-1) error = %v", err) + } + if credentials.Username != "tjulian" || credentials.Password != "token-1" { + t.Fatalf("stored credential entry = %#v, want tjulian/token-1", credentials) + } + + if binding.LocalVaultPath != "/tmp/family.kdbx" { + t.Fatalf("binding LocalVaultPath = %q, want /tmp/family.kdbx", binding.LocalVaultPath) + } + if binding.RemoteProfileID != "family-webdav" || binding.CredentialEntryID != "remote-creds-1" { + t.Fatalf("binding = %#v, want only vault references", binding) + } +} + +func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input RemoteBindingInput + }{ + { + name: "missing_local_vault_path", + input: RemoteBindingInput{ + RemoteProfileID: "family-webdav", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + Password: "token-1", + }, + }, + { + name: "missing_remote_base_url", + input: RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + Password: "token-1", + }, + }, + { + name: "missing_credential_entry_id", + input: RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + Password: "token-1", + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var model vault.Model + if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil { + t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input) + } + }) + } +} diff --git a/main_test.go b/main_test.go index f41920d..3165c59 100644 --- a/main_test.go +++ b/main_test.go @@ -4411,7 +4411,13 @@ func TestShowLocalVaultChooser(t *testing.T) { func TestShowRemoteConnectionChooser(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + dir := t.TempDir() + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + }) u.lifecycleMode = "remote" u.remoteBaseURL.SetText("") u.remotePath.SetText("") @@ -4442,7 +4448,13 @@ func TestShowRemoteConnectionChooser(t *testing.T) { func TestApplyingRecentRemoteRecordMarksSelectedRemoteConnection(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + dir := t.TempDir() + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + }) if u.hasSelectedRemoteTarget() { t.Fatal("hasSelectedRemoteTarget() = true, want false before selecting a saved remote connection") }