Coverage Summary for Class: BackupImporterImplKt (com.georgeci.moneysurfer.data.backup)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| BackupImporterImplKt |
100%
(1/1)
|
100%
(1/1)
|
50%
(1/2)
|
100%
(2/2)
|
57.9%
(11/19)
|
package com.georgeci.moneysurfer.data.backup
import com.georgeci.moneysurfer.data.backup.crypto.EncryptedBackupCodec
import com.georgeci.moneysurfer.data.backup.zip.ZipStoredReader
import com.georgeci.moneysurfer.data.db.MONEY_SURFER_DB_VERSION
import com.georgeci.moneysurfer.data.db.MoneySurferDatabase
import com.georgeci.moneysurfer.domain.backup.BackupEncryptionInfo
import com.georgeci.moneysurfer.domain.backup.BackupError
import com.georgeci.moneysurfer.domain.backup.BackupImporter
import com.georgeci.moneysurfer.domain.backup.BackupManifest
import com.georgeci.moneysurfer.domain.backup.BackupStorageLocator
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okio.Buffer
import okio.BufferedSource
import okio.FileSystem
import okio.Path
import okio.SYSTEM
import okio.blackholeSink
import okio.buffer
import okio.use
import org.koin.core.annotation.Single
@Single(binds = [BackupImporter::class])
class BackupImporterImpl(
private val database: MoneySurferDatabase,
private val locator: BackupStorageLocator,
) : BackupImporter {
private val mutex = Mutex()
private val fs: FileSystem = FileSystem.SYSTEM
override suspend fun isPassphraseProtected(source: BufferedSource): Boolean =
withContext(Dispatchers.IO) {
runCatching {
readManifestEntry(ZipStoredReader(source.peek())).encryption != null
}.getOrDefault(false)
}
override suspend fun importFrom(
source: BufferedSource,
passphrase: String?,
): Result<BackupManifest> = mutex.withLock {
try {
Result.success(withContext(Dispatchers.IO) { runImport(source, passphrase) })
} catch (ce: CancellationException) {
throw ce
} catch (be: BackupError) {
Result.failure(be)
} catch (
@Suppress("TooGenericExceptionCaught") t: Throwable,
) {
Result.failure(BackupError.Io(t))
}
}
private fun runImport(source: BufferedSource, passphrase: String?): BackupManifest {
val reader = ZipStoredReader(source)
val manifest = readManifestEntry(reader)
return if (manifest.encryption != null) {
importEncrypted(reader, manifest, passphrase)
} else {
importPlain(reader, manifest)
}
}
/**
* Unwraps an encrypted backup: the outer manifest only advertises the
* scheme; the sealed `payload.enc` entry holds a complete plain archive
* whose own manifest is the authenticated one (the codec's MAC covers it),
* so the plain path re-validates everything after decryption.
*/
private fun importEncrypted(
reader: ZipStoredReader,
outerManifest: BackupManifest,
passphrase: String?,
): BackupManifest {
val scheme = outerManifest.encryption?.scheme
val error = when {
scheme != BackupEncryptionInfo.SCHEME_V1 ->
BackupError.InvalidArchive("Unsupported encryption scheme: $scheme")
passphrase.isNullOrEmpty() -> BackupError.PassphraseRequired
else -> null
}
if (error != null) {
throw error
}
// Fail fast on the (unauthenticated) outer version fields before the
// costly key derivation; the inner manifest is re-checked afterwards.
validateVersions(outerManifest)
val sealed = readEntry(reader, BackupManifest.ENCRYPTED_PAYLOAD_ENTRY_NAME)
val innerZip = EncryptedBackupCodec.open(sealed.readByteArray(), checkNotNull(passphrase))
val innerReader = ZipStoredReader(Buffer().apply { write(innerZip) })
val innerManifest = readManifestEntry(innerReader)
if (innerManifest.encryption != null) {
throw BackupError.InvalidArchive("Nested encrypted payloads are not supported")
}
return importPlain(innerReader, innerManifest)
}
private fun importPlain(reader: ZipStoredReader, manifest: BackupManifest): BackupManifest {
validateManifest(manifest)
val mainDbPath = locator.moneySurferDbFile()
val dataStorePath = locator.dataStoreFile()
val syncDbPath = locator.syncDbFile()
val mainDbTemp = mainDbPath.appendSuffix(SUFFIX_RESTORE_TMP)
val dataStoreTemp = dataStorePath.appendSuffix(SUFFIX_RESTORE_TMP)
runCatching {
extractRemainingEntries(reader, mainDbTemp, dataStoreTemp)
RestoredDatabaseVerifier.verify(fs, mainDbTemp)
}
.onFailure { cleanUpTempFiles(mainDbTemp, dataStoreTemp) }
.getOrThrow()
database.close()
val mainDbBackup = mainDbPath.appendSuffix(SUFFIX_PRE_IMPORT)
val dataStoreBackup = dataStorePath.appendSuffix(SUFFIX_PRE_IMPORT)
try {
fs.deleteIfExists(mainDbBackup)
fs.deleteIfExists(dataStoreBackup)
if (fs.exists(mainDbPath)) fs.atomicMove(mainDbPath, mainDbBackup)
if (fs.exists(dataStorePath)) fs.atomicMove(dataStorePath, dataStoreBackup)
mainDbPath.parent?.let { fs.createDirectories(it) }
dataStorePath.parent?.let { fs.createDirectories(it) }
fs.atomicMove(mainDbTemp, mainDbPath)
fs.atomicMove(dataStoreTemp, dataStorePath)
// Wipe stale WAL/SHM next to the main DB so SQLite re-creates fresh ones.
fs.deleteIfExists(mainDbPath.appendSuffix(SUFFIX_WAL))
fs.deleteIfExists(mainDbPath.appendSuffix(SUFFIX_SHM))
// Drop sync state entirely; Room rebuilds an empty file on next start, so the
// outbox + sync_meta are clean and the next sync pulls fresh from cloud.
fs.deleteIfExists(syncDbPath)
fs.deleteIfExists(syncDbPath.appendSuffix(SUFFIX_WAL))
fs.deleteIfExists(syncDbPath.appendSuffix(SUFFIX_SHM))
fs.deleteIfExists(mainDbBackup)
fs.deleteIfExists(dataStoreBackup)
} catch (
@Suppress("TooGenericExceptionCaught") error: Throwable,
) {
rollback(mainDbPath, mainDbBackup)
rollback(dataStorePath, dataStoreBackup)
throw error
}
return manifest
}
private fun cleanUpTempFiles(mainDbTemp: Path, dataStoreTemp: Path) {
fs.deleteIfExists(mainDbTemp)
fs.deleteIfExists(dataStoreTemp)
// Side files a WAL-mode open of the temp DB may have left behind.
fs.deleteIfExists(mainDbTemp.appendSuffix(SUFFIX_WAL))
fs.deleteIfExists(mainDbTemp.appendSuffix(SUFFIX_SHM))
}
private fun readManifestEntry(reader: ZipStoredReader): BackupManifest {
val firstName = reader.nextEntryName()
val problem = when {
firstName == null -> "Empty archive"
firstName != BackupManifest.MANIFEST_ENTRY_NAME ->
"Expected ${BackupManifest.MANIFEST_ENTRY_NAME} as first entry; got $firstName"
(reader.currentEntrySize ?: 0L) > MAX_MANIFEST_BYTES ->
"Manifest entry too large: ${reader.currentEntrySize} bytes"
else -> null
}
if (problem != null) {
throw BackupError.InvalidArchive(problem)
}
val manifestJson = reader.readCurrentEntryToBuffer().readUtf8()
return runCatching { Json.decodeFromString(BackupManifest.serializer(), manifestJson) }
.getOrElse { error ->
throw BackupError.InvalidArchive("Manifest is not valid JSON: ${error.message}", error)
}
}
private fun validateVersions(manifest: BackupManifest) {
val error = when {
manifest.backupFormatVersion != BackupManifest.CURRENT_FORMAT_VERSION ->
BackupError.FormatMismatch(
expected = BackupManifest.CURRENT_FORMAT_VERSION,
actual = manifest.backupFormatVersion,
)
manifest.moneySurferDbVersion != MONEY_SURFER_DB_VERSION ->
BackupError.SchemaMismatch(
expected = MONEY_SURFER_DB_VERSION,
actual = manifest.moneySurferDbVersion,
)
else -> null
}
if (error != null) {
throw error
}
}
private fun validateManifest(manifest: BackupManifest) {
validateVersions(manifest)
val error = when {
BackupManifest.MAIN_DB_ENTRY_NAME !in manifest.files ->
BackupError.MissingFile(BackupManifest.MAIN_DB_ENTRY_NAME)
BackupManifest.DATASTORE_ENTRY_NAME !in manifest.files ->
BackupError.MissingFile(BackupManifest.DATASTORE_ENTRY_NAME)
else -> null
}
if (error != null) {
throw error
}
}
/** Reads the single entry named [name], skipping unknown entries. */
private fun readEntry(reader: ZipStoredReader, name: String): Buffer {
while (true) {
val entryName = reader.nextEntryName() ?: break
if (entryName == name) {
return reader.readCurrentEntryToBuffer()
}
blackholeSink().buffer().use { reader.readCurrentEntryTo(it) }
}
throw BackupError.MissingFile(name)
}
private fun extractRemainingEntries(
reader: ZipStoredReader,
mainDbTemp: Path,
dataStoreTemp: Path,
) {
var sawMainDb = false
var sawDataStore = false
while (true) {
val name = reader.nextEntryName() ?: break
when (name) {
BackupManifest.MAIN_DB_ENTRY_NAME -> {
sawMainDb = true
writeEntryTo(reader, mainDbTemp)
}
BackupManifest.DATASTORE_ENTRY_NAME -> {
sawDataStore = true
writeEntryTo(reader, dataStoreTemp)
}
else -> {
// Unknown entries are ignored — gives us forward-compat slack.
blackholeSink().buffer().use { reader.readCurrentEntryTo(it) }
}
}
}
if (!sawMainDb) throw BackupError.MissingFile(BackupManifest.MAIN_DB_ENTRY_NAME)
if (!sawDataStore) throw BackupError.MissingFile(BackupManifest.DATASTORE_ENTRY_NAME)
}
private fun writeEntryTo(reader: ZipStoredReader, target: Path) {
target.parent?.let { fs.createDirectories(it) }
fs.deleteIfExists(target)
fs.sink(target).buffer().use { reader.readCurrentEntryTo(it) }
}
private fun rollback(target: Path, backup: Path) {
if (fs.exists(backup)) {
runCatching { fs.deleteIfExists(target) }
runCatching { fs.atomicMove(backup, target) }
}
}
}
private fun Path.appendSuffix(suffix: String): Path {
val parent = parent ?: error("Backup path must be absolute: $this")
return parent / "$name$suffix"
}
private const val SUFFIX_RESTORE_TMP = ".restore.tmp"
private const val SUFFIX_PRE_IMPORT = ".preimport.bak"
private const val SUFFIX_WAL = "-wal"
private const val SUFFIX_SHM = "-shm"
/** A manifest is a small JSON object; anything bigger is hostile or corrupt. */
private const val MAX_MANIFEST_BYTES = 1L * 1024 * 1024