fix(cache): extend fail-closed SQLCipher handling beyond resolveOpenMode (#359)
Preserve the in-flight #359 crash-loop fix recovered from an orphaned agent worktree (host crashed before it committed). Widens the fail-closed LinkageError handling to the keyed opens that previously escaped it (AccountDataMigrator, deferred Room open, headless workers/IdleService via a new EncryptedCacheWorkerGuard), plus unit + instrumented regression tests. Not yet validated end-to-end; gate + E2E run to follow.
This commit is contained in:
+64
@@ -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<Context>()
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+54
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<CacheEncryptionUnavailableException> { 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<CacheEncryptionUnavailableException> { 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user