Add advanced synchronize dialog and APK tooling
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user