summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/cli.go8
-rw-r--r--internal/cli/cli_backend.go2
-rw-r--r--internal/cli/cli_paths.go47
-rw-r--r--internal/cli/cli_test.go3
-rw-r--r--internal/cli/kdbx_store.go95
-rw-r--r--internal/cli/kdbx_store_test.go9
-rw-r--r--internal/cli/migrate_kdbx.go165
7 files changed, 78 insertions, 251 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.go b/internal/cli/kdbx_store.go
deleted file mode 100644
index bdaeb31..0000000
--- a/internal/cli/kdbx_store.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package cli
-
-import (
- "fmt"
- "os"
-
- gokeepasslib "github.com/tobischo/gokeepasslib/v3"
-
- "codeberg.org/snonux/foostore/internal/keepass"
-)
-
-// KDBXStore is the minimal interface needed by migrate-kdbx.
-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
-}
-
-type kdbxStore struct {
- path string
- db *gokeepasslib.Database
-}
-
-// OpenKDBXStore opens an existing KDBX database using password credentials.
-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)
- }
-
- 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)
- }
-
- 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.
-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 to avoid duplication.
-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 (keepass.Backend.save() uses the same helper).
-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/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
-}