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/migrate/migrator.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/migrate/migrator.go')
| -rw-r--r-- | internal/migrate/migrator.go | 181 |
1 files changed, 181 insertions, 0 deletions
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 +} |
