Merge pull request #135 from JMR-dev/refactor-db-file-backup-sot

refactor(security): derive backup exclusion set from DatabaseFiles
This commit was merged in pull request #135.
This commit is contained in:
Jason Ross
2026-07-02 10:58:05 -05:00
committed by GitHub
6 changed files with 79 additions and 24 deletions
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.settings.AppSettings
/**
@@ -24,13 +25,16 @@ object BackupPolicy {
"datastore/libremail_dbkey.preferences_pb",
)
/** `databases`-dir-relative names that must never leave the device (encrypted credentials + mail cache). */
val EXCLUDED_DATABASE_PATHS: List<String> = listOf(
"libremail.db",
"libremail.db-wal",
"libremail.db-shm",
"libremail.db-journal",
)
/**
* `databases`-dir-relative names that must never leave the device: the encrypted mail cache
* ([DatabaseFiles.NAME]) AND the accounts + encrypted-credentials database
* ([DatabaseFiles.ACCOUNTS_NAME]), each with its SQLite sidecars. Derived from [DatabaseFiles]
* rather than hand-listed, so a newly added database can never silently fall out of the
* never-back-up set (issue #103).
*/
val EXCLUDED_DATABASE_PATHS: List<String> =
DatabaseFiles.fileNames(DatabaseFiles.NAME) +
DatabaseFiles.fileNames(DatabaseFiles.ACCOUNTS_NAME)
/**
* Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up
@@ -2,9 +2,8 @@
package org.libremail.data.local
import android.content.Context
import java.io.File
/** Central name and wipe helper for the Room cache database file (and its SQLite sidecars). */
/** Central names and wipe helper for the Room database files (and their SQLite sidecars). */
object DatabaseFiles {
const val NAME = "libremail.db"
@@ -17,15 +16,27 @@ object DatabaseFiles {
const val ACCOUNTS_NAME = "libremail-accounts.db"
/**
* Delete the database and any WAL/SHM/journal sidecars. Call only when no connection is open —
* used by the "clear + re-sync" path when the encryption key is invalidated and the encrypted
* database can no longer be decrypted.
* The statically-nameable SQLite sidecars that accompany a database file. A transient `-mj*`
* master journal can also exist, but its suffix is random and so can't be listed by name —
* [clear] leans on [Context.deleteDatabase] to sweep that one up.
*/
private val SIDECAR_SUFFIXES = listOf("-wal", "-shm", "-journal")
/**
* [name] plus each of its statically-nameable sidecars. The single source of truth for which
* on-disk files make up a database file; `BackupPolicy` derives its never-back-up set from this
* so a new database (or a new sidecar suffix) can never silently fall out of the exclusions.
*/
fun fileNames(name: String): List<String> = listOf(name) + SIDECAR_SUFFIXES.map { name + it }
/**
* Delete the cache database ([NAME]) and every sidecar — including the `-mj*` master journal a
* hand-rolled suffix list would miss — via [Context.deleteDatabase]. NEVER touches
* [ACCOUNTS_NAME], so a cache-key invalidation keeps the user signed in (issue #111). Call only
* when no connection is open — used by the "clear + re-sync" path when the encryption key is
* invalidated and the encrypted database can no longer be decrypted.
*/
fun clear(context: Context) {
val db = context.getDatabasePath(NAME)
val dir = db.parentFile ?: return
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
File(dir, db.name + suffix).delete()
}
context.deleteDatabase(NAME)
}
}
+5 -4
View File
@@ -5,10 +5,11 @@
which applies on API 31+. Backup is still gated by LibreMailBackupAgent (opt-in, OFF by default).
<include> makes this a strict allowlist: ONLY the libremail_settings DataStore is backed up. The
Keystore-sealed cache passphrase (datastore/libremail_dbkey.preferences_pb) and the encrypted
credentials + mail-cache database (libremail.db and its -wal/-shm/-journal side files) are kept
off-device by being omitted from the allowlist; the mail cache re-downloads on the next sync and
accounts are re-added on a new device. (Lint's FullBackupContent rule forbids <exclude> paths
Keystore-sealed cache passphrase (datastore/libremail_dbkey.preferences_pb), the mail-cache
database (libremail.db) and the accounts + encrypted-credentials database (libremail-accounts.db,
which issue #111 split out of libremail.db) — each with their -wal/-shm/-journal side files — are
kept off-device by being omitted from the allowlist; the mail cache re-downloads on the next sync
and accounts are re-added on a new device. (Lint's FullBackupContent rule forbids <exclude> paths
outside an <include>, so exclusion is expressed by omission rather than explicit <exclude> entries.)
-->
<full-backup-content>
@@ -11,8 +11,11 @@
- datastore/libremail_dbkey.preferences_pb: the Keystore-sealed SQLCipher passphrase for the
encrypted cache. The wrapping Keystore key is non-exportable and device-bound, so the
ciphertext is useless anywhere else.
- libremail.db (+ -wal/-shm/-journal): encrypted IMAP/OAuth credentials and the cached mail.
The cache re-downloads on the next sync; accounts are re-added on a new device.
- libremail.db (+ -wal/-shm/-journal): the cached mail; re-downloads on the next sync.
- libremail-accounts.db (+ -wal/-shm/-journal): accounts, encrypted IMAP/OAuth credentials,
per-account settings and signatures (issue #111 moved these out of libremail.db). The
credentials are sealed with a device-bound key, so they would only ever restore as
undecryptable ciphertext; accounts are re-added on a new device.
(Lint's FullBackupContent rule forbids <exclude> paths outside an <include>, so the exclusions are
expressed by simply not listing those paths rather than as explicit <exclude> entries.)
-->
@@ -2,6 +2,7 @@
package org.libremail.backup
import org.junit.Test
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.settings.AppSettings
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -37,10 +38,35 @@ class BackupPolicyTest {
}
@Test
fun `the credentials and mail-cache database is never eligible for backup`() {
fun `the mail-cache database is never eligible for backup`() {
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db"))
// WAL/SHM/journal side-files can hold recently written rows too.
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-wal"))
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-shm"))
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-journal"))
}
@Test
fun `the accounts and credentials database is never eligible for backup`() {
// Since #111 the accounts + encrypted IMAP/OAuth credentials live in their OWN database file
// (libremail-accounts.db), so it must be in the never-back-up set just like the cache.
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail-accounts.db"))
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail-accounts.db-wal"))
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail-accounts.db-shm"))
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail-accounts.db-journal"))
}
@Test
fun `excluded database paths are derived from DatabaseFiles so none can silently fall out`() {
val derived = DatabaseFiles.fileNames(DatabaseFiles.NAME) +
DatabaseFiles.fileNames(DatabaseFiles.ACCOUNTS_NAME)
// Derived, not hand-maintained: the exclusion set is exactly the DatabaseFiles-known files —
// no more (nothing stale) and no less (every DB + sidecar covered).
assertEquals(derived, BackupPolicy.EXCLUDED_DATABASE_PATHS)
listOf(DatabaseFiles.NAME, DatabaseFiles.ACCOUNTS_NAME).forEach { name ->
DatabaseFiles.fileNames(name).forEach { path ->
assertTrue(path in BackupPolicy.EXCLUDED_DATABASE_PATHS, "$path must never be backed up")
}
}
}
}
@@ -2,6 +2,7 @@
package org.libremail.backup
import org.junit.Test
import org.libremail.data.local.DatabaseFiles
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
@@ -73,4 +74,13 @@ class DataExtractionRulesTest {
fun `full backup content (API 29-30) mirrors the same exclusions`() {
assertSafe(parseSection(resource("backup_rules.xml"), "full-backup-content"))
}
@Test
fun `both the cache and accounts databases are guarded against the backup allowlist`() {
// The exclusion SoT is derived from DatabaseFiles, so both databases flow into secretPaths
// and are asserted-absent from every include section by assertSafe above. Pin that here so
// the accounts + credentials DB added in #111 can't quietly drop out of the guarded set.
assertTrue(secretPaths.contains("database:${DatabaseFiles.NAME}"))
assertTrue(secretPaths.contains("database:${DatabaseFiles.ACCOUNTS_NAME}"))
}
}