summaryrefslogtreecommitdiff
path: root/internal/cli/migrate_kdbx.go
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/cli/migrate_kdbx.go
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/cli/migrate_kdbx.go')
-rw-r--r--internal/cli/migrate_kdbx.go165
1 files changed, 23 insertions, 142 deletions
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
-}