fix(database): reject hidden migration source columns

This commit is contained in:
SaladDay
2026-08-01 19:56:17 +00:00
parent c036c6359e
commit ce6edf68a0
2 changed files with 40 additions and 6 deletions
@@ -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)?,
)
}
+22 -6
View File
@@ -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::<Result<Vec<_>, _>>()
})
.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::<Vec<_>>();
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,
)));
}