go-bananas/internal/store/backup.go

63 lines
2.7 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
package store
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// Snapshot writes a consistent copy of the database to dest.
//
// It uses SQLite's VACUUM INTO rather than copying the file. Copying a database
// while a write is in flight yields a torn copy: the header can say one thing and
// the pages say another, and the result may open successfully while being
// corrupt. VACUUM INTO reads through SQLite's own backup machinery, so the result
// is consistent even while the service is serving traffic, and it does not block
// writers for the length of the copy.
//
// Two further properties come from the statement itself rather than from code
// here: it refuses to overwrite an existing file, so a stale snapshot can never be
// mistaken for a fresh one, and it writes to a temporary name and renames on
// success, so a snapshot that fails halfway is not left looking complete.
//
// The connection must not be read-only: VACUUM INTO needs to read the schema and
// pages through a normal connection even though it only ever writes to the new
// file.
func (s *Store) Snapshot(ctx context.Context, dest string) error {
trimmed := strings.TrimSpace(dest)
if trimmed == "" {
return fmt.Errorf("%w: snapshot destination must not be empty", domain.ErrInvalid)
}
if dir := filepath.Dir(trimmed); dir != "" && dir != "." {
// Created by the process rather than by SQLite, which has no mkdir. The
// snapshot directory normally already exists (the Helm chart's subPath mount
// creates it), so this is a convenience for a manual invocation.
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create snapshot directory %s: %w", dir, err)
}
}
// VACUUM INTO takes an SQL string literal rather than a bound parameter on some
// SQLite builds, so the path is escaped into one here. Doubling the quote is
// the documented escape, and rejecting an embedded NUL keeps the literal
// well-formed regardless.
if strings.ContainsRune(trimmed, 0) {
return fmt.Errorf("%w: snapshot destination contains a NUL byte", domain.ErrInvalid)
}
literal := "'" + strings.ReplaceAll(trimmed, "'", "''") + "'"
//nolint:gosec // G202: VACUUM INTO takes an SQL string literal rather than a
// bound parameter on some SQLite builds. The literal is escaped above by doubling
// any single quote, which is the documented escape, and NUL bytes are rejected so
// the literal cannot be broken out of. TestSnapshotEscapesQuotesInThePath covers
// a path containing a quote.
if _, err := s.db.ExecContext(ctx, "VACUUM INTO "+literal); err != nil {
return fmt.Errorf("write snapshot to %s: %w", trimmed, err)
}
return nil
}