diff options
| -rw-r--r-- | internal/cli/cli.go | 8 | ||||
| -rw-r--r-- | internal/cli/cli_backend.go | 2 | ||||
| -rw-r--r-- | internal/cli/cli_paths.go | 47 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 3 | ||||
| -rw-r--r-- | internal/cli/kdbx_store_test.go | 9 | ||||
| -rw-r--r-- | internal/cli/migrate_kdbx.go | 165 | ||||
| -rw-r--r-- | internal/migrate/kdbx_store.go (renamed from internal/cli/kdbx_store.go) | 32 | ||||
| -rw-r--r-- | internal/migrate/migrator.go | 181 | ||||
| -rw-r--r-- | internal/migrate/migrator_test.go | 202 |
9 files changed, 482 insertions, 167 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 01342be..674d120 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -9,7 +9,8 @@ // - cli_backend.go — backend factory (buildBackend, buildGeheimBackend, buildKeepassBackend, ...) // - cli_dispatch.go — shell loop (shellLoop) and command dispatcher (dispatch, dispatchSimple, dispatchSearch) // - cli_commands.go — concrete command handlers (cmdAdd, cmdImport, …) and action-function factories -// - migrate_kdbx.go — migrate-kdbx command and its helpers +// - cli_paths.go — shared path utilities (readPasswordFile, resolveHomeDir, expandHome) +// - migrate_kdbx.go — thin CLI handler for migrate-kdbx; delegates logic to internal/migrate package cli import ( @@ -22,6 +23,7 @@ import ( "codeberg.org/snonux/foostore/internal/backend" "codeberg.org/snonux/foostore/internal/clipboard" "codeberg.org/snonux/foostore/internal/config" + "codeberg.org/snonux/foostore/internal/migrate" "codeberg.org/snonux/foostore/internal/shell" "codeberg.org/snonux/foostore/internal/store" ) @@ -73,7 +75,7 @@ type CLI struct { g Gitter // real *git.Git or *git.NoOp when kdbx is outside a repo clip *clipboard.Clipboard sh *shell.Shell - openKDBX func(string, string) (KDBXStore, error) + openKDBX func(string, string) (migrate.KDBXStore, error) now func() time.Time lastResult string // most recent search result description effectiveBackend string // resolved backend: --backend flag > cfg.Backend > "geheim" @@ -133,7 +135,7 @@ func newCLI(ctx context.Context, backendName, kdbxPath string) (*CLI, error) { st: st, g: g, clip: clip, - openKDBX: OpenKDBXStore, + openKDBX: migrate.OpenKDBXStore, now: time.Now, effectiveBackend: effectiveBackend, } diff --git a/internal/cli/cli_backend.go b/internal/cli/cli_backend.go index 698c9f3..6a19427 100644 --- a/internal/cli/cli_backend.go +++ b/internal/cli/cli_backend.go @@ -147,7 +147,7 @@ func readKeepassPassphrase(cfg *config.Config) (string, error) { } // 2. KDBXPassFile — a file containing the password (trimmed of newlines). - // readPasswordFile is defined in migrate_kdbx.go and shared here. + // readPasswordFile is defined in cli_paths.go and shared across this package. if cfg.KDBXPassFile != "" { pass, err := readPasswordFile(cfg.KDBXPassFile) if err != nil { diff --git a/internal/cli/cli_paths.go b/internal/cli/cli_paths.go new file mode 100644 index 0000000..5eae85a --- /dev/null +++ b/internal/cli/cli_paths.go @@ -0,0 +1,47 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// readPasswordFile reads a password from a file, trimming trailing newlines. +// Returns an error when the file cannot be read or the result is empty. +// Used by both migrate-kdbx and the KeePass backend initialisation +// (cli_backend.go → readKeepassPassphrase). +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 +} + +// resolveHomeDir returns the current user's home directory. +// Falls back to "." when os.UserHomeDir fails so callers always receive a +// non-empty path. +func resolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "." + } + return home +} + +// expandHome expands a leading "~" or "~/" to the current user's home directory. +// Paths that do not start with "~" are returned unchanged. +func expandHome(path string) string { + if path == "~" { + return resolveHomeDir() + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(resolveHomeDir(), path[2:]) + } + return path +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4b05487..487907d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -18,6 +18,7 @@ import ( "codeberg.org/snonux/foostore/internal/config" "codeberg.org/snonux/foostore/internal/crypto" "codeberg.org/snonux/foostore/internal/git" + "codeberg.org/snonux/foostore/internal/migrate" "codeberg.org/snonux/foostore/internal/shell" "codeberg.org/snonux/foostore/internal/store" ) @@ -700,7 +701,7 @@ func TestDispatch_migrateKDBX_writesBinaryAndSavesKDBX(t *testing.T) { fake := &fakeKDBXStore{ overwrites: map[string]bool{"notes": true}, } - c.openKDBX = func(path, password string) (KDBXStore, error) { + c.openKDBX = func(path, password string) (migrate.KDBXStore, error) { if path != dbPath { t.Fatalf("openKDBX path = %q; want %q", path, dbPath) } diff --git a/internal/cli/kdbx_store_test.go b/internal/cli/kdbx_store_test.go index bff1a0a..035c03c 100644 --- a/internal/cli/kdbx_store_test.go +++ b/internal/cli/kdbx_store_test.go @@ -25,12 +25,3 @@ func TestSanitizeRelativePathRejectsTraversal(t *testing.T) { } } -func TestExtractPasswordFromContent(t *testing.T) { - password, notes := extractPasswordFromContent("user: alice\npassword: s3cr3t\nurl: example.com\n") - if password != "s3cr3t" { - t.Fatalf("password = %q; want s3cr3t", password) - } - if notes != "user: alice\nurl: example.com" { - t.Fatalf("notes = %q; want without password line", notes) - } -} 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 -} diff --git a/internal/cli/kdbx_store.go b/internal/migrate/kdbx_store.go index bdaeb31..d2ad900 100644 --- a/internal/cli/kdbx_store.go +++ b/internal/migrate/kdbx_store.go @@ -1,4 +1,7 @@ -package cli +// 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" @@ -9,19 +12,24 @@ import ( "codeberg.org/snonux/foostore/internal/keepass" ) -// KDBXStore is the minimal interface needed by migrate-kdbx. +// 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. +// 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 { @@ -38,6 +46,13 @@ func OpenKDBXStore(dbPath, password string) (KDBXStore, error) { 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() } @@ -49,16 +64,11 @@ func OpenKDBXStore(dbPath, password string) (KDBXStore, error) { root.Name = "Root" db.Content.Root.Groups = append(db.Content.Root.Groups, root) } - - return &kdbxStore{ - path: dbPath, - db: db, - }, nil } // 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 to avoid duplication. +// 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) @@ -70,7 +80,7 @@ func (s *kdbxStore) UpsertTextEntry(groupPath []string, title, password, notes s // UpsertBinaryEntry creates or updates a binary attachment entry in groupPath. // Delegates field manipulation to keepass.SetEntryField and group navigation -// to keepass.EnsureGroup to avoid duplication. +// 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) @@ -86,7 +96,7 @@ func (s *kdbxStore) UpsertBinaryEntry(groupPath []string, title, filename string // 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 (keepass.Backend.save() uses the same helper). +// duplicating that logic here. func (s *kdbxStore) Save() error { if err := s.db.LockProtectedEntries(); err != nil { return fmt.Errorf("locking kdbx entries: %w", err) 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) + } +} |
