Files
keepassgo/internal/vault/model.go
T
2026-04-09 06:42:21 -07:00

602 lines
13 KiB
Go

package vault
import (
"errors"
"slices"
"strings"
)
var ErrEntryNotFound = errors.New("entry not found")
var ErrGroupNotEmpty = errors.New("group is not empty")
var ErrRemoteProfileNotFound = errors.New("remote profile not found")
type RemoteBackend string
const (
RemoteBackendWebDAV RemoteBackend = "webdav"
)
type RemoteProfile struct {
ID string
Name string
Backend RemoteBackend
BaseURL string
Path string
}
type Entry struct {
ID string
Title string
Username string
Password string
URL string
Notes string
Tags []string
Fields map[string]string
Attachments map[string][]byte
History []Entry
Path []string
}
type SearchResult struct {
Entry Entry
Path string
}
type Model struct {
Entries []Entry
Templates []Entry
RecycleBin []Entry
Groups [][]string
RemoteProfiles []RemoteProfile
}
func (m Model) ChildGroups(path []string) []string {
seen := map[string]bool{}
var groups []string
for _, entry := range m.Entries {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
if len(entry.Path) == len(path) {
continue
}
group := entry.Path[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
for _, groupPath := range m.Groups {
if len(path) > len(groupPath) {
continue
}
if !slices.Equal(groupPath[:len(path)], path) {
continue
}
if len(groupPath) == len(path) {
continue
}
group := groupPath[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
slices.Sort(groups)
return groups
}
func (m Model) EntriesInPath(path []string) []Entry {
var entries []Entry
for _, entry := range m.Entries {
if slices.Equal(entry.Path, path) {
entries = append(entries, entry)
}
}
slices.SortFunc(entries, func(a, b Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return entries
}
func (m Model) EntriesUnderPath(path []string) []Entry {
var entries []Entry
for _, entry := range m.Entries {
if !hasPathPrefix(entry.Path, path) {
continue
}
entries = append(entries, entry)
}
slices.SortFunc(entries, func(a, b Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return entries
}
func (m Model) Search(query string) []SearchResult {
query = strings.TrimSpace(strings.ToLower(query))
if query == "" {
return nil
}
var results []SearchResult
for _, entry := range m.Entries {
haystack := strings.ToLower(
entry.Title + " " +
entry.Username + " " +
entry.URL + " " +
strings.Join(entry.Path, " "),
)
if !strings.Contains(haystack, query) {
continue
}
results = append(results, SearchResult{
Entry: entry,
Path: strings.Join(entry.Path, " / "),
})
}
slices.SortFunc(results, func(a, b SearchResult) int {
switch {
case a.Entry.Title < b.Entry.Title:
return -1
case a.Entry.Title > b.Entry.Title:
return 1
default:
return 0
}
})
return results
}
func (m *Model) UpsertEntry(entry Entry) {
for i := range m.Entries {
if m.Entries[i].ID != entry.ID {
continue
}
previous := cloneEntry(m.Entries[i])
entry.History = append([]Entry{previous}, cloneHistory(m.Entries[i].History)...)
m.Entries[i] = cloneEntry(entry)
return
}
m.Entries = append(m.Entries, cloneEntry(entry))
}
func (m *Model) RemoveEntryByID(id string) bool {
for i := range m.Entries {
if m.Entries[i].ID != id {
continue
}
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
return true
}
return false
}
func (m *Model) EntryByID(id string) (Entry, error) {
for _, entry := range m.Entries {
if entry.ID == id {
return cloneEntry(entry), nil
}
}
return Entry{}, ErrEntryNotFound
}
func (m *Model) UpsertRemoteProfile(profile RemoteProfile) {
for i := range m.RemoteProfiles {
if m.RemoteProfiles[i].ID != profile.ID {
continue
}
m.RemoteProfiles[i] = profile
return
}
m.RemoteProfiles = append(m.RemoteProfiles, profile)
}
func (m *Model) RemoveRemoteProfileByID(id string) bool {
for i := range m.RemoteProfiles {
if m.RemoteProfiles[i].ID != id {
continue
}
m.RemoteProfiles = append(m.RemoteProfiles[:i], m.RemoteProfiles[i+1:]...)
return true
}
return false
}
func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) {
for _, profile := range m.RemoteProfiles {
if profile.ID == id {
return profile, nil
}
}
return RemoteProfile{}, ErrRemoteProfileNotFound
}
func (m *Model) UpsertTemplate(entry Entry) {
for i := range m.Templates {
if m.Templates[i].ID != entry.ID {
continue
}
m.Templates[i] = cloneEntry(entry)
return
}
m.Templates = append(m.Templates, cloneEntry(entry))
}
func (m *Model) DeleteTemplate(id string) error {
for i := range m.Templates {
if m.Templates[i].ID != id {
continue
}
m.Templates = append(m.Templates[:i], m.Templates[i+1:]...)
return nil
}
return ErrEntryNotFound
}
func (m *Model) DeleteEntry(id string) error {
for i := range m.Entries {
if m.Entries[i].ID != id {
continue
}
m.RecycleBin = append(m.RecycleBin, cloneEntry(m.Entries[i]))
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
return nil
}
return ErrEntryNotFound
}
func (m *Model) RestoreEntry(id string) error {
for i := range m.RecycleBin {
if m.RecycleBin[i].ID != id {
continue
}
m.Entries = append(m.Entries, cloneEntry(m.RecycleBin[i]))
m.RecycleBin = append(m.RecycleBin[:i], m.RecycleBin[i+1:]...)
return nil
}
return ErrEntryNotFound
}
func (m *Model) InstantiateTemplate(templateID string, overrides Entry) (Entry, error) {
for i := range m.Templates {
if m.Templates[i].ID != templateID {
continue
}
entry := mergeEntryTemplate(m.Templates[i], overrides)
m.UpsertEntry(entry)
return cloneEntry(entry), nil
}
return Entry{}, ErrEntryNotFound
}
func (m *Model) DuplicateEntry(id, duplicateID string) (Entry, error) {
for i := range m.Entries {
if m.Entries[i].ID != id {
continue
}
duplicate := cloneEntry(m.Entries[i])
duplicate.ID = duplicateID
duplicate.Title = duplicate.Title + " (Copy)"
duplicate.History = nil
m.Entries = append(m.Entries, duplicate)
return cloneEntry(duplicate), nil
}
return Entry{}, ErrEntryNotFound
}
func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
for i := range m.Entries {
if m.Entries[i].ID != id {
continue
}
if historyIndex < 0 || historyIndex >= len(m.Entries[i].History) {
return ErrEntryNotFound
}
current := cloneEntry(m.Entries[i])
restored := cloneEntry(m.Entries[i].History[historyIndex])
restored.ID = current.ID
restored.History = append([]Entry{current}, append(
cloneHistory(m.Entries[i].History[:historyIndex]),
cloneHistory(m.Entries[i].History[historyIndex+1:])...,
)...)
m.Entries[i] = restored
return nil
}
return ErrEntryNotFound
}
func (m *Model) CreateGroup(parent []string, name string) {
groupPath := append([]string(nil), parent...)
for _, part := range splitGroupPath(name) {
groupPath = append(groupPath, part)
if groupPathExists(m.Groups, groupPath) {
continue
}
m.Groups = append(m.Groups, append([]string(nil), groupPath...))
}
}
func (m *Model) RenameGroup(path []string, newName string) error {
if len(path) == 0 {
return ErrEntryNotFound
}
renamed := false
newPath := append(append([]string(nil), path[:len(path)-1]...), newName)
for i := range m.Entries {
if !hasPathPrefix(m.Entries[i].Path, path) {
continue
}
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
renamed = true
}
for i := range m.Templates {
if !hasPathPrefix(m.Templates[i].Path, path) {
continue
}
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
renamed = true
}
for i := range m.Groups {
if !hasPathPrefix(m.Groups[i], path) {
continue
}
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
renamed = true
}
if !renamed {
return ErrEntryNotFound
}
return nil
}
func (m *Model) MoveEntry(id string, path []string) error {
for i := range m.Entries {
if m.Entries[i].ID != id {
continue
}
m.Entries[i].Path = append([]string(nil), path...)
return nil
}
return ErrEntryNotFound
}
func (m *Model) MoveGroup(path, parent []string) error {
if len(path) == 0 {
return ErrEntryNotFound
}
if hasPathPrefix(parent, path) {
return ErrEntryNotFound
}
groupName := path[len(path)-1]
newPath := append(append([]string(nil), parent...), groupName)
moved := false
for i := range m.Entries {
if !hasPathPrefix(m.Entries[i].Path, path) {
continue
}
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
moved = true
}
for i := range m.Templates {
if !hasPathPrefix(m.Templates[i].Path, path) {
continue
}
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
moved = true
}
for i := range m.Groups {
if !hasPathPrefix(m.Groups[i], path) {
continue
}
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
moved = true
}
if !moved {
return ErrEntryNotFound
}
if !groupPathExists(m.Groups, newPath) {
m.Groups = append(m.Groups, append([]string(nil), newPath...))
}
return nil
}
func (m *Model) MoveTemplate(id string, path []string) error {
for i := range m.Templates {
if m.Templates[i].ID != id {
continue
}
m.Templates[i].Path = append([]string(nil), path...)
return nil
}
return ErrEntryNotFound
}
func (m *Model) DeleteGroup(path []string) error {
for _, entry := range m.Entries {
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
return ErrGroupNotEmpty
}
}
for _, entry := range m.Templates {
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
return ErrGroupNotEmpty
}
}
for i := range m.Groups {
if slices.Equal(m.Groups[i], path) {
m.Groups = append(m.Groups[:i], m.Groups[i+1:]...)
return nil
}
}
return ErrEntryNotFound
}
func hasPathPrefix(path, prefix []string) bool {
if len(prefix) > len(path) {
return false
}
return slices.Equal(path[:len(prefix)], prefix)
}
func splitGroupPath(name string) []string {
var parts []string
for _, part := range strings.Split(name, "/") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
parts = append(parts, part)
}
return parts
}
func groupPathExists(groups [][]string, path []string) bool {
for _, existing := range groups {
if slices.Equal(existing, path) {
return true
}
}
return false
}
func mergeEntryTemplate(template, overrides Entry) Entry {
entry := cloneEntry(template)
if overrides.ID != "" {
entry.ID = overrides.ID
}
if overrides.Title != "" {
entry.Title = overrides.Title
}
if overrides.Username != "" {
entry.Username = overrides.Username
}
if overrides.Password != "" {
entry.Password = overrides.Password
}
if overrides.URL != "" {
entry.URL = overrides.URL
}
if overrides.Notes != "" {
entry.Notes = overrides.Notes
}
if len(overrides.Tags) > 0 {
entry.Tags = slices.Clone(overrides.Tags)
}
if len(overrides.Path) > 0 {
entry.Path = slices.Clone(overrides.Path)
}
entry.Fields = mergeStringMaps(template.Fields, overrides.Fields)
entry.Attachments = mergeBinaryMaps(template.Attachments, overrides.Attachments)
entry.History = nil
return entry
}
func mergeStringMaps(base, overrides map[string]string) map[string]string {
if len(base) == 0 && len(overrides) == 0 {
return nil
}
out := make(map[string]string, len(base)+len(overrides))
for key, value := range base {
out[key] = value
}
for key, value := range overrides {
out[key] = value
}
return out
}
func mergeBinaryMaps(base, overrides map[string][]byte) map[string][]byte {
if len(base) == 0 && len(overrides) == 0 {
return nil
}
out := make(map[string][]byte, len(base)+len(overrides))
for key, value := range base {
out[key] = slices.Clone(value)
}
for key, value := range overrides {
out[key] = slices.Clone(value)
}
return out
}
func cloneEntry(entry Entry) 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 []Entry) []Entry {
if len(history) == 0 {
return nil
}
out := make([]Entry, len(history))
for i := range history {
out[i] = cloneEntry(history[i])
}
return out
}