Open the downloaded snapshot database read-only, on a private temp dir (closes #162)
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user