Merge main into test-257-reportupload-seams-e2e

This commit is contained in:
Jason Ross
2026-07-03 23:58:56 -05:00
committed by GitHub
3 changed files with 335 additions and 0 deletions
@@ -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<Context>()
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,
)
}