diff --git a/Cargo.lock b/Cargo.lock index 59a725fa..0dde0c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,9 +718,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -878,9 +878,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "foldhash", ] @@ -893,11 +893,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1281,9 +1281,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libsqlite3-sys" -version = "0.32.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -2010,11 +2010,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + [[package]] name = "rusqlite" -version = "0.34.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ "bitflags", "fallible-iterator", @@ -2022,6 +2032,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -2415,6 +2426,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 451484b3..b33cd6b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ futures-util = "0.3" object_store = { version = "0.14.1", default-features = false } prost = "0.13" reqwest = { version = "0.12", default-features = false } -rusqlite = { version = "0.34", features = ["bundled"] } +rusqlite = { version = "0.39", features = ["bundled", "fallible_uint"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" diff --git a/crates/cellule-ltx/docs/safety.md b/crates/cellule-ltx/docs/safety.md index 9124770a..6841f400 100644 --- a/crates/cellule-ltx/docs/safety.md +++ b/crates/cellule-ltx/docs/safety.md @@ -4,6 +4,13 @@ LTX CRC64 protects file structure and rolling database state; it is not an authenticator. Cell roots and objects carry BLAKE3 digests. The embedding service authenticates the authority record that selects a root. +Managed databases use bundled SQLite 3.51.3 through rusqlite 0.39. This includes +SQLite's [WAL-reset race fix](https://sqlite.org/releaselog/3_51_3.html), required +when writer and checkpoint connections overlap. The `fallible_uint` feature +preserves range-checked unsigned SQL bindings used by the runtime. Embedders +sharing rusqlite types must select the same compatible dependency version; +LTX capture, durability, and recovery contracts remain unchanged. + | Invariant | Effect | | --- | --- | | First segment is a full snapshot | No delta-only restore. | diff --git a/crates/cellule-ltx/src/db/tests/reader.rs b/crates/cellule-ltx/src/db/tests/reader.rs index 34700962..84aabdfd 100644 --- a/crates/cellule-ltx/src/db/tests/reader.rs +++ b/crates/cellule-ltx/src/db/tests/reader.rs @@ -1,6 +1,6 @@ use super::*; use rusqlite::{ - DatabaseName, ErrorCode, + ErrorCode, MAIN_DB, hooks::{AuthAction, AuthContext, Authorization}, }; @@ -19,19 +19,21 @@ fn database(temp: &tempfile::TempDir) -> Db { fn reader_is_protected_and_refreshes_its_snapshot_without_reprepare() { let temp = tempfile::TempDir::new().unwrap(); let mut db = database(&temp); - assert!(db.reader.is_readonly(DatabaseName::Main).unwrap()); + assert!(db.reader.is_readonly(MAIN_DB).unwrap()); let preparations = Arc::new(AtomicU64::new(0)); let observed = Arc::clone(&preparations); - db.reader.authorizer(Some(move |context: AuthContext<'_>| { - if let AuthAction::Read { - table_name: "events", - column_name: "value", - } = context.action - { - observed.fetch_add(1, Ordering::Relaxed); - } - Authorization::Allow - })); + db.reader + .authorizer(Some(move |context: AuthContext<'_>| { + if let AuthAction::Read { + table_name: "events", + column_name: "value", + } = context.action + { + observed.fetch_add(1, Ordering::Relaxed); + } + Authorization::Allow + })) + .unwrap(); for expected in 1..=3 { let value = db .query_with(|connection| { @@ -171,15 +173,17 @@ fn managed_interrupt_reaches_an_executing_read_and_is_inert_after_close() { let (started, receive) = std::sync::mpsc::channel(); let worker = std::thread::spawn(move || { let mut started = Some(started); - db.reader.progress_handler( - 1_000, - Some(move || { - if let Some(started) = started.take() { - let _ = started.send(()); - } - false - }), - ); + db.reader + .progress_handler( + 1_000, + Some(move || { + if let Some(started) = started.take() { + let _ = started.send(()); + } + false + }), + ) + .unwrap(); let result = db.query_with(|connection| connection.query_row( "WITH RECURSIVE counter(value) AS (VALUES(0) UNION ALL SELECT value + 1 FROM counter WHERE value < 1000000000) SELECT sum(value) FROM counter", [], |row| row.get::<_, i64>(0), diff --git a/crates/cellule-ltx/tests/cell/roots/lifecycle.rs b/crates/cellule-ltx/tests/cell/roots/lifecycle.rs index bad94eb6..6bd7d6c3 100644 --- a/crates/cellule-ltx/tests/cell/roots/lifecycle.rs +++ b/crates/cellule-ltx/tests/cell/roots/lifecycle.rs @@ -49,11 +49,7 @@ async fn read_only_roots_keep_exact_snapshots_without_materializing_pages() { .unwrap(); assert_eq!(value, 1); connection.pragma_update(None, "query_only", false).unwrap(); - assert!( - connection - .is_readonly(rusqlite::DatabaseName::Main) - .unwrap() - ); + assert!(connection.is_readonly(rusqlite::MAIN_DB).unwrap()); assert!( connection .execute("UPDATE counter SET value=99", []) diff --git a/crates/cellule-ltx/tests/cell/roots/sparse.rs b/crates/cellule-ltx/tests/cell/roots/sparse.rs index 34b860b2..ff41d018 100644 --- a/crates/cellule-ltx/tests/cell/roots/sparse.rs +++ b/crates/cellule-ltx/tests/cell/roots/sparse.rs @@ -10,7 +10,7 @@ async fn sparse_owner_reader_materializes_pages_but_refuses_sql_mutations() { assert!(!before.complete()); writer .query_with(|connection| -> cellule_ltx::rusqlite::Result<()> { - assert!(connection.is_readonly(cellule_ltx::rusqlite::DatabaseName::Main)?); + assert!(connection.is_readonly(cellule_ltx::rusqlite::MAIN_DB)?); let value: Vec = connection.query_row("SELECT value FROM payload", [], |row| row.get(0))?; assert_eq!(value.len(), 2_000_000); diff --git a/crates/cellule-runtime/src/cell/executor/tests.rs b/crates/cellule-runtime/src/cell/executor/tests.rs index e1c938be..5f601ca5 100644 --- a/crates/cellule-runtime/src/cell/executor/tests.rs +++ b/crates/cellule-runtime/src/cell/executor/tests.rs @@ -483,7 +483,7 @@ fn mixed_owner_reads_preserve_warm_kv_write_statements() { observed.fetch_add(1, Ordering::Relaxed); } Authorization::Allow - })); + }))?; Ok(()) }) .unwrap(); diff --git a/crates/cellule-runtime/src/cell/schema_cache/tests.rs b/crates/cellule-runtime/src/cell/schema_cache/tests.rs index 9f340d1b..2ce8ddba 100644 --- a/crates/cellule-runtime/src/cell/schema_cache/tests.rs +++ b/crates/cellule-runtime/src/cell/schema_cache/tests.rs @@ -12,16 +12,18 @@ fn unchanged_schema_reuses_capabilities_and_committed_ddl_refreshes_them() { let mut connection = Connection::open_in_memory().unwrap(); let scans = Arc::new(AtomicUsize::new(0)); let observed_scans = Arc::clone(&scans); - connection.authorizer(Some(move |context: AuthContext<'_>| { - if let AuthAction::Read { - table_name: "sqlite_master", - column_name: "name", - } = context.action - { - observed_scans.fetch_add(1, Ordering::Relaxed); - } - Authorization::Allow - })); + connection + .authorizer(Some(move |context: AuthContext<'_>| { + if let AuthAction::Read { + table_name: "sqlite_master", + column_name: "name", + } = context.action + { + observed_scans.fetch_add(1, Ordering::Relaxed); + } + Authorization::Allow + })) + .unwrap(); let mut cache = SchemaCache::default(); for _ in 0..10 { let transaction = connection.transaction().unwrap(); diff --git a/crates/cellule-runtime/src/primitives/sql/mod.rs b/crates/cellule-runtime/src/primitives/sql/mod.rs index 3f3c76fd..e19fc875 100644 --- a/crates/cellule-runtime/src/primitives/sql/mod.rs +++ b/crates/cellule-runtime/src/primitives/sql/mod.rs @@ -102,8 +102,7 @@ pub(crate) fn write_blob( "SQL blob write exceeds 1 MiB", )?; } - let mut blob = - transaction.blob_open(rusqlite::DatabaseName::Main, table, column, row_id, false)?; + let mut blob = transaction.blob_open(rusqlite::MAIN_DB, table, column, row_id, false)?; let written = blob.write_at(bytes, offset); let closed = blob.close(); written?; @@ -117,7 +116,7 @@ fn execute_batch( mode: AccessMode, ) -> Result> { validate_batch(batch)?; - let _authorizer = AuthorizerGuard::install(connection, mode); + let _authorizer = AuthorizerGuard::install(connection, mode)?; let mut result_sets = Vec::with_capacity(batch.statements.len()); let mut row_count = 0_usize; let mut result_bytes = 0_usize; @@ -300,20 +299,26 @@ struct AuthorizerGuard<'a> { } impl<'a> AuthorizerGuard<'a> { - fn install(connection: &'a Connection, mode: AccessMode) -> Self { + fn install(connection: &'a Connection, mode: AccessMode) -> Result { let authorizer: fn(AuthContext<'_>) -> Authorization = match mode { AccessMode::ReadOnly => authorize_read, AccessMode::ReadWrite => authorize_write, }; - connection.authorizer(Some(authorizer)); - Self { connection } + // Borrowed handles reject hook installation. No application SQL may + // run until authorization is installed on this exact connection. + connection.authorizer(Some(authorizer))?; + Ok(Self { connection }) } } impl Drop for AuthorizerGuard<'_> { fn drop(&mut self) { - self.connection - .authorizer(None::) -> Authorization>); + if let Err(error) = self + .connection + .authorizer(None::) -> Authorization>) + { + tracing::error!(%error, "SQL authorizer cleanup failed"); + } } } diff --git a/crates/cellule-runtime/tests/primitives/sql.rs b/crates/cellule-runtime/tests/primitives/sql.rs index e5ca386b..ca07973c 100644 --- a/crates/cellule-runtime/tests/primitives/sql.rs +++ b/crates/cellule-runtime/tests/primitives/sql.rs @@ -315,6 +315,28 @@ fn typed_batch_mutates_and_materializes_in_order() { transaction.commit().unwrap(); } +#[test] +fn borrowed_sql_connection_cannot_bypass_the_authorizer() { + let connection = connection(); + // SAFETY: The owner outlives this borrowed wrapper; access is sequential, + // and dropping the wrapper does not close the owner's SQLite handle. + let borrowed = unsafe { Connection::from_handle(connection.handle()) }.unwrap(); + let result = sql_query_batch( + &borrowed, + &SqlBatch { + statements: vec![statement( + "SELECT commit_sequence FROM sys_meta", + Vec::new(), + )], + }, + ); + assert!(matches!( + result, + Err(cellule_runtime::Error::Sqlite(error)) + if error.sqlite_error_code() == Some(cellule_ltx::rusqlite::ErrorCode::ApiMisuse) + )); +} + #[test] fn authorizer_blocks_runtime_tables_and_indirect_trigger_or_view_access() { let mut connection = connection();