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 | |
| 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')
| -rw-r--r-- | internal/migrate/kdbx_store.go | 105 | ||||
| -rw-r--r-- | internal/migrate/migrator.go | 181 | ||||
| -rw-r--r-- | internal/migrate/migrator_test.go | 202 |
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) + } +} |
