047a813278
Two spine infra items (feature-ranking #1, part of the migration prereq): - migrations.go: PRAGMA user_version runner, empty (no-op) migration slice, one tx per step, fail-closed. Mechanism in place before any real schema change needs it. - crypt.go: file-level at-rest encryption. On-disk file is always AES-256-GCM ciphertext; decrypted to a tmpfs working copy modernc sqlite operates on; re-encrypted atomically on Close, plaintext wiped, key zeroed. Pure stdlib, CGO stays off. Fails closed on wrong key/tamper, never falls back to plaintext. Key is a 32-byte seam (config db_key_b64/db_key_env today; the passkey-derived L3 cold-start key plugs into the same seam later). Chosen over cgo SQLCipher (would force libsqlcipher + CGO across the project) and over the ncruces page-level VFS (swaps the driver project-wide); noted as the upgrade path in a ponytail: comment. Threat model is disk-at-rest only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
48 lines
1.3 KiB
Go
48 lines
1.3 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
func userVersion(t *testing.T, s *Store) int {
|
|
t.Helper()
|
|
var v int
|
|
if err := s.db.QueryRowContext(context.Background(), "PRAGMA user_version").Scan(&v); err != nil {
|
|
t.Fatalf("read user_version: %v", err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
// Empty baseline slice leaves the DB at version 0.
|
|
if v := userVersion(t, s); v != 0 {
|
|
t.Fatalf("fresh DB user_version = %d, want 0", v)
|
|
}
|
|
|
|
// Append a fake migration and run it: creates a throwaway table, bumps to 1.
|
|
migrations = append(migrations, `CREATE TABLE migrate_probe (id INTEGER PRIMARY KEY)`)
|
|
t.Cleanup(func() { migrations = migrations[:len(migrations)-1] })
|
|
|
|
if err := migrate(ctx, s.db); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if v := userVersion(t, s); v != 1 {
|
|
t.Fatalf("after migrate user_version = %d, want 1", v)
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, "INSERT INTO migrate_probe DEFAULT VALUES"); err != nil {
|
|
t.Fatalf("probe table not created: %v", err)
|
|
}
|
|
|
|
// Second run is a no-op — re-running the CREATE would error (no IF NOT EXISTS).
|
|
if err := migrate(ctx, s.db); err != nil {
|
|
t.Fatalf("migrate second run not idempotent: %v", err)
|
|
}
|
|
if v := userVersion(t, s); v != 1 {
|
|
t.Fatalf("after re-migrate user_version = %d, want 1", v)
|
|
}
|
|
}
|