summaryrefslogtreecommitdiff
path: root/internal/keepass/write.go
blob: 9f77de345172f51ca1f1f14d934139572e2abb92 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
package keepass

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// Add creates or updates a KeePass entry from plaintext data.
//
// Attachment detection: if the last path component does not match any existing
// standalone entry but its parent does, the call is routed to addAttachment so
// that "Group/Title/file.bin" attaches raw bytes to "Group/Title". The parent
// entry must already exist; return an error if it does not.
// Otherwise, the description is treated as a normal text entry and its fields
// are parsed from data via parseContent.
func (b *Backend) Add(ctx context.Context, description, data string) error {
	if parentDesc, attachName, ok := b.isAttachmentPath(description); ok {
		return b.addAttachment(parentDesc, attachName, []byte(data))
	}
	return b.addTextEntry(description, data)
}

// addTextEntry creates or updates the KeePass entry at description using the
// fields parsed from data. This is the normal (non-attachment) Add path.
func (b *Backend) addTextEntry(description, data string) error {
	groupPath, title, err := SplitDescriptionPath(description)
	if err != nil {
		return fmt.Errorf("keepass add: %w", err)
	}
	// data is already a string; pass directly to avoid a redundant []byte
	// allocation that parseContent would immediately convert back (mistake #40).
	password, user, url, notes := parseContent(data)
	g := EnsureGroup(b.root(), groupPath)
	entry, _ := UpsertEntryByTitle(g, title)
	SetEntryField(entry, "Title", title)
	SetEntryField(entry, "Password", password)
	SetEntryField(entry, "UserName", user)
	SetEntryField(entry, "URL", url)
	SetEntryField(entry, "Notes", notes)
	return b.save()
}

// Import reads a file from srcPath and stores it under destPath.
// When force is false and an entry already exists at destPath, the import is
// skipped silently (a warning is printed to stderr) and nil is returned —
// matching the interface contract and the behaviour of store.Store.Import.
func (b *Backend) Import(ctx context.Context, srcPath, destPath string, force bool) error {
	if !force && b.entryExists(destPath) {
		// Skip without error; warn on stderr so the operator knows something
		// was skipped, consistent with store.Store.Import(force=false).
		fmt.Fprintf(os.Stderr, "Warning: keepass entry %q already exists, skipping (use force to overwrite)\n", destPath)
		return nil
	}
	content, err := os.ReadFile(srcPath)
	if err != nil {
		return fmt.Errorf("keepass import: reading %q: %w", srcPath, err)
	}
	// importBytes keeps the file data as []byte throughout, avoiding a
	// []byte→string→[]byte round-trip that would occur when routing through
	// the public Add(string) API for attachment entries (mistake #40).
	return b.importBytes(destPath, content)
}

// importBytes stores raw file bytes under destPath. It routes to either
// addAttachment (for virtual attachment paths) or addTextEntry, preserving
// the []byte so that attachment data never undergoes a redundant conversion.
//
// ctx is intentionally not threaded through to addAttachment or addTextEntry:
// both are synchronous, purely in-memory operations (no I/O, no goroutines)
// that complete without any blocking calls that could respect cancellation.
func (b *Backend) importBytes(destPath string, content []byte) error {
	if parentDesc, attachName, ok := b.isAttachmentPath(destPath); ok {
		return b.addAttachment(parentDesc, attachName, content)
	}
	// Text entries parse the content as a string; a single conversion here
	// is unavoidable because parseContent operates on strings for efficiency.
	return b.addTextEntry(destPath, string(content))
}

// entryExists reports whether an entry with the given description already
// exists in the KeePass database. Used by Import to detect duplicates.
func (b *Backend) entryExists(description string) bool {
	for _, ve := range walkEntries(b.root()) {
		if ve.description == description {
			return true
		}
	}
	return false
}

// ImportRecursive walks directory recursively and imports every regular file
// under destDir, preserving the relative sub-directory structure in the
// description path. Uses filepath.WalkDir to descend into sub-directories.
func (b *Backend) ImportRecursive(ctx context.Context, directory, destDir string) error {
	baseDir := strings.TrimRight(destDir, "/")
	return walkDirFilesRecursive(directory, func(relFile string) error {
		destPath := filepath.Join(baseDir, relFile)
		srcPath := filepath.Join(directory, relFile)
		return b.Import(ctx, srcPath, destPath, false)
	})
}

// walkDirFilesRecursive walks a directory tree calling fn(relativeFilePath)
// for each regular file found at any depth. Uses filepath.WalkDir so that
// sub-directories are descended into.
func walkDirFilesRecursive(dir string, fn func(string) error) error {
	return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return fmt.Errorf("keepass: walking %q: %w", path, err)
		}
		if d.IsDir() {
			return nil
		}
		rel, relErr := filepath.Rel(dir, path)
		if relErr != nil {
			return fmt.Errorf("keepass: computing relative path for %q: %w", path, relErr)
		}
		// Use forward slashes in the description regardless of OS separator.
		return fn(filepath.ToSlash(rel))
	})
}