diff --git a/README.md b/README.md index 8e53be7..d9a792c 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,4 @@ SQLite3MC is MIT licensed. The amalgamation also carries public-domain code (SQL 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. -CI also runs SQLite3MC's own tests, a rekey of every cipher and SQLite's TCL suite through the shipped amalgamation, once plainly and once under the address and undefined-behaviour sanitizers, with every expected failure named and explained. Six fuzz targets in `fuzz/` run on every pull request and daily through `ClusterFuzzLite`: a self-consistency check across the six native cipher schemes, and dedicated targets for rekeying, `journal_mode=WAL`, `ATTACH DATABASE`, the `sqlite3_backup_*` API, and shared-cache mode. +CI also runs SQLite3MC's own tests, a rekey of every cipher and SQLite's TCL suite through the shipped amalgamation, once plainly and once under the address and undefined-behaviour sanitizers, with every expected failure named and explained. Five fuzz targets in `fuzz/` run on every pull request and daily through `ClusterFuzzLite`: a self-consistency check across the six native cipher schemes, and dedicated targets for rekeying, `journal_mode=WAL`, `ATTACH DATABASE` and the `sqlite3_backup_*` API. Shared-cache mode is left out, since SQLite discourages it and SQLite3MC supports it only as legacy ([#290](https://github.com/utelle/SQLite3MultipleCiphers/issues/290)). diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 6fdd5f7..f1ebe76 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -3,7 +3,7 @@ name = "sqlite3mc-src-fuzz" version = "0.0.0" edition = "2021" publish = false -description = "Fuzzing SQLite3MC's own cipher schemes for self-consistency, its rekey support, its WAL journal mode, its ATTACH DATABASE support, its backup API, and its shared-cache mode" +description = "Fuzzing SQLite3MC's own cipher schemes for self-consistency, its rekey support, its WAL journal mode, its ATTACH DATABASE support, and its backup API" [package.metadata] cargo-fuzz = true @@ -51,13 +51,6 @@ test = false doc = false bench = false -[[bin]] -name = "shared_cache" -path = "fuzz_targets/shared_cache.rs" -test = false -doc = false -bench = false - # The crate's table, minus unsafe_code (FFI) and missing_docs and cargo (never published). [lints.rust] rust_2018_idioms = { level = "warn", priority = -1 } diff --git a/fuzz/corpus/shared_cache/all-siblings-correct-key b/fuzz/corpus/shared_cache/all-siblings-correct-key deleted file mode 100644 index 471ffcd..0000000 Binary files a/fuzz/corpus/shared_cache/all-siblings-correct-key and /dev/null differ diff --git a/fuzz/corpus/shared_cache/many-siblings b/fuzz/corpus/shared_cache/many-siblings deleted file mode 100644 index 510ffd4..0000000 Binary files a/fuzz/corpus/shared_cache/many-siblings and /dev/null differ diff --git a/fuzz/corpus/shared_cache/mixed-correct-and-wrong b/fuzz/corpus/shared_cache/mixed-correct-and-wrong deleted file mode 100644 index c4d9adb..0000000 Binary files a/fuzz/corpus/shared_cache/mixed-correct-and-wrong and /dev/null differ diff --git a/fuzz/corpus/shared_cache/no-siblings b/fuzz/corpus/shared_cache/no-siblings deleted file mode 100644 index f3c7c60..0000000 --- a/fuzz/corpus/shared_cache/no-siblings +++ /dev/null @@ -1,2 +0,0 @@ -]ÄÙ -רèí]xÁr‰40Å~“�bÿ £)“N˜l “Aþ·Öäò@àt®tF§òÒ!Y«™°^‹·•PtÒù \ No newline at end of file diff --git a/fuzz/corpus/shared_cache/one-sibling-correct-key b/fuzz/corpus/shared_cache/one-sibling-correct-key deleted file mode 100644 index 4b4ffdb..0000000 Binary files a/fuzz/corpus/shared_cache/one-sibling-correct-key and /dev/null differ diff --git a/fuzz/corpus/shared_cache/one-sibling-wrong-key b/fuzz/corpus/shared_cache/one-sibling-wrong-key deleted file mode 100644 index 4226697..0000000 Binary files a/fuzz/corpus/shared_cache/one-sibling-wrong-key and /dev/null differ diff --git a/fuzz/corpus/shared_cache/sibling-has-ops b/fuzz/corpus/shared_cache/sibling-has-ops deleted file mode 100644 index da2da4f..0000000 Binary files a/fuzz/corpus/shared_cache/sibling-has-ops and /dev/null differ diff --git a/fuzz/examples/gen_shared_cache_seeds.rs b/fuzz/examples/gen_shared_cache_seeds.rs deleted file mode 100644 index ec9046a..0000000 --- a/fuzz/examples/gen_shared_cache_seeds.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Builds `corpus/shared_cache` the same way `gen_backup_seeds` builds `corpus/backup`: rejection -//! sampling through the real `Arbitrary` impl, keeping the first *clean* draw that lands on each -//! target parameter family. A draw that panics is never committed to `corpus/shared_cache` (it -//! would permanently fail the replay test); the first one found per family is saved to -//! `corpus/findings/` instead, which nothing replays, and the search continues past it for a -//! clean representative. - -use std::path::Path; - -use sqlite3mc_src_fuzz::{ - run_shared_cache, - seedgen::{run_search, DivergencePolicy, Family, Rng}, - SharedCacheCase, SiblingKey, -}; - -const fn has_content(case: &SharedCacheCase) -> bool { - !case.main_ops.is_empty() -} - -const fn shared(key: &SiblingKey) -> bool { - matches!(key, SiblingKey::Shared) -} - -fn families() -> &'static [Family] { - &[ - Family { - name: "no-siblings", - matches: |c| c.siblings.is_empty(), - }, - Family { - name: "one-sibling-correct-key", - matches: |c| c.siblings.len() == 1 && shared(&c.siblings[0].key) && has_content(c), - }, - Family { - name: "one-sibling-wrong-key", - matches: |c| c.siblings.len() == 1 && !shared(&c.siblings[0].key) && has_content(c), - }, - Family { - name: "many-siblings", - matches: |c| c.siblings.len() >= 2 && has_content(c), - }, - Family { - name: "sibling-has-ops", - matches: |c| c.siblings.iter().any(|s| !s.ops.is_empty()) && has_content(c), - }, - Family { - name: "all-siblings-correct-key", - matches: |c| { - !c.siblings.is_empty() - && c.siblings.iter().all(|s| shared(&s.key)) - && has_content(c) - }, - }, - Family { - name: "mixed-correct-and-wrong", - matches: |c| { - c.siblings.len() >= 2 - && c.siblings.iter().any(|s| shared(&s.key)) - && c.siblings.iter().any(|s| !shared(&s.key)) - && has_content(c) - }, - }, - ] -} - -fn main() { - let dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/corpus/shared_cache")); - let findings_dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/corpus/findings")); - run_search( - families(), - Rng::new(0x5C00_0000_5C00_0001), - dir, - 8000, - 60_000, - run_shared_cache, - &DivergencePolicy::QuarantineTo { - dir: findings_dir, - prefix: "shared-cache-", - }, - ); -} diff --git a/fuzz/fuzz_targets/shared_cache.rs b/fuzz/fuzz_targets/shared_cache.rs deleted file mode 100755 index 93619e8..0000000 --- a/fuzz/fuzz_targets/shared_cache.rs +++ /dev/null @@ -1,5 +0,0 @@ -#![no_main] - -libfuzzer_sys::fuzz_target!(|case: sqlite3mc_src_fuzz::SharedCacheCase| { - sqlite3mc_src_fuzz::run_shared_cache(&case); -}); diff --git a/fuzz/src/db.rs b/fuzz/src/db.rs index 0ad724c..138a7bf 100644 --- a/fuzz/src/db.rs +++ b/fuzz/src/db.rs @@ -53,13 +53,6 @@ pub(crate) enum Param<'a> { Blob(&'a [u8]), } -/// `sqlite3_enable_shared_cache`: process-wide, so every connection opened after the call -/// defaults to shared-cache mode. -pub(crate) fn enable_shared_cache(enable: bool) -> Result<(), Code> { - // SAFETY: takes a plain `c_int`, touches no pointers, safe to call at any time. - check(unsafe { ffi::sqlite3_enable_shared_cache(c_int::from(enable)) }) -} - /// An open connection, closed on drop. pub(crate) struct Connection { db: NonNull, diff --git a/fuzz/src/ffi.rs b/fuzz/src/ffi.rs index ce60110..3999928 100644 --- a/fuzz/src/ffi.rs +++ b/fuzz/src/ffi.rs @@ -91,7 +91,6 @@ unsafe extern "C" { ) -> *mut Backup; pub(crate) fn sqlite3_backup_step(p: *mut Backup, n_page: c_int) -> c_int; pub(crate) fn sqlite3_backup_finish(p: *mut Backup) -> c_int; - pub(crate) fn sqlite3_enable_shared_cache(enable: c_int) -> c_int; /// SQLite3MC's own entry points, absent from upstream SQLite. pub(crate) fn sqlite3mc_config(db: *mut Sqlite3, param: *const c_char, value: c_int) -> c_int; pub(crate) fn sqlite3mc_config_cipher( diff --git a/fuzz/src/input.rs b/fuzz/src/input.rs index d19bf9e..b5978b3 100644 --- a/fuzz/src/input.rs +++ b/fuzz/src/input.rs @@ -590,48 +590,3 @@ pub struct BackupCase { /// steps of it. pub dest_ops: Vec, } - -/// Connections beyond this, opened to the same file under shared-cache mode, are ignored. -pub(crate) const MAX_SIBLINGS: usize = 3; - -/// The cipher and key a [`SharedConnection`] stages and keys with. -#[derive(Debug, Clone, PartialEq, Eq, Arbitrary)] -pub enum SiblingKey { - /// [`SharedCacheCase::written`], the one key shared-cache mode supports per file. - Shared, - /// A configuration of its own, almost certainly not [`SharedCacheCase::written`]. - Own(NativeConfig), -} - -/// One sibling connection, opened to the same file `main` already created and populated under -/// [`SharedCacheCase::written`]'s key, under shared-cache mode. -#[derive(Debug, Clone, PartialEq, Arbitrary)] -pub struct SharedConnection { - /// The cipher and key this connection stages and keys with. - pub key: SiblingKey, - /// Workload attempted on this connection after opening, at most [`MAX_OPS`] steps of it, - /// tolerated to fail when this connection's own key turned out wrong. - pub ops: Vec, -} - -/// One shared-cache execution: a main connection creates and populates a database, then up to -/// [`MAX_SIBLINGS`] further connections open the *same* file under shared-cache mode. -/// -/// Shared-cache connections to one file share one pager and one codec, so SQLite3MC supports a -/// single key and cipher configuration per file, and a sibling keying it differently replaces -/// the codec under `main` (`utelle/SQLite3MultipleCiphers` issue #281). -/// -/// Oracle: when every sibling keys with [`SiblingKey::Shared`], `main`'s own reads must keep -/// succeeding throughout. Unless a sibling ran a workload after one keyed the file differently, -/// and so wrote through the replaced codec, the file itself must still read back cleanly under -/// `written` once every connection closes, the same way the other harnesses' own "nothing -/// corrupts the file at rest" oracle does. -#[derive(Debug, Clone, PartialEq, Arbitrary)] -pub struct SharedCacheCase { - /// `main`'s own cipher and key, the file's one true key. - pub written: NativeConfig, - /// Workload `main` runs before any sibling connects, at most [`MAX_OPS`] steps of it. - pub main_ops: Vec, - /// Sibling connections to the same file, at most [`MAX_SIBLINGS`] of them. - pub siblings: Vec, -} diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 9248adf..304d7dd 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -1,6 +1,5 @@ //! Fuzzing SQLite3MC's own cipher schemes for self-consistency, its rekey support, its WAL -//! journal mode, its `ATTACH DATABASE` support, its `sqlite3_backup_*` API, and its shared-cache -//! mode. +//! journal mode, its `ATTACH DATABASE` support, and its `sqlite3_backup_*` API. #![expect( clippy::redundant_pub_crate, @@ -15,6 +14,6 @@ pub mod seedgen; pub use input::{ Algorithm, AttachCase, BackupCase, Checkpoint, Cipher, NativeCase, NativeKey, Op, RekeyAction, - RekeyCase, SharedCacheCase, SiblingKey, Table, WalCase, + RekeyCase, Table, WalCase, }; -pub use run::{run_attach, run_backup, run_native, run_rekey, run_shared_cache, run_wal}; +pub use run::{run_attach, run_backup, run_native, run_rekey, run_wal}; diff --git a/fuzz/src/run.rs b/fuzz/src/run.rs index cdeb6ce..cae61f5 100644 --- a/fuzz/src/run.rs +++ b/fuzz/src/run.rs @@ -15,8 +15,8 @@ use crate::{ ffi::{SQLITE_DONE, SQLITE_OPEN_CREATE, SQLITE_OPEN_READONLY, SQLITE_OPEN_READWRITE}, input::{ Algorithm, AttachCase, AttachedDb, BackupCase, Cell, Cipher, Column, NativeCase, - NativeConfig, NativeKey, Op, PageSize, RekeyAction, RekeyCase, SharedCacheCase, SiblingKey, - Table, WalCase, MAX_ATTACHED, MAX_ATTACH_ROWS, MAX_FLIPS, MAX_OPS, MAX_SIBLINGS, + NativeConfig, NativeKey, Op, PageSize, RekeyAction, RekeyCase, Table, WalCase, + MAX_ATTACHED, MAX_ATTACH_ROWS, MAX_FLIPS, MAX_OPS, }, }; @@ -594,89 +594,6 @@ fn attach( .is_ok() } -/// Runs `case` against SQLite3MC's handling of shared-cache mode. -/// -/// A main connection creates and populates a database, then up to [`MAX_SIBLINGS`] further -/// connections open the *same* file under shared-cache mode, each keying it with `main`'s own -/// configuration or one of its own. -/// -/// # Panics -/// -/// When SQLite3MC refuses a setting or step the harness generates as valid, when `main`'s own -/// read fails after siblings keyed with the same configuration connect, or when the file itself -/// fails to read back cleanly under its one true key once every connection closes. A sibling -/// workload run after another sibling keyed the file differently writes through that sibling's -/// codec (`utelle/SQLite3MultipleCiphers` issue #281), so such a run skips the file check. -pub fn run_shared_cache(case: &SharedCacheCase) { - static SERIAL: Mutex<()> = Mutex::new(()); - let _serial = begin_run(&SERIAL); - - crate::db::enable_shared_cache(true).expect("SQLite3MC accepts sqlite3_enable_shared_cache(1)"); - - let dir = WORKDIR.as_path(); - let path = dir.join("shared-cache.db"); - remove(&path); - - let cipher = case.written.cipher; - let main = open_native(&path, &case.written, "shared-cache database"); - if let Err(code) = main.execute(c"CREATE TABLE t0(i INTEGER, r REAL, t TEXT, b BLOB)", &[]) { - panic!( - "SQLite3MC refused to create main's own {} table: {code:?}", - cipher.name() - ); - } - run_workload(&main, "SQLite3MC", &case.main_ops, case); - - let mut siblings = Vec::with_capacity(case.siblings.len().min(MAX_SIBLINGS)); - let mut one_key = true; - let mut foreign_write = false; - for sibling in case.siblings.iter().take(MAX_SIBLINGS) { - let Ok(connection) = - Connection::open(&c_path(&path), SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE) - else { - continue; - }; - let config = match &sibling.key { - SiblingKey::Shared => &case.written, - SiblingKey::Own(config) => { - one_key &= *config == case.written; - config - } - }; - let _ = configure_native(&connection, config); - foreign_write |= !one_key && !sibling.ops.is_empty(); - run_workload_tolerant(&connection, &sibling.ops); - siblings.push(connection); - } - - if one_key { - if let Err(code) = main.execute(c"SELECT count(*) FROM sqlite_schema", &[]) { - panic!( - "SQLite3MC's main connection could not read its own {} shared-cache database \ - after {} sibling connection(s) keyed the same file identically for {case:#?}: \ - {code:?}", - cipher.name(), - siblings.len(), - ); - } - } - - drop(siblings); - drop(main); - - if !foreign_write { - let own = dump_native(&path, &case.written); - assert!( - clean_read(&own), - "SQLite3MC corrupted its own {} shared-cache database file for {case:#?}: {}", - cipher.name(), - describe(own.iter().rev().take(3)), - ); - } - - remove(&path); -} - /// A per-process directory on tmpfs where there is one. static WORKDIR: LazyLock = LazyLock::new(|| { let shm = Path::new("/dev/shm"); @@ -948,97 +865,83 @@ pub(crate) fn run_workload( label: &str, ops: &[Op], context: &D, -) { - run_workload_with( - connection, - ops, - |connection, text, params, op| match connection.execute(&sql(text), params) { - Ok(()) => true, - Err(code) => panic!("{label} failed {op:?} ({text}) with {code:?} for {context:#?}"), - }, - ); -} - -/// Runs `ops` against an already-open, already-configured connection, silently stopping at the -/// first statement that fails instead of panicking. For a connection whose own key or cipher may -/// be wrong on purpose, where failure is expected and not itself a defect. -pub(crate) fn run_workload_tolerant(connection: &Connection, ops: &[Op]) { - run_workload_with(connection, ops, |connection, text, params, _op| { - connection.execute(&sql(text), params).is_ok() - }); -} - -/// The shared dispatch `run_workload`/`run_workload_tolerant` both turn `ops` into SQL through; -/// `run` reports whether its statement succeeded, which decides whether dependent state (a table -/// now existing, a transaction now open) advances or the op is treated as never having happened. -fn run_workload_with( - connection: &Connection, - ops: &[Op], - mut run: impl FnMut(&Connection, &str, &[Param<'_>], &Op) -> bool, ) { let mut created = [false; 3]; // Tables created inside a transaction disappear again on rollback. let mut before_transaction: Option<[bool; 3]> = None; + let run = |text: &str, params: &[Param<'_>], op: &Op| { + if let Err(code) = connection.execute(&sql(text), params) { + panic!("{label} failed {op:?} ({text}) with {code:?} for {context:#?}"); + } + }; for op in ops.iter().take(MAX_OPS) { let exists = |table: Table| created[table.index()]; match op { Op::Create(table) => { let t = TABLE_NAMES[table.index()]; - let text = - format!("CREATE TABLE IF NOT EXISTS {t}(i INTEGER, r REAL, t TEXT, b BLOB)"); - if run(connection, &text, &[], op) { - created[table.index()] = true; - } + run( + &format!("CREATE TABLE IF NOT EXISTS {t}(i INTEGER, r REAL, t TEXT, b BLOB)"), + &[], + op, + ); + created[table.index()] = true; } Op::Index(table, column) if exists(*table) => { let (t, c) = (TABLE_NAMES[table.index()], column_name(*column)); - let text = format!("CREATE INDEX IF NOT EXISTS {t}_{c} ON {t}({c})"); - run(connection, &text, &[], op); + run( + &format!("CREATE INDEX IF NOT EXISTS {t}_{c} ON {t}({c})"), + &[], + op, + ); } Op::Insert(table, cells) if exists(*table) => { let owned = cells.each_ref().map(Owned::new); let params = owned.each_ref().map(Owned::param); let t = TABLE_NAMES[table.index()]; - let text = format!("INSERT INTO {t}(i, r, t, b) VALUES (?1, ?2, ?3, ?4)"); - run(connection, &text, ¶ms, op); + run( + &format!("INSERT INTO {t}(i, r, t, b) VALUES (?1, ?2, ?3, ?4)"), + ¶ms, + op, + ); } Op::Update(table, rowid, column, cell) if exists(*table) => { let owned = Owned::new(cell); let (t, c) = (TABLE_NAMES[table.index()], column_name(*column)); let params = [owned.param(), Param::Integer(i64::from(*rowid))]; - let text = format!("UPDATE {t} SET {c} = ?1 WHERE rowid = ?2"); - run(connection, &text, ¶ms, op); + run( + &format!("UPDATE {t} SET {c} = ?1 WHERE rowid = ?2"), + ¶ms, + op, + ); } Op::Delete(table, rowid) if exists(*table) => { let t = TABLE_NAMES[table.index()]; - let text = format!("DELETE FROM {t} WHERE rowid = ?1"); - run(connection, &text, &[Param::Integer(i64::from(*rowid))], op); - } - Op::Vacuum if before_transaction.is_none() => { - run(connection, "VACUUM", &[], op); + run( + &format!("DELETE FROM {t} WHERE rowid = ?1"), + &[Param::Integer(i64::from(*rowid))], + op, + ); } + Op::Vacuum if before_transaction.is_none() => run("VACUUM", &[], op), Op::Begin if before_transaction.is_none() => { - if run(connection, "BEGIN", &[], op) { - before_transaction = Some(created); - } + run("BEGIN", &[], op); + before_transaction = Some(created); } Op::Commit if before_transaction.is_some() => { - if run(connection, "COMMIT", &[], op) { - before_transaction = None; - } + run("COMMIT", &[], op); + before_transaction = None; } Op::Rollback => { if let Some(saved) = before_transaction.take() { - if run(connection, "ROLLBACK", &[], op) { - created = saved; - } + run("ROLLBACK", &[], op); + created = saved; } } _ => {} } } if before_transaction.is_some() { - run(connection, "COMMIT", &[], &Op::Commit); + run("COMMIT", &[], &Op::Commit); } } diff --git a/fuzz/tests/replay.rs b/fuzz/tests/replay.rs index ee64f37..a230dbb 100644 --- a/fuzz/tests/replay.rs +++ b/fuzz/tests/replay.rs @@ -1,11 +1,11 @@ //! Replays the committed corpora for self-consistency across the native cipher schemes, rekey -//! round-trips, WAL-mode crash tolerance, `ATTACH DATABASE` cross-database isolation, the backup -//! API's compatibility guard, and shared-cache mode, without libFuzzer. +//! round-trips, WAL-mode crash tolerance, `ATTACH DATABASE` cross-database isolation, and the +//! backup API's compatibility guard, without libFuzzer. use arbitrary::{Arbitrary, Unstructured}; use sqlite3mc_src_fuzz::{ - run_attach, run_backup, run_native, run_rekey, run_shared_cache, run_wal, AttachCase, - BackupCase, NativeCase, RekeyCase, SharedCacheCase, WalCase, + run_attach, run_backup, run_native, run_rekey, run_wal, AttachCase, BackupCase, NativeCase, + RekeyCase, WalCase, }; #[test] @@ -102,22 +102,3 @@ fn committed_backup_corpus_never_reports_success_for_an_unreadable_destination() run_backup(&case); } } - -#[test] -fn committed_shared_cache_corpus_keeps_the_main_connection_reading() { - let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/corpus/shared_cache"); - let mut seeds: Vec<_> = std::fs::read_dir(dir) - .expect("the committed corpus exists") - .map(|entry| entry.expect("the corpus is readable").path()) - .collect(); - seeds.sort(); - assert!(!seeds.is_empty(), "{dir} holds no seeds"); - for seed in seeds { - let bytes = std::fs::read(&seed).expect("seeds are readable"); - // libFuzzer's `fuzz_target!` decodes the same way, taking the rest of the input last. - let case = SharedCacheCase::arbitrary_take_rest(Unstructured::new(&bytes)) - .unwrap_or_else(|e| panic!("{} does not decode: {e}", seed.display())); - eprintln!("{}", seed.display()); - run_shared_cache(&case); - } -}