diff --git a/src-tauri/src/database/backup_restore_certification_ext.rs b/src-tauri/src/database/backup_restore_certification_ext.rs index 0593d1ac9..c07e390ca 100644 --- a/src-tauri/src/database/backup_restore_certification_ext.rs +++ b/src-tauri/src/database/backup_restore_certification_ext.rs @@ -1061,6 +1061,24 @@ fn source_spec_rejects_extra_tables_and_columns_at_both_entries() -> Result<(), "v17-extra-column", "source column set mismatch", &dump_exact_source(&extra_column)?, + )?; + + // `PRAGMA table_info` does not expose generated columns. Replacing an + // expected source field with a generated field therefore proves that + // recognition uses `table_xinfo` and rejects hidden/generated semantics, + // even when the visible name set is otherwise exact. + let generated_expected_column = exact_source_connection(super::SCHEMA_VERSION)?; + generated_expected_column.execute_batch( + "DROP TABLE settings; + CREATE TABLE settings ( + key TEXT, + value TEXT GENERATED ALWAYS AS (key) VIRTUAL + );", + )?; + assert_fixture_rejected( + "v17-generated-expected-column", + "generated_or_hidden", + &dump_exact_source(&generated_expected_column)?, ) } diff --git a/src-tauri/src/database/migration_source.rs b/src-tauri/src/database/migration_source.rs index edce3c5e3..2c88dfc4e 100644 --- a/src-tauri/src/database/migration_source.rs +++ b/src-tauri/src/database/migration_source.rs @@ -1614,7 +1614,7 @@ impl Database { ) -> Result<(), AppError> { let observed_tables = conn .prepare( - "SELECT name FROM sqlite_schema + "SELECT name FROM main.sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", ) @@ -1646,12 +1646,18 @@ impl Database { } for table in &spec.tables { - let pragma = format!("PRAGMA table_info({})", quoted_identifier(table.name)); + // `table_info` omits generated and virtual-table hidden columns. + // `table_xinfo` is required for an exact source column set; a + // generated replacement for an expected column must not acquire + // authority merely because its name and observed values match. + let pragma = format!("PRAGMA main.table_xinfo({})", quoted_identifier(table.name)); let observed_columns = conn .prepare(&pragma) .and_then(|mut statement| { statement - .query_map([], |row| row.get::<_, String>(1))? + .query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, i64>(6)?)) + })? .collect::, _>>() }) .map_err(|error| { @@ -1660,7 +1666,15 @@ impl Database { spec.version, table.name )) })?; - let observed_by_name: BTreeSet<_> = observed_columns.iter().cloned().collect(); + let observed_by_name: BTreeSet<_> = observed_columns + .iter() + .map(|(name, _)| name.clone()) + .collect(); + let hidden_or_generated = observed_columns + .iter() + .filter(|(_, hidden)| *hidden != 0) + .map(|(name, hidden)| format!("{name}:{hidden}")) + .collect::>(); let expected_by_name: BTreeSet<_> = table .columns .iter() @@ -1668,6 +1682,7 @@ impl Database { .collect(); if observed_by_name.len() != observed_columns.len() || observed_by_name != expected_by_name + || !hidden_or_generated.is_empty() { let missing: Vec<_> = expected_by_name .difference(&observed_by_name) @@ -1678,8 +1693,9 @@ impl Database { .cloned() .collect(); return Err(AppError::InvalidInput(format!( - "untrusted v{} source column set mismatch for {}; missing={missing:?}, extra={extra:?}", - spec.version, table.name + "untrusted v{} source column set mismatch for {}; missing={missing:?}, \ + extra={extra:?}, generated_or_hidden={hidden_or_generated:?}", + spec.version, table.name, ))); }