Merge branch 'main' into fix-359-encryption-gate-coverage

This commit is contained in:
Jason Ross
2026-07-07 21:41:31 -05:00
committed by GitHub
9 changed files with 617 additions and 30 deletions
+90 -25
View File
@@ -6,9 +6,17 @@
# Spec: docs/ci/mergify-integration-spec.md + docs/ci/mergify.yml.proposed
# (issues #407 / #408).
# Schema: https://docs.mergify.com/configuration/file-format/
# Verified against the LIVE Mergify docs on 2026-07-06 (queue rules,
# the queue action, priority rules, parallel checks, batches, setup and
# lifecycle pages) — the config format evolves, so this is not from memory.
# Verified against the LIVE Mergify docs on 2026-07-07 (file-format, queue
# rules, priority, merge-queue lifecycle/setup/batches, and the
# merge-protections auto-merge pages) — the config format evolves, so this is
# not from memory.
# 2026-07-07 CHANGE: auto-queueing migrated OFF the `pull_request_rules`
# queue-action path (which no longer auto-queues — a green matching PR just
# reported "Merge queue is ready — use `@Mergifyio queue`" and sat there) ONTO
# `merge_protections_settings.auto_merge_conditions` (see that block below).
# The old `autoqueue`/queue-action auto path is DEPRECATED and "will stop
# working on 2026-07-16" (docs.mergify.com/merge-queue/rules). This changes only
# the TRIGGER; the queue's merge semantics (below) are untouched.
# ============================================================================
#
# WHAT THIS DOES
@@ -31,6 +39,23 @@
# * The single required status check stays "CI passed" — the exact `name:` of the
# `ci-passed` job in .github/workflows/ci.yml. NOT "ci-passed". A wrong name means
# PRs queue but never merge.
# * IN-PLACE CHECKS, not speculative draft-PR checks. GitHub's strict
# `required_status_checks` ruleset (require-branches-up-to-date) rejects
# speculative checks outright — Mergify surfaced this as a "Configuration not
# compatible with `required_status_checks` ruleset rule" check on #422. The fix
# (per Mergify: docs.mergify.com/merge-queue/rules) is to make Mergify validate
# each PR IN PLACE, on the real PR branch, which requires ALL THREE of:
# (a) `merge_queue.max_parallel_checks: 1` (below),
# (b) every `queue_rules[].batch_size: 1` (below), and
# (c) `queue_rules.default.queue_conditions` IDENTICAL (same conditions, same
# order) to `queue_rules.default.merge_conditions` — i.e. no "two-step CI"
# where the conditions to ENTER the queue differ from the conditions to
# MERGE. Mergify runs three condition sets, sequentially:
# `merge_protections_settings.auto_merge_conditions` (TRIGGERS auto-queueing)
# → `queue_conditions` (validates a PR's queue ENTRY) → `merge_conditions`
# (validates the MERGE). Omitting `queue_conditions` — as this config first
# did — reads as a two-step-CI mismatch and re-trips the incompatibility
# check, so we keep all three lists identical. Do not let them drift apart.
# ---------------------------------------------------------------------------
# queue_rules — how a queued PR is validated and merged.
@@ -38,16 +63,35 @@
queue_rules:
- name: default
# Final merge gate. Merge ONLY when the single required context is green (the exact
# same check branch protection requires), the PR is not a draft, has no merge
# conflicts, and is not flagged `broken`. NOTE: branch protection requires 0
# same check branch protection requires), the PR targets `main`, is not a draft, has
# no merge conflicts, and is not flagged `broken`. NOTE: branch protection requires 0
# approvals here (the active repository ruleset sets required_approving_review_count
# = 0), so there is deliberately NO `#approved-reviews-by` condition — adding one
# would wedge the solo-maintainer flow, where nobody can approve their own PR.
merge_conditions:
- check-success = CI passed
#
# IN-PLACE CHECKS: `queue_conditions` (what a PR must satisfy to ENTER/stay in the
# queue) MUST be IDENTICAL (same conditions, same order) to `merge_conditions` (what
# it must satisfy to MERGE) below. When those two lists match — plus batch_size 1 and
# max_parallel_checks 1 — Mergify validates each PR IN PLACE on the real PR branch
# instead of running speculative draft-PR checks, which is what GitHub's strict
# `required_status_checks` ruleset (require-branches-up-to-date) demands. Omitting
# `queue_conditions` (as this config originally did) is treated as a "two-step CI"
# mismatch and Mergify flags the ruleset as incompatible. Keep the three lists here —
# `queue_conditions`, `merge_conditions`, and
# `merge_protections_settings.auto_merge_conditions` — all identical; if any diverge,
# Mergify's ruleset-compatibility check fails again.
queue_conditions:
- base = main
- -draft
- -conflict
- label != broken
- check-success = CI passed
merge_conditions:
- base = main
- -draft
- -conflict
- label != broken
- check-success = CI passed
# SERIAL: exactly one PR per merge. No batching (Phase 2 / #410). One merge commit
# per PR, which is what lets require-up-to-date stay literally ON.
batch_size: 1
@@ -125,22 +169,43 @@ priority_rules:
priority: 1000
# ---------------------------------------------------------------------------
# pull_request_rules — WHICH PRs enter the queue.
# The `queue` action is what actually ADDS a PR to the merge queue: per
# docs.mergify.com/merge-queue/lifecycle, queue_conditions alone do NOT auto-queue a PR —
# a queue action (or an `@mergifyio queue` command / auto_merge) is required, otherwise the
# "Mergify Merge Queue" check sits permanently pending. A PR is queued as soon as it is green
# on "CI passed", targets `main`, is not a draft, has no conflicts, and is not flagged
# `broken`. `broken` / `draft` PRs are never queued.
# merge_protections_settings — WHICH PRs are AUTOMATICALLY added to the queue.
#
# This REPLACES the old `pull_request_rules` `queue` action. That action no longer
# auto-queues in current Mergify: a green, matching PR just reported "Merge queue is
# ready — use `@Mergifyio queue`" and sat there forever (never merged). Automatic
# queueing now lives in `auto_merge_conditions` under `merge_protections_settings`. The
# old `queue_rules[].autoqueue` field (and the queue-action auto path) is DEPRECATED and
# "will stop working on 2026-07-16. Use `auto_merge_conditions` in
# `merge_protections_settings` instead" (docs.mergify.com/merge-queue/rules).
#
# `auto_merge_conditions` accepts `true` (auto-queue every mergeable PR) or, as here,
# "a list of conditions to restrict the audience" (docs.mergify.com/configuration/
# file-format). We give the SAME set the old queue action used, so EXACTLY the same PRs
# auto-queue: green on "CI passed", targeting `main`, not a draft, no conflicts, not
# `broken`.
#
# WHY THIS PRESERVES require-up-to-date: this changes only the TRIGGER (manual →
# automatic). It does NOT touch how the queue validates or merges — batch_size 1,
# merge_method merge, and max_parallel_checks 1 above are unchanged — and those are the
# settings that interact with require-up-to-date (only BATCHING, batch_size > 1, forces
# that checkbox OFF; see the invariants header + docs.mergify.com/merge-queue/batches).
# When a merge queue is configured, a matched PR is auto-QUEUED, not merged directly:
# "Every PR is auto-queued. The merge queue then handles routing and merging"
# (docs.mergify.com/merge-protections/auto-merge) — so it still goes through the serial
# queue, gets updated onto the latest `main`, re-runs CI, and merges on the real green
# "CI passed". Mergify also auto-reads GitHub branch protection (the required "CI passed"
# check + require-up-to-date) and injects it as a merge condition, so the GitHub gate is
# enforced on top of queue_rules.merge_conditions.
#
# This list MUST stay IDENTICAL (same conditions, same order) to
# `queue_rules.default.queue_conditions` and `.merge_conditions` above — see the note
# there and the IN-PLACE CHECKS hard invariant at the top of this file.
# ---------------------------------------------------------------------------
pull_request_rules:
- name: Queue green, non-draft, non-conflicting PRs targeting main
conditions:
- base = main
- -draft
- -conflict
- label != broken
- check-success = CI passed
actions:
queue:
name: default
merge_protections_settings:
auto_merge_conditions:
- base = main
- -draft
- -conflict
- label != broken
- check-success = CI passed
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.security.KeystoreCrypto
import java.io.File
/**
* On-device proof for issue #369: with at-rest encryption ON, [ReportStore] persists a report as real
* Android Keystore ciphertext — no report content in plaintext on disk — and reads it back intact;
* with it OFF the file stays plaintext JSON. This closes the gap between the JVM-tested ReportStore
* branching (which fakes the cipher) and the device-only [KeystoreCrypto] the branching drives in
* production, using the same non-auth master key that lets a crash-while-locked report still be sealed.
*/
@RunWith(AndroidJUnit4::class)
class ReportStoreEncryptionInstrumentedTest {
private val context =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
private val dir = File(context.cacheDir, "report-encryption-test")
@Before
fun setUp() {
dir.deleteRecursively()
}
@After
fun tearDown() {
dir.deleteRecursively()
}
@Test
fun encryptedReportIsCiphertextOnDiskAndReadsBack() {
val store = store(enabled = true)
store.save(report("enc"))
val raw = File(dir, "enc.json").readText()
// The distinctive plaintext token must NOT be on disk — the report is Keystore-sealed at rest.
assertFalse("report content must not be persisted in plaintext", raw.contains(SENTINEL))
assertFalse("a sealed report is not plaintext JSON", raw.startsWith("{"))
// A fresh store over the same directory (same master key) unseals and reads it back intact.
assertEquals(SENTINEL, store(enabled = true).find("enc")?.logs?.single())
}
@Test
fun plaintextReportWhenEncryptionOff() {
val store = store(enabled = false)
store.save(report("plain"))
val raw = File(dir, "plain.json").readText()
assertTrue("with encryption off the report stays plaintext JSON", raw.contains(SENTINEL))
assertTrue(raw.startsWith("{"))
}
private fun store(enabled: Boolean): ReportStore {
val crypto = KeystoreCrypto()
val encryption = object : ReportEncryption {
override fun enabled(): Boolean = enabled
override fun encrypt(plaintext: String): String = crypto.encrypt(plaintext)
override fun decrypt(encoded: String): String = crypto.decrypt(encoded)
}
return ReportStore(dir, CoroutineScope(Dispatchers.Unconfined), encryption)
}
private fun report(id: String) = DebugReport(
id = id,
createdAtMillis = 1_000L,
kind = ReportKind.CRASH,
appVersionName = "1.0",
appVersionCode = 1L,
androidRelease = "14",
androidSdkInt = 34,
deviceManufacturer = "Test",
deviceModel = "Model",
stackTrace = SENTINEL,
settings = emptyMap(),
logs = listOf(SENTINEL),
)
private companion object {
const val SENTINEL = "SENTINEL-PLAINTEXT-TOKEN-369"
}
}
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.libremail.data.security.KeystoreReportEncryption
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.domain.repository.AccountRepository
@@ -48,6 +49,8 @@ class LibreMailApplication :
@Inject lateinit var diagnosticsCollector: DiagnosticsCollector
@Inject lateinit var reportEncryption: KeystoreReportEncryption
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
@@ -74,6 +77,10 @@ class LibreMailApplication :
// Warm the settings cache so a later crash report can include non-PII settings without
// touching DataStore on the crashing thread.
appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } }
// Mirror the encryptCache setting so a crash-time report save (synchronous, on the crashing
// thread) can seal the report at rest without touching DataStore (#369). Collects for the
// process lifetime, so a mid-session toggle takes effect on the next report write.
appScope.launch { runCatching { reportEncryption.observeEncryptCacheSetting() } }
syncScheduler.schedulePeriodicSync()
// Full-history backfill (#12) and device-only retention pruning (#13) run as their own bounded,
// resumable background jobs so they never block foreground sync / pull-to-refresh.
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.libremail.data.settings.SettingsRepository
import org.libremail.reporting.AppLog
import org.libremail.reporting.ReportEncryption
import javax.inject.Inject
import javax.inject.Singleton
/**
* On-device [ReportEncryption]: seals a persisted report's JSON with the non-auth Keystore master key
* ([KeystoreCrypto]) so at-rest report storage honours the opt-in `encryptCache` setting (issue #369).
* Reuses the vetted AES-256-GCM crypto rather than rolling new; encryption is `Base64(iv || ciphertext)`.
*
* The **master** key (not the auth-bound cache key) is deliberate: it is usable without a user-presence
* prompt, so a crash that occurs while the app is locked can still seal and persist its report — the
* ticket requires crash reports to survive, encrypted, even then.
*
* [enabled] is answered from an in-memory mirror of the `encryptCache` setting, never a live DataStore
* read: a crash-time [org.libremail.reporting.ReportStore.save] runs synchronously on the crashing
* thread and must not touch DataStore (#296). [observeEncryptCacheSetting], launched once at startup,
* keeps that mirror current so a mid-session toggle takes effect on the next report write. The mirror
* defaults to `false` (plaintext) until the first settings value lands — the same brief unwarmed
* startup window [org.libremail.reporting.DiagnosticsCollector] accepts for a crash report's settings.
*/
@Singleton
class KeystoreReportEncryption @Inject constructor(
private val crypto: KeystoreCrypto,
private val settingsRepository: SettingsRepository,
) : ReportEncryption {
@Volatile
private var encryptionEnabled: Boolean = false
override fun enabled(): Boolean = encryptionEnabled
override fun encrypt(plaintext: String): String = crypto.encrypt(plaintext)
override fun decrypt(encoded: String): String = crypto.decrypt(encoded)
/**
* Mirrors the `encryptCache` setting into [encryptionEnabled] for the process lifetime. Collects
* forever, so launch it once from application startup. PII-free — only the on/off state is logged.
*/
suspend fun observeEncryptCacheSetting() {
settingsRepository.settings
.map { it.encryptCache }
.distinctUntilChanged()
.collect { enabled ->
encryptionEnabled = enabled
AppLog.i(TAG, "Report at-rest encryption is now ${if (enabled) "ON" else "OFF"}")
}
}
private companion object {
const val TAG = "ReportEncryption"
}
}
@@ -8,6 +8,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.libremail.BuildConfig
import org.libremail.data.security.KeystoreReportEncryption
import org.libremail.reporting.DebugReportEndpoint
import org.libremail.reporting.ReportStore
import java.io.File
@@ -17,10 +18,19 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object ReportingModule {
/**
* The file-backed report store. [KeystoreReportEncryption] wires in the opt-in at-rest encryption
* (issue #369): reports are sealed on disk when the `encryptCache` setting is on, plaintext when off.
*/
@Provides
@Singleton
fun provideReportStore(@ApplicationContext context: Context): ReportStore =
ReportStore(File(context.filesDir, "debug_reports"))
fun provideReportStore(
@ApplicationContext context: Context,
reportEncryption: KeystoreReportEncryption,
): ReportStore = ReportStore(
directory = File(context.filesDir, "debug_reports"),
encryption = reportEncryption,
)
/**
* The debug-report ingest endpoint (empty by default — see [DebugReportEndpoint]). Provided as an
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
/**
* At-rest encryption seam for persisted [DebugReport]s (issue #369). When the opt-in cache encryption
* (`encryptCache`) is ON, [ReportStore] seals a report's storage JSON with this before writing it and
* unseals it on read; when OFF the JSON is stored plaintext exactly as before.
*
* Kept behind an interface so [ReportStore] stays a plain `java.io.File` component that JVM-tests
* without the Android Keystore. The on-device implementation
* (`org.libremail.data.security.KeystoreReportEncryption`) wraps the vetted Keystore crypto; [None] is
* the plaintext default used where encryption isn't wired and by tests that don't exercise it.
*/
interface ReportEncryption {
/**
* Whether reports must be encrypted at rest right now — a synchronous, crash-safe mirror of the
* `encryptCache` setting, never a live DataStore read (a crash-time [ReportStore.save] runs on the
* crashing thread). When false, [encrypt]/[decrypt] are never called.
*/
fun enabled(): Boolean
/** Seals [plaintext] to an opaque at-rest blob. Only invoked when [enabled] is true. */
fun encrypt(plaintext: String): String
/** Unseals a blob produced by [encrypt] back to the original plaintext. */
fun decrypt(encoded: String): String
/** The plaintext default: encryption disabled, so [encrypt]/[decrypt] are pass-throughs never used. */
object None : ReportEncryption {
override fun enabled(): Boolean = false
override fun encrypt(plaintext: String): String = plaintext
override fun decrypt(encoded: String): String = encoded
}
}
@@ -22,10 +22,19 @@ import java.io.File
* to [scope] (IO by default). Reactive consumers (Problem Reports, the startup prompt) observe
* [reports] and update when the scan lands; the empty window is momentary. Writes ([save] etc.)
* re-scan synchronously so a crash-time save is never lost to the pending initial scan.
*
* At-rest encryption (issue #369): when [encryption] reports it is [ReportEncryption.enabled], each
* report's storage JSON is sealed before it is written and tagged with [ENCRYPTED_PREFIX]; when it is
* off the JSON is stored plaintext exactly as before. Reads sniff the prefix, so plaintext reports from
* before the setting was turned on and sealed reports written after it coexist transparently. Writes
* FAIL CLOSED: if sealing throws while encryption is on, the report is dropped rather than written in
* plaintext, so the user's opt-in encryption is never silently defeated by leaving a plaintext report
* on disk. The default [ReportEncryption.None] keeps the store plaintext (its historical behaviour).
*/
class ReportStore(
private val directory: File,
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val encryption: ReportEncryption = ReportEncryption.None,
) {
private val lock = Any()
private val _reports = MutableStateFlow<List<DebugReport>>(emptyList())
@@ -41,8 +50,9 @@ class ReportStore(
fun save(report: DebugReport) {
synchronized(lock) {
val serialized = serializeForDisk(report) ?: return
directory.mkdirs()
File(directory, fileName(report.id)).writeText(report.toStorageJson())
File(directory, fileName(report.id)).writeText(serialized)
_reports.value = scan()
}
}
@@ -58,7 +68,8 @@ class ReportStore(
synchronized(lock) {
val report = _reports.value.firstOrNull { it.id == id } ?: return
if (report.surfaced) return
File(directory, fileName(id)).writeText(report.copy(surfaced = true).toStorageJson())
val serialized = serializeForDisk(report.copy(surfaced = true)) ?: return
File(directory, fileName(id)).writeText(serialized)
_reports.value = scan()
}
}
@@ -85,13 +96,57 @@ class ReportStore(
val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) }
?: return emptyList()
return files
.mapNotNull { file -> runCatching { DebugReport.fromStorageJson(file.readText()) }.getOrNull() }
.mapNotNull { file -> readReport(file) }
.sortedByDescending { it.createdAtMillis }
}
/**
* Renders [report] for on-disk storage. With encryption OFF this is the plaintext storage JSON,
* byte-for-byte as before. With it ON the JSON is sealed and tagged with [ENCRYPTED_PREFIX]. Returns
* `null` — so the caller writes nothing — when sealing fails while encryption is ON: the report is
* deliberately NOT written in plaintext (that would defeat the user's opt-in encryption, #369), so a
* failed seal drops the report rather than leaking it. A dropped crash report still lets the original
* crash propagate to the system handler.
*/
private fun serializeForDisk(report: DebugReport): String? {
val json = report.toStorageJson()
if (!encryption.enabled()) return json
return runCatching { ENCRYPTED_PREFIX + encryption.encrypt(json) }.getOrElse { e ->
AppLog.e(TAG, "Report encryption failed; not persisting to avoid a plaintext report on disk", e)
null
}
}
/**
* Reads one stored report, transparently unsealing files tagged with [ENCRYPTED_PREFIX]. A file that
* cannot be decrypted (e.g. the master key was cleared) is logged and skipped rather than crashing
* the list; an unparseable plaintext file is skipped silently, as before.
*/
private fun readReport(file: File): DebugReport? {
val raw = runCatching { file.readText() }.getOrNull() ?: return null
val json = if (raw.startsWith(ENCRYPTED_PREFIX)) {
runCatching { encryption.decrypt(raw.removePrefix(ENCRYPTED_PREFIX)) }.getOrElse { e ->
AppLog.w(TAG, "Skipping a stored report that could not be decrypted", e)
return null
}
} else {
raw
}
return runCatching { DebugReport.fromStorageJson(json) }.getOrNull()
}
private fun fileName(id: String) = "$id$SUFFIX"
private companion object {
const val SUFFIX = ".json"
const val TAG = "ReportStore"
/**
* Marks a file whose body is `Base64(iv || ciphertext)` rather than plaintext JSON. Contains
* characters outside Base64's alphabet (`.`/`:`) and never matches a plaintext report's leading
* `{`, so a read tells sealed from plaintext files unambiguously — the two coexist on disk after
* the encryption setting is toggled.
*/
const val ENCRYPTED_PREFIX = "libremail.report.enc.v1:"
}
}
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [KeystoreReportEncryption] delegates the crypto to [KeystoreCrypto] (device-bound, tested in
* `KeystoreCryptoTest`) and answers [org.libremail.reporting.ReportEncryption.enabled] from an
* in-memory mirror of the `encryptCache` setting. These pin the delegation and that the mirror tracks
* the observed setting — including the crash-safe default of "off" before the first value lands.
*/
class KeystoreReportEncryptionTest {
private val crypto = mockk<KeystoreCrypto>()
private val settingsRepository = mockk<SettingsRepository>()
private val encryption = KeystoreReportEncryption(crypto, settingsRepository)
@Before
fun setUp() {
// observeEncryptCacheSetting breadcrumbs the on/off state through AppLog -> android.util.Log,
// a no-op stub that throws "not mocked" under plain JVM tests. Fully-qualified (a raw
// android.util.Log import is detekt-forbidden, epic #324).
mockkStatic(android.util.Log::class)
every { android.util.Log.i(any<String>(), any<String>()) } returns 0
}
@After
fun tearDown() = unmockkAll()
@Test
fun `is disabled before the setting has been observed`() {
assertFalse(encryption.enabled())
}
@Test
fun `encrypt delegates to the keystore crypto`() {
every { crypto.encrypt("report-json") } returns "cipher"
assertEquals("cipher", encryption.encrypt("report-json"))
}
@Test
fun `decrypt delegates to the keystore crypto`() {
every { crypto.decrypt("cipher") } returns "report-json"
assertEquals("report-json", encryption.decrypt("cipher"))
}
@Test
fun `enabled mirrors the latest encryptCache setting when on`() = runTest {
every { settingsRepository.settings } returns
flowOf(AppSettings(encryptCache = false), AppSettings(encryptCache = true))
encryption.observeEncryptCacheSetting()
assertTrue(encryption.enabled())
}
@Test
fun `enabled mirrors the latest encryptCache setting when off`() = runTest {
every { settingsRepository.settings } returns
flowOf(AppSettings(encryptCache = true), AppSettings(encryptCache = false))
encryption.observeEncryptCacheSetting()
assertFalse(encryption.enabled())
}
}
@@ -0,0 +1,179 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
import java.util.Base64
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* At-rest encryption of persisted reports (issue #369). Uses a reversible fake [ReportEncryption]
* (Base64, so the on-disk body genuinely scrambles the report content the way real AES-GCM does) — the
* Keystore round-trip itself is [org.libremail.data.security.KeystoreCrypto]'s device-bound concern.
* These pin [ReportStore]'s own branching: seal-on-write when enabled, plaintext when off, both formats
* readable together, and the fail-closed guarantee that a sealing failure never leaves a plaintext
* report on disk.
*/
class ReportStoreEncryptionTest {
@get:Rule
val tempFolder = TemporaryFolder()
@Before
fun setUp() {
// ReportStore's fail-closed / skip-on-failure paths breadcrumb through AppLog -> android.util.Log,
// a no-op stub that throws "not mocked" under plain JVM tests. Fully-qualified (a raw
// android.util.Log import is detekt-forbidden, epic #324).
mockkStatic(android.util.Log::class)
every { android.util.Log.e(any<String>(), any<String>(), any()) } returns 0
every { android.util.Log.w(any<String>(), any<String>(), any()) } returns 0
}
@After
fun tearDown() = unmockkAll()
@Test
fun `encrypts the report at rest when enabled and reads it back`() {
val store = store(FakeEncryption(enabled = true))
store.save(report("enc"))
val raw = File(tempFolder.root, "enc.json").readText()
// Sealed at rest: the distinctive plaintext token is NOT on disk, and the file is not the JSON.
assertFalse(raw.contains(SENTINEL))
assertFalse(raw.startsWith("{"))
// Still fully readable through the store (decrypted on scan).
assertEquals(SENTINEL, store.find("enc")?.logs?.single())
// Survives a fresh instance over the same directory (the next launch decrypts and reads it).
assertEquals("enc", store(FakeEncryption(enabled = true)).find("enc")?.id)
}
@Test
fun `stores plaintext byte-for-byte when disabled`() {
val store = store(FakeEncryption(enabled = false))
store.save(report("plain"))
val raw = File(tempFolder.root, "plain.json").readText()
// Exactly the historical plaintext storage form — the token is present and it is JSON.
assertEquals(report("plain").toStorageJson(), raw)
assertTrue(raw.contains(SENTINEL))
assertEquals("plain", store.find("plain")?.id)
}
@Test
fun `persists a crash report encrypted at rest`() {
val store = store(FakeEncryption(enabled = true))
store.save(report("crash", kind = ReportKind.CRASH))
val raw = File(tempFolder.root, "crash.json").readText()
assertFalse(raw.contains(SENTINEL))
assertEquals(ReportKind.CRASH, store.find("crash")?.kind)
}
@Test
fun `fails closed - a sealing failure never writes a plaintext report`() {
val store = store(FakeEncryption(enabled = true, encryptor = { error("keystore unavailable") }))
store.save(report("boom"))
// Nothing was written: no plaintext leak, and the report is simply absent (crash still propagates).
assertFalse(File(tempFolder.root, "boom.json").exists())
assertTrue(store.reports.value.isEmpty())
assertNull(store.find("boom"))
}
@Test
fun `reads a mix of plaintext and encrypted reports on disk`() {
// An older plaintext report from before encryption was turned on, written directly.
File(tempFolder.root, "old.json").writeText(report("old", createdAt = 1L).toStorageJson())
val store = store(FakeEncryption(enabled = true))
// A newer report saved after the setting was turned on is sealed.
store.save(report("new", createdAt = 2L))
assertEquals(listOf("new", "old"), store.reports.value.map { it.id })
}
@Test
fun `skips a report that cannot be decrypted`() {
// Seal a report with a working store, then reopen with one whose decrypt fails (key rotated).
store(FakeEncryption(enabled = true)).save(report("sealed"))
val broken = store(FakeEncryption(enabled = true, decryptor = { error("key was cleared") }))
assertTrue(broken.reports.value.isEmpty())
assertNull(broken.find("sealed"))
}
@Test
fun `markSurfaced re-seals the report and persists the flag`() {
val store = store(FakeEncryption(enabled = true))
store.save(report("a"))
store.markSurfaced("a")
val raw = File(tempFolder.root, "a.json").readText()
assertFalse(raw.contains(SENTINEL))
assertTrue(store.find("a")!!.surfaced)
// A fresh instance decrypts and reads it back as surfaced.
assertTrue(store(FakeEncryption(enabled = true)).find("a")!!.surfaced)
}
@Test
fun `None encryption is a disabled plaintext pass-through`() {
assertFalse(ReportEncryption.None.enabled())
assertEquals("x", ReportEncryption.None.encrypt("x"))
assertEquals("x", ReportEncryption.None.decrypt("x"))
}
private fun store(encryption: ReportEncryption) =
ReportStore(tempFolder.root, CoroutineScope(Dispatchers.Unconfined), encryption)
private fun report(id: String, createdAt: Long = 1L, kind: ReportKind = ReportKind.MANUAL) = DebugReport(
id = id,
createdAtMillis = createdAt,
kind = kind,
appVersionName = "0.1.0",
appVersionCode = 1,
androidRelease = "14",
androidSdkInt = 34,
deviceManufacturer = "Google",
deviceModel = "Pixel",
stackTrace = null,
settings = emptyMap(),
logs = listOf(SENTINEL),
)
/**
* A reversible stand-in for the Keystore cipher: Base64 genuinely scrambles the report content on
* disk (so a "not plaintext" assertion is meaningful) while round-tripping. [encryptor]/[decryptor]
* are overridable to inject failures.
*/
private class FakeEncryption(
private val enabled: Boolean,
private val encryptor: (String) -> String = { Base64.getEncoder().encodeToString(it.toByteArray()) },
private val decryptor: (String) -> String = { String(Base64.getDecoder().decode(it)) },
) : ReportEncryption {
override fun enabled(): Boolean = enabled
override fun encrypt(plaintext: String): String = encryptor(plaintext)
override fun decrypt(encoded: String): String = decryptor(encoded)
}
private companion object {
const val SENTINEL = "SENTINEL-PLAINTEXT-369"
}
}