summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-18 16:22:05 +0300
committerPaul Buetow <paul@buetow.org>2026-04-18 16:22:05 +0300
commit667bf24a8ac2c2b4c4f9befd7f77b22f0a156a27 (patch)
tree620e41dba973e2916087ff7743d5a7ec6afd9d38 /internal
parent60f717b97ce6c375679080472750e60aab9dcd8f (diff)
refactor: extract migration logic into internal/migrate package (task q6)
Move all geheim→KeePass migration business logic from internal/cli/migrate_kdbx.go into a new internal/migrate package (migrator.go, kdbx_store.go). The CLI layer is now a thin orchestrator: parse flags, open KDBX, call migrate.Run, save, report. Key design decisions: - migrate.Run accepts separate logFn (stdout info) and warnFn (stderr errors) so per-entry errors correctly route to stderr via warn(), not stdout via logMsg() - migrate.Options contains only DryRun; DBPath/BinaryOutDir removed (CLI-only concerns) - StoreWalker interface is narrow, avoiding a direct dependency on backend.Backend - cli_paths.go extracts readPasswordFile/resolveHomeDir/expandHome shared within cli - kdbx_store.go deleted from cli (moved to internal/migrate) - Duplicate TestExtractPasswordFromContent removed from kdbx_store_test.go - Custom contains/containsStr helpers replaced with strings.Contains in tests - Dry-run test now asserts logged message content Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go8
-rw-r--r--internal/cli/cli_backend.go2
-rw-r--r--internal/cli/cli_paths.go47
-rw-r--r--internal/cli/cli_test.go3
-rw-r--r--internal/cli/kdbx_store_test.go9
-rw-r--r--internal/cli/migrate_kdbx.go165
-rw-r--r--internal/migrate/kdbx_store.go (renamed from internal/cli/kdbx_store.go)32
-rw-r--r--internal/migrate/migrator.go181
-rw-r--r--internal/migrate/migrator_test.go202
9 files changed, 482 insertions, 167 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 01342be..674d120 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -9,7 +9,8 @@
// - cli_backend.go — backend factory (buildBackend, buildGeheimBackend, buildKeepassBackend, ...)
// - cli_dispatch.go — shell loop (shellLoop) and command dispatcher (dispatch, dispatchSimple, dispatchSearch)
// - cli_commands.go — concrete command handlers (cmdAdd, cmdImport, …) and action-function factories
-// - migrate_kdbx.go — migrate-kdbx command and its helpers
+// - cli_paths.go — shared path utilities (readPasswordFile, resolveHomeDir, expandHome)
+// - migrate_kdbx.go — thin CLI handler for migrate-kdbx; delegates logic to internal/migrate
package cli
import (
@@ -22,6 +23,7 @@ import (
"codeberg.org/snonux/foostore/internal/backend"
"codeberg.org/snonux/foostore/internal/clipboard"
"codeberg.org/snonux/foostore/internal/config"
+ "codeberg.org/snonux/foostore/internal/migrate"
"codeberg.org/snonux/foostore/internal/shell"
"codeberg.org/snonux/foostore/internal/store"
)
@@ -73,7 +75,7 @@ type CLI struct {
g Gitter // real *git.Git or *git.NoOp when kdbx is outside a repo
clip *clipboard.Clipboard
sh *shell.Shell
- openKDBX func(string, string) (KDBXStore, error)
+ openKDBX func(string, string) (migrate.KDBXStore, error)
now func() time.Time
lastResult string // most recent search result description
effectiveBackend string // resolved backend: --backend flag > cfg.Backend > "geheim"
@@ -133,7 +135,7 @@ func newCLI(ctx context.Context, backendName, kdbxPath string) (*CLI, error) {
st: st,
g: g,
clip: clip,
- openKDBX: OpenKDBXStore,
+ openKDBX: migrate.OpenKDBXStore,
now: time.Now,
effectiveBackend: effectiveBackend,
}
diff --git a/internal/cli/cli_backend.go b/internal/cli/cli_backend.go
index 698c9f3..6a19427 100644
--- a/internal/cli/cli_backend.go
+++ b/internal/cli/cli_backend.go
@@ -147,7 +147,7 @@ func readKeepassPassphrase(cfg *config.Config) (string, error) {
}
// 2. KDBXPassFile — a file containing the password (trimmed of newlines).
- // readPasswordFile is defined in migrate_kdbx.go and shared here.
+ // readPasswordFile is defined in cli_paths.go and shared across this package.
if cfg.KDBXPassFile != "" {
pass, err := readPasswordFile(cfg.KDBXPassFile)
if err != nil {
diff --git a/internal/cli/cli_paths.go b/internal/cli/cli_paths.go
new file mode 100644
index 0000000..5eae85a
--- /dev/null
+++ b/internal/cli/cli_paths.go
@@ -0,0 +1,47 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// readPasswordFile reads a password from a file, trimming trailing newlines.
+// Returns an error when the file cannot be read or the result is empty.
+// Used by both migrate-kdbx and the KeePass backend initialisation
+// (cli_backend.go → readKeepassPassphrase).
+func readPasswordFile(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("reading password file %q: %w", path, err)
+ }
+ pass := strings.TrimRight(string(data), "\r\n")
+ if pass == "" {
+ return "", fmt.Errorf("password file %q is empty", path)
+ }
+ return pass, nil
+}
+
+// resolveHomeDir returns the current user's home directory.
+// Falls back to "." when os.UserHomeDir fails so callers always receive a
+// non-empty path.
+func resolveHomeDir() string {
+ home, err := os.UserHomeDir()
+ if err != nil || home == "" {
+ return "."
+ }
+ return home
+}
+
+// expandHome expands a leading "~" or "~/" to the current user's home directory.
+// Paths that do not start with "~" are returned unchanged.
+func expandHome(path string) string {
+ if path == "~" {
+ return resolveHomeDir()
+ }
+ if strings.HasPrefix(path, "~/") {
+ return filepath.Join(resolveHomeDir(), path[2:])
+ }
+ return path
+}
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index 4b05487..487907d 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -18,6 +18,7 @@ import (
"codeberg.org/snonux/foostore/internal/config"
"codeberg.org/snonux/foostore/internal/crypto"
"codeberg.org/snonux/foostore/internal/git"
+ "codeberg.org/snonux/foostore/internal/migrate"
"codeberg.org/snonux/foostore/internal/shell"
"codeberg.org/snonux/foostore/internal/store"
)
@@ -700,7 +701,7 @@ func TestDispatch_migrateKDBX_writesBinaryAndSavesKDBX(t *testing.T) {
fake := &fakeKDBXStore{
overwrites: map[string]bool{"notes": true},
}
- c.openKDBX = func(path, password string) (KDBXStore, error) {
+ c.openKDBX = func(path, password string) (migrate.KDBXStore, error) {
if path != dbPath {
t.Fatalf("openKDBX path = %q; want %q", path, dbPath)
}
diff --git a/internal/cli/kdbx_store_test.go b/internal/cli/kdbx_store_test.go
index bff1a0a..035c03c 100644
--- a/internal/cli/kdbx_store_test.go
+++ b/internal/cli/kdbx_store_test.go
@@ -25,12 +25,3 @@ func TestSanitizeRelativePathRejectsTraversal(t *testing.T) {
}
}
-func TestExtractPasswordFromContent(t *testing.T) {
- password, notes := extractPasswordFromContent("user: alice\npassword: s3cr3t\nurl: example.com\n")
- if password != "s3cr3t" {
- t.Fatalf("password = %q; want s3cr3t", password)
- }
- if notes != "user: alice\nurl: example.com" {
- t.Fatalf("notes = %q; want without password line", notes)
- }
-}
diff --git a/internal/cli/migrate_kdbx.go b/internal/cli/migrate_kdbx.go
index 64241ac..beec5c6 100644
--- a/internal/cli/migrate_kdbx.go
+++ b/internal/cli/migrate_kdbx.go
@@ -5,15 +5,13 @@ import (
"fmt"
"os"
"path/filepath"
- "regexp"
- "sort"
- "strings"
"time"
- "codeberg.org/snonux/foostore/internal/keepass"
- "codeberg.org/snonux/foostore/internal/store"
+ "codeberg.org/snonux/foostore/internal/migrate"
)
+// migrateKDBXOptions holds the parsed CLI flags for the migrate-kdbx command.
+// Path fields are expanded (~ resolved, absolute) before use.
type migrateKDBXOptions struct {
DBPath string
PassFile string
@@ -21,22 +19,15 @@ type migrateKDBXOptions struct {
DryRun bool
}
-type migrateKDBXStats struct {
- Total int
- TextMigrated int
- BinaryMigrated int
- OverwrittenText int
- OverwrittenBin int
- Errors int
-}
-
+// cmdMigrateKDBX is the CLI handler for "migrate-kdbx". It validates the active
+// backend, parses flags, opens the KDBX database, runs the migration, and
+// reports a summary. All migration business logic is delegated to internal/migrate.
+//
+// migrate-kdbx only makes sense when the source is the geheim backend.
+// We check c.effectiveBackend (which incorporates the --backend flag override)
+// rather than c.cfg.Backend so that "foostore --backend keepass migrate-kdbx"
+// is correctly rejected even when cfg.Backend is empty.
func (c *CLI) cmdMigrateKDBX(ctx context.Context, argv []string) int {
- // migrate-kdbx only makes sense when the source is the geheim backend.
- // Refuse early when the active backend is already keepass to avoid
- // accidentally migrating keepass→keepass.
- // We check c.effectiveBackend (which incorporates the --backend flag override)
- // rather than c.cfg.Backend (config file only) so that "foostore --backend keepass
- // migrate-kdbx" is correctly rejected even when cfg.Backend is empty.
if c.effectiveBackend == "keepass" {
warn("migrate-kdbx is not supported when the active backend is 'keepass'; it migrates geheim→keepass only")
return 1
@@ -59,11 +50,11 @@ func (c *CLI) cmdMigrateKDBX(ctx context.Context, argv []string) int {
return 1
}
- var kdbx KDBXStore
+ var kdbx migrate.KDBXStore
if !opts.DryRun {
opener := c.openKDBX
if opener == nil {
- opener = OpenKDBXStore
+ opener = migrate.OpenKDBXStore
}
kdbx, err = opener(opts.DBPath, password)
if err != nil {
@@ -72,23 +63,16 @@ func (c *CLI) cmdMigrateKDBX(ctx context.Context, argv []string) int {
}
}
- var indexes store.IndexSlice
- if err := c.st.WalkIndexes(ctx, "", func(idx *store.Index) error {
- indexes = append(indexes, idx)
- return nil
- }); err != nil {
- warn(fmt.Sprintf("listing store entries: %v", err))
- return 1
+ migrateOpts := migrate.Options{
+ DryRun: opts.DryRun,
}
- sort.Sort(indexes)
- stats := migrateKDBXStats{}
- for _, idx := range indexes {
- stats.Total++
- if err := c.migrateOneEntry(ctx, idx, opts, kdbx, &stats); err != nil {
- stats.Errors++
- warn(err.Error())
- }
+ // logMsg routes informational messages to stdout; warn routes per-entry
+ // errors to stderr so they are visually distinct and script-filterable.
+ stats, err := migrate.Run(ctx, c.st, kdbx, migrateOpts, logMsg, warn)
+ if err != nil {
+ warn(err.Error())
+ return 1
}
if !opts.DryRun {
@@ -117,60 +101,8 @@ func (c *CLI) cmdMigrateKDBX(ctx context.Context, argv []string) int {
return 0
}
-func (c *CLI) migrateOneEntry(ctx context.Context, idx *store.Index, opts migrateKDBXOptions, kdbx KDBXStore, stats *migrateKDBXStats) error {
- safePath, err := keepass.SanitizeRelativePath(idx.Description)
- if err != nil {
- return fmt.Errorf("entry %q: %w", idx.Description, err)
- }
-
- d, err := c.st.LoadData(ctx, idx)
- if err != nil {
- return fmt.Errorf("loading data for %q: %w", idx.Description, err)
- }
-
- if idx.IsBinary() {
- groupPath, title, err := keepass.SplitDescriptionPath(safePath)
- if err != nil {
- return fmt.Errorf("mapping binary entry %q: %w", idx.Description, err)
- }
- if opts.DryRun {
- logMsg(fmt.Sprintf("DRY-RUN binary migrate: %s -> attachment=%s", idx.Description, title))
- stats.BinaryMigrated++
- return nil
- }
- overwrote, err := kdbx.UpsertBinaryEntry(groupPath, title, title, d.Content)
- if err != nil {
- return fmt.Errorf("upserting binary entry %q: %w", idx.Description, err)
- }
- if overwrote {
- stats.OverwrittenBin++
- }
- stats.BinaryMigrated++
- return nil
- }
-
- groupPath, title, err := keepass.SplitDescriptionPath(safePath)
- if err != nil {
- return fmt.Errorf("mapping text entry %q: %w", idx.Description, err)
- }
- if opts.DryRun {
- logMsg(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(d.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
-}
-
+// parseMigrateKDBXOptions parses the migrate-kdbx flags from argv and returns
+// a migrateKDBXOptions with all paths resolved and validated.
func (c *CLI) parseMigrateKDBXOptions(argv []string) (migrateKDBXOptions, error) {
now := c.now
if now == nil {
@@ -227,54 +159,3 @@ func (c *CLI) parseMigrateKDBXOptions(argv []string) (migrateKDBXOptions, error)
}
return opts, nil
}
-
-func readPasswordFile(path string) (string, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return "", fmt.Errorf("reading password file %q: %w", path, err)
- }
- pass := strings.TrimRight(string(data), "\r\n")
- if pass == "" {
- return "", fmt.Errorf("password file %q is empty", path)
- }
- return pass, nil
-}
-
-func resolveHomeDir() string {
- home, err := os.UserHomeDir()
- if err != nil || home == "" {
- return "."
- }
- return home
-}
-
-func expandHome(path string) string {
- if path == "~" {
- return resolveHomeDir()
- }
- if strings.HasPrefix(path, "~/") {
- return filepath.Join(resolveHomeDir(), path[2:])
- }
- return path
-}
-
-var passwordLinePattern = regexp.MustCompile(`(?i)^\s*(pass|password)\s*:\s*(.*)\s*$`)
-
-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 {
- if password == "" {
- password = strings.TrimSpace(m[2])
- }
- continue
- }
- notesLines = append(notesLines, line)
- }
-
- notes = strings.TrimRight(strings.Join(notesLines, "\n"), "\n")
- return password, notes
-}
diff --git a/internal/cli/kdbx_store.go b/internal/migrate/kdbx_store.go
index bdaeb31..d2ad900 100644
--- a/internal/cli/kdbx_store.go
+++ b/internal/migrate/kdbx_store.go
@@ -1,4 +1,7 @@
-package cli
+// Package migrate provides the geheim→KeePass migration logic for foostore.
+// The CLI layer (internal/cli) is responsible only for flag parsing and exit
+// codes; all migration business logic lives here.
+package migrate
import (
"fmt"
@@ -9,19 +12,24 @@ import (
"codeberg.org/snonux/foostore/internal/keepass"
)
-// KDBXStore is the minimal interface needed by migrate-kdbx.
+// KDBXStore is the minimal interface needed by the migrator to write entries
+// into a KeePass database. Keeping it small satisfies the Interface Segregation
+// Principle and allows easy substitution in tests.
type KDBXStore interface {
UpsertTextEntry(groupPath []string, title, password, notes string) (overwrote bool, err error)
UpsertBinaryEntry(groupPath []string, title, filename string, content []byte) (overwrote bool, err error)
Save() error
}
+// kdbxStore wraps an in-memory gokeepasslib.Database and the file path it will
+// be written to on Save().
type kdbxStore struct {
path string
db *gokeepasslib.Database
}
-// OpenKDBXStore opens an existing KDBX database using password credentials.
+// OpenKDBXStore opens an existing KDBX database using password credentials,
+// decodes and unlocks protected entries, and returns a ready-to-use KDBXStore.
func OpenKDBXStore(dbPath, password string) (KDBXStore, error) {
f, err := os.Open(dbPath)
if err != nil {
@@ -38,6 +46,13 @@ func OpenKDBXStore(dbPath, password string) (KDBXStore, error) {
return nil, fmt.Errorf("unlocking kdbx %q: %w", dbPath, err)
}
+ ensureRootGroup(db)
+ return &kdbxStore{path: dbPath, db: db}, nil
+}
+
+// ensureRootGroup guarantees that the database has a valid Content, Root, and
+// at least one top-level Group so callers never have to nil-check these fields.
+func ensureRootGroup(db *gokeepasslib.Database) {
if db.Content == nil {
db.Content = gokeepasslib.NewContent()
}
@@ -49,16 +64,11 @@ func OpenKDBXStore(dbPath, password string) (KDBXStore, error) {
root.Name = "Root"
db.Content.Root.Groups = append(db.Content.Root.Groups, root)
}
-
- return &kdbxStore{
- path: dbPath,
- db: db,
- }, nil
}
// UpsertTextEntry creates or updates a text entry in groupPath with the given
// title, password, and notes. Delegates field manipulation to keepass.SetEntryField
-// and group navigation to keepass.EnsureGroup to avoid duplication.
+// and group navigation to keepass.EnsureGroup.
func (s *kdbxStore) UpsertTextEntry(groupPath []string, title, password, notes string) (bool, error) {
g := keepass.EnsureGroup(&s.db.Content.Root.Groups[0], groupPath)
entry, overwrote := keepass.UpsertEntryByTitle(g, title)
@@ -70,7 +80,7 @@ func (s *kdbxStore) UpsertTextEntry(groupPath []string, title, password, notes s
// UpsertBinaryEntry creates or updates a binary attachment entry in groupPath.
// Delegates field manipulation to keepass.SetEntryField and group navigation
-// to keepass.EnsureGroup to avoid duplication.
+// to keepass.EnsureGroup.
func (s *kdbxStore) UpsertBinaryEntry(groupPath []string, title, filename string, content []byte) (bool, error) {
g := keepass.EnsureGroup(&s.db.Content.Root.Groups[0], groupPath)
entry, overwrote := keepass.UpsertEntryByTitle(g, title)
@@ -86,7 +96,7 @@ func (s *kdbxStore) UpsertBinaryEntry(groupPath []string, title, filename string
// Save locks protected entries and atomically writes the database to disk.
// Delegates the tmp→encode→rename sequence to keepass.AtomicSave to avoid
-// duplicating that logic here (keepass.Backend.save() uses the same helper).
+// duplicating that logic here.
func (s *kdbxStore) Save() error {
if err := s.db.LockProtectedEntries(); err != nil {
return fmt.Errorf("locking kdbx entries: %w", err)
diff --git a/internal/migrate/migrator.go b/internal/migrate/migrator.go
new file mode 100644
index 0000000..7ff0e30
--- /dev/null
+++ b/internal/migrate/migrator.go
@@ -0,0 +1,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
+}
diff --git a/internal/migrate/migrator_test.go b/internal/migrate/migrator_test.go
new file mode 100644
index 0000000..b8a11a2
--- /dev/null
+++ b/internal/migrate/migrator_test.go
@@ -0,0 +1,202 @@
+package migrate_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/foostore/internal/migrate"
+ "codeberg.org/snonux/foostore/internal/store"
+)
+
+// ---- ExtractPasswordFromContent tests ----------------------------------------
+
+func TestExtractPasswordFromContent(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ wantPassword string
+ wantNotesHas string
+ wantNotesMiss string
+ }{
+ {
+ name: "password line extracted",
+ input: "user: alice\npassword: s3cr3t\nurl: example.com\n",
+ wantPassword: "s3cr3t",
+ wantNotesHas: "user: alice",
+ wantNotesMiss: "password: s3cr3t",
+ },
+ {
+ name: "pass shorthand extracted",
+ input: "pass: abc123\nhost: db.local",
+ wantPassword: "abc123",
+ wantNotesHas: "host: db.local",
+ wantNotesMiss: "pass: abc123",
+ },
+ {
+ name: "case insensitive",
+ input: "PASSWORD: hidden\nother: line",
+ wantPassword: "hidden",
+ wantNotesHas: "other: line",
+ wantNotesMiss: "PASSWORD",
+ },
+ {
+ name: "no password line",
+ input: "just notes\nno password here",
+ wantPassword: "",
+ wantNotesHas: "just notes",
+ wantNotesMiss: "",
+ },
+ {
+ name: "only first password line taken",
+ input: "password: first\npassword: second",
+ wantPassword: "first",
+ wantNotesMiss: "first",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ pw, notes := migrate.ExtractPasswordFromContent(tc.input)
+ if pw != tc.wantPassword {
+ t.Errorf("password = %q; want %q", pw, tc.wantPassword)
+ }
+ if tc.wantNotesHas != "" && !strings.Contains(notes, tc.wantNotesHas) {
+ t.Errorf("notes %q should contain %q", notes, tc.wantNotesHas)
+ }
+ if tc.wantNotesMiss != "" && strings.Contains(notes, tc.wantNotesMiss) {
+ t.Errorf("notes %q should NOT contain %q", notes, tc.wantNotesMiss)
+ }
+ })
+ }
+}
+
+// ---- Run tests with fakes ----------------------------------------------------
+
+// fakeWalker implements StoreWalker using an in-memory list of entries.
+type fakeWalker struct {
+ indexes []*store.Index
+ // dataByDesc maps description to raw content bytes.
+ dataByDesc map[string][]byte
+}
+
+func (w *fakeWalker) WalkIndexes(_ context.Context, _ string, fn func(*store.Index) error) error {
+ for _, idx := range w.indexes {
+ if err := fn(idx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (w *fakeWalker) LoadData(_ context.Context, idx *store.Index) (*store.Data, error) {
+ content := w.dataByDesc[idx.Description]
+ return &store.Data{Content: content}, nil
+}
+
+// fakeKDBX records calls to UpsertTextEntry and UpsertBinaryEntry.
+type fakeKDBX struct {
+ texts []string
+ binaries []string
+ saved bool
+}
+
+func (k *fakeKDBX) UpsertTextEntry(groupPath []string, title, password, notes string) (bool, error) {
+ k.texts = append(k.texts, title+"|"+password)
+ return false, nil
+}
+
+func (k *fakeKDBX) UpsertBinaryEntry(groupPath []string, title, filename string, content []byte) (bool, error) {
+ k.binaries = append(k.binaries, title)
+ return false, nil
+}
+
+func (k *fakeKDBX) Save() error {
+ k.saved = true
+ return nil
+}
+
+func makeIndex(description string) *store.Index {
+ return &store.Index{Description: description}
+}
+
+func TestRun_dryRun(t *testing.T) {
+ walker := &fakeWalker{
+ indexes: []*store.Index{
+ makeIndex("work/notes"),
+ makeIndex("images/logo.png"),
+ },
+ dataByDesc: map[string][]byte{
+ "work/notes": []byte("password: secret\nsome notes"),
+ "images/logo.png": {0, 1, 2},
+ },
+ }
+
+ var logged []string
+ logFn := func(msg string) { logged = append(logged, msg) }
+ warnFn := func(string) {}
+
+ opts := migrate.Options{DryRun: true}
+ stats, err := migrate.Run(context.Background(), walker, nil, opts, logFn, warnFn)
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if stats.Total != 2 {
+ t.Errorf("Total = %d; want 2", stats.Total)
+ }
+ if stats.TextMigrated != 1 {
+ t.Errorf("TextMigrated = %d; want 1", stats.TextMigrated)
+ }
+ if stats.BinaryMigrated != 1 {
+ t.Errorf("BinaryMigrated = %d; want 1", stats.BinaryMigrated)
+ }
+ if stats.Errors != 0 {
+ t.Errorf("Errors = %d; want 0", stats.Errors)
+ }
+ // Verify dry-run log messages are emitted for both entry types.
+ foundText := false
+ foundBinary := false
+ for _, msg := range logged {
+ if strings.Contains(msg, "DRY-RUN text migrate") {
+ foundText = true
+ }
+ if strings.Contains(msg, "DRY-RUN binary migrate") {
+ foundBinary = true
+ }
+ }
+ if !foundText {
+ t.Errorf("no 'DRY-RUN text migrate' message in logged: %v", logged)
+ }
+ if !foundBinary {
+ t.Errorf("no 'DRY-RUN binary migrate' message in logged: %v", logged)
+ }
+}
+
+func TestRun_liveWrites(t *testing.T) {
+ walker := &fakeWalker{
+ indexes: []*store.Index{
+ makeIndex("finance/budget"),
+ makeIndex("attachments/report.pdf"),
+ },
+ dataByDesc: map[string][]byte{
+ "finance/budget": []byte("password: money\nnotes line"),
+ "attachments/report.pdf": {5, 6, 7},
+ },
+ }
+
+ kdbx := &fakeKDBX{}
+ opts := migrate.Options{DryRun: false}
+ stats, err := migrate.Run(context.Background(), walker, kdbx, opts, func(string) {}, func(string) {})
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if stats.TextMigrated != 1 || stats.BinaryMigrated != 1 {
+ t.Errorf("stats = %+v; want text=1 binary=1", stats)
+ }
+ if len(kdbx.texts) != 1 || !strings.Contains(kdbx.texts[0], "money") {
+ t.Errorf("texts = %v; want password field 'money'", kdbx.texts)
+ }
+ if len(kdbx.binaries) != 1 {
+ t.Errorf("binaries = %v; want 1 entry", kdbx.binaries)
+ }
+}