Harden local runtime safety and error handling

This commit is contained in:
Mikhail Chusavitin
2026-03-15 16:28:32 +03:00
parent f0e6bba7e9
commit c964d66e64
25 changed files with 726 additions and 245 deletions

View File

@@ -10,6 +10,10 @@ import (
"sort"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type backupPeriod struct {
@@ -250,6 +254,12 @@ func pruneOldBackups(periodDir string, keep int) error {
}
func createBackupArchive(destPath, dbPath, configPath string) error {
snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath)
if err != nil {
return err
}
defer cleanup()
file, err := os.Create(destPath)
if err != nil {
return err
@@ -257,12 +267,10 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
defer file.Close()
zipWriter := zip.NewWriter(file)
if err := addZipFile(zipWriter, dbPath); err != nil {
if err := addZipFileAs(zipWriter, snapshotPath, filepath.Base(dbPath)); err != nil {
_ = zipWriter.Close()
return err
}
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath)
@@ -274,6 +282,77 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
return file.Sync()
}
func createSQLiteSnapshot(dbPath string) (string, func(), error) {
tempFile, err := os.CreateTemp("", "qfs-backup-*.db")
if err != nil {
return "", func() {}, err
}
tempPath := tempFile.Name()
if err := tempFile.Close(); err != nil {
_ = os.Remove(tempPath)
return "", func() {}, err
}
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
return "", func() {}, err
}
cleanup := func() {
_ = os.Remove(tempPath)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
cleanup()
return "", func() {}, err
}
sqlDB, err := db.DB()
if err != nil {
cleanup()
return "", func() {}, err
}
defer sqlDB.Close()
if err := db.Exec("PRAGMA busy_timeout = 5000").Error; err != nil {
cleanup()
return "", func() {}, fmt.Errorf("configure sqlite busy_timeout: %w", err)
}
literalPath := strings.ReplaceAll(tempPath, "'", "''")
if err := vacuumIntoWithRetry(db, literalPath); err != nil {
cleanup()
return "", func() {}, err
}
return tempPath, cleanup, nil
}
func vacuumIntoWithRetry(db *gorm.DB, literalPath string) error {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if err := db.Exec("VACUUM INTO '" + literalPath + "'").Error; err != nil {
lastErr = err
if !isSQLiteBusyError(err) {
return fmt.Errorf("create sqlite snapshot: %w", err)
}
time.Sleep(time.Duration(attempt+1) * 250 * time.Millisecond)
continue
}
return nil
}
return fmt.Errorf("create sqlite snapshot after retries: %w", lastErr)
}
func isSQLiteBusyError(err error) bool {
if err == nil {
return false
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "database is locked") || strings.Contains(lower, "database is busy")
}
func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
@@ -282,6 +361,10 @@ func addZipOptionalFile(writer *zip.Writer, path string) error {
}
func addZipFile(writer *zip.Writer, path string) error {
return addZipFileAs(writer, path, filepath.Base(path))
}
func addZipFileAs(writer *zip.Writer, path string, archiveName string) error {
in, err := os.Open(path)
if err != nil {
return err
@@ -297,7 +380,7 @@ func addZipFile(writer *zip.Writer, path string) error {
if err != nil {
return err
}
header.Name = filepath.Base(path)
header.Name = archiveName
header.Method = zip.Deflate
out, err := writer.CreateHeader(header)

View File

@@ -1,11 +1,15 @@
package appstate
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
@@ -13,8 +17,8 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write db: %v", err)
if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write sqlite db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err)
@@ -36,6 +40,7 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
if _, err := os.Stat(dailyArchive); err != nil {
t.Fatalf("daily archive missing: %v", err)
}
assertZipContains(t, dailyArchive, "qfs.db", "config.yaml")
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
@@ -57,8 +62,8 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write db: %v", err)
if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write sqlite db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err)
@@ -95,8 +100,8 @@ func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
t.Fatalf("mkdir data dir: %v", err)
}
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write db: %v", err)
if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write sqlite db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write cfg: %v", err)
@@ -110,3 +115,43 @@ func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
func writeTestSQLiteDB(path string) error {
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
defer sqlDB.Close()
return db.Exec(`
CREATE TABLE sample_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
INSERT INTO sample_items(name) VALUES ('backup');
`).Error
}
func assertZipContains(t *testing.T, archivePath string, expected ...string) {
t.Helper()
reader, err := zip.OpenReader(archivePath)
if err != nil {
t.Fatalf("open archive: %v", err)
}
defer reader.Close()
found := make(map[string]bool, len(reader.File))
for _, file := range reader.File {
found[file.Name] = true
}
for _, name := range expected {
if !found[name] {
t.Fatalf("archive %s missing %s", archivePath, name)
}
}
}