fix(security): copy account tables by shared columns, not SELECT *

Device upgrade testing surfaced a crash: on a cache last written before
v13, account_settings has 4 columns (accountId, signature,
signatureEnabled, notificationsEnabled) but the destination table has 6
(retentionCount/retentionMonths were added at v13). The migrator ran
`INSERT OR IGNORE INTO account_settings SELECT * FROM cache...`, which
supplied 4 values for 6 columns and threw SQLiteException — and because
the done-flag is only set after a successful copy, every launch re-ran
and re-crashed (crash loop).

AccountDataMigrator now copies each table by the column names present in
BOTH the freshly-created destination and the (possibly older) source, so
columns the source lacks take the destination's defaults instead of
overflowing the value list. Verified on-device: the upgrade migrates a
pre-v13 install cleanly and the account stays signed in (sync/backfill
workers run). Regression test seeds a v12 cache and asserts the copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 08:47:05 -05:00
co-authored by Claude Fable 5
parent 37e6115b45
commit e53a553398
2 changed files with 65 additions and 3 deletions
@@ -187,6 +187,40 @@ class AccountDataMigratorTest {
}
}
@Test
fun copiesFromACacheOlderThanTheCurrentSchema() = runBlocking<Unit> {
// A cache last written at v12 — before account_settings gained retentionCount/retentionMonths
// (v13). The copy must not choke on the columns the destination has but the source lacks
// (a device upgrade from an old install crashed the migrator here).
helper.createDatabase(cacheName, 12).apply {
execSQL(
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
)
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
execSQL(
"INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled) " +
"VALUES ('acct', 'Sig', 0, 1)",
)
close()
}
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
openAccountsDb().apply {
assertEquals("ada@example.org", accountDao().getById("acct")?.email)
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
val settings = accountSettingsDao().get("acct")
assertEquals(false, settings?.signatureEnabled)
assertEquals(true, settings?.notificationsEnabled)
// Columns the v12 source lacked come across as the destination's defaults (null).
assertNull("retentionCount absent from a v12 cache must default to null", settings?.retentionCount)
assertNull(settings?.retentionMonths)
close()
}
}
@Test
fun migratorDdlMatchesExportedAccountDatabaseSchema() {
val schema = JSONObject(