summaryrefslogtreecommitdiff
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
parente353f20ac9ac8da0bc0cbcd7bb22838f765f47bc (diff)
Add KeePass migration command with text/password and binary attachment supportHEADmaster
-rw-r--r--README.md2
-rw-r--r--go.mod9
-rw-r--r--go.sum22
-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
-rw-r--r--internal/store/store.go8
9 files changed, 688 insertions, 13 deletions
diff --git a/README.md b/README.md
index 3d162a3..501018a 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-> **🚧 PRE-ALPHA SOFTWARE:** This project is in a pre-alpha state and is intended for my own personal use only. Use at your own risk.
+> **🚧 PRE-ALPHA SOFTWARE:** This project is in active early development, unstable, and intended for personal use. Expect bugs, breaking changes, missing safeguards, and possible data loss. Backward compatibility and upgrade paths are not guaranteed. Use at your own risk.
# foostore
diff --git a/go.mod b/go.mod
index 623e7ac..50b47ba 100644
--- a/go.mod
+++ b/go.mod
@@ -1,14 +1,17 @@
module codeberg.org/snonux/foostore
-go 1.24.0
+go 1.25.0
require (
github.com/ergochat/readline v0.1.3
github.com/magefile/mage v1.15.0
+ github.com/tobischo/gokeepasslib/v3 v3.6.2
+ golang.org/x/term v0.40.0
)
require (
+ github.com/tobischo/argon2 v0.1.0 // indirect
+ golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
- golang.org/x/term v0.40.0 // indirect
- golang.org/x/text v0.9.0 // indirect
+ golang.org/x/text v0.34.0 // indirect
)
diff --git a/go.sum b/go.sum
index b8a6368..42a77bd 100644
--- a/go.sum
+++ b/go.sum
@@ -1,12 +1,26 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=
github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
-golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tobischo/argon2 v0.1.0 h1:mwAx/9DK/4rP0xzNifb/XMAf43dU3eG1B3aeF88qu4Y=
+github.com/tobischo/argon2 v0.1.0/go.mod h1:4NLmLFwhWPbT66nRZNgcktV/mibJ6fESoeEp43h9GRw=
+github.com/tobischo/gokeepasslib/v3 v3.6.2 h1:SJzzllmNe7iZLudLJ3Lzdm3pDb++AJqZlmqG+SR8bVc=
+github.com/tobischo/gokeepasslib/v3 v3.6.2/go.mod h1:ga7HFqG0TZSLNao/QOnV2+yngkrf5186saPxSQ1Xp7o=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
-golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index 527ece8..c91798f 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -149,6 +149,14 @@ func (s *Store) processIndexFile(ctx context.Context, path, searchTerm string, r
return nil
}
+// LoadData decrypts and returns the .data payload for the given index entry.
+func (s *Store) LoadData(ctx context.Context, idx *Index) (*Data, error) {
+ if idx == nil {
+ return nil, fmt.Errorf("loading data: nil index")
+ }
+ return loadData(ctx, filepath.Join(s.cfg.DataDir, idx.DataFile), s.cipher, s.git)
+}
+
// Search collects all indexes matching searchTerm, sorts them by Description,
// and applies the given action to each. For ActionCat the decrypted content is
// printed; for ActionExport/ActionPathExport the content is written to ExportDir.