From 17d7135f590599cc16ec0f61f0834a3c013dde9a Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 23:30:49 -0500 Subject: [PATCH] test(db): instrumented cold-open of a pre-encrypted cache Guards the SQLCipher cold-start crash fixed in 592a797 (bug #210): a cold process opening an already-encrypted cache with nothing to convert reached Room's keyed nativeOpen with the native .so unloaded and crash-looped with UnsatisfiedLinkError. Every existing on-device test (DatabaseEncryptionTest, DatabaseProvisionerInstrumentedTest, DatabaseModuleInstrumentedTest, AccountDataMigratorTest) runs a conversion first, which loads the process-global library in-process, masking the bug exactly as production did. System.loadLibrary is process-global, so the instrumentation process can no longer observe a cold open once it has minted the encrypted fixture. This adds ColdOpenCacheProbe -- a debug-only ContentProvider declared with android:process=":coldopen" -- to host the open in a separate, pristine app process. The test mints the encrypted fixture in the instrumentation process (a file created by a prior encrypted DB instance) and drives the cold open in the :coldopen process via ContentResolver.call, mirroring DatabaseProvisioner's encrypted branch + DatabaseModule's open lambda against the real DatabaseEncryption, DeferredOpenHelperFactory and SupportOpenHelperFactory. A cold probe (a keyed open with no preceding load, asserted to throw UnsatisfiedLinkError) makes the isolation self-verifying: the test fails rather than passing hollow if the library was already loaded in the harness process. Verified locally on an API 36 emulator (connectedDebugAndroidTest): 1 test, 0 failures. Co-Authored-By: Claude Opus 4.8 --- .../data/local/ColdOpenEncryptedCacheTest.kt | 127 ++++++++++++ app/src/debug/AndroidManifest.xml | 21 ++ .../data/local/coldopen/ColdOpenCacheProbe.kt | 187 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/ColdOpenEncryptedCacheTest.kt create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/kotlin/org/libremail/data/local/coldopen/ColdOpenCacheProbe.kt diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/ColdOpenEncryptedCacheTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/ColdOpenEncryptedCacheTest.kt new file mode 100644 index 0000000..857f981 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/ColdOpenEncryptedCacheTest.kt @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.coldopen.ColdOpenCacheProbe +import org.libremail.data.local.entity.MessageEntity +import java.io.File + +/** + * Issue #221: a **process-isolated** cold open of a pre-encrypted cache — the SQLCipher regression fixed + * in 592a797. The crash only surfaced on a cold process opening an already-encrypted cache with nothing + * to convert, where the keyed open reached `SQLiteConnection.nativeOpen` with the native `.so` unloaded + * and threw `UnsatisfiedLinkError`. Every existing on-device test ([DatabaseEncryptionTest], + * [DatabaseProvisionerInstrumentedTest], `DatabaseModuleInstrumentedTest`, `AccountDataMigratorTest`) + * runs a conversion first, which loads the process-global library in-process — masking the bug exactly + * as production did before the fix. + * + * Process isolation is unavoidable here: `System.loadLibrary` is process-global, so once THIS + * (instrumentation) process mints the encrypted fixture, it can no longer observe a cold open. The + * fixture is therefore opened in a separate `:coldopen` app process hosted by [ColdOpenCacheProbe] (a + * debug-only [android.content.ContentProvider]); this test mints the fixture here — "a file created by a + * prior encrypted DB instance" — and drives the cold open there via `ContentResolver.call`, which spins + * that pristine process up on demand. The harness [ColdOpenCacheProbe.KEY_COLD_PROBE] check makes the + * isolation self-verifying: if the library were already loaded in the harness process, this test fails + * rather than passing a hollow assertion. + */ +@RunWith(AndroidJUnit4::class) +class ColdOpenEncryptedCacheTest { + + private val appContext = ApplicationProvider.getApplicationContext() + private val dbName = "coldopen_encrypted_cache_test.db" + private val dbFile: File get() = appContext.getDatabasePath(dbName) + + // 64 hex chars == a 32-byte SQLCipher passphrase, matching DatabaseKeyStore's format. + private val passphrase = "0123456789abcdef".repeat(4) + + @Before + @After + fun clean() { + appContext.deleteDatabase(dbName) + dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() } + } + + @Test + fun coldProcessOpensAPreEncryptedCacheWithoutUnsatisfiedLinkError() { + // Mint a real SQLCipher-encrypted cache with one row HERE, in the instrumentation process. This + // loads the process-global .so in THIS process — precisely why the cold open must be observed in a + // different, pristine process. + seedEncryptedFixture() + assertTrue("precondition: the fixture is genuinely encrypted", DatabaseEncryption.isEncrypted(dbFile)) + + val result = callColdOpenProcess() + assertNotNull("the :coldopen process returned no result", result) + val probe = result?.getString(ColdOpenCacheProbe.KEY_COLD_PROBE) + val open = result?.getString(ColdOpenCacheProbe.KEY_OPEN) + + // The harness proves its own process is genuinely cold: a keyed open with no preceding + // System.loadLibrary must fail at nativeOpen. Anything else means the .so was already loaded there + // and the "cold" open would be meaningless — so fail loudly instead of passing a hollow assertion. + assertEquals( + "the :coldopen process was not actually cold (SQLCipher already loaded); isolation broke — open=$open", + ColdOpenCacheProbe.PROBE_UNSATISFIED_LINK, + probe, + ) + + // The headline #221 guard: opening the pre-encrypted cache through the production wiring on a cold + // process succeeds (no UnsatisfiedLinkError) and reads the seeded row back. + assertEquals( + "cold open of the pre-encrypted cache failed (probe=$probe)", + ColdOpenCacheProbe.OPEN_OK, + open, + ) + } + + private fun callColdOpenProcess(): Bundle? { + val authority = appContext.packageName + ColdOpenCacheProbe.AUTHORITY_SUFFIX + return appContext.contentResolver.call( + Uri.parse("content://$authority"), + ColdOpenCacheProbe.METHOD_COLD_OPEN, + null, + bundleOf( + ColdOpenCacheProbe.KEY_DB_NAME to dbName, + ColdOpenCacheProbe.KEY_PASSPHRASE to passphrase, + ), + ) + } + + /** + * Builds a plaintext cache with one row and converts it to real SQLCipher ciphertext — the steady- + * state shape an already-encrypted install presents at the next cold start, where `ensureEncrypted` + * has nothing left to convert (592a797's exact failure mode). + */ + private fun seedEncryptedFixture() { + Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName).build().apply { + runBlocking { messageDao().insertNew(listOf(message(ColdOpenCacheProbe.EXPECTED_ROW_ID))) } + close() + } + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + } + + private fun message(id: String) = MessageEntity( + id = id, + accountId = "acct", + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "", + body = "", + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + ) +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..bf9bfc3 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/app/src/debug/kotlin/org/libremail/data/local/coldopen/ColdOpenCacheProbe.kt b/app/src/debug/kotlin/org/libremail/data/local/coldopen/ColdOpenCacheProbe.kt new file mode 100644 index 0000000..bd6c4ac --- /dev/null +++ b/app/src/debug/kotlin/org/libremail/data/local/coldopen/ColdOpenCacheProbe.kt @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.coldopen + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import org.libremail.data.local.DatabaseEncryption +import org.libremail.data.local.DeferredOpenHelperFactory +import org.libremail.data.local.LibreMailDatabase + +/** + * Test-only harness for issue #221. Lives in the **debug** source set (never in a release APK) and is + * declared in the debug manifest with `android:process=":coldopen"`, so its work runs in a SEPARATE app + * process from the instrumentation process. + * + * SQLCipher's `System.loadLibrary("sqlcipher")` is process-global: once the instrumentation process mints + * the encrypted fixture (or any earlier test opens a keyed database), the `.so` is loaded there and a + * later "cold open" can no longer be observed in that process. That is the exact blind spot behind the + * 592a797 crash — a cold start opening an already-encrypted cache with nothing to convert, where the + * keyed open reaches `SQLiteConnection.nativeOpen` with the library unloaded and throws + * `UnsatisfiedLinkError`. This provider gives the test a pristine process to reproduce it. + * + * [call] runs in the `:coldopen` process and reports two things back in a [Bundle]: + * 1. [KEY_COLD_PROBE] — proof the process is genuinely cold: a keyed open with NO preceding + * `System.loadLibrary` must fail at `nativeOpen`. Anything else means the `.so` was already loaded + * here, so the isolation premise broke and the "cold" open below would be meaningless. + * 2. [KEY_OPEN] — the result of opening the pre-encrypted cache exactly the way production does on a + * steady-state encrypted start ([DatabaseProvisioner]'s encrypted branch + [DatabaseModule]'s open + * lambda): [DatabaseEncryption.ensureEncrypted] (a no-op on an already-encrypted file), + * [DatabaseEncryption.ensureNativeLibraryLoaded] (the 592a797 fix), then a Room open through a + * [DeferredOpenHelperFactory] wrapping [SupportOpenHelperFactory]. Reports whether the seeded row + * reads back. + * + * The `DatabaseProvisioner`/`DatabaseModule` objects themselves are not invoked here because they require + * MockK-substituted collaborators (`DatabaseKeyStore`/`SettingsRepository`/`AccountDataMigrator` are + * final and read the real Keystore/DataStore), and the test APK — hence MockK — is not on a forked app + * process's classloader. The provisioner's encrypted-branch decision is instead mirrored line-for-line, + * against the real `DatabaseEncryption`, `DeferredOpenHelperFactory` and `SupportOpenHelperFactory`. + */ +class ColdOpenCacheProbe : ContentProvider() { + + override fun onCreate(): Boolean = true + + override fun call(method: String, arg: String?, extras: Bundle?): Bundle { + val out = Bundle() + if (method != METHOD_COLD_OPEN) { + out.putString(KEY_ERROR, "unknown method: $method") + return out + } + val appContext = requireNotNull(context).applicationContext + val bundle = requireNotNull(extras) { "cold-open call requires extras" } + val dbName = requireNotNull(bundle.getString(KEY_DB_NAME)) { "missing $KEY_DB_NAME" } + val passphrase = requireNotNull(bundle.getString(KEY_PASSPHRASE)) { "missing $KEY_PASSPHRASE" } + + out.putString(KEY_COLD_PROBE, probeKeyedOpenWithoutLoad(appContext)) + out.putString(KEY_OPEN, openThroughProductionWiring(appContext, dbName, passphrase)) + return out + } + + /** + * A keyed open with NO preceding `System.loadLibrary` — the exact call the pre-592a797 provisioner + * reached with the `.so` unloaded. In a genuinely cold process this hits `nativeOpen` and throws + * [UnsatisfiedLinkError]. Runs against a THROWAWAY file so a partially-created database can never + * disturb the real fixture opened afterwards. + */ + private fun probeKeyedOpenWithoutLoad(appContext: Context): String { + val probeName = "coldopen_probe_$PROBE_NONCE.db" + return try { + val helper = SupportOpenHelperFactory(PROBE_KEY.toByteArray(Charsets.US_ASCII), null, false) + .create(noOpConfiguration(appContext, probeName)) + try { + helper.writableDatabase // forces the keyed nativeOpen — the 592a797 crash surface + PROBE_OPENED_UNEXPECTEDLY + } finally { + runCatching { helper.close() } + } + } catch (t: Throwable) { + if (hasUnsatisfiedLink(t)) PROBE_UNSATISFIED_LINK else "$PROBE_OTHER${t.javaClass.name}" + } finally { + appContext.deleteDatabase(probeName) + } + } + + /** + * Opens the pre-encrypted fixture the same way production does on a steady-state encrypted start, + * against the real production classes. Returns [OPEN_OK] iff the keyed open succeeds cold and the + * seeded row reads back. + */ + private fun openThroughProductionWiring(appContext: Context, dbName: String, passphrase: String): String { + val dbFile = appContext.getDatabasePath(dbName) + return try { + // Mirror DatabaseProvisioner's encrypted branch: the conversion is a no-op on an already- + // encrypted file, so the explicit native-library load is what lets the keyed open succeed. + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + DatabaseEncryption.ensureNativeLibraryLoaded() + val keyBytes = passphrase.toByteArray(Charsets.US_ASCII) + val database = Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName) + // Mirror DatabaseModule.provideDatabase's encrypted open lambda exactly. + .openHelperFactory( + DeferredOpenHelperFactory { configuration -> + SupportOpenHelperFactory(keyBytes, null, false).create(configuration) + }, + ) + .build() + try { + val ids = runBlocking { database.messageDao().observeSummaries().first().map { it.id } } + if (ids == listOf(EXPECTED_ROW_ID)) OPEN_OK else "$OPEN_ROWS$ids" + } finally { + database.close() + } + } catch (t: Throwable) { + "$OPEN_FAIL${t.javaClass.name}: ${t.message}" + } + } + + private fun noOpConfiguration(appContext: Context, name: String): SupportSQLiteOpenHelper.Configuration = + SupportSQLiteOpenHelper.Configuration.builder(appContext) + .name(name) + .callback(object : SupportSQLiteOpenHelper.Callback(CALLBACK_VERSION) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + override fun onDowngrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + }) + .build() + + // Unused ContentProvider surface — this provider exists only for its process-isolated call(). + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = + 0 + + companion object { + /** Appended to the app's `applicationId` to form the provider authority (see the debug manifest). */ + const val AUTHORITY_SUFFIX = ".coldopen" + const val METHOD_COLD_OPEN = "coldOpen" + + const val KEY_DB_NAME = "dbName" + const val KEY_PASSPHRASE = "passphrase" + const val KEY_COLD_PROBE = "coldProbe" + const val KEY_OPEN = "open" + const val KEY_ERROR = "error" + + /** The id of the single row the fixture is seeded with; the cold open must read it back. */ + const val EXPECTED_ROW_ID = "acct:1" + + const val PROBE_UNSATISFIED_LINK = "UNSATISFIED_LINK" + const val PROBE_OPENED_UNEXPECTEDLY = "OPENED_UNEXPECTEDLY" + const val PROBE_OTHER = "OTHER:" + const val OPEN_OK = "OK" + const val OPEN_FAIL = "FAIL:" + const val OPEN_ROWS = "ROWS:" + + private const val PROBE_KEY = "coldopenprobe" + private const val CALLBACK_VERSION = 1 + private val PROBE_NONCE = System.nanoTime() + + private fun hasUnsatisfiedLink(throwable: Throwable): Boolean { + var current: Throwable? = throwable + while (current != null) { + if (current is UnsatisfiedLinkError) return true + current = current.cause + } + return false + } + } +}