1012 lines
26 KiB
Go
1012 lines
26 KiB
Go
package session
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
|
|
"git.julianfamily.org/keepassgo/internal/vault"
|
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
|
)
|
|
|
|
var (
|
|
ErrLocked = errors.New("vault is locked")
|
|
ErrNoPath = errors.New("no vault path configured")
|
|
)
|
|
|
|
type Manager struct {
|
|
model vault.Model
|
|
config *vault.KDBXConfig
|
|
path string
|
|
key vault.MasterKey
|
|
vaultRoot string
|
|
locked bool
|
|
encoded []byte
|
|
remoteClient *webdav.Client
|
|
remotePath string
|
|
remoteVersion webdav.Version
|
|
}
|
|
|
|
type PreparedLocalOpen struct {
|
|
Model vault.Model
|
|
Config *vault.KDBXConfig
|
|
Path string
|
|
Key vault.MasterKey
|
|
Encoded []byte
|
|
VaultRoot string
|
|
}
|
|
|
|
type PreparedRemoteOpen struct {
|
|
Model vault.Model
|
|
Config *vault.KDBXConfig
|
|
Client webdav.Client
|
|
Path string
|
|
Key vault.MasterKey
|
|
Encoded []byte
|
|
VaultRoot string
|
|
RemoteVersion webdav.Version
|
|
}
|
|
|
|
type PreparedUnlock struct {
|
|
Model vault.Model
|
|
Config *vault.KDBXConfig
|
|
Key vault.MasterKey
|
|
VaultRoot string
|
|
}
|
|
|
|
func (m *Manager) SecuritySettings() vault.SecuritySettings {
|
|
return vault.DetectSecuritySettings(m.config)
|
|
}
|
|
|
|
func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
|
|
config, err := vault.ApplySecuritySettings(configOrCurrent(m.config, nil), settings)
|
|
if err != nil {
|
|
return fmt.Errorf("configure security settings: %w", err)
|
|
}
|
|
m.config = config
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
|
root := detectSingleVaultRoot(model)
|
|
model = normalizeUnderRoot(model, root)
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
|
|
return fmt.Errorf("encode new vault: %w", err)
|
|
}
|
|
|
|
m.model = model
|
|
m.key = key
|
|
m.vaultRoot = root
|
|
m.encoded = encoded.Bytes()
|
|
m.locked = false
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) HasVault() bool {
|
|
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
|
|
}
|
|
|
|
func (m *Manager) EncodedBytes() []byte {
|
|
return append([]byte(nil), m.encoded...)
|
|
}
|
|
|
|
func (m *Manager) IsLocked() bool {
|
|
return m.locked
|
|
}
|
|
|
|
func (m *Manager) IsRemote() bool {
|
|
return m.remoteClient != nil && m.remotePath != ""
|
|
}
|
|
|
|
func (m *Manager) Open(path string, key vault.MasterKey) error {
|
|
prepared, err := PrepareLocalOpen(path, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.ApplyPreparedLocalOpen(prepared)
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) Save() error {
|
|
if m.remoteClient != nil && m.remotePath != "" {
|
|
return m.SaveRemote()
|
|
}
|
|
|
|
if m.path == "" {
|
|
return ErrNoPath
|
|
}
|
|
|
|
return m.saveToPath(m.path)
|
|
}
|
|
|
|
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
|
|
prepared, err := PrepareRemoteOpen(client, path, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.ApplyPreparedRemoteOpen(prepared)
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) SaveRemote() error {
|
|
if m.remoteClient == nil || m.remotePath == "" {
|
|
return ErrNoPath
|
|
}
|
|
|
|
encoded, err := m.persistableBytes()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
version, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded), m.remoteVersion)
|
|
if err != nil {
|
|
return fmt.Errorf("save remote %s: %w", m.remotePath, err)
|
|
}
|
|
|
|
m.encoded = encoded
|
|
m.remoteVersion = version
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) Synchronize() error {
|
|
switch {
|
|
case m.remoteClient != nil && m.remotePath != "":
|
|
return m.synchronizeRemote()
|
|
case m.path != "":
|
|
return m.synchronizeLocal()
|
|
default:
|
|
return ErrNoPath
|
|
}
|
|
}
|
|
|
|
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) SynchronizeFromLocalBytes(name string, content []byte) error {
|
|
other, _, err := loadLocalSourceBytes(name, content, 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
|
|
}
|
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
|
if err := saveModelToLocal(path, merged, m.key, configOrCurrent(config, m.config)); err != nil {
|
|
return err
|
|
}
|
|
m.model = normalizeUnderRoot(merged, m.vaultRoot)
|
|
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
|
|
}
|
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
|
if err := saveModelToRemote(client, path, merged, m.key, configOrCurrent(config, m.config), version); err != nil {
|
|
return err
|
|
}
|
|
m.model = normalizeUnderRoot(merged, m.vaultRoot)
|
|
m.locked = false
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) SaveAs(path string) error {
|
|
if err := m.saveToPath(path); err != nil {
|
|
return err
|
|
}
|
|
|
|
m.path = path
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) Replace(model vault.Model) {
|
|
root := m.vaultRoot
|
|
if root == "" {
|
|
root = detectSingleVaultRoot(model)
|
|
}
|
|
m.model = normalizeUnderRoot(model, root)
|
|
m.vaultRoot = root
|
|
m.locked = false
|
|
}
|
|
|
|
func (m *Manager) Current() (vault.Model, error) {
|
|
if m.locked {
|
|
return vault.Model{}, ErrLocked
|
|
}
|
|
|
|
return m.model, nil
|
|
}
|
|
|
|
func (m *Manager) Lock() error {
|
|
if m.locked {
|
|
return nil
|
|
}
|
|
|
|
var encoded bytes.Buffer
|
|
model := normalizeUnderRoot(m.model, m.vaultRoot)
|
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, m.key, m.config); err != nil {
|
|
return fmt.Errorf("encode vault for lock: %w", err)
|
|
}
|
|
|
|
m.encoded = encoded.Bytes()
|
|
m.model = vault.Model{}
|
|
m.locked = true
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) Unlock(key vault.MasterKey) error {
|
|
prepared, err := PrepareUnlock(m.encoded, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.ApplyPreparedUnlock(prepared)
|
|
return nil
|
|
}
|
|
|
|
func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, error) {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return PreparedLocalOpen{}, fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
|
if err != nil {
|
|
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
return PreparedLocalOpen{
|
|
Model: model,
|
|
Config: config,
|
|
Path: path,
|
|
Key: key,
|
|
Encoded: content,
|
|
VaultRoot: detectSingleVaultRoot(model),
|
|
}, nil
|
|
}
|
|
|
|
func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (PreparedRemoteOpen, error) {
|
|
content, version, err := client.Open(path)
|
|
if err != nil {
|
|
return PreparedRemoteOpen{}, fmt.Errorf("open remote %s: %w", path, err)
|
|
}
|
|
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
|
if err != nil {
|
|
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
|
|
}
|
|
return PreparedRemoteOpen{
|
|
Model: model,
|
|
Config: config,
|
|
Client: client,
|
|
Path: path,
|
|
Key: key,
|
|
Encoded: content,
|
|
VaultRoot: detectSingleVaultRoot(model),
|
|
RemoteVersion: version,
|
|
}, nil
|
|
}
|
|
|
|
func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error) {
|
|
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(encoded), key)
|
|
if err != nil {
|
|
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
|
|
}
|
|
return PreparedUnlock{
|
|
Model: model,
|
|
Config: config,
|
|
Key: key,
|
|
VaultRoot: detectSingleVaultRoot(model),
|
|
}, nil
|
|
}
|
|
|
|
func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
|
|
m.model = prepared.Model
|
|
m.config = prepared.Config
|
|
m.path = prepared.Path
|
|
m.key = prepared.Key
|
|
m.vaultRoot = prepared.VaultRoot
|
|
m.encoded = prepared.Encoded
|
|
m.locked = false
|
|
m.remoteClient = nil
|
|
m.remotePath = ""
|
|
m.remoteVersion = webdav.Version{}
|
|
}
|
|
|
|
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
|
m.model = prepared.Model
|
|
m.config = prepared.Config
|
|
m.key = prepared.Key
|
|
m.vaultRoot = prepared.VaultRoot
|
|
m.encoded = prepared.Encoded
|
|
m.locked = false
|
|
m.remoteClient = &prepared.Client
|
|
m.remotePath = prepared.Path
|
|
m.remoteVersion = prepared.RemoteVersion
|
|
m.path = ""
|
|
}
|
|
|
|
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
|
m.model = prepared.Model
|
|
m.config = prepared.Config
|
|
m.key = prepared.Key
|
|
m.vaultRoot = prepared.VaultRoot
|
|
m.locked = false
|
|
}
|
|
|
|
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
|
|
var (
|
|
model vault.Model
|
|
config *vault.KDBXConfig
|
|
err error
|
|
)
|
|
|
|
if m.locked {
|
|
model, config, err = vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), m.key)
|
|
if err != nil {
|
|
return fmt.Errorf("decode locked vault: %w", err)
|
|
}
|
|
} else {
|
|
model = m.model
|
|
config = m.config
|
|
}
|
|
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, config); err != nil {
|
|
return fmt.Errorf("encode vault with updated master key: %w", err)
|
|
}
|
|
|
|
m.key = key
|
|
m.config = config
|
|
m.encoded = encoded.Bytes()
|
|
if !m.locked {
|
|
m.model = model
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) saveToPath(path string) error {
|
|
encoded, err := m.persistableBytes()
|
|
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 %s: %w", path, err)
|
|
}
|
|
|
|
m.encoded = encoded
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) persistableBytes() ([]byte, error) {
|
|
if m.locked {
|
|
return append([]byte(nil), m.encoded...), nil
|
|
}
|
|
model, err := m.currentModelForPersistence()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, m.key, m.config); err != nil {
|
|
return nil, fmt.Errorf("encode vault: %w", err)
|
|
}
|
|
return encoded.Bytes(), nil
|
|
}
|
|
|
|
func (m *Manager) synchronizeLocal() error {
|
|
current, err := m.currentModelForPersistence()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
content, err := os.ReadFile(m.path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return m.saveToPath(m.path)
|
|
}
|
|
return fmt.Errorf("read %s: %w", m.path, err)
|
|
}
|
|
|
|
latest, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), m.key)
|
|
if err != nil {
|
|
return fmt.Errorf("open %s for synchronize: %w", m.path, err)
|
|
}
|
|
|
|
base, err := m.baseModel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
merged := mergeModels(base, current, latest)
|
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
|
|
return fmt.Errorf("encode synchronized vault: %w", err)
|
|
}
|
|
if err := os.WriteFile(m.path, encoded.Bytes(), 0o600); err != nil {
|
|
return fmt.Errorf("write synchronized %s: %w", m.path, err)
|
|
}
|
|
|
|
m.model = merged
|
|
m.config = config
|
|
m.encoded = encoded.Bytes()
|
|
m.locked = false
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) synchronizeRemote() error {
|
|
current, err := m.currentModelForPersistence()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
content, version, err := m.remoteClient.Open(m.remotePath)
|
|
if err != nil {
|
|
return fmt.Errorf("open remote %s for synchronize: %w", m.remotePath, err)
|
|
}
|
|
|
|
latest, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), m.key)
|
|
if err != nil {
|
|
return fmt.Errorf("decode remote %s for synchronize: %w", m.remotePath, err)
|
|
}
|
|
|
|
base, err := m.baseModel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
merged := mergeModels(base, current, latest)
|
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
|
|
return fmt.Errorf("encode synchronized remote vault: %w", err)
|
|
}
|
|
|
|
nextVersion, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded.Bytes()), version)
|
|
if err != nil {
|
|
return fmt.Errorf("save synchronized remote %s: %w", m.remotePath, err)
|
|
}
|
|
|
|
m.model = merged
|
|
m.config = config
|
|
m.encoded = encoded.Bytes()
|
|
m.remoteVersion = nextVersion
|
|
m.locked = false
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) currentModelForPersistence() (vault.Model, error) {
|
|
if m.locked {
|
|
model, err := vault.LoadKDBXWithKey(bytes.NewReader(m.encoded), m.key)
|
|
if err != nil {
|
|
return vault.Model{}, err
|
|
}
|
|
return normalizeUnderRoot(model, m.vaultRoot), nil
|
|
}
|
|
return normalizeUnderRoot(m.model, m.vaultRoot), nil
|
|
}
|
|
|
|
func (m *Manager) baseModel() (vault.Model, error) {
|
|
if len(m.encoded) == 0 {
|
|
return vault.Model{}, nil
|
|
}
|
|
model, err := vault.LoadKDBXWithKey(bytes.NewReader(m.encoded), m.key)
|
|
if err != nil {
|
|
return vault.Model{}, fmt.Errorf("decode baseline vault: %w", err)
|
|
}
|
|
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 {
|
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
|
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 {
|
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
|
encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.model = merged
|
|
if root := detectSingleVaultRoot(merged); root != "" {
|
|
m.vaultRoot = root
|
|
}
|
|
m.encoded = encoded
|
|
m.locked = false
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
|
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
|
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
|
|
if root := detectSingleVaultRoot(merged); root != "" {
|
|
m.vaultRoot = root
|
|
}
|
|
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)
|
|
merged.Templates = mergeEntrySet(base.Templates, local.Templates, latest.Templates)
|
|
merged.RecycleBin = mergeEntrySet(base.RecycleBin, local.RecycleBin, latest.RecycleBin)
|
|
merged.Groups = mergeGroups(base.Groups, local.Groups, latest.Groups)
|
|
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)
|
|
latestByID := mapEntries(latest)
|
|
|
|
for id, current := range localByID {
|
|
original, hadBase := baseByID[id]
|
|
if !hadBase || !entriesEqual(original, current) {
|
|
if latestCurrent, latestChanged := latestByID[id]; hadBase && latestChanged && !entriesEqual(original, latestCurrent) && !entriesEqual(latestCurrent, current) {
|
|
current = mergeConflictedEntry(current, latestCurrent)
|
|
}
|
|
latestByID[id] = current
|
|
}
|
|
}
|
|
for id := range baseByID {
|
|
if _, stillLocal := localByID[id]; stillLocal {
|
|
continue
|
|
}
|
|
delete(latestByID, id)
|
|
}
|
|
|
|
out := make([]vault.Entry, 0, len(latestByID))
|
|
for _, item := range latestByID {
|
|
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 mergeConflictedEntry(current, latest vault.Entry) vault.Entry {
|
|
displaced := cloneEntry(latest)
|
|
if sameEntryVersion(current, displaced) {
|
|
return current
|
|
}
|
|
|
|
mergedHistory := make([]vault.Entry, 0, len(current.History)+1)
|
|
mergedHistory = append(mergedHistory, displaced)
|
|
for _, item := range current.History {
|
|
if sameEntryVersion(item, displaced) {
|
|
continue
|
|
}
|
|
mergedHistory = append(mergedHistory, cloneEntry(item))
|
|
}
|
|
current.History = mergedHistory
|
|
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 {
|
|
out[item.ID] = item
|
|
}
|
|
return out
|
|
}
|
|
|
|
func entriesEqual(a, b vault.Entry) bool {
|
|
return a.ID == b.ID &&
|
|
a.Title == b.Title &&
|
|
a.Username == b.Username &&
|
|
a.Password == b.Password &&
|
|
a.URL == b.URL &&
|
|
a.Notes == b.Notes &&
|
|
slices.Equal(a.Tags, b.Tags) &&
|
|
slices.Equal(a.Path, b.Path) &&
|
|
reflect.DeepEqual(a.History, b.History) &&
|
|
reflect.DeepEqual(a.Fields, b.Fields) &&
|
|
equalAttachments(a.Attachments, b.Attachments)
|
|
}
|
|
|
|
func equalAttachments(a, b map[string][]byte) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for key, value := range a {
|
|
if !slices.Equal(value, b[key]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func cloneEntry(entry vault.Entry) vault.Entry {
|
|
entry.Tags = slices.Clone(entry.Tags)
|
|
entry.Path = slices.Clone(entry.Path)
|
|
entry.History = cloneHistory(entry.History)
|
|
if entry.Fields != nil {
|
|
fields := make(map[string]string, len(entry.Fields))
|
|
for key, value := range entry.Fields {
|
|
fields[key] = value
|
|
}
|
|
entry.Fields = fields
|
|
}
|
|
if entry.Attachments != nil {
|
|
attachments := make(map[string][]byte, len(entry.Attachments))
|
|
for key, value := range entry.Attachments {
|
|
attachments[key] = slices.Clone(value)
|
|
}
|
|
entry.Attachments = attachments
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func cloneHistory(history []vault.Entry) []vault.Entry {
|
|
if len(history) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]vault.Entry, len(history))
|
|
for i := range history {
|
|
out[i] = cloneEntry(history[i])
|
|
}
|
|
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)
|
|
}
|
|
|
|
func mergeGroups(base, local, latest [][]string) [][]string {
|
|
set := map[string][]string{}
|
|
for _, path := range latest {
|
|
set[pathKey(path)] = append([]string(nil), path...)
|
|
}
|
|
baseSet := map[string]bool{}
|
|
for _, path := range base {
|
|
baseSet[pathKey(path)] = true
|
|
}
|
|
localSet := map[string]bool{}
|
|
for _, path := range local {
|
|
key := pathKey(path)
|
|
localSet[key] = true
|
|
set[key] = append([]string(nil), path...)
|
|
}
|
|
for key := range baseSet {
|
|
if localSet[key] {
|
|
continue
|
|
}
|
|
delete(set, key)
|
|
}
|
|
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 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 detectSingleVaultRoot(model vault.Model) string {
|
|
if len(model.EntriesInPath(nil)) != 0 {
|
|
return ""
|
|
}
|
|
groups := model.ChildGroups(nil)
|
|
if len(groups) != 1 {
|
|
return ""
|
|
}
|
|
return groups[0]
|
|
}
|
|
|
|
func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
|
if root == "" {
|
|
return model
|
|
}
|
|
|
|
out := cloneModel(model)
|
|
normalizePath := func(path []string) []string {
|
|
switch {
|
|
case len(path) == 0:
|
|
return []string{root}
|
|
case path[0] == root:
|
|
return path
|
|
default:
|
|
return append([]string{root}, path...)
|
|
}
|
|
}
|
|
|
|
for i := range out.Entries {
|
|
out.Entries[i].Path = normalizePath(out.Entries[i].Path)
|
|
for j := range out.Entries[i].History {
|
|
out.Entries[i].History[j].Path = normalizePath(out.Entries[i].History[j].Path)
|
|
}
|
|
}
|
|
for i := range out.RecycleBin {
|
|
out.RecycleBin[i].Path = normalizePath(out.RecycleBin[i].Path)
|
|
for j := range out.RecycleBin[i].History {
|
|
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
|
|
}
|
|
}
|
|
for i := range out.Groups {
|
|
out.Groups[i] = normalizePath(out.Groups[i])
|
|
}
|
|
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 loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
|
if len(content) == 0 {
|
|
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
|
|
}
|
|
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
|
if err != nil {
|
|
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, 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")
|
|
}
|