diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-18 16:22:05 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-18 16:22:05 +0300 |
| commit | 667bf24a8ac2c2b4c4f9befd7f77b22f0a156a27 (patch) | |
| tree | 620e41dba973e2916087ff7743d5a7ec6afd9d38 /internal/cli/migrate_kdbx.go | |
| parent | 60f717b97ce6c375679080472750e60aab9dcd8f (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.go | 165 |
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 -} |
