commit b30761b8fdf8c04d8e4d4de3e5db12d26fdbd600
parent 278349715401e8c8c5d61405dcabffb9880e42e3
Author: triesap <tyson@radroots.org>
Date: Sat, 15 Aug 2026 20:13:31 +0000
service-sqlite: bound persisted diagnostics
Diffstat:
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])",