lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit b30761b8fdf8c04d8e4d4de3e5db12d26fdbd600
parent 278349715401e8c8c5d61405dcabffb9880e42e3
Author: triesap <tyson@radroots.org>
Date:   Sat, 15 Aug 2026 20:13:31 +0000

service-sqlite: bound persisted diagnostics

Diffstat:
Mcrates/service_sqlite/README.md | 8++++++++
Mcrates/service_sqlite/src/backup/capture.rs | 155++++++++++++++++++++++++++++++++++++-------------------------------------------
Mcrates/service_sqlite/src/backup/verify.rs | 174+++++++++++++++++++++++++++++++++++--------------------------------------------
Mcrates/service_sqlite/src/integrity/inspection.rs | 25+++++++++++++------------
Mcrates/service_sqlite/src/integrity/mod.rs | 23+++++++++++++----------
Mcrates/service_sqlite/src/lib.rs | 2++
Mcrates/service_sqlite/src/metadata.rs | 148++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/service_sqlite/src/migration.rs | 194++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Acrates/service_sqlite/src/persisted_value.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_sqlite/src/restore/stage.rs | 31+++++++++++++++++++++++--------
Mcrates/service_sqlite/tests/package_boundary.rs | 43+++++++++++++++++++++++++++++++++++--------
11 files changed, 565 insertions(+), 331 deletions(-)

