diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 57d8402..a498b8f 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -128,6 +128,8 @@ func run(args []string) error { wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)") reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)") allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)") + wipe := flag.Bool("wipe", false, "print every table and its row count, then exit without serving; add -confirm-wipe to delete all of it") + confirmWipe := flag.Bool("confirm-wipe", false, "with -wipe, actually remove every piece of personal data (facts, notes, vectors, events, tasks, sessions, traces, voiceprints). config, models, passkeys and the encryption key are files and survive") flag.CommandLine.Parse(args) reembedOnStart = *reembed allowSeedOnStart = *allowSeed @@ -210,6 +212,14 @@ func run(args []string) error { }() } + // ----- wipe: never serves, exits when it is done (Vikunja #494) ----- + if *wipe { + if locked { + return fmt.Errorf("wipe: the store is locked and there is no key to open it with") + } + return runWipe(ctx, st, os.Stdout, *confirmWipe) + } + // ----- daemon components (only wired when unlocked) ----- // Pre-declare so the unlock path can wire them later. var ( diff --git a/cmd/mavend/wipe.go b/cmd/mavend/wipe.go new file mode 100644 index 0000000..b59ee25 --- /dev/null +++ b/cmd/mavend/wipe.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "fmt" + "io" + + "github.com/kami/maven/internal/store" +) + +// runWipe implements the -wipe flag: it prints what the database holds, and +// removes it only when the operator also passed -confirm-wipe (Vikunja #494). +// +// Two flags rather than one, because the destructive reading of a single flag +// is the reading a mistyped command gets. Without the confirmation this is a +// dry run that costs nothing and answers the question a QA session actually +// has — what is on this box right now. +// +// It runs before any daemon component is wired, so nothing is writing while +// the tables go. The daemon exits afterwards rather than serving a store it +// just emptied, because every component that read the old rows at boot would +// still be holding them. +func runWipe(ctx context.Context, st *store.Store, out io.Writer, confirmed bool) error { + counts, err := st.WipeCounts(ctx) + if err != nil { + return fmt.Errorf("wipe: read counts: %w", err) + } + total := 0 + for _, c := range counts { + total += c.Rows + fmt.Fprintf(out, " %-24s %d\n", c.Table, c.Rows) + } + fmt.Fprintf(out, " %-24s %d rows in %d tables\n", "TOTAL", total, len(counts)) + + if !confirmed { + fmt.Fprintln(out, "\nnothing was deleted. pass -confirm-wipe to delete all of it.") + fmt.Fprintln(out, "config, models, passkeys and the encryption key are files and are never touched.") + return nil + } + if err := st.Wipe(ctx); err != nil { + return err + } + fmt.Fprintf(out, "\nwiped. %d rows gone, the schema is intact, mavend knows nobody.\n", total) + return nil +} diff --git a/internal/store/wipe.go b/internal/store/wipe.go new file mode 100644 index 0000000..bb62eba --- /dev/null +++ b/internal/store/wipe.go @@ -0,0 +1,121 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" +) + +// WipeCount is one table and the number of rows a wipe would remove from it. +type WipeCount struct { + Table string + Rows int +} + +// WipeCounts reports every table in the database and its row count, so a caller +// can show what a wipe is about to destroy before it destroys it. Tables with +// no rows are included: a QA reader needs to see that the table was considered, +// not guess whether it was missed. +func (s *Store) WipeCounts(ctx context.Context) ([]WipeCount, error) { + names, err := userTables(ctx, s.db) + if err != nil { + return nil, err + } + out := make([]WipeCount, 0, len(names)) + for _, name := range names { + var n int + // name comes from sqlite_master, not from a caller, and is quoted. + q := fmt.Sprintf(`SELECT count(*) FROM %s`, quoteIdent(name)) + if err := s.db.QueryRowContext(ctx, q).Scan(&n); err != nil { + return nil, fmt.Errorf("count %s: %w", name, err) + } + out = append(out, WipeCount{Table: name, Rows: n}) + } + return out, nil +} + +// Wipe removes every piece of personal data and leaves the install standing. +// +// It drops every object in the database and rebuilds the schema from +// schema.sql plus the migration list, rather than deleting from a hand-written +// list of tables. That is the whole point: a list has to be edited every time a +// table is added, and the one time it is not, a wipe leaves personal data +// behind while reporting success. Dropping what the database says exists cannot +// miss a table (Vikunja #494). +// +// Nothing outside the database is touched. Config, models, the WebAuthn +// credentials in passkeys.json and the encryption key all live in files, so +// this leaves a working install with no memory of anyone. +// +// The caller is responsible for the daemon being the only thing holding the +// store, and for asking whoever ran it whether they meant it. +func (s *Store) Wipe(ctx context.Context) error { + names, err := userTables(ctx, s.db) + if err != nil { + return err + } + // Foreign keys are on per connection. Dropping tables in an arbitrary order + // trips them, and the drop order does not matter once everything goes. + if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil { + return fmt.Errorf("wipe: disable foreign keys: %w", err) + } + defer func() { + if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil { + // The handle is about to be closed by the caller either way. + _ = err + } + }() + + var drops strings.Builder + for _, name := range names { + fmt.Fprintf(&drops, "DROP TABLE IF EXISTS %s;\n", quoteIdent(name)) + } + // user_version back to zero, or migrate() applies nothing to the fresh + // schema and every ALTER that a later migration assumes is missing. + drops.WriteString("PRAGMA user_version = 0;\n") + if _, err := s.db.ExecContext(ctx, drops.String()); err != nil { + return fmt.Errorf("wipe: drop tables: %w", err) + } + if _, err := s.db.ExecContext(ctx, schemaSQL); err != nil { + return fmt.Errorf("wipe: reapply schema: %w", err) + } + if err := migrate(ctx, s.db); err != nil { + return fmt.Errorf("wipe: re-migrate: %w", err) + } + // The file keeps the pages the dropped rows used until it is vacuumed, and + // a wipe that leaves readable text in free pages is not a wipe. + if _, err := s.db.ExecContext(ctx, `VACUUM`); err != nil { + return fmt.Errorf("wipe: vacuum: %w", err) + } + return nil +} + +// userTables lists every table the schema owns, sorted, excluding sqlite's own +// internal tables (sqlite_sequence and friends, which cannot be dropped). +func userTables(ctx context.Context, db *sql.DB) ([]string, error) { + rows, err := db.QueryContext(ctx, + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`) + if err != nil { + return nil, fmt.Errorf("list tables: %w", err) + } + defer rows.Close() + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("list tables: %w", err) + } + names = append(names, name) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list tables: %w", err) + } + sort.Strings(names) + return names, nil +} + +func quoteIdent(name string) string { + return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` +} diff --git a/internal/store/wipe_test.go b/internal/store/wipe_test.go new file mode 100644 index 0000000..b7af127 --- /dev/null +++ b/internal/store/wipe_test.go @@ -0,0 +1,99 @@ +package store + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" +) + +// A wipe has to leave a working, empty install: every table still there, every +// migration still applied, and not one row of his anywhere (Vikunja #494). +func TestWipeEmptiesEveryTableAndLeavesTheSchemaUsable(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "wipe.db") + s, err := Open(ctx, path) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + now := time.Now() + if _, err := s.WriteFact(ctx, now, KindSelf, "monitor", "новый", "tap:test", 1, sql.NullInt64{}); err != nil { + t.Fatal(err) + } + if _, err := s.WriteNote(ctx, now, "купил монитор", []float32{0.1, 0.2}, "tap:test"); err != nil { + t.Fatal(err) + } + if _, err := s.CaptureTask(ctx, Task{Text: "вернуть монитор", Source: "tap:test", Status: TaskOpen, CreatedTs: now}); err != nil { + t.Fatal(err) + } + + before, err := s.WipeCounts(ctx) + if err != nil { + t.Fatal(err) + } + populated := 0 + for _, c := range before { + if c.Rows > 0 { + populated++ + } + } + if populated == 0 { + t.Fatal("nothing was written, so the wipe proves nothing") + } + + if err := s.Wipe(ctx); err != nil { + t.Fatal(err) + } + + after, err := s.WipeCounts(ctx) + if err != nil { + t.Fatal(err) + } + if len(after) != len(before) { + t.Errorf("table count changed across the wipe: %d before, %d after", len(before), len(after)) + } + for _, c := range after { + if c.Rows != 0 { + t.Errorf("table %s still holds %d rows after the wipe", c.Table, c.Rows) + } + } + + // The migrations are what make the schema usable, so the check that matters + // is a write through the newest columns, not a version number. + if _, err := s.CaptureTask(ctx, Task{Text: "новая задача", Source: "tap:test", Status: TaskOpen, CreatedTs: now}); err != nil { + t.Errorf("the store is not usable after a wipe: %v", err) + } + if _, err := s.WriteFact(ctx, now, KindSelf, "monitor", "другой", "tap:test", 1, sql.NullInt64{}); err != nil { + t.Errorf("the store is not usable after a wipe: %v", err) + } +} + +// A wipe that reports success while leaving a table behind is the failure the +// drop-everything approach exists to prevent, so the count has to see the +// tables migrations added, not only the ones schema.sql declares. +func TestWipeCountsSeeMigratedTables(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, filepath.Join(t.TempDir(), "counts.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + counts, err := s.WipeCounts(ctx) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, c := range counts { + seen[c.Table] = true + } + // One from schema.sql, three from migrations, one from a table rebuild. + for _, want := range []string{"facts", "memory_vectors", "tasks", "ecosystem_traces", "nudges"} { + if !seen[want] { + t.Errorf("wipe does not see table %s", want) + } + } +}