Files
hanzo-dev 6abb32ac93 feat(reader): read SQLCipher databases read-only, pure Go, no cgo
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.
2026-07-16 13:11:04 -07:00

290 lines
10 KiB
Go

// Package reader opens SQLCipher databases read-only, in pure Go, with no cgo.
//
// It decrypts pages in memory as SQLite asks for them and never writes: the
// database file is not modified, not even by a failed write. Use it to verify a
// backup or replica, to run reports or forensics over an encrypted store, and to
// read a ledger during migration — anywhere a C toolchain is unwelcome.
//
// # Why read-only, and why that is not a limitation of ambition
//
// SQLCipher's codec is a hook inside SQLite's pager, above the VFS, and the pager
// encrypts WAL frames and rollback-journal records too — whose checksums SQLite
// computes over the resulting ciphertext. A VFS-level codec sits below the pager,
// so it cannot reproduce those sidecars: SQLite would checksum plaintext while the
// VFS wrote ciphertext, and cross-engine recovery would silently drop committed
// transactions. Reading has no WAL frames and no journal records to get wrong, so
// the blocker that rules out a read-write VFS does not apply here. This package is
// exactly the part that is safe, and it refuses the part that is not.
//
// # What it refuses
//
// Open fails, rather than returning a plausible answer, when:
//
// - the key is wrong (page 1 does not authenticate),
// - a non-empty -wal or -journal sits beside the database.
//
// The second is the important one. Either file may hold committed data that is not
// yet in the main database, or uncommitted data that must be rolled back out of it.
// Whether a WAL is fully checkpointed cannot be settled from the file alone, so
// reading the main database while ignoring them could hand back stale or
// uncommitted numbers — silently, and off the money path is not where that is
// discovered. Checkpoint and close the database cleanly, then read it.
package reader
import (
"database/sql"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"time"
"github.com/hanzoai/sqlcipher"
_ "modernc.org/sqlite" // registers the "sqlite" driver this opens through
"modernc.org/sqlite/vfs"
)
// ErrBusy reports that a -wal or -journal sits beside the database, so the main
// file alone is not the whole truth. It is not a lock: it means the database was
// not closed cleanly and reading it could return stale or uncommitted data.
var ErrBusy = errors.New("sqlcipher/reader: database has a -wal or -journal beside it and may not be up to date; checkpoint and close it cleanly first")
// name is what the database is called inside the private file system handed to
// SQLite. The real path never reaches SQLite, so it cannot be reopened, guessed
// at, or written through by path.
const name = "db"
// DB is a read-only handle to an encrypted database. Close it to release both the
// pool and the file system registered for it.
type DB struct {
*sql.DB
fsys *vfs.FS
}
// Close closes the pool and unregisters the file system.
func (db *DB) Close() error {
err := db.DB.Close()
if ferr := db.fsys.Close(); err == nil {
err = ferr
}
return err
}
// Open opens the SQLCipher database at path read-only. A wrong key is reported
// here, at open, rather than as corruption later.
func Open(path string, k sqlcipher.Key, p sqlcipher.Params) (*DB, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
st, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
s, err := newStore(f, st.Size(), k, p)
if err != nil {
f.Close()
return nil, err
}
if err := checkQuiet(path); err != nil {
f.Close()
return nil, err
}
id, fsys, err := vfs.New(s)
if err != nil {
f.Close()
return nil, fmt.Errorf("sqlcipher/reader: register file system: %w", err)
}
// immutable=1 is load-bearing, not decoration, and mode=ro alone is NOT
// enough. SQLite normally decides read-only from the flags the VFS reports
// back out of xOpen (pager.c: readOnly = (fout&SQLITE_OPEN_READONLY)!=0),
// and modernc's read-only VFS reports os.O_RDONLY there — which is 0, where
// SQLITE_OPEN_READONLY is 1. The bit is dropped, so mode=ro silently yields a
// read-WRITE pager and the only thing left refusing writes is that VFS's
// blanket refusal to open a journal. PRAGMA journal_mode=MEMORY removes even
// that, and the next write reaches the VFS's null xWrite and segfaults the
// process. immutable=1 takes a different route (pager.c: vfsFlags |=
// SQLITE_OPEN_READONLY, then act_like_temp_file derives readOnly from
// vfsFlags rather than from the VFS), so the pager really is read-only, the
// btree gets BTS_READ_ONLY, and writes are refused before any journal or page
// write. It cannot be turned off from SQL, which is why it is preferred over
// PRAGMA query_only. mode=ro stays because it states the intent and becomes
// the real gate if modernc ever reports the flag correctly.
//
// immutable also tells SQLite the file cannot change underneath it, which is
// true of what this package is for — a snapshot, a backup, a replica, a
// cleanly closed store — and is why Open refuses a database with a live
// sidecar. Do not point it at a file another process is writing.
db, err := sql.Open("sqlite", "file:"+name+"?vfs="+id+"&mode=ro&immutable=1")
if err != nil {
fsys.Close()
f.Close()
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
fsys.Close()
f.Close()
return nil, fmt.Errorf("sqlcipher/reader: open %s: %w", path, err)
}
if err := proveReadOnly(db); err != nil {
db.Close()
fsys.Close()
f.Close()
return nil, err
}
return &DB{DB: db, fsys: fsys}, nil
}
// proveReadOnly refuses to hand back a handle whose read-only guard is not
// actually in force. The guard depends on how SQLite and modernc's VFS interact
// (see the DSN comment above), and getting it wrong does not fail loudly — it
// segfaults on the first write. So it is checked rather than assumed, once, at
// open: a write that is refused proves the pager is read-only; a write that
// succeeds means the database is writable and must not be handed out as a reader.
func proveReadOnly(db *sql.DB) error {
_, err := db.Exec("PRAGMA user_version = 1")
if err == nil {
return errors.New("sqlcipher/reader: refusing to open: the read-only guard is not in force, this handle could modify the database")
}
return nil
}
// checkQuiet refuses a database whose sidecars may hold data the main file does
// not. A zero-length -wal is what a cleanly closed database in persistent-WAL
// mode leaves behind and carries no frames, so it is allowed.
func checkQuiet(path string) error {
for _, suffix := range []string{"-wal", "-journal"} {
st, err := os.Stat(path + suffix)
if err != nil {
continue // absent is what we want
}
if st.Size() > 0 {
return fmt.Errorf("%w: %s is %d bytes", ErrBusy, filepath.Base(path+suffix), st.Size())
}
}
return nil
}
// store is the decrypting file system handed to SQLite: exactly one file, no
// directory, no writes.
type store struct {
src io.ReaderAt
size int64
codec *sqlcipher.Codec
}
func newStore(src io.ReaderAt, size int64, k sqlcipher.Key, p sqlcipher.Params) (*store, error) {
head := make([]byte, sqlcipher.SaltSize)
if _, err := src.ReadAt(head, 0); err != nil {
return nil, fmt.Errorf("sqlcipher/reader: read salt: %w", err)
}
salt, err := sqlcipher.FileSalt(head)
if err != nil {
return nil, err
}
c, err := sqlcipher.NewCodec(k, salt, p)
if err != nil {
return nil, err
}
if size == 0 || size%int64(c.PageSize()) != 0 {
return nil, fmt.Errorf("sqlcipher/reader: %d bytes is not a whole number of %d-byte pages", size, c.PageSize())
}
s := &store{src: src, size: size, codec: c}
// Decrypting page 1 authenticates it, so a wrong key is caught here rather
// than surfacing as corruption once SQLite starts reading.
page1, err := s.page(1)
if err != nil {
return nil, err
}
if got := page1[20]; got != sqlcipher.Reserve {
return nil, fmt.Errorf("sqlcipher/reader: page reserve is %d, want %d", got, sqlcipher.Reserve)
}
return s, nil
}
// page decrypts one page by number.
func (s *store) page(pgno uint32) ([]byte, error) {
buf := make([]byte, s.codec.PageSize())
if _, err := s.src.ReadAt(buf, int64(pgno-1)*int64(s.codec.PageSize())); err != nil {
return nil, fmt.Errorf("sqlcipher/reader: read page %d: %w", pgno, err)
}
return s.codec.Decrypt(pgno, buf)
}
// Open implements fs.FS. It serves the one database and nothing else, so SQLite's
// probes for a journal or WAL inside this file system find nothing.
func (s *store) Open(n string) (fs.File, error) {
if n != name {
return nil, &fs.PathError{Op: "open", Path: n, Err: fs.ErrNotExist}
}
return &file{store: s}, nil
}
// file is a read cursor over the decrypted database. modernc's VFS seeks and then
// reads, so it needs io.Seeker as well as fs.File.
type file struct {
*store
off int64
cached uint32 // page number held in page, 0 when empty
page []byte
}
func (f *file) Stat() (fs.FileInfo, error) { return info{size: f.size}, nil }
func (f *file) Close() error { return nil }
func (f *file) Seek(off int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
case io.SeekCurrent:
off += f.off
case io.SeekEnd:
off += f.size
default:
return 0, fmt.Errorf("sqlcipher/reader: bad whence %d", whence)
}
if off < 0 {
return 0, fmt.Errorf("sqlcipher/reader: negative offset %d", off)
}
f.off = off
return off, nil
}
func (f *file) Read(b []byte) (int, error) {
if f.off >= f.size {
return 0, io.EOF
}
ps := int64(f.codec.PageSize())
n := 0
for n < len(b) && f.off < f.size {
pgno := uint32(f.off/ps) + 1
if f.cached != pgno {
page, err := f.store.page(pgno)
if err != nil {
return n, err
}
f.page, f.cached = page, pgno
}
c := copy(b[n:], f.page[f.off%ps:])
n += c
f.off += int64(c)
}
return n, nil
}
// info is the minimum fs.FileInfo modernc's VFS reads: it asks only for Size.
type info struct{ size int64 }
func (i info) Name() string { return name }
func (i info) Size() int64 { return i.size }
func (i info) Mode() fs.FileMode { return 0o400 }
func (i info) ModTime() time.Time { return time.Time{} }
func (i info) IsDir() bool { return false }
func (i info) Sys() any { return nil }