refactor(data): route DB/keystore logging through AppLog (#327)

Migrates the DB/keystore area's raw android.util.Log calls to AppLog so
key-invalidation and DB-conversion breadcrumbs land in the process
RingLogBuffer (and thus a user-reviewed debug report) even in release
builds, where Log.d is otherwise stripped from Logcat only.

- DatabaseKeyCipher: 4 auth-bound-key decision points (encrypt retry,
  isInvalidated's three branches) now log via AppLog.d(tag, msg, e).
- DatabaseEncryption.migrate: adds an AppLog.i "converting local cache
  database (targetEncrypted=...)" breadcrumb at the start, alongside the
  existing "converted" completion line now routed through AppLog.d.
- AccountDataMigrator: the "moved account tables into the account
  database: $present" breadcrumb (table names only) now routed through
  AppLog.d.

No PII or key material is logged; table-name sets and boolean flags only.

Adds instrumented tests (DatabaseKeyCipher is device-only and
behavior-preserving, so no new test there) asserting the breadcrumbs
land in a RingLogBuffer and never contain the seeded email, secret, or
passphrase.

Part of #324.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 21:12:59 -05:00
co-authored by Claude Opus 4.8
parent 41015a5c40
commit 0cb9bb2906
5 changed files with 71 additions and 9 deletions
@@ -13,6 +13,7 @@ import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -21,6 +22,8 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.CredentialEntity
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
/**
* The one-time move performed by [AccountDataMigrator] (issue #111): copying accounts / credentials /
@@ -142,6 +145,26 @@ class AccountDataMigratorTest {
}
}
@Test
fun copyEmitsANonPiiAppLogBreadcrumbNamingOnlyTheMovedTables() = runBlocking<Unit> {
seedVersion14Cache()
val buffer = RingLogBuffer()
AppLog.install(buffer)
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
val entry = buffer.snapshot()
.single { it.message.startsWith("moved account tables into the account database") }
assertEquals("the migration breadcrumb is a debug line", 'D', entry.level)
listOf("accounts", "credentials", "account_settings", "signatures").forEach { table ->
assertTrue("breadcrumb must name the moved table $table", entry.message.contains(table))
}
// The breadcrumb carries only table names — never the seeded email, secret, or passphrase.
assertFalse(entry.message.contains("ada@example.org"))
assertFalse(entry.message.contains("sealed-secret"))
assertFalse(entry.message.contains(passphrase))
}
@Test
fun reRunningTheCopyIsIdempotentAndKeepsLaterEdits() = runBlocking<Unit> {
seedVersion14Cache()
@@ -17,6 +17,8 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.MessageEntity
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import java.io.File
/**
@@ -150,6 +152,42 @@ class DatabaseEncryptionTest {
assertEquals("Room's schema version must survive the plaintext -> encrypted conversion", 19, version)
}
@Test
fun conversionEmitsNonPiiAppLogBreadcrumbs() = runBlocking<Unit> {
val buffer = RingLogBuffer()
AppLog.install(buffer)
// The seeded row carries an email address so the PII assertions below are meaningful.
openPlaintext().apply {
messageDao().insertNew(listOf(message("acct:1")))
close()
}
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
val afterEncrypt = buffer.snapshot()
val converting = afterEncrypt.single { it.message.startsWith("converting local cache database") }
assertEquals("the start breadcrumb is informational", 'I', converting.level)
assertEquals("converting local cache database (targetEncrypted=true)", converting.message)
val convertedAfterEncrypt = afterEncrypt.single { it.message == "local cache database converted" }
assertEquals('D', convertedAfterEncrypt.level)
// Converting back to plaintext logs the same pair with the flag flipped.
buffer.clear()
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
val afterDecrypt = buffer.snapshot()
assertTrue(
afterDecrypt.any { it.message == "converting local cache database (targetEncrypted=false)" },
)
assertTrue(afterDecrypt.any { it.message == "local cache database converted" })
// Neither conversion's breadcrumbs may leak the passphrase, the on-disk path, or account PII.
(afterEncrypt + afterDecrypt).forEach { entry ->
assertFalse("must not leak the passphrase", entry.message.contains(passphrase))
assertFalse("must not leak the db file path", entry.message.contains(dbFile.absolutePath))
assertFalse("must not leak the seeded email", entry.message.contains("ada@example.org"))
}
}
private fun openPlaintext(): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build()