lib

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

mod.rs (23039B)


      1 //! Exact schema-object catalog verification.
      2 
      3 pub(crate) mod catalog;
      4 mod inspection;
      5 
      6 pub use catalog::{
      7     SchemaCatalog, SchemaCatalogContractError, SchemaDigest, SchemaObject, SchemaObjectKind,
      8     SchemaVersionCatalog,
      9 };
     10 pub use inspection::{
     11     IntegrityCheckOutcome, IntegrityCheckedAtUnixMs, IntegrityDiagnosticCode,
     12     ServiceSqliteIntegrityReport,
     13 };
     14 
     15 #[cfg(any(target_os = "linux", target_os = "macos"))]
     16 pub(crate) use inspection::inspect_database_integrity;
     17 
     18 #[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
     19 pub(crate) use inspection::test_seam as integrity_test_seam;
     20 
     21 #[cfg(any(target_os = "linux", target_os = "macos"))]
     22 use core::fmt;
     23 #[cfg(any(target_os = "linux", target_os = "macos"))]
     24 use std::error::Error;
     25 
     26 #[cfg(any(target_os = "linux", target_os = "macos"))]
     27 use sqlx::{Row, SqliteConnection};
     28 
     29 #[cfg(any(target_os = "linux", target_os = "macos"))]
     30 use crate::{ServiceSqliteError, ServiceSqliteErrorKind};
     31 
     32 #[cfg(any(target_os = "linux", target_os = "macos"))]
     33 use self::catalog::{
     34     MAX_SCHEMA_CATALOG_UTF8_BYTES, MAX_SCHEMA_OBJECT_COUNT, MAX_SCHEMA_SQL_UTF8_BYTES, ObjectRef,
     35     object_digest, snapshot_digest,
     36 };
     37 
     38 #[cfg(any(target_os = "linux", target_os = "macos"))]
     39 const READ_SCHEMA_CATALOG_SQL: &str = r#"
     40 WITH raw_catalog AS (
     41     SELECT type, name, tbl_name, sql
     42     FROM main.sqlite_schema
     43     WHERE NOT (typeof(name) = 'text' AND substr(name, 1, 7) = 'sqlite_')
     44 ), bounded_catalog AS (
     45     SELECT
     46         type,
     47         name,
     48         tbl_name,
     49         sql,
     50         COUNT(*) OVER () AS object_count,
     51         COALESCE(SUM(length(CAST(sql AS BLOB))) OVER (), 0) AS total_sql_bytes
     52     FROM raw_catalog
     53 )
     54 SELECT
     55     object_count,
     56     total_sql_bytes,
     57     CASE
     58         WHEN object_count <= 4096
     59          AND typeof(type) = 'text'
     60          AND length(CAST(type AS BLOB)) BETWEEN 1 AND 16
     61         THEN type
     62     END AS bounded_type,
     63     CASE
     64         WHEN object_count <= 4096
     65          AND typeof(name) = 'text'
     66          AND length(CAST(name AS BLOB)) BETWEEN 1 AND 128
     67         THEN name
     68     END AS bounded_name,
     69     CASE
     70         WHEN object_count <= 4096
     71          AND typeof(tbl_name) = 'text'
     72          AND length(CAST(tbl_name AS BLOB)) BETWEEN 1 AND 128
     73         THEN tbl_name
     74     END AS bounded_table_name,
     75     CASE
     76         WHEN object_count <= 4096
     77          AND total_sql_bytes <= 16777216
     78          AND typeof(sql) = 'text'
     79          AND length(CAST(sql AS BLOB)) BETWEEN 1 AND 1048576
     80         THEN sql
     81     END AS bounded_sql
     82 FROM bounded_catalog
     83 LIMIT 4097
     84 "#;
     85 
     86 #[cfg(any(target_os = "linux", target_os = "macos"))]
     87 #[derive(Clone, Debug, PartialEq, Eq)]
     88 pub(crate) struct SchemaVerificationReport {
     89     version: u32,
     90     expected_count: u32,
     91     actual_count: u32,
     92     expected_digest: SchemaDigest,
     93     actual_digest: SchemaDigest,
     94 }
     95 
     96 #[cfg(any(target_os = "linux", target_os = "macos"))]
     97 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     98 enum SchemaIntegrityFailureKind {
     99     CatalogMismatch,
    100     CatalogCorrupt,
    101 }
    102 
    103 #[cfg(any(target_os = "linux", target_os = "macos"))]
    104 struct SchemaIntegrityFailure {
    105     kind: SchemaIntegrityFailureKind,
    106     report: Option<SchemaVerificationReport>,
    107 }
    108 
    109 #[cfg(any(target_os = "linux", target_os = "macos"))]
    110 impl fmt::Debug for SchemaIntegrityFailure {
    111     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    112         formatter
    113             .debug_struct("SchemaIntegrityFailure")
    114             .field("kind", &self.kind)
    115             .field("report", &self.report)
    116             .finish()
    117     }
    118 }
    119 
    120 #[cfg(any(target_os = "linux", target_os = "macos"))]
    121 impl fmt::Display for SchemaIntegrityFailure {
    122     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    123         formatter.write_str(match self.kind {
    124             SchemaIntegrityFailureKind::CatalogMismatch => {
    125                 "SQLite schema object catalog does not match"
    126             }
    127             SchemaIntegrityFailureKind::CatalogCorrupt => "SQLite schema object catalog is invalid",
    128         })
    129     }
    130 }
    131 
    132 #[cfg(any(target_os = "linux", target_os = "macos"))]
    133 impl Error for SchemaIntegrityFailure {}
    134 
    135 #[cfg(any(target_os = "linux", target_os = "macos"))]
    136 fn integrity_error(kind: SchemaIntegrityFailureKind) -> ServiceSqliteError {
    137     ServiceSqliteError::with_source(
    138         ServiceSqliteErrorKind::Integrity,
    139         SchemaIntegrityFailure { kind, report: None },
    140     )
    141 }
    142 
    143 #[cfg(any(target_os = "linux", target_os = "macos"))]
    144 fn mismatch_error(report: SchemaVerificationReport) -> ServiceSqliteError {
    145     ServiceSqliteError::with_source(
    146         ServiceSqliteErrorKind::Integrity,
    147         SchemaIntegrityFailure {
    148             kind: SchemaIntegrityFailureKind::CatalogMismatch,
    149             report: Some(report),
    150         },
    151     )
    152 }
    153 
    154 #[cfg(any(target_os = "linux", target_os = "macos"))]
    155 struct RuntimeSchemaObject {
    156     kind: SchemaObjectKind,
    157     name: String,
    158     table_name: String,
    159     sql: String,
    160     digest: SchemaDigest,
    161 }
    162 
    163 #[cfg(any(target_os = "linux", target_os = "macos"))]
    164 pub(crate) async fn verify_schema_catalog(
    165     connection: &mut SqliteConnection,
    166     catalog: &SchemaCatalog,
    167     version: u32,
    168 ) -> Result<SchemaVerificationReport, ServiceSqliteError> {
    169     let expected = catalog
    170         .version(version)
    171         .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogMismatch))?;
    172     let rows = sqlx::query(READ_SCHEMA_CATALOG_SQL)
    173         .fetch_all(connection)
    174         .await
    175         .map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    176     require_schema_row_limit(rows.len())?;
    177     let mut objects = Vec::with_capacity(rows.len());
    178     let mut reported_count = None;
    179     let mut reported_total = None;
    180     for row in rows {
    181         let count = row
    182             .try_get::<i64, _>("object_count")
    183             .ok()
    184             .and_then(|value| u32::try_from(value).ok())
    185             .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    186         let total = row
    187             .try_get::<i64, _>("total_sql_bytes")
    188             .ok()
    189             .and_then(|value| usize::try_from(value).ok())
    190             .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    191         require_catalog_summary_projection([
    192             count <= u32::try_from(MAX_SCHEMA_OBJECT_COUNT).unwrap_or(u32::MAX),
    193             total <= MAX_SCHEMA_CATALOG_UTF8_BYTES,
    194             reported_count.is_none_or(|observed| observed == count),
    195             reported_total.is_none_or(|observed| observed == total),
    196         ])?;
    197         reported_count = Some(count);
    198         reported_total = Some(total);
    199         let object_type = row
    200             .try_get::<String, _>("bounded_type")
    201             .map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    202         let name = row
    203             .try_get::<String, _>("bounded_name")
    204             .map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    205         let table_name = row
    206             .try_get::<String, _>("bounded_table_name")
    207             .map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    208         let sql = row
    209             .try_get::<String, _>("bounded_sql")
    210             .map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    211         require_schema_sql_limit(sql.len())?;
    212         let kind = SchemaObjectKind::from_sqlite(&object_type)
    213             .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    214         require_runtime_object_projection(kind, &name, &table_name)?;
    215         let digest = object_digest(kind, &name, &table_name, &sql);
    216         objects.push(RuntimeSchemaObject {
    217             kind,
    218             name,
    219             table_name,
    220             sql,
    221             digest,
    222         });
    223     }
    224     let actual_count = u32::try_from(objects.len())
    225         .map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    226     require_reported_object_count(reported_count, actual_count)?;
    227     let mut identities = std::collections::BTreeSet::new();
    228     require_unique_runtime_objects(
    229         !objects
    230             .iter()
    231             .any(|object| !identities.insert((object.kind, object.name.as_str()))),
    232     )?;
    233     let refs = objects
    234         .iter()
    235         .map(|object| ObjectRef {
    236             kind: object.kind,
    237             name: &object.name,
    238             table_name: &object.table_name,
    239             sql: &object.sql,
    240             digest: object.digest,
    241         })
    242         .collect::<Vec<_>>();
    243     let actual_digest = snapshot_digest(version, &refs);
    244     let report = SchemaVerificationReport {
    245         version,
    246         expected_count: expected.object_count(),
    247         actual_count,
    248         expected_digest: expected.digest(),
    249         actual_digest,
    250     };
    251     require_schema_report_projection(report)
    252 }
    253 
    254 #[cfg(any(target_os = "linux", target_os = "macos"))]
    255 fn require_catalog_summary_projection(matches: [bool; 4]) -> Result<(), ServiceSqliteError> {
    256     crate::all_constraints(matches)
    257         .then_some(())
    258         .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))
    259 }
    260 
    261 #[cfg(any(target_os = "linux", target_os = "macos"))]
    262 fn require_runtime_object_projection(
    263     kind: SchemaObjectKind,
    264     name: &str,
    265     table_name: &str,
    266 ) -> Result<(), ServiceSqliteError> {
    267     crate::all_constraints([
    268         runtime_name_is_valid(name),
    269         runtime_name_is_valid(table_name),
    270         (kind == SchemaObjectKind::Table) == (name == table_name),
    271     ])
    272     .then_some(())
    273     .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))
    274 }
    275 
    276 #[cfg(any(target_os = "linux", target_os = "macos"))]
    277 fn require_schema_report_projection(
    278     report: SchemaVerificationReport,
    279 ) -> Result<SchemaVerificationReport, ServiceSqliteError> {
    280     let matches = crate::all_constraints([
    281         report.expected_count == report.actual_count,
    282         report.expected_digest == report.actual_digest,
    283     ]);
    284     if matches {
    285         Ok(report)
    286     } else {
    287         Err(mismatch_error(report))
    288     }
    289 }
    290 
    291 #[cfg(any(target_os = "linux", target_os = "macos"))]
    292 pub(crate) async fn verify_database_integrity(
    293     connection: &mut SqliteConnection,
    294 ) -> Result<(), ServiceSqliteError> {
    295     let rows = sqlx::query(crate::persisted_value::INTEGRITY_CHECK_SQL)
    296         .fetch_all(&mut *connection)
    297         .await
    298         .map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    299     let value = rows
    300         .first()
    301         .and_then(crate::persisted_value::bounded_integrity_bytes);
    302     catalog_corrupt_unless(integrity_projection_matches(rows.len(), value))?;
    303     let foreign_key_violation =
    304         sqlx::query_scalar::<_, i64>("SELECT 1 FROM pragma_foreign_key_check LIMIT 1")
    305             .fetch_optional(connection)
    306             .await
    307             .map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
    308     require_no_foreign_key_violation(foreign_key_violation.is_some())
    309 }
    310 
    311 #[cfg(any(target_os = "linux", target_os = "macos"))]
    312 fn catalog_corrupt_unless(condition: bool) -> Result<(), ServiceSqliteError> {
    313     condition
    314         .then_some(())
    315         .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))
    316 }
    317 
    318 #[cfg(any(target_os = "linux", target_os = "macos"))]
    319 fn require_schema_row_limit(row_count: usize) -> Result<(), ServiceSqliteError> {
    320     catalog_corrupt_unless(row_count <= MAX_SCHEMA_OBJECT_COUNT)
    321 }
    322 
    323 #[cfg(any(target_os = "linux", target_os = "macos"))]
    324 fn require_schema_sql_limit(sql_bytes: usize) -> Result<(), ServiceSqliteError> {
    325     catalog_corrupt_unless(sql_bytes <= MAX_SCHEMA_SQL_UTF8_BYTES)
    326 }
    327 
    328 #[cfg(any(target_os = "linux", target_os = "macos"))]
    329 fn require_reported_object_count(
    330     reported: Option<u32>,
    331     actual: u32,
    332 ) -> Result<(), ServiceSqliteError> {
    333     catalog_corrupt_unless(reported.unwrap_or(0) == actual)
    334 }
    335 
    336 #[cfg(any(target_os = "linux", target_os = "macos"))]
    337 fn require_unique_runtime_objects(unique: bool) -> Result<(), ServiceSqliteError> {
    338     catalog_corrupt_unless(unique)
    339 }
    340 
    341 #[cfg(any(target_os = "linux", target_os = "macos"))]
    342 fn require_no_foreign_key_violation(present: bool) -> Result<(), ServiceSqliteError> {
    343     catalog_corrupt_unless(!present)
    344 }
    345 
    346 #[cfg(any(target_os = "linux", target_os = "macos"))]
    347 fn integrity_projection_matches(row_count: usize, value: Option<&[u8]>) -> bool {
    348     let present = value.is_some();
    349     let bounded = value
    350         .is_some_and(|value| value.len() <= crate::persisted_value::MAX_INTEGRITY_RESULT_BYTES);
    351     let exact = value == Some(b"ok".as_slice());
    352     crate::all_constraints([row_count == 1, present, bounded, exact])
    353 }
    354 
    355 #[cfg(any(target_os = "linux", target_os = "macos"))]
    356 fn runtime_name_is_valid(value: &str) -> bool {
    357     let bytes = value.as_bytes();
    358     if bytes.is_empty() {
    359         return false;
    360     }
    361     crate::all_constraints([
    362         bytes.len() <= 128,
    363         bytes[0].is_ascii_lowercase(),
    364         bytes[bytes.len() - 1].is_ascii_alphanumeric(),
    365         !bytes.windows(2).any(|pair| pair == b"__"),
    366         bytes
    367             .iter()
    368             .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_'),
    369     ])
    370 }
    371 
    372 #[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
    373 mod tests {
    374 
    375     use super::*;
    376     use sqlx::{Connection, Executor, sqlite::SqliteConnectOptions};
    377 
    378     const TABLE_SQL: &str =
    379         "CREATE TABLE alpha (id INTEGER PRIMARY KEY, value INTEGER NOT NULL) STRICT";
    380     const INDEX_SQL: &str = "CREATE INDEX alpha_value_idx ON alpha(value)";
    381     const TRIGGER_SQL: &str = "CREATE TRIGGER alpha_guard BEFORE UPDATE ON alpha BEGIN SELECT RAISE(ABORT, 'blocked'); END";
    382 
    383     #[test]
    384     fn schema_integrity_failure_inventory_is_complete_and_source_free() {
    385         for (kind, message) in [
    386             (
    387                 SchemaIntegrityFailureKind::CatalogMismatch,
    388                 "SQLite schema object catalog does not match",
    389             ),
    390             (
    391                 SchemaIntegrityFailureKind::CatalogCorrupt,
    392                 "SQLite schema object catalog is invalid",
    393             ),
    394         ] {
    395             let failure = SchemaIntegrityFailure { kind, report: None };
    396             assert_eq!(failure.to_string(), message);
    397             assert!(failure.source().is_none());
    398             assert!(format!("{failure:?}").contains(&format!("{kind:?}")));
    399             let error = integrity_error(kind);
    400             assert_eq!(error.kind(), ServiceSqliteErrorKind::Integrity);
    401             assert!(error.source().is_some());
    402         }
    403     }
    404 
    405     #[test]
    406     fn database_integrity_projection_rejects_each_independent_drift() {
    407         assert!(integrity_projection_matches(1, Some(b"ok")));
    408         assert!(!integrity_projection_matches(0, Some(b"ok")));
    409         assert!(!integrity_projection_matches(2, Some(b"ok")));
    410         assert!(!integrity_projection_matches(1, None));
    411         assert!(!integrity_projection_matches(1, Some(b"not-ok")));
    412         let oversized = [b'x'; 65];
    413         assert!(!integrity_projection_matches(1, Some(&oversized)));
    414     }
    415 
    416     #[test]
    417     fn catalog_projection_helpers_reject_every_independent_drift() {
    418         assert!(require_catalog_summary_projection([true; 4]).is_ok());
    419         for changed in 0..4 {
    420             let mut matches = [true; 4];
    421             matches[changed] = false;
    422             assert!(require_catalog_summary_projection(matches).is_err());
    423         }
    424 
    425         assert!(
    426             require_runtime_object_projection(SchemaObjectKind::Table, "alpha", "alpha").is_ok()
    427         );
    428         assert!(
    429             require_runtime_object_projection(SchemaObjectKind::Index, "alpha_idx", "alpha")
    430                 .is_ok()
    431         );
    432         for (kind, name, table) in [
    433             (SchemaObjectKind::Table, "Bad", "Bad"),
    434             (SchemaObjectKind::Table, "alpha", "Bad"),
    435             (SchemaObjectKind::Table, "alpha", "other"),
    436             (SchemaObjectKind::Index, "alpha", "alpha"),
    437         ] {
    438             assert!(require_runtime_object_projection(kind, name, table).is_err());
    439         }
    440 
    441         let digest = SchemaDigest::from_bytes([7; 32]);
    442         let mut report = SchemaVerificationReport {
    443             version: 1,
    444             expected_count: 1,
    445             actual_count: 1,
    446             expected_digest: digest,
    447             actual_digest: digest,
    448         };
    449         assert!(require_schema_report_projection(report.clone()).is_ok());
    450         report.actual_count = 2;
    451         assert!(require_schema_report_projection(report.clone()).is_err());
    452         report.actual_count = 1;
    453         report.actual_digest = SchemaDigest::from_bytes([8; 32]);
    454         assert!(require_schema_report_projection(report).is_err());
    455 
    456         assert!(catalog_corrupt_unless(true).is_ok());
    457         assert!(catalog_corrupt_unless(false).is_err());
    458         assert!(require_schema_row_limit(MAX_SCHEMA_OBJECT_COUNT).is_ok());
    459         assert!(require_schema_row_limit(MAX_SCHEMA_OBJECT_COUNT + 1).is_err());
    460         assert!(require_schema_sql_limit(MAX_SCHEMA_SQL_UTF8_BYTES).is_ok());
    461         assert!(require_schema_sql_limit(MAX_SCHEMA_SQL_UTF8_BYTES + 1).is_err());
    462         assert!(require_reported_object_count(Some(1), 1).is_ok());
    463         assert!(require_reported_object_count(None, 1).is_err());
    464         assert!(require_reported_object_count(Some(2), 1).is_err());
    465         assert!(require_unique_runtime_objects(true).is_ok());
    466         assert!(require_unique_runtime_objects(false).is_err());
    467         assert!(require_no_foreign_key_violation(false).is_ok());
    468         assert!(require_no_foreign_key_violation(true).is_err());
    469     }
    470 
    471     fn object(
    472         kind: SchemaObjectKind,
    473         name: &'static str,
    474         table_name: &'static str,
    475         sql: &'static str,
    476     ) -> SchemaObject {
    477         SchemaObject::new(
    478             kind,
    479             name,
    480             table_name,
    481             sql,
    482             SchemaObject::computed_digest(kind, name, table_name, sql).unwrap(),
    483         )
    484         .unwrap()
    485     }
    486 
    487     fn expected(objects: Vec<SchemaObject>) -> SchemaCatalog {
    488         let migrations = crate::MigrationCatalog::new([]).unwrap();
    489         let digest = SchemaVersionCatalog::computed_digest(1, objects.iter().cloned()).unwrap();
    490         let version = SchemaVersionCatalog::new(1, objects, digest).unwrap();
    491         SchemaCatalog::new(&migrations, [version]).unwrap()
    492     }
    493 
    494     fn full_objects() -> Vec<SchemaObject> {
    495         vec![
    496             object(
    497                 SchemaObjectKind::Trigger,
    498                 "alpha_guard",
    499                 "alpha",
    500                 TRIGGER_SQL,
    501             ),
    502             object(
    503                 SchemaObjectKind::Index,
    504                 "alpha_value_idx",
    505                 "alpha",
    506                 INDEX_SQL,
    507             ),
    508             object(SchemaObjectKind::Table, "alpha", "alpha", TABLE_SQL),
    509         ]
    510     }
    511 
    512     async fn shared_database() -> SqliteConnection {
    513         let mut connection =
    514             SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(":memory:"))
    515                 .await
    516                 .unwrap();
    517         for statement in catalog::METADATA_SCHEMA_SQL
    518             .into_iter()
    519             .chain(catalog::MIGRATION_LEDGER_SCHEMA_SQL)
    520         {
    521             connection.execute(statement).await.unwrap();
    522         }
    523         connection
    524     }
    525 
    526     async fn full_database() -> SqliteConnection {
    527         let mut connection = shared_database().await;
    528         for statement in [TABLE_SQL, INDEX_SQL, TRIGGER_SQL] {
    529             connection.execute(statement).await.unwrap();
    530         }
    531         connection
    532     }
    533 
    534     #[tokio::test(flavor = "current_thread")]
    535     async fn exact_catalog_is_order_independent_and_report_is_bounded() {
    536         let mut connection = full_database().await;
    537         let catalog = expected(full_objects());
    538         let first = verify_schema_catalog(&mut connection, &catalog, 1)
    539             .await
    540             .unwrap();
    541         let second = verify_schema_catalog(&mut connection, &catalog, 1)
    542             .await
    543             .unwrap();
    544         assert_eq!(first, second);
    545         assert_eq!(first.version, 1);
    546         assert_eq!(first.expected_count, 9);
    547         assert_eq!(first.actual_count, 9);
    548         assert_eq!(first.expected_digest, first.actual_digest);
    549         assert!(!format!("{first:?}").contains(TABLE_SQL));
    550     }
    551 
    552     #[tokio::test(flavor = "current_thread")]
    553     async fn missing_extra_replaced_index_trigger_column_and_view_fail_closed() {
    554         let cases = [
    555             "DROP INDEX alpha_value_idx",
    556             "CREATE TABLE extra (value INTEGER)",
    557             "DROP INDEX alpha_value_idx; CREATE INDEX alpha_value_idx ON alpha(value DESC)",
    558             "DROP TRIGGER alpha_guard; CREATE TRIGGER alpha_guard BEFORE UPDATE ON alpha BEGIN SELECT RAISE(ABORT, 'changed'); END",
    559             "ALTER TABLE alpha ADD COLUMN changed TEXT",
    560             "CREATE VIEW alpha_view AS SELECT value FROM alpha",
    561         ];
    562         for mutation in cases {
    563             let mut connection = full_database().await;
    564             sqlx::raw_sql(mutation)
    565                 .execute(&mut connection)
    566                 .await
    567                 .unwrap();
    568             let error = verify_schema_catalog(&mut connection, &expected(full_objects()), 1)
    569                 .await
    570                 .expect_err("schema drift must fail");
    571             assert_eq!(error.kind(), ServiceSqliteErrorKind::Integrity);
    572             assert!(!error.to_string().contains("alpha"));
    573             assert!(!format!("{error:?}").contains("alpha"));
    574         }
    575 
    576         let mut missing = shared_database().await;
    577         assert_eq!(
    578             verify_schema_catalog(&mut missing, &expected(full_objects()), 1)
    579                 .await
    580                 .expect_err("missing table")
    581                 .kind(),
    582             ServiceSqliteErrorKind::Integrity
    583         );
    584     }
    585 
    586     #[tokio::test(flavor = "current_thread")]
    587     async fn oversized_persisted_sql_is_rejected_before_rust_decode() {
    588         let mut connection = shared_database().await;
    589         let oversized = "x".repeat(catalog::MAX_SCHEMA_SQL_UTF8_BYTES + 1);
    590         let statement = format!("CREATE TABLE oversized (value TEXT DEFAULT '{oversized}')");
    591         sqlx::query(sqlx::AssertSqlSafe(statement))
    592             .execute(&mut connection)
    593             .await
    594             .unwrap();
    595         let error = verify_schema_catalog(&mut connection, &expected(Vec::new()), 1)
    596             .await
    597             .expect_err("oversized SQL must fail");
    598         assert_eq!(error.kind(), ServiceSqliteErrorKind::Integrity);
    599         assert!(!error.to_string().contains(&oversized));
    600         assert!(!format!("{error:?}").contains(&oversized));
    601     }
    602 
    603     #[test]
    604     fn runtime_names_bind_every_grammar_constraint() {
    605         assert!(runtime_name_is_valid("a"));
    606         assert!(runtime_name_is_valid("alpha_2"));
    607         assert!(!runtime_name_is_valid(""));
    608         assert!(!runtime_name_is_valid("Alpha"));
    609         assert!(!runtime_name_is_valid("2alpha"));
    610         assert!(!runtime_name_is_valid("alpha_"));
    611         assert!(!runtime_name_is_valid("alpha__beta"));
    612         assert!(!runtime_name_is_valid("alpha-beta"));
    613         assert!(runtime_name_is_valid(&"a".repeat(128)));
    614         assert!(!runtime_name_is_valid(&"a".repeat(129)));
    615     }
    616 }