Open the downloaded snapshot database read-only, on a private temp dir (closes #162)
check / check (pull_request) Successful in 1m21s
check / check (push) Successful in 3m4s

Restore and deep verify used to open the decrypted snapshot database read-write through the local-index constructor, which applied migrations against whatever the file carried, and left the decrypted file in the shared temp directory. A forged file could redefine what restore queries return, and an interrupted open left decrypted metadata on disk.

Add database.OpenReadOnly: opens the file read-only (mode=ro) with query_only and trusted_schema=OFF, never applies schema files, and refuses a file whose schema carries a trigger, view or virtual table or lacks an expected table. Restore and deep verify now both use it, each inside its own private (0700) temp directory removed on every return path. pickNextDownload returns (FileID, bool) so a genuine nil-UUID file is not mistaken for "nothing left".

Model: opus-4-8
This commit was merged in pull request #186.
This commit is contained in:
2026-09-22 12:45:53 +02:00
parent b4654f8e52
commit a6434de57f
7 changed files with 541 additions and 62 deletions
+41 -24
View File
@@ -9,12 +9,12 @@ import (
"hash"
"io"
"os"
"path/filepath"
"time"
"github.com/klauspost/compress/zstd"
// Blank import registers the pure-Go sqlite driver for database/sql.
_ "modernc.org/sqlite"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
)
@@ -195,7 +195,7 @@ func (v *Vaultik) loadVerificationData(
fmt.Errorf("failed to decrypt database: %w", err))
}
dbBlobs, err := v.getBlobsFromDatabase(tdb.DB)
dbBlobs, err := v.getBlobsFromDatabase(tdb.db.Conn())
if err != nil {
_ = tdb.Close()
@@ -256,7 +256,7 @@ func (v *Vaultik) runVerificationSteps(
len(dbBlobs), ubytes(totalSize))
}
err = v.performDeepVerificationFromDB(dbBlobs, tdb.DB, opts)
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts)
if err != nil {
return v.deepVerifyFailure(result, opts, err.Error(), err)
}
@@ -264,16 +264,18 @@ func (v *Vaultik) runVerificationSteps(
return nil
}
// tempDB wraps sql.DB with cleanup
// tempDB is the downloaded snapshot database opened read-only for deep
// verify, held in a private temp directory removed in full on Close.
type tempDB struct {
*sql.DB
tempPath string
db *database.DB
tempDir string
}
func (t *tempDB) Close() error {
err := t.DB.Close()
_ = os.Remove(t.tempPath)
err := t.db.Close()
// Remove the whole private directory so the decrypted database and
// any SQLite side files are gone on every path.
_ = os.RemoveAll(t.tempDir)
return err
}
@@ -300,41 +302,56 @@ func (v *Vaultik) decryptAndLoadDatabase(reader io.ReadCloser) (*tempDB, error)
}
defer decompressor.Close()
// Create temporary file for the database
tempFile, err := os.CreateTemp("", "vaultik-verify-*.db")
// Materialize the decrypted database inside a private (0700) temp
// directory so it is never world-readable, and remove the whole
// directory on any failure below.
tempDir, err := os.MkdirTemp("", "vaultik-verify-")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
success := false
defer func() {
if !success {
_ = os.RemoveAll(tempDir)
}
}()
dbPath := filepath.Join(tempDir, snapshotDBFilename)
//nolint:gosec // G304: dbPath is our MkdirTemp dir plus a constant filename
tempFile, err := os.OpenFile(
dbPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, restoreFileMode)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
tempPath := tempFile.Name()
// Stream decompress directly to file
log.Info("Decompressing database...")
written, err := io.Copy(tempFile, decompressor)
if err != nil {
_ = tempFile.Close()
_ = os.Remove(tempPath)
return nil, fmt.Errorf("failed to decompress database: %w", err)
}
_ = tempFile.Close()
err = tempFile.Close()
if err != nil {
return nil, fmt.Errorf("failed to close temp database file: %w", err)
}
log.Info("Database decompressed", "size", ubytes(written))
// Open the database
db, err := sql.Open("sqlite", tempPath)
db, err := database.OpenReadOnly(v.ctx, dbPath)
if err != nil {
_ = os.Remove(tempPath)
return nil, fmt.Errorf("failed to open database: %w", err)
}
return &tempDB{
DB: db,
tempPath: tempPath,
}, nil
success = true
return &tempDB{db: db, tempDir: tempDir}, nil
}
// verifyBlob downloads and verifies a single blob