diff --git a/DECISION_LOG.md b/DECISION_LOG.md index a292d1b..a01980d 100644 --- a/DECISION_LOG.md +++ b/DECISION_LOG.md @@ -1,5 +1,31 @@ # DECISION LOG +## 2026-04-14: Ship encrypted backup as a subcommand; leave restore as plain `age -d` + +### Context + +The `private_memory` wiring stores sensitive content (therapy, health, relationships). Telemetry already has a privacy-strict mode, but the main memory DB on disk remains plaintext. The threat model of "laptop lost spento" is covered by FileVault/BitLocker/LUKS at the OS level; the gap is cloud backup: iCloud Drive / Google Drive / Dropbox all corrupt live SQLite DBs (WAL race with sync client). Cross-platform encryption-at-rest on the live DB would require SQLCipher (CGO) and cross-platform keychain integration — both violate the pure-Go single-binary invariant for marginal gain over OS crypto. + +### Decision + +Add a `workmem backup` subcommand that writes an age-encrypted snapshot of the global memory DB. The snapshot is consistent (produced via `VACUUM INTO`, not a raw copy), the plaintext intermediate never leaves the temp directory, and the output uses `0600` permissions. Decryption is not wrapped by the CLI — users run `age -d -i identity.txt backup.age > memory.db` manually. + +### Rationale + +- `filippo.io/age` is pure Go — preserves `CGO_ENABLED=0` and the single-binary product direction. +- `VACUUM INTO` produces a driver-agnostic consistent snapshot; no dependence on modernc or SQLite-private backup APIs. +- End-to-end encryption with a user-held key gives cloud-sync safety (the `.age` file is useless without the identity) without claiming to solve problems the OS already solves (live-disk encryption). +- Asymmetric-only (no passphrase) keeps the operator UX honest: key lives in a file, backup is unattended, no prompts. +- Not wrapping restore keeps the CLI honest about its scope — restore is context-dependent (stop server? atomic swap? merge?) and those choices should be the user's, not ours. + +### Alternatives considered + +- **Encryption at rest on the live DB via SQLCipher + cross-platform keychain.** Rejected: requires CGO, cross-platform keychain gymnastics (macOS Keychain, Windows Credential Manager, Linux Secret Service with headless fallback), and the threat model above the OS crypto layer is narrow (laptop awake, unlocked, and stolen — chain of custody the app cannot fully defend anyway). +- **Litestream / WAL streaming replication to S3.** Rejected for v1: overkill for a personal tool where daily backup is enough, and introduces an operational dependency (object store + credentials) that clashes with "one binary, one file" positioning. Might reconsider later for power users. +- **Passphrase-based symmetric encryption.** Rejected: worse UX for unattended runs, and still funnels into age's file format anyway — if a user wants symmetric, `age -p` on the output file is a one-liner. +- **Include project-scoped DBs automatically.** Rejected: project DBs belong to workspaces, not to the user's top-level knowledge. Auto-including them couples backup to filesystem scanning and makes the unit of restore ambiguous. A `backup` invocation per workspace is explicit. +- **Include telemetry.db in the snapshot.** Rejected: telemetry is operational, rebuildable, and has a different lifecycle than knowledge. Mixing them also risks leaking telemetry via recall if paths cross. + ## 2026-04-14: Use the official Go MCP SDK for transport ### Context diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 42fc889..ae17b82 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -96,4 +96,16 @@ Brief description. **Gate:** release artifacts exist for major OS targets and in - [ ] Draft Homebrew strategy - [ ] Validate install path with fresh-machine assumptions -**On Step Gate (all items [x]):** trigger release readiness review. \ No newline at end of file +**On Step Gate (all items [x]):** trigger release readiness review. + +### Step 3.4: Encrypted backup command [✅] + +Ship a `workmem backup` subcommand that produces an age-encrypted snapshot of the global memory DB, taken via `VACUUM INTO` for consistency and streamed through `filippo.io/age` without any CGO additions. **Gate:** round-trip test proves the encrypted snapshot decrypts back to a readable SQLite database matching the source. + +- [x] Add `filippo.io/age` dependency (pure Go, preserves `CGO_ENABLED=0`) +- [x] Implement `internal/backup` package: `Run(ctx, sourceDB, destPath, recipients)` with VACUUM INTO + age encryption, plus `ParseRecipients` accepting both raw `age1…` keys and recipients-file paths +- [x] Unit tests: round-trip, missing source, zero recipients, unwritable dest, invalid recipients +- [x] Wire `backup` subcommand in `cmd/workmem/main.go` with `--to`, `--age-recipient` (repeatable), `--db`, `--env-file` +- [x] README section documenting usage and manual `age -d` restore + +**On Step Gate (all items [x]):** trigger correctness review focused on crypto wiring and VACUUM INTO error paths. \ No newline at end of file diff --git a/README.md b/README.md index 912960c..016393b 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,28 @@ Don't remember: transient tasks, code snippets, things already in docs/git. SQLite with WAL mode. Tables: `entities`, `observations`, `relations`, `events`, `memory_fts` (FTS5). Schema created automatically. Soft-delete via `deleted_at` tombstones — forgotten facts are excluded from retrieval but remain in the database. +## Backup + +Produce an end-to-end encrypted snapshot with the `backup` subcommand. The snapshot is taken via `VACUUM INTO` (consistent, no lock on the live DB) and encrypted with [age](https://age-encryption.org). The plaintext intermediate never leaves the temp directory; the output is written with `0600` permissions. + +```bash +# single recipient +workmem backup --to backup.age --age-recipient age1yourpubkey... + +# multiple recipients and/or a recipients file +workmem backup --to backup.age \ + --age-recipient age1alpha... \ + --age-recipient /path/to/recipients.txt +``` + +Restore with the standard age CLI: + +```bash +age -d -i my-identity.txt backup.age > memory.db +``` + +Only the global memory DB is included. Project-scoped DBs live in their own workspaces and are out of scope. Telemetry data (if enabled) is operational and not included — rebuild freely. + ## Design principles - **Stupidity of use, solidity of backend.** The model doesn't think about memory. It just calls tools. The ranking, decay, and retrieval happen behind the curtain. diff --git a/cmd/workmem/main.go b/cmd/workmem/main.go index f417bf8..ae3d213 100644 --- a/cmd/workmem/main.go +++ b/cmd/workmem/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "syscall" + "workmem/internal/backup" "workmem/internal/dotenv" "workmem/internal/mcpserver" "workmem/internal/store" @@ -24,6 +25,8 @@ func main() { runMCP(os.Args[2:]) case os.Args[1] == "sqlite-canary": runSQLiteCanary(os.Args[2:]) + case os.Args[1] == "backup": + runBackup(os.Args[2:]) case os.Args[1][0] == '-': // no subcommand, treat remaining args as flags for the default (serve) command runMCP(os.Args[1:]) @@ -34,6 +37,61 @@ func main() { } } +// recipientFlag collects repeatable --age-recipient arguments. +type recipientFlag []string + +func (r *recipientFlag) String() string { return fmt.Sprint([]string(*r)) } +func (r *recipientFlag) Set(value string) error { + *r = append(*r, value) + return nil +} + +func runBackup(args []string) { + fs := flag.NewFlagSet("backup", flag.ExitOnError) + dbPath := fs.String("db", "", "path to the SQLite database file (defaults to MEMORY_DB_PATH or binary-relative memory.db)") + envFile := fs.String("env-file", "", "path to a .env file to load before running (process env wins over file values)") + to := fs.String("to", "", "destination file for the encrypted snapshot (required)") + var recipients recipientFlag + fs.Var(&recipients, "age-recipient", "age recipient public key (age1...) or path to a recipients file; repeatable, at least one required") + _ = fs.Parse(args) + + loadEnvFile(*envFile) + + if *to == "" { + fmt.Fprintln(os.Stderr, "backup: --to is required") + os.Exit(2) + } + if len(recipients) == 0 { + fmt.Fprintln(os.Stderr, "backup: at least one --age-recipient is required") + os.Exit(2) + } + + sourceDB, err := mcpserver.ResolveDBPath(*dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "backup: resolve source db: %v\n", err) + os.Exit(1) + } + + parsed, err := backup.ParseRecipients(recipients) + if err != nil { + fmt.Fprintf(os.Stderr, "backup: %v\n", err) + os.Exit(1) + } + + // Signal-aware context so Ctrl+C (SIGINT) and SIGTERM can interrupt a + // long VACUUM cleanly. The backup package threads the context into + // sql.ExecContext, so cancellation propagates to the SQLite driver. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := backup.Run(ctx, sourceDB, *to, parsed); err != nil { + fmt.Fprintf(os.Stderr, "backup: %v\n", err) + os.Exit(1) + } + + fmt.Printf("backup: wrote encrypted snapshot of %s to %s (%d recipient(s))\n", sourceDB, *to, len(parsed)) +} + func runMCP(args []string) { fs := flag.NewFlagSet("serve", flag.ExitOnError) dbPath := fs.String("db", "", "path to the SQLite database file") @@ -101,8 +159,14 @@ func printUsage() { fmt.Fprintf(os.Stderr, " workmem [flags]\n\n") fmt.Fprintf(os.Stderr, "commands:\n") fmt.Fprintf(os.Stderr, " serve run the MCP server over stdio (default)\n") - fmt.Fprintf(os.Stderr, " sqlite-canary prove schema init, FTS insert/match/delete, and persistence\n\n") - fmt.Fprintf(os.Stderr, "flags (all commands):\n") + fmt.Fprintf(os.Stderr, " sqlite-canary prove schema init, FTS insert/match/delete, and persistence\n") + fmt.Fprintf(os.Stderr, " backup write an age-encrypted snapshot of memory.db\n\n") + fmt.Fprintf(os.Stderr, "flags (serve, sqlite-canary, backup):\n") fmt.Fprintf(os.Stderr, " -db path to the SQLite database file\n") - fmt.Fprintf(os.Stderr, " -env-file load variables from a .env file (process env takes precedence)\n") + fmt.Fprintf(os.Stderr, " -env-file load variables from a .env file (process env takes precedence)\n\n") + fmt.Fprintf(os.Stderr, "backup flags:\n") + fmt.Fprintf(os.Stderr, " -to destination file for the encrypted snapshot (required)\n") + fmt.Fprintf(os.Stderr, " -age-recipient age recipient (age1... or file path), repeatable, at least one required\n\n") + fmt.Fprintf(os.Stderr, "restore a backup with the age CLI:\n") + fmt.Fprintf(os.Stderr, " age -d -i > memory.db\n") } diff --git a/go.mod b/go.mod index 11e394b..0fe1352 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,13 @@ module workmem go 1.26.1 require ( + filippo.io/age v1.3.1 github.com/modelcontextprotocol/go-sdk v1.5.0 modernc.org/sqlite v1.39.1 ) require ( + filippo.io/hpke v0.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect @@ -17,6 +19,7 @@ require ( github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index 5543eb2..07bc65f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -24,6 +30,8 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000..f76dbb6 --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,275 @@ +// Package backup produces an age-encrypted SQLite snapshot of the workmem +// memory database. The snapshot is consistent (taken via VACUUM INTO, not a +// raw file copy) and the plaintext intermediate file never leaves the +// temporary directory. +// +// Restoration is deliberately left as a one-liner with the age CLI: +// +// age -d -i > memory.db +// +// This keeps the CLI honest about its scope — backup is the side of the +// pipeline that has filesystem consistency concerns; restore is a plain +// decrypt and place-where-you-want. +package backup + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "filippo.io/age" + + _ "modernc.org/sqlite" +) + +// Run produces an age-encrypted consistent snapshot of sourceDB at destPath. +// The snapshot is created via VACUUM INTO into a temporary file, then streamed +// through age.Encrypt into a sibling temp file next to destPath, fsynced, +// and atomically renamed onto destPath. This guarantees destPath either +// contains the previous valid backup or the new valid backup — never a +// truncated halfway state, even if the process is interrupted mid-write. +// destPath is written with 0600 permissions (enforced via Chmod on the open +// file, so a pre-existing file with looser mode is tightened before +// sensitive ciphertext is written to it). +// +// destPath must not resolve to the same filesystem object as sourceDB — +// overwriting the live DB with its encrypted backup would corrupt it. +// +// At least one recipient is required. The caller is responsible for supplying +// recipients; this function has no notion of keychains or default keys. +func Run(ctx context.Context, sourceDB, destPath string, recipients []age.Recipient) error { + if sourceDB == "" { + return fmt.Errorf("source db path is empty") + } + if destPath == "" { + return fmt.Errorf("destination path is empty") + } + if len(recipients) == 0 { + return fmt.Errorf("at least one age recipient is required") + } + sourceInfo, err := os.Stat(sourceDB) + if err != nil { + return fmt.Errorf("stat source db: %w", err) + } + if err := rejectDestEqualsSource(sourceDB, destPath, sourceInfo); err != nil { + return err + } + + tmpDir, err := os.MkdirTemp("", "workmem-backup-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + snapPath := filepath.Join(tmpDir, "snap.db") + + if err := vacuumSnapshot(ctx, sourceDB, snapPath); err != nil { + return err + } + + return encryptToFile(ctx, snapPath, destPath, recipients) +} + +// ctxReader wraps an io.Reader with a context so a cancelled context during +// a long io.Copy aborts promptly on the next Read instead of silently +// continuing to completion. The overhead is a single load per Read call. +type ctxReader struct { + ctx context.Context + r io.Reader +} + +func (c *ctxReader) Read(p []byte) (int, error) { + if err := c.ctx.Err(); err != nil { + return 0, err + } + return c.r.Read(p) +} + +// rejectDestEqualsSource refuses to proceed when destPath would overwrite +// sourceDB. Checks (in order): cleaned absolute path equality (covers the +// direct case including "./memory.db" vs "memory.db") and os.SameFile when +// destPath already exists (covers hard links and symlinks to the same inode). +// The exotic "dest is a symlink to source but does not yet exist" case is +// not covered — the VACUUM INTO step would fail later on the symlink target +// rather than corrupting anything, but no guarantees. +func rejectDestEqualsSource(sourceDB, destPath string, sourceInfo os.FileInfo) error { + srcAbs, err := filepath.Abs(sourceDB) + if err != nil { + return fmt.Errorf("resolve source path: %w", err) + } + dstAbs, err := filepath.Abs(destPath) + if err != nil { + return fmt.Errorf("resolve destination path: %w", err) + } + if srcAbs == dstAbs { + return fmt.Errorf("destination path is the same as source db path: %s", sourceDB) + } + if destInfo, statErr := os.Stat(destPath); statErr == nil && sourceInfo != nil { + if os.SameFile(sourceInfo, destInfo) { + return fmt.Errorf("destination path resolves to the same file as source db path: %s -> %s", destPath, sourceDB) + } + } + return nil +} + +func vacuumSnapshot(ctx context.Context, sourceDB, snapPath string) error { + db, err := sql.Open("sqlite", sourceDB) + if err != nil { + return fmt.Errorf("open source db: %w", err) + } + // Align with the main store's modernc.org/sqlite conventions: pin the + // pool to a single connection for deterministic behavior, and Ping so + // open failures surface here rather than midway through VACUUM INTO. + db.SetMaxOpenConns(1) + defer db.Close() + if err := db.PingContext(ctx); err != nil { + return fmt.Errorf("ping source db: %w", err) + } + if _, err := db.ExecContext(ctx, "VACUUM INTO ?", snapPath); err != nil { + return fmt.Errorf("vacuum into snapshot: %w", err) + } + return nil +} + +func encryptToFile(ctx context.Context, snapPath, destPath string, recipients []age.Recipient) error { + if err := ctx.Err(); err != nil { + return err + } + + src, err := os.Open(snapPath) + if err != nil { + return fmt.Errorf("open snapshot: %w", err) + } + defer src.Close() + + // Write the ciphertext into a sibling temp file next to destPath, then + // rename atomically. This preserves any existing destPath file as long + // as the rename itself has not happened — a crash or Ctrl+C during + // encryption leaves the old backup untouched. CreateTemp places the + // file in the same directory so the rename is guaranteed to be on the + // same filesystem (cross-device renames are not atomic). + destDir := filepath.Dir(destPath) + tmp, err := os.CreateTemp(destDir, filepath.Base(destPath)+".tmp-*") + if err != nil { + return fmt.Errorf("create temp destination next to %s: %w", destPath, err) + } + tmpPath := tmp.Name() + commit := false + defer func() { + if !commit { + _ = os.Remove(tmpPath) + } + }() + + // CreateTemp uses 0600 by default, but be explicit for clarity and to + // guard against umask weirdness or Windows semantics. + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("set destination permissions: %w", err) + } + + enc, err := age.Encrypt(tmp, recipients...) + if err != nil { + _ = tmp.Close() + return fmt.Errorf("start age encryption: %w", err) + } + + if _, err := io.Copy(enc, &ctxReader{ctx: ctx, r: src}); err != nil { + _ = enc.Close() + _ = tmp.Close() + return fmt.Errorf("encrypt copy: %w", err) + } + if err := enc.Close(); err != nil { + _ = tmp.Close() + return fmt.Errorf("finalize age encryption: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync destination: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close destination: %w", err) + } + if err := os.Rename(tmpPath, destPath); err != nil { + return fmt.Errorf("rename temp onto destination: %w", err) + } + // POSIX durability: the rename is atomic wrt other reads, but the + // directory entry's persistence is not guaranteed until the containing + // directory's fsync. On Windows, os.File.Sync on a directory returns + // an error — we ignore it (best-effort) since the rename itself is + // durable by the time ReplaceFile/MoveFileEx returns on NTFS. + if d, err := os.Open(destDir); err == nil { + _ = d.Sync() + _ = d.Close() + } + commit = true + return nil +} + +// ParseRecipients accepts a slice where each entry is either an age1... +// recipient literal or a path to a recipients file (one key per line, # +// comments allowed — the format consumed by age.ParseRecipients). +// +// Disambiguation: an input whose path resolves on disk is treated as a file, +// even if the base name starts with "age1" (so "./age1-recipients.txt" +// works). An input whose os.Stat reports "does not exist" is parsed as an +// age1 literal. Any other stat error (notably permission denied) is +// surfaced verbatim rather than silently falling through to literal +// parsing — a misconfigured file with restrictive permissions would +// otherwise produce the misleading "neither file nor literal" message. +// +// Directories are rejected explicitly: os.Open on a directory succeeds, +// but age.ParseRecipients on it returns a confusing scan error. +// +// At least one valid recipient must be resolved or an error is returned. +func ParseRecipients(inputs []string) ([]age.Recipient, error) { + var out []age.Recipient + for _, s := range inputs { + s = strings.TrimSpace(s) + if s == "" { + continue + } + info, statErr := os.Stat(s) + switch { + case statErr == nil: + if info.IsDir() { + return nil, fmt.Errorf("recipient path %q is a directory, not a file", s) + } + f, err := os.Open(s) + if err != nil { + return nil, fmt.Errorf("open recipients file %q: %w", s, err) + } + rs, parseErr := age.ParseRecipients(f) + _ = f.Close() + if parseErr != nil { + return nil, fmt.Errorf("parse recipients file %q: %w", s, parseErr) + } + out = append(out, rs...) + continue + case errors.Is(statErr, fs.ErrNotExist): + // Fall through to literal parsing below. Only a genuinely-absent + // path is allowed to be reinterpreted as an age1 literal. + default: + return nil, fmt.Errorf("stat recipient path %q: %w", s, statErr) + } + if strings.HasPrefix(s, "age1") { + r, err := age.ParseX25519Recipient(s) + if err != nil { + return nil, fmt.Errorf("parse recipient %q: %w", s, err) + } + out = append(out, r) + continue + } + return nil, fmt.Errorf("recipient %q is neither an existing file nor an age1 literal", s) + } + if len(out) == 0 { + return nil, fmt.Errorf("no recipients resolved from input") + } + return out, nil +} diff --git a/internal/backup/backup_permissions_test.go b/internal/backup/backup_permissions_test.go new file mode 100644 index 0000000..bb83f14 --- /dev/null +++ b/internal/backup/backup_permissions_test.go @@ -0,0 +1,53 @@ +//go:build !windows + +package backup + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A stat error other than fs.ErrNotExist must surface verbatim — if +// ParseRecipients silently fell through to literal parsing on EACCES, a +// misconfigured recipients file with restrictive permissions would produce +// the misleading "neither an existing file nor an age1 literal" error. +// +// POSIX-gated: on Windows, directory permissions do not block traversal the +// same way, and root on Linux bypasses permission checks entirely — so the +// test also skips when running as root. +func TestParseRecipientsSurfacesNonNotExistStatError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses POSIX permission checks; EACCES cannot be synthesized") + } + tmp := t.TempDir() + // Create a subdir with a file inside, then strip all perms from the + // subdir so os.Stat on the nested file fails with permission denied. + blockDir := filepath.Join(tmp, "blocked") + if err := os.Mkdir(blockDir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + nested := filepath.Join(blockDir, "recipients.txt") + if err := os.WriteFile(nested, []byte("age1whatever\n"), 0o600); err != nil { + t.Fatalf("write nested: %v", err) + } + if err := os.Chmod(blockDir, 0o000); err != nil { + t.Fatalf("chmod block dir: %v", err) + } + t.Cleanup(func() { + // Restore perms so t.TempDir cleanup can remove the tree. + _ = os.Chmod(blockDir, 0o700) + }) + + _, err := ParseRecipients([]string{nested}) + if err == nil { + t.Fatalf("expected error on EACCES stat, got nil") + } + if strings.Contains(err.Error(), "neither an existing file nor an age1 literal") { + t.Fatalf("EACCES stat fell through to literal-parse path: %v", err) + } + if !strings.Contains(err.Error(), "stat recipient path") { + t.Fatalf("error = %v, want 'stat recipient path' prefix", err) + } +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 0000000..1978474 --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,389 @@ +package backup + +import ( + "context" + "database/sql" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "filippo.io/age" + + _ "modernc.org/sqlite" +) + +// newIdentity generates a fresh X25519 age identity for the test, failing +// loudly if the rand source is broken rather than silently handing back a +// nil identity whose Recipient() call would panic later. +func newIdentity(t *testing.T) *age.X25519Identity { + t.Helper() + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + return id +} + +// seedSourceDB creates a minimal SQLite database with a single table and a +// couple of rows so the round-trip test has something to compare after +// decryption. +func seedSourceDB(t *testing.T, path string) { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open seed db: %v", err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)`); err != nil { + t.Fatalf("create table: %v", err) + } + if _, err := db.Exec(`INSERT INTO notes (body) VALUES (?), (?)`, "first row", "second row"); err != nil { + t.Fatalf("insert rows: %v", err) + } +} + +func TestRunRoundTripDecryptsToReadableDatabase(t *testing.T) { + tmp := t.TempDir() + source := filepath.Join(tmp, "memory.db") + dest := filepath.Join(tmp, "backup.age") + seedSourceDB(t, source) + + identity := newIdentity(t) + + if err := Run(context.Background(), source, dest, []age.Recipient{identity.Recipient()}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + info, err := os.Stat(dest) + if err != nil { + t.Fatalf("stat dest: %v", err) + } + if info.Size() == 0 { + t.Fatalf("destination file is empty") + } + + encFile, err := os.Open(dest) + if err != nil { + t.Fatalf("open dest for decrypt: %v", err) + } + defer encFile.Close() + + plainReader, err := age.Decrypt(encFile, identity) + if err != nil { + t.Fatalf("age.Decrypt error = %v", err) + } + + restored := filepath.Join(tmp, "restored.db") + rf, err := os.Create(restored) + if err != nil { + t.Fatalf("create restored: %v", err) + } + if _, err := io.Copy(rf, plainReader); err != nil { + _ = rf.Close() + t.Fatalf("copy decrypted: %v", err) + } + if err := rf.Close(); err != nil { + t.Fatalf("close restored: %v", err) + } + + rdb, err := sql.Open("sqlite", restored) + if err != nil { + t.Fatalf("open restored db: %v", err) + } + defer rdb.Close() + + rows, err := rdb.Query(`SELECT body FROM notes ORDER BY id`) + if err != nil { + t.Fatalf("query restored: %v", err) + } + defer rows.Close() + var got []string + for rows.Next() { + var body string + if err := rows.Scan(&body); err != nil { + t.Fatalf("scan: %v", err) + } + got = append(got, body) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows iteration: %v", err) + } + want := []string{"first row", "second row"} + if len(got) != len(want) { + t.Fatalf("rows = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("rows[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestRunFailsOnMissingSource(t *testing.T) { + tmp := t.TempDir() + identity := newIdentity(t) + err := Run(context.Background(), + filepath.Join(tmp, "does-not-exist.db"), + filepath.Join(tmp, "out.age"), + []age.Recipient{identity.Recipient()}, + ) + if err == nil { + t.Fatalf("Run() expected error on missing source, got nil") + } + if !strings.Contains(err.Error(), "stat source db") { + t.Fatalf("error = %v, want mention of stat source db", err) + } +} + +func TestRunFailsOnZeroRecipients(t *testing.T) { + tmp := t.TempDir() + source := filepath.Join(tmp, "memory.db") + seedSourceDB(t, source) + err := Run(context.Background(), source, filepath.Join(tmp, "out.age"), nil) + if err == nil { + t.Fatalf("Run() expected error on zero recipients, got nil") + } + if !strings.Contains(err.Error(), "at least one age recipient") { + t.Fatalf("error = %v, want recipient-required message", err) + } +} + +func TestRunFailsOnUnwritableDestination(t *testing.T) { + tmp := t.TempDir() + source := filepath.Join(tmp, "memory.db") + seedSourceDB(t, source) + identity := newIdentity(t) + err := Run(context.Background(), source, + filepath.Join(tmp, "no-such-dir", "out.age"), + []age.Recipient{identity.Recipient()}, + ) + if err == nil { + t.Fatalf("Run() expected error on unwritable dest, got nil") + } +} + +func TestRunEmptyPathsRejected(t *testing.T) { + identity := newIdentity(t) + if err := Run(context.Background(), "", "/tmp/x.age", []age.Recipient{identity.Recipient()}); err == nil { + t.Fatalf("expected error on empty source") + } + if err := Run(context.Background(), "/tmp/x.db", "", []age.Recipient{identity.Recipient()}); err == nil { + t.Fatalf("expected error on empty dest") + } +} + +func TestParseRecipientsAcceptsLiteralKey(t *testing.T) { + identity := newIdentity(t) + pub := identity.Recipient().String() + out, err := ParseRecipients([]string{pub}) + if err != nil { + t.Fatalf("ParseRecipients error = %v", err) + } + if len(out) != 1 { + t.Fatalf("expected 1 recipient, got %d", len(out)) + } +} + +func TestParseRecipientsAcceptsFilePath(t *testing.T) { + tmp := t.TempDir() + id1 := newIdentity(t) + id2 := newIdentity(t) + path := filepath.Join(tmp, "recipients.txt") + content := "# comment line\n" + id1.Recipient().String() + "\n" + id2.Recipient().String() + "\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + out, err := ParseRecipients([]string{path}) + if err != nil { + t.Fatalf("ParseRecipients error = %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 recipients, got %d", len(out)) + } +} + +func TestParseRecipientsRejectsInvalidKey(t *testing.T) { + _, err := ParseRecipients([]string{"age1-invalid"}) + if err == nil { + t.Fatalf("expected error on invalid recipient literal") + } +} + +func TestParseRecipientsRejectsEmptyInput(t *testing.T) { + _, err := ParseRecipients([]string{"", " "}) + if err == nil { + t.Fatalf("expected error when only whitespace recipients") + } +} + +func TestRunRejectsDestEqualToSource(t *testing.T) { + tmp := t.TempDir() + source := filepath.Join(tmp, "memory.db") + seedSourceDB(t, source) + identity := newIdentity(t) + + err := Run(context.Background(), source, source, []age.Recipient{identity.Recipient()}) + if err == nil { + t.Fatalf("Run() expected error when destPath == sourceDB, got nil") + } + if !strings.Contains(err.Error(), "same as source") { + t.Fatalf("error = %v, want 'same as source' guard", err) + } +} + +func TestRunLeavesNoTempFileAfterSuccess(t *testing.T) { + tmp := t.TempDir() + source := filepath.Join(tmp, "memory.db") + dest := filepath.Join(tmp, "backup.age") + seedSourceDB(t, source) + identity := newIdentity(t) + + if err := Run(context.Background(), source, dest, []age.Recipient{identity.Recipient()}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + // The atomic-write pattern uses a sibling ".tmp-*" file and renames it + // onto dest on success. After a successful Run the dest dir must + // contain only the source DB and backup.age — no orphan temp file. + entries, err := os.ReadDir(tmp) + if err != nil { + t.Fatalf("readdir: %v", err) + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if name == "memory.db" || name == "backup.age" { + continue + } + t.Fatalf("unexpected file %q in dest dir — atomic-write cleanup leaked", name) + } +} + +func TestRunAtomicWriteReplacesExistingBackupOnSecondRun(t *testing.T) { + tmp := t.TempDir() + source := filepath.Join(tmp, "memory.db") + dest := filepath.Join(tmp, "backup.age") + seedSourceDB(t, source) + + id1 := newIdentity(t) + id2 := newIdentity(t) + + // First backup with id1 + if err := Run(context.Background(), source, dest, []age.Recipient{id1.Recipient()}); err != nil { + t.Fatalf("Run() #1 error = %v", err) + } + // Second backup with id2 — should atomically replace, not append or merge + if err := Run(context.Background(), source, dest, []age.Recipient{id2.Recipient()}); err != nil { + t.Fatalf("Run() #2 error = %v", err) + } + + // The final dest must decrypt with id2 (new) and NOT with id1 (old). + f, err := os.Open(dest) + if err != nil { + t.Fatalf("open dest: %v", err) + } + defer f.Close() + if _, err := age.Decrypt(f, id2); err != nil { + t.Fatalf("dest should decrypt with new identity after atomic replace: %v", err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatalf("seek: %v", err) + } + if _, err := age.Decrypt(f, id1); err == nil { + t.Fatalf("dest unexpectedly decrypted with old identity — rename did not fully replace prior content") + } +} + +func TestRunRespectsCancelledContext(t *testing.T) { + tmp := t.TempDir() + source := filepath.Join(tmp, "memory.db") + dest := filepath.Join(tmp, "backup.age") + seedSourceDB(t, source) + identity := newIdentity(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel upfront — every context-aware step must bail + + err := Run(ctx, source, dest, []age.Recipient{identity.Recipient()}) + if err == nil { + t.Fatalf("Run() with cancelled context expected error, got nil") + } + if _, err := os.Stat(dest); err == nil { + t.Fatalf("dest file %q must not exist after cancelled Run", dest) + } +} + +func TestParseRecipientsPrefersFileOverAge1Prefix(t *testing.T) { + tmp := t.TempDir() + id := newIdentity(t) + + // File whose base name starts with "age1" — disambiguation must pick + // the file over treating the path as a literal recipient. + path := filepath.Join(tmp, "age1-recipients.txt") + if err := os.WriteFile(path, []byte(id.Recipient().String()+"\n"), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + + out, err := ParseRecipients([]string{path}) + if err != nil { + t.Fatalf("ParseRecipients file-path-with-age1-prefix: %v", err) + } + if len(out) != 1 { + t.Fatalf("expected 1 recipient from file, got %d", len(out)) + } +} + +func TestParseRecipientsRejectsNeitherFileNorLiteral(t *testing.T) { + // Not on disk, not an age1 literal — must fail loudly instead of + // silently ignoring. + _, err := ParseRecipients([]string{"/definitely/not/a/path/and/not/age1"}) + if err == nil { + t.Fatalf("expected error for input that is neither file nor age1 literal") + } + if !strings.Contains(err.Error(), "neither an existing file nor an age1 literal") { + t.Fatalf("error = %v, want disambiguation message", err) + } +} + +// A directory path is a legitimate os.Stat success but cannot be a +// recipients file. The old code would fall into os.Open (which succeeds +// on directories on POSIX) and age.ParseRecipients would surface a +// confusing scan error. The explicit IsDir check produces a clear +// diagnostic instead. +func TestParseRecipientsRejectsDirectory(t *testing.T) { + tmp := t.TempDir() + _, err := ParseRecipients([]string{tmp}) + if err == nil { + t.Fatalf("expected error when recipient path is a directory") + } + if !strings.Contains(err.Error(), "is a directory") { + t.Fatalf("error = %v, want 'is a directory' message", err) + } +} + +func TestRunRejectsDestResolvingToSameFile(t *testing.T) { + tmp := t.TempDir() + source := filepath.Join(tmp, "memory.db") + seedSourceDB(t, source) + + // Hard-link dest onto source so the two distinct paths share an inode. + // os.SameFile should catch this even though filepath.Abs comparison does + // not. + linked := filepath.Join(tmp, "aliased.db") + if err := os.Link(source, linked); err != nil { + t.Skipf("os.Link not supported on this filesystem: %v", err) + } + + identity := newIdentity(t) + err := Run(context.Background(), source, linked, []age.Recipient{identity.Recipient()}) + if err == nil { + t.Fatalf("Run() expected error when dest hard-links to source, got nil") + } + if !strings.Contains(err.Error(), "same file as source") { + t.Fatalf("error = %v, want 'same file as source' guard", err) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index e211fc6..ce9216b 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -36,7 +36,7 @@ type toolDefinition struct { } func New(config Config) (*Runtime, error) { - dbPath, err := resolveDBPath(config.DBPath) + dbPath, err := ResolveDBPath(config.DBPath) if err != nil { return nil, err } @@ -146,7 +146,7 @@ func (r *Runtime) handleTool(_ context.Context, def toolDefinition, req *mcp.Cal return successResult(result) } -func resolveDBPath(configPath string) (string, error) { +func ResolveDBPath(configPath string) (string, error) { if configPath != "" { return filepath.Clean(configPath), nil }