mirror of
https://github.com/hanzoai/sqlcipher.git
synced 2026-08-06 17:32:17 +00:00
Reads a database the C library wrote, decrypting pages in memory, on modernc's existing read-only VFS. The pager-level blocker that rules out a read-write VFS does not apply: reading has no WAL frames and no journal records whose checksums could disagree. immutable=1 is load-bearing, not decoration. modernc's read-only VFS reports os.O_RDONLY (0) out of xOpen where SQLite expects SQLITE_OPEN_READONLY (1), and SQLite decides read-only from exactly that flag (pager.c:5044 -> btree.c:2640). So mode=ro silently yields a read-WRITE pager, leaving only that VFS's blanket refusal to open a journal between a caller and its null xWrite. PRAGMA journal_mode=MEMORY removes the accident and the next write SIGSEGVs the process -- reproduced, and now a regression test. immutable=1 routes through pager.c:5081, where readOnly comes from vfsFlags rather than from the VFS, so the pager really is read-only and writes are refused before any journal or page write. It cannot be turned off from SQL, unlike PRAGMA query_only. Read-only is proven at open, not assumed: Open runs a write and refuses to return a handle if it succeeds. Open also refuses a database with a non-empty -wal or -journal beside it. Either may hold committed data not yet in the main file, or uncommitted data that must come out of it, and whether a WAL is checkpointed cannot be settled from the file alone. Answering anyway would mean stale numbers off a ledger. A wrong key is an error at open, not corruption later.
322 lines
9.2 KiB
Go
322 lines
9.2 KiB
Go
package reader
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/sqlcipher"
|
|
)
|
|
|
|
var key = func() []byte {
|
|
b := make([]byte, 32)
|
|
for i := range b {
|
|
b[i] = byte(i)
|
|
}
|
|
return b
|
|
}()
|
|
|
|
const keyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
|
|
|
// fixture copies a database the real C library wrote into a temp dir.
|
|
func fixture(t *testing.T, src string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join("..", "testdata", src))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(t.TempDir(), "c.db")
|
|
if err := os.WriteFile(path, b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func sum(t *testing.T, path string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := sha256.Sum256(b)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// TestReadsDatabaseWrittenByC is the point of the package: pure Go, no cgo, reads
|
|
// a database the C library wrote.
|
|
func TestReadsDatabaseWrittenByC(t *testing.T) {
|
|
path := fixture(t, "c-4.5.6.db")
|
|
db, err := Open(path, sqlcipher.RawKey(key), sqlcipher.Params{})
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
var got []string
|
|
rows, err := db.Query("SELECT v FROM t ORDER BY id")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var v string
|
|
if err := rows.Scan(&v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got = append(got, v)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"hello-from-c", "ledger-row-two"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
|
|
// Overflow pages and an index: the reader must serve every page, not just
|
|
// the header.
|
|
var n, total int
|
|
if err := db.QueryRow("SELECT count(*), coalesce(sum(length(blob)),0) FROM wide").Scan(&n, &total); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 2 || total != 14000 {
|
|
t.Errorf("wide: got %d rows / %d bytes, want 2 / 14000", n, total)
|
|
}
|
|
if err := db.QueryRow("PRAGMA integrity_check").Scan(new(string)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestReadsPassphraseDatabaseWrittenByC(t *testing.T) {
|
|
path := fixture(t, "c-4.5.6-passphrase.db")
|
|
db, err := Open(path, sqlcipher.Passphrase("correct horse battery staple"), sqlcipher.Params{})
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
var v string
|
|
if err := db.QueryRow("SELECT v FROM t").Scan(&v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v != "passphrase-keyed-by-c" {
|
|
t.Errorf("got %q", v)
|
|
}
|
|
}
|
|
|
|
// TestWrongKeyFailsClosed: a wrong key must be an error at Open, not garbage, not
|
|
// an empty database.
|
|
func TestWrongKeyFailsClosed(t *testing.T) {
|
|
path := fixture(t, "c-4.5.6.db")
|
|
for _, tc := range []struct {
|
|
name string
|
|
k sqlcipher.Key
|
|
}{
|
|
{"wrong raw key", sqlcipher.RawKey(make([]byte, 32))},
|
|
{"wrong passphrase", sqlcipher.Passphrase("nope")},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
db, err := Open(path, tc.k, sqlcipher.Params{})
|
|
if err == nil {
|
|
db.Close()
|
|
t.Fatal("FAIL OPEN: opened with the wrong key")
|
|
}
|
|
if !errors.Is(err, sqlcipher.ErrKey) {
|
|
t.Fatalf("want ErrKey, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReadOnlyIsEnforced: every statement that would change data must fail with
|
|
// a real error and leave the file byte-identical.
|
|
//
|
|
// Note journal_mode pragmas are NOT in this list: they set an in-memory pager
|
|
// mode and write nothing, so SQLite accepts them on a read-only database and is
|
|
// right to. What matters is that no byte of the file moves, which is asserted
|
|
// after every case.
|
|
func TestReadOnlyIsEnforced(t *testing.T) {
|
|
path := fixture(t, "c-4.5.6.db")
|
|
before := sum(t, path)
|
|
|
|
db, err := Open(path, sqlcipher.RawKey(key), sqlcipher.Params{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
for _, q := range []string{
|
|
"INSERT INTO t(v) VALUES('nope')",
|
|
"UPDATE t SET v='nope'",
|
|
"DELETE FROM t",
|
|
"CREATE TABLE u(x)",
|
|
"DROP TABLE t",
|
|
"CREATE INDEX idx_nope ON t(v)",
|
|
"PRAGMA user_version=42",
|
|
"PRAGMA application_id=7",
|
|
"VACUUM",
|
|
"REINDEX",
|
|
} {
|
|
t.Run(q, func(t *testing.T) {
|
|
if _, err := db.Exec(q); err == nil {
|
|
t.Fatalf("FAIL OPEN: %q succeeded on a read-only database", q)
|
|
}
|
|
if after := sum(t, path); after != before {
|
|
t.Fatalf("%q changed the file", q)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Reads still work after all those refusals.
|
|
var n int
|
|
if err := db.QueryRow("SELECT count(*) FROM t").Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 2 {
|
|
t.Errorf("got %d rows, want 2", n)
|
|
}
|
|
if after := sum(t, path); after != before {
|
|
t.Fatal("the database file changed under a read-only open")
|
|
}
|
|
}
|
|
|
|
// TestJournalModeMemoryCannotReachTheNullWrite is a regression test for a real
|
|
// crash, and the reason Open sets immutable=1.
|
|
//
|
|
// modernc's read-only VFS has no xWrite at all and reports os.O_RDONLY (0) where
|
|
// SQLite expects SQLITE_OPEN_READONLY (1), so SQLite does not learn the file is
|
|
// read-only from the VFS. With only mode=ro, writes were refused by accident —
|
|
// that VFS refuses to open a journal — and PRAGMA journal_mode=MEMORY removes the
|
|
// accident: no journal file is needed, so the next write walks into the null
|
|
// xWrite and SIGSEGVs the process. immutable=1 makes the pager genuinely
|
|
// read-only, so the write is refused before any of that.
|
|
//
|
|
// If this test ever segfaults instead of failing, the guard is gone.
|
|
func TestJournalModeMemoryCannotReachTheNullWrite(t *testing.T) {
|
|
path := fixture(t, "c-4.5.6.db")
|
|
before := sum(t, path)
|
|
db, err := Open(path, sqlcipher.RawKey(key), sqlcipher.Params{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Setting an in-memory journal is allowed: it writes nothing.
|
|
if _, err := db.Exec("PRAGMA journal_mode=MEMORY"); err != nil {
|
|
t.Logf("journal_mode=MEMORY: %v", err)
|
|
}
|
|
for _, q := range []string{"PRAGMA user_version=42", "INSERT INTO t(v) VALUES('x')"} {
|
|
if _, err := db.Exec(q); err == nil {
|
|
t.Fatalf("FAIL OPEN: %q wrote to a read-only database with an in-memory journal", q)
|
|
}
|
|
}
|
|
if after := sum(t, path); after != before {
|
|
t.Fatal("the database file changed")
|
|
}
|
|
}
|
|
|
|
// TestRefusesHotSidecar: a -wal or -journal beside the database means the main
|
|
// file may be stale or hold uncommitted data. Refuse rather than answer.
|
|
func TestRefusesHotSidecar(t *testing.T) {
|
|
for _, suffix := range []string{"-wal", "-journal"} {
|
|
t.Run(suffix, func(t *testing.T) {
|
|
path := fixture(t, "c-4.5.6.db")
|
|
if err := os.WriteFile(path+suffix, []byte("frames"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := Open(path, sqlcipher.RawKey(key), sqlcipher.Params{})
|
|
if err == nil {
|
|
db.Close()
|
|
t.Fatalf("FAIL OPEN: read a database with a hot %s behind it", suffix)
|
|
}
|
|
if !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("want ErrBusy, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A zero-length -wal is what a clean close in persistent-WAL mode leaves and
|
|
// carries no frames, so it must not block a read.
|
|
t.Run("empty -wal is fine", func(t *testing.T) {
|
|
path := fixture(t, "c-4.5.6.db")
|
|
if err := os.WriteFile(path+"-wal", nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := Open(path, sqlcipher.RawKey(key), sqlcipher.Params{})
|
|
if err != nil {
|
|
t.Fatalf("an empty -wal must not block a read: %v", err)
|
|
}
|
|
db.Close()
|
|
})
|
|
}
|
|
|
|
func TestRejectsMalformed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
for _, tc := range []struct {
|
|
name, want string
|
|
body []byte
|
|
}{
|
|
{"empty file", "pages", nil},
|
|
{"partial page", "pages", make([]byte, 100)},
|
|
{"not a whole number of pages", "pages", make([]byte, 4096+7)},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
path := filepath.Join(dir, strings.ReplaceAll(tc.name, " ", "_")+".db")
|
|
if err := os.WriteFile(path, tc.body, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := Open(path, sqlcipher.RawKey(key), sqlcipher.Params{})
|
|
if err == nil {
|
|
db.Close()
|
|
t.Fatal("want error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Logf("error (acceptable): %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReads461 closes the version gap: production ships SQLCipher 4.6.1 while the
|
|
// committed fixtures were written by 4.5.6. This builds 4.6.1 from source and
|
|
// reads what it wrote. Skipped where the toolchain is absent.
|
|
func TestReads461(t *testing.T) {
|
|
bin := os.Getenv("SQLCIPHER_461_SHELL")
|
|
if bin == "" {
|
|
t.Skip("set SQLCIPHER_461_SHELL to a sqlcipher 4.6.1 shell to close the version gap")
|
|
}
|
|
path := filepath.Join(t.TempDir(), "c461.db")
|
|
sql := "PRAGMA key = \"x'" + keyHex + "'\";\nPRAGMA journal_mode=DELETE;\n" +
|
|
"CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT);\nINSERT INTO t(v) VALUES('written-by-461');\n"
|
|
cmd := exec.Command(bin, path)
|
|
cmd.Stdin = strings.NewReader(sql)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("sqlcipher 4.6.1 create: %v\n%s", err, out)
|
|
}
|
|
if b, err := os.ReadFile(path); err != nil {
|
|
t.Fatal(err)
|
|
} else if bytes.HasPrefix(b, []byte("SQLite format 3")) {
|
|
t.Fatal("the 4.6.1 shell wrote plaintext: not linked against SQLCipher")
|
|
}
|
|
|
|
db, err := Open(path, sqlcipher.RawKey(key), sqlcipher.Params{})
|
|
if err != nil {
|
|
t.Fatalf("pure Go could not read a database SQLCipher 4.6.1 wrote: %v", err)
|
|
}
|
|
defer db.Close()
|
|
var v string
|
|
if err := db.QueryRow("SELECT v FROM t").Scan(&v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v != "written-by-461" {
|
|
t.Errorf("got %q", v)
|
|
}
|
|
t.Logf("pure Go read %q from a SQLCipher 4.6.1 database", v)
|
|
}
|