diff options
Diffstat (limited to 'internal/cli')
| -rw-r--r-- | internal/cli/cli.go | 19 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 159 | ||||
| -rw-r--r-- | internal/cli/kdbx_store.go | 182 | ||||
| -rw-r--r-- | internal/cli/kdbx_store_test.go | 32 | ||||
| -rw-r--r-- | internal/cli/migrate_kdbx.go | 268 |
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 +} |
