82d9a3324e
There was no documented way to repair a poisoned box. Two invented facts written during QA disabled world answering for every later turn (V-470), and revert voids the SQL row while leaving the vector behind (V-493). This is the operation that undoes both. Store.Wipe drops every table sqlite_master reports and rebuilds from schema.sql plus the migrations, rather than deleting from a hand-written list. A list has to be edited whenever a table is added, and the once it is not, the wipe leaves personal data behind while reporting success. It vacuums afterwards, because free pages still hold readable text. mavend -wipe prints every table and its row count and exits. That alone is a dry run and answers what a QA session actually asks: what is on this box. It deletes only with -confirm-wipe. Two flags, because the destructive reading of one flag is the reading a mistyped command gets. Nothing outside the database moves. Config, models, passkeys.json and the encryption key are files. QA isolation and onboarding are the other two thirds of V-494 and are not here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
122 lines
4.2 KiB
Go
122 lines
4.2 KiB
Go
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, `"`, `""`) + `"`
|
|
}
|