textmachine/backend/internal/store/backup.go

102 lines
4.2 KiB
Go

package store
// backup.go: the F4 SPOF guard (backlog row 83). The owner's SIGNED bank, every checkpoint and the spend
// ledger live in ONE SQLite file per project, so a paid run must (1) leave a fresh, consistent restore point
// before it starts and (2) refuse to start on a corrupt file — a silent loss of the signed bank is
// unrecoverable. Both are cheap, deterministic and provider-free. The caller (tmctl) decides WHEN to run
// this; here we only verify integrity and copy.
import (
"database/sql"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
// IntegrityCheck runs `PRAGMA integrity_check` against the SQLite file at dbPath and returns a loud error
// unless the check reports the single row "ok". It opens a private read-only connection (query_only), so it
// can never mutate the file it is verifying and is safe to run against a database another reader holds.
func IntegrityCheck(dbPath string) error {
db, err := openForBackup(dbPath, true)
if err != nil {
return err
}
defer db.Close()
return integrityCheck(db, dbPath)
}
// BackupSQLite verifies the project database's structural integrity and then writes a transactionally
// consistent copy to backupDir/<stamp>.db via `VACUUM INTO` — the SQLite-blessed online-backup path (a
// defragmented snapshot, no torn WAL frames). stamp is supplied by the caller (a timestamp) so the backup
// name is deterministic under test. A non-"ok" integrity_check is a LOUD error and NO backup is written (a
// corrupt source must never be trusted as a restore point). It refuses to overwrite an existing backup, so a
// caller cannot silently clobber a good restore point. Returns the backup file's path.
func BackupSQLite(dbPath, backupDir, stamp string) (string, error) {
if _, err := os.Stat(dbPath); err != nil {
return "", fmt.Errorf("store: backup source %s does not exist: %w", dbPath, err)
}
db, err := openForBackup(dbPath, false) // VACUUM INTO is a write statement — query_only would block it
if err != nil {
return "", err
}
defer db.Close()
if err := integrityCheck(db, dbPath); err != nil {
return "", err
}
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", fmt.Errorf("store: backup dir %s: %w", backupDir, err)
}
backupPath := filepath.Join(backupDir, stamp+".db")
if _, err := os.Stat(backupPath); err == nil {
return "", fmt.Errorf("store: backup %s already exists (refusing to overwrite a restore point)", backupPath)
}
// VACUUM INTO takes a string literal path; the single-quote is the only SQL metachar, escaped by doubling.
if _, err := db.Exec("VACUUM INTO '" + strings.ReplaceAll(backupPath, "'", "''") + "'"); err != nil {
return "", fmt.Errorf("store: VACUUM INTO %s: %w", backupPath, err)
}
return backupPath, nil
}
// openForBackup opens a private single connection to dbPath. readOnly adds query_only(1) for a pure check;
// the backup path needs a writable connection because VACUUM INTO is classified as a write statement (it
// still only READS the source and writes a separate file).
func openForBackup(dbPath string, readOnly bool) (*sql.DB, error) {
v := url.Values{}
v.Add("_pragma", "busy_timeout(5000)")
if readOnly {
v.Add("_pragma", "query_only(1)")
}
db, err := sql.Open("sqlite", "file:"+dbPath+"?"+v.Encode())
if err != nil {
return nil, fmt.Errorf("store: open %s for backup: %w", dbPath, err)
}
db.SetMaxOpenConns(1)
return db, nil
}
// integrityCheck runs the pragma over db and demands the single canonical "ok" row; anything else (a list of
// corruption reports, or a "file is not a database" open error surfaced here) is a loud failure.
func integrityCheck(db *sql.DB, dbPath string) error {
rows, err := db.Query("PRAGMA integrity_check")
if err != nil {
return fmt.Errorf("store: integrity_check %s: %w", dbPath, err)
}
defer rows.Close()
var lines []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return fmt.Errorf("store: integrity_check %s scan: %w", dbPath, err)
}
lines = append(lines, s)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: integrity_check %s: %w", dbPath, err)
}
if len(lines) != 1 || lines[0] != "ok" {
return fmt.Errorf("store: integrity_check FAILED for %s: %v", dbPath, lines)
}
return nil
}