diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt index abcc37cd..b12c7ab7 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt @@ -37,11 +37,11 @@ class KoinInstrumentationTestApp : App() { single { spyk(BackupNotificationManager(context)) } single { spyk(FullBackup(get(), get(), get(), get())) } - single { spyk(KVBackup(get(), get(), get(), get(), get(), get())) } + single { spyk(KVBackup(get(), get(), get(), get())) } single { spyk(InputFactory()) } single { spyk(FullRestore(get(), get(), get(), get(), get(), get())) } - single { spyk(KVRestore(get(), get(), get(), get(), get(), get())) } + single { spyk(KVRestore(get(), get(), get(), get(), get(), get(), get())) } single { spyk(OutputFactory()) } viewModel { diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt index e7de8fdc..55c5d4c4 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt @@ -111,7 +111,7 @@ internal interface LargeBackupTestBase : LargeTestBase { var data = mutableMapOf() coEvery { - spyKVBackup.performBackup(any(), any(), any(), any(), any()) + spyKVBackup.performBackup(any(), any(), any()) } answers { packageName = firstArg().packageName callOriginal() diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt index f5ebc1d6..89ea8e9a 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt @@ -164,7 +164,7 @@ internal interface LargeRestoreTestBase : LargeTestBase { clearMocks(spyKVRestore) coEvery { - spyKVRestore.initializeState(any(), any(), any(), any(), any()) + spyKVRestore.initializeState(any(), any(), any(), any()) } answers { packageName = arg(3).packageName restoreResult.kv[packageName!!] = mutableMapOf() diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/KvBackupInstrumentationTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/KvBackupInstrumentationTest.kt new file mode 100644 index 00000000..11700b80 --- /dev/null +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/KvBackupInstrumentationTest.kt @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import android.app.backup.BackupDataInput +import android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.stevesoltys.seedvault.settings.SettingsManager +import io.mockk.CapturingSlot +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.random.Random +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@MediumTest +class KvBackupInstrumentationTest : KoinComponent { + + private val settingsManager: SettingsManager by inject() + private val backupReceiver: BackupReceiver = mockk() + private val inputFactory: InputFactory = mockk() + private val dbManager: KvDbManager by inject() + + private val backup = KVBackup( + settingsManager = settingsManager, + backupReceiver = backupReceiver, + inputFactory = inputFactory, + dbManager = dbManager, + ) + + private val data = mockk() + private val dataInput = mockk() + private val key = "foo.bar" + private val dataValue = Random.nextBytes(23) + + @Test + fun `test non-incremental backup with existing DB`() { + val packageName = "com.example" + val backupData = BackupData(emptyList(), emptyMap()) + + // create existing db + dbManager.getDb(packageName).use { db -> + db.put("foo", "bar".toByteArray()) + } + + val packageInfo = PackageInfo().apply { + this.packageName = packageName + } + + every { backupReceiver.assertFinalized() } just Runs + every { inputFactory.getBackupDataInput(data) } returns dataInput + every { dataInput.readNextHeader() } returnsMany listOf(true, false) + every { dataInput.key } returns key + every { dataInput.dataSize } returns dataValue.size + val slot = CapturingSlot() + every { dataInput.readEntityData(capture(slot), 0, dataValue.size) } answers { + dataValue.copyInto(slot.captured) + dataValue.size + } + every { data.close() } just Runs + + backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL) + + coEvery { backupReceiver.readFromStream(any()) } just Runs + coEvery { backupReceiver.finalize() } returns backupData + + runBlocking { + assertEquals(backupData, backup.finishBackup()) + } + + dbManager.getDb(packageName).use { db -> + assertNull(db.get("foo")) // existing data foo is gone + assertArrayEquals(dataValue, db.get(key)) // new data got added + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index b1f37eed..d9e7733b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -22,7 +22,6 @@ import android.os.ParcelFileDescriptor import android.util.Log import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.Clock -import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.backend.getMetadataOutputStream import com.stevesoltys.seedvault.backend.isOutOfSpace @@ -157,7 +156,7 @@ internal class BackupCoordinator( fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { // report back quota Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.") - val quota = if (isFullBackup) full.quota else kv.getQuota() + val quota = if (isFullBackup) full.quota else kv.quota Log.i(TAG, "Reported quota of $quota bytes.") return quota } @@ -217,7 +216,7 @@ internal class BackupCoordinator( * [TRANSPORT_NOT_INITIALIZED] (if the backend dataset has become lost due to * inactivity purge or some other reason and needs re-initializing) */ - suspend fun performIncrementalBackup( + fun performIncrementalBackup( packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int, @@ -232,9 +231,7 @@ internal class BackupCoordinator( // This causes a backup error, but things should go back to normal afterwards. return TRANSPORT_NOT_INITIALIZED } - val token = settingsManager.getToken() ?: error("no token in performFullBackup") - val salt = metadataManager.salt - return kv.performBackup(packageInfo, data, flags, token, salt) + return kv.performBackup(packageInfo, data, flags) } // ------------------------------------------------------------------------------------ @@ -323,17 +320,8 @@ internal class BackupCoordinator( * * @return the same error codes as [performFullBackup]. */ - suspend fun clearBackupData(packageInfo: PackageInfo): Int { - val packageName = packageInfo.packageName - Log.i(TAG, "Clear Backup Data of $packageName.") - val token = settingsManager.getToken() ?: error("no token in clearBackupData") - val salt = metadataManager.salt - try { - kv.clearBackupData(packageInfo, token, salt) - } catch (e: IOException) { - Log.w(TAG, "Error clearing K/V backup data for $packageName", e) - return TRANSPORT_ERROR - } + fun clearBackupData(packageInfo: PackageInfo): Int { + Log.i(TAG, "Ignoring clear backup data of ${packageInfo.packageName}.") // we don't clear backup data anymore, we have snapshots and those old ones stay valid state.calledClearBackupData = true return TRANSPORT_OK @@ -348,33 +336,29 @@ internal class BackupCoordinator( * @return the same error codes as [performIncrementalBackup] or [performFullBackup]. */ suspend fun finishBackup(): Int = when { - kv.hasState() -> { + kv.hasState -> { check(!full.hasState) { "K/V backup has state, but full backup has dangling state as well" } // getCurrentPackage() not-null because we have state, call before finishing - val packageInfo = kv.getCurrentPackage()!! + val packageInfo = kv.currentPackageInfo!! val packageName = packageInfo.packageName - val size = kv.getCurrentSize() - // tell K/V backup to finish - var result = kv.finishBackup() - if (result == TRANSPORT_OK) { - val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER - // call onPackageBackedUp for @pm@ only if we can do backups right now - if (isNormalBackup || backendManager.canDoBackupNow()) { - try { - metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, size) - } catch (e: Exception) { - Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e) - if (e.isOutOfSpace()) nm.onInsufficientSpaceError() - result = TRANSPORT_PACKAGE_REJECTED - } - } + try { + // tell K/V backup to finish + val backupData = kv.finishBackup() + snapshotCreator.onPackageBackedUp(packageInfo, BackupType.KV, backupData) + // TODO unify both calls + metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, backupData.size) + TRANSPORT_OK + } catch (e: Exception) { + Log.e(TAG, "Error finishing K/V backup for $packageName", e) + if (e.isOutOfSpace()) nm.onInsufficientSpaceError() + onPackageBackupError(packageInfo, BackupType.KV) + TRANSPORT_PACKAGE_REJECTED } - result } full.hasState -> { - check(!kv.hasState()) { + check(!kv.hasState) { "Full backup has state, but K/V backup has dangling state as well" } // getCurrentPackage() not-null because we have state @@ -390,6 +374,7 @@ internal class BackupCoordinator( } catch (e: Exception) { Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e) if (e.isOutOfSpace()) nm.onInsufficientSpaceError() + onPackageBackupError(packageInfo, BackupType.FULL) TRANSPORT_PACKAGE_REJECTED } } @@ -400,6 +385,7 @@ internal class BackupCoordinator( else -> throw IllegalStateException("Unexpected state in finishBackup()") } + // TODO is this only nice to have info, or do we need to do more? private suspend fun onPackageBackupError(packageInfo: PackageInfo, type: BackupType) { val packageName = packageInfo.packageName try { diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index 3071bed2..fcd312f7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -28,11 +28,9 @@ val backupModule = module { single { KvDbManagerImpl(androidContext()) } single { KVBackup( - backendManager = get(), settingsManager = get(), - nm = get(), + backupReceiver = get(), inputFactory = get(), - crypto = get(), dbManager = get(), ) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt index f5a2e91b..683fac0c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt @@ -14,122 +14,87 @@ import android.app.backup.BackupTransport.TRANSPORT_OK import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor import android.util.Log -import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER -import com.stevesoltys.seedvault.backend.BackendManager -import com.stevesoltys.seedvault.backend.isOutOfSpace -import com.stevesoltys.seedvault.crypto.Crypto -import com.stevesoltys.seedvault.header.VERSION -import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.settings.SettingsManager -import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager -import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.IOException -import java.util.zip.GZIPOutputStream class KVBackupState( internal val packageInfo: PackageInfo, - val token: Long, - val name: String, val db: KVDb, -) { - var needsUpload: Boolean = false -} +) const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong() private val TAG = KVBackup::class.java.simpleName internal class KVBackup( - private val backendManager: BackendManager, private val settingsManager: SettingsManager, - private val nm: BackupNotificationManager, + private val backupReceiver: BackupReceiver, private val inputFactory: InputFactory, - private val crypto: Crypto, private val dbManager: KvDbManager, ) { - private val backend get() = backendManager.backend private var state: KVBackupState? = null - fun hasState() = state != null + val hasState get() = state != null + val currentPackageInfo get() = state?.packageInfo + val quota: Long + get() = if (settingsManager.isQuotaUnlimited()) { + Long.MAX_VALUE + } else { + DEFAULT_QUOTA_KEY_VALUE_BACKUP + } - fun getCurrentPackage() = state?.packageInfo - - fun getCurrentSize() = getCurrentPackage()?.let { - dbManager.getDbSize(it.packageName) - } - - fun getQuota(): Long = if (settingsManager.isQuotaUnlimited()) { - Long.MAX_VALUE - } else { - DEFAULT_QUOTA_KEY_VALUE_BACKUP - } - - suspend fun performBackup( + fun performBackup( packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int, - token: Long, - salt: String, ): Int { val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0 val isIncremental = flags and FLAG_INCREMENTAL != 0 val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0 val packageName = packageInfo.packageName - when { - dataNotChanged -> { - Log.i(TAG, "No K/V backup data has changed for $packageName") - } - isIncremental -> { - Log.i(TAG, "Performing incremental K/V backup for $packageName") - } - isNonIncremental -> { - Log.i(TAG, "Performing non-incremental K/V backup for $packageName") - } - else -> { - Log.i(TAG, "Performing K/V backup for $packageName") - } + dataNotChanged -> Log.i(TAG, "No K/V backup data has changed for $packageName") + isIncremental -> Log.i(TAG, "Performing incremental K/V backup for $packageName") + isNonIncremental -> Log.i(TAG, "Performing non-incremental K/V backup for $packageName") + else -> Log.i(TAG, "Performing K/V backup for $packageName") } + check(state == null) { "Have unexpected state for ${state?.packageInfo?.packageName}" } + backupReceiver.assertFinalized() // initialize state - val state = this.state - if (state != null) { - throw AssertionError("Have state for ${state.packageInfo.packageName}") - } - val name = crypto.getNameForPackage(salt, packageName) - val db = dbManager.getDb(packageName) - this.state = KVBackupState(packageInfo, token, name, db) + state = KVBackupState(packageInfo = packageInfo, db = dbManager.getDb(packageName)) - // no need for backup when no data has changed + // handle case where data hasn't changed since last backup + val hasDataForPackage = dbManager.existsDb(packageName) if (dataNotChanged) { data.close() - return TRANSPORT_OK + return if (hasDataForPackage) { + TRANSPORT_OK + } else { + Log.w(TAG, "No previous data for $packageName, requesting non-incremental backup!") + backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED) + } } - // check if we have existing data for the given package - val hasDataForPackage = dbManager.existsDb(packageName) if (isIncremental && !hasDataForPackage) { Log.w( TAG, "Requested incremental, but transport currently stores no data" + " for $packageName, requesting non-incremental retry." ) + data.close() return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED) } - - // TODO check if package is over-quota and respect unlimited setting - + // check if we have existing data, but the system wants clean slate if (isNonIncremental && hasDataForPackage) { - Log.w(TAG, "Requested non-incremental, deleting existing data.") - try { - clearBackupData(packageInfo, token, salt) - } catch (e: IOException) { - Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e) - } + Log.w(TAG, "Requested non-incremental, deleting existing data...") + dbManager.deleteDb(packageInfo.packageName) + // KvBackupInstrumentationTest tells us that the DB gets re-created automatically } - // parse and store the K/V updates - return storeRecords(data) + return data.use { + storeRecords(it) + } } private fun storeRecords(data: ParcelFileDescriptor): Int { @@ -140,18 +105,6 @@ internal class KVBackup( Log.e(TAG, "Exception reading backup input", result.exception) return backupError(TRANSPORT_ERROR) } - state.needsUpload = if (state.packageInfo.packageName == MAGIC_PACKAGE_MANAGER) { - // Don't upload, if we currently can't do backups. - // If we tried, we would fail @pm@ backup which causes the system to do a re-init. - // See: https://github.com/seedvault-app/seedvault/issues/102 - // K/V backups (typically starting with package manager metadata - @pm@) - // are scheduled with JobInfo.Builder#setOverrideDeadline() - // and thus do not respect backoff. - backendManager.canDoBackupNow() - } else { - // all other packages always need upload - true - } val op = (result as Result.Ok).result if (op.value == null) { Log.e(TAG, "Deleting record with key ${op.key}") @@ -205,27 +158,21 @@ internal class KVBackup( } @Throws(IOException::class) - suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) { - Log.i(TAG, "Clearing K/V data of ${packageInfo.packageName}") - val name = state?.name ?: crypto.getNameForPackage(salt, packageInfo.packageName) - backend.remove(LegacyAppBackupFile.Blob(token, name)) - if (!dbManager.deleteDb(packageInfo.packageName)) throw IOException() - } - - suspend fun finishBackup(): Int { + suspend fun finishBackup(): BackupData { val state = this.state ?: error("No state in finishBackup") val packageName = state.packageInfo.packageName - Log.i(TAG, "Finish K/V Backup of $packageName - needs upload: ${state.needsUpload}") + Log.i(TAG, "Finish K/V Backup of $packageName") - return try { - if (state.needsUpload) uploadDb(state.token, state.name, packageName, state.db) - else state.db.close() - TRANSPORT_OK - } catch (e: IOException) { - Log.e(TAG, "Error uploading DB", e) - if (e.isOutOfSpace()) nm.onInsufficientSpaceError() - TRANSPORT_ERROR - } finally { + try { + state.db.vacuum() + state.db.close() + dbManager.getDbInputStream(packageName).use { inputStream -> + backupReceiver.readFromStream(inputStream) + } + val backupData = backupReceiver.finalize() + Log.d(TAG, "Uploaded db file for $packageName.") + return backupData + } finally { // exceptions bubble up this.state = null } } @@ -240,36 +187,10 @@ internal class KVBackup( Log.i(TAG, "Resetting state because of K/V Backup error of $packageName") state.db.close() - this.state = null return result } - @Throws(IOException::class) - private suspend fun uploadDb( - token: Long, - name: String, - packageName: String, - db: KVDb, - ) { - db.vacuum() - db.close() - - val handle = LegacyAppBackupFile.Blob(token, name) - backend.save(handle).use { outputStream -> - outputStream.write(ByteArray(1) { VERSION }) - val ad = getADForKV(VERSION, packageName) - crypto.newEncryptingStreamV1(outputStream, ad).use { encryptedStream -> - GZIPOutputStream(encryptedStream).use { gZipStream -> - dbManager.getDbInputStream(packageName).use { inputStream -> - inputStream.copyTo(gZipStream) - } - } - } - } - Log.d(TAG, "Uploaded db file for $packageName.") - } - private class KVOperation( val key: String, /** diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt index 93214b78..127ab067 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt @@ -14,17 +14,17 @@ import android.util.Log import com.stevesoltys.seedvault.ANCESTRAL_RECORD_KEY import com.stevesoltys.seedvault.GLOBAL_METADATA_KEY import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.decodeBase64 import com.stevesoltys.seedvault.header.HeaderReader import com.stevesoltys.seedvault.header.UnsupportedVersionException -import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForKV -import com.stevesoltys.seedvault.backend.LegacyStoragePlugin -import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.transport.backup.KVDb import com.stevesoltys.seedvault.transport.backup.KvDbManager import libcore.io.IoUtils.closeQuietly +import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.IOException import java.security.GeneralSecurityException @@ -33,19 +33,21 @@ import javax.crypto.AEADBadTagException private class KVRestoreState( val version: Byte, - val token: Long, - val name: String, val packageInfo: PackageInfo, + val blobHandles: List? = null, + val token: Long? = null, + val name: String? = null, /** * Optional [PackageInfo] for single package restore, optimizes restore of @pm@ */ - val autoRestorePackageInfo: PackageInfo?, + val autoRestorePackageInfo: PackageInfo? = null, ) private val TAG = KVRestore::class.java.simpleName internal class KVRestore( private val backendManager: BackendManager, + private val loader: Loader, @Suppress("Deprecation") private val legacyPlugin: LegacyStoragePlugin, private val outputFactory: OutputFactory, @@ -78,12 +80,32 @@ internal class KVRestore( */ fun initializeState( version: Byte, + packageInfo: PackageInfo, + blobHandles: List, + autoRestorePackageInfo: PackageInfo? = null, + ) { + state = KVRestoreState( + version = version, + packageInfo = packageInfo, + blobHandles = blobHandles, + autoRestorePackageInfo = autoRestorePackageInfo, + ) + } + + fun initializeStateV1( token: Long, name: String, packageInfo: PackageInfo, autoRestorePackageInfo: PackageInfo? = null, ) { - state = KVRestoreState(version, token, name, packageInfo, autoRestorePackageInfo) + state = KVRestoreState(1, packageInfo, null, token, name, autoRestorePackageInfo) + } + + fun initializeStateV0( + token: Long, + packageInfo: PackageInfo, + ) { + state = KVRestoreState(0x00, packageInfo, null, token) } /** @@ -106,7 +128,8 @@ internal class KVRestore( val database = if (isAutoRestore) { getCachedRestoreDb(state) } else { - downloadRestoreDb(state) + if (state.version == 1.toByte()) downloadRestoreDbV1(state) + else downloadRestoreDb(state) } database.use { db -> val out = outputFactory.getBackupDataOutput(data) @@ -150,17 +173,37 @@ internal class KVRestore( return if (dbManager.existsDb(packageName)) { dbManager.getDb(packageName) } else { - downloadRestoreDb(state) + if (state.version == 1.toByte()) downloadRestoreDbV1(state) + else downloadRestoreDb(state) } } @Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class) private suspend fun downloadRestoreDb(state: KVRestoreState): KVDb { val packageName = state.packageInfo.packageName - val handle = LegacyAppBackupFile.Blob(state.token, state.name) + val handles = state.blobHandles ?: error("no blob handles for v2") + loader.loadFiles(handles).use { inputStream -> + dbManager.getDbOutputStream(packageName).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + return dbManager.getDb(packageName, true) + } + + // + // v1 restore legacy code below + // + + @Suppress("DEPRECATION") + @Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class) + private suspend fun downloadRestoreDbV1(state: KVRestoreState): KVDb { + val token = state.token ?: error("No token for v1 restore") + val name = state.name ?: error("No name for v1 restore") + val packageName = state.packageInfo.packageName + val handle = LegacyAppBackupFile.Blob(token, name) backend.load(handle).use { inputStream -> headerReader.readVersion(inputStream, state.version) - val ad = getADForKV(VERSION, packageName) + val ad = getADForKV(state.version, packageName) crypto.newDecryptingStreamV1(inputStream, ad).use { decryptedStream -> GZIPInputStream(decryptedStream).use { gzipStream -> dbManager.getDbOutputStream(packageName).use { outputStream -> @@ -182,7 +225,8 @@ internal class KVRestore( // We return the data in lexical order sorted by key, // so that apps which use synthetic keys like BLOB_1, BLOB_2, etc // will see the date in the most obvious order. - val sortedKeys = getSortedKeysV0(state.token, state.packageInfo) + val token = state.token ?: error("No token for v0 restore") + val sortedKeys = getSortedKeysV0(token, state.packageInfo) if (sortedKeys == null) { // nextRestorePackage() ensures the dir exists, so this is an error Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}") @@ -245,7 +289,7 @@ internal class KVRestore( state: KVRestoreState, dKey: DecodedKey, out: BackupDataOutput, - ) = legacyPlugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key) + ) = legacyPlugin.getInputStreamForRecord(state.token!!, state.packageInfo, dKey.base64Key) .use { inputStream -> val version = headerReader.readVersion(inputStream, state.version) val packageName = state.packageInfo.packageName diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt index c9df2d83..5de3d7d1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt @@ -78,6 +78,7 @@ internal class RestoreCoordinator( private val failedPackages = ArrayList() suspend fun getAvailableBackups(): RestorableBackupResult { + Log.i(TAG, "getAvailableBackups") val fileHandles = try { backend.getAvailableBackupFileHandles() } catch (e: Exception) { @@ -135,6 +136,7 @@ internal class RestoreCoordinator( * or null if an error occurred (the attempt should be rescheduled). **/ suspend fun getAvailableRestoreSets(): Array? { + Log.d(TAG, "getAvailableRestoreSets") val result = getAvailableBackups() as? RestorableBackupResult.SuccessResult ?: return null val backups = result.backups return backups.map { backup -> @@ -160,6 +162,7 @@ internal class RestoreCoordinator( * or 0 if there is no backup set available corresponding to the current device state. */ fun getCurrentRestoreSet(): Long { + Log.d(TAG, "getCurrentRestoreSet() = ") // TODO where to store current token? return (settingsManager.getToken() ?: 0L).apply { Log.i(TAG, "Got current restore set token: $this") } @@ -191,10 +194,10 @@ internal class RestoreCoordinator( */ suspend fun startRestore(token: Long, packages: Array): Int { check(state == null) { "Started new restore with existing state: $state" } - Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}") + Log.i(TAG, "Start restore $token with ${packages.map { info -> info.packageName }}") // If there's only one package to restore (Auto Restore feature), add it to the state - val pmPackageInfo = + val autoRestorePackageInfo = if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) { val pmPackageName = packages[1].packageName Log.d(TAG, "Optimize for single package restore of $pmPackageName") @@ -218,11 +221,27 @@ internal class RestoreCoordinator( val backup = if (restorableBackup?.token == token) { restorableBackup!! // if token matches, backupMetadata is non-null } else { - val backup = getAvailableBackups() as? RestorableBackupResult.SuccessResult - ?: return TRANSPORT_ERROR - backup.backups.find { it.token == token } ?: return TRANSPORT_ERROR + if (autoRestorePackageInfo == null) { // no auto-restore + Log.e(TAG, "No cached backups, loading all and look for $token") + val backup = getAvailableBackups() as? RestorableBackupResult.SuccessResult + ?: return TRANSPORT_ERROR + backup.backups.find { it.token == token } ?: return TRANSPORT_ERROR + } else { + // this is auto-restore, so we try harder to find a working restore set + Log.i(TAG, "No cached backups, loading all and look for $token") + // TODO may be cold start and need snapshot loading (ideally from cache only?) + val backup = getAvailableBackups() as? RestorableBackupResult.SuccessResult + ?: return TRANSPORT_ERROR + val autoRestorePackageName = autoRestorePackageInfo.packageName + val sortedBackups = backup.backups.sortedByDescending { it.token } + sortedBackups.find { it.token == token } ?: sortedBackups.find { + val chunkIds = it.packageMetadataMap[autoRestorePackageName]?.chunkIds + // try a backup where our auto restore package has data + !chunkIds.isNullOrEmpty() + } ?: return TRANSPORT_ERROR + } } - state = RestoreCoordinatorState(token, packages.iterator(), pmPackageInfo, backup) + state = RestoreCoordinatorState(token, packages.iterator(), autoRestorePackageInfo, backup) restorableBackup = null failedPackages.clear() return TRANSPORT_OK @@ -269,22 +288,29 @@ internal class RestoreCoordinator( val snapshot = state.backup.snapshot ?: error("No snapshot in v2 backup") val type = when (state.backup.packageMetadataMap[packageName]?.backupType) { BackupType.KV -> { - val name = crypto.getNameForPackage(state.backup.salt, packageName) + val blobHandles = try { + val chunkIds = state.backup.packageMetadataMap[packageName]?.chunkIds + ?: error("no metadata or chunkIds") + snapshot.getBlobHandles(repoId, chunkIds) + } catch (e: Exception) { + Log.e(TAG, "Error getting blob handles: ", e) + failedPackages.add(packageName) + // abort here as this is close to an assertion error + return null + } kv.initializeState( version = version, - token = state.token, - name = name, packageInfo = packageInfo, - autoRestorePackageInfo = state.autoRestorePackageInfo + blobHandles = blobHandles, + autoRestorePackageInfo = state.autoRestorePackageInfo, ) state.currentPackage = packageName TYPE_KEY_VALUE } - BackupType.FULL -> { - val chunkIds = state.backup.packageMetadataMap[packageName]?.chunkIds - ?: error("no metadata or chunkIds") val blobHandles = try { + val chunkIds = state.backup.packageMetadataMap[packageName]?.chunkIds + ?: error("no metadata or chunkIds") snapshot.getBlobHandles(repoId, chunkIds) } catch (e: Exception) { Log.e(TAG, "Error getting blob handles: ", e) @@ -296,7 +322,6 @@ internal class RestoreCoordinator( state.currentPackage = packageName TYPE_FULL_STREAM } - null -> { Log.i(TAG, "No backup type found for $packageName. Skipping...") state.backup.packageMetadataMap[packageName]?.backupType?.let { s -> @@ -318,25 +343,21 @@ internal class RestoreCoordinator( val packageName = packageInfo.packageName val type = when (state.backup.packageMetadataMap[packageName]?.backupType) { BackupType.KV -> { - val name = crypto.getNameForPackage(state.backup.salt, packageName) - kv.initializeState( - version = 1, + kv.initializeStateV1( token = state.token, - name = name, + name = crypto.getNameForPackage(state.backup.salt, packageName), packageInfo = packageInfo, - autoRestorePackageInfo = state.autoRestorePackageInfo + autoRestorePackageInfo = state.autoRestorePackageInfo, ) state.currentPackage = packageName TYPE_KEY_VALUE } - BackupType.FULL -> { val name = crypto.getNameForPackage(state.backup.salt, packageName) full.initializeStateV1(state.token, name, packageInfo) state.currentPackage = packageName TYPE_FULL_STREAM } - null -> { Log.i(TAG, "No backup type found for $packageName. Skipping...") state.backup.packageMetadataMap[packageName]?.backupType?.let { s -> @@ -361,18 +382,16 @@ internal class RestoreCoordinator( // check key/value data first and if available, don't even check for full data kv.hasDataForPackage(state.token, packageInfo) -> { Log.i(TAG, "Found K/V data for $packageName.") - kv.initializeState(0x00, state.token, "", packageInfo, null) + kv.initializeStateV0(state.token, packageInfo) state.currentPackage = packageName TYPE_KEY_VALUE } - full.hasDataForPackage(state.token, packageInfo) -> { Log.i(TAG, "Found full backup data for $packageName.") full.initializeStateV0(state.token, packageInfo) state.currentPackage = packageName TYPE_FULL_STREAM } - else -> { Log.i(TAG, "No data found for $packageName. Skipping.") return nextRestorePackage() @@ -396,6 +415,7 @@ internal class RestoreCoordinator( * @return the same error codes as [startRestore]. */ suspend fun getRestoreData(data: ParcelFileDescriptor): Int { + Log.d(TAG, "getRestoreData()") return kv.getRestoreData(data).apply { if (this != TRANSPORT_OK) { // add current package to failed ones diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt index aa85e590..b786064b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt @@ -11,7 +11,7 @@ import org.koin.dsl.module val restoreModule = module { single { OutputFactory() } single { Loader(get(), get()) } - single { KVRestore(get(), get(), get(), get(), get(), get()) } + single { KVRestore(get(), get(), get(), get(), get(), get(), get()) } single { FullRestore(get(), get(), get(), get(), get(), get()) } single { RestoreCoordinator( diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt index f7ffecdd..975db930 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt @@ -192,7 +192,7 @@ internal class ApkRestoreTest : TransportTest() { every { backupStateManager.isAutoRestoreEnabled } returns false every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() every { strictContext.cacheDir } returns File(tmpDir.toString()) - coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream + coEvery { loader.loadFiles(listOf(blobHandle1)) } returns apkInputStream every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo every { backend.providerPackageName } returns storageProviderPackageName @@ -649,7 +649,7 @@ internal class ApkRestoreTest : TransportTest() { private fun cacheBaseApkAndGetInfo(tmpDir: Path) { every { strictContext.cacheDir } returns File(tmpDir.toString()) - coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream + coEvery { loader.loadFiles(listOf(blobHandle1)) } returns apkInputStream every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo every { applicationInfo.loadIcon(pm) } returns icon every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt index 9bde3607..21e8d4c2 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -48,13 +48,13 @@ import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.runBlocking import org.calyxos.seedvault.core.backends.Backend -import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Test import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.InputStream import kotlin.random.Random internal class CoordinatorIntegrationTest : TransportTest() { @@ -78,11 +78,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val loader = mockk() private val backupReceiver = mockk() private val kvBackup = KVBackup( - backendManager = backendManager, settingsManager = settingsManager, - nm = notificationManager, + backupReceiver = backupReceiver, inputFactory = inputFactory, - crypto = cryptoImpl, dbManager = dbManager, ) private val fullBackup = FullBackup( @@ -107,6 +105,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val kvRestore = KVRestore( backendManager, + loader, legacyPlugin, outputFactory, headerReader, @@ -133,13 +132,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val fileDescriptor = mockk(relaxed = true) private val appData = ByteArray(42).apply { Random.nextBytes(this) } private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) } - private val metadataOutputStream = ByteArrayOutputStream() private val key = "RestoreKey" private val key2 = "RestoreKey2" - // as we use real crypto, we need a real name for packageInfo - private val realName = cryptoImpl.getNameForPackage(salt, packageName) - init { every { backendManager.backend } returns backend every { appBackupManager.snapshotCreator } returns snapshotCreator @@ -149,11 +144,11 @@ internal class CoordinatorIntegrationTest : TransportTest() { fun `test key-value backup and restore with 2 records`() = runBlocking { val value = CapturingSlot() val value2 = CapturingSlot() + val inputStream = CapturingSlot() val bOutputStream = ByteArrayOutputStream() every { metadataManager.requiresInit } returns false - every { settingsManager.getToken() } returns token - every { metadataManager.salt } returns salt + every { backupReceiver.assertFinalized() } just Runs // read one key/value record and write it to output stream every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput every { backupDataInput.readNextHeader() } returns true andThen true andThen false @@ -167,21 +162,21 @@ internal class CoordinatorIntegrationTest : TransportTest() { appData2.copyInto(value2.captured) // write the app data into the passed ByteArray appData2.size } - every { - metadataManager.onPackageBackedUp( - packageInfo = packageInfo, - type = BackupType.KV, - size = more((appData.size + appData2.size).toLong()), // more because DB overhead - ) - } just Runs // start K/V backup assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) // upload DB - coEvery { - backend.save(LegacyAppBackupFile.Blob(token, realName)) - } returns bOutputStream + coEvery { backupReceiver.readFromStream(capture(inputStream)) } answers { + inputStream.captured.copyTo(bOutputStream) + } + coEvery { backupReceiver.finalize() } returns apkBackupData + every { + snapshotCreator.onPackageBackedUp(packageInfo, BackupType.KV, apkBackupData) + } just Runs + every { + metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, apkBackupData.size) + } just Runs // finish K/V backup assertEquals(TRANSPORT_OK, backup.finishBackup()) @@ -190,9 +185,6 @@ internal class CoordinatorIntegrationTest : TransportTest() { restore.beforeStartRestore(restorableBackup) assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo))) - // find data for K/V backup - every { crypto.getNameForPackage(metadata.salt, packageInfo.packageName) } returns name - val restoreDescription = restore.nextRestorePackage() ?: fail() assertEquals(packageInfo.packageName, restoreDescription.packageName) assertEquals(RestoreDescription.TYPE_KEY_VALUE, restoreDescription.dataType) @@ -200,9 +192,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { // restore finds the backed up key and writes the decrypted value val backupDataOutput = mockk() val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) - coEvery { - backend.load(LegacyAppBackupFile.Blob(token, name)) - } returns rInputStream + coEvery { loader.loadFiles(listOf(blobHandle1)) } returns rInputStream every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137 every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size @@ -222,13 +212,13 @@ internal class CoordinatorIntegrationTest : TransportTest() { @Test fun `test key-value backup with huge value`() = runBlocking { val value = CapturingSlot() + val inputStream = CapturingSlot() val size = Random.nextInt(5) * MAX_SEGMENT_CLEARTEXT_LENGTH + Random.nextInt(0, 1337) val appData = ByteArray(size).apply { Random.nextBytes(this) } val bOutputStream = ByteArrayOutputStream() every { metadataManager.requiresInit } returns false - every { settingsManager.getToken() } returns token - every { metadataManager.salt } returns salt + every { backupReceiver.assertFinalized() } just Runs // read one key/value record and write it to output stream every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput every { backupDataInput.readNextHeader() } returns true andThen false @@ -238,25 +228,21 @@ internal class CoordinatorIntegrationTest : TransportTest() { appData.copyInto(value.captured) // write the app data into the passed ByteArray appData.size } - every { settingsManager.getToken() } returns token - coEvery { - backend.save(LegacyAppBackupFile.Metadata(token)) - } returns metadataOutputStream - every { - metadataManager.onPackageBackedUp( - packageInfo = packageInfo, - type = BackupType.KV, - size = more(size.toLong()), // more than $size, because DB overhead - ) - } just Runs // start K/V backup assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) // upload DB - coEvery { - backend.save(LegacyAppBackupFile.Blob(token, realName)) - } returns bOutputStream + coEvery { backupReceiver.readFromStream(capture(inputStream)) } answers { + inputStream.captured.copyTo(bOutputStream) + } + coEvery { backupReceiver.finalize() } returns apkBackupData + every { + snapshotCreator.onPackageBackedUp(packageInfo, BackupType.KV, apkBackupData) + } just Runs + every { + metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, apkBackupData.size) + } just Runs // finish K/V backup assertEquals(TRANSPORT_OK, backup.finishBackup()) @@ -265,9 +251,6 @@ internal class CoordinatorIntegrationTest : TransportTest() { restore.beforeStartRestore(restorableBackup) assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo))) - // find data for K/V backup - every { crypto.getNameForPackage(metadata.salt, packageInfo.packageName) } returns name - val restoreDescription = restore.nextRestorePackage() ?: fail() assertEquals(packageInfo.packageName, restoreDescription.packageName) assertEquals(RestoreDescription.TYPE_KEY_VALUE, restoreDescription.dataType) @@ -275,9 +258,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { // restore finds the backed up key and writes the decrypted value val backupDataOutput = mockk() val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) - coEvery { - backend.load(LegacyAppBackupFile.Blob(token, name)) - } returns rInputStream + coEvery { loader.loadFiles(listOf(blobHandle1)) } returns rInputStream every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137 every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size @@ -294,7 +275,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { fun `test full backup and restore with two chunks`() = runBlocking { metadata.packageMetadataMap[packageName] = PackageMetadata( backupType = BackupType.FULL, - chunkIds = listOf(apkChunkId), + chunkIds = listOf(chunkId1), ) // package is of type FULL @@ -342,7 +323,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { // reverse the backup streams into restore input val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) val rOutputStream = ByteArrayOutputStream() - coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns rInputStream + coEvery { loader.loadFiles(listOf(blobHandle1)) } returns rInputStream every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream // restore data diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt index ef14a346..34276218 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt @@ -69,21 +69,7 @@ internal abstract class TransportTest { protected val pmPackageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } - protected val metadata = BackupMetadata( - token = token, - salt = getRandomBase64(METADATA_SALT_SIZE), - androidVersion = Random.nextInt(), - androidIncremental = getRandomString(), - deviceName = getRandomString(), - packageMetadataMap = PackageMetadataMap().apply { - put(packageInfo.packageName, PackageMetadata(backupType = BackupType.KV)) - } - ) - protected val d2dMetadata = metadata.copy( - d2dBackup = true - ) - protected val salt = metadata.salt protected val name = getRandomString(12) protected val name2 = getRandomString(23) protected val storageProviderPackageName = getRandomString(23) @@ -92,26 +78,27 @@ internal abstract class TransportTest { protected val repoId = Random.nextBytes(32).toHexString() protected val splitName = getRandomString() protected val splitBytes = byteArrayOf(0x07, 0x08, 0x09) - protected val apkChunkId = Random.nextBytes(32).toHexString() - protected val splitChunkId = Random.nextBytes(32).toHexString() + protected val chunkId1 = Random.nextBytes(32).toHexString() + protected val chunkId2 = Random.nextBytes(32).toHexString() protected val apkBlob = blob { id = ByteString.copyFrom(Random.nextBytes(32)) } protected val splitBlob = blob { id = ByteString.copyFrom(Random.nextBytes(32)) } - protected val apkBlobHandle = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto()) - protected val apkBackupData = BackupData(listOf(apkChunkId), mapOf(apkChunkId to apkBlob)) + protected val blobHandle1 = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto()) + protected val blobHandle2 = AppBackupFileType.Blob(repoId, splitBlob.id.hexFromProto()) + protected val apkBackupData = BackupData(listOf(chunkId1), mapOf(chunkId1 to apkBlob)) protected val splitBackupData = - BackupData(listOf(splitChunkId), mapOf(splitChunkId to splitBlob)) + BackupData(listOf(chunkId2), mapOf(chunkId2 to splitBlob)) protected val chunkMap = apkBackupData.chunkMap + splitBackupData.chunkMap protected val baseSplit = split { name = BASE_SPLIT - chunkIds.add(ByteString.fromHex(apkChunkId)) + chunkIds.add(ByteString.fromHex(chunkId1)) } protected val apkSplit = split { name = splitName - chunkIds.add(ByteString.fromHex(splitChunkId)) + chunkIds.add(ByteString.fromHex(chunkId2)) } protected val apk = SnapshotKt.apk { versionCode = packageInfo.longVersionCode - 1 @@ -128,6 +115,23 @@ internal abstract class TransportTest { apps[packageName] = app blobs.putAll(chunkMap) } + protected val metadata = BackupMetadata( + token = token, + salt = getRandomBase64(METADATA_SALT_SIZE), + androidVersion = Random.nextInt(), + androidIncremental = getRandomString(), + deviceName = getRandomString(), + packageMetadataMap = PackageMetadataMap().apply { + put( + packageInfo.packageName, + PackageMetadata(backupType = BackupType.KV, chunkIds = listOf(chunkId1)), + ) + } + ) + protected val d2dMetadata = metadata.copy( + d2dBackup = true + ) + protected val salt = metadata.salt init { mockkStatic(Log::class) diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index 50e977cf..cf091242 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -21,6 +21,7 @@ import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED +import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.worker.ApkBackup import io.mockk.Runs @@ -81,7 +82,7 @@ internal class BackupCoordinatorTest : BackupTest() { @Test fun `device initialization succeeds and delegates to plugin`() = runBlocking { expectStartNewRestoreSet() - every { kv.hasState() } returns false + every { kv.hasState } returns false every { full.hasState } returns false assertEquals(TRANSPORT_OK, backup.initializeDevice()) @@ -108,7 +109,7 @@ internal class BackupCoordinatorTest : BackupTest() { assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) // finish will only be called when TRANSPORT_OK is returned, so it should throw - every { kv.hasState() } returns false + every { kv.hasState } returns false every { full.hasState } returns false coAssertThrows(IllegalStateException::class.java) { backup.finishBackup() @@ -127,7 +128,7 @@ internal class BackupCoordinatorTest : BackupTest() { assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) // finish will only be called when TRANSPORT_OK is returned, so it should throw - every { kv.hasState() } returns false + every { kv.hasState } returns false every { full.hasState } returns false coAssertThrows(IllegalStateException::class.java) { backup.finishBackup() @@ -163,51 +164,61 @@ internal class BackupCoordinatorTest : BackupTest() { if (isFullBackup) { every { full.quota } returns quota } else { - every { kv.getQuota() } returns quota + every { kv.quota } returns quota } assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup)) } @Test - fun `clearing KV backup data throws`() = runBlocking { - every { settingsManager.getToken() } returns token - every { metadataManager.salt } returns salt - coEvery { kv.clearBackupData(packageInfo, token, salt) } throws IOException() + fun `clearing backup data does nothing`() = runBlocking { + assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo)) - assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo)) + every { kv.hasState } returns false + every { full.hasState } returns false + + assertEquals(TRANSPORT_OK, backup.finishBackup()) } @Test fun `finish backup delegates to KV plugin if it has state`() = runBlocking { - val size = 0L + val snapshotCreator: SnapshotCreator = mockk() + val size = Random.nextLong() - every { kv.hasState() } returns true + every { kv.hasState } returns true every { full.hasState } returns false - every { kv.getCurrentPackage() } returns packageInfo - coEvery { kv.finishBackup() } returns TRANSPORT_OK - every { kv.getCurrentSize() } returns size + every { kv.currentPackageInfo } returns packageInfo + coEvery { kv.finishBackup() } returns apkBackupData + every { appBackupManager.snapshotCreator } returns snapshotCreator every { - metadataManager.onPackageBackedUp( - packageInfo = packageInfo, - type = BackupType.KV, - size = size, - ) + snapshotCreator.onPackageBackedUp(packageInfo, BackupType.KV, apkBackupData) + } just Runs + every { + metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, apkBackupData.size) } just Runs assertEquals(TRANSPORT_OK, backup.finishBackup()) } @Test - fun `finish backup does not upload @pm@ metadata, if it can't do backups`() = runBlocking { - every { kv.hasState() } returns true + fun `finish KV backup throws exception`() = runBlocking { + every { kv.hasState } returns true every { full.hasState } returns false - every { kv.getCurrentPackage() } returns pmPackageInfo - every { kv.getCurrentSize() } returns 42L + every { kv.currentPackageInfo } returns packageInfo + coEvery { kv.finishBackup() } throws IOException() - coEvery { kv.finishBackup() } returns TRANSPORT_OK - every { backendManager.canDoBackupNow() } returns false + every { settingsManager.getToken() } returns token + every { + metadataManager.onPackageBackupError( + packageInfo, + UNKNOWN_ERROR, + metadataOutputStream, + BackupType.KV, + ) + } just Runs + coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream + every { metadataOutputStream.close() } just Runs - assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.finishBackup()) } @Test @@ -215,7 +226,7 @@ internal class BackupCoordinatorTest : BackupTest() { val snapshotCreator: SnapshotCreator = mockk() val size: Long = 2345 - every { kv.hasState() } returns false + every { kv.hasState } returns false every { full.hasState } returns true every { full.currentPackageInfo } returns packageInfo coEvery { full.finishBackup() } returns apkBackupData @@ -236,8 +247,6 @@ internal class BackupCoordinatorTest : BackupTest() { @Test fun `metadata does not get updated when no APK was backed up`() = runBlocking { - every { settingsManager.getToken() } returns token - every { metadataManager.salt } returns salt coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK @@ -248,8 +257,6 @@ internal class BackupCoordinatorTest : BackupTest() { @Test fun `app exceeding quota gets cancelled and reason written to metadata`() = runBlocking { - every { settingsManager.getToken() } returns token - every { metadataManager.salt } returns salt coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK @@ -300,8 +307,6 @@ internal class BackupCoordinatorTest : BackupTest() { @Test fun `app with no data gets cancelled and reason written to metadata`() = runBlocking { - every { settingsManager.getToken() } returns token - every { metadataManager.salt } returns salt coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt index 5c83cee0..9adb8f2b 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt @@ -13,132 +13,137 @@ import android.app.backup.BackupTransport.TRANSPORT_ERROR import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED import android.app.backup.BackupTransport.TRANSPORT_OK import android.content.pm.PackageInfo -import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE -import com.stevesoltys.seedvault.header.VERSION -import com.stevesoltys.seedvault.header.getADForKV -import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import io.mockk.CapturingSlot import io.mockk.Runs import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.runBlocking -import org.calyxos.seedvault.core.backends.Backend import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import java.io.ByteArrayInputStream import java.io.IOException import kotlin.random.Random internal class KVBackupTest : BackupTest() { - private val backendManager = mockk() - private val notificationManager = mockk() + private val backupReceiver = mockk() private val dataInput = mockk() private val dbManager = mockk() private val backup = KVBackup( - backendManager = backendManager, settingsManager = settingsManager, - nm = notificationManager, + backupReceiver = backupReceiver, inputFactory = inputFactory, - crypto = crypto, - dbManager = dbManager + dbManager = dbManager, ) private val db = mockk() - private val backend = mockk() private val key = getRandomString(MAX_KEY_LENGTH_SIZE) private val dataValue = Random.nextBytes(23) private val dbBytes = Random.nextBytes(42) private val inputStream = ByteArrayInputStream(dbBytes) - init { - every { backendManager.backend } returns backend - } - @Test fun `has no initial state`() { - assertFalse(backup.hasState()) + assertFalse(backup.hasState) } @Test fun `simple backup with one record`() = runBlocking { singleRecordBackup() - assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt)) - assertTrue(backup.hasState()) - assertEquals(packageInfo, backup.getCurrentPackage()) - assertEquals(TRANSPORT_OK, backup.finishBackup()) - assertFalse(backup.hasState()) + every { data.close() } just Runs + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState) + assertEquals(packageInfo, backup.currentPackageInfo) + + assertEquals(apkBackupData, backup.finishBackup()) + assertFalse(backup.hasState) + + verify { data.close() } } @Test fun `incremental backup with no data gets rejected`() = runBlocking { initPlugin(false) + every { data.close() } just Runs every { db.close() } just Runs assertEquals( TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, - backup.performBackup(packageInfo, data, FLAG_INCREMENTAL, token, salt) + backup.performBackup(packageInfo, data, FLAG_INCREMENTAL) ) - assertFalse(backup.hasState()) + assertFalse(backup.hasState) + + verify { data.close() } } @Test fun `non-incremental backup with data clears old data first`() = runBlocking { - singleRecordBackup(true) - coEvery { backend.remove(handle) } just Runs every { dbManager.deleteDb(packageName) } returns true + singleRecordBackup(true) + every { data.close() } just Runs assertEquals( TRANSPORT_OK, - backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL, token, salt) + backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL) ) - assertTrue(backup.hasState()) - assertEquals(TRANSPORT_OK, backup.finishBackup()) - assertFalse(backup.hasState()) + assertTrue(backup.hasState) + + assertEquals(apkBackupData, backup.finishBackup()) + assertFalse(backup.hasState) + + verify { data.close() } } @Test - fun `ignoring exception when clearing data when non-incremental backup has data`() = - runBlocking { - singleRecordBackup(true) - coEvery { backend.remove(handle) } throws IOException() - - assertEquals( - TRANSPORT_OK, - backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL, token, salt) - ) - assertTrue(backup.hasState()) - assertEquals(TRANSPORT_OK, backup.finishBackup()) - assertFalse(backup.hasState()) - } - - @Test - fun `package with no new data comes back ok right away`() = runBlocking { - every { crypto.getNameForPackage(salt, packageName) } returns name + fun `package with no new data comes back ok right away (if we have data)`() = runBlocking { + every { backupReceiver.assertFinalized() } just Runs + every { dbManager.existsDb(packageName) } returns true every { dbManager.getDb(packageName) } returns db every { data.close() } just Runs assertEquals( TRANSPORT_OK, - backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED, token, salt) + backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED) ) - assertTrue(backup.hasState()) + assertTrue(backup.hasState) + + uploadData() // we still "upload", so old data gets into new snapshot + + assertEquals(apkBackupData, backup.finishBackup()) + assertFalse(backup.hasState) verify { data.close() } - every { db.close() } just Runs + } - assertEquals(TRANSPORT_OK, backup.finishBackup()) - assertFalse(backup.hasState()) + @Test + fun `request non-incremental backup when no data has changed, but we lost it`() = runBlocking { + every { backupReceiver.assertFinalized() } just Runs + every { dbManager.existsDb(packageName) } returns false + every { dbManager.getDb(packageName) } returns db + every { db.close() } just Runs + every { data.close() } just Runs + + assertEquals( + TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, + backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED) + ) + assertFalse(backup.hasState) // gets cleared + + verify { + db.close() + data.close() + } } @Test @@ -147,9 +152,15 @@ internal class KVBackupTest : BackupTest() { createBackupDataInput() every { dataInput.readNextHeader() } throws IOException() every { db.close() } just Runs + every { data.close() } just Runs - assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0, token, salt)) - assertFalse(backup.hasState()) + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState) + + verify { + db.close() + data.close() + } } @Test @@ -161,23 +172,35 @@ internal class KVBackupTest : BackupTest() { every { dataInput.dataSize } returns dataValue.size every { dataInput.readEntityData(any(), 0, dataValue.size) } throws IOException() every { db.close() } just Runs + every { data.close() } just Runs - assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0, token, salt)) - assertFalse(backup.hasState()) + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState) + + verify { data.close() } } @Test fun `no data records`() = runBlocking { initPlugin(false) getDataInput(listOf(false)) + every { data.close() } just Runs - assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt)) - assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState) every { db.close() } just Runs - assertEquals(TRANSPORT_OK, backup.finishBackup()) - assertFalse(backup.hasState()) + // if there's no data, the system wouldn't call us, so no special handling here + uploadData() + + assertEquals(apkBackupData, backup.finishBackup()) + assertFalse(backup.hasState) + + verify { + db.close() + data.close() + } } @Test @@ -188,82 +211,69 @@ internal class KVBackupTest : BackupTest() { every { dataInput.key } returns key every { dataInput.dataSize } returns -1 // just documented by example code in LocalTransport every { db.delete(key) } just Runs + every { data.close() } just Runs - assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt)) - assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState) uploadData() - assertEquals(TRANSPORT_OK, backup.finishBackup()) - assertFalse(backup.hasState()) + assertEquals(apkBackupData, backup.finishBackup()) + assertFalse(backup.hasState) + + verify { data.close() } } @Test - fun `exception while writing version`() = runBlocking { + fun `exception while finalizing`() = runBlocking { initPlugin(false) getDataInput(listOf(true, false)) every { db.put(key, dataValue) } just Runs + every { data.close() } just Runs - assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt)) - assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState) every { db.vacuum() } just Runs every { db.close() } just Runs - coEvery { backend.save(handle) } returns outputStream - every { outputStream.write(ByteArray(1) { VERSION }) } throws IOException() - every { outputStream.close() } just Runs - assertEquals(TRANSPORT_ERROR, backup.finishBackup()) - assertFalse(backup.hasState()) + every { dbManager.getDbInputStream(packageName) } returns inputStream + coEvery { backupReceiver.readFromStream(inputStream) } just Runs + coEvery { backupReceiver.finalize() } throws IOException() - verify { outputStream.close() } - } - - @Test - fun `exception while writing encrypted value to output stream`() = runBlocking { - initPlugin(false) - getDataInput(listOf(true, false)) - every { db.put(key, dataValue) } just Runs - - assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt)) - assertTrue(backup.hasState()) - - every { db.vacuum() } just Runs - every { db.close() } just Runs - coEvery { backend.save(handle) } returns outputStream - every { outputStream.write(ByteArray(1) { VERSION }) } just Runs - val ad = getADForKV(VERSION, packageInfo.packageName) - every { crypto.newEncryptingStreamV1(outputStream, ad) } returns encryptedOutputStream - every { encryptedOutputStream.write(any()) } throws IOException() - - assertEquals(TRANSPORT_ERROR, backup.finishBackup()) - assertFalse(backup.hasState()) + assertThrows { // we let exceptions bubble up to coordinators + backup.finishBackup() + } + assertFalse(backup.hasState) verify { - encryptedOutputStream.close() - outputStream.close() + db.close() + data.close() } } @Test - fun `no upload when we back up @pm@ while we can't do backups`() = runBlocking { - every { dbManager.existsDb(pmPackageInfo.packageName) } returns false - every { crypto.getNameForPackage(salt, pmPackageInfo.packageName) } returns name - every { dbManager.getDb(pmPackageInfo.packageName) } returns db - every { backendManager.canDoBackupNow() } returns false - every { db.put(key, dataValue) } just Runs + fun `exception while uploading data`() = runBlocking { + initPlugin(false) getDataInput(listOf(true, false)) + every { db.put(key, dataValue) } just Runs + every { data.close() } just Runs - assertEquals(TRANSPORT_OK, backup.performBackup(pmPackageInfo, data, 0, token, salt)) - assertTrue(backup.hasState()) - assertEquals(pmPackageInfo, backup.getCurrentPackage()) + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState) + every { db.vacuum() } just Runs every { db.close() } just Runs + every { dbManager.getDbInputStream(packageName) } returns inputStream + coEvery { backupReceiver.readFromStream(inputStream) } throws IOException() - assertEquals(TRANSPORT_OK, backup.finishBackup()) - assertFalse(backup.hasState()) + assertThrows { // we let exceptions bubble up to coordinators + backup.finishBackup() + } + assertFalse(backup.hasState) - coVerify(exactly = 0) { - backend.save(handle) + verify { + db.close() + data.close() } } @@ -275,8 +285,8 @@ internal class KVBackupTest : BackupTest() { } private fun initPlugin(hasDataForPackage: Boolean = false, pi: PackageInfo = packageInfo) { + every { backupReceiver.assertFinalized() } just Runs every { dbManager.existsDb(pi.packageName) } returns hasDataForPackage - every { crypto.getNameForPackage(salt, pi.packageName) } returns name every { dbManager.getDb(pi.packageName) } returns db } @@ -299,16 +309,9 @@ internal class KVBackupTest : BackupTest() { private fun uploadData() { every { db.vacuum() } just Runs every { db.close() } just Runs - - coEvery { backend.save(handle) } returns outputStream - every { outputStream.write(ByteArray(1) { VERSION }) } just Runs - val ad = getADForKV(VERSION, packageInfo.packageName) - every { crypto.newEncryptingStreamV1(outputStream, ad) } returns encryptedOutputStream - every { encryptedOutputStream.write(any()) } just Runs // gzip header - every { encryptedOutputStream.write(any(), any(), any()) } just Runs // stream copy every { dbManager.getDbInputStream(packageName) } returns inputStream - every { encryptedOutputStream.close() } just Runs - every { outputStream.close() } just Runs + coEvery { backupReceiver.readFromStream(inputStream) } just Runs + coEvery { backupReceiver.finalize() } returns apkBackupData } } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt index e056e99a..7d98c235 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt @@ -52,7 +52,7 @@ internal class FullRestoreTest : RestoreTest() { private val encrypted = getRandomByteArray() private val outputStream = ByteArrayOutputStream() - private val blobHandles = listOf(apkBlobHandle) + private val blobHandles = listOf(blobHandle1) init { every { backendManager.backend } returns backend diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt index 6214bf41..24180ba6 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt @@ -8,15 +8,14 @@ package com.stevesoltys.seedvault.transport.restore import android.app.backup.BackupDataOutput import android.app.backup.BackupTransport.TRANSPORT_ERROR import android.app.backup.BackupTransport.TRANSPORT_OK -import com.stevesoltys.seedvault.coAssertThrows -import com.stevesoltys.seedvault.encodeBase64 -import com.stevesoltys.seedvault.getRandomByteArray -import com.stevesoltys.seedvault.header.UnsupportedVersionException -import com.stevesoltys.seedvault.header.VERSION -import com.stevesoltys.seedvault.header.VersionHeader -import com.stevesoltys.seedvault.header.getADForKV -import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import android.content.pm.PackageInfo +import com.stevesoltys.seedvault.ANCESTRAL_RECORD_KEY +import com.stevesoltys.seedvault.GLOBAL_METADATA_KEY +import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.coAssertThrows +import com.stevesoltys.seedvault.getRandomByteArray +import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.transport.backup.KVDb import com.stevesoltys.seedvault.transport.backup.KvDbManager import io.mockk.Runs @@ -24,59 +23,39 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.verify import io.mockk.verifyAll import kotlinx.coroutines.runBlocking -import org.calyxos.seedvault.core.backends.Backend import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test -import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.IOException -import java.io.InputStream import java.security.GeneralSecurityException -import java.util.zip.GZIPOutputStream import kotlin.random.Random internal class KVRestoreTest : RestoreTest() { private val backendManager: BackendManager = mockk() - private val backend = mockk() - @Suppress("DEPRECATION") - private val legacyPlugin = mockk() + private val loader = mockk() private val dbManager = mockk() private val output = mockk() private val restore = KVRestore( backendManager = backendManager, - legacyPlugin = legacyPlugin, + loader = loader, + legacyPlugin = mockk(), outputFactory = outputFactory, - headerReader = headerReader, - crypto = crypto, + headerReader = mockk(), + crypto = mockk(), dbManager = dbManager, ) private val db = mockk() - private val ad = getADForKV(VERSION, packageInfo.packageName) + private val blobHandles = listOf(blobHandle1) private val key = "Restore Key" - private val key64 = key.encodeBase64() private val key2 = "Restore Key2" - private val key264 = key2.encodeBase64() private val data2 = getRandomByteArray() - private val outputStream = ByteArrayOutputStream().apply { - GZIPOutputStream(this).close() - } - private val decryptInputStream = ByteArrayInputStream(outputStream.toByteArray()) - - init { - // for InputStream#readBytes() - mockkStatic("kotlin.io.ByteStreamsKt") - - every { backendManager.backend } returns backend - } - @Test fun `getRestoreData() throws without initializing state`() { coAssertThrows(IllegalStateException::class.java) { @@ -85,45 +64,27 @@ internal class KVRestoreTest : RestoreTest() { } @Test - fun `unexpected version aborts with error`() = runBlocking { - restore.initializeState(VERSION, token, name, packageInfo) + fun `loader#loadFiles() throws`() = runBlocking { + restore.initializeState(VERSION, packageInfo, blobHandles) - coEvery { backend.load(handle) } returns inputStream - every { - headerReader.readVersion(inputStream, VERSION) - } throws UnsupportedVersionException(Byte.MAX_VALUE) + coEvery { loader.loadFiles(blobHandles) } throws GeneralSecurityException() every { dbManager.deleteDb(packageInfo.packageName, true) } returns true streamsGetClosed() assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() - } - - @Test - fun `newDecryptingStream throws`() = runBlocking { - restore.initializeState(VERSION, token, name, packageInfo) - - coEvery { backend.load(handle) } returns inputStream - every { headerReader.readVersion(inputStream, VERSION) } returns VERSION - every { crypto.newDecryptingStreamV1(inputStream, ad) } throws GeneralSecurityException() - every { dbManager.deleteDb(packageInfo.packageName, true) } returns true - streamsGetClosed() - - assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() verifyAll { + fileDescriptor.close() dbManager.deleteDb(packageInfo.packageName, true) } } @Test fun `writeEntityHeader throws`() = runBlocking { - restore.initializeState(VERSION, token, name, packageInfo) + restore.initializeState(VERSION, packageInfo, blobHandles) - coEvery { backend.load(handle) } returns inputStream - every { headerReader.readVersion(inputStream, VERSION) } returns VERSION - every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptInputStream + coEvery { loader.loadFiles(blobHandles) } returns inputStream + every { inputStream.read(any()) } returns -1 // the DB we'll mock below every { dbManager.getDbOutputStream(packageInfo.packageName) } returns ByteArrayOutputStream() @@ -144,11 +105,10 @@ internal class KVRestoreTest : RestoreTest() { @Test fun `two records get restored`() = runBlocking { - restore.initializeState(VERSION, token, name, packageInfo) + restore.initializeState(VERSION, packageInfo, blobHandles) - coEvery { backend.load(handle) } returns inputStream - every { headerReader.readVersion(inputStream, VERSION) } returns VERSION - every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptInputStream + coEvery { loader.loadFiles(blobHandles) } returns inputStream + every { inputStream.read(any()) } returns -1 // the DB we'll mock below every { dbManager.getDbOutputStream(packageInfo.packageName) } returns ByteArrayOutputStream() @@ -180,226 +140,43 @@ internal class KVRestoreTest : RestoreTest() { } } - // - // v0 legacy tests below - // - @Test - @Suppress("Deprecation") - fun `v0 hasDataForPackage() delegates to plugin`() = runBlocking { - val result = Random.nextBoolean() + fun `auto restore uses cached DB`() = runBlocking { + val pmPackageInfo = PackageInfo().apply { + packageName = MAGIC_PACKAGE_MANAGER + } + restore.initializeState(2, pmPackageInfo, blobHandles, packageInfo) - coEvery { legacyPlugin.hasDataForPackage(token, packageInfo) } returns result - - assertEquals(result, restore.hasDataForPackage(token, packageInfo)) - } - - @Test - fun `v0 listing records throws`() = runBlocking { - restore.initializeState(0x00, token, name, packageInfo) - - coEvery { legacyPlugin.listRecords(token, packageInfo) } throws IOException() - - assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) - } - - @Test - fun `v0 reading VersionHeader with unsupported version throws`() = runBlocking { - restore.initializeState(0x00, token, name, packageInfo) - - getRecordsAndOutput() - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) - } returns inputStream - every { - headerReader.readVersion(inputStream, 0x00) - } throws UnsupportedVersionException(unsupportedVersion) - streamsGetClosed() - - assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() - } - - @Test - fun `v0 error reading VersionHeader throws`() = runBlocking { - restore.initializeState(0x00, token, name, packageInfo) - - getRecordsAndOutput() - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) - } returns inputStream - every { headerReader.readVersion(inputStream, 0x00) } throws IOException() - streamsGetClosed() - - assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() - } - - @Test - @Suppress("deprecation") - fun `v0 decrypting stream throws`() = runBlocking { - restore.initializeState(0x00, token, name, packageInfo) - - getRecordsAndOutput() - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) - } returns inputStream - every { headerReader.readVersion(inputStream, 0x00) } returns 0x00 - every { - crypto.decryptHeader(inputStream, 0x00, packageInfo.packageName, key) - } throws IOException() - streamsGetClosed() - - assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() - } - - @Test - @Suppress("deprecation") - fun `v0 decrypting stream throws security exception`() = runBlocking { - restore.initializeState(0x00, token, name, packageInfo) - - getRecordsAndOutput() - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) - } returns inputStream - every { headerReader.readVersion(inputStream, 0x00) } returns 0x00 - every { - crypto.decryptHeader(inputStream, 0x00, packageInfo.packageName, key) - } returns VersionHeader(0x00, packageInfo.packageName, key) - every { crypto.decryptMultipleSegments(inputStream) } throws IOException() - streamsGetClosed() - - assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() - } - - @Test - @Suppress("Deprecation") - fun `v0 writing header throws`() = runBlocking { - restore.initializeState(0, token, name, packageInfo) - - getRecordsAndOutput() - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) - } returns inputStream - every { headerReader.readVersion(inputStream, 0) } returns 0 - every { - crypto.decryptHeader(inputStream, 0x00, packageInfo.packageName, key) - } returns VersionHeader(0x00, packageInfo.packageName, key) - every { crypto.decryptMultipleSegments(inputStream) } returns data - every { output.writeEntityHeader(key, data.size) } throws IOException() - streamsGetClosed() - - assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() - } - - @Test - @Suppress("deprecation") - fun `v0 writing value throws`() = runBlocking { - restore.initializeState(0, token, name, packageInfo) - - getRecordsAndOutput() - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) - } returns inputStream - every { headerReader.readVersion(inputStream, 0) } returns 0 - every { - crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key) - } returns VersionHeader(0, packageInfo.packageName, key) - every { crypto.decryptMultipleSegments(inputStream) } returns data - every { output.writeEntityHeader(key, data.size) } returns 42 - every { output.writeEntityData(data, data.size) } throws IOException() - streamsGetClosed() - - assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() - } - - @Test - @Suppress("deprecation") - fun `v0 writing value succeeds`() = runBlocking { - restore.initializeState(0, token, name, packageInfo) - - getRecordsAndOutput() - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) - } returns inputStream - every { headerReader.readVersion(inputStream, 0) } returns 0 - every { - crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key) - } returns VersionHeader(0, packageInfo.packageName, key) - every { crypto.decryptMultipleSegments(inputStream) } returns data - every { output.writeEntityHeader(key, data.size) } returns 42 - every { output.writeEntityData(data, data.size) } returns data.size - streamsGetClosed() - - assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() - } - - @Test - @Suppress("deprecation") - fun `v0 writing value uses old v0 code`() = runBlocking { - restore.initializeState(0, token, name, packageInfo) - - getRecordsAndOutput() - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) - } returns inputStream - every { headerReader.readVersion(inputStream, 0) } returns 0 - every { - crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key) - } returns VersionHeader(VERSION, packageInfo.packageName, key) - every { crypto.decryptMultipleSegments(inputStream) } returns data - every { output.writeEntityHeader(key, data.size) } returns 42 - every { output.writeEntityData(data, data.size) } returns data.size - streamsGetClosed() - - assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) - verifyStreamWasClosed() - } - - @Test - @Suppress("Deprecation") - fun `v0 writing two values succeeds`() = runBlocking { - val data2 = getRandomByteArray() - val inputStream2 = mockk() - restore.initializeState(0, token, name, packageInfo) - - getRecordsAndOutput(listOf(key64, key264)) - // first key/value - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) - } returns inputStream - every { headerReader.readVersion(inputStream, 0) } returns 0 - every { - crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key) - } returns VersionHeader(0, packageInfo.packageName, key) - every { crypto.decryptMultipleSegments(inputStream) } returns data - every { output.writeEntityHeader(key, data.size) } returns 42 - every { output.writeEntityData(data, data.size) } returns data.size - // second key/value - coEvery { - legacyPlugin.getInputStreamForRecord(token, packageInfo, key264) - } returns inputStream2 - every { headerReader.readVersion(inputStream2, 0) } returns 0 - every { - crypto.decryptHeader(inputStream2, 0, packageInfo.packageName, key2) - } returns VersionHeader(0, packageInfo.packageName, key2) - every { crypto.decryptMultipleSegments(inputStream2) } returns data2 - every { output.writeEntityHeader(key2, data2.size) } returns 42 - every { output.writeEntityData(data2, data2.size) } returns data2.size - every { inputStream2.close() } just Runs - streamsGetClosed() - - assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) - } - - private fun getRecordsAndOutput(recordKeys: List = listOf(key64)) { - coEvery { legacyPlugin.listRecords(token, packageInfo) } returns recordKeys + every { dbManager.existsDb(MAGIC_PACKAGE_MANAGER) } returns true + every { dbManager.getDb(MAGIC_PACKAGE_MANAGER) } returns db every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output + every { db.getAll() } returns listOf( + Pair(ANCESTRAL_RECORD_KEY, data), + Pair(GLOBAL_METADATA_KEY, data), + Pair(packageName, data2), + Pair("foo", Random.nextBytes(23)), // should get filtered out + Pair("bar", Random.nextBytes(42)), // should get filtered out + ) + every { output.writeEntityHeader(ANCESTRAL_RECORD_KEY, data.size) } returns data.size + every { output.writeEntityHeader(GLOBAL_METADATA_KEY, data.size) } returns data.size + every { output.writeEntityHeader(packageName, data2.size) } returns data2.size + every { output.writeEntityData(data, data.size) } returns data.size + every { output.writeEntityData(data2, data2.size) } returns data2.size + every { db.close() } just Runs + + every { dbManager.deleteDb(MAGIC_PACKAGE_MANAGER, true) } returns true + every { fileDescriptor.close() } just Runs + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + + verify(exactly = 0) { + output.writeEntityHeader("foo", any()) + output.writeEntityHeader("bar", any()) + } + verify { + fileDescriptor.close() + db.close() + } } private fun streamsGetClosed() { @@ -408,7 +185,7 @@ internal class KVRestoreTest : RestoreTest() { } private fun verifyStreamWasClosed() { - verifyAll { + verify { inputStream.close() fileDescriptor.close() } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreV1Test.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreV1Test.kt new file mode 100644 index 00000000..c56555e8 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreV1Test.kt @@ -0,0 +1,418 @@ +/* + * SPDX-FileCopyrightText: 2020 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.restore + +import android.app.backup.BackupDataOutput +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.coAssertThrows +import com.stevesoltys.seedvault.encodeBase64 +import com.stevesoltys.seedvault.getRandomByteArray +import com.stevesoltys.seedvault.header.UnsupportedVersionException +import com.stevesoltys.seedvault.header.VersionHeader +import com.stevesoltys.seedvault.header.getADForKV +import com.stevesoltys.seedvault.transport.backup.KVDb +import com.stevesoltys.seedvault.transport.backup.KvDbManager +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import io.mockk.verifyAll +import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.security.GeneralSecurityException +import java.util.zip.GZIPOutputStream +import kotlin.random.Random + +internal class KVRestoreV1Test : RestoreTest() { + + private val backendManager: BackendManager = mockk() + private val loader = mockk() + private val backend = mockk() + @Suppress("DEPRECATION") + private val legacyPlugin = mockk() + private val dbManager = mockk() + private val output = mockk() + private val restore = KVRestore( + backendManager = backendManager, + loader = loader, + legacyPlugin = legacyPlugin, + outputFactory = outputFactory, + headerReader = headerReader, + crypto = crypto, + dbManager = dbManager, + ) + + private val db = mockk() + private val ad = getADForKV(1, packageInfo.packageName) + + private val key = "Restore Key" + private val key64 = key.encodeBase64() + private val key2 = "Restore Key2" + private val key264 = key2.encodeBase64() + private val data2 = getRandomByteArray() + + private val outputStream = ByteArrayOutputStream().apply { + GZIPOutputStream(this).close() + } + private val decryptInputStream = ByteArrayInputStream(outputStream.toByteArray()) + + init { + // for InputStream#readBytes() + mockkStatic("kotlin.io.ByteStreamsKt") + + every { backendManager.backend } returns backend + } + + @Test + fun `getRestoreData() throws without initializing state`() { + coAssertThrows(IllegalStateException::class.java) { + restore.getRestoreData(fileDescriptor) + } + } + + @Test + fun `unexpected version aborts with error`() = runBlocking { + restore.initializeStateV1(token, name, packageInfo) + + coEvery { backend.load(handle) } returns inputStream + every { + headerReader.readVersion(inputStream, 1) + } throws UnsupportedVersionException(Byte.MAX_VALUE) + every { dbManager.deleteDb(packageInfo.packageName, true) } returns true + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `newDecryptingStream throws`() = runBlocking { + restore.initializeStateV1(token, name, packageInfo) + + coEvery { backend.load(handle) } returns inputStream + every { headerReader.readVersion(inputStream, 1) } returns 1 + every { crypto.newDecryptingStreamV1(inputStream, ad) } throws GeneralSecurityException() + every { dbManager.deleteDb(packageInfo.packageName, true) } returns true + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + + verifyAll { + dbManager.deleteDb(packageInfo.packageName, true) + } + } + + @Test + fun `writeEntityHeader throws`() = runBlocking { + restore.initializeStateV1(token, name, packageInfo) + + coEvery { backend.load(handle) } returns inputStream + every { headerReader.readVersion(inputStream, 1) } returns 1 + every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptInputStream + every { + dbManager.getDbOutputStream(packageInfo.packageName) + } returns ByteArrayOutputStream() + every { dbManager.getDb(packageInfo.packageName, true) } returns db + every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output + every { db.getAll() } returns listOf(Pair(key, data)) + every { output.writeEntityHeader(key, data.size) } throws IOException() + every { dbManager.deleteDb(packageInfo.packageName, true) } returns true + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + + verify { + dbManager.deleteDb(packageInfo.packageName, true) + } + } + + @Test + fun `two records get restored`() = runBlocking { + restore.initializeStateV1(token, name, packageInfo) + + coEvery { backend.load(handle) } returns inputStream + every { headerReader.readVersion(inputStream, 1) } returns 1 + every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptInputStream + every { + dbManager.getDbOutputStream(packageInfo.packageName) + } returns ByteArrayOutputStream() + every { dbManager.getDb(packageInfo.packageName, true) } returns db + every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output + every { db.getAll() } returns listOf( + Pair(key, data), + Pair(key2, data2) + ) + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } returns data.size + every { output.writeEntityHeader(key2, data2.size) } returns 42 + every { output.writeEntityData(data2, data2.size) } returns data2.size + + every { db.close() } just Runs + every { dbManager.deleteDb(packageInfo.packageName, true) } returns true + streamsGetClosed() + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + + verify { + output.writeEntityHeader(key, data.size) + output.writeEntityData(data, data.size) + output.writeEntityHeader(key2, data2.size) + output.writeEntityData(data2, data2.size) + db.close() + dbManager.deleteDb(packageInfo.packageName, true) + } + } + + // + // v0 legacy tests below + // + + @Test + @Suppress("Deprecation") + fun `v0 hasDataForPackage() delegates to plugin`() = runBlocking { + val result = Random.nextBoolean() + + coEvery { legacyPlugin.hasDataForPackage(token, packageInfo) } returns result + + assertEquals(result, restore.hasDataForPackage(token, packageInfo)) + } + + @Test + fun `v0 listing records throws`() = runBlocking { + restore.initializeStateV0(token, packageInfo) + + coEvery { legacyPlugin.listRecords(token, packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + } + + @Test + fun `v0 reading VersionHeader with unsupported version throws`() = runBlocking { + restore.initializeStateV0(token, packageInfo) + + getRecordsAndOutput() + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) + } returns inputStream + every { + headerReader.readVersion(inputStream, 0x00) + } throws UnsupportedVersionException(unsupportedVersion) + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `v0 error reading VersionHeader throws`() = runBlocking { + restore.initializeStateV0(token, packageInfo) + + getRecordsAndOutput() + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) + } returns inputStream + every { headerReader.readVersion(inputStream, 0x00) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + @Suppress("deprecation") + fun `v0 decrypting stream throws`() = runBlocking { + restore.initializeStateV0(token, packageInfo) + + getRecordsAndOutput() + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) + } returns inputStream + every { headerReader.readVersion(inputStream, 0x00) } returns 0x00 + every { + crypto.decryptHeader(inputStream, 0x00, packageInfo.packageName, key) + } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + @Suppress("deprecation") + fun `v0 decrypting stream throws security exception`() = runBlocking { + restore.initializeStateV0(token, packageInfo) + + getRecordsAndOutput() + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) + } returns inputStream + every { headerReader.readVersion(inputStream, 0x00) } returns 0x00 + every { + crypto.decryptHeader(inputStream, 0x00, packageInfo.packageName, key) + } returns VersionHeader(0x00, packageInfo.packageName, key) + every { crypto.decryptMultipleSegments(inputStream) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + @Suppress("Deprecation") + fun `v0 writing header throws`() = runBlocking { + restore.initializeStateV0(token, packageInfo) + + getRecordsAndOutput() + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) + } returns inputStream + every { headerReader.readVersion(inputStream, 0) } returns 0 + every { + crypto.decryptHeader(inputStream, 0x00, packageInfo.packageName, key) + } returns VersionHeader(0x00, packageInfo.packageName, key) + every { crypto.decryptMultipleSegments(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + @Suppress("deprecation") + fun `v0 writing value throws`() = runBlocking { + restore.initializeStateV0(token, packageInfo) + + getRecordsAndOutput() + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) + } returns inputStream + every { headerReader.readVersion(inputStream, 0) } returns 0 + every { + crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key) + } returns VersionHeader(0, packageInfo.packageName, key) + every { crypto.decryptMultipleSegments(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + @Suppress("deprecation") + fun `v0 writing value succeeds`() = runBlocking { + restore.initializeStateV0(token, packageInfo) + + getRecordsAndOutput() + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) + } returns inputStream + every { headerReader.readVersion(inputStream, 0) } returns 0 + every { + crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key) + } returns VersionHeader(0, packageInfo.packageName, key) + every { crypto.decryptMultipleSegments(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } returns data.size + streamsGetClosed() + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + @Suppress("deprecation") + fun `v0 writing value uses old v0 code`() = runBlocking { + restore.initializeStateV0(token, packageInfo) + + getRecordsAndOutput() + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) + } returns inputStream + every { headerReader.readVersion(inputStream, 0) } returns 0 + every { + crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key) + } returns VersionHeader(1, packageInfo.packageName, key) + every { crypto.decryptMultipleSegments(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } returns data.size + streamsGetClosed() + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + @Suppress("Deprecation") + fun `v0 writing two values succeeds`() = runBlocking { + val data2 = getRandomByteArray() + val inputStream2 = mockk() + restore.initializeStateV0(token, packageInfo) + + getRecordsAndOutput(listOf(key64, key264)) + // first key/value + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key64) + } returns inputStream + every { headerReader.readVersion(inputStream, 0) } returns 0 + every { + crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key) + } returns VersionHeader(0, packageInfo.packageName, key) + every { crypto.decryptMultipleSegments(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } returns data.size + // second key/value + coEvery { + legacyPlugin.getInputStreamForRecord(token, packageInfo, key264) + } returns inputStream2 + every { headerReader.readVersion(inputStream2, 0) } returns 0 + every { + crypto.decryptHeader(inputStream2, 0, packageInfo.packageName, key2) + } returns VersionHeader(0, packageInfo.packageName, key2) + every { crypto.decryptMultipleSegments(inputStream2) } returns data2 + every { output.writeEntityHeader(key2, data2.size) } returns 42 + every { output.writeEntityData(data2, data2.size) } returns data2.size + every { inputStream2.close() } just Runs + streamsGetClosed() + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + } + + private fun getRecordsAndOutput(recordKeys: List = listOf(key64)) { + coEvery { legacyPlugin.listRecords(token, packageInfo) } returns recordKeys + every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output + } + + private fun streamsGetClosed() { + every { inputStream.close() } just Runs + every { fileDescriptor.close() } just Runs + } + + private fun verifyStreamWasClosed() { + verifyAll { + inputStream.close() + fileDescriptor.close() + } + } + +} diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt index 5f2efa19..e0d1d3a6 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt @@ -15,15 +15,18 @@ import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.coAssertThrows +import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.MetadataReader import com.stevesoltys.seedvault.metadata.PackageMetadata +import com.stevesoltys.seedvault.proto.copy import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import io.mockk.Runs import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -35,11 +38,14 @@ import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.FileInfo import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.calyxos.seedvault.core.backends.saf.SafProperties +import org.calyxos.seedvault.core.toHexString import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream import kotlin.random.Random @@ -82,7 +88,7 @@ internal class RestoreCoordinatorTest : TransportTest() { init { metadata.packageMetadataMap[packageInfo2.packageName] = PackageMetadata( backupType = BackupType.FULL, - chunkIds = listOf(apkChunkId), + chunkIds = listOf(chunkId2), ) mockkStatic("com.stevesoltys.seedvault.backend.BackendExtKt") @@ -250,6 +256,62 @@ internal class RestoreCoordinatorTest : TransportTest() { } } + @Test + fun `startRestore() loads snapshots for auto-restore`() = runBlocking { + val handle = AppBackupFileType.Snapshot(repoId, getRandomByteArray(32).toHexString()) + val info = FileInfo(handle, 1) + val snapshotBytes = ByteArrayOutputStream().apply { + snapshot.writeTo(this) + }.toByteArray() + + every { backendManager.backendProperties } returns safStorage + every { safStorage.isUnavailableUsb(context) } returns false + + coEvery { + backend.list( + topLevelFolder = null, + AppBackupFileType.Snapshot::class, LegacyAppBackupFile.Metadata::class, + callback = captureLambda<(FileInfo) -> Unit>() + ) + } answers { + val callback = lambda<(FileInfo) -> Unit>().captured + callback(info) + } + coEvery { loader.loadFile(handle) } returns ByteArrayInputStream(snapshotBytes) + + assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray)) + } + + @Test + fun `startRestore() errors when it can't find snapshots`() = runBlocking { + val handle = AppBackupFileType.Snapshot(repoId, getRandomByteArray(32).toHexString()) + val info = FileInfo(handle, 1) + val snapshotBytes = ByteArrayOutputStream().apply { // snapshot has different token + snapshot.copy { token = this@RestoreCoordinatorTest.token - 1 }.writeTo(this) + }.toByteArray() + + every { backendManager.backendProperties } returns safStorage + every { safStorage.isUnavailableUsb(context) } returns false + + coEvery { + backend.list( + topLevelFolder = null, + AppBackupFileType.Snapshot::class, LegacyAppBackupFile.Metadata::class, + callback = captureLambda<(FileInfo) -> Unit>() + ) + } answers { + val callback = lambda<(FileInfo) -> Unit>().captured + callback(info) + } + coEvery { loader.loadFile(handle) } returns ByteArrayInputStream(snapshotBytes) + + assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray)) + + coVerify { + loader.loadFile(handle) // really loaded snapshot + } + } + @Test fun `startRestore() with removed storage shows no notification`() = runBlocking { every { backendManager.backendProperties } returns safStorage @@ -274,12 +336,12 @@ internal class RestoreCoordinatorTest : TransportTest() { } @Test - fun `nextRestorePackage() returns KV description`() = runBlocking { - restore.beforeStartRestore(restorableBackup) + fun `nextRestorePackageV1() returns KV description`() = runBlocking { + restore.beforeStartRestore(restorableBackup.copy(metadata.copy(version = 1))) restore.startRestore(token, packageInfoArray) every { crypto.getNameForPackage(metadata.salt, packageName) } returns name - every { kv.initializeState(VERSION, token, name, packageInfo) } just Runs + every { kv.initializeStateV1(token, name, packageInfo) } just Runs val expected = RestoreDescription(packageName, TYPE_KEY_VALUE) assertEquals(expected, restore.nextRestorePackage()) @@ -293,7 +355,7 @@ internal class RestoreCoordinatorTest : TransportTest() { restore.startRestore(token, packageInfoArray) coEvery { kv.hasDataForPackage(token, packageInfo) } returns true - every { kv.initializeState(0x00, token, "", packageInfo) } just Runs + every { kv.initializeStateV0(token, packageInfo) } just Runs val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) assertEquals(expected, restore.nextRestorePackage()) @@ -321,7 +383,23 @@ internal class RestoreCoordinatorTest : TransportTest() { restore.beforeStartRestore(restorableBackup) restore.startRestore(token, packageInfoArray2) - every { full.initializeState(VERSION, packageInfo2, listOf(apkBlobHandle)) } just Runs + every { full.initializeState(VERSION, packageInfo2, listOf(blobHandle2)) } just Runs + + val expected = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM) + assertEquals(expected, restore.nextRestorePackage()) + + assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) + } + + @Test + fun `nextRestorePackageV1() tries next package if one has no backup type()`() = runBlocking { + metadata.packageMetadataMap[packageName] = + metadata.packageMetadataMap[packageName]!!.copy(backupType = null) + restore.beforeStartRestore(restorableBackup.copy(metadata.copy(version = 1))) + restore.startRestore(token, packageInfoArray2) + + every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2 + every { full.initializeStateV1(token, name2, packageInfo2) } just Runs val expected = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM) assertEquals(expected, restore.nextRestorePackage()) @@ -334,13 +412,33 @@ internal class RestoreCoordinatorTest : TransportTest() { restore.beforeStartRestore(restorableBackup) restore.startRestore(token, packageInfoArray2) - every { crypto.getNameForPackage(metadata.salt, packageName) } returns name - every { kv.initializeState(VERSION, token, name, packageInfo) } just Runs + every { kv.initializeState(VERSION, packageInfo, listOf(blobHandle1)) } just Runs val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) assertEquals(expected, restore.nextRestorePackage()) - every { full.initializeState(VERSION, packageInfo2, listOf(apkBlobHandle)) } just Runs + every { full.initializeState(VERSION, packageInfo2, listOf(blobHandle2)) } just Runs + + val expected2 = + RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM) + assertEquals(expected2, restore.nextRestorePackage()) + + assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) + } + + @Test + fun `nextRestorePackageV1() returns all packages from startRestore()`() = runBlocking { + restore.beforeStartRestore(restorableBackup.copy(metadata.copy(version = 1))) + restore.startRestore(token, packageInfoArray2) + + every { crypto.getNameForPackage(metadata.salt, packageName) } returns name + every { kv.initializeStateV1(token, name, packageInfo) } just Runs + + val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) + assertEquals(expected, restore.nextRestorePackage()) + + every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2 + every { full.initializeStateV1(token, name2, packageInfo2) } just Runs val expected2 = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM) @@ -357,7 +455,7 @@ internal class RestoreCoordinatorTest : TransportTest() { restore.startRestore(token, packageInfoArray2) coEvery { kv.hasDataForPackage(token, packageInfo) } returns true - every { kv.initializeState(0.toByte(), token, "", packageInfo) } just Runs + every { kv.initializeStateV0(token, packageInfo) } just Runs val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) assertEquals(expected, restore.nextRestorePackage()) diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt index fe6e73ca..229b3d37 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt @@ -62,6 +62,7 @@ internal class RestoreV0IntegrationTest : TransportTest() { private val backend = mockk() private val kvRestore = KVRestore( backendManager = backendManager, + loader = loader, legacyPlugin = legacyPlugin, outputFactory = outputFactory, headerReader = headerReader,