Skip to content

Commit ba307c9

Browse files
Check native and Wasm SQLite3MC open each other's files, under Node and in browsers
1 parent 8fe5535 commit ba307c9

12 files changed

Lines changed: 508 additions & 102 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ jobs:
6565
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
6666
with:
6767
persist-credentials: false
68-
- run: shellcheck -x upgrade.sh bump.sh sigstore.sh
68+
- run: shellcheck -x upgrade.sh bump.sh sigstore.sh interop.sh
6969
- name: actionlint
7070
run: |
7171
bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/914e7df21a07ef503a81201c76d2b11c789d3fca/scripts/download-actionlint.bash) 1.7.12
@@ -121,8 +121,8 @@ jobs:
121121
- run: cargo run --manifest-path "$SMOKE_MANIFEST"
122122
shell: bash
123123

124-
wasm:
125-
name: sqlite-wasm-rs builds the packaged amalgamation for Wasm
124+
interop:
125+
name: Native and Wasm SQLite3MC open each other's files
126126
runs-on: ubuntu-latest
127127
steps:
128128
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -136,19 +136,44 @@ jobs:
136136
- uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2.87.19
137137
with:
138138
tool: wasm-pack
139-
# A C library function the released sqlite-wasm-rs does not provide fails this link.
139+
# Both sides compile the unpacked .crate, and a C library function the released sqlite-wasm-rs does not provide fails the Wasm link.
140140
- name: Package and unpack
141141
run: |
142142
set -euo pipefail
143143
cargo package
144144
crate=$(echo target/package/sqlite3mc-src-*.crate)
145+
dir="$PWD/target/package/$(basename "$crate" .crate)"
145146
tar -xzf "$crate" -C target/package
146-
echo "SQLITE_WASM_RS_SOURCE_DIR=$PWD/target/package/$(basename "$crate" .crate)/sqlite3mc" >> "$GITHUB_ENV"
147-
- run: wasm-pack test --node --release
148-
working-directory: wasm
147+
cp -r smoke "$dir/smoke"
148+
echo "PACKAGE_DIR=$dir" >> "$GITHUB_ENV"
149+
- run: ./interop.sh node
150+
env:
151+
INTEROP_CRATE: ${{ env.PACKAGE_DIR }}
152+
WASM_BINDGEN_TEST_TIMEOUT: "300"
149153
# sqlite-wasm-rs vendors the same release, so only its build log shows which copy it compiled.
150-
- run: grep -rqsF "rerun-if-changed=$SQLITE_WASM_RS_SOURCE_DIR" wasm/target/wasm32-unknown-unknown/release/build
154+
- run: grep -rqsF "rerun-if-changed=$PACKAGE_DIR/sqlite3mc" wasm/target/wasm32-unknown-unknown/release/build
151155
- run: cargo clippy --manifest-path wasm/Cargo.toml --target wasm32-unknown-unknown --all-targets -- -D warnings
156+
env:
157+
SQLITE_WASM_RS_SOURCE_DIR: ${{ env.PACKAGE_DIR }}/sqlite3mc
158+
159+
browsers:
160+
name: Wasm SQLite3MC in Chrome and Firefox opens native files and writes files native opens, on OPFS too
161+
runs-on: ubuntu-latest
162+
env:
163+
WASM_BINDGEN_TEST_TIMEOUT: "300"
164+
steps:
165+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
166+
with:
167+
persist-credentials: false
168+
- uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master
169+
with:
170+
toolchain: stable
171+
targets: wasm32-unknown-unknown
172+
- uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2.87.19
173+
with:
174+
tool: wasm-pack
175+
- run: ./interop.sh chrome
176+
- run: ./interop.sh firefox
152177

153178
rusqlite:
154179
name: rusqlite's SQLite3MC tests pass on these sources

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ The version encodes the release, so `205.1.x` is SQLite3MC 2.5.1. A `205.1` requ
1919

2020
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.
2121

22-
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.
22+
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.

