mirror of
https://github.com/hanzoai/cek.git
synced 2026-10-09 02:37:18 +00:00
The copy was SQLCipher's sqlcipher_export, a SQL function only the C libsqlcipher defines. So a build without that library could not convert a plaintext database at all: the store it was pointed at stayed unopenable, and every test covering the conversion skipped rather than ran. Nothing else here needed the C engine. github.com/hanzoai/sqlite encrypts on every build — the pure-Go SQLCipher codec envelope, byte-compatible with the C one — so the conversion was the last place the dependency survived, and it did not have to. It is now a logical copy in Go over two ordinary handles: the plaintext source opened with no key, a fresh encrypted target opened with one. Tables, then rows, then indexes and triggers and views, so an index is not rebuilt per insert and a trigger cannot fire on rows the source never had. Columns are named from table_xinfo rather than SELECT * — a generated column cannot be inserted into, and naming them also stops a normalised column order from writing values into the wrong column. The AUTOINCREMENT counters come across from sqlite_sequence, which takes a delete and an insert because that table carries no unique index for an ON CONFLICT target to name. A byte-level encrypt is not available for this: SQLCipher reserves 80 bytes per page for its IV and tag, a plaintext database reserves none, and no pragma retrofits that into an existing file. A virtual table is REFUSED, naming itself. Its shadow tables hold an index layout rather than rows, so recreating it regenerates bytes no logical copy can promise and verify would report a difference the data does not explain. The C function did that from inside the engine. The suite now runs on the pure-Go build with nothing skipped — 30 tests, including fidelity over AUTOINCREMENT, WITHOUT ROWID, a GENERATED column and foreign keys — and passes identically against a real libsqlcipher. A store converted by either engine reads back under the other, rows, header fields and counters intact. Co-authored-by: Hanzo Dev <[email protected]>
142 lines
4.4 KiB
Go
142 lines
4.4 KiB
Go
package cek
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/namespace"
|
|
sqlitedrv "github.com/hanzoai/sqlite"
|
|
)
|
|
|
|
// What survives a conversion, measured rather than assumed.
|
|
//
|
|
// The parity check compares a schema hash and per-table row counts and content
|
|
// hashes. That leaves a question it cannot answer about itself: does the copy
|
|
// carry the things which are NOT rows in a user table — AUTOINCREMENT counters,
|
|
// views, triggers, indexes, WITHOUT ROWID tables, generated columns,
|
|
// user_version — and would the check even notice if one went missing?
|
|
//
|
|
// This builds a database holding every one of them, converts it, and compares
|
|
// the two by asking the database itself.
|
|
func TestAConversionCarriesEverythingNotJustRows(t *testing.T) {
|
|
setTestMaster(t)
|
|
|
|
path := filepath.Join(t.TempDir(), "hostile.db")
|
|
|
|
db, err := sql.Open("sqlite", sqlitedrv.DSN(path, nil))
|
|
if err != nil {
|
|
t.Fatalf("open plaintext: %v", err)
|
|
}
|
|
if _, err := db.Exec(`
|
|
PRAGMA user_version = 42;
|
|
PRAGMA application_id = 777;
|
|
|
|
CREATE TABLE seq (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT);
|
|
INSERT INTO seq (v) VALUES ('a'),('b'),('c');
|
|
DELETE FROM seq WHERE v='c';
|
|
|
|
CREATE TABLE norowid (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;
|
|
INSERT INTO norowid VALUES ('k1','v1'),('k2','v2');
|
|
|
|
CREATE TABLE gen (a INTEGER, b INTEGER, s INTEGER GENERATED ALWAYS AS (a+b) VIRTUAL);
|
|
INSERT INTO gen (a,b) VALUES (2,3);
|
|
|
|
CREATE TABLE parent (id INTEGER PRIMARY KEY);
|
|
CREATE TABLE child (id INTEGER PRIMARY KEY, p INTEGER REFERENCES parent(id));
|
|
INSERT INTO parent VALUES (1);
|
|
INSERT INTO child VALUES (1,1);
|
|
|
|
CREATE INDEX seq_v ON seq(v);
|
|
CREATE UNIQUE INDEX norowid_v ON norowid(v);
|
|
CREATE VIEW seq_view AS SELECT v FROM seq;
|
|
CREATE TRIGGER seq_guard AFTER INSERT ON seq BEGIN UPDATE seq SET v=upper(NEW.v) WHERE id=NEW.id; END;
|
|
`); err != nil {
|
|
t.Fatalf("build the hostile database: %v", err)
|
|
}
|
|
before := describe(t, db)
|
|
if err := db.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
if err := Convert(namespace.System(), "hostile", path); err != nil {
|
|
t.Fatalf("Convert: %v", err)
|
|
}
|
|
|
|
enc, err := OpenAt(namespace.System(), "hostile", path)
|
|
if err != nil {
|
|
t.Fatalf("OpenAt: %v", err)
|
|
}
|
|
defer func() { _ = enc.Close() }()
|
|
after := describe(t, enc)
|
|
|
|
for k, want := range before {
|
|
got, ok := after[k]
|
|
if !ok {
|
|
t.Errorf("%s: missing from the converted database (was %q)", k, want)
|
|
continue
|
|
}
|
|
if got != want {
|
|
t.Errorf("%s: converted database has %q, original had %q", k, got, want)
|
|
}
|
|
}
|
|
for k := range after {
|
|
if _, ok := before[k]; !ok {
|
|
t.Errorf("%s: appeared in the converted database and was not in the original", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// describe asks a database what it is, in facts a conversion could plausibly
|
|
// drop.
|
|
func describe(t *testing.T, db *sql.DB) map[string]string {
|
|
t.Helper()
|
|
out := map[string]string{}
|
|
|
|
for _, p := range []string{"user_version", "application_id", "page_size"} {
|
|
var v string
|
|
if err := db.QueryRow("PRAGMA " + p).Scan(&v); err != nil {
|
|
t.Fatalf("PRAGMA %s: %v", p, err)
|
|
}
|
|
out["pragma:"+p] = v
|
|
}
|
|
|
|
// Every schema object, including the internal sqlite_sequence row that holds
|
|
// an AUTOINCREMENT counter — the one a "rows in user tables" check cannot see.
|
|
rows, err := db.Query(`SELECT type,name,COALESCE(sql,'') FROM sqlite_master ORDER BY type,name`)
|
|
if err != nil {
|
|
t.Fatalf("sqlite_master: %v", err)
|
|
}
|
|
for rows.Next() {
|
|
var typ, name, ddl string
|
|
if err := rows.Scan(&typ, &name, &ddl); err != nil {
|
|
t.Fatalf("scan master: %v", err)
|
|
}
|
|
out["object:"+typ+":"+name] = ddl
|
|
}
|
|
_ = rows.Close()
|
|
|
|
var seqName, seqVal sql.NullString
|
|
if err := db.QueryRow(`SELECT name,seq FROM sqlite_sequence WHERE name='seq'`).Scan(&seqName, &seqVal); err == nil {
|
|
out["autoincrement:seq"] = seqVal.String
|
|
} else {
|
|
out["autoincrement:seq"] = "ABSENT: " + err.Error()
|
|
}
|
|
|
|
for _, q := range []struct{ k, sql string }{
|
|
{"rows:seq", `SELECT group_concat(id||'='||v,',') FROM (SELECT id,v FROM seq ORDER BY id)`},
|
|
{"rows:norowid", `SELECT group_concat(k||'='||v,',') FROM (SELECT k,v FROM norowid ORDER BY k)`},
|
|
{"rows:gen", `SELECT group_concat(a||'+'||b||'='||s,',') FROM gen`},
|
|
{"rows:view", `SELECT group_concat(v,',') FROM seq_view`},
|
|
{"count:child", `SELECT count(*) FROM child`},
|
|
} {
|
|
var v sql.NullString
|
|
if err := db.QueryRow(q.sql).Scan(&v); err != nil {
|
|
out[q.k] = "ERROR: " + err.Error()
|
|
continue
|
|
}
|
|
out[q.k] = v.String
|
|
}
|
|
return out
|
|
}
|