Files
keeporg/internal/client/decide.go
T
root 4d0d53ff04 Add client engine and desktop CLI that mirror KDBX files
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.
2026-10-29 16:01:35 +01:00

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
}