diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionProbeInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionProbeInstrumentedTest.kt new file mode 100644 index 0000000..955049a --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionProbeInstrumentedTest.kt @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * On-device cover for the issue-#359 keyed-open probe ([DatabaseEncryption.probeKeyedOpen]). The probe + * is the fix for gap 2: it reaches the REAL `SQLiteConnection.nativeOpen` — the exact #359 crash site — + * from inside `DatabaseProvisioner`'s fail-closed handler, so an open-time `UnsatisfiedLinkError` on an + * incompatible device is caught and converted to `CacheEncryptionUnavailableException` before Room's + * later deferred open can crash on it uncaught. + * + * This runs the real SQLCipher native path (mocked out of the JVM unit tests), asserting two things a + * device is needed for: on a compatible device the probe opens+closes the keyed database WITHOUT + * throwing, and — on every device — it leaves no file behind (it opens a throwaway sibling, never the + * real cache, and cleans up its sidecars). All data is synthetic; nothing here is PII. + */ +@RunWith(AndroidJUnit4::class) +class DatabaseEncryptionProbeInstrumentedTest { + + private val context = ApplicationProvider.getApplicationContext() + private val cacheName = "probe_test_cache.db" + private val cacheFile: File get() = context.getDatabasePath(cacheName) + + // 64 hex chars == a 32-byte SQLCipher passphrase, matching DatabaseKeyStore's format. + private val passphrase = "0123456789abcdef".repeat(4) + + @Before + @After + fun clean() { + cacheFile.parentFile + ?.listFiles { f -> f.name.startsWith(cacheName) } + ?.forEach { it.delete() } + } + + @Test + fun probeKeyedOpen_reachesNativeOpen_thenLeavesNoFileBehind() { + // The provisioner loads the native library immediately before probing (probeKeyedOpen's + // precondition, kept out of the probe so the load happens exactly once per open); mirror that here. + DatabaseEncryption.ensureNativeLibraryLoaded() + // On a compatible device this returns normally (keyed nativeOpen succeeds); on an incompatible one + // it throws UnsatisfiedLinkError here — the exact #359 signature the provisioner now fails closed + // on. Either way, no probe file may survive. + DatabaseEncryption.probeKeyedOpen(cacheFile, passphrase) + + val dir = cacheFile.parentFile!! + listOf("", "-wal", "-shm", "-journal").forEach { suffix -> + assertFalse( + "probe left a '$cacheName.openprobe$suffix' file behind", + File(dir, "$cacheName.openprobe$suffix").exists(), + ) + } + // The probe uses a throwaway sibling, so it must never create the real cache file. + assertFalse("probe must not create the real cache file", cacheFile.exists()) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/CacheEncryptionUnavailableException.kt b/app/src/main/kotlin/org/libremail/data/local/CacheEncryptionUnavailableException.kt index a73a30a..8bc4c8b 100644 --- a/app/src/main/kotlin/org/libremail/data/local/CacheEncryptionUnavailableException.kt +++ b/app/src/main/kotlin/org/libremail/data/local/CacheEncryptionUnavailableException.kt @@ -22,3 +22,19 @@ class CacheEncryptionUnavailableException(cause: Throwable) : "Encrypted cache unavailable: the SQLCipher native library failed to load on this device", cause, ) + +/** + * True when this throwable (or anything in its cause chain) signals that SQLCipher's native library is + * unavailable on this device (issue #359): a [CacheEncryptionUnavailableException] the provisioner raised + * when it failed closed, or — defensively — a bare [LinkageError] (an open-time `UnsatisfiedLinkError` at + * `SQLiteConnection.nativeOpen` that reached a headless entry point before the provisioner wrapped it). + * + * Headless entry points that inject the Room cache directly — the WorkManager workers and `IdleService` — + * have no UI gate (that is `CacheEncryptionGate`'s job), so they consult this to treat such a failure as a + * soft defer/skip (a worker retries; the push service stops) instead of crashing. A later launch may load + * the library and recover, so deferring rather than failing hard is correct. The whole cause chain is + * walked because coroutine stack-trace recovery can re-wrap the throwable as it crosses the database-open + * boundary (the same reason [DatabaseProvisioner]'s own tests assert on the cause chain, not the instance). + */ +fun Throwable.isCacheEncryptionUnavailable(): Boolean = generateSequence(this) { it.cause } + .any { it is CacheEncryptionUnavailableException || it is LinkageError } diff --git a/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt b/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt index 7122f26..3ede05f 100644 --- a/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt @@ -115,8 +115,45 @@ object DatabaseEncryption { } } + /** + * Opens a throwaway keyed SQLCipher database next to [cacheFile] and immediately closes it, purely to + * reach `SQLiteConnection.nativeOpen` — the exact call site of the #359 crash — from inside + * [DatabaseProvisioner]'s fail-closed handler. [ensureNativeLibraryLoaded] + * (`System.loadLibrary("sqlcipher")`) can succeed on a device whose bundled `.so` is otherwise + * incompatible, yet the JNI-bound `nativeOpen` still be unresolved; that only surfaces when a keyed + * database is actually opened, which Room does LATER via its deferred open helper + * ([org.libremail.di.DatabaseModule]) — outside any handler. Probing the real open here lets the + * provisioner catch that [LinkageError] and fail closed BEFORE Room reaches it. + * + * Deliberately opens a sibling throwaway file (never the real cache) so it can never create, mutate, + * or leave `-wal`/`-shm` sidecars on the cache, then deletes the probe and its sidecars in a `finally`. + * Mirrors `SqlCipherOpenSpikeTest`'s stage-B keyed-open probe (issue #359). + * + * PRECONDITION: the caller must have already loaded the native library (via [ensureNativeLibraryLoaded]). + * The sole production caller — [DatabaseProvisioner]'s encrypted branch — does so on the line above its + * probe call. Kept out of here on purpose so the provisioner loads the library exactly ONCE per open + * (the `ensureNativeLibraryLoaded()`-exactly-once invariant its instrumented tests pin), not twice. + */ + fun probeKeyedOpen(cacheFile: File, passphrase: String) { + val dir = cacheFile.parentFile ?: error("cache database file has no parent directory") + val probe = File(dir, cacheFile.name + PROBE_SUFFIX) + try { + SQLiteDatabase.openOrCreateDatabase( + probe.absolutePath, + passphrase.toByteArray(Charsets.US_ASCII), + null, // no CursorFactory + null, // no DatabaseErrorHandler + ).close() + } finally { + listOf("", "-wal", "-shm", "-journal").forEach { File(dir, probe.name + it).delete() } + } + } + private const val TAG = "LibreMailDbCrypto" + // Suffix of the throwaway file [probeKeyedOpen] opens to reach nativeOpen without touching the cache. + private const val PROBE_SUFFIX = ".openprobe" + // The 16-byte magic that opens every plaintext SQLite file: "SQLite format 3" + a NUL terminator. // Spelled out as bytes to keep the trailing NUL unambiguous. private val SQLITE_HEADER = byteArrayOf( diff --git a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt index e301c02..409e3eb 100644 --- a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt @@ -104,35 +104,45 @@ class DatabaseProvisioner internal constructor( keyStore.clearClearPending() } - // One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase - // (issue #111). MUST run before the cache opens: opening it applies MIGRATION_15_16, which drops - // the moved tables. Runs AFTER the wipe above so an unrecoverable-key cache is gone first - // (nothing left to move) and we never block waiting on a passphrase we can't get. - accountDataMigrator.migrateIfNeeded() - - // Opt-in at-rest encryption of the local cache (off by default). The conversion runs here — - // before the database is opened — so it never races an open connection; toggling the setting - // therefore takes effect on the next app start. The passphrase source is resolved from which - // seal actually exists (DatabaseKeyStore.resolvePassphrase), NOT from the app-lock setting (a - // separate DataStore that can disagree). When app-lock is ON the sealing key is auth-bound, so - // resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this - // must never run on the main thread while the cache is locked (issue #93). - val settings = settingsRepository.settings.first() + // FAIL CLOSED (issue #359, security rework of #367). SQLCipher's native library can fail to LOAD + // (UnsatisfiedLinkError from ensureNativeLibraryLoaded) OR to LINK (the library loads, but the + // JNI-bound SQLiteConnection.nativeOpen is unresolved and throws only when a keyed database is + // actually opened — the exact #359 signature). EVERY startup step that touches that library must + // therefore run inside this ONE handler, so any such LinkageError becomes a single fail-closed + // signal rather than escaping as a raw crash. On that signal we deliberately do NOT silently + // degrade to an unencrypted cache (that would defeat the user's opt-in encryption): we never open + // plaintext, wipe the on-disk ciphertext, or write the encryptCache setting — we raise a distinct + // exception the startup UI (CacheEncryptionGate) catches to show the encryption error gate. It is + // NOT memoized (a throw skips prepareCache's `.also { prepared = it }`), so the next launch + // re-attempts and recovers automatically if the library later loads. + // + // The catch stays typed LinkageError ONLY. It must NOT be broadened to Exception/Throwable: the + // migrator below deliberately throws a NON-linkage error ("crash-loop rather than lose data") on + // an unexpected copy failure, and that — like any other non-linkage error — must still propagate + // uncaught so we never drop the not-yet-copied source tables. return try { + // One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase + // (issue #111). MUST run before the cache opens: opening it applies MIGRATION_15_16, which drops + // the moved tables. Runs AFTER the wipe above so an unrecoverable-key cache is gone first + // (nothing left to move) and we never block waiting on a passphrase we can't get. Runs INSIDE + // this handler (issue #359 gap 1): its copyAccountTables loads SQLCipher and does a keyed + // openOrCreateDatabase + ATTACH … KEY (a real nativeOpen) even when encryption is OFF, so a + // LinkageError there used to escape this handler entirely and crash-loop. + accountDataMigrator.migrateIfNeeded() + + // Opt-in at-rest encryption of the local cache (off by default). The conversion runs here — + // before the database is opened — so it never races an open connection; toggling the setting + // therefore takes effect on the next app start. The passphrase source is resolved from which + // seal actually exists (DatabaseKeyStore.resolvePassphrase), NOT from the app-lock setting (a + // separate DataStore that can disagree). When app-lock is ON the sealing key is auth-bound, so + // resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this + // must never run on the main thread while the cache is locked (issue #93). + val settings = settingsRepository.settings.first() resolveOpenMode(settings, dbFile) } catch (nativeLoadFailure: LinkageError) { - // FAIL CLOSED (issue #359, security rework of #367). SQLCipher's native library could not be - // loaded/linked (e.g. UnsatisfiedLinkError at SQLiteConnection.nativeOpen or from - // ensureNativeLibraryLoaded), so the encrypted cache cannot be opened OR converted. We must - // NOT silently degrade to an unencrypted cache (that would defeat the user's opt-in - // encryption), so we deliberately do NOT: open plaintext, wipe the on-disk ciphertext, or - // write the encryptCache setting. Instead raise a distinct signal the startup UI catches to - // show the encryption error gate. This throw is NOT memoized (it skips prepareCache's - // `.also { prepared = it }`), so the next launch re-attempts and recovers automatically if - // the library later loads. AppLog.w( TAG, - "SQLCipher native library failed to load; failing closed (encrypted cache unavailable)", + "SQLCipher native library failed to load or link; failing closed (encrypted cache unavailable)", nativeLoadFailure, ) throw CacheEncryptionUnavailableException(nativeLoadFailure) @@ -159,6 +169,13 @@ class DatabaseProvisioner internal constructor( // load the keyed open reaches SQLiteConnection.nativeOpen with no library loaded and // crashes with UnsatisfiedLinkError on every cold start once encryption is enabled. DatabaseEncryption.ensureNativeLibraryLoaded() + // Probe the REAL keyed open HERE (issue #359 gap 2), inside the fail-closed handler. + // ensureNativeLibraryLoaded() above only loads the .so; the keyed nativeOpen that can still + // throw on an incompatible device fires LATER, in DatabaseModule's DeferredOpenHelperFactory + // AFTER prepareCache() returns — outside any handler — so an open-time UnsatisfiedLinkError + // there would escape uncaught. Reaching a keyed nativeOpen now converts that LinkageError to + // CacheEncryptionUnavailableException before Room's deferred open can crash on it. + DatabaseEncryption.probeKeyedOpen(dbFile, passphrase) CacheOpenMode.Encrypted(passphrase) } diff --git a/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt index d35402a..8a5ea04 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt @@ -10,6 +10,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import org.libremail.BuildConfig +import org.libremail.data.local.isCacheEncryptionUnavailable import org.libremail.data.security.EncryptedCacheGuard import org.libremail.reporting.AppLog @@ -60,7 +61,14 @@ class BackfillWorker @AssistedInject constructor( }, onFailure = { error -> if (error is CancellationException) throw error - AppLog.w(TAG, "backfill worker: retry", error) + // A DB open that fails because SQLCipher's native library is unavailable (issue #359) lands + // here (thrown inside the runCatching above); log it distinctly but still defer softly — a + // later launch may load the library and recover — instead of a generic retry. + if (error.isCacheEncryptionUnavailable()) { + AppLog.w(TAG, "backfill deferred: encrypted cache unavailable (SQLCipher native library)", error) + } else { + AppLog.w(TAG, "backfill worker: retry", error) + } Result.retry() }, ) diff --git a/app/src/main/kotlin/org/libremail/data/sync/EncryptedCacheWorkerGuard.kt b/app/src/main/kotlin/org/libremail/data/sync/EncryptedCacheWorkerGuard.kt new file mode 100644 index 0000000..5d8164f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/EncryptedCacheWorkerGuard.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import androidx.work.ListenableWorker.Result +import org.libremail.data.local.isCacheEncryptionUnavailable +import org.libremail.reporting.AppLog + +/** + * Runs [block] and, if opening the Room cache fails because SQLCipher's native library is unavailable on + * this device (issue #359 — a `CacheEncryptionUnavailableException` the provisioner raised, or defensively + * a bare `LinkageError` at `nativeOpen`), logs a PII-free breadcrumb under [tag] and returns + * [Result.retry] rather than letting the failure crash the worker. WorkManager workers inject the cache + * directly, with no UI gate; a later launch may load the library and recover, so a soft retry — not a hard + * failure — is correct. Every other throwable (including `CancellationException`) propagates unchanged. + * + * `inline` so the (suspending) [block] runs in the worker's own coroutine context, mirroring `runCatching`. + */ +internal inline fun retryIfEncryptedCacheUnavailable(tag: String, block: () -> Result): Result = try { + block() +} catch (failure: Throwable) { + if (!failure.isCacheEncryptionUnavailable()) throw failure + AppLog.w(tag, "deferred: encrypted cache unavailable (SQLCipher native library)", failure) + Result.retry() +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/PruneWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/PruneWorker.kt index 003c9c4..93b76c6 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/PruneWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/PruneWorker.kt @@ -8,6 +8,7 @@ import androidx.work.WorkerParameters import dagger.Lazy import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import org.libremail.data.local.isCacheEncryptionUnavailable import org.libremail.data.security.EncryptedCacheGuard import org.libremail.reporting.AppLog @@ -39,7 +40,14 @@ class PruneWorker @AssistedInject constructor( Result.success() }, onFailure = { error -> - AppLog.w(TAG, "prune worker: retry", error) + // A DB open that fails because SQLCipher's native library is unavailable (issue #359) lands + // here (it is thrown inside the runCatching above); log it distinctly but still defer softly + // — a later launch may load the library and recover — instead of a generic retry. + if (error.isCacheEncryptionUnavailable()) { + AppLog.w(TAG, "prune deferred: encrypted cache unavailable (SQLCipher native library)", error) + } else { + AppLog.w(TAG, "prune worker: retry", error) + } Result.retry() }, ) diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt index 4bade40..7e18f0a 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -52,6 +52,13 @@ class SendWorker @AssistedInject constructor( override suspend fun doWork(): Result { if (cacheGuard.isCacheLocked()) return Result.retry() + // Resolving the Lazy DB deps below opens the Room cache; if SQLCipher's native library is + // unavailable on this device (issue #359) that open throws — with no UI gate here, treat it as a + // soft retry rather than letting it crash the worker. + return retryIfEncryptedCacheUnavailable(TAG) { drainOutbox() } + } + + private suspend fun drainOutbox(): Result { val outboxDao = this.outboxDao.get() val accountDao = this.accountDao.get() val connectionFactory = this.connectionFactory.get() diff --git a/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt index 987162c..0f5e2f0 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt @@ -28,16 +28,21 @@ class SyncWorker @AssistedInject constructor( AppLog.i(TAG, "sync deferred: cache locked") return Result.retry() } - return mailSyncer.get().syncAll().fold( - onSuccess = { - AppLog.i(TAG, "sync worker: success") - Result.success() - }, - onFailure = { error -> - AppLog.w(TAG, "sync worker: retry", error) - Result.retry() - }, - ) + // syncAll()'s first DB access (and thus Room's deferred cache open) can throw if SQLCipher's + // native library is unavailable on this device (issue #359) — that open fires OUTSIDE syncAll's own + // runCatching, so without this guard it would escape doWork. Treat it as a soft retry, not a crash. + return retryIfEncryptedCacheUnavailable(TAG) { + mailSyncer.get().syncAll().fold( + onSuccess = { + AppLog.i(TAG, "sync worker: success") + Result.success() + }, + onFailure = { error -> + AppLog.w(TAG, "sync worker: retry", error) + Result.retry() + }, + ) + } } private companion object { diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt index 6120467..f2f3722 100644 --- a/app/src/main/kotlin/org/libremail/push/IdleService.kt +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.isCacheEncryptionUnavailable import org.libremail.data.local.toDomain import org.libremail.data.security.EncryptedCacheGuard import org.libremail.data.sync.MailConnectionFactory @@ -110,7 +111,20 @@ class IdleService : Service() { stopSelf() return@launch } - reconcileWatchers() + // Headless tolerance for issue #359: this service injects the Room cache with NO UI gate + // (CacheEncryptionGate wraps only MainActivity), so if SQLCipher's native library is unavailable + // on this device reconcileWatchers()'s first DB access throws — a CacheEncryptionUnavailableException + // from the provisioner, or defensively a bare LinkageError at nativeOpen. Left uncaught, a child of + // this SupervisorJob would route it to the app's default handler and crash the process. Treat it + // like the cache-locked case: log PII-free and stop. The app's CacheEncryptionGate surfaces the + // error to the user, and a later restart re-probes and recovers if the library loads. + try { + reconcileWatchers() + } catch (unavailable: Throwable) { + if (!unavailable.isCacheEncryptionUnavailable()) throw unavailable + AppLog.w(TAG, "encrypted cache unavailable (SQLCipher native lib); deferring IDLE push", unavailable) + stopSelf() + } } // The reuse cache (issue #357 Part 2) keeps interactive/sync IMAP connections warm; sweep // them so a socket that has gone idle past the reuse timeout is closed rather than left diff --git a/app/src/test/kotlin/org/libremail/data/local/CacheEncryptionUnavailableExceptionTest.kt b/app/src/test/kotlin/org/libremail/data/local/CacheEncryptionUnavailableExceptionTest.kt new file mode 100644 index 0000000..9307a8c --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/local/CacheEncryptionUnavailableExceptionTest.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import kotlinx.coroutines.CancellationException +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [isCacheEncryptionUnavailable] is the shared classifier the headless entry points (WorkManager workers + * and `IdleService`) use to decide whether a database-open failure is the issue-#359 "SQLCipher native + * library unavailable" condition — which they defer/skip softly — versus any other failure, which they + * handle normally. It must recognise the provisioner's [CacheEncryptionUnavailableException] AND a bare + * [LinkageError] (defensive), even when wrapped several layers deep (coroutine stack-trace recovery + * re-wraps the throwable across the open boundary), and reject everything else — including + * [CancellationException], which must always propagate. + */ +class CacheEncryptionUnavailableExceptionTest { + + @Test + fun `recognises a direct CacheEncryptionUnavailableException`() { + val failure = CacheEncryptionUnavailableException(UnsatisfiedLinkError("nativeOpen")) + assertTrue(failure.isCacheEncryptionUnavailable()) + } + + @Test + fun `recognises a bare LinkageError`() { + assertTrue(UnsatisfiedLinkError("SQLiteConnection.nativeOpen").isCacheEncryptionUnavailable()) + } + + @Test + fun `recognises a CacheEncryptionUnavailableException wrapped deep in the cause chain`() { + val wrapped = RuntimeException( + "room open failed", + IllegalStateException("delegate", CacheEncryptionUnavailableException(UnsatisfiedLinkError())), + ) + assertTrue(wrapped.isCacheEncryptionUnavailable()) + } + + @Test + fun `recognises a LinkageError nested as a cause`() { + assertTrue(RuntimeException("open", UnsatisfiedLinkError("nativeOpen")).isCacheEncryptionUnavailable()) + } + + @Test + fun `rejects an unrelated exception`() { + assertFalse(IllegalStateException("database is locked").isCacheEncryptionUnavailable()) + } + + @Test + fun `rejects a CancellationException so cancellation still propagates`() { + assertFalse(CancellationException("job cancelled").isCacheEncryptionUnavailable()) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt index 7695bb4..3844381 100644 --- a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt @@ -71,6 +71,7 @@ class DatabaseProvisionerTest { every { DatabaseEncryption.ensureEncrypted(any(), any()) } just Runs every { DatabaseEncryption.ensurePlaintext(any(), any()) } just Runs every { DatabaseEncryption.ensureNativeLibraryLoaded() } just Runs + every { DatabaseEncryption.probeKeyedOpen(any(), any()) } just Runs every { settingsRepository.settings } returns flowOf(AppSettings()) coEvery { keyStore.isClearPending() } returns false @@ -104,6 +105,10 @@ class DatabaseProvisionerTest { // load only rode on that conversion, Room's keyed open would hit nativeOpen with no .so loaded // and throw UnsatisfiedLinkError on every cold start. verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() } + // Issue #359 gap 2: the encrypted branch also probes a REAL keyed open (nativeOpen) inside the + // fail-closed handler, so an open-time UnsatisfiedLinkError is caught here rather than escaping + // Room's later deferred open. + verify(exactly = 1) { DatabaseEncryption.probeKeyedOpen(any(), PASSPHRASE) } } @Test @@ -147,6 +152,53 @@ class DatabaseProvisionerTest { coVerify(exactly = 0) { settingsRepository.setEncryptCache(any()) } } + @Test + fun `a native-library load failure in the account migrator fails closed`() = runTest { + // Issue #359 gap 1 (PRIMARY): AccountDataMigrator.copyAccountTables loads SQLCipher and does a + // keyed openOrCreateDatabase + ATTACH … KEY (a real nativeOpen) even when encryption is OFF, so a + // LinkageError there must fail closed too. It used to ESCAPE the handler because migrateIfNeeded() + // ran OUTSIDE the try/catch (crash-loop). Un-mock the `just Runs` default (which hid the gap) and + // make the migrator throw the exact #359 error — encryption stays at its default OFF here to prove + // the migrator drags EVERY upgrader through the native library regardless of the setting. + coEvery { accountDataMigrator.migrateIfNeeded() } throws + UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned") + + val error = assertFailsWith { provisioner().prepareCache() } + + assertTrue( + generateSequence(error.cause) { it.cause }.any { it is LinkageError }, + "the native-load LinkageError must be preserved as the cause, not surface as a raw LinkageError", + ) + // Fail CLOSED, not open: nothing wiped, no seal reset, the setting untouched. + verify(exactly = 0) { DatabaseFiles.clear(any()) } + coVerify(exactly = 0) { keyStore.resetSealedPassphrase() } + coVerify(exactly = 0) { settingsRepository.setEncryptCache(any()) } + } + + @Test + fun `a nativeOpen failure while probing the encrypted open fails closed`() = runTest { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false)) + // Issue #359 gap 2 (SECONDARY): loadLibrary succeeds, but the REAL keyed open throws an + // UnsatisfiedLinkError at SQLiteConnection.nativeOpen — the exact #359 signature. The provisioner + // probes that open INSIDE the fail-closed handler, so it converts here rather than letting Room's + // later deferred open (DatabaseModule) crash uncaught. + every { DatabaseEncryption.probeKeyedOpen(any(), any()) } throws + UnsatisfiedLinkError("SQLiteConnection.nativeOpen") + + val error = assertFailsWith { provisioner().prepareCache() } + + assertTrue( + generateSequence(error.cause) { it.cause }.any { it is LinkageError }, + "the nativeOpen LinkageError must be preserved as the cause", + ) + // The library loaded fine (loadLibrary was not the failure); it was the keyed nativeOpen probe. + verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() } + verify(exactly = 1) { DatabaseEncryption.probeKeyedOpen(any(), PASSPHRASE) } + // No fail-open side effects. + verify(exactly = 0) { DatabaseFiles.clear(any()) } + coVerify(exactly = 0) { keyStore.resetSealedPassphrase() } + } + @Test fun `a native-library load failure is not memoized and retries on the next open`() = runTest { every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false)) @@ -237,8 +289,10 @@ class DatabaseProvisionerTest { verify(exactly = 0) { DatabaseEncryption.ensureEncrypted(any(), any()) } verify(exactly = 0) { DatabaseEncryption.ensurePlaintext(any(), any()) } // A plaintext cache opens with the framework helper, never SQLCipher, so it must not touch the - // native library — the counterpart to the encrypted path's mandatory load above. + // native library — the counterpart to the encrypted path's mandatory load above — nor probe a + // keyed open (issue #359: the probe belongs to the encrypted branch only). verify(exactly = 0) { DatabaseEncryption.ensureNativeLibraryLoaded() } + verify(exactly = 0) { DatabaseEncryption.probeKeyedOpen(any(), any()) } } @Test diff --git a/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt index 0550f6c..8652c05 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test +import org.libremail.data.local.CacheEncryptionUnavailableException import org.libremail.data.security.EncryptedCacheGuard import org.libremail.reporting.AppLog import org.libremail.reporting.RingLogBuffer @@ -86,6 +87,22 @@ class BackfillWorkerTest { assertEquals(Result.retry(), worker().doWork()) } + @Test + fun `defers with a retry and a distinct breadcrumb when the encrypted cache is unavailable`() = runTest { + // Issue #359: the DB open fails because SQLCipher's native library is unavailable. It lands in the + // worker's runCatching (thrown inside runBackfill()), so it retries — but it must now log a DISTINCT + // breadcrumb (not the generic retry, and not mistaken for a cancellation) and still never crash. + coEvery { cacheGuard.isCacheLocked() } returns false + coEvery { backfiller.runBackfill(any()) } throws + CacheEncryptionUnavailableException(UnsatisfiedLinkError("SQLiteConnection.nativeOpen")) + + assertEquals(Result.retry(), worker().doWork()) + + val entry = logBuffer.snapshot().single() + assertEquals('W', entry.level) + assertTrue(entry.message.startsWith("backfill deferred: encrypted cache unavailable"), entry.message) + } + // --- issue #329: AppLog breadcrumbs --------------------------------------------------------- @Test diff --git a/app/src/test/kotlin/org/libremail/data/sync/PruneWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/PruneWorkerTest.kt index 45fc95d..e91c9db 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/PruneWorkerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/PruneWorkerTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test +import org.libremail.data.local.CacheEncryptionUnavailableException import org.libremail.data.security.EncryptedCacheGuard import org.libremail.reporting.AppLog import org.libremail.reporting.RingLogBuffer @@ -84,6 +85,23 @@ class PruneWorkerTest { assertEquals(Result.retry(), worker().doWork()) } + @Test + fun `defers with a retry and a distinct breadcrumb when the encrypted cache is unavailable`() = runTest { + // Issue #359: the DB open fails because SQLCipher's native library is unavailable. It lands in the + // worker's runCatching (thrown inside prune()), so it already retried — but it must now log a + // DISTINCT breadcrumb so a debug report shows "encrypted cache unavailable" rather than a generic + // retry, and still never crash. + coEvery { cacheGuard.isCacheLocked() } returns false + coEvery { pruner.prune(any()) } throws + CacheEncryptionUnavailableException(UnsatisfiedLinkError("SQLiteConnection.nativeOpen")) + + assertEquals(Result.retry(), worker().doWork()) + + val entry = logBuffer.snapshot().single() + assertEquals('W', entry.level) + assertTrue(entry.message.startsWith("prune deferred: encrypted cache unavailable"), entry.message) + } + // --- issue #329: AppLog breadcrumbs --------------------------------------------------------- @Test diff --git a/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt index 1326337..a272323 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt @@ -19,6 +19,7 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.libremail.data.attachment.AttachmentUriGrants +import org.libremail.data.local.CacheEncryptionUnavailableException import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.entity.AccountEntity @@ -139,6 +140,21 @@ class SendWorkerTest { verify(exactly = 0) { lazyGrants.get() } } + @Test + fun `defers with a retry when the encrypted cache is unavailable, without crashing`() = runTest { + // Cache unlocked (setUp), so the worker resolves its Lazy DB deps and reads the outbox; that first + // DB access (Room's deferred cache open) throws because SQLCipher's native library is unavailable + // on this device (issue #359). It is OUTSIDE any per-message runCatching, so without the guard it + // would escape doWork — the guard turns it into a soft retry with a PII-free breadcrumb. + coEvery { outboxDao.getAll() } throws + CacheEncryptionUnavailableException(UnsatisfiedLinkError("SQLiteConnection.nativeOpen")) + + assertEquals(Result.retry(), worker().doWork()) + + val messages = logBuffer.snapshot().map { it.message } + assertTrue(messages.any { it.startsWith("deferred: encrypted cache unavailable") }, "messages=$messages") + } + @Test fun `an empty outbox succeeds without sending`() = runTest { coEvery { outboxDao.getAll() } returns emptyList() diff --git a/app/src/test/kotlin/org/libremail/data/sync/SyncWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/SyncWorkerTest.kt index 17faa97..f7126d4 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/SyncWorkerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/SyncWorkerTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test +import org.libremail.data.local.CacheEncryptionUnavailableException import org.libremail.data.security.EncryptedCacheGuard import org.libremail.reporting.AppLog import org.libremail.reporting.RingLogBuffer @@ -82,6 +83,24 @@ class SyncWorkerTest { assertEquals(ListenableWorker.Result.retry(), worker().doWork()) } + // --- issue #359: headless tolerance when SQLCipher's native library is unavailable ------------ + + @Test + fun `defers with a retry when the encrypted cache is unavailable, without crashing`() = runTest { + coEvery { cacheGuard.isCacheLocked() } returns false + // The cache is unlocked, so the worker resolves MailSyncer; its first DB access (Room's deferred + // cache open) throws because SQLCipher's native library is unavailable on this device (issue #359). + // That open is OUTSIDE syncAll's own runCatching, so without the guard it would escape doWork. + coEvery { mailSyncer.syncAll() } throws + CacheEncryptionUnavailableException(UnsatisfiedLinkError("SQLiteConnection.nativeOpen")) + + assertEquals(ListenableWorker.Result.retry(), worker().doWork()) + + val entry = logBuffer.snapshot().single() + assertEquals('W', entry.level) + assertTrue(entry.message.startsWith("deferred: encrypted cache unavailable"), entry.message) + } + // --- issue #329: AppLog breadcrumbs --------------------------------------------------------- @Test