‎interop.sh‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/bin/sh -e
2+
3+
# Native SQLite3MC writes a file per cipher. Each runtime named (node when none is, chrome, firefox) runs the
4+
# Wasm tests, which read those files and print each file they write, and native reads the printed files.
5+
# Both sides compile the sources of $INTEROP_CRATE, an unpacked .crate with smoke/ copied in, or of this checkout.
6+
cd "$(dirname "$0")"
7+
crate=$(cd "${INTEROP_CRATE:-.}" && pwd)
8+
# wasm/build.rs embeds everything in $fixtures, so what Wasm hands back goes to $returned.
9+
fixtures="$PWD/target/interop"
10+
returned="$PWD/target/interop-wasm"
11+
export SQLITE_WASM_RS_SOURCE_DIR="$crate/sqlite3mc"
12+
native() {
13+
cargo run --release --manifest-path "$crate/smoke/Cargo.toml" --bin interop -- "$@"
14+
}
15+
16+
[ $# -gt 0 ] || set -- node
17+
native write "$fixtures"
18+
rm -rf "$returned" && mkdir -p "$returned"
19+
for runtime; do
20+
log="$returned/$runtime.log"
21+
# sahpool skips itself under Node, which has no OPFS.
22+
case $runtime in
23+
node)
24+
vfses=memvfs
25+
run() { wasm-pack test --node --release --test encryption --test sahpool -- --nocapture; }
26+
;;
27+
chrome | firefox)
28+
vfses="memvfs opfs"
29+
run() { WASM_BINDGEN_USE_BROWSER=1 wasm-pack test --headless "--$runtime" --release --test encryption --test sahpool -- --nocapture; }
30+
;;
31+
*) echo "unknown runtime $runtime" >&2 && exit 1 ;;
32+
esac
33+
(cd wasm && run) >"$log" 2>&1 || { cat "$log" && exit 1; }
34+
grep -v sqlite3mc-interop-file "$log"
35+
# A file whose line is missing or cut short fails the native read.
36+
sed -n 's/^.*sqlite3mc-interop-file \([a-z0-9-]*\) \([A-Za-z0-9+/=]*\) end$/\1 \2/p' "$log" |
37+
while read -r name data; do
38+
printf '%s' "$data" | base64 -d >"$returned/$runtime-$name.db"
39+
done
40+
for vfs in $vfses; do
41+
native read "$returned" "$runtime-$vfs"
42+
done
43+
done

‎smoke/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ version = "0.0.0"
44
edition = "2021"
55
publish = false
66
description = "Compiles and links the vendored amalgamation as a -sys crate would"
7+
default-run = "sqlite3mc-src-smoke"
78

89
[dependencies]
910
sqlite3mc-src = { path = ".." }

‎smoke/src/bin/interop.rs‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//! `write DIR` creates `native-<cipher>.db` for every cipher, `read DIR PREFIX` checks the `PREFIX-<cipher>.db` Wasm wrote.
2+
3+
#[path = "../exchange.rs"]
4+
mod exchange;
5+
6+
#[expect(
7+
non_camel_case_types,
8+
reason = "SQLite's C names, as sqlite-wasm-rs spells them"
9+
)]
10+
mod ffi {
11+
use std::ffi::{c_char, c_int, c_uchar, c_void};
12+
13+
pub enum sqlite3 {}
14+
pub enum sqlite3_stmt {}
15+
16+
pub const SQLITE_OK: c_int = 0;
17+
pub const SQLITE_NOTADB: c_int = 26;
18+
pub const SQLITE_ROW: c_int = 100;
19+
pub const SQLITE_OPEN_READWRITE: c_int = 2;
20+
pub const SQLITE_OPEN_CREATE: c_int = 4;
21+
22+
type ExecCallback =
23+
unsafe extern "C" fn(*mut c_void, c_int, *mut *mut c_char, *mut *mut c_char) -> c_int;
24+
25+
unsafe extern "C" {
26+
pub fn sqlite3_open_v2(
27+
filename: *const c_char,
28+
db: *mut *mut sqlite3,
29+
flags: c_int,
30+
vfs: *const c_char,
31+
) -> c_int;
32+
pub fn sqlite3_close(db: *mut sqlite3) -> c_int;
33+
pub fn sqlite3_exec(
34+
db: *mut sqlite3,
35+
sql: *const c_char,
36+
callback: Option<ExecCallback>,
37+
arg: *mut c_void,
38+
errmsg: *mut *mut c_char,
39+
) -> c_int;
40+
pub fn sqlite3_prepare_v2(
41+
db: *mut sqlite3,
42+
sql: *const c_char,
43+
bytes: c_int,
44+
stmt: *mut *mut sqlite3_stmt,
45+
tail: *mut *const c_char,
46+
) -> c_int;
47+
pub fn sqlite3_step(stmt: *mut sqlite3_stmt) -> c_int;
48+
pub fn sqlite3_column_text(stmt: *mut sqlite3_stmt, column: c_int) -> *const c_uchar;
49+
pub fn sqlite3_finalize(stmt: *mut sqlite3_stmt) -> c_int;
50+
pub fn sqlite3_errmsg(db: *mut sqlite3) -> *const c_char;
51+
}
52+
}
53+
54+
fn main() {
55+
let args: Vec<String> = std::env::args().skip(1).collect();
56+
let (write, dir, prefix) = match args.as_slice() {
57+
[command, dir] if command == "write" => (true, dir, "native"),
58+
[command, dir, prefix] if command == "read" => (false, dir, prefix.as_str()),
59+
_ => panic!("usage: interop write DIR | interop read DIR PREFIX"),
60+
};
61+
let marker = if write {
62+
// Files of an earlier run must never pass for this one's.
63+
let _ = std::fs::remove_dir_all(dir);
64+
std::fs::create_dir_all(dir).unwrap();
65+
exchange::NATIVE
66+
} else {
67+
exchange::WASM
68+
};
69+
for (cipher, pragmas) in exchange::CIPHERS {
70+
let name = format!("{dir}/{prefix}-{cipher}.db");
71+
if write {
72+
exchange::write(&name, pragmas, marker);
73+
}
74+
let bytes = std::fs::read(&name).unwrap_or_else(|e| panic!("{name}: {e}"));
75+
exchange::check(&name, &bytes, pragmas, marker);
76+
}
77+
}

