diff --git a/internal/database/database.go b/internal/database/database.go index b272637..b668b06 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -17,6 +17,7 @@ import ( "embed" "errors" "fmt" + "net/url" "os" "path/filepath" "sort" @@ -219,6 +220,135 @@ func openWithRecovery(ctx context.Context, path string) (*DB, error) { return db, nil } +// errUntrustedSnapshotSchema is returned when a downloaded snapshot +// database carries schema objects the real schema never defines, or is +// missing a table the restore and deep-verify queries read. +var errUntrustedSnapshotSchema = errors.New( + "downloaded snapshot database has an untrusted schema") + +// snapshotReadOnlyDSN builds the driver DSN that opens a materialized +// snapshot database file read-only. mode=ro opens the file read-only at +// the OS level, query_only rejects any write the engine is asked to make, +// and trusted_schema=OFF refuses to run application code named in the +// schema. The file: URI form is required for the driver to honour the +// mode parameter. +func snapshotReadOnlyDSN(path string) string { + u := url.URL{ + Scheme: "file", + Path: path, + RawQuery: "mode=ro&_pragma=query_only(true)&_pragma=trusted_schema(false)", + } + + return u.String() +} + +// OpenReadOnly opens an already-materialized SQLite file for read-only +// querying of a snapshot database downloaded from the store, used by +// restore and deep verify. Unlike New it never applies schema migrations +// and never writes: the connection is opened read-only with query_only +// and trusted_schema=OFF. It refuses any file whose schema carries a +// trigger, view or virtual table, or lacks an expected table, so a forged +// file cannot redefine what the restore queries return. The caller owns +// the file and must remove it. +func OpenReadOnly(ctx context.Context, path string) (*DB, error) { + conn, err := sql.Open("sqlite", snapshotReadOnlyDSN(path)) + if err != nil { + return nil, fmt.Errorf("opening read-only database: %w", err) + } + + configureConnPool(conn) + + err = conn.PingContext(ctx) + if err != nil { + _ = conn.Close() + + return nil, fmt.Errorf("opening read-only database: %w", err) + } + + err = verifySnapshotSchema(ctx, conn) + if err != nil { + _ = conn.Close() + + return nil, err + } + + return &DB{conn: conn, path: path}, nil +} + +// verifySnapshotSchema rejects a downloaded database whose schema is not +// the plain table set the real schema defines. Any trigger, view or +// virtual table, or a missing expected table, fails the open. +func verifySnapshotSchema(ctx context.Context, conn *sql.DB) error { + // expectedSnapshotTables are the tables the restore and deep-verify + // queries read. A downloaded database missing any of them is not a + // genuine snapshot database and is refused. + expectedSnapshotTables := []string{ + "blob_chunks", + "blobs", + "chunks", + "file_chunks", + "files", + } + + rows, err := conn.QueryContext( + ctx, "SELECT type, name, sql FROM sqlite_master") + if err != nil { + return fmt.Errorf("reading snapshot schema: %w", err) + } + + defer func() { _ = rows.Close() }() + + present := make(map[string]struct{}) + + for rows.Next() { + var objType, name string + + var objSQL sql.NullString + + err = rows.Scan(&objType, &name, &objSQL) + if err != nil { + return fmt.Errorf("reading snapshot schema: %w", err) + } + + switch objType { + case "trigger", "view": + return fmt.Errorf( + "%w: unexpected %s %q", errUntrustedSnapshotSchema, objType, name) + case "table": + if isVirtualTableSQL(objSQL.String) { + return fmt.Errorf( + "%w: unexpected virtual table %q", + errUntrustedSnapshotSchema, name) + } + + present[name] = struct{}{} + } + } + + err = rows.Err() + if err != nil { + return fmt.Errorf("reading snapshot schema: %w", err) + } + + for _, table := range expectedSnapshotTables { + if _, ok := present[table]; !ok { + return fmt.Errorf( + "%w: missing table %q", errUntrustedSnapshotSchema, table) + } + } + + return nil +} + +// isVirtualTableSQL reports whether a sqlite_master row's SQL defines a +// virtual table. Virtual tables are recorded with type 'table' but a +// "CREATE VIRTUAL TABLE" definition and can run module code, so they are +// refused alongside triggers and views. +func isVirtualTableSQL(createSQL string) bool { + return strings.HasPrefix( + strings.ToUpper(strings.TrimSpace(createSQL)), "CREATE VIRTUAL TABLE") +} + // NewTestDB creates an in-memory SQLite database for testing purposes. // The database is automatically initialized with the schema and is ready // for use. Each call creates a new independent database instance. diff --git a/internal/database/read_only_test.go b/internal/database/read_only_test.go new file mode 100644 index 0000000..2b0ecdd --- /dev/null +++ b/internal/database/read_only_test.go @@ -0,0 +1,145 @@ +//nolint:testpackage // exercises unexported read-only open internals +package database + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" +) + +// genuineSnapshotDB writes a real snapshot database (the full schema +// applied) to a fresh file and returns its path. +func genuineSnapshotDB(t *testing.T) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "snapshot.db") + + db, err := New(context.Background(), path) + if err != nil { + t.Fatalf("creating snapshot database: %v", err) + } + + err = db.Close() + if err != nil { + t.Fatalf("closing snapshot database: %v", err) + } + + return path +} + +// forgedDB creates an empty database file and runs the given statements +// against it read-write, so a test can plant schema objects the real +// schema never defines. +func forgedDB(t *testing.T, stmts ...string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "forged.db") + + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("opening forged database: %v", err) + } + + for _, stmt := range stmts { + _, err = db.ExecContext(context.Background(), stmt) + if err != nil { + t.Fatalf("executing %q: %v", stmt, err) + } + } + + err = db.Close() + if err != nil { + t.Fatalf("closing forged database: %v", err) + } + + return path +} + +func TestOpenReadOnlyAcceptsGenuineSnapshot(t *testing.T) { + t.Parallel() + + db, err := OpenReadOnly(context.Background(), genuineSnapshotDB(t)) + if err != nil { + t.Fatalf("OpenReadOnly refused a genuine snapshot database: %v", err) + } + + t.Cleanup(func() { _ = db.Close() }) +} + +func TestOpenReadOnlyRefusesWrites(t *testing.T) { + t.Parallel() + + db, err := OpenReadOnly(context.Background(), genuineSnapshotDB(t)) + if err != nil { + t.Fatalf("OpenReadOnly: %v", err) + } + + t.Cleanup(func() { _ = db.Close() }) + + // A schema write depends on no table columns, so the only reason it + // can fail is that the database is open read-only. + _, err = db.Conn().ExecContext(context.Background(), + "CREATE TABLE probe_readonly (x)") + if err == nil { + t.Fatal("expected a write to a read-only snapshot database to fail") + } +} + +func TestOpenReadOnlyRejectsView(t *testing.T) { + t.Parallel() + + path := forgedDB(t, "CREATE VIEW files AS SELECT 1 AS path") + + _, err := OpenReadOnly(context.Background(), path) + if !errors.Is(err, errUntrustedSnapshotSchema) { + t.Fatalf("expected a view named files to be refused, got %v", err) + } +} + +func TestOpenReadOnlyRejectsTrigger(t *testing.T) { + t.Parallel() + + path := forgedDB(t, + "CREATE TABLE files (path TEXT)", + "CREATE TRIGGER t AFTER INSERT ON files BEGIN SELECT 1; END") + + _, err := OpenReadOnly(context.Background(), path) + if !errors.Is(err, errUntrustedSnapshotSchema) { + t.Fatalf("expected a trigger to be refused, got %v", err) + } +} + +func TestOpenReadOnlyRejectsMissingTable(t *testing.T) { + t.Parallel() + + // Only one of the expected tables is present. + path := forgedDB(t, "CREATE TABLE files (path TEXT)") + + _, err := OpenReadOnly(context.Background(), path) + if !errors.Is(err, errUntrustedSnapshotSchema) { + t.Fatalf("expected a missing expected table to be refused, got %v", err) + } +} + +func TestIsVirtualTableSQL(t *testing.T) { + t.Parallel() + + cases := []struct { + sql string + want bool + }{ + {"CREATE VIRTUAL TABLE t USING fts5(x)", true}, + {" create virtual table t using fts5(x)", true}, + {"CREATE TABLE t (x)", false}, + {"CREATE VIEW t AS SELECT 1", false}, + {"", false}, + } + + for _, c := range cases { + if got := isVirtualTableSQL(c.sql); got != c.want { + t.Errorf("isVirtualTableSQL(%q) = %v, want %v", c.sql, got, c.want) + } + } +} diff --git a/internal/vaultik/restore.go b/internal/vaultik/restore.go index 1cb6fb1..1244943 100644 --- a/internal/vaultik/restore.go +++ b/internal/vaultik/restore.go @@ -39,8 +39,14 @@ var ( "refusing to restore path outside the target directory") errTrailingRestoreData = errors.New( "restored file has trailing data after its last chunk") + errRestoreIncomplete = errors.New( + "restore loop ended with files still pending") ) +// snapshotDBFilename is the name the decrypted snapshot database is +// written under inside its private temp directory. +const snapshotDBFilename = "snapshot.db" + // restoreDirMode is the permission mode for directories created while // restoring (parent directories and the target root; restored // directories themselves get their stored mode). @@ -102,7 +108,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { // Step 1: Download and decrypt the snapshot metadata database log.Info("Downloading snapshot metadata...") - tempDB, err := v.downloadSnapshotDB(opts.SnapshotID, identity) + tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identity) if err != nil { return fmt.Errorf("downloading snapshot database: %w", err) } @@ -112,10 +118,11 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { if err != nil { log.Debug("Failed to close temp database", "error", err) } - // Clean up temp file - err = v.Fs.Remove(tempDB.Path()) + // Remove the whole private directory, so the decrypted database + // and any SQLite side files it produced are gone on every path. + err = v.Fs.RemoveAll(tempDir) if err != nil { - log.Debug("Failed to remove temp database", "error", err) + log.Debug("Failed to remove temp database directory", "error", err) } }() @@ -368,6 +375,13 @@ func (v *Vaultik) runRestoreLoop( totalBytesExpected, startTime, &lastStatusTime) } + // The loop above stops as soon as nothing is ready and nothing more + // can be downloaded. If files still remain, they were abandoned + // rather than restored; fail loudly instead of reporting success. + if plan.hasPending() { + return errRestoreIncomplete + } + return nil } @@ -382,8 +396,8 @@ func (v *Vaultik) runRestoreLoop( func (s *restoreSession) downloadNextBlobSet(plan *restorePlan) (bool, error) { s.sweeper.sweep() - next := plan.pickNextDownload() - if next.IsZero() { + next, ok := plan.pickNextDownload() + if !ok { return false, nil } @@ -594,10 +608,10 @@ func (v *Vaultik) handleRestoreVerification( // index can restore the snapshots it can only see on the store. func (v *Vaultik) downloadSnapshotDB( snapshotID string, identity age.Identity, -) (*database.DB, error) { +) (*database.DB, string, error) { remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID) if err != nil { - return nil, err + return nil, "", err } // Download encrypted database from storage @@ -605,7 +619,7 @@ func (v *Vaultik) downloadSnapshotDB( reader, err := v.Storage.Get(v.ctx, dbKey) if err != nil { - return nil, fmt.Errorf("downloading %s: %w", dbKey, err) + return nil, "", fmt.Errorf("downloading %s: %w", dbKey, err) } defer func() { _ = reader.Close() }() @@ -613,7 +627,7 @@ func (v *Vaultik) downloadSnapshotDB( // Read all data encryptedData, err := io.ReadAll(reader) if err != nil { - return nil, fmt.Errorf("reading encrypted data: %w", err) + return nil, "", fmt.Errorf("reading encrypted data: %w", err) } log.Debug("Downloaded encrypted database", @@ -622,7 +636,7 @@ func (v *Vaultik) downloadSnapshotDB( // Decrypt and decompress using blobgen.Reader blobReader, err := blobgen.NewReader(bytes.NewReader(encryptedData), identity) if err != nil { - return nil, fmt.Errorf("creating decryption reader: %w", err) + return nil, "", fmt.Errorf("creating decryption reader: %w", err) } defer func() { _ = blobReader.Close() }() @@ -630,44 +644,52 @@ func (v *Vaultik) downloadSnapshotDB( // Read the binary SQLite database dbData, err := io.ReadAll(blobReader) if err != nil { - return nil, fmt.Errorf("decrypting and decompressing: %w", err) + return nil, "", fmt.Errorf("decrypting and decompressing: %w", err) } log.Debug("Decrypted database", "size", ubytes(int64(len(dbData)))) - // Create a temporary database file and write the binary SQLite data directly - tempFile, err := afero.TempFile(v.Fs, "", "vaultik-restore-*.db") + return v.materializeSnapshotDB(dbData) +} + +// materializeSnapshotDB writes the decrypted snapshot database bytes into +// a fresh private (0700) temp directory and opens the file read-only. On +// any failure it removes the directory before returning, so no decrypted +// metadata is left on disk when the open is interrupted or the payload is +// damaged. On success the returned directory is the caller's to remove. +func (v *Vaultik) materializeSnapshotDB( + dbData []byte, +) (*database.DB, string, error) { + tempDir, err := afero.TempDir(v.Fs, "", "vaultik-restore-") if err != nil { - return nil, fmt.Errorf("creating temp file: %w", err) + return nil, "", fmt.Errorf("creating temp directory: %w", err) } - tempPath := tempFile.Name() + success := false - // Write the binary SQLite database directly - _, err = tempFile.Write(dbData) + defer func() { + if !success { + _ = v.Fs.RemoveAll(tempDir) + } + }() + + dbPath := filepath.Join(tempDir, snapshotDBFilename) + + err = afero.WriteFile(v.Fs, dbPath, dbData, restoreFileMode) if err != nil { - _ = tempFile.Close() - _ = v.Fs.Remove(tempPath) - - return nil, fmt.Errorf("writing database file: %w", err) + return nil, "", fmt.Errorf("writing database file: %w", err) } - err = tempFile.Close() - if err != nil { - _ = v.Fs.Remove(tempPath) + log.Debug("Created restore database", "path", dbPath) - return nil, fmt.Errorf("closing temp file: %w", err) + db, err := database.OpenReadOnly(v.ctx, dbPath) + if err != nil { + return nil, "", fmt.Errorf("opening restore database: %w", err) } - log.Debug("Created restore database", "path", tempPath) + success = true - // Open the database - db, err := database.New(v.ctx, tempPath) - if err != nil { - return nil, fmt.Errorf("opening restore database: %w", err) - } - - return db, nil + return db, tempDir, nil } // getFilesToRestore returns the list of files to restore based on path filters diff --git a/internal/vaultik/restore_plan.go b/internal/vaultik/restore_plan.go index e40afde..8ec0f1f 100644 --- a/internal/vaultik/restore_plan.go +++ b/internal/vaultik/restore_plan.go @@ -171,10 +171,13 @@ func (p *restorePlan) finishFile(fileID types.FileID) { // downloaded next, after which it — together with any other pending // files whose blob sets become empty — moves to the ready queue. // -// The zero FileID return means nothing is pending. -func (p *restorePlan) pickNextDownload() types.FileID { +// The second return value is false when no file needs a download, so a +// genuine file carrying the nil UUID is picked rather than mistaken for +// "nothing left". +func (p *restorePlan) pickNextDownload() (types.FileID, bool) { var best types.FileID + found := false bestCount := math.MaxInt var bestID string @@ -188,14 +191,15 @@ func (p *restorePlan) pickNextDownload() types.FileID { } idStr := id.String() - if n < bestCount || (n == bestCount && (best.IsZero() || idStr < bestID)) { + if !found || n < bestCount || (n == bestCount && idStr < bestID) { best = id + found = true bestCount = n bestID = idStr } } - return best + return best, found } // blobsNeeded returns the uncached blob hashes for fileID in any order. diff --git a/internal/vaultik/restore_plan_test.go b/internal/vaultik/restore_plan_test.go new file mode 100644 index 0000000..ce23200 --- /dev/null +++ b/internal/vaultik/restore_plan_test.go @@ -0,0 +1,88 @@ +package vaultik //nolint:testpackage // inspects unexported restore plan internals + +import ( + "context" + "math" + "testing" + + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/database" + "sneak.berlin/go/vaultik/internal/types" +) + +// TestPickNextDownloadReturnsNilUUIDFile proves a genuine pending file +// carrying the nil UUID is picked for download rather than mistaken for +// "nothing left" — the bug that could abandon every remaining file. +func TestPickNextDownloadReturnsNilUUIDFile(t *testing.T) { + t.Parallel() + + var nilID types.FileID // zero value is the nil UUID + + plan := &restorePlan{ + fileBlobs: map[types.FileID]map[string]struct{}{ + nilID: {"blobhash": {}}, + }, + } + + id, ok := plan.pickNextDownload() + require.True(t, ok, + "pickNextDownload treated a pending nil-UUID file as nothing to do") + require.True(t, id.IsZero(), "expected the nil-UUID file to be picked") +} + +// TestPickNextDownloadEmptyPlan confirms the second return value is false +// only when no file needs a download. +func TestPickNextDownloadEmptyPlan(t *testing.T) { + t.Parallel() + + plan := &restorePlan{ + fileBlobs: map[types.FileID]map[string]struct{}{}, + } + + _, ok := plan.pickNextDownload() + require.False(t, ok, "pickNextDownload reported work on an empty plan") +} + +// TestRunRestoreLoopFailsOnAbandonedFiles proves the loop returns an +// error rather than silent success when files remain pending after it +// can make no further progress. +func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + db, err := database.NewTestDB() + require.NoError(t, err) + + t.Cleanup(func() { _ = db.Close() }) + + repos := database.NewRepositories(db) + + cache, err := newBlobDiskCache(math.MaxInt64) + require.NoError(t, err) + + t.Cleanup(func() { _ = cache.Close() }) + + v := &Vaultik{ctx: ctx} + session := &restoreSession{ + v: v, + ctx: ctx, + repos: repos, + sweeper: newRestoreSweeper(ctx, repos, cache, 1), + result: &RestoreResult{}, + } + + // A file that is still pending but whose uncached-blob set is empty + // and which was never queued as ready: the loop can neither restore + // nor download it. This is the abandonment the guard must catch. + var stuck types.FileID + + plan := &restorePlan{ + fileBlobs: map[types.FileID]map[string]struct{}{stuck: {}}, + blobFiles: map[string]map[types.FileID]struct{}{}, + cached: map[string]struct{}{}, + } + + err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0) + require.ErrorIs(t, err, errRestoreIncomplete) +} diff --git a/internal/vaultik/restore_snapshotdb_test.go b/internal/vaultik/restore_snapshotdb_test.go new file mode 100644 index 0000000..0cf3268 --- /dev/null +++ b/internal/vaultik/restore_snapshotdb_test.go @@ -0,0 +1,73 @@ +package vaultik //nolint:testpackage // inspects unexported snapshot-db materialization + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/database" +) + +// genuineSnapshotDBBytes returns the on-disk bytes of a real snapshot +// database (the full schema applied). +func genuineSnapshotDBBytes(t *testing.T) []byte { + t.Helper() + + path := filepath.Join(t.TempDir(), "snapshot.db") + + db, err := database.New(context.Background(), path) + require.NoError(t, err) + require.NoError(t, db.Close()) + + data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled temp path + require.NoError(t, err) + + return data +} + +// TestMaterializeSnapshotDBPrivateDir proves the decrypted database lands +// in a private (0700) directory and opens read-only. +func TestMaterializeSnapshotDBPrivateDir(t *testing.T) { + dbData := genuineSnapshotDBBytes(t) + + t.Setenv("TMPDIR", t.TempDir()) + + v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()} + + db, dir, err := v.materializeSnapshotDB(dbData) + require.NoError(t, err) + + t.Cleanup(func() { + _ = db.Close() + _ = os.RemoveAll(dir) + }) + + info, err := os.Stat(dir) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o700), info.Mode().Perm(), + "snapshot database directory must not be world-readable") + + _, err = db.Conn().ExecContext(context.Background(), + "CREATE TABLE probe_readonly (x)") + require.Error(t, err, "materialized snapshot database must be read-only") +} + +// TestMaterializeSnapshotDBRemovesDirOnOpenFailure proves a failed open +// leaves no temp directory behind. +func TestMaterializeSnapshotDBRemovesDirOnOpenFailure(t *testing.T) { + base := t.TempDir() + + t.Setenv("TMPDIR", base) + + v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()} + + _, _, err := v.materializeSnapshotDB([]byte("this is not a sqlite database")) + require.Error(t, err) + + entries, rerr := os.ReadDir(base) + require.NoError(t, rerr) + require.Empty(t, entries, "temp directory left behind after open failure") +} diff --git a/internal/vaultik/verify.go b/internal/vaultik/verify.go index 787f6d2..69ab7ed 100644 --- a/internal/vaultik/verify.go +++ b/internal/vaultik/verify.go @@ -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