summaryrefslogtreecommitdiff
path: root/internal/migrate/migrator.go
blob: 7ff0e304dbccb1aa8c6df373eeccd3a9da5a53ac (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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
package migrate

import (
	"context"
	"fmt"
	"regexp"
	"sort"
	"strings"

	"codeberg.org/snonux/foostore/internal/keepass"
	"codeberg.org/snonux/foostore/internal/store"
)

// Options holds the resolved parameters for a geheim→KeePass migration run.
type Options struct {
	DryRun bool
}

// Stats accumulates counters for a migration run so callers can report progress.
type Stats struct {
	Total           int
	TextMigrated    int
	BinaryMigrated  int
	OverwrittenText int
	OverwrittenBin  int
	Errors          int
}

// StoreWalker is the subset of backend.Backend needed by the migrator. Using a
// narrow interface here avoids importing the full backend package and makes
// testing straightforward.
type StoreWalker interface {
	WalkIndexes(ctx context.Context, prefix string, fn func(*store.Index) error) error
	LoadData(ctx context.Context, idx *store.Index) (*store.Data, error)
}

// Run walks all index entries in src, migrating each one into kdbx.
// It returns aggregated Stats. When opts.DryRun is true, no writes are made
// to kdbx (kdbx may be nil in that case). logFn receives informational
// messages; warnFn receives per-entry error messages (should write to stderr).
// Neither may be nil.
func Run(ctx context.Context, src StoreWalker, kdbx KDBXStore, opts Options, logFn, warnFn func(string)) (Stats, error) {
	var indexes store.IndexSlice
	if err := src.WalkIndexes(ctx, "", func(idx *store.Index) error {
		indexes = append(indexes, idx)
		return nil
	}); err != nil {
		return Stats{}, fmt.Errorf("listing store entries: %w", err)
	}
	sort.Sort(indexes)

	var stats Stats
	for _, idx := range indexes {
		stats.Total++
		if err := migrateOneEntry(ctx, src, idx, opts, kdbx, &stats, logFn); err != nil {
			stats.Errors++
			// Per-entry errors go through warnFn so the caller can route them
			// to stderr, keeping informational log and error output separate.
			warnFn(err.Error())
		}
	}
	return stats, nil
}

// migrateOneEntry migrates a single index entry from src into kdbx (or logs
// the action when dry-run is active). Errors are returned so the caller can
// increment the error counter and continue.
func migrateOneEntry(
	ctx context.Context,
	src StoreWalker,
	idx *store.Index,
	opts Options,
	kdbx KDBXStore,
	stats *Stats,
	logFn func(string),
) error {
	safePath, err := keepass.SanitizeRelativePath(idx.Description)
	if err != nil {
		return fmt.Errorf("entry %q: %w", idx.Description, err)
	}

	d, err := src.LoadData(ctx, idx)
	if err != nil {
		return fmt.Errorf("loading data for %q: %w", idx.Description, err)
	}

	if idx.IsBinary() {
		return migrateBinaryEntry(idx, safePath, d.Content, opts, kdbx, stats, logFn)
	}
	return migrateTextEntry(idx, safePath, d.Content, opts, kdbx, stats, logFn)
}

// migrateBinaryEntry handles a binary (non-text) store entry.
func migrateBinaryEntry(
	idx *store.Index,
	safePath string,
	content []byte,
	opts Options,
	kdbx KDBXStore,
	stats *Stats,
	logFn func(string),
) error {
	groupPath, title, err := keepass.SplitDescriptionPath(safePath)
	if err != nil {
		return fmt.Errorf("mapping binary entry %q: %w", idx.Description, err)
	}
	if opts.DryRun {
		logFn(fmt.Sprintf("DRY-RUN binary migrate: %s -> attachment=%s", idx.Description, title))
		stats.BinaryMigrated++
		return nil
	}
	overwrote, err := kdbx.UpsertBinaryEntry(groupPath, title, title, content)
	if err != nil {
		return fmt.Errorf("upserting binary entry %q: %w", idx.Description, err)
	}
	if overwrote {
		stats.OverwrittenBin++
	}
	stats.BinaryMigrated++
	return nil
}

// migrateTextEntry handles a text (non-binary) store entry.
func migrateTextEntry(
	idx *store.Index,
	safePath string,
	content []byte,
	opts Options,
	kdbx KDBXStore,
	stats *Stats,
	logFn func(string),
) error {
	groupPath, title, err := keepass.SplitDescriptionPath(safePath)
	if err != nil {
		return fmt.Errorf("mapping text entry %q: %w", idx.Description, err)
	}
	if opts.DryRun {
		logFn(fmt.Sprintf("DRY-RUN text migrate: %s -> group=%q title=%q",
			idx.Description, strings.Join(groupPath, "/"), title))
		stats.TextMigrated++
		return nil
	}
	entryPassword, entryNotes := ExtractPasswordFromContent(string(content))
	overwrote, err := kdbx.UpsertTextEntry(groupPath, title, entryPassword, entryNotes)
	if err != nil {
		return fmt.Errorf("upserting text entry %q: %w", idx.Description, err)
	}
	if overwrote {
		stats.OverwrittenText++
	}
	stats.TextMigrated++
	return nil
}

// passwordLinePattern matches lines like "password: s3cr3t" or "pass: s3cr3t"
// (case-insensitive) so that the password field can be extracted from entry content.
var passwordLinePattern = regexp.MustCompile(`(?i)^\s*(pass|password)\s*:\s*(.*)\s*$`)

// ExtractPasswordFromContent splits entry text content into a password (from
// the first "pass:" or "password:" line) and the remaining notes. The password
// line itself is removed from the notes output. This is exported so the CLI
// package can call it directly in tests without duplicating the logic.
func ExtractPasswordFromContent(content string) (password, notes string) {
	lines := strings.Split(content, "\n")
	notesLines := make([]string, 0, len(lines))

	for _, line := range lines {
		m := passwordLinePattern.FindStringSubmatch(line)
		if len(m) == 3 {
			// Take only the first password line encountered.
			if password == "" {
				password = strings.TrimSpace(m[2])
			}
			continue
		}
		notesLines = append(notesLines, line)
	}

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