From ba307c977c994d79679495bd423249d5cceb0ae8 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 30 Sep 2026 17:55:53 +0200 Subject: [PATCH] Check native and Wasm SQLite3MC open each other's files, under Node and in browsers --- .github/workflows/ci.yml | 41 ++++++++-- README.md | 2 +- interop.sh | 43 +++++++++++ smoke/Cargo.toml | 1 + smoke/src/bin/interop.rs | 77 +++++++++++++++++++ smoke/src/exchange.rs | 157 +++++++++++++++++++++++++++++++++++++++ wasm/Cargo.lock | 32 ++++++++ wasm/Cargo.toml | 8 +- wasm/build.rs | 35 +++++++++ wasm/src/lib.rs | 74 +++++++++++++++++- wasm/tests/encryption.rs | 109 +++++---------------------- wasm/tests/sahpool.rs | 31 ++++++++ 12 files changed, 508 insertions(+), 102 deletions(-) create mode 100755 interop.sh create mode 100644 smoke/src/bin/interop.rs create mode 100644 smoke/src/exchange.rs create mode 100644 wasm/build.rs create mode 100644 wasm/tests/sahpool.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c990deb..b385bd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - run: shellcheck -x upgrade.sh bump.sh sigstore.sh + - run: shellcheck -x upgrade.sh bump.sh sigstore.sh interop.sh - name: actionlint run: | bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/914e7df21a07ef503a81201c76d2b11c789d3fca/scripts/download-actionlint.bash) 1.7.12 @@ -121,8 +121,8 @@ jobs: - run: cargo run --manifest-path "$SMOKE_MANIFEST" shell: bash - wasm: - name: sqlite-wasm-rs builds the packaged amalgamation for Wasm + interop: + name: Native and Wasm SQLite3MC open each other's files runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -136,19 +136,44 @@ jobs: - uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2.87.19 with: tool: wasm-pack - # A C library function the released sqlite-wasm-rs does not provide fails this link. + # Both sides compile the unpacked .crate, and a C library function the released sqlite-wasm-rs does not provide fails the Wasm link. - name: Package and unpack run: | set -euo pipefail cargo package crate=$(echo target/package/sqlite3mc-src-*.crate) + dir="$PWD/target/package/$(basename "$crate" .crate)" tar -xzf "$crate" -C target/package - echo "SQLITE_WASM_RS_SOURCE_DIR=$PWD/target/package/$(basename "$crate" .crate)/sqlite3mc" >> "$GITHUB_ENV" - - run: wasm-pack test --node --release - working-directory: wasm + cp -r smoke "$dir/smoke" + echo "PACKAGE_DIR=$dir" >> "$GITHUB_ENV" + - run: ./interop.sh node + env: + INTEROP_CRATE: ${{ env.PACKAGE_DIR }} + WASM_BINDGEN_TEST_TIMEOUT: "300" # sqlite-wasm-rs vendors the same release, so only its build log shows which copy it compiled. - - run: grep -rqsF "rerun-if-changed=$SQLITE_WASM_RS_SOURCE_DIR" wasm/target/wasm32-unknown-unknown/release/build + - run: grep -rqsF "rerun-if-changed=$PACKAGE_DIR/sqlite3mc" wasm/target/wasm32-unknown-unknown/release/build - run: cargo clippy --manifest-path wasm/Cargo.toml --target wasm32-unknown-unknown --all-targets -- -D warnings + env: + SQLITE_WASM_RS_SOURCE_DIR: ${{ env.PACKAGE_DIR }}/sqlite3mc + + browsers: + name: Wasm SQLite3MC in Chrome and Firefox opens native files and writes files native opens, on OPFS too + runs-on: ubuntu-latest + env: + WASM_BINDGEN_TEST_TIMEOUT: "300" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable + targets: wasm32-unknown-unknown + - uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2.87.19 + with: + tool: wasm-pack + - run: ./interop.sh chrome + - run: ./interop.sh firefox rusqlite: name: rusqlite's SQLite3MC tests pass on these sources diff --git a/README.md b/README.md index 17b4d1d..9ad083e 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,4 @@ The version encodes the release, so `205.1.x` is SQLite3MC 2.5.1. A `205.1` requ SQLite3MC is MIT licensed. The amalgamation also carries public-domain code (SQLite among it), a password-hashing file under CC0-1.0, a block under the Unlicense, and Argon2 under CC0-1.0 or Apache-2.0. -A daily workflow in the [repository](https://github.com/LucaCappelletti94/sqlite3mc-src) opens a pull request for each new SQLite3MC release, taking the archive's checksum only from the release's Sigstore-signed `SHA256SUMS`. CI re-runs `upgrade.sh`, which checks that signature again before trusting the pinned checksum, to prove the vendored bytes match the pinned release. +A daily workflow in the [repository](https://github.com/LucaCappelletti94/sqlite3mc-src) opens a pull request for each new SQLite3MC release, taking the archive's checksum only from the release's Sigstore-signed `SHA256SUMS`. CI re-runs `upgrade.sh`, which checks that signature again before trusting the pinned checksum, to prove the vendored bytes match the pinned release. It also compiles the packaged sources natively and through `sqlite-wasm-rs`, and checks that each build opens the other's files in every cipher, under Node and in headless Chrome and Firefox, on OPFS too. diff --git a/interop.sh b/interop.sh new file mode 100755 index 0000000..8956e35 --- /dev/null +++ b/interop.sh @@ -0,0 +1,43 @@ +#!/bin/sh -e + +# Native SQLite3MC writes a file per cipher. Each runtime named (node when none is, chrome, firefox) runs the +# Wasm tests, which read those files and print each file they write, and native reads the printed files. +# Both sides compile the sources of $INTEROP_CRATE, an unpacked .crate with smoke/ copied in, or of this checkout. +cd "$(dirname "$0")" +crate=$(cd "${INTEROP_CRATE:-.}" && pwd) +# wasm/build.rs embeds everything in $fixtures, so what Wasm hands back goes to $returned. +fixtures="$PWD/target/interop" +returned="$PWD/target/interop-wasm" +export SQLITE_WASM_RS_SOURCE_DIR="$crate/sqlite3mc" +native() { + cargo run --release --manifest-path "$crate/smoke/Cargo.toml" --bin interop -- "$@" +} + +[ $# -gt 0 ] || set -- node +native write "$fixtures" +rm -rf "$returned" && mkdir -p "$returned" +for runtime; do + log="$returned/$runtime.log" + # sahpool skips itself under Node, which has no OPFS. + case $runtime in + node) + vfses=memvfs + run() { wasm-pack test --node --release --test encryption --test sahpool -- --nocapture; } + ;; + chrome | firefox) + vfses="memvfs opfs" + run() { WASM_BINDGEN_USE_BROWSER=1 wasm-pack test --headless "--$runtime" --release --test encryption --test sahpool -- --nocapture; } + ;; + *) echo "unknown runtime $runtime" >&2 && exit 1 ;; + esac + (cd wasm && run) >"$log" 2>&1 || { cat "$log" && exit 1; } + grep -v sqlite3mc-interop-file "$log" + # A file whose line is missing or cut short fails the native read. + sed -n 's/^.*sqlite3mc-interop-file \([a-z0-9-]*\) \([A-Za-z0-9+/=]*\) end$/\1 \2/p' "$log" | + while read -r name data; do + printf '%s' "$data" | base64 -d >"$returned/$runtime-$name.db" + done + for vfs in $vfses; do + native read "$returned" "$runtime-$vfs" + done +done diff --git a/smoke/Cargo.toml b/smoke/Cargo.toml index 89988f0..0a469f0 100644 --- a/smoke/Cargo.toml +++ b/smoke/Cargo.toml @@ -4,6 +4,7 @@ version = "0.0.0" edition = "2021" publish = false description = "Compiles and links the vendored amalgamation as a -sys crate would" +default-run = "sqlite3mc-src-smoke" [dependencies] sqlite3mc-src = { path = ".." } diff --git a/smoke/src/bin/interop.rs b/smoke/src/bin/interop.rs new file mode 100644 index 0000000..3cef981 --- /dev/null +++ b/smoke/src/bin/interop.rs @@ -0,0 +1,77 @@ +//! `write DIR` creates `native-.db` for every cipher, `read DIR PREFIX` checks the `PREFIX-.db` Wasm wrote. + +#[path = "../exchange.rs"] +mod exchange; + +#[expect( + non_camel_case_types, + reason = "SQLite's C names, as sqlite-wasm-rs spells them" +)] +mod ffi { + use std::ffi::{c_char, c_int, c_uchar, c_void}; + + pub enum sqlite3 {} + pub enum sqlite3_stmt {} + + pub const SQLITE_OK: c_int = 0; + pub const SQLITE_NOTADB: c_int = 26; + pub const SQLITE_ROW: c_int = 100; + pub const SQLITE_OPEN_READWRITE: c_int = 2; + pub const SQLITE_OPEN_CREATE: c_int = 4; + + type ExecCallback = + unsafe extern "C" fn(*mut c_void, c_int, *mut *mut c_char, *mut *mut c_char) -> c_int; + + unsafe extern "C" { + pub fn sqlite3_open_v2( + filename: *const c_char, + db: *mut *mut sqlite3, + flags: c_int, + vfs: *const c_char, + ) -> c_int; + pub fn sqlite3_close(db: *mut sqlite3) -> c_int; + pub fn sqlite3_exec( + db: *mut sqlite3, + sql: *const c_char, + callback: Option, + arg: *mut c_void, + errmsg: *mut *mut c_char, + ) -> c_int; + pub fn sqlite3_prepare_v2( + db: *mut sqlite3, + sql: *const c_char, + bytes: c_int, + stmt: *mut *mut sqlite3_stmt, + tail: *mut *const c_char, + ) -> c_int; + pub fn sqlite3_step(stmt: *mut sqlite3_stmt) -> c_int; + pub fn sqlite3_column_text(stmt: *mut sqlite3_stmt, column: c_int) -> *const c_uchar; + pub fn sqlite3_finalize(stmt: *mut sqlite3_stmt) -> c_int; + pub fn sqlite3_errmsg(db: *mut sqlite3) -> *const c_char; + } +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let (write, dir, prefix) = match args.as_slice() { + [command, dir] if command == "write" => (true, dir, "native"), + [command, dir, prefix] if command == "read" => (false, dir, prefix.as_str()), + _ => panic!("usage: interop write DIR | interop read DIR PREFIX"), + }; + let marker = if write { + // Files of an earlier run must never pass for this one's. + let _ = std::fs::remove_dir_all(dir); + std::fs::create_dir_all(dir).unwrap(); + exchange::NATIVE + } else { + exchange::WASM + }; + for (cipher, pragmas) in exchange::CIPHERS { + let name = format!("{dir}/{prefix}-{cipher}.db"); + if write { + exchange::write(&name, pragmas, marker); + } + let bytes = std::fs::read(&name).unwrap_or_else(|e| panic!("{name}: {e}")); + exchange::check(&name, &bytes, pragmas, marker); + } +} diff --git a/smoke/src/exchange.rs b/smoke/src/exchange.rs new file mode 100644 index 0000000..ef3ccd3 --- /dev/null +++ b/smoke/src/exchange.rs @@ -0,0 +1,157 @@ +//! The encrypted files native and Wasm SQLite3MC hand each other, and the checks both sides run on them. +//! The native `interop` binary and the Wasm tests include this file, each next to its own `ffi` module. + +use super::ffi; +use std::ffi::{c_int, CStr, CString}; + +const KEY: &str = "PRAGMA key = 'correct horse battery staple'"; +const WRONG_KEY: &str = "PRAGMA key = 'wrong horse battery staple'"; +/// Start of every row the native build writes. +pub const NATIVE: &str = "written natively"; +/// Start of every row the Wasm build writes. +pub const WASM: &str = "written by Wasm"; +/// Enough rows of about 100 bytes to fill several pages. +const ROWS: u32 = 400; +const SQLCIPHER: &str = "PRAGMA cipher = 'sqlcipher'"; + +/// Every cipher SQLite3MC ships, by file name, as the pragmas that select it before `PRAGMA key`. +/// A reader that skips the last pragma of a cipher must fail, which proves the pragmas took effect. +pub const CIPHERS: &[(&str, &[&str])] = &[ + ("chacha20", &[]), + ("aes128cbc", &["PRAGMA cipher = 'aes128cbc'"]), + ("aes256cbc", &["PRAGMA cipher = 'aes256cbc'"]), + ("sqlcipher1", &[SQLCIPHER, "PRAGMA legacy = 1"]), + ("sqlcipher2", &[SQLCIPHER, "PRAGMA legacy = 2"]), + ("sqlcipher3", &[SQLCIPHER, "PRAGMA legacy = 3"]), + ("sqlcipher4", &[SQLCIPHER, "PRAGMA legacy = 4"]), + ("rc4", &["PRAGMA cipher = 'rc4'"]), + ("ascon128", &["PRAGMA cipher = 'ascon128'"]), + ("aegis", &["PRAGMA cipher = 'aegis'"]), +]; + +/// A connection to `name` in the side's default VFS, which fails the test on any SQLite error. +pub struct Db(*mut ffi::sqlite3, String); + +impl Db { + /// Opens `name` read-write, creating it if missing, and runs `pragmas`. + /// + /// # Panics + /// + /// If opening or a pragma fails. + #[must_use] + pub fn open(name: &str, pragmas: &[&str]) -> Self { + let c_name = CString::new(name).unwrap(); + let mut handle = std::ptr::null_mut(); + let flags = ffi::SQLITE_OPEN_READWRITE | ffi::SQLITE_OPEN_CREATE; + // `c_name` outlives the call, and SQLite copies it. + let rc = + unsafe { ffi::sqlite3_open_v2(c_name.as_ptr(), &mut handle, flags, std::ptr::null()) }; + let db = Self(handle, name.to_owned()); + assert_eq!(rc, ffi::SQLITE_OK, "{name}: {}", db.error()); + for pragma in pragmas { + db.exec(pragma); + } + db + } + + /// Runs `sql` and returns SQLite's result code. + fn status(&self, sql: &str) -> c_int { + let sql = CString::new(sql).unwrap(); + // `sql` outlives the call, and no callback or error pointer is passed. + unsafe { + let (arg, errmsg) = (std::ptr::null_mut(), std::ptr::null_mut()); + ffi::sqlite3_exec(self.0, sql.as_ptr(), None, arg, errmsg) + } + } + + fn exec(&self, sql: &str) { + let rc = self.status(sql); + assert_eq!(rc, ffi::SQLITE_OK, "{}: {sql}: {}", self.1, self.error()); + } + + /// First column of the first row of `sql`, as text. + /// + /// # Panics + /// + /// If it fails or returns no text. + #[must_use] + pub fn text(&self, sql: &str) -> String { + let c_sql = CString::new(sql).unwrap(); + let mut stmt = std::ptr::null_mut(); + // `c_sql` outlives the call, which reads it up to its NUL. + let rc = unsafe { + ffi::sqlite3_prepare_v2(self.0, c_sql.as_ptr(), -1, &mut stmt, std::ptr::null_mut()) + }; + assert_eq!(rc, ffi::SQLITE_OK, "{}: {sql}: {}", self.1, self.error()); + // `stmt` was just prepared, and the text is copied before it is finalized. + let text = unsafe { + let value = if ffi::sqlite3_step(stmt) == ffi::SQLITE_ROW { + ffi::sqlite3_column_text(stmt, 0) + } else { + std::ptr::null() + }; + let text = (!value.is_null()) + .then(|| CStr::from_ptr(value.cast()).to_string_lossy().into_owned()); + let error = self.error(); + ffi::sqlite3_finalize(stmt); + text.ok_or(error) + }; + text.unwrap_or_else(|error| panic!("{}: {sql}: {error}", self.1)) + } + + fn error(&self) -> String { + // SQLite returns a NUL-terminated message for any handle, even a null one. + unsafe { CStr::from_ptr(ffi::sqlite3_errmsg(self.0)) } + .to_string_lossy() + .into_owned() + } +} + +impl Drop for Db { + fn drop(&mut self) { + // The handle came from `sqlite3_open_v2`, and every statement on it is finalized. + unsafe { ffi::sqlite3_close(self.0) }; + } +} + +/// Writes `name` in the format `pragmas` select, with rows starting with `marker`. +pub fn write(name: &str, pragmas: &[&str], marker: &str) { + Db::open(name, &[pragmas, &[KEY]].concat()).exec(&format!( + "CREATE TABLE t(v TEXT); + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < {ROWS}) + INSERT INTO t SELECT '{marker} ' || i || ' ' || hex(zeroblob(40)) FROM n;" + )); +} + +/// Asserts `bytes`, the file `name` holding rows that start with `marker`, is encrypted on every page, +/// and that it opens with its key and pragmas, but not with a wrong key or without its last pragma. +/// +/// # Panics +/// +/// If any of that does not hold. +pub fn check(name: &str, bytes: &[u8], pragmas: &[&str], marker: &str) { + assert!(bytes.len() > 16 * 1024, "{name} is {} bytes", bytes.len()); + for plain in [ + b"SQLite format 3".as_slice(), + b"CREATE TABLE", + marker.as_bytes(), + ] { + let leak = bytes.windows(plain.len()).any(|w| w == plain); + assert!(!leak, "{name} holds {:?}", String::from_utf8_lossy(plain)); + } + let db = Db::open(name, &[pragmas, &[KEY]].concat()); + assert_eq!(db.text("PRAGMA quick_check"), "ok", "{name}"); + let count = db.text(&format!("SELECT count(*) FROM t WHERE v LIKE '{marker} %'")); + assert_eq!(count, ROWS.to_string(), "{name}"); + let probe = "SELECT count(*) FROM sqlite_schema"; + let rc = Db::open(name, &[pragmas, &[WRONG_KEY]].concat()).status(probe); + assert_eq!(rc, ffi::SQLITE_NOTADB, "{name} opened with a wrong key"); + if let Some((_, fewer)) = pragmas.split_last() { + let rc = Db::open(name, &[fewer, &[KEY]].concat()).status(probe); + assert_eq!( + rc, + ffi::SQLITE_NOTADB, + "{name} opened without its last pragma" + ); + } +} diff --git a/wasm/Cargo.lock b/wasm/Cargo.lock index b8e8012..6329b07 100644 --- a/wasm/Cargo.lock +++ b/wasm/Cargo.lock @@ -19,6 +19,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bumpalo" version = "3.20.3" @@ -262,6 +268,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "sqlite-wasm-vfs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11859fbd5ce417c37e9e8c1993b55b9dc21ea7dd6452ba9097115f06e54a7e4a" +dependencies = [ + "futures-util", + "js-sys", + "rsqlite-vfs", + "thiserror", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "sqlite3mc-src" version = "205.1.0+sqlite3mc-2.5.1-sqlite-3.53.4" @@ -270,7 +290,9 @@ version = "205.1.0+sqlite3mc-2.5.1-sqlite-3.53.4" name = "sqlite3mc-src-wasm" version = "0.0.0" dependencies = [ + "base64", "sqlite-wasm-rs", + "sqlite-wasm-vfs", "sqlite3mc-src", "wasm-bindgen-test", ] @@ -426,6 +448,16 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 3d85b4f..964a7ee 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -3,10 +3,14 @@ name = "sqlite3mc-src-wasm" version = "0.0.0" edition = "2021" publish = false -description = "Builds sqlite-wasm-rs's sqlite3mc feature from the vendored amalgamation and exercises encryption" +description = "Builds sqlite-wasm-rs's sqlite3mc feature from the vendored amalgamation and exchanges encrypted files with the native build" -[dev-dependencies] +[dependencies] +base64 = "0.22" sqlite-wasm-rs = { version = "0.6", features = ["wasm-bindgen", "sqlite3mc"] } + +[dev-dependencies] +sqlite-wasm-vfs = { version = "0.3", features = ["sahpool"] } sqlite3mc-src = { path = ".." } wasm-bindgen-test = "0.3" diff --git a/wasm/build.rs b/wasm/build.rs new file mode 100644 index 0000000..4ac4e13 --- /dev/null +++ b/wasm/build.rs @@ -0,0 +1,35 @@ +//! Embeds the files the native interop binary wrote, since a browser has no filesystem to read them from. + +use std::fmt::Write as _; +use std::path::Path; + +fn main() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/interop"); + println!("cargo::rerun-if-changed={}", dir.display()); + // A missing file fails the test that wants it, never this build, so clippy runs without them. + let mut files: Vec<_> = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .starts_with("native-") + }) + .collect(); + files.sort(); + let mut table = String::from("pub const NATIVE_FILES: &[(&str, &[u8])] = &[\n"); + for path in files { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + writeln!( + table, + "({name:?}, include_bytes!({:?})),", + path.canonicalize().unwrap() + ) + .unwrap(); + } + table.push_str("];\n"); + let out = Path::new(&std::env::var_os("OUT_DIR").unwrap()).join("native_files.rs"); + std::fs::write(out, table).unwrap(); +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 931b38e..1fd2352 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -1 +1,73 @@ -//! Only its tests matter, which build sqlite-wasm-rs from the vendored amalgamation. +//! SQLite3MC as sqlite-wasm-rs builds it from this crate, and the checks its tests share. +//! +//! The files native SQLite3MC wrote come in through `build.rs`, and the files these tests write go back +//! as `sqlite3mc-interop-file` lines in the test output, since a browser has no filesystem. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use sqlite_wasm_rs as ffi; +use sqlite_wasm_rs::vfs::memvfs::MemVfsUtil; +use sqlite_wasm_rs::vfs::transfer::DbTransfer; +use std::fmt::Debug; + +#[path = "../../smoke/src/exchange.rs"] +pub mod exchange; + +include!(concat!(env!("OUT_DIR"), "/native_files.rs")); + +/// Initializes SQLite and returns the in-memory default VFS. +/// +/// # Panics +/// +/// If SQLite fails to initialize. +#[must_use] +pub fn memvfs() -> MemVfsUtil { + // A Wasm instance runs one thread, so nothing else calls into SQLite meanwhile. + assert_eq!(unsafe { ffi::sqlite3_initialize() }, ffi::SQLITE_OK); + // The default VFS stays registered for the life of the instance. + unsafe { MemVfsUtil::get() }.unwrap() +} + +/// Imports every `native-.db` into `vfs`, which backs the default VFS, and checks it. +/// +/// # Panics +/// +/// If a native file is missing or fails a check. +pub fn read_native_files(vfs: &T) +where + T::Error: Debug, +{ + for (cipher, pragmas) in exchange::CIPHERS { + let name = format!("native-{cipher}.db"); + let (_, bytes) = NATIVE_FILES + .iter() + .find(|(file, _)| *file == name) + .unwrap_or_else(|| panic!("no {name} in target/interop, which interop.sh fills")); + vfs.import_db_unchecked(&name, bytes).unwrap(); + exchange::check(&name, bytes, pragmas, exchange::NATIVE); + } +} + +/// Writes and checks `wasm-.db` for every cipher through the default VFS, which `vfs` backs, +/// and returns a `sqlite3mc-interop-file - end` line for each. +/// +/// # Panics +/// +/// If a file leaks plaintext or does not read back. +pub fn write_every_cipher(vfs: &T, vfs_label: &str) -> Vec +where + T::Error: Debug, +{ + let mut lines = Vec::new(); + for (cipher, pragmas) in exchange::CIPHERS { + let name = format!("wasm-{cipher}.db"); + exchange::write(&name, pragmas, exchange::WASM); + let bytes = vfs.export_db(&name).unwrap(); + exchange::check(&name, &bytes, pragmas, exchange::WASM); + let data = STANDARD.encode(bytes); + lines.push(format!( + "sqlite3mc-interop-file {vfs_label}-{cipher} {data} end" + )); + } + lines +} diff --git a/wasm/tests/encryption.rs b/wasm/tests/encryption.rs index 549ae11..c9b211f 100644 --- a/wasm/tests/encryption.rs +++ b/wasm/tests/encryption.rs @@ -1,98 +1,27 @@ -//! SQLite3MC built by sqlite-wasm-rs from this crate encrypts, reopens, and rejects a wrong key. -use sqlite_wasm_rs as ffi; -use sqlite_wasm_rs::vfs::memvfs::MemVfsUtil; -use sqlite_wasm_rs::vfs::transfer::DbTransfer; -use std::ffi::{CStr, CString}; -use wasm_bindgen_test::wasm_bindgen_test; +//! SQLite3MC built by sqlite-wasm-rs from this crate is the vendored release, reads every native file, +//! and writes one per cipher for native to read. +use sqlite3mc_src_wasm::exchange::Db; +use sqlite3mc_src_wasm::{memvfs, read_native_files, write_every_cipher}; +use wasm_bindgen_test::{console_log, wasm_bindgen_test}; -struct Db(*mut ffi::sqlite3); - -impl Db { - fn open(name: &str) -> Self { - let name = CString::new(name).unwrap(); - let mut db = std::ptr::null_mut(); - let flags = ffi::SQLITE_OPEN_READWRITE | ffi::SQLITE_OPEN_CREATE; - let rc = unsafe { ffi::sqlite3_open_v2(name.as_ptr(), &mut db, flags, std::ptr::null()) }; - assert_eq!(rc, ffi::SQLITE_OK); - Self(db) - } - - fn exec(&self, sql: &str) -> i32 { - let sql = CString::new(sql).unwrap(); - unsafe { - ffi::sqlite3_exec( - self.0, - sql.as_ptr(), - None, - std::ptr::null_mut(), - std::ptr::null_mut(), - ) - } - } - - fn text(&self, sql: &str) -> Option { - let sql = CString::new(sql).unwrap(); - let mut stmt = std::ptr::null_mut(); - unsafe { - let rc = - ffi::sqlite3_prepare_v2(self.0, sql.as_ptr(), -1, &mut stmt, std::ptr::null_mut()); - if rc != ffi::SQLITE_OK { - return None; - } - let row = (ffi::sqlite3_step(stmt) == ffi::SQLITE_ROW).then(|| { - CStr::from_ptr(ffi::sqlite3_column_text(stmt, 0).cast()) - .to_string_lossy() - .into_owned() - }); - ffi::sqlite3_finalize(stmt); - row - } - } +#[wasm_bindgen_test] +fn compiles_the_vendored_release() { + let _vfs = memvfs(); + let version = Db::open("version.db", &[]).text("SELECT sqlite3mc_version()"); + assert!( + version.ends_with(sqlite3mc_src::SQLITE3MC_VERSION), + "{version}" + ); } -impl Drop for Db { - fn drop(&mut self) { - unsafe { ffi::sqlite3_close(self.0) }; - } +#[wasm_bindgen_test] +fn reads_every_native_file() { + read_native_files(&memvfs()); } #[wasm_bindgen_test] -fn sqlite3mc_encrypts_and_rejects_a_wrong_key() { - assert_eq!(unsafe { ffi::sqlite3_initialize() }, ffi::SQLITE_OK); - let util = unsafe { MemVfsUtil::get() }.unwrap(); - { - let db = Db::open("mc.db"); - let version = db.text("SELECT sqlite3mc_version()").unwrap(); - assert!( - version.ends_with(sqlite3mc_src::SQLITE3MC_VERSION), - "{version}" - ); - assert_eq!( - db.exec("PRAGMA key = 'correct horse battery staple'"), - ffi::SQLITE_OK - ); - let rc = db.exec("CREATE TABLE t(v TEXT); INSERT INTO t VALUES ('sqlite3mc-secret')"); - assert_eq!(rc, ffi::SQLITE_OK); +fn writes_every_cipher_for_native() { + for line in write_every_cipher(&memvfs(), "memvfs") { + console_log!("{line}"); } - let bytes = util.export_db("mc.db").unwrap(); - assert!(!bytes.is_empty()); - assert!(!bytes.starts_with(b"SQLite format 3")); - assert!(!bytes.windows(16).any(|w| w == b"sqlite3mc-secret")); - - let right = Db::open("mc.db"); - assert_eq!( - right.exec("PRAGMA key = 'correct horse battery staple'"), - ffi::SQLITE_OK - ); - assert_eq!( - right.text("SELECT v FROM t").as_deref(), - Some("sqlite3mc-secret") - ); - - let wrong = Db::open("mc.db"); - assert_eq!(wrong.exec("PRAGMA key = 'wrong'"), ffi::SQLITE_OK); - assert_eq!( - wrong.exec("SELECT count(*) FROM sqlite_schema"), - ffi::SQLITE_NOTADB - ); } diff --git a/wasm/tests/sahpool.rs b/wasm/tests/sahpool.rs new file mode 100644 index 0000000..e113067 --- /dev/null +++ b/wasm/tests/sahpool.rs @@ -0,0 +1,31 @@ +//! The native exchange on the persistent OPFS `sahpool` VFS, which exists only in a browser's dedicated worker. +use sqlite3mc_src_wasm::exchange::CIPHERS; +use sqlite3mc_src_wasm::{read_native_files, write_every_cipher}; +use sqlite_wasm_rs::{self as ffi, WasmOsCallback}; +use sqlite_wasm_vfs::sahpool::{install, OpfsSAHPoolCfgBuilder}; +use wasm_bindgen_test::{console_log, wasm_bindgen_test}; + +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker); + +#[wasm_bindgen_test] +#[expect( + clippy::future_not_send, + reason = "the pool holds an Rc, and a Wasm worker has one thread" +)] +async fn exchanges_every_cipher_on_opfs() { + let cfg = OpfsSAHPoolCfgBuilder::new() + .vfs_name("sahpool") + .directory("sqlite3mc-src") + .clear_on_init(true) + // Room for the files of both sides, each with its rollback journal. + .initial_capacity(4 * CIPHERS.len()) + .build(); + let pool = install::(&cfg, false).await.unwrap(); + // SQLite3MC encrypts only through its own VFS wrapped around the real one, made the default here. + let rc = unsafe { ffi::sqlite3mc_vfs_create(c"sahpool".as_ptr(), 1) }; + assert_eq!(rc, ffi::SQLITE_OK); + read_native_files(&pool); + for line in write_every_cipher(&pool, "opfs") { + console_log!("{line}"); + } +}