diff --git a/.gitignore b/.gitignore index 147d7ee..125a61f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -gio-keepass-mock +build/ +*.apk diff --git a/AGENTS.md b/AGENTS.md index 8cefd2d..1eb9c36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,7 @@ These features are product requirements, not “nice to have” ideas. - Keep `golangci-lint` passing. - Keep `go test ./...` passing. +- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging. - When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the user’s real config. - Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory. - Do not commit generated binaries. diff --git a/APK.md b/APK.md new file mode 100644 index 0000000..1aa68ed --- /dev/null +++ b/APK.md @@ -0,0 +1,22 @@ +# Android Build + +Build the APK with: + +```sh +make apk +``` + +Environment: + +- `ANDROID_SDK_ROOT` or `ANDROID_HOME` must point at an Android SDK install. +- `APP_ID` overrides the Android application id. +- `APK_OUT` overrides the output path. +- `APK_VERSION` overrides the packaged app version. +- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK. +- `ANDROID_TARGET_SDK` overrides the target Android SDK. + +The repo tracks `gogio` as a Go tool, so the build runs through: + +```sh +go tool gogio -target android ... +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bf867c1 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +APP_ID ?= org.julianfamily.keepassgo +APK_OUT ?= build/keepassgo.apk +APK_VERSION ?= 0.1.0.1 +ANDROID_MIN_SDK ?= 28 +ANDROID_TARGET_SDK ?= 35 + +.PHONY: apk +apk: + @test -n "$${ANDROID_SDK_ROOT:-$${ANDROID_HOME:-}}" || { echo "Set ANDROID_SDK_ROOT or ANDROID_HOME"; exit 1; } + go tool gogio -target android \ + -buildmode exe \ + -appid $(APP_ID) \ + -o $(APK_OUT) \ + -version $(APK_VERSION) \ + -minsdk $(ANDROID_MIN_SDK) \ + -targetsdk $(ANDROID_TARGET_SDK) \ + . diff --git a/appstate/state.go b/appstate/state.go index b468eb5..85e56b4 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -53,6 +53,14 @@ type SynchronizableSession interface { Synchronize() error } +type AdvancedSynchronizableSession interface { + CurrentSession + SynchronizeFromLocal(string) error + SynchronizeToLocal(string) error + SynchronizeFromRemote(webdav.Client, string) error + SynchronizeToRemote(webdav.Client, string) error +} + type CreateableSession interface { CurrentSession Create(vault.Model, vault.MasterKey) error @@ -529,6 +537,54 @@ func (s *State) Synchronize() error { return nil } +func (s *State) SynchronizeFromLocal(path string) error { + session, ok := s.Session.(AdvancedSynchronizableSession) + if !ok { + return fmt.Errorf("session is not advanced-synchronizable") + } + if err := session.SynchronizeFromLocal(path); err != nil { + return err + } + s.Dirty = false + return nil +} + +func (s *State) SynchronizeToLocal(path string) error { + session, ok := s.Session.(AdvancedSynchronizableSession) + if !ok { + return fmt.Errorf("session is not advanced-synchronizable") + } + if err := session.SynchronizeToLocal(path); err != nil { + return err + } + s.Dirty = true + return nil +} + +func (s *State) SynchronizeFromRemote(client webdav.Client, path string) error { + session, ok := s.Session.(AdvancedSynchronizableSession) + if !ok { + return fmt.Errorf("session is not advanced-synchronizable") + } + if err := session.SynchronizeFromRemote(client, path); err != nil { + return err + } + s.Dirty = false + return nil +} + +func (s *State) SynchronizeToRemote(client webdav.Client, path string) error { + session, ok := s.Session.(AdvancedSynchronizableSession) + if !ok { + return fmt.Errorf("session is not advanced-synchronizable") + } + if err := session.SynchronizeToRemote(client, path); err != nil { + return err + } + s.Dirty = true + return nil +} + func (s *State) CreateVault(key vault.MasterKey) error { session, ok := s.Session.(CreateableSession) if !ok { diff --git a/go.mod b/go.mod index f4eb6f7..b7a77d1 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect + gioui.org/cmd v0.9.0 // indirect gioui.org/shader v1.0.8 // indirect github.com/4meepo/tagalign v1.4.2 // indirect github.com/Abirdcfly/dupword v0.1.3 // indirect @@ -26,6 +27,7 @@ require ( github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/akavel/rsrc v0.10.1 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect github.com/alexkohler/nakedret/v2 v2.0.5 // indirect github.com/alexkohler/prealloc v1.0.0 // indirect @@ -207,4 +209,7 @@ require ( mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect ) -tool github.com/golangci/golangci-lint/cmd/golangci-lint +tool ( + gioui.org/cmd/gogio + github.com/golangci/golangci-lint/cmd/golangci-lint +) diff --git a/go.sum b/go.sum index 80dcd53..0c3c999 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc= gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao= +gioui.org/cmd v0.9.0 h1:H1F2u3vBAd8TDRvaJd4IbrbbiOPBWc7Z3ZykOYoq/20= +gioui.org/cmd v0.9.0/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= @@ -66,6 +68,8 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+ github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= +github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o= +github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= diff --git a/main.go b/main.go index 3a5f95d..d779e00 100644 --- a/main.go +++ b/main.go @@ -88,10 +88,10 @@ type recentVaultRecord struct { } type recentRemoteRecord struct { - BaseURL string `json:"baseUrl"` - Path string `json:"path"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` + BaseURL string `json:"baseUrl"` + Path string `json:"path"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` LastGroup []string `json:"lastGroup,omitempty"` } @@ -99,6 +99,20 @@ type uiPreferences struct { GroupControlsHidden bool `json:"groupControlsHidden"` } +type syncSourceMode string + +const ( + syncSourceLocal syncSourceMode = "local" + syncSourceRemote syncSourceMode = "remote" +) + +type syncDirection string + +const ( + syncDirectionPull syncDirection = "pull" + syncDirectionPush syncDirection = "push" +) + type ui struct { mode string theme *material.Theme @@ -143,10 +157,15 @@ type ui struct { openRemote widget.Clickable changeMasterKey widget.Clickable synchronizeVault widget.Clickable + toggleSyncMenu widget.Clickable + openAdvancedSync widget.Clickable + closeAdvancedSync widget.Clickable + runAdvancedSync widget.Clickable editEntry widget.Clickable cancelEdit widget.Clickable pickVaultPath widget.Clickable pickKeyFile widget.Clickable + pickSyncLocalPath widget.Clickable addEntry widget.Clickable saveEntry widget.Clickable duplicateEntry widget.Clickable @@ -169,11 +188,16 @@ type ui struct { addCustomField widget.Clickable toggleGroupControls widget.Clickable togglePasswordInline widget.Clickable + toggleSyncPassword widget.Clickable showEntries widget.Clickable showTemplates widget.Clickable showRecycle widget.Clickable showLocalLifecycle widget.Clickable showRemoteLifecycle widget.Clickable + showSyncLocal widget.Clickable + showSyncRemote widget.Clickable + showSyncPull widget.Clickable + showSyncPush widget.Clickable rememberRemoteAuth widget.Bool entryClicks []widget.Clickable historyClicks []widget.Clickable @@ -200,9 +224,20 @@ type ui struct { copyIcon *widget.Icon expandMoreIcon *widget.Icon expandLessIcon *widget.Icon + chevronDownIcon *widget.Icon clipboardWriter clipboard.Writer loadingMessage string lifecycleMode string + syncSourceMode syncSourceMode + syncDirection syncDirection + syncLocalPath widget.Editor + syncRemoteBaseURL widget.Editor + syncRemotePath widget.Editor + syncRemoteUsername widget.Editor + syncRemotePassword widget.Editor + syncDialogOpen bool + syncMenuOpen bool + showSyncPassword bool keyboardFocus focusID defaultSaveAsPath string recentVaultsPath string @@ -268,6 +303,11 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) remotePath: widget.Editor{SingleLine: true, Submit: false}, remoteUsername: widget.Editor{SingleLine: true, Submit: false}, remotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'}, + syncLocalPath: widget.Editor{SingleLine: true, Submit: false}, + syncRemoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, + syncRemotePath: widget.Editor{SingleLine: true, Submit: false}, + syncRemoteUsername: widget.Editor{SingleLine: true, Submit: false}, + syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'}, masterPassword: widget.Editor{SingleLine: true, Submit: false}, keyFilePath: widget.Editor{SingleLine: true, Submit: false}, entryID: widget.Editor{SingleLine: true, Submit: false}, @@ -303,6 +343,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) recentRemotesPath: paths.RecentRemotesPath, recentVaultGroups: map[string][]string{}, now: time.Now, + syncSourceMode: syncSourceLocal, + syncDirection: syncDirectionPull, } u.state.Session = sess u.phoneSplit.Value = 0.46 @@ -311,6 +353,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore) u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess) + u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown) u.passwordProfile.SetText("strong") u.keyboardFocus = focusSearch u.setCustomFieldRows(nil) @@ -648,6 +691,77 @@ func (u *ui) synchronizeAction() error { if err := u.state.Synchronize(); err != nil { return err } + u.syncMenuOpen = false + u.filter() + return nil +} + +func (u *ui) openAdvancedSyncDialog() { + u.syncDialogOpen = true + u.syncMenuOpen = false + u.showSyncPassword = false + if strings.TrimSpace(u.syncLocalPath.Text()) == "" { + u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) + } +} + +func (u *ui) advancedSyncAction() error { + switch u.syncDirection { + case syncDirectionPush: + return u.advancedSyncToAction() + default: + return u.advancedSyncFromAction() + } +} + +func (u *ui) advancedSyncFromAction() error { + switch u.syncSourceMode { + case syncSourceRemote: + client := webdav.Client{ + BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), + Username: strings.TrimSpace(u.syncRemoteUsername.Text()), + Password: u.syncRemotePassword.Text(), + } + if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { + return err + } + default: + path := strings.TrimSpace(u.syncLocalPath.Text()) + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.SynchronizeFromLocal(path); err != nil { + return err + } + } + u.syncDialogOpen = false + u.showSyncPassword = false + u.filter() + return nil +} + +func (u *ui) advancedSyncToAction() error { + switch u.syncSourceMode { + case syncSourceRemote: + client := webdav.Client{ + BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), + Username: strings.TrimSpace(u.syncRemoteUsername.Text()), + Password: u.syncRemotePassword.Text(), + } + if err := u.state.SynchronizeToRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { + return err + } + default: + path := strings.TrimSpace(u.syncLocalPath.Text()) + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.SynchronizeToLocal(path); err != nil { + return err + } + } + u.syncDialogOpen = false + u.showSyncPassword = false u.filter() return nil } @@ -1320,6 +1434,19 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.synchronizeVault.Clicked(gtx) { u.runAction("synchronize vault", u.synchronizeAction) } + for u.toggleSyncMenu.Clicked(gtx) { + u.syncMenuOpen = !u.syncMenuOpen + } + for u.openAdvancedSync.Clicked(gtx) { + u.openAdvancedSyncDialog() + } + for u.closeAdvancedSync.Clicked(gtx) { + u.syncDialogOpen = false + u.showSyncPassword = false + } + for u.runAdvancedSync.Clicked(gtx) { + u.runAction("advanced synchronize vault", u.advancedSyncAction) + } for u.unlockVault.Clicked(gtx) { u.runAction("unlock vault", u.unlockAction) } @@ -1341,6 +1468,18 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.showRemoteLifecycle.Clicked(gtx) { u.lifecycleMode = "remote" } + for u.showSyncLocal.Clicked(gtx) { + u.syncSourceMode = syncSourceLocal + } + for u.showSyncRemote.Clicked(gtx) { + u.syncSourceMode = syncSourceRemote + } + for u.showSyncPull.Clicked(gtx) { + u.syncDirection = syncDirectionPull + } + for u.showSyncPush.Clicked(gtx) { + u.syncDirection = syncDirectionPush + } for u.lockVault.Clicked(gtx) { u.runAction("lock vault", u.lockAction) } @@ -1358,6 +1497,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.pickKeyFile.Clicked(gtx) { u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) } + for u.pickSyncLocalPath.Clicked(gtx) { + u.runAction("choose sync path", func() error { return u.chooseExistingFileAction(&u.syncLocalPath) }) + } for i := range u.recentVaultClicks { for u.recentVaultClicks[i].Clicked(gtx) { if i < len(u.recentVaults) { @@ -1462,66 +1604,84 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.togglePasswordInline.Clicked(gtx) { u.showPassword = !u.showPassword } + for u.toggleSyncPassword.Clicked(gtx) { + u.showSyncPassword = !u.showSyncPassword + if u.showSyncPassword { + u.syncRemotePassword.Mask = 0 + } else { + u.syncRemotePassword.Mask = '•' + } + } if _, changed := u.search.Update(gtx); changed { u.filter() } inset := layout.UniformInset(unit.Dp(16)) - return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { - return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(u.header), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.bannerSurface().Kind == bannerNone { - return layout.Dimensions{} - } + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { + return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(u.banner), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - ) - }), - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - if u.shouldShowLifecycleSetup() { - return u.lifecycleScreen(gtx) - } - if u.shouldUseLockedSinglePane() { - return u.detailPanel(gtx) - } - if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) { - u.phoneSpan = gtx.Constraints.Max.Y - listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) - if listHeight < gtx.Dp(unit.Dp(180)) { - listHeight = gtx.Dp(unit.Dp(180)) - } - if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) { - listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)) - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - gtx.Constraints.Min.Y = listHeight - gtx.Constraints.Max.Y = listHeight - return u.listPanel(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(u.phoneSlider), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - func() layout.FlexChild { - if u.shouldUseCompactPhoneDetailPane() { - return layout.Rigid(u.detailPanel) + layout.Rigid(u.header), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.bannerSurface().Kind == bannerNone { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(u.banner), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + ) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + if u.shouldShowLifecycleSetup() { + return u.lifecycleScreen(gtx) + } + if u.shouldUseLockedSinglePane() { + return u.detailPanel(gtx) + } + if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) { + u.phoneSpan = gtx.Constraints.Max.Y + listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) + if listHeight < gtx.Dp(unit.Dp(180)) { + listHeight = gtx.Dp(unit.Dp(180)) } - return layout.Flexed(1, u.detailPanel) - }(), - ) - } - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Flexed(0.38, u.listPanel), - layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), - layout.Flexed(0.62, u.detailPanel), + if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) { + listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.Y = listHeight + gtx.Constraints.Max.Y = listHeight + return u.listPanel(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(u.phoneSlider), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + func() layout.FlexChild { + if u.shouldUseCompactPhoneDetailPane() { + return layout.Rigid(u.detailPanel) + } + return layout.Flexed(1, u.detailPanel) + }(), + ) + } + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Flexed(0.38, u.listPanel), + layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), + layout.Flexed(0.62, u.detailPanel), + ) + }), ) - }), - ) - }) - }) + }) + }) + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + if !u.syncDialogOpen { + return layout.Dimensions{} + } + return u.syncDialog(gtx) + }), + ) } func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { @@ -1539,6 +1699,134 @@ func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { }) } +func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) + return layout.Dimensions{Size: gtx.Constraints.Max} + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + width := gtx.Dp(unit.Dp(620)) + if width > gtx.Constraints.Max.X { + width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) + } + if width < 1 { + width = gtx.Constraints.Max.X + } + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return card(gtx, u.syncDialogContent) + }) + }), + ) +} + +func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { + return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(20), "Synchronize...") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), "Choose another source and whether to pull it into the current vault or push the current vault to it.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showSyncPull, "Pull From Source") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showSyncPush, "Push To Source") + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showSyncLocal, "Local File") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV") + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.syncSourceMode == syncSourceRemote { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.syncPasswordField(gtx) + }), + ) + } + return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.runAdvancedSync, "Synchronize") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel") + }), + ) + }), + ) + }) +} + +func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + field := func(gtx layout.Context) layout.Dimensions { + editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password") + editor.Color = u.theme.Palette.Fg + editor.HintColor = mutedColor + return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return outlinedFieldState(gtx, false, field) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) +} + func (u *ui) header(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { @@ -1578,10 +1866,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.synchronizeVault, "Synchronize") - return btn.Layout(gtx) - }), + layout.Rigid(u.syncButtonGroup), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") @@ -1590,6 +1875,45 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { ) } +func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.synchronizeVault, "Synchronize") + btn.CornerRadius = unit.Dp(10) + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.syncMenuToggle(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !u.syncMenuOpen { + return layout.Dimensions{} + } + return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, u.syncMenu) + }), + ) +} + +func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { + btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions") + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + return btn.Layout(gtx) +} + +func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Synchronize...") + }), + ) + }) +} + func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card spacing := unit.Dp(12) @@ -1848,10 +2172,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.synchronizeVault, "Synchronize") - return btn.Layout(gtx) - }), + layout.Rigid(u.syncButtonGroup), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") @@ -2288,12 +2609,6 @@ func detailLine(th *material.Theme, label, value string) layout.Widget { func (u *ui) passwordLine(label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { - icon := u.eyeIcon - desc := "Show password" - if u.showPassword { - icon = u.eyeOffIcon - desc = "Hide password" - } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(label)) @@ -2307,12 +2622,7 @@ func (u *ui) passwordLine(label, value string) layout.Widget { return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.IconButton(u.theme, &u.togglePasswordInline, icon, desc) - btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} - btn.Color = accentColor - btn.Size = unit.Dp(18) - btn.Inset = layout.UniformInset(unit.Dp(8)) - return btn.Layout(gtx) + return u.inlinePasswordToggle(gtx, &u.togglePasswordInline, u.showPassword) }), ) }), @@ -2320,6 +2630,21 @@ func (u *ui) passwordLine(label, value string) layout.Widget { } } +func (u *ui) inlinePasswordToggle(gtx layout.Context, click *widget.Clickable, showing bool) layout.Dimensions { + icon := u.eyeIcon + desc := "Show password" + if showing { + icon = u.eyeOffIcon + desc = "Hide password" + } + btn := material.IconButton(u.theme, click, icon, desc) + btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} + btn.Color = accentColor + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + return btn.Layout(gtx) +} + func (u *ui) detailPasswordValue() string { item, ok := u.selectedEntry() if !ok { diff --git a/main_test.go b/main_test.go index d0d5d17..d4f3ad6 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "errors" + "io" "net/http" "net/http/httptest" "os" @@ -12,8 +13,8 @@ import ( "testing" "time" - "gioui.org/layout" "gioui.org/io/key" + "gioui.org/layout" "gioui.org/unit" "gioui.org/widget" @@ -678,6 +679,145 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { } } +func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + otherPath := filepath.Join(t.TempDir(), "other.kdbx") + + writeKDBXMainTestFile(t, currentPath, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-current", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key) + writeKDBXMainTestFile(t, otherPath, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-other", + Title: "Bellagio", + Username: "rustyryan", + Password: "token-other", + URL: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key) + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(currentPath) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + u.openAdvancedSyncDialog() + u.syncDirection = syncDirectionPull + u.syncSourceMode = syncSourceLocal + u.syncLocalPath.SetText(otherPath) + if err := u.advancedSyncAction(); err != nil { + t.Fatalf("advancedSyncAction() error = %v", err) + } + + var reopened session.Manager + if err := reopened.Open(currentPath, key); err != nil { + t.Fatalf("reopen Open(current) error = %v", err) + } + model, err := reopened.Current() + 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) + } +} + +func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + writeKDBXMainTestFile(t, currentPath, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-current", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key) + + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-remote", + Title: "Bellagio", + Username: "rustyryan", + Password: "token-remote", + URL: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + etag := "\"v1\"" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("ETag", etag) + _, _ = w.Write(remoteBytes.Bytes()) + case http.MethodPut: + payload, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll(PUT body) error = %v", err) + } + remoteBytes.Reset() + if _, err := remoteBytes.Write(payload); err != nil { + t.Fatalf("Write(remoteBytes) error = %v", err) + } + etag = "\"v2\"" + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(currentPath) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + u.openAdvancedSyncDialog() + u.syncDirection = syncDirectionPush + u.syncSourceMode = syncSourceRemote + u.syncRemoteBaseURL.SetText(server.URL) + u.syncRemotePath.SetText("vaults/other.kdbx") + if err := u.advancedSyncAction(); err != nil { + t.Fatalf("advancedSyncAction() error = %v", err) + } + + var reopened session.Manager + if err := reopened.OpenRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx", key); err != nil { + t.Fatalf("OpenRemote(reopened) error = %v", err) + } + model, err := reopened.Current() + 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) + } +} + func TestUIMasterKeyInputSupportsKeyFileAndCompositeKeys(t *testing.T) { t.Parallel() @@ -2839,3 +2979,15 @@ func TestUICurrentMasterKeyReportsUnreadableKeyFile(t *testing.T) { t.Fatalf("currentMasterKey() error = %v, want os.ErrNotExist", err) } } + +func writeKDBXMainTestFile(t *testing.T, path string, model vault.Model, key vault.MasterKey) { + t.Helper() + + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey(%s) error = %v", path, err) + } + if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { + t.Fatalf("WriteFile(%s) error = %v", path, err) + } +} diff --git a/session/session.go b/session/session.go index aa0963e..7594999 100644 --- a/session/session.go +++ b/session/session.go @@ -141,6 +141,64 @@ func (m *Manager) Synchronize() error { } } +func (m *Manager) SynchronizeFromLocal(path string) error { + other, _, err := loadLocalSource(path, m.key) + if err != nil { + return err + } + merged, err := m.mergedWithPeer(other) + if err != nil { + return err + } + return m.persistMergedToCurrentSource(merged) +} + +func (m *Manager) SynchronizeToLocal(path string) error { + other, config, err := loadLocalSourceOrEmpty(path, m.key) + if err != nil { + return err + } + merged, err := m.mergedWithPeer(other) + if err != nil { + return err + } + if err := saveModelToLocal(path, merged, m.key, configOrCurrent(config, m.config)); err != nil { + return err + } + m.model = merged + m.locked = false + return nil +} + +func (m *Manager) SynchronizeFromRemote(client webdav.Client, path string) error { + other, _, _, err := loadRemoteSource(client, path, m.key) + if err != nil { + return err + } + merged, err := m.mergedWithPeer(other) + if err != nil { + return err + } + return m.persistMergedToCurrentSource(merged) +} + +func (m *Manager) SynchronizeToRemote(client webdav.Client, path string) error { + other, config, version, err := loadRemoteSourceOrEmpty(client, path, m.key) + if err != nil { + return err + } + merged, err := m.mergedWithPeer(other) + if err != nil { + return err + } + if err := saveModelToRemote(client, path, merged, m.key, configOrCurrent(config, m.config), version); err != nil { + return err + } + m.model = merged + m.locked = false + return nil +} + func (m *Manager) SaveAs(path string) error { if err := m.saveToPath(path); err != nil { return err @@ -351,6 +409,61 @@ func (m *Manager) baseModel() (vault.Model, error) { return model, nil } +func (m *Manager) mergedWithPeer(other vault.Model) (vault.Model, error) { + current, err := m.currentModelForPersistence() + if err != nil { + return vault.Model{}, err + } + return mergePeerModels(current, other), nil +} + +func (m *Manager) persistMergedToCurrentSource(merged vault.Model) error { + switch { + case m.remoteClient != nil && m.remotePath != "": + if err := saveModelToRemote(*m.remoteClient, m.remotePath, merged, m.key, configOrCurrent(m.config, nil), m.remoteVersion); err != nil { + return err + } + return m.reloadCurrentRemote(merged) + case m.path != "": + if err := saveModelToLocal(m.path, merged, m.key, configOrCurrent(m.config, nil)); err != nil { + return err + } + return m.reloadCurrentLocal(merged) + default: + return ErrNoPath + } +} + +func (m *Manager) reloadCurrentLocal(merged vault.Model) error { + encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil)) + if err != nil { + return err + } + m.model = merged + m.encoded = encoded + m.locked = false + return nil +} + +func (m *Manager) reloadCurrentRemote(merged vault.Model) error { + encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil)) + if err != nil { + return err + } + content, version, err := m.remoteClient.Open(m.remotePath) + if err != nil { + return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err) + } + m.model = merged + m.encoded = encoded + m.remoteVersion = version + m.locked = false + if len(content) > 0 { + m.encoded = content + } + return nil +} + func mergeModels(base, local, latest vault.Model) vault.Model { merged := latest merged.Entries = mergeEntrySet(base.Entries, local.Entries, latest.Entries) @@ -360,6 +473,15 @@ func mergeModels(base, local, latest vault.Model) vault.Model { return merged } +func mergePeerModels(primary, secondary vault.Model) vault.Model { + merged := cloneModel(secondary) + merged.Entries = mergePeerEntrySet(primary.Entries, secondary.Entries) + merged.Templates = mergePeerEntrySet(primary.Templates, secondary.Templates) + merged.RecycleBin = mergePeerEntrySet(primary.RecycleBin, secondary.RecycleBin) + merged.Groups = mergePeerGroups(primary.Groups, secondary.Groups) + return merged +} + func mergeEntrySet(base, local, latest []vault.Entry) []vault.Entry { baseByID := mapEntries(base) localByID := mapEntries(local) @@ -416,6 +538,33 @@ func mergeConflictedEntry(current, latest vault.Entry) vault.Entry { return current } +func mergePeerEntrySet(primary, secondary []vault.Entry) []vault.Entry { + outByID := mapEntries(secondary) + for _, item := range primary { + if existing, ok := outByID[item.ID]; ok && !sameEntryVersion(item, existing) { + outByID[item.ID] = mergeConflictedEntry(cloneEntry(item), existing) + continue + } + outByID[item.ID] = cloneEntry(item) + } + + out := make([]vault.Entry, 0, len(outByID)) + for _, item := range outByID { + out = append(out, item) + } + slices.SortFunc(out, func(a, b vault.Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + default: + return 0 + } + }) + return out +} + func mapEntries(entries []vault.Entry) map[string]vault.Entry { out := make(map[string]vault.Entry, len(entries)) for _, item := range entries { @@ -482,6 +631,20 @@ func cloneHistory(history []vault.Entry) []vault.Entry { return out } +func cloneModel(model vault.Model) vault.Model { + out := model + out.Entries = cloneHistory(model.Entries) + out.Templates = cloneHistory(model.Templates) + out.RecycleBin = cloneHistory(model.RecycleBin) + if len(model.Groups) > 0 { + out.Groups = make([][]string, len(model.Groups)) + for i := range model.Groups { + out.Groups[i] = slices.Clone(model.Groups[i]) + } + } + return out +} + func sameEntryVersion(a, b vault.Entry) bool { return entriesEqual(a, b) } @@ -526,6 +689,119 @@ func mergeGroups(base, local, latest [][]string) [][]string { return out } +func mergePeerGroups(primary, secondary [][]string) [][]string { + set := map[string][]string{} + for _, path := range secondary { + set[pathKey(path)] = slices.Clone(path) + } + for _, path := range primary { + set[pathKey(path)] = slices.Clone(path) + } + out := make([][]string, 0, len(set)) + for _, path := range set { + out = append(out, path) + } + slices.SortFunc(out, func(a, b []string) int { + joinedA := pathKey(a) + joinedB := pathKey(b) + switch { + case joinedA < joinedB: + return -1 + case joinedA > joinedB: + return 1 + default: + return 0 + } + }) + return out +} + +func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { + content, err := os.ReadFile(path) + if err != nil { + return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", path, err) + } + model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key) + if err != nil { + return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", path, err) + } + return model, config, nil +} + +func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { + model, config, err := loadLocalSource(path, key) + if err == nil { + return model, config, nil + } + if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), os.ErrNotExist.Error()) { + return vault.Model{}, nil, nil + } + return vault.Model{}, nil, err +} + +func loadRemoteSource(client webdav.Client, path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, webdav.Version, error) { + content, version, err := client.Open(path) + if err != nil { + return vault.Model{}, nil, webdav.Version{}, fmt.Errorf("open remote %s for synchronize: %w", path, err) + } + model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key) + if err != nil { + return vault.Model{}, nil, webdav.Version{}, fmt.Errorf("decode remote %s for synchronize: %w", path, err) + } + return model, config, version, nil +} + +func loadRemoteSourceOrEmpty(client webdav.Client, path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, webdav.Version, error) { + model, config, version, err := loadRemoteSource(client, path, key) + if err == nil { + return model, config, version, nil + } + if strings.Contains(err.Error(), "unexpected status 404") { + return vault.Model{}, nil, webdav.Version{}, nil + } + return vault.Model{}, nil, webdav.Version{}, err +} + +func encodeModelWithConfig(model vault.Model, key vault.MasterKey, config *vault.KDBXConfig) ([]byte, error) { + var encoded bytes.Buffer + if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, config); err != nil { + return nil, fmt.Errorf("encode synchronized vault: %w", err) + } + return encoded.Bytes(), nil +} + +func saveModelToLocal(path string, model vault.Model, key vault.MasterKey, config *vault.KDBXConfig) error { + encoded, err := encodeModelWithConfig(model, key, config) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create parent dir for %s: %w", path, err) + } + if err := os.WriteFile(path, encoded, 0o600); err != nil { + return fmt.Errorf("write synchronized %s: %w", path, err) + } + return nil +} + +func saveModelToRemote(client webdav.Client, path string, model vault.Model, key vault.MasterKey, config *vault.KDBXConfig, version webdav.Version) error { + encoded, err := encodeModelWithConfig(model, key, config) + if err != nil { + return err + } + if _, err := client.Save(path, bytes.NewReader(encoded), version); err != nil { + return fmt.Errorf("save synchronized remote %s: %w", path, err) + } + return nil +} + +func configOrCurrent(config, fallback *vault.KDBXConfig) *vault.KDBXConfig { + if config != nil { + return config + } + return fallback +} + func pathKey(path []string) string { return strings.Join(path, "\x00") } diff --git a/session/session_test.go b/session/session_test.go index 91692b2..9263f46 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -790,3 +790,218 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing. t.Fatalf("History[0] = %#v, want displaced remote version first", got[0].History[0]) } } + +func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + otherPath := filepath.Join(t.TempDir(), "other.kdbx") + + currentModel := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-current", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + otherModel := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-other", + Title: "Bellagio", + Username: "rustyryan", + Password: "token-other", + URL: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + writeKDBXTestFile(t, currentPath, currentModel, key) + writeKDBXTestFile(t, otherPath, otherModel, key) + + var sess Manager + if err := sess.Open(currentPath, key); err != nil { + t.Fatalf("Open(current) error = %v", err) + } + + if err := sess.SynchronizeFromLocal(otherPath); err != nil { + t.Fatalf("SynchronizeFromLocal() error = %v", err) + } + + var reopened Manager + if err := reopened.Open(currentPath, key); err != nil { + t.Fatalf("reopen Open(current) error = %v", err) + } + current, err := reopened.Current() + if err != nil { + t.Fatalf("reopened Current() error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) + } +} + +func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + otherPath := filepath.Join(t.TempDir(), "other.kdbx") + + currentModel := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-current", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + otherModel := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-other", + Title: "Bellagio", + Username: "rustyryan", + Password: "token-other", + URL: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + writeKDBXTestFile(t, currentPath, currentModel, key) + writeKDBXTestFile(t, otherPath, otherModel, key) + + var sess Manager + if err := sess.Open(currentPath, key); err != nil { + t.Fatalf("Open(current) error = %v", err) + } + + if err := sess.SynchronizeToLocal(otherPath); err != nil { + t.Fatalf("SynchronizeToLocal() error = %v", err) + } + + var reopened Manager + if err := reopened.Open(otherPath, key); err != nil { + t.Fatalf("reopen Open(other) error = %v", err) + } + current, err := reopened.Current() + if err != nil { + t.Fatalf("reopened Current() error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) + } +} + +func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + currentModel := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-current", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + remoteModel := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-remote", + Title: "Bellagio", + Username: "rustyryan", + Password: "token-remote", + URL: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + writeKDBXTestFile(t, currentPath, currentModel, key) + + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + etag := "\"v1\"" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("ETag", etag) + _, _ = w.Write(remoteBytes.Bytes()) + case http.MethodPut: + payload, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll(PUT body) error = %v", err) + } + remoteBytes.Reset() + if _, err := remoteBytes.Write(payload); err != nil { + t.Fatalf("Write(remoteBytes) error = %v", err) + } + etag = "\"v2\"" + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + var sess Manager + if err := sess.Open(currentPath, key); err != nil { + t.Fatalf("Open(current) error = %v", err) + } + + if err := sess.SynchronizeToRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx"); err != nil { + t.Fatalf("SynchronizeToRemote() error = %v", err) + } + + var reopened Manager + if err := reopened.OpenRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx", key); err != nil { + t.Fatalf("OpenRemote(reopened) error = %v", err) + } + current, err := reopened.Current() + if err != nil { + t.Fatalf("reopened Current() error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) + } +} + +func writeKDBXTestFile(t *testing.T, path string, model vault.Model, key vault.MasterKey) { + t.Helper() + + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey(%s) error = %v", path, err) + } + if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { + t.Fatalf("WriteFile(%s) error = %v", path, err) + } +}