‎smoke/src/exchange.rs‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//! The encrypted files native and Wasm SQLite3MC hand each other, and the checks both sides run on them.
2+
//! The native `interop` binary and the Wasm tests include this file, each next to its own `ffi` module.
3+
4+
use super::ffi;
5+
use std::ffi::{c_int, CStr, CString};
6+
7+
const KEY: &str = "PRAGMA key = 'correct horse battery staple'";
8+
const WRONG_KEY: &str = "PRAGMA key = 'wrong horse battery staple'";
9+
/// Start of every row the native build writes.
10+
pub const NATIVE: &str = "written natively";
11+
/// Start of every row the Wasm build writes.
12+
pub const WASM: &str = "written by Wasm";
13+
/// Enough rows of about 100 bytes to fill several pages.
14+
const ROWS: u32 = 400;
15+
const SQLCIPHER: &str = "PRAGMA cipher = 'sqlcipher'";
16+
17+
/// Every cipher SQLite3MC ships, by file name, as the pragmas that select it before `PRAGMA key`.
18+
/// A reader that skips the last pragma of a cipher must fail, which proves the pragmas took effect.
19+
pub const CIPHERS: &[(&str, &[&str])] = &[
20+
("chacha20", &[]),
21+
("aes128cbc", &["PRAGMA cipher = 'aes128cbc'"]),
22+
("aes256cbc", &["PRAGMA cipher = 'aes256cbc'"]),
23+
("sqlcipher1", &[SQLCIPHER, "PRAGMA legacy = 1"]),
24+
("sqlcipher2", &[SQLCIPHER, "PRAGMA legacy = 2"]),
25+
("sqlcipher3", &[SQLCIPHER, "PRAGMA legacy = 3"]),
26+
("sqlcipher4", &[SQLCIPHER, "PRAGMA legacy = 4"]),
27+
("rc4", &["PRAGMA cipher = 'rc4'"]),
28+
("ascon128", &["PRAGMA cipher = 'ascon128'"]),
29+
("aegis", &["PRAGMA cipher = 'aegis'"]),
30+
];
31+
32+
/// A connection to `name` in the side's default VFS, which fails the test on any SQLite error.
33+
pub struct Db(*mut ffi::sqlite3, String);
34+
35+
impl Db {
36+
/// Opens `name` read-write, creating it if missing, and runs `pragmas`.
37+
///
38+
/// # Panics
39+
///
40+
/// If opening or a pragma fails.
41+
#[must_use]
42+
pub fn open(name: &str, pragmas: &[&str]) -> Self {
43+
let c_name = CString::new(name).unwrap();
44+
let mut handle = std::ptr::null_mut();
45+
let flags = ffi::SQLITE_OPEN_READWRITE | ffi::SQLITE_OPEN_CREATE;
46+
// `c_name` outlives the call, and SQLite copies it.
47+
let rc =
48+
unsafe { ffi::sqlite3_open_v2(c_name.as_ptr(), &mut handle, flags, std::ptr::null()) };
49+
let db = Self(handle, name.to_owned());
50+
assert_eq!(rc, ffi::SQLITE_OK, "{name}: {}", db.error());
51+
for pragma in pragmas {
52+
db.exec(pragma);
53+
}
54+
db
55+
}
56+
57+
/// Runs `sql` and returns SQLite's result code.
58+
fn status(&self, sql: &str) -> c_int {
59+
let sql = CString::new(sql).unwrap();
60+
// `sql` outlives the call, and no callback or error pointer is passed.
61+
unsafe {
62+
let (arg, errmsg) = (std::ptr::null_mut(), std::ptr::null_mut());
63+
ffi::sqlite3_exec(self.0, sql.as_ptr(), None, arg, errmsg)
64+
}
65+
}
66+
67+
fn exec(&self, sql: &str) {
68+
let rc = self.status(sql);
69+
assert_eq!(rc, ffi::SQLITE_OK, "{}: {sql}: {}", self.1, self.error());
70+
}
71+
72+
/// First column of the first row of `sql`, as text.
73+
///
74+
/// # Panics
75+
///
76+
/// If it fails or returns no text.
77+
#[must_use]
78+
pub fn text(&self, sql: &str) -> String {
79+
let c_sql = CString::new(sql).unwrap();
80+
let mut stmt = std::ptr::null_mut();
81+
// `c_sql` outlives the call, which reads it up to its NUL.
82+
let rc = unsafe {
83+
ffi::sqlite3_prepare_v2(self.0, c_sql.as_ptr(), -1, &mut stmt, std::ptr::null_mut())
84+
};
85+
assert_eq!(rc, ffi::SQLITE_OK, "{}: {sql}: {}", self.1, self.error());
86+
// `stmt` was just prepared, and the text is copied before it is finalized.
87+
let text = unsafe {
88+
let value = if ffi::sqlite3_step(stmt) == ffi::SQLITE_ROW {
89+
ffi::sqlite3_column_text(stmt, 0)
90+
} else {
91+
std::ptr::null()
92+
};
93+
let text = (!value.is_null())
94+
.then(|| CStr::from_ptr(value.cast()).to_string_lossy().into_owned());
95+
let error = self.error();
96+
ffi::sqlite3_finalize(stmt);
97+
text.ok_or(error)
98+
};
99+
text.unwrap_or_else(|error| panic!("{}: {sql}: {error}", self.1))
100+
}
101+
102+
fn error(&self) -> String {
103+
// SQLite returns a NUL-terminated message for any handle, even a null one.
104+
unsafe { CStr::from_ptr(ffi::sqlite3_errmsg(self.0)) }
105+
.to_string_lossy()
106+
.into_owned()
107+
}
108+
}
109+
110+
impl Drop for Db {
111+
fn drop(&mut self) {
112+
// The handle came from `sqlite3_open_v2`, and every statement on it is finalized.
113+
unsafe { ffi::sqlite3_close(self.0) };
114+
}
115+
}
116+
117+
/// Writes `name` in the format `pragmas` select, with rows starting with `marker`.
118+
pub fn write(name: &str, pragmas: &[&str], marker: &str) {
119+
Db::open(name, &[pragmas, &[KEY]].concat()).exec(&format!(
120+
"CREATE TABLE t(v TEXT);
121+
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < {ROWS})
122+
INSERT INTO t SELECT '{marker} ' || i || ' ' || hex(zeroblob(40)) FROM n;"
123+
));
124+
}
125+
126+
/// Asserts `bytes`, the file `name` holding rows that start with `marker`, is encrypted on every page,
127+
/// and that it opens with its key and pragmas, but not with a wrong key or without its last pragma.
128+
///
129+
/// # Panics
130+
///
131+
/// If any of that does not hold.
132+
pub fn check(name: &str, bytes: &[u8], pragmas: &[&str], marker: &str) {
133+
assert!(bytes.len() > 16 * 1024, "{name} is {} bytes", bytes.len());
134+
for plain in [
135+
b"SQLite format 3".as_slice(),
136+
b"CREATE TABLE",
137+
marker.as_bytes(),
138+
] {
139+
let leak = bytes.windows(plain.len()).any(|w| w == plain);
140+
assert!(!leak, "{name} holds {:?}", String::from_utf8_lossy(plain));
141+
}
142+
let db = Db::open(name, &[pragmas, &[KEY]].concat());
143+
assert_eq!(db.text("PRAGMA quick_check"), "ok", "{name}");
144+
let count = db.text(&format!("SELECT count(*) FROM t WHERE v LIKE '{marker} %'"));
145+
assert_eq!(count, ROWS.to_string(), "{name}");
146+
let probe = "SELECT count(*) FROM sqlite_schema";
147+
let rc = Db::open(name, &[pragmas, &[WRONG_KEY]].concat()).status(probe);
148+
assert_eq!(rc, ffi::SQLITE_NOTADB, "{name} opened with a wrong key");
149+
if let Some((_, fewer)) = pragmas.split_last() {
150+
let rc = Db::open(name, &[fewer, &[KEY]].concat()).status(probe);
151+
assert_eq!(
152+
rc,
153+
ffi::SQLITE_NOTADB,
154+
"{name} opened without its last pragma"
155+
);
156+
}
157+
}

0 commit comments

Comments
 (0)