summaryrefslogtreecommitdiff
path: root/internal/keepass/format.go
blob: 98f5af1b9258fa9ba24714d659f5741b69588ad7 (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
// Package keepass provides a read-only backend.Backend implementation that
// reads secrets from a KeePass (.kdbx) database. It flattens KeePass groups
// into Description="Group/Subgroup/Title" entries and formats content as
// "Password:...\nUser:...\nURL:...\nNotes:\n..." text.
package keepass

import (
	"regexp"
	"strings"
)

// Content field patterns — compiled once at package init to avoid
// per-call regex allocation.
var (
	passwordPattern = regexp.MustCompile(`(?i)^\s*pass(?:word)?\s*:\s*(.*)\s*$`)
	userPattern     = regexp.MustCompile(`(?i)^\s*user(?:name)?\s*:\s*(.*)\s*$`)
	urlPattern      = regexp.MustCompile(`(?i)^\s*url\s*:\s*(.*)\s*$`)
	// notesPattern matches the "Notes:" header with nothing after the colon.
	// This is intentional: formatContent always emits "Notes:\n" on its own
	// line so that everything following the header is treated as multi-line
	// notes body. A "Notes: value" form on the same line is not produced by
	// formatContent and would be silently ignored during parseContent; that
	// case is explicitly unsupported to keep the parser simple.
	notesPattern = regexp.MustCompile(`(?i)^\s*notes\s*:\s*$`)
)

// formatContent builds the canonical multi-line text representation for a
// KeePass entry. The stable field order (Password / User / URL / Notes)
// ensures round-trips through parseContent are lossless.
//
// Output format:
//
//	Password: <value>
//	User: <value>
//	URL: <value>
//	Notes:
//	<notes lines>
func formatContent(password, user, url, notes string) []byte {
	var b strings.Builder
	b.WriteString("Password: ")
	b.WriteString(password)
	b.WriteByte('\n')
	b.WriteString("User: ")
	b.WriteString(user)
	b.WriteByte('\n')
	b.WriteString("URL: ")
	b.WriteString(url)
	b.WriteByte('\n')
	b.WriteString("Notes:\n")
	if notes != "" {
		b.WriteString(notes)
		if !strings.HasSuffix(notes, "\n") {
			b.WriteByte('\n')
		}
	}
	return []byte(b.String())
}

// parseContent is the inverse of formatContent. It tolerates missing or
// reordered fields and handles a multi-line Notes section. Lines that appear
// before a "Notes:" header are matched against the password/user/url patterns;
// everything after "Notes:" is collected verbatim.
//
// This generalises extractPasswordFromContent from internal/cli/migrate_kdbx.go
// to also handle User: and URL: fields.
func parseContent(content []byte) (password, user, url, notes string) {
	lines := strings.Split(string(content), "\n")
	inNotes := false
	var notesLines []string

	for _, line := range lines {
		if inNotes {
			notesLines = append(notesLines, line)
			continue
		}
		if notesPattern.MatchString(line) {
			inNotes = true
			continue
		}
		if m := passwordPattern.FindStringSubmatch(line); len(m) == 2 && password == "" {
			password = strings.TrimSpace(m[1])
			continue
		}
		if m := userPattern.FindStringSubmatch(line); len(m) == 2 && user == "" {
			user = strings.TrimSpace(m[1])
			continue
		}
		if m := urlPattern.FindStringSubmatch(line); len(m) == 2 && url == "" {
			url = strings.TrimSpace(m[1])
			continue
		}
	}

	notes = strings.TrimRight(strings.Join(notesLines, "\n"), "\n")
	return password, user, url, notes
}