summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-07 15:41:44 +0200
committerPaul Buetow <paul@buetow.org>2026-03-07 15:41:44 +0200
commitfcbe17d60b7bf71e32a288db18d95e7f0948c2db (patch)
tree4443021dbb1a53c983d8ab0296734602a12391f4 /internal/cli
parente353f20ac9ac8da0bc0cbcd7bb22838f765f47bc (diff)
Add KeePass migration command with text/password and binary attachment supportHEADmaster
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/cli.go19
-rw-r--r--internal/cli/cli_test.go159
-rw-r--r--internal/cli/kdbx_store.go182
-rw-r--r--internal/cli/kdbx_store_test.go32
-rw-r--r--internal/cli/migrate_kdbx.go268
5 files changed, 655 insertions, 5 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 11f1f7e..c8a271a 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -14,6 +14,7 @@ import (
"path/filepath"
"runtime"
"strings"
+ "time"
"codeberg.org/snonux/foostore/internal/clipboard"
"codeberg.org/snonux/foostore/internal/config"
@@ -29,7 +30,7 @@ import (
var CommandList = []string{
"ls", "search", "cat", "paste", "get", "add", "export", "pathexport",
"open", "edit", "import", "import_r", "rm", "sync", "status", "commit",
- "reset", "fullcommit", "shred", "version", "commands", "help", "shell",
+ "reset", "fullcommit", "shred", "migrate-kdbx", "version", "commands", "help", "shell",
"exit", "last",
}
@@ -55,6 +56,8 @@ type CLI struct {
g *git.Git
clip *clipboard.Clipboard
sh *shell.Shell
+ openKDBX func(string, string) (KDBXStore, error)
+ now func() time.Time
lastResult string // most recent search result description
}
@@ -100,10 +103,12 @@ func newCLI(ctx context.Context) (*CLI, error) {
clip := clipboard.New(cfg.GnomeClipboardCmd, cfg.MacOSClipboardCmd)
c := &CLI{
- cfg: &cfg,
- st: st,
- g: g,
- clip: clip,
+ cfg: &cfg,
+ st: st,
+ g: g,
+ clip: clip,
+ openKDBX: OpenKDBXStore,
+ now: time.Now,
}
// Create the shell with a completion function that references the CLI.
@@ -322,6 +327,9 @@ func (c *CLI) dispatchSimple(ctx context.Context, argv []string, cmd string) (in
}
return 0, "", true
+ case "migrate-kdbx":
+ return c.cmdMigrateKDBX(ctx, argv), "", true
+
case "version":
logMsg(fmt.Sprintf("foostore %s", version.Version))
return 0, "", true
@@ -681,6 +689,7 @@ import_r DIRECTORY [DEST_DIRECTORY]
rm SEARCHTERM
sync|status|commit|reset|fullcommit
shred
+migrate-kdbx [--db PATH] [--pass-file PATH] [--binary-out PATH] [--dry-run]
version
commands
help
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index cae7f43..10712c1 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -12,6 +12,7 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
"codeberg.org/snonux/foostore/internal/clipboard"
"codeberg.org/snonux/foostore/internal/config"
@@ -582,3 +583,161 @@ func TestDispatch_importR_missingDir(t *testing.T) {
}
})
}
+
+type fakeKDBXStore struct {
+ overwrites map[string]bool
+ upserts []string
+ saved bool
+}
+
+func (f *fakeKDBXStore) UpsertTextEntry(groupPath []string, title, password, notes string) (bool, error) {
+ key := strings.Join(groupPath, "/") + "|" + title + "|" + password + "|" + notes
+ f.upserts = append(f.upserts, key)
+ return f.overwrites[title], nil
+}
+
+func (f *fakeKDBXStore) UpsertBinaryEntry(groupPath []string, title, filename string, content []byte) (bool, error) {
+ key := strings.Join(groupPath, "/") + "|" + title + "|binary:" + filename
+ f.upserts = append(f.upserts, key)
+ return f.overwrites[title], nil
+}
+
+func (f *fakeKDBXStore) Save() error {
+ f.saved = true
+ return nil
+}
+
+func TestReadPasswordFile(t *testing.T) {
+ file := filepath.Join(t.TempDir(), ".master.pass")
+ if err := os.WriteFile(file, []byte("supersecret\n"), 0o600); err != nil {
+ t.Fatalf("write password file: %v", err)
+ }
+
+ got, err := readPasswordFile(file)
+ if err != nil {
+ t.Fatalf("readPasswordFile: %v", err)
+ }
+ if got != "supersecret" {
+ t.Fatalf("readPasswordFile = %q; want supersecret", got)
+ }
+}
+
+func TestDispatch_migrateKDBX_dryRun(t *testing.T) {
+ c, cfg := testCLI(t)
+ initGitRepo(t, cfg.DataDir)
+
+ ctx := context.Background()
+ if err := c.st.Add(ctx, "work/entry", "top-secret"); err != nil {
+ t.Fatalf("Add text entry: %v", err)
+ }
+
+ srcBinary := filepath.Join(t.TempDir(), "logo.png")
+ if err := os.WriteFile(srcBinary, []byte{0, 1, 2, 3}, 0o600); err != nil {
+ t.Fatalf("write binary source: %v", err)
+ }
+ if err := c.st.Import(ctx, srcBinary, "images/logo.png", false); err != nil {
+ t.Fatalf("Import binary: %v", err)
+ }
+
+ dbPath := filepath.Join(t.TempDir(), "master")
+ if err := os.WriteFile(dbPath, []byte("not-used-in-dry-run"), 0o600); err != nil {
+ t.Fatalf("write db file: %v", err)
+ }
+ passFile := filepath.Join(t.TempDir(), ".master.pass")
+ if err := os.WriteFile(passFile, []byte("pw\n"), 0o600); err != nil {
+ t.Fatalf("write pass file: %v", err)
+ }
+ binaryOut := filepath.Join(t.TempDir(), "binary-out")
+
+ out := captureStdout(func() {
+ ec := c.dispatch(ctx, []string{
+ "migrate-kdbx",
+ "--db", dbPath,
+ "--pass-file", passFile,
+ "--binary-out", binaryOut,
+ "--dry-run",
+ })
+ if ec != 0 {
+ t.Fatalf("dispatch(migrate-kdbx dry-run) = %d; want 0", ec)
+ }
+ })
+
+ if !strings.Contains(out, "text_migrated=1") || !strings.Contains(out, "binary_migrated=1") {
+ t.Fatalf("unexpected migrate summary output: %q", out)
+ }
+ if _, err := os.Stat(filepath.Join(binaryOut, "images", "logo.png")); err == nil {
+ t.Fatalf("dry-run should not export binary files")
+ }
+}
+
+func TestDispatch_migrateKDBX_writesBinaryAndSavesKDBX(t *testing.T) {
+ c, cfg := testCLI(t)
+ initGitRepo(t, cfg.DataDir)
+
+ ctx := context.Background()
+ if err := c.st.Add(ctx, "finance/notes", "password: supersecret\nline1\nline2"); err != nil {
+ t.Fatalf("Add text entry: %v", err)
+ }
+
+ srcBinary := filepath.Join(t.TempDir(), "blob.bin")
+ if err := os.WriteFile(srcBinary, []byte{7, 8, 9}, 0o600); err != nil {
+ t.Fatalf("write binary source: %v", err)
+ }
+ if err := c.st.Import(ctx, srcBinary, "bin/blob.bin", false); err != nil {
+ t.Fatalf("Import binary: %v", err)
+ }
+
+ dbPath := filepath.Join(t.TempDir(), "master")
+ if err := os.WriteFile(dbPath, []byte("placeholder"), 0o600); err != nil {
+ t.Fatalf("write db file: %v", err)
+ }
+ passFile := filepath.Join(t.TempDir(), ".master.pass")
+ if err := os.WriteFile(passFile, []byte("pw\n"), 0o600); err != nil {
+ t.Fatalf("write pass file: %v", err)
+ }
+ binaryOut := filepath.Join(t.TempDir(), "binary-out")
+
+ fake := &fakeKDBXStore{
+ overwrites: map[string]bool{"notes": true},
+ }
+ c.openKDBX = func(path, password string) (KDBXStore, error) {
+ if path != dbPath {
+ t.Fatalf("openKDBX path = %q; want %q", path, dbPath)
+ }
+ if password != "pw" {
+ t.Fatalf("openKDBX password = %q; want pw", password)
+ }
+ return fake, nil
+ }
+ c.now = func() time.Time { return time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC) }
+
+ out := captureStdout(func() {
+ ec := c.dispatch(ctx, []string{
+ "migrate-kdbx",
+ "--db", dbPath,
+ "--pass-file", passFile,
+ "--binary-out", binaryOut,
+ })
+ if ec != 0 {
+ t.Fatalf("dispatch(migrate-kdbx) = %d; want 0", ec)
+ }
+ })
+
+ if !fake.saved {
+ t.Fatalf("expected kdbx Save() to be called")
+ }
+ if len(fake.upserts) != 2 {
+ t.Fatalf("expected 2 upserts (text+binary), got %d (%v)", len(fake.upserts), fake.upserts)
+ }
+ all := strings.Join(fake.upserts, "\n")
+ if !strings.Contains(all, "finance|notes|supersecret|line1\nline2") {
+ t.Fatalf("missing text upsert payload, got %v", fake.upserts)
+ }
+ if !strings.Contains(out, "overwritten_text=1") {
+ t.Fatalf("expected overwritten_text=1 in summary, got %q", out)
+ }
+
+ if !strings.Contains(all, "bin|blob.bin|binary:blob.bin") {
+ t.Fatalf("expected binary upsert record, got %v", fake.upserts)
+ }
+}
diff --git a/internal/cli/kdbx_store.go b/internal/cli/kdbx_store.go
new file mode 100644
index 0000000..86c4f9a
--- /dev/null
+++ b/internal/cli/kdbx_store.go
@@ -0,0 +1,182 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ gokeepasslib "github.com/tobischo/gokeepasslib/v3"
+)
+
+// 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
+}
+
+func (s *kdbxStore) UpsertTextEntry(groupPath []string, title, password, notes string) (bool, error) {
+ g := s.ensureGroup(groupPath)
+ entry, overwrote := upsertEntryByTitle(g, title)
+ setEntryField(entry, "Title", title)
+ setEntryField(entry, "Password", password)
+ setEntryField(entry, "Notes", notes)
+ return overwrote, nil
+}
+
+func (s *kdbxStore) UpsertBinaryEntry(groupPath []string, title, filename string, content []byte) (bool, error) {
+ g := s.ensureGroup(groupPath)
+ entry, overwrote := upsertEntryByTitle(g, title)
+ setEntryField(entry, "Title", title)
+ setEntryField(entry, "Password", "")
+
+ b := s.db.AddBinary(content)
+ entry.Binaries = []gokeepasslib.BinaryReference{b.CreateReference(filename)}
+ // Keep notes concise for binary-only entries.
+ setEntryField(entry, "Notes", fmt.Sprintf("Migrated binary attachment: %s", filename))
+ return overwrote, nil
+}
+
+func (s *kdbxStore) ensureGroup(groupPath []string) *gokeepasslib.Group {
+ g := &s.db.Content.Root.Groups[0]
+ for _, segment := range groupPath {
+ if segment == "" {
+ continue
+ }
+ found := -1
+ for i := range g.Groups {
+ if g.Groups[i].Name == segment {
+ found = i
+ break
+ }
+ }
+ if found == -1 {
+ ng := gokeepasslib.NewGroup()
+ ng.Name = segment
+ g.Groups = append(g.Groups, ng)
+ found = len(g.Groups) - 1
+ }
+ g = &g.Groups[found]
+ }
+ return g
+}
+
+func upsertEntryByTitle(g *gokeepasslib.Group, title string) (*gokeepasslib.Entry, bool) {
+ for i := range g.Entries {
+ if g.Entries[i].GetTitle() == title {
+ return &g.Entries[i], true
+ }
+ }
+ e := gokeepasslib.NewEntry()
+ g.Entries = append(g.Entries, e)
+ return &g.Entries[len(g.Entries)-1], false
+}
+
+func (s *kdbxStore) Save() error {
+ if err := s.db.LockProtectedEntries(); err != nil {
+ return fmt.Errorf("locking kdbx entries: %w", err)
+ }
+
+ tmpPath := s.path + ".tmp"
+ out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("creating temporary kdbx %q: %w", tmpPath, err)
+ }
+ defer out.Close()
+
+ if err := gokeepasslib.NewEncoder(out).Encode(s.db); err != nil {
+ return fmt.Errorf("encoding kdbx to %q: %w", tmpPath, err)
+ }
+ if err := out.Close(); err != nil {
+ return fmt.Errorf("closing temporary kdbx %q: %w", tmpPath, err)
+ }
+ if err := os.Rename(tmpPath, s.path); err != nil {
+ return fmt.Errorf("replacing kdbx %q: %w", s.path, err)
+ }
+ return nil
+}
+
+func setEntryField(entry *gokeepasslib.Entry, key, value string) {
+ for i := range entry.Values {
+ if entry.Values[i].Key == key {
+ entry.Values[i].Value.Content = value
+ return
+ }
+ }
+
+ entry.Values = append(entry.Values, gokeepasslib.ValueData{
+ Key: key,
+ Value: gokeepasslib.V{
+ Content: value,
+ },
+ })
+}
+
+func splitDescriptionPath(description string) ([]string, string, error) {
+ safePath, err := sanitizeRelativePath(description)
+ if err != nil {
+ return nil, "", err
+ }
+
+ parts := strings.Split(safePath, "/")
+ if len(parts) == 1 {
+ return nil, parts[0], nil
+ }
+ return parts[:len(parts)-1], parts[len(parts)-1], nil
+}
+
+func sanitizeRelativePath(path string) (string, error) {
+ normalised := strings.ReplaceAll(path, "\\", "/")
+ normalised = strings.TrimSpace(normalised)
+ if normalised == "" {
+ return "", fmt.Errorf("empty entry description")
+ }
+
+ clean := filepath.Clean(normalised)
+ clean = strings.TrimPrefix(clean, "/")
+ if clean == "." || clean == "" || clean == ".." || strings.HasPrefix(clean, "../") {
+ return "", fmt.Errorf("unsafe entry description path %q", path)
+ }
+ return clean, nil
+}
diff --git a/internal/cli/kdbx_store_test.go b/internal/cli/kdbx_store_test.go
new file mode 100644
index 0000000..fc79ca6
--- /dev/null
+++ b/internal/cli/kdbx_store_test.go
@@ -0,0 +1,32 @@
+package cli
+
+import "testing"
+
+func TestSplitDescriptionPath(t *testing.T) {
+ group, title, err := splitDescriptionPath("foo/bar/baz")
+ if err != nil {
+ t.Fatalf("splitDescriptionPath: %v", err)
+ }
+ if title != "baz" {
+ t.Fatalf("title = %q; want baz", title)
+ }
+ if len(group) != 2 || group[0] != "foo" || group[1] != "bar" {
+ t.Fatalf("group path = %v; want [foo bar]", group)
+ }
+}
+
+func TestSanitizeRelativePathRejectsTraversal(t *testing.T) {
+ if _, err := sanitizeRelativePath("../secret"); err == nil {
+ t.Fatal("sanitizeRelativePath should reject traversal path")
+ }
+}
+
+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
new file mode 100644
index 0000000..f4bb58c
--- /dev/null
+++ b/internal/cli/migrate_kdbx.go
@@ -0,0 +1,268 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "codeberg.org/snonux/foostore/internal/store"
+)
+
+type migrateKDBXOptions struct {
+ DBPath string
+ PassFile string
+ BinaryOutDir string
+ DryRun bool
+}
+
+type migrateKDBXStats struct {
+ Total int
+ TextMigrated int
+ BinaryMigrated int
+ OverwrittenText int
+ OverwrittenBin int
+ Errors int
+}
+
+func (c *CLI) cmdMigrateKDBX(ctx context.Context, argv []string) int {
+ opts, err := c.parseMigrateKDBXOptions(argv)
+ if err != nil {
+ warn(err.Error())
+ return 1
+ }
+
+ if err := os.MkdirAll(opts.BinaryOutDir, 0o700); err != nil {
+ warn(fmt.Sprintf("creating binary output directory %q: %v", opts.BinaryOutDir, err))
+ return 1
+ }
+
+ password, err := readPasswordFile(opts.PassFile)
+ if err != nil {
+ warn(err.Error())
+ return 1
+ }
+
+ var kdbx KDBXStore
+ if !opts.DryRun {
+ opener := c.openKDBX
+ if opener == nil {
+ opener = OpenKDBXStore
+ }
+ kdbx, err = opener(opts.DBPath, password)
+ if err != nil {
+ warn(err.Error())
+ return 1
+ }
+ }
+
+ 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
+ }
+ 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())
+ }
+ }
+
+ if !opts.DryRun {
+ if err := kdbx.Save(); err != nil {
+ warn(err.Error())
+ return 1
+ }
+ }
+
+ logMsg(fmt.Sprintf(
+ "migrate-kdbx done: total=%d text_migrated=%d binary_migrated=%d overwritten_text=%d overwritten_binary=%d errors=%d db=%s binary_out=%s dry_run=%t",
+ stats.Total,
+ stats.TextMigrated,
+ stats.BinaryMigrated,
+ stats.OverwrittenText,
+ stats.OverwrittenBin,
+ stats.Errors,
+ opts.DBPath,
+ opts.BinaryOutDir,
+ opts.DryRun,
+ ))
+
+ if stats.Errors > 0 {
+ return 1
+ }
+ return 0
+}
+
+func (c *CLI) migrateOneEntry(ctx context.Context, idx *store.Index, opts migrateKDBXOptions, kdbx KDBXStore, stats *migrateKDBXStats) error {
+ safePath, err := 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 := 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 := 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
+}
+
+func (c *CLI) parseMigrateKDBXOptions(argv []string) (migrateKDBXOptions, error) {
+ now := c.now
+ if now == nil {
+ now = func() time.Time { return time.Now() }
+ }
+ home := resolveHomeDir()
+ exportDir := filepath.Join(home, ".foostore-export")
+ if c.cfg != nil && c.cfg.ExportDir != "" {
+ exportDir = c.cfg.ExportDir
+ }
+
+ opts := migrateKDBXOptions{
+ DBPath: filepath.Join(home, "Documents", "Keepass", "master"),
+ PassFile: filepath.Join(home, ".master.pass"),
+ BinaryOutDir: filepath.Join(exportDir, "keepass-binary-dump", now().Format("20060102-150405")),
+ }
+
+ for i := 1; i < len(argv); i++ {
+ switch argv[i] {
+ case "--db":
+ i++
+ if i >= len(argv) {
+ return opts, fmt.Errorf("--db requires a value")
+ }
+ opts.DBPath = argv[i]
+ case "--pass-file":
+ i++
+ if i >= len(argv) {
+ return opts, fmt.Errorf("--pass-file requires a value")
+ }
+ opts.PassFile = argv[i]
+ case "--binary-out":
+ i++
+ if i >= len(argv) {
+ return opts, fmt.Errorf("--binary-out requires a value")
+ }
+ opts.BinaryOutDir = argv[i]
+ case "--dry-run":
+ opts.DryRun = true
+ default:
+ return opts, fmt.Errorf("unknown flag for migrate-kdbx: %s", argv[i])
+ }
+ }
+
+ opts.DBPath = expandHome(opts.DBPath)
+ opts.PassFile = expandHome(opts.PassFile)
+ opts.BinaryOutDir = expandHome(opts.BinaryOutDir)
+
+ if _, err := os.Stat(opts.DBPath); err != nil {
+ return opts, fmt.Errorf("database file %q is not readable: %w", opts.DBPath, err)
+ }
+ if _, err := os.Stat(opts.PassFile); err != nil {
+ return opts, fmt.Errorf("password file %q is not readable: %w", opts.PassFile, err)
+ }
+ 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
+}