summaryrefslogtreecommitdiff
path: root/internal/migrate
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/migrate
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/migrate')
-rw-r--r--internal/migrate/kdbx_store.go105
-rw-r--r--internal/migrate/migrator.go181
-rw-r--r--internal/migrate/migrator_test.go202
3 files changed, 488 insertions, 0 deletions
diff --git a/internal/migrate/kdbx_store.go b/internal/migrate/kdbx_store.go
new file mode 100644
index 0000000..d2ad900
--- /dev/null
+++ b/internal/migrate/kdbx_store.go
@@ -0,0 +1,105 @@
+// 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"
+ "os"
+
+ gokeepasslib "github.com/tobischo/gokeepasslib/v3"
+
+ "codeberg.org/snonux/foostore/internal/keepass"
+)
+
+// 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,
+// 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 {
+ return nil, fmt.Errorf("opening kdbx %q: %w", dbPath, err)
+ }
+ defer f.Close()
+
+ db := gokeepasslib.NewDatabase()
+ db.Credentials = gokeepasslib.NewPasswordCredentials(password)
+ if err := gokeepasslib.NewDecoder(f).Decode(db); err != nil {
+ return nil, fmt.Errorf("decoding kdbx %q: %w", dbPath, err)
+ }
+ if err := db.UnlockProtectedEntries(); err != nil {
+ 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()
+ }
+ if db.Content.Root == nil {
+ db.Content.Root = gokeepasslib.NewRootData()
+ }
+ if len(db.Content.Root.Groups) == 0 {
+ root := gokeepasslib.NewGroup()
+ root.Name = "Root"
+ db.Content.Root.Groups = append(db.Content.Root.Groups, root)
+ }
+}
+
+// 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.
+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)
+ keepass.SetEntryField(entry, "Title", title)
+ keepass.SetEntryField(entry, "Password", password)
+ keepass.SetEntryField(entry, "Notes", notes)
+ return overwrote, nil
+}
+
+// UpsertBinaryEntry creates or updates a binary attachment entry in groupPath.
+// Delegates field manipulation to keepass.SetEntryField and group navigation
+// 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)
+ keepass.SetEntryField(entry, "Title", title)
+ keepass.SetEntryField(entry, "Password", "")
+
+ b := s.db.AddBinary(content)
+ entry.Binaries = []gokeepasslib.BinaryReference{b.CreateReference(filename)}
+ // Keep notes concise for binary-only entries.
+ keepass.SetEntryField(entry, "Notes", fmt.Sprintf("Migrated binary attachment: %s", filename))
+ return overwrote, nil
+}
+
+// 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.
+func (s *kdbxStore) Save() error {
+ if err := s.db.LockProtectedEntries(); err != nil {
+ return fmt.Errorf("locking kdbx entries: %w", err)
+ }
+ return keepass.AtomicSave(s.db, s.path)
+}
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)
+ }
+}