diff --git a/crates/service_sqlite/README.md b/crates/service_sqlite/README.md @@ -24,6 +24,14 @@ sticky for the transaction, so ignoring the immediate SQL error cannot permit commit. Runner-owned setup remains private, and the complete connection policy is revalidated before commit and before a connection can return to the pool. +Persisted SQLite text and blob values are admitted through bounded projections +before Rust decoding. Service and instance identifiers, source generations, +migration names, checksums, build identity, schema text, and database inventory +values carry an exact SQLite type, reported byte length, capped byte prefix, +and bounded row count. Integrity diagnostics use a borrowed-byte cap over the +exact `PRAGMA integrity_check(1)` operation and never convert an unbounded +diagnostic to UTF-8. + Cancelling a host transaction before the runner enables outer commit quarantines its connection and leaves no authoritative transaction effect. A service-operation error is returned only after rollback is confirmed; an diff --git a/crates/service_sqlite/src/backup/capture.rs b/crates/service_sqlite/src/backup/capture.rs @@ -38,8 +38,6 @@ use crate::{ const MAX_STAGING_PATH_BYTES: usize = 4_096; const BACKUP_PAGES_PER_STEP: i32 = 64; const HASH_BUFFER_BYTES: usize = 16 * 1_024; -const MAX_ID_UTF8_BYTES: i64 = 128; -const MAX_INTEGRITY_RESULT_UTF8_BYTES: usize = 64; const STATE_FILE_NAME: &str = radroots_runtime_paths::SERVICE_STATE_DATABASE_FILE_NAME; const KNOWN_SIDECARS: [&str; 3] = [ "state.sqlite-wal", @@ -1165,21 +1163,35 @@ fn identity(status: &rustix::fs::Stat) -> Result<FileIdentity, ServiceSqliteErro async fn verify_database_inventory( connection: &mut SqliteConnection, ) -> Result<(), ServiceSqliteError> { - let rows = sqlx::query("SELECT seq, name FROM pragma_database_list LIMIT 2") - .fetch_all(connection) - .await - .map_err(|source| backup_source(BackupFailureKind::Capture, source))?; + let rows = sqlx::query( + "SELECT + seq, + typeof(name) = 'text' AS name_type_ok, + length(CAST(name AS BLOB)) AS name_length, + substr(CAST(name AS BLOB), 1, 5) AS name_prefix + FROM pragma_database_list + LIMIT 2", + ) + .fetch_all(connection) + .await + .map_err(|source| backup_source(BackupFailureKind::Capture, source))?; let first = rows .first() .ok_or_else(|| backup_error(BackupFailureKind::Capture))?; let sequence = first .try_get::<i64, _>(0) .map_err(|source| backup_source(BackupFailureKind::Capture, source))?; - let name = first - .try_get::<String, _>(1) - .map_err(|source| backup_source(BackupFailureKind::Capture, source))?; + let name = crate::persisted_value::bounded_utf8( + first, + "name_type_ok", + "name_length", + "name_prefix", + 1, + 4, + ) + .ok_or_else(|| backup_error(BackupFailureKind::Capture))?; require_backup_condition( - database_inventory_matches(sequence, &name, rows.len() > 1), + database_inventory_matches(sequence, name, rows.len() > 1), BackupFailureKind::Capture, )?; Ok(()) @@ -1212,15 +1224,15 @@ async fn verify_database_metadata( )?; let row = sqlx::query( "SELECT - CASE WHEN typeof(service_id) = 'text' - AND length(CAST(service_id AS BLOB)) BETWEEN 1 AND ?1 - THEN service_id END, - CASE WHEN typeof(instance_id) = 'text' - AND length(CAST(instance_id AS BLOB)) BETWEEN 1 AND ?1 - THEN instance_id END, - CASE WHEN typeof(source_generation) = 'blob' - AND length(source_generation) = 32 - THEN source_generation END, + typeof(service_id) = 'text' AS service_id_type_ok, + length(CAST(service_id AS BLOB)) AS service_id_length, + substr(CAST(service_id AS BLOB), 1, 129) AS service_id_prefix, + typeof(instance_id) = 'text' AS instance_id_type_ok, + length(CAST(instance_id AS BLOB)) AS instance_id_length, + substr(CAST(instance_id AS BLOB), 1, 129) AS instance_id_prefix, + typeof(source_generation) = 'blob' AS source_generation_type_ok, + length(source_generation) AS source_generation_length, + substr(source_generation, 1, 33) AS source_generation_prefix, CASE WHEN typeof(state_schema_version) = 'integer' THEN state_schema_version END, CASE WHEN typeof(created_at_unix_ms) = 'integer' @@ -1229,34 +1241,49 @@ async fn verify_database_metadata( WHERE singleton = 1 LIMIT 1", ) - .bind(MAX_ID_UTF8_BYTES) .fetch_optional(&mut *connection) .await .map_err(metadata_source)?; let Some(row) = row else { return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata)); }; - let service = row - .try_get::<Option<String>, _>(0) - .map_err(metadata_source)?; - let instance = row - .try_get::<Option<String>, _>(1) - .map_err(metadata_source)?; - let generation = row - .try_get::<Option<Vec<u8>>, _>(2) - .map_err(metadata_source)?; - let schema = row.try_get::<Option<i64>, _>(3).map_err(metadata_source)?; - let created_at = row.try_get::<Option<i64>, _>(4).map_err(metadata_source)?; - let (Some(service), Some(instance), Some(generation), Some(schema), Some(created_at)) = - (service, instance, generation, schema, created_at) - else { + let service = crate::persisted_value::bounded_utf8( + &row, + "service_id_type_ok", + "service_id_length", + "service_id_prefix", + 1, + crate::persisted_value::MAX_IDENTIFIER_UTF8_BYTES, + ) + .ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata))?; + let instance = crate::persisted_value::bounded_utf8( + &row, + "instance_id_type_ok", + "instance_id_length", + "instance_id_prefix", + 1, + crate::persisted_value::MAX_IDENTIFIER_UTF8_BYTES, + ) + .ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata))?; + let generation = crate::persisted_value::bounded_bytes( + &row, + "source_generation_type_ok", + "source_generation_length", + "source_generation_prefix", + 32, + 32, + ) + .ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata))?; + let schema = row.try_get::<Option<i64>, _>(9).map_err(metadata_source)?; + let created_at = row.try_get::<Option<i64>, _>(10).map_err(metadata_source)?; + let (Some(schema), Some(created_at)) = (schema, created_at) else { return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata)); }; crate::require_condition( crate::all_constraints([ service == expected.service().as_str(), instance == expected.instance().as_str(), - generation.as_slice() == expected.source_generation().as_bytes(), + generation == expected.source_generation().as_bytes(), schema == i64::from(expected.state_schema_version().get()), created_at == i64::try_from(expected.created_at_unix_ms()).unwrap_or(-1), ]), @@ -1266,20 +1293,16 @@ async fn verify_database_metadata( } async fn verify_integrity(connection: &mut SqliteConnection) -> Result<(), ServiceSqliteError> { - let rows = sqlx::query("PRAGMA integrity_check(1)") + let rows = sqlx::query(crate::persisted_value::INTEGRITY_CHECK_SQL) .fetch_all(&mut *connection) .await .map_err(integrity_source)?; let row = rows .first() .ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity))?; - let value = row.try_get::<&str, _>(0).map_err(integrity_source)?; + let value = crate::persisted_value::bounded_integrity_bytes(row); crate::require_condition( - integrity_projection_is_ok( - Some("text"), - i64::try_from(value.len()).ok(), - Some(value.as_bytes()), - ) && rows.len() == 1, + integrity_projection_is_ok(value) && rows.len() == 1, ServiceSqliteErrorKind::Integrity, )?; let violation = sqlx::query_scalar::<_, i64>("SELECT 1 FROM pragma_foreign_key_check LIMIT 1") @@ -1290,20 +1313,8 @@ async fn verify_integrity(connection: &mut SqliteConnection) -> Result<(), Servi Ok(()) } -fn integrity_projection_is_ok( - value_type: Option<&str>, - byte_length: Option<i64>, - value: Option<&[u8]>, -) -> bool { - crate::all_constraints([ - value_type == Some("text"), - byte_length.is_some_and(|length| { - length > 0 - && usize::try_from(length) - .is_ok_and(|length| length <= MAX_INTEGRITY_RESULT_UTF8_BYTES) - }), - value == Some(b"ok"), - ]) +fn integrity_projection_is_ok(value: Option<&[u8]>) -> bool { + value == Some(b"ok") } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1819,34 +1830,10 @@ mod tests { #[test] fn integrity_projection_bounds_corrupt_text_before_semantic_acceptance() { - assert!(integrity_projection_is_ok( - Some("text"), - Some(2), - Some(b"ok") - )); - assert!(!integrity_projection_is_ok( - Some("text"), - Some(0), - Some(b"") - )); - assert!(!integrity_projection_is_ok( - Some("text"), - Some(6), - Some(b"not-ok") - )); - let maximum = vec![b'x'; MAX_INTEGRITY_RESULT_UTF8_BYTES]; - assert!(!integrity_projection_is_ok( - Some("text"), - i64::try_from(maximum.len()).ok(), - Some(&maximum) - )); - let over_maximum = vec![b'x'; MAX_INTEGRITY_RESULT_UTF8_BYTES + 1]; - assert!(!integrity_projection_is_ok( - Some("text"), - i64::try_from(over_maximum.len()).ok(), - Some(&over_maximum) - )); - assert!(!integrity_projection_is_ok(None, None, None)); + assert!(integrity_projection_is_ok(Some(b"ok"))); + assert!(!integrity_projection_is_ok(Some(b""))); + assert!(!integrity_projection_is_ok(Some(b"not-ok"))); + assert!(!integrity_projection_is_ok(None)); } #[test] diff --git a/crates/service_sqlite/src/backup/verify.rs b/crates/service_sqlite/src/backup/verify.rs @@ -33,10 +33,6 @@ use { const MAX_BUNDLE_PATH_BYTES: usize = 4_096; #[cfg(any(target_os = "linux", target_os = "macos"))] const HASH_BUFFER_BYTES: usize = 64 * 1_024; -#[cfg(any(target_os = "linux", target_os = "macos"))] -const MAX_ID_UTF8_BYTES: i64 = 128; -#[cfg(any(target_os = "linux", target_os = "macos"))] -const MAX_INTEGRITY_RESULT_UTF8_BYTES: usize = 64; /// Non-forgeable proof that one retained backup member passed v1 verification. /// @@ -543,15 +539,30 @@ fn require_verification_connection_policy( async fn verify_database_inventory( connection: &mut SqliteConnection, ) -> Result<(), ServiceSqliteError> { - let rows = sqlx::query("SELECT seq, name FROM pragma_database_list LIMIT 2") - .fetch_all(connection) - .await - .map_err(integrity_source)?; + let rows = sqlx::query( + "SELECT + seq, + typeof(name) = 'text' AS name_type_ok, + length(CAST(name AS BLOB)) AS name_length, + substr(CAST(name AS BLOB), 1, 5) AS name_prefix + FROM pragma_database_list + LIMIT 2", + ) + .fetch_all(connection) + .await + .map_err(integrity_source)?; let Some(first) = rows.first() else { return Err(integrity_error(IntegrityFailureKind::DatabaseInventory)); }; let sequence_matches = first.try_get::<i64, _>(0).ok() == Some(0); - let name_matches = first.try_get::<&str, _>(1).ok() == Some("main"); + let name_matches = crate::persisted_value::bounded_utf8( + first, + "name_type_ok", + "name_length", + "name_prefix", + 1, + 4, + ) == Some("main"); require_verification_database_inventory(sequence_matches, name_matches, rows.len() > 1) } @@ -599,15 +610,15 @@ async fn verify_database_metadata( let rows = sqlx::query( "SELECT CASE WHEN typeof(singleton) = 'integer' THEN singleton END, - CASE WHEN typeof(service_id) = 'text' - AND length(CAST(service_id AS BLOB)) BETWEEN 1 AND ?1 - THEN service_id END, - CASE WHEN typeof(instance_id) = 'text' - AND length(CAST(instance_id AS BLOB)) BETWEEN 1 AND ?1 - THEN instance_id END, - CASE WHEN typeof(source_generation) = 'blob' - AND length(source_generation) = 32 - THEN source_generation END, + typeof(service_id) = 'text' AS service_id_type_ok, + length(CAST(service_id AS BLOB)) AS service_id_length, + substr(CAST(service_id AS BLOB), 1, 129) AS service_id_prefix, + typeof(instance_id) = 'text' AS instance_id_type_ok, + length(CAST(instance_id AS BLOB)) AS instance_id_length, + substr(CAST(instance_id AS BLOB), 1, 129) AS instance_id_prefix, + typeof(source_generation) = 'blob' AS source_generation_type_ok, + length(source_generation) AS source_generation_length, + substr(source_generation, 1, 33) AS source_generation_prefix, CASE WHEN typeof(state_schema_version) = 'integer' THEN state_schema_version END, CASE WHEN typeof(created_at_unix_ms) = 'integer' @@ -615,33 +626,51 @@ async fn verify_database_metadata( FROM radroots_service_metadata LIMIT 2", ) - .bind(MAX_ID_UTF8_BYTES) .fetch_all(connection) .await .map_err(metadata_source)?; let row = rows.first().ok_or_else(metadata_error)?; let singleton = row.try_get::<Option<i64>, _>(0).map_err(metadata_source)?; - let service = row - .try_get::<Option<String>, _>(1) - .map_err(metadata_source)?; - let instance = row - .try_get::<Option<String>, _>(2) - .map_err(metadata_source)?; - let generation = row - .try_get::<Option<Vec<u8>>, _>(3) - .map_err(metadata_source)?; - let schema = row.try_get::<Option<i64>, _>(4).map_err(metadata_source)?; - let created_at = row.try_get::<Option<i64>, _>(5).map_err(metadata_source)?; crate::require_condition(rows.len() == 1, ServiceSqliteErrorKind::Metadata)?; - let (Some(1), Some(service), Some(instance), Some(generation), Some(schema), Some(created_at)) = - (singleton, service, instance, generation, schema, created_at) - else { + if singleton != Some(1) { + return Err(metadata_error()); + } + let service = crate::persisted_value::bounded_utf8( + row, + "service_id_type_ok", + "service_id_length", + "service_id_prefix", + 1, + crate::persisted_value::MAX_IDENTIFIER_UTF8_BYTES, + ) + .and_then(|value| ServiceId::new(value).ok()) + .ok_or_else(metadata_error)?; + let instance = crate::persisted_value::bounded_utf8( + row, + "instance_id_type_ok", + "instance_id_length", + "instance_id_prefix", + 1, + crate::persisted_value::MAX_IDENTIFIER_UTF8_BYTES, + ) + .and_then(|value| InstanceId::new(value).ok()) + .ok_or_else(metadata_error)?; + let generation = crate::persisted_value::bounded_bytes( + row, + "source_generation_type_ok", + "source_generation_length", + "source_generation_prefix", + 32, + 32, + ) + .and_then(|value| <[u8; 32]>::try_from(value).ok()) + .and_then(|value| SourceGeneration::new(value).ok()) + .ok_or_else(metadata_error)?; + let schema = row.try_get::<Option<i64>, _>(10).map_err(metadata_source)?; + let created_at = row.try_get::<Option<i64>, _>(11).map_err(metadata_source)?; + let (Some(schema), Some(created_at)) = (schema, created_at) else { return Err(metadata_error()); }; - let service = ServiceId::new(service).map_err(|_| metadata_error())?; - let instance = InstanceId::new(instance).map_err(|_| metadata_error())?; - let generation = SourceGeneration::new(generation.try_into().map_err(|_| metadata_error())?) - .map_err(|_| metadata_error())?; let schema = NonZeroU32::new(u32::try_from(schema).map_err(|_| metadata_error())?) .ok_or_else(metadata_error)?; let created_at = u64::try_from(created_at).map_err(|_| metadata_error())?; @@ -670,20 +699,15 @@ async fn verify_database_metadata( #[cfg(any(target_os = "linux", target_os = "macos"))] async fn verify_integrity(connection: &mut SqliteConnection) -> Result<(), ServiceSqliteError> { - let rows = sqlx::query("PRAGMA integrity_check(1)") + let rows = sqlx::query(crate::persisted_value::INTEGRITY_CHECK_SQL) .fetch_all(&mut *connection) .await .map_err(integrity_source)?; let row = rows .first() .ok_or_else(|| integrity_error(IntegrityFailureKind::Sqlite))?; - let value = row.try_get::<&str, _>(0).map_err(integrity_source)?; - let projection = verification_integrity_value_projection( - Some("text"), - i64::try_from(value.len()).ok(), - Some(value.as_bytes()), - ); - require_verification_integrity_projection(projection, rows.len() > 1)?; + let value = crate::persisted_value::bounded_integrity_bytes(row); + require_verification_integrity_projection(value == Some(b"ok"), rows.len() > 1)?; let violation = sqlx::query_scalar::<_, i64>("SELECT 1 FROM pragma_foreign_key_check LIMIT 1") .fetch_optional(connection) .await @@ -701,35 +725,13 @@ fn require_verification_metadata_projection(matches: [bool; 9]) -> Result<(), Se } #[cfg(any(target_os = "linux", target_os = "macos"))] -fn verification_integrity_value_projection( - value_type: Option<&str>, - byte_length: Option<i64>, - value: Option<&[u8]>, -) -> [bool; 4] { - [ - value_type == Some("text"), - byte_length.is_some_and(|length| length > 0), - byte_length.is_some_and(|length| { - usize::try_from(length).is_ok_and(|length| length <= MAX_INTEGRITY_RESULT_UTF8_BYTES) - }), - value == Some(b"ok"), - ] -} - -#[cfg(any(target_os = "linux", target_os = "macos"))] fn require_verification_integrity_projection( - projection: [bool; 4], + value_matches: bool, has_extra: bool, ) -> Result<(), ServiceSqliteError> { - crate::all_constraints([ - projection[0], - projection[1], - projection[2], - projection[3], - !has_extra, - ]) - .then_some(()) - .ok_or_else(|| integrity_error(IntegrityFailureKind::Sqlite)) + crate::all_constraints([value_matches, !has_extra]) + .then_some(()) + .ok_or_else(|| integrity_error(IntegrityFailureKind::Sqlite)) } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -944,33 +946,9 @@ mod tests { assert!(require_verification_metadata_projection(matches).is_err()); } - assert!( - require_verification_integrity_projection( - verification_integrity_value_projection(Some("text"), Some(2), Some(b"ok")), - false, - ) - .is_ok() - ); - let oversized = [b'x'; MAX_INTEGRITY_RESULT_UTF8_BYTES + 1]; - for (value_type, byte_length, value, extra) in [ - (None, None, None, false), - (Some("text"), Some(0), Some(b"".as_slice()), false), - ( - Some("text"), - i64::try_from(oversized.len()).ok(), - Some(oversized.as_slice()), - false, - ), - (Some("text"), Some(6), Some(b"not ok".as_slice()), false), - (Some("text"), Some(2), Some(b"ok".as_slice()), true), - ] { - assert!( - require_verification_integrity_projection( - verification_integrity_value_projection(value_type, byte_length, value), - extra, - ) - .is_err() - ); + assert!(require_verification_integrity_projection(true, false).is_ok()); + for (value_matches, extra) in [(false, false), (true, true)] { + assert!(require_verification_integrity_projection(value_matches, extra).is_err()); } } diff --git a/crates/service_sqlite/src/integrity/inspection.rs b/crates/service_sqlite/src/integrity/inspection.rs @@ -119,12 +119,11 @@ impl ServiceSqliteIntegrityReport { #[cfg(any(target_os = "linux", target_os = "macos"))] mod native { - use sqlx::{Connection, Row, SqliteConnection}; + use sqlx::{Connection, SqliteConnection}; use super::{IntegrityCheckOutcome, IntegrityCheckedAtUnixMs, ServiceSqliteIntegrityReport}; use crate::{ServiceSqliteError, ServiceSqliteErrorKind}; - const SQLITE_INTEGRITY_SQL: &str = "PRAGMA integrity_check(1)"; const FOREIGN_KEY_SQL: &str = "SELECT 1 FROM pragma_foreign_key_check LIMIT 1"; pub(crate) async fn inspect_database_integrity( @@ -155,15 +154,17 @@ mod native { #[cfg(test)] super::test_seam::pause(super::test_seam::PHASE_BEFORE_SQLITE).await; - let sqlite_rows = sqlx::query(SQLITE_INTEGRITY_SQL) + let sqlite_rows = sqlx::query(crate::persisted_value::INTEGRITY_CHECK_SQL) .fetch_all(&mut *transaction) .await; validate()?; let sqlite = match sqlite_rows { - Ok(rows) if rows.len() == 1 => match rows[0].try_get::<&str, _>(0) { - Ok(value) => classify_integrity_value(value), - Err(_) => return rollback_error(transaction, &mut validate).await, - }, + Ok(rows) if rows.len() == 1 => { + match crate::persisted_value::integrity_result_failed(&rows[0]) { + Some(failed) => classify_integrity_failure(failed), + None => return rollback_error(transaction, &mut validate).await, + } + } Ok(_) | Err(_) => return rollback_error(transaction, &mut validate).await, }; @@ -205,17 +206,17 @@ mod native { ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity) } - fn classify_integrity_value(value: &str) -> IntegrityCheckOutcome { - if value == "ok" { - IntegrityCheckOutcome::Verified - } else { + fn classify_integrity_failure(failed: bool) -> IntegrityCheckOutcome { + if failed { IntegrityCheckOutcome::Failed + } else { + IntegrityCheckOutcome::Verified } } #[cfg(test)] pub(super) fn classify_test_value(value: &str) -> IntegrityCheckOutcome { - classify_integrity_value(value) + classify_integrity_failure(value != "ok") } } diff --git a/crates/service_sqlite/src/integrity/mod.rs b/crates/service_sqlite/src/integrity/mod.rs @@ -292,11 +292,13 @@ fn require_schema_report_projection( pub(crate) async fn verify_database_integrity( connection: &mut SqliteConnection, ) -> Result<(), ServiceSqliteError> { - let rows = sqlx::query("PRAGMA integrity_check(1)") + let rows = sqlx::query(crate::persisted_value::INTEGRITY_CHECK_SQL) .fetch_all(&mut *connection) .await .map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?; - let value = rows.first().and_then(|row| row.try_get::<&str, _>(0).ok()); + let value = rows + .first() + .and_then(crate::persisted_value::bounded_integrity_bytes); catalog_corrupt_unless(integrity_projection_matches(rows.len(), value))?; let foreign_key_violation = sqlx::query_scalar::<_, i64>("SELECT 1 FROM pragma_foreign_key_check LIMIT 1") @@ -342,10 +344,11 @@ fn require_no_foreign_key_violation(present: bool) -> Result<(), ServiceSqliteEr } #[cfg(any(target_os = "linux", target_os = "macos"))] -fn integrity_projection_matches(row_count: usize, value: Option<&str>) -> bool { +fn integrity_projection_matches(row_count: usize, value: Option<&[u8]>) -> bool { let present = value.is_some(); - let bounded = value.is_some_and(|value| value.len() <= 64); - let exact = value == Some("ok"); + let bounded = value + .is_some_and(|value| value.len() <= crate::persisted_value::MAX_INTEGRITY_RESULT_BYTES); + let exact = value == Some(b"ok".as_slice()); crate::all_constraints([row_count == 1, present, bounded, exact]) } @@ -401,12 +404,12 @@ mod tests { #[test] fn database_integrity_projection_rejects_each_independent_drift() { - assert!(integrity_projection_matches(1, Some("ok"))); - assert!(!integrity_projection_matches(0, Some("ok"))); - assert!(!integrity_projection_matches(2, Some("ok"))); + assert!(integrity_projection_matches(1, Some(b"ok"))); + assert!(!integrity_projection_matches(0, Some(b"ok"))); + assert!(!integrity_projection_matches(2, Some(b"ok"))); assert!(!integrity_projection_matches(1, None)); - assert!(!integrity_projection_matches(1, Some("not-ok"))); - let oversized = "x".repeat(65); + assert!(!integrity_projection_matches(1, Some(b"not-ok"))); + let oversized = [b'x'; 65]; assert!(!integrity_projection_matches(1, Some(&oversized))); } diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs @@ -17,6 +17,8 @@ mod migration; #[cfg(any(target_os = "linux", target_os = "macos"))] mod native_metadata; mod open; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod persisted_value; mod restore; #[cfg(any(target_os = "linux", target_os = "macos"))] #[allow( diff --git a/crates/service_sqlite/src/metadata.rs b/crates/service_sqlite/src/metadata.rs @@ -430,16 +430,21 @@ async fn read_database_metadata( .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?; let rows = sqlx::query( "SELECT - singleton, service_id, instance_id, source_generation, + singleton, state_schema_version, created_at_unix_ms, - typeof(singleton) AS singleton_type, - typeof(service_id) AS service_id_type, - typeof(instance_id) AS instance_id_type, - typeof(source_generation) AS source_generation_type, - typeof(state_schema_version) AS state_schema_version_type, - typeof(created_at_unix_ms) AS created_at_unix_ms_type + typeof(singleton) = 'integer' AS singleton_type_ok, + typeof(service_id) = 'text' AS service_id_type_ok, + length(CAST(service_id AS BLOB)) AS service_id_length, + substr(CAST(service_id AS BLOB), 1, 129) AS service_id_prefix, + typeof(instance_id) = 'text' AS instance_id_type_ok, + length(CAST(instance_id AS BLOB)) AS instance_id_length, + substr(CAST(instance_id AS BLOB), 1, 129) AS instance_id_prefix, + typeof(source_generation) = 'blob' AS source_generation_type_ok, + length(source_generation) AS source_generation_length, + substr(source_generation, 1, 33) AS source_generation_prefix, + typeof(state_schema_version) = 'integer' AS state_schema_version_type_ok, + typeof(created_at_unix_ms) = 'integer' AS created_at_unix_ms_type_ok FROM radroots_service_metadata - ORDER BY singleton LIMIT 2", ) .fetch_all(&mut *connection) @@ -452,21 +457,17 @@ async fn read_database_metadata( MetadataFailureKind::Corrupt })); }; - for (column, expected_type) in [ - ("singleton_type", "integer"), - ("service_id_type", "text"), - ("instance_id_type", "text"), - ("source_generation_type", "blob"), - ("state_schema_version_type", "integer"), - ("created_at_unix_ms_type", "integer"), + for column in [ + "singleton_type_ok", + "state_schema_version_type_ok", + "created_at_unix_ms_type_ok", ] { - if row - .try_get::<String, _>(column) - .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))? - != expected_type - { - return Err(metadata_error(MetadataFailureKind::Corrupt)); - } + require_metadata_condition( + row.try_get::<i64, _>(column) + .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))? + == 1, + MetadataFailureKind::Corrupt, + )?; } require_metadata_condition( row.try_get::<i64, _>("singleton") @@ -474,23 +475,37 @@ async fn read_database_metadata( == 1, MetadataFailureKind::Corrupt, )?; - let service = ServiceId::new( - row.try_get::<String, _>("service_id") - .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + let service = crate::persisted_value::bounded_utf8( + row, + "service_id_type_ok", + "service_id_length", + "service_id_prefix", + 1, + crate::persisted_value::MAX_IDENTIFIER_UTF8_BYTES, ) - .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?; - let instance = InstanceId::new( - row.try_get::<String, _>("instance_id") - .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + .and_then(|value| ServiceId::new(value).ok()) + .ok_or_else(|| metadata_error(MetadataFailureKind::Corrupt))?; + let instance = crate::persisted_value::bounded_utf8( + row, + "instance_id_type_ok", + "instance_id_length", + "instance_id_prefix", + 1, + crate::persisted_value::MAX_IDENTIFIER_UTF8_BYTES, ) - .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?; - let source_generation = SourceGeneration::new( - row.try_get::<Vec<u8>, _>("source_generation") - .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))? - .try_into() - .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?, + .and_then(|value| InstanceId::new(value).ok()) + .ok_or_else(|| metadata_error(MetadataFailureKind::Corrupt))?; + let source_generation = crate::persisted_value::bounded_bytes( + row, + "source_generation_type_ok", + "source_generation_length", + "source_generation_prefix", + 32, + 32, ) - .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?; + .and_then(|value| <[u8; 32]>::try_from(value).ok()) + .and_then(|value| SourceGeneration::new(value).ok()) + .ok_or_else(|| metadata_error(MetadataFailureKind::Corrupt))?; let state_schema_version = NonZeroU32::new( u32::try_from( row.try_get::<i64, _>("state_schema_version") @@ -956,6 +971,67 @@ mod tests { ); } + let oversized_text = "a".repeat(4 * 1024 * 1024); + for column in ["service_id", "instance_id"] { + let mut connection = memory_connection().await; + sqlx::raw_sql(PERMISSIVE_TABLE) + .execute(&mut connection) + .await + .expect("permissive metadata table"); + sqlx::query("PRAGMA application_id = 1380209489") + .execute(&mut connection) + .await + .expect("application ID"); + sqlx::raw_sql( + "INSERT INTO radroots_service_metadata VALUES + (1, 'myc', 'primary', randomblob(32), 1, 1700000000000)", + ) + .execute(&mut connection) + .await + .expect("metadata row"); + let statement = match column { + "service_id" => "UPDATE radroots_service_metadata SET service_id = ?", + "instance_id" => "UPDATE radroots_service_metadata SET instance_id = ?", + _ => unreachable!("fixed oversized identifier column inventory"), + }; + sqlx::query(statement) + .bind(&oversized_text) + .execute(&mut connection) + .await + .expect("oversized persisted identifier"); + assert_eq!( + verify_database_metadata(&mut connection, &expected.identity()) + .await + .expect_err("oversized identifier must fail before decode") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + } + + let mut oversized_generation = memory_connection().await; + sqlx::raw_sql(PERMISSIVE_TABLE) + .execute(&mut oversized_generation) + .await + .expect("permissive metadata table"); + sqlx::query("PRAGMA application_id = 1380209489") + .execute(&mut oversized_generation) + .await + .expect("application ID"); + sqlx::query( + "INSERT INTO radroots_service_metadata VALUES + (1, 'myc', 'primary', zeroblob(4194304), 1, 1700000000000)", + ) + .execute(&mut oversized_generation) + .await + .expect("oversized persisted generation"); + assert_eq!( + verify_database_metadata(&mut oversized_generation, &expected.identity()) + .await + .expect_err("oversized generation must fail before decode") + .kind(), + ServiceSqliteErrorKind::Metadata + ); + for (application_id, statement) in [ (0_i64, "PRAGMA application_id = 0"), (-1, "PRAGMA application_id = -1"), diff --git a/crates/service_sqlite/src/migration.rs b/crates/service_sqlite/src/migration.rs @@ -1283,42 +1283,40 @@ async fn read_migration_history( let rows = sqlx::query( "SELECT version, - CASE WHEN typeof(name) = 'text' AND length(CAST(name AS BLOB)) <= 128 - THEN name END AS name, - CASE WHEN typeof(checksum) = 'blob' AND length(checksum) <= 32 - THEN checksum END AS checksum, applied_at_unix_s, - CASE WHEN typeof(service_version) = 'text' - AND length(CAST(service_version AS BLOB)) <= 128 - THEN service_version END AS service_version, - CASE WHEN typeof(service_commit) = 'text' - AND length(CAST(service_commit AS BLOB)) <= 40 - THEN service_commit END AS service_commit, - CASE WHEN typeof(lib_revision) = 'text' - AND length(CAST(lib_revision AS BLOB)) <= 40 - THEN lib_revision END AS lib_revision, - CASE WHEN typeof(rust_version) = 'text' - AND length(CAST(rust_version AS BLOB)) <= 128 - THEN rust_version END AS rust_version, - CASE WHEN typeof(target) = 'text' AND length(CAST(target AS BLOB)) <= 128 - THEN target END AS target, - CASE WHEN typeof(feature_profile) = 'text' - AND length(CAST(feature_profile AS BLOB)) <= 128 - THEN feature_profile END AS feature_profile, config_contract_version, state_contract_version, admin_contract_version, status_contract_version, provider_contract_version, - typeof(version) AS version_type, typeof(name) AS name_type, - typeof(checksum) AS checksum_type, typeof(applied_at_unix_s) AS applied_at_type, - typeof(service_version) AS service_version_type, - typeof(service_commit) AS service_commit_type, - typeof(lib_revision) AS lib_revision_type, - typeof(rust_version) AS rust_version_type, - typeof(target) AS target_type, typeof(feature_profile) AS feature_profile_type, - typeof(config_contract_version) AS config_contract_version_type, - typeof(state_contract_version) AS state_contract_version_type, - typeof(admin_contract_version) AS admin_contract_version_type, - typeof(status_contract_version) AS status_contract_version_type, - typeof(provider_contract_version) AS provider_contract_version_type + typeof(version) = 'integer' AS version_type_ok, + typeof(name) = 'text' AS name_type_ok, + length(CAST(name AS BLOB)) AS name_length, + substr(CAST(name AS BLOB), 1, 129) AS name_prefix, + typeof(checksum) = 'blob' AS checksum_type_ok, + length(checksum) AS checksum_length, + substr(checksum, 1, 33) AS checksum_prefix, + typeof(applied_at_unix_s) = 'integer' AS applied_at_type_ok, + typeof(service_version) = 'text' AS service_version_type_ok, + length(CAST(service_version AS BLOB)) AS service_version_length, + substr(CAST(service_version AS BLOB), 1, 129) AS service_version_prefix, + typeof(service_commit) = 'text' AS service_commit_type_ok, + length(CAST(service_commit AS BLOB)) AS service_commit_length, + substr(CAST(service_commit AS BLOB), 1, 41) AS service_commit_prefix, + typeof(lib_revision) = 'text' AS lib_revision_type_ok, + length(CAST(lib_revision AS BLOB)) AS lib_revision_length, + substr(CAST(lib_revision AS BLOB), 1, 41) AS lib_revision_prefix, + typeof(rust_version) = 'text' AS rust_version_type_ok, + length(CAST(rust_version AS BLOB)) AS rust_version_length, + substr(CAST(rust_version AS BLOB), 1, 129) AS rust_version_prefix, + typeof(target) = 'text' AS target_type_ok, + length(CAST(target AS BLOB)) AS target_length, + substr(CAST(target AS BLOB), 1, 129) AS target_prefix, + typeof(feature_profile) = 'text' AS feature_profile_type_ok, + length(CAST(feature_profile AS BLOB)) AS feature_profile_length, + substr(CAST(feature_profile AS BLOB), 1, 129) AS feature_profile_prefix, + typeof(config_contract_version) = 'integer' AS config_contract_version_type_ok, + typeof(state_contract_version) = 'integer' AS state_contract_version_type_ok, + typeof(admin_contract_version) = 'integer' AS admin_contract_version_type_ok, + typeof(status_contract_version) = 'integer' AS status_contract_version_type_ok, + typeof(provider_contract_version) = 'integer' AS provider_contract_version_type_ok FROM schema_migrations ORDER BY version LIMIT 4097", @@ -1337,47 +1335,50 @@ async fn read_migration_history( fn parse_applied_migration( row: &sqlx::sqlite::SqliteRow, ) -> Result<AppliedMigration, ServiceSqliteError> { - for (column, expected_type) in [ - ("version_type", "integer"), - ("name_type", "text"), - ("checksum_type", "blob"), - ("applied_at_type", "integer"), - ("service_version_type", "text"), - ("service_commit_type", "text"), - ("lib_revision_type", "text"), - ("rust_version_type", "text"), - ("target_type", "text"), - ("feature_profile_type", "text"), - ("config_contract_version_type", "integer"), - ("state_contract_version_type", "integer"), - ("admin_contract_version_type", "integer"), - ("status_contract_version_type", "integer"), - ("provider_contract_version_type", "integer"), + for column in [ + "version_type_ok", + "applied_at_type_ok", + "config_contract_version_type_ok", + "state_contract_version_type_ok", + "admin_contract_version_type_ok", + "status_contract_version_type_ok", + "provider_contract_version_type_ok", ] { - if row - .try_get::<String, _>(column) - .map_err(|source| migration_source(MigrationFailureKind::HistoryCorrupt, source))? - != expected_type - { - return Err(migration_error(MigrationFailureKind::HistoryCorrupt)); - } + require_migration_condition( + row.try_get::<i64, _>(column) + .map_err(|source| migration_source(MigrationFailureKind::HistoryCorrupt, source))? + == 1, + MigrationFailureKind::HistoryCorrupt, + )?; } let version = u32::try_from( row.try_get::<i64, _>("version") .map_err(|source| migration_source(MigrationFailureKind::HistoryCorrupt, source))?, ) .map_err(|_| migration_error(MigrationFailureKind::HistoryCorrupt))?; - let name = row - .try_get::<String, _>("name") - .map_err(|source| migration_source(MigrationFailureKind::HistoryCorrupt, source))?; - if !valid_name(&name) { + let name = crate::persisted_value::bounded_utf8( + row, + "name_type_ok", + "name_length", + "name_prefix", + 1, + MAX_MIGRATION_NAME_UTF8_BYTES, + ) + .ok_or_else(|| migration_error(MigrationFailureKind::HistoryCorrupt))?; + if !valid_name(name) { return Err(migration_error(MigrationFailureKind::HistoryCorrupt)); } - let checksum: [u8; 32] = row - .try_get::<Vec<u8>, _>("checksum") - .map_err(|source| migration_source(MigrationFailureKind::HistoryCorrupt, source))? - .try_into() - .map_err(|_| migration_error(MigrationFailureKind::HistoryCorrupt))?; + let checksum: [u8; 32] = crate::persisted_value::bounded_bytes( + row, + "checksum_type_ok", + "checksum_length", + "checksum_prefix", + 32, + 32, + ) + .ok_or_else(|| migration_error(MigrationFailureKind::HistoryCorrupt))? + .try_into() + .map_err(|_| migration_error(MigrationFailureKind::HistoryCorrupt))?; let applied_at = MigrationAppliedAtUnixSeconds::new( u64::try_from( row.try_get::<i64, _>("applied_at_unix_s") @@ -1386,9 +1387,16 @@ fn parse_applied_migration( .map_err(|_| migration_error(MigrationFailureKind::HistoryCorrupt))?, ) .map_err(|_| migration_error(MigrationFailureKind::HistoryCorrupt))?; - let text = |column| { - row.try_get::<String, _>(column) - .map_err(|source| migration_source(MigrationFailureKind::HistoryCorrupt, source)) + let text = |type_column, length_column, prefix_column, minimum, maximum| { + crate::persisted_value::bounded_utf8( + row, + type_column, + length_column, + prefix_column, + minimum, + maximum, + ) + .ok_or_else(|| migration_error(MigrationFailureKind::HistoryCorrupt)) }; let version_field = |column| { u32::try_from( @@ -1398,12 +1406,48 @@ fn parse_applied_migration( .map_err(|_| migration_error(MigrationFailureKind::HistoryCorrupt)) }; let build = MigrationBuildIdentity::new( - text("service_version")?, - text("service_commit")?, - text("lib_revision")?, - text("rust_version")?, - text("target")?, - text("feature_profile")?, + text( + "service_version_type_ok", + "service_version_length", + "service_version_prefix", + 1, + MAX_MIGRATION_BUILD_ID_UTF8_BYTES, + )?, + text( + "service_commit_type_ok", + "service_commit_length", + "service_commit_prefix", + 40, + 40, + )?, + text( + "lib_revision_type_ok", + "lib_revision_length", + "lib_revision_prefix", + 40, + 40, + )?, + text( + "rust_version_type_ok", + "rust_version_length", + "rust_version_prefix", + 1, + MAX_MIGRATION_BUILD_ID_UTF8_BYTES, + )?, + text( + "target_type_ok", + "target_length", + "target_prefix", + 1, + MAX_MIGRATION_BUILD_ID_UTF8_BYTES, + )?, + text( + "feature_profile_type_ok", + "feature_profile_length", + "feature_profile_prefix", + 1, + MAX_MIGRATION_BUILD_ID_UTF8_BYTES, + )?, version_field("config_contract_version")?, version_field("state_contract_version")?, version_field("admin_contract_version")?, @@ -1413,7 +1457,7 @@ fn parse_applied_migration( .map_err(|_| migration_error(MigrationFailureKind::HistoryCorrupt))?; Ok(AppliedMigration { version, - name, + name: name.to_owned(), checksum: MigrationChecksum::from_bytes(checksum), applied_at, build, diff --git a/crates/service_sqlite/src/persisted_value.rs b/crates/service_sqlite/src/persisted_value.rs @@ -0,0 +1,93 @@ +//! Bounded projections for untrusted SQLite TEXT and BLOB values. + +use sqlx::{Row, sqlite::SqliteRow}; + +pub(crate) const MAX_IDENTIFIER_UTF8_BYTES: usize = 128; +pub(crate) const MAX_INTEGRITY_RESULT_BYTES: usize = 64; +pub(crate) const INTEGRITY_CHECK_SQL: &str = "PRAGMA integrity_check(1)"; + +pub(crate) fn bounded_bytes<'row>( + row: &'row SqliteRow, + type_ok_column: &str, + length_column: &str, + prefix_column: &str, + minimum: usize, + maximum: usize, +) -> Option<&'row [u8]> { + let type_ok = row.try_get::<i64, _>(type_ok_column).ok()? == 1; + let length = row.try_get::<Option<i64>, _>(length_column).ok()??; + let length = usize::try_from(length).ok()?; + let prefix = row.try_get::<Option<&'row [u8]>, _>(prefix_column).ok()??; + bounded_projection(type_ok, length, prefix, minimum, maximum) +} + +pub(crate) fn bounded_utf8<'row>( + row: &'row SqliteRow, + type_ok_column: &str, + length_column: &str, + prefix_column: &str, + minimum: usize, + maximum: usize, +) -> Option<&'row str> { + core::str::from_utf8(bounded_bytes( + row, + type_ok_column, + length_column, + prefix_column, + minimum, + maximum, + )?) + .ok() +} + +pub(crate) fn bounded_integrity_bytes(row: &SqliteRow) -> Option<&[u8]> { + let value = row.try_get::<&[u8], _>(0).ok()?; + crate::all_constraints([!value.is_empty(), value.len() <= MAX_INTEGRITY_RESULT_BYTES]) + .then_some(value) +} + +pub(crate) fn integrity_result_failed(row: &SqliteRow) -> Option<bool> { + let value = row.try_get::<&[u8], _>(0).ok()?; + (!value.is_empty()).then_some(value != b"ok") +} + +fn bounded_projection( + type_ok: bool, + length: usize, + prefix: &[u8], + minimum: usize, + maximum: usize, +) -> Option<&[u8]> { + crate::all_constraints([ + type_ok, + minimum <= maximum, + length >= minimum, + length <= maximum, + prefix.len() == length, + ]) + .then_some(prefix) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounded_projection_rejects_every_independent_drift() { + assert_eq!( + bounded_projection(true, 2, b"ok", 1, 64), + Some(b"ok".as_slice()) + ); + assert!(bounded_projection(false, 2, b"ok", 1, 64).is_none()); + assert!(bounded_projection(true, 2, b"ok", 65, 64).is_none()); + assert!(bounded_projection(true, 0, b"", 1, 64).is_none()); + assert!(bounded_projection(true, 65, &[b'x'; 65], 1, 64).is_none()); + assert!(bounded_projection(true, 2, b"x", 1, 64).is_none()); + } + + #[test] + fn integrity_query_and_borrowed_byte_cap_are_exact() { + assert_eq!(INTEGRITY_CHECK_SQL, "PRAGMA integrity_check(1)"); + assert_eq!(MAX_INTEGRITY_RESULT_BYTES, 64); + } +} diff --git a/crates/service_sqlite/src/restore/stage.rs b/crates/service_sqlite/src/restore/stage.rs @@ -368,23 +368,38 @@ async fn verify_read_only_policy( .fetch_one(&mut *connection) .await .map_err(|source| restore_source(RestoreFailureKind::Policy, source))?; - let databases = sqlx::query("PRAGMA database_list") - .fetch_all(connection) - .await - .map_err(|source| restore_source(RestoreFailureKind::Policy, source))?; + let databases = sqlx::query( + "SELECT + seq, + typeof(name) = 'text' AS name_type_ok, + length(CAST(name AS BLOB)) AS name_length, + substr(CAST(name AS BLOB), 1, 5) AS name_prefix + FROM pragma_database_list + LIMIT 2", + ) + .fetch_all(connection) + .await + .map_err(|source| restore_source(RestoreFailureKind::Policy, source))?; let first_sequence = databases .first() .and_then(|row| row.try_get::<i64, _>(0).ok()); - let first_name = databases - .first() - .and_then(|row| row.try_get::<String, _>(1).ok()); + let first_name = databases.first().and_then(|row| { + crate::persisted_value::bounded_utf8( + row, + "name_type_ok", + "name_length", + "name_prefix", + 1, + 4, + ) + }); require_restore_condition( read_only_policy_matches( query_only, trusted_schema, databases.len(), first_sequence, - first_name.as_deref(), + first_name, ), RestoreFailureKind::Policy, )?; diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs @@ -24,6 +24,7 @@ const METADATA_SOURCE: &str = include_str!("../src/metadata.rs"); const MIGRATION_SOURCE: &str = include_str!("../src/migration.rs"); const NATIVE_METADATA_SOURCE: &str = include_str!("../src/native_metadata.rs"); const OPEN_SOURCE: &str = include_str!("../src/open.rs"); +const PERSISTED_VALUE_SOURCE: &str = include_str!("../src/persisted_value.rs"); const RESTORE_MARKER_SOURCE: &str = include_str!("../src/restore/marker.rs"); const RESTORE_FINALIZE_SOURCE: &str = include_str!("../src/restore/finalize.rs"); const RESTORE_RECOVER_SOURCE: &str = include_str!("../src/restore/recover.rs"); @@ -127,6 +128,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "migration", "native_metadata", "open", + "persisted_value", "restore", "sqlite_native_backup", "status", @@ -136,6 +138,27 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { ); assert!(public_modules(ROOT).is_empty()); for required in [ + "pub(crate) const INTEGRITY_CHECK_SQL", + "PRAGMA integrity_check(1)", + "pub(crate) const MAX_INTEGRITY_RESULT_BYTES: usize = 64", + "pub(crate) fn bounded_integrity_bytes", + "pub(crate) fn integrity_result_failed", + "row.try_get::<&[u8], _>(0)", + "pub(crate) fn bounded_bytes", + "pub(crate) fn bounded_utf8", + ] { + assert!( + PERSISTED_VALUE_SOURCE.contains(required), + "persisted-value boundary is missing `{required}`" + ); + } + for forbidden in ["pub mod persisted_value", "pub use persisted_value"] { + assert!( + !ROOT.contains(forbidden), + "persisted-value boundary leaked through `{forbidden}`" + ); + } + for required in [ "`ServiceSqliteHost` is the only public connection host", "borrowed `ServiceSqliteTransaction` executor", "transaction begin, commit, rollback, policy", @@ -797,8 +820,12 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "statement_control_rejected", "SAVEPOINT radroots_migration_transaction_probe", "FROM pragma_database_list", - "CASE WHEN typeof(name) = 'text'", - "CASE WHEN typeof(checksum) = 'blob'", + "typeof(name) = 'text' AS name_type_ok", + "substr(CAST(name AS BLOB), 1, 129) AS name_prefix", + "typeof(checksum) = 'blob' AS checksum_type_ok", + "substr(checksum, 1, 33) AS checksum_prefix", + "crate::persisted_value::bounded_utf8", + "crate::persisted_value::bounded_bytes", ] { assert!( migration_production.contains(required), @@ -906,7 +933,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "ForeignKeyViolation", "Box<[IntegrityDiagnosticCode]>", "pub const fn storage_integrity", - "PRAGMA integrity_check(1)", + "crate::persisted_value::INTEGRITY_CHECK_SQL", "SELECT 1 FROM pragma_foreign_key_check LIMIT 1", "connection.begin().await", "transaction.rollback().await", @@ -942,7 +969,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "std::fs", "OpenOptions", "write_all", - "persist", + ".persist(", "cache", "myc_", "rhi_", @@ -962,8 +989,8 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC", "Mode::RUSR | Mode::WUSR | Mode::XUSR", "Mode::RUSR | Mode::WUSR", - "PRAGMA integrity_check(1)", - "MAX_INTEGRITY_RESULT_UTF8_BYTES", + "crate::persisted_value::INTEGRITY_CHECK_SQL", + "crate::persisted_value::bounded_integrity_bytes", "FROM pragma_foreign_key_check", "BackupSourceValidator", "PoolConnection<Sqlite>", @@ -1041,7 +1068,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "FROM pragma_database_list", "FROM main.sqlite_schema", "Some(\"table\")", - "PRAGMA integrity_check(1)", + "crate::persisted_value::INTEGRITY_CHECK_SQL", "FROM pragma_foreign_key_check", "state_schema_version() <= expected.supported_state_schema_version()", "binding.hash_state(maximum_state_bytes)", @@ -1154,7 +1181,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "/dev/fd/{descriptor}", "PRAGMA query_only = ON", "PRAGMA trusted_schema = OFF", - "PRAGMA database_list", + "FROM pragma_database_list", "cleanup_exact_stage", "authority.release()", "StagedServiceRestore([redacted])",