Why: a KeePass application must see an ordinary .kdbx file. A mirror directory of real files gives that on every desktop platform without FUSE, WinFsp or macFUSE, and without cgo. What: internal/client holds the API wrapper, an SSE reader, and a mirror agent whose entire policy is one pure decide() table: adopt, pull, push, force-push, server-wins, restore or manual. Saves are detected by rescan, remote changes arrive over the event stream, and every overwrite of local bytes leaves a conflict copy. cmd/keeporg drives it: login (password, TOTP or browser/OIDC device flow), run, sync, status, plus the group, grant, database and invite administration. Proved: go test ./internal/client covers the decision table and runs a real server in-process for adopt, follow, server-wins, client-wins, manual, read-only restore, server-side rename and event-driven update. Also smoke tested live: built both binaries, bootstrapped a server, logged in, created a database and adopted a local file.
98 lines
2.4 KiB
Go
98 lines
2.4 KiB
Go
package client
|
|
|
|
// action is what the agent does to one database on one pass.
|
|
type action int
|
|
|
|
const (
|
|
actNothing action = iota
|
|
// actPull writes the server copy over the local file.
|
|
actPull
|
|
// actPush uploads the local file against the version it was based on.
|
|
actPush
|
|
// actForcePush uploads the local file and overwrites a newer server copy.
|
|
actForcePush
|
|
// actServerWins keeps the local file as a conflict copy, then pulls.
|
|
actServerWins
|
|
// actRestore is actServerWins forced on the client by read-only access.
|
|
actRestore
|
|
// actManual writes the server copy beside the local file and waits for a
|
|
// human.
|
|
actManual
|
|
)
|
|
|
|
func (a action) String() string {
|
|
switch a {
|
|
case actPull:
|
|
return "pull"
|
|
case actPush:
|
|
return "push"
|
|
case actForcePush:
|
|
return "force-push"
|
|
case actServerWins:
|
|
return "server-wins"
|
|
case actRestore:
|
|
return "restore"
|
|
case actManual:
|
|
return "manual-conflict"
|
|
}
|
|
return "nothing"
|
|
}
|
|
|
|
// situation is everything the agent knows about one database at one moment.
|
|
type situation struct {
|
|
LocalExists bool
|
|
// LocalSHA is the digest of the file on disk now.
|
|
LocalSHA string
|
|
// SyncedSHA and SyncedVersion are what this client last agreed with the
|
|
// server. An empty SyncedSHA means this client has never synced the file.
|
|
SyncedSHA string
|
|
SyncedVersion int64
|
|
RemoteSHA string
|
|
RemoteVersion int64
|
|
CanWrite bool
|
|
Strategy string
|
|
// ConflictSeen is set when this exact manual conflict was already
|
|
// reported, so it is not reported again on every pass.
|
|
ConflictSeen bool
|
|
}
|
|
|
|
// decide is the whole client-side synchronization policy, kept pure so the
|
|
// table of cases can be read and tested in one place.
|
|
func decide(s situation) action {
|
|
localChanged := s.LocalExists && s.LocalSHA != s.SyncedSHA
|
|
remoteChanged := s.RemoteVersion != s.SyncedVersion || s.RemoteSHA != s.SyncedSHA
|
|
|
|
switch {
|
|
case !s.LocalExists:
|
|
if s.RemoteVersion > 0 {
|
|
return actPull
|
|
}
|
|
return actNothing
|
|
|
|
case localChanged && !remoteChanged:
|
|
if !s.CanWrite {
|
|
return actRestore
|
|
}
|
|
return actPush
|
|
|
|
case !localChanged && remoteChanged:
|
|
return actPull
|
|
|
|
case localChanged && remoteChanged:
|
|
switch {
|
|
case !s.CanWrite:
|
|
return actRestore
|
|
case s.Strategy == "client-wins":
|
|
return actForcePush
|
|
case s.Strategy == "manual":
|
|
if s.ConflictSeen {
|
|
return actNothing
|
|
}
|
|
return actManual
|
|
default:
|
|
return actServerWins
|
|
}
|
|
}
|
|
return actNothing
|
|
}
|