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) HasSaveTarget() bool { return m.path != "" || (m.remoteClient != nil && 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") }