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 }