Add advanced synchronize dialog and APK tooling

This commit is contained in:
Joe Julian
2026-03-29 21:38:46 -07:00
parent 6e2760c514
commit 96ec583c7e
11 changed files with 1154 additions and 80 deletions
+2 -1
View File
@@ -1 +1,2 @@
gio-keepass-mock
build/
*.apk
+1
View File
@@ -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 users 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.
+22
View File
@@ -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 ...
```
+17
View File
@@ -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) \
.
+56
View File
@@ -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 {
+6 -1
View File
@@ -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
)
+4
View File
@@ -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=
+402 -77
View File
@@ -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 {
+153 -1
View File
@@ -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)
}
}
+276
View File
@@ -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")
}
+215
View File
@@ -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)
}
}