K/V backup and restore using v2

while maintaining support for v0 and v1
This commit is contained in:
Torsten Grote 2024-09-10 16:10:48 -03:00
parent 7c7ea5fcd7
commit c2ad309f93
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
20 changed files with 1073 additions and 724 deletions

View file

@ -37,11 +37,11 @@ class KoinInstrumentationTestApp : App() {
single { spyk(BackupNotificationManager(context)) } single { spyk(BackupNotificationManager(context)) }
single { spyk(FullBackup(get(), get(), get(), get())) } 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(InputFactory()) }
single { spyk(FullRestore(get(), get(), get(), get(), get(), get())) } 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()) } single { spyk(OutputFactory()) }
viewModel { viewModel {

View file

@ -111,7 +111,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
var data = mutableMapOf<String, ByteArray>() var data = mutableMapOf<String, ByteArray>()
coEvery { coEvery {
spyKVBackup.performBackup(any(), any(), any(), any(), any()) spyKVBackup.performBackup(any(), any(), any())
} answers { } answers {
packageName = firstArg<PackageInfo>().packageName packageName = firstArg<PackageInfo>().packageName
callOriginal() callOriginal()

View file

@ -164,7 +164,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
clearMocks(spyKVRestore) clearMocks(spyKVRestore)
coEvery { coEvery {
spyKVRestore.initializeState(any(), any(), any(), any(), any()) spyKVRestore.initializeState(any(), any(), any(), any())
} answers { } answers {
packageName = arg<PackageInfo>(3).packageName packageName = arg<PackageInfo>(3).packageName
restoreResult.kv[packageName!!] = mutableMapOf() restoreResult.kv[packageName!!] = mutableMapOf()

View file

@ -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<ParcelFileDescriptor>()
private val dataInput = mockk<BackupDataInput>()
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<ByteArray>()
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
}
}
}

View file

@ -22,7 +22,6 @@ import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.getMetadataOutputStream import com.stevesoltys.seedvault.backend.getMetadataOutputStream
import com.stevesoltys.seedvault.backend.isOutOfSpace import com.stevesoltys.seedvault.backend.isOutOfSpace
@ -157,7 +156,7 @@ internal class BackupCoordinator(
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
// report back quota // report back quota
Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.") 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.") Log.i(TAG, "Reported quota of $quota bytes.")
return quota return quota
} }
@ -217,7 +216,7 @@ internal class BackupCoordinator(
* [TRANSPORT_NOT_INITIALIZED] (if the backend dataset has become lost due to * [TRANSPORT_NOT_INITIALIZED] (if the backend dataset has become lost due to
* inactivity purge or some other reason and needs re-initializing) * inactivity purge or some other reason and needs re-initializing)
*/ */
suspend fun performIncrementalBackup( fun performIncrementalBackup(
packageInfo: PackageInfo, packageInfo: PackageInfo,
data: ParcelFileDescriptor, data: ParcelFileDescriptor,
flags: Int, flags: Int,
@ -232,9 +231,7 @@ internal class BackupCoordinator(
// This causes a backup error, but things should go back to normal afterwards. // This causes a backup error, but things should go back to normal afterwards.
return TRANSPORT_NOT_INITIALIZED return TRANSPORT_NOT_INITIALIZED
} }
val token = settingsManager.getToken() ?: error("no token in performFullBackup") return kv.performBackup(packageInfo, data, flags)
val salt = metadataManager.salt
return kv.performBackup(packageInfo, data, flags, token, salt)
} }
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
@ -323,17 +320,8 @@ internal class BackupCoordinator(
* *
* @return the same error codes as [performFullBackup]. * @return the same error codes as [performFullBackup].
*/ */
suspend fun clearBackupData(packageInfo: PackageInfo): Int { fun clearBackupData(packageInfo: PackageInfo): Int {
val packageName = packageInfo.packageName Log.i(TAG, "Ignoring clear backup data of ${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
}
// we don't clear backup data anymore, we have snapshots and those old ones stay valid // we don't clear backup data anymore, we have snapshots and those old ones stay valid
state.calledClearBackupData = true state.calledClearBackupData = true
return TRANSPORT_OK return TRANSPORT_OK
@ -348,33 +336,29 @@ internal class BackupCoordinator(
* @return the same error codes as [performIncrementalBackup] or [performFullBackup]. * @return the same error codes as [performIncrementalBackup] or [performFullBackup].
*/ */
suspend fun finishBackup(): Int = when { suspend fun finishBackup(): Int = when {
kv.hasState() -> { kv.hasState -> {
check(!full.hasState) { check(!full.hasState) {
"K/V backup has state, but full backup has dangling state as well" "K/V backup has state, but full backup has dangling state as well"
} }
// getCurrentPackage() not-null because we have state, call before finishing // getCurrentPackage() not-null because we have state, call before finishing
val packageInfo = kv.getCurrentPackage()!! val packageInfo = kv.currentPackageInfo!!
val packageName = packageInfo.packageName 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 { try {
metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, size) // 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) { } catch (e: Exception) {
Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e) Log.e(TAG, "Error finishing K/V backup for $packageName", e)
if (e.isOutOfSpace()) nm.onInsufficientSpaceError() if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
result = TRANSPORT_PACKAGE_REJECTED onPackageBackupError(packageInfo, BackupType.KV)
TRANSPORT_PACKAGE_REJECTED
} }
} }
}
result
}
full.hasState -> { full.hasState -> {
check(!kv.hasState()) { check(!kv.hasState) {
"Full backup has state, but K/V backup has dangling state as well" "Full backup has state, but K/V backup has dangling state as well"
} }
// getCurrentPackage() not-null because we have state // getCurrentPackage() not-null because we have state
@ -390,6 +374,7 @@ internal class BackupCoordinator(
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e) Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
if (e.isOutOfSpace()) nm.onInsufficientSpaceError() if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
onPackageBackupError(packageInfo, BackupType.FULL)
TRANSPORT_PACKAGE_REJECTED TRANSPORT_PACKAGE_REJECTED
} }
} }
@ -400,6 +385,7 @@ internal class BackupCoordinator(
else -> throw IllegalStateException("Unexpected state in finishBackup()") 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) { private suspend fun onPackageBackupError(packageInfo: PackageInfo, type: BackupType) {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
try { try {

View file

@ -28,11 +28,9 @@ val backupModule = module {
single<KvDbManager> { KvDbManagerImpl(androidContext()) } single<KvDbManager> { KvDbManagerImpl(androidContext()) }
single { single {
KVBackup( KVBackup(
backendManager = get(),
settingsManager = get(), settingsManager = get(),
nm = get(), backupReceiver = get(),
inputFactory = get(), inputFactory = get(),
crypto = get(),
dbManager = get(), dbManager = get(),
) )
} }

View file

@ -14,122 +14,87 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log 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.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import java.io.IOException import java.io.IOException
import java.util.zip.GZIPOutputStream
class KVBackupState( class KVBackupState(
internal val packageInfo: PackageInfo, internal val packageInfo: PackageInfo,
val token: Long,
val name: String,
val db: KVDb, val db: KVDb,
) { )
var needsUpload: Boolean = false
}
const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong() const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
private val TAG = KVBackup::class.java.simpleName private val TAG = KVBackup::class.java.simpleName
internal class KVBackup( internal class KVBackup(
private val backendManager: BackendManager,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager, private val backupReceiver: BackupReceiver,
private val inputFactory: InputFactory, private val inputFactory: InputFactory,
private val crypto: Crypto,
private val dbManager: KvDbManager, private val dbManager: KvDbManager,
) { ) {
private val backend get() = backendManager.backend
private var state: KVBackupState? = null private var state: KVBackupState? = null
fun hasState() = state != null val hasState get() = state != null
val currentPackageInfo get() = state?.packageInfo
fun getCurrentPackage() = state?.packageInfo val quota: Long
get() = if (settingsManager.isQuotaUnlimited()) {
fun getCurrentSize() = getCurrentPackage()?.let {
dbManager.getDbSize(it.packageName)
}
fun getQuota(): Long = if (settingsManager.isQuotaUnlimited()) {
Long.MAX_VALUE Long.MAX_VALUE
} else { } else {
DEFAULT_QUOTA_KEY_VALUE_BACKUP DEFAULT_QUOTA_KEY_VALUE_BACKUP
} }
suspend fun performBackup( fun performBackup(
packageInfo: PackageInfo, packageInfo: PackageInfo,
data: ParcelFileDescriptor, data: ParcelFileDescriptor,
flags: Int, flags: Int,
token: Long,
salt: String,
): Int { ): Int {
val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0 val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0
val isIncremental = flags and FLAG_INCREMENTAL != 0 val isIncremental = flags and FLAG_INCREMENTAL != 0
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0 val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
when { when {
dataNotChanged -> { dataNotChanged -> Log.i(TAG, "No K/V backup data has changed for $packageName")
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")
isIncremental -> { else -> Log.i(TAG, "Performing K/V backup for $packageName")
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 // initialize state
val state = this.state state = KVBackupState(packageInfo = packageInfo, db = dbManager.getDb(packageName))
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)
// 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) { if (dataNotChanged) {
data.close() 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 // check if we have existing data for the given package
val hasDataForPackage = dbManager.existsDb(packageName)
if (isIncremental && !hasDataForPackage) { if (isIncremental && !hasDataForPackage) {
Log.w( Log.w(
TAG, "Requested incremental, but transport currently stores no data" + TAG, "Requested incremental, but transport currently stores no data" +
" for $packageName, requesting non-incremental retry." " for $packageName, requesting non-incremental retry."
) )
data.close()
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED) return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
} }
// check if we have existing data, but the system wants clean slate
// TODO check if package is over-quota and respect unlimited setting
if (isNonIncremental && hasDataForPackage) { if (isNonIncremental && hasDataForPackage) {
Log.w(TAG, "Requested non-incremental, deleting existing data.") Log.w(TAG, "Requested non-incremental, deleting existing data...")
try { dbManager.deleteDb(packageInfo.packageName)
clearBackupData(packageInfo, token, salt) // KvBackupInstrumentationTest tells us that the DB gets re-created automatically
} catch (e: IOException) {
Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e)
} }
}
// parse and store the K/V updates // parse and store the K/V updates
return storeRecords(data) return data.use {
storeRecords(it)
}
} }
private fun storeRecords(data: ParcelFileDescriptor): Int { private fun storeRecords(data: ParcelFileDescriptor): Int {
@ -140,18 +105,6 @@ internal class KVBackup(
Log.e(TAG, "Exception reading backup input", result.exception) Log.e(TAG, "Exception reading backup input", result.exception)
return backupError(TRANSPORT_ERROR) 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 val op = (result as Result.Ok).result
if (op.value == null) { if (op.value == null) {
Log.e(TAG, "Deleting record with key ${op.key}") Log.e(TAG, "Deleting record with key ${op.key}")
@ -205,27 +158,21 @@ internal class KVBackup(
} }
@Throws(IOException::class) @Throws(IOException::class)
suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) { suspend fun finishBackup(): BackupData {
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 {
val state = this.state ?: error("No state in finishBackup") val state = this.state ?: error("No state in finishBackup")
val packageName = state.packageInfo.packageName 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 { try {
if (state.needsUpload) uploadDb(state.token, state.name, packageName, state.db) state.db.vacuum()
else state.db.close() state.db.close()
TRANSPORT_OK dbManager.getDbInputStream(packageName).use { inputStream ->
} catch (e: IOException) { backupReceiver.readFromStream(inputStream)
Log.e(TAG, "Error uploading DB", e) }
if (e.isOutOfSpace()) nm.onInsufficientSpaceError() val backupData = backupReceiver.finalize()
TRANSPORT_ERROR Log.d(TAG, "Uploaded db file for $packageName.")
} finally { return backupData
} finally { // exceptions bubble up
this.state = null this.state = null
} }
} }
@ -240,36 +187,10 @@ internal class KVBackup(
Log.i(TAG, "Resetting state because of K/V Backup error of $packageName") Log.i(TAG, "Resetting state because of K/V Backup error of $packageName")
state.db.close() state.db.close()
this.state = null this.state = null
return result 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( private class KVOperation(
val key: String, val key: String,
/** /**

View file

@ -14,17 +14,17 @@ import android.util.Log
import com.stevesoltys.seedvault.ANCESTRAL_RECORD_KEY import com.stevesoltys.seedvault.ANCESTRAL_RECORD_KEY
import com.stevesoltys.seedvault.GLOBAL_METADATA_KEY import com.stevesoltys.seedvault.GLOBAL_METADATA_KEY
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER 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.crypto.Crypto
import com.stevesoltys.seedvault.decodeBase64 import com.stevesoltys.seedvault.decodeBase64
import com.stevesoltys.seedvault.header.HeaderReader import com.stevesoltys.seedvault.header.HeaderReader
import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForKV 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.KVDb
import com.stevesoltys.seedvault.transport.backup.KvDbManager import com.stevesoltys.seedvault.transport.backup.KvDbManager
import libcore.io.IoUtils.closeQuietly import libcore.io.IoUtils.closeQuietly
import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import java.io.IOException import java.io.IOException
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
@ -33,19 +33,21 @@ import javax.crypto.AEADBadTagException
private class KVRestoreState( private class KVRestoreState(
val version: Byte, val version: Byte,
val token: Long,
val name: String,
val packageInfo: PackageInfo, val packageInfo: PackageInfo,
val blobHandles: List<Blob>? = null,
val token: Long? = null,
val name: String? = null,
/** /**
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@ * Optional [PackageInfo] for single package restore, optimizes restore of @pm@
*/ */
val autoRestorePackageInfo: PackageInfo?, val autoRestorePackageInfo: PackageInfo? = null,
) )
private val TAG = KVRestore::class.java.simpleName private val TAG = KVRestore::class.java.simpleName
internal class KVRestore( internal class KVRestore(
private val backendManager: BackendManager, private val backendManager: BackendManager,
private val loader: Loader,
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyPlugin: LegacyStoragePlugin, private val legacyPlugin: LegacyStoragePlugin,
private val outputFactory: OutputFactory, private val outputFactory: OutputFactory,
@ -78,12 +80,32 @@ internal class KVRestore(
*/ */
fun initializeState( fun initializeState(
version: Byte, version: Byte,
packageInfo: PackageInfo,
blobHandles: List<Blob>,
autoRestorePackageInfo: PackageInfo? = null,
) {
state = KVRestoreState(
version = version,
packageInfo = packageInfo,
blobHandles = blobHandles,
autoRestorePackageInfo = autoRestorePackageInfo,
)
}
fun initializeStateV1(
token: Long, token: Long,
name: String, name: String,
packageInfo: PackageInfo, packageInfo: PackageInfo,
autoRestorePackageInfo: PackageInfo? = null, 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) { val database = if (isAutoRestore) {
getCachedRestoreDb(state) getCachedRestoreDb(state)
} else { } else {
downloadRestoreDb(state) if (state.version == 1.toByte()) downloadRestoreDbV1(state)
else downloadRestoreDb(state)
} }
database.use { db -> database.use { db ->
val out = outputFactory.getBackupDataOutput(data) val out = outputFactory.getBackupDataOutput(data)
@ -150,17 +173,37 @@ internal class KVRestore(
return if (dbManager.existsDb(packageName)) { return if (dbManager.existsDb(packageName)) {
dbManager.getDb(packageName) dbManager.getDb(packageName)
} else { } else {
downloadRestoreDb(state) if (state.version == 1.toByte()) downloadRestoreDbV1(state)
else downloadRestoreDb(state)
} }
} }
@Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class) @Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class)
private suspend fun downloadRestoreDb(state: KVRestoreState): KVDb { private suspend fun downloadRestoreDb(state: KVRestoreState): KVDb {
val packageName = state.packageInfo.packageName 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 -> backend.load(handle).use { inputStream ->
headerReader.readVersion(inputStream, state.version) headerReader.readVersion(inputStream, state.version)
val ad = getADForKV(VERSION, packageName) val ad = getADForKV(state.version, packageName)
crypto.newDecryptingStreamV1(inputStream, ad).use { decryptedStream -> crypto.newDecryptingStreamV1(inputStream, ad).use { decryptedStream ->
GZIPInputStream(decryptedStream).use { gzipStream -> GZIPInputStream(decryptedStream).use { gzipStream ->
dbManager.getDbOutputStream(packageName).use { outputStream -> dbManager.getDbOutputStream(packageName).use { outputStream ->
@ -182,7 +225,8 @@ internal class KVRestore(
// We return the data in lexical order sorted by key, // We return the data in lexical order sorted by key,
// so that apps which use synthetic keys like BLOB_1, BLOB_2, etc // so that apps which use synthetic keys like BLOB_1, BLOB_2, etc
// will see the date in the most obvious order. // 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) { if (sortedKeys == null) {
// nextRestorePackage() ensures the dir exists, so this is an error // nextRestorePackage() ensures the dir exists, so this is an error
Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}") Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}")
@ -245,7 +289,7 @@ internal class KVRestore(
state: KVRestoreState, state: KVRestoreState,
dKey: DecodedKey, dKey: DecodedKey,
out: BackupDataOutput, out: BackupDataOutput,
) = legacyPlugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key) ) = legacyPlugin.getInputStreamForRecord(state.token!!, state.packageInfo, dKey.base64Key)
.use { inputStream -> .use { inputStream ->
val version = headerReader.readVersion(inputStream, state.version) val version = headerReader.readVersion(inputStream, state.version)
val packageName = state.packageInfo.packageName val packageName = state.packageInfo.packageName

View file

@ -78,6 +78,7 @@ internal class RestoreCoordinator(
private val failedPackages = ArrayList<String>() private val failedPackages = ArrayList<String>()
suspend fun getAvailableBackups(): RestorableBackupResult { suspend fun getAvailableBackups(): RestorableBackupResult {
Log.i(TAG, "getAvailableBackups")
val fileHandles = try { val fileHandles = try {
backend.getAvailableBackupFileHandles() backend.getAvailableBackupFileHandles()
} catch (e: Exception) { } catch (e: Exception) {
@ -135,6 +136,7 @@ internal class RestoreCoordinator(
* or null if an error occurred (the attempt should be rescheduled). * or null if an error occurred (the attempt should be rescheduled).
**/ **/
suspend fun getAvailableRestoreSets(): Array<RestoreSet>? { suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
Log.d(TAG, "getAvailableRestoreSets")
val result = getAvailableBackups() as? RestorableBackupResult.SuccessResult ?: return null val result = getAvailableBackups() as? RestorableBackupResult.SuccessResult ?: return null
val backups = result.backups val backups = result.backups
return backups.map { backup -> 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. * or 0 if there is no backup set available corresponding to the current device state.
*/ */
fun getCurrentRestoreSet(): Long { fun getCurrentRestoreSet(): Long {
Log.d(TAG, "getCurrentRestoreSet() = ") // TODO where to store current token?
return (settingsManager.getToken() ?: 0L).apply { return (settingsManager.getToken() ?: 0L).apply {
Log.i(TAG, "Got current restore set token: $this") Log.i(TAG, "Got current restore set token: $this")
} }
@ -191,10 +194,10 @@ internal class RestoreCoordinator(
*/ */
suspend fun startRestore(token: Long, packages: Array<out PackageInfo>): Int { suspend fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
check(state == null) { "Started new restore with existing state: $state" } 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 // 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) { if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
val pmPackageName = packages[1].packageName val pmPackageName = packages[1].packageName
Log.d(TAG, "Optimize for single package restore of $pmPackageName") Log.d(TAG, "Optimize for single package restore of $pmPackageName")
@ -218,11 +221,27 @@ internal class RestoreCoordinator(
val backup = if (restorableBackup?.token == token) { val backup = if (restorableBackup?.token == token) {
restorableBackup!! // if token matches, backupMetadata is non-null restorableBackup!! // if token matches, backupMetadata is non-null
} else { } else {
if (autoRestorePackageInfo == null) { // no auto-restore
Log.e(TAG, "No cached backups, loading all and look for $token")
val backup = getAvailableBackups() as? RestorableBackupResult.SuccessResult val backup = getAvailableBackups() as? RestorableBackupResult.SuccessResult
?: return TRANSPORT_ERROR ?: return TRANSPORT_ERROR
backup.backups.find { it.token == token } ?: 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 restorableBackup = null
failedPackages.clear() failedPackages.clear()
return TRANSPORT_OK return TRANSPORT_OK
@ -269,22 +288,29 @@ internal class RestoreCoordinator(
val snapshot = state.backup.snapshot ?: error("No snapshot in v2 backup") val snapshot = state.backup.snapshot ?: error("No snapshot in v2 backup")
val type = when (state.backup.packageMetadataMap[packageName]?.backupType) { val type = when (state.backup.packageMetadataMap[packageName]?.backupType) {
BackupType.KV -> { 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( kv.initializeState(
version = version, version = version,
token = state.token,
name = name,
packageInfo = packageInfo, packageInfo = packageInfo,
autoRestorePackageInfo = state.autoRestorePackageInfo blobHandles = blobHandles,
autoRestorePackageInfo = state.autoRestorePackageInfo,
) )
state.currentPackage = packageName state.currentPackage = packageName
TYPE_KEY_VALUE TYPE_KEY_VALUE
} }
BackupType.FULL -> { BackupType.FULL -> {
val blobHandles = try {
val chunkIds = state.backup.packageMetadataMap[packageName]?.chunkIds val chunkIds = state.backup.packageMetadataMap[packageName]?.chunkIds
?: error("no metadata or chunkIds") ?: error("no metadata or chunkIds")
val blobHandles = try {
snapshot.getBlobHandles(repoId, chunkIds) snapshot.getBlobHandles(repoId, chunkIds)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error getting blob handles: ", e) Log.e(TAG, "Error getting blob handles: ", e)
@ -296,7 +322,6 @@ internal class RestoreCoordinator(
state.currentPackage = packageName state.currentPackage = packageName
TYPE_FULL_STREAM TYPE_FULL_STREAM
} }
null -> { null -> {
Log.i(TAG, "No backup type found for $packageName. Skipping...") Log.i(TAG, "No backup type found for $packageName. Skipping...")
state.backup.packageMetadataMap[packageName]?.backupType?.let { s -> state.backup.packageMetadataMap[packageName]?.backupType?.let { s ->
@ -318,25 +343,21 @@ internal class RestoreCoordinator(
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
val type = when (state.backup.packageMetadataMap[packageName]?.backupType) { val type = when (state.backup.packageMetadataMap[packageName]?.backupType) {
BackupType.KV -> { BackupType.KV -> {
val name = crypto.getNameForPackage(state.backup.salt, packageName) kv.initializeStateV1(
kv.initializeState(
version = 1,
token = state.token, token = state.token,
name = name, name = crypto.getNameForPackage(state.backup.salt, packageName),
packageInfo = packageInfo, packageInfo = packageInfo,
autoRestorePackageInfo = state.autoRestorePackageInfo autoRestorePackageInfo = state.autoRestorePackageInfo,
) )
state.currentPackage = packageName state.currentPackage = packageName
TYPE_KEY_VALUE TYPE_KEY_VALUE
} }
BackupType.FULL -> { BackupType.FULL -> {
val name = crypto.getNameForPackage(state.backup.salt, packageName) val name = crypto.getNameForPackage(state.backup.salt, packageName)
full.initializeStateV1(state.token, name, packageInfo) full.initializeStateV1(state.token, name, packageInfo)
state.currentPackage = packageName state.currentPackage = packageName
TYPE_FULL_STREAM TYPE_FULL_STREAM
} }
null -> { null -> {
Log.i(TAG, "No backup type found for $packageName. Skipping...") Log.i(TAG, "No backup type found for $packageName. Skipping...")
state.backup.packageMetadataMap[packageName]?.backupType?.let { s -> 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 // check key/value data first and if available, don't even check for full data
kv.hasDataForPackage(state.token, packageInfo) -> { kv.hasDataForPackage(state.token, packageInfo) -> {
Log.i(TAG, "Found K/V data for $packageName.") Log.i(TAG, "Found K/V data for $packageName.")
kv.initializeState(0x00, state.token, "", packageInfo, null) kv.initializeStateV0(state.token, packageInfo)
state.currentPackage = packageName state.currentPackage = packageName
TYPE_KEY_VALUE TYPE_KEY_VALUE
} }
full.hasDataForPackage(state.token, packageInfo) -> { full.hasDataForPackage(state.token, packageInfo) -> {
Log.i(TAG, "Found full backup data for $packageName.") Log.i(TAG, "Found full backup data for $packageName.")
full.initializeStateV0(state.token, packageInfo) full.initializeStateV0(state.token, packageInfo)
state.currentPackage = packageName state.currentPackage = packageName
TYPE_FULL_STREAM TYPE_FULL_STREAM
} }
else -> { else -> {
Log.i(TAG, "No data found for $packageName. Skipping.") Log.i(TAG, "No data found for $packageName. Skipping.")
return nextRestorePackage() return nextRestorePackage()
@ -396,6 +415,7 @@ internal class RestoreCoordinator(
* @return the same error codes as [startRestore]. * @return the same error codes as [startRestore].
*/ */
suspend fun getRestoreData(data: ParcelFileDescriptor): Int { suspend fun getRestoreData(data: ParcelFileDescriptor): Int {
Log.d(TAG, "getRestoreData()")
return kv.getRestoreData(data).apply { return kv.getRestoreData(data).apply {
if (this != TRANSPORT_OK) { if (this != TRANSPORT_OK) {
// add current package to failed ones // add current package to failed ones

View file

@ -11,7 +11,7 @@ import org.koin.dsl.module
val restoreModule = module { val restoreModule = module {
single { OutputFactory() } single { OutputFactory() }
single { Loader(get(), get()) } 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 { FullRestore(get(), get(), get(), get(), get(), get()) }
single { single {
RestoreCoordinator( RestoreCoordinator(

View file

@ -192,7 +192,7 @@ internal class ApkRestoreTest : TransportTest() {
every { backupStateManager.isAutoRestoreEnabled } returns false every { backupStateManager.isAutoRestoreEnabled } returns false
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException() every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
every { strictContext.cacheDir } returns File(tmpDir.toString()) 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<Int>()) } returns packageInfo every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
every { backend.providerPackageName } returns storageProviderPackageName every { backend.providerPackageName } returns storageProviderPackageName
@ -649,7 +649,7 @@ internal class ApkRestoreTest : TransportTest() {
private fun cacheBaseApkAndGetInfo(tmpDir: Path) { private fun cacheBaseApkAndGetInfo(tmpDir: Path) {
every { strictContext.cacheDir } returns File(tmpDir.toString()) 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<Int>()) } returns packageInfo every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
every { applicationInfo.loadIcon(pm) } returns icon every { applicationInfo.loadIcon(pm) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName

View file

@ -48,13 +48,13 @@ import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.Backend 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.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream
import kotlin.random.Random import kotlin.random.Random
internal class CoordinatorIntegrationTest : TransportTest() { internal class CoordinatorIntegrationTest : TransportTest() {
@ -78,11 +78,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val loader = mockk<Loader>() private val loader = mockk<Loader>()
private val backupReceiver = mockk<BackupReceiver>() private val backupReceiver = mockk<BackupReceiver>()
private val kvBackup = KVBackup( private val kvBackup = KVBackup(
backendManager = backendManager,
settingsManager = settingsManager, settingsManager = settingsManager,
nm = notificationManager, backupReceiver = backupReceiver,
inputFactory = inputFactory, inputFactory = inputFactory,
crypto = cryptoImpl,
dbManager = dbManager, dbManager = dbManager,
) )
private val fullBackup = FullBackup( private val fullBackup = FullBackup(
@ -107,6 +105,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val kvRestore = KVRestore( private val kvRestore = KVRestore(
backendManager, backendManager,
loader,
legacyPlugin, legacyPlugin,
outputFactory, outputFactory,
headerReader, headerReader,
@ -133,13 +132,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true) private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
private val appData = ByteArray(42).apply { Random.nextBytes(this) } private val appData = ByteArray(42).apply { Random.nextBytes(this) }
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) } private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
private val metadataOutputStream = ByteArrayOutputStream()
private val key = "RestoreKey" private val key = "RestoreKey"
private val key2 = "RestoreKey2" private val key2 = "RestoreKey2"
// as we use real crypto, we need a real name for packageInfo
private val realName = cryptoImpl.getNameForPackage(salt, packageName)
init { init {
every { backendManager.backend } returns backend every { backendManager.backend } returns backend
every { appBackupManager.snapshotCreator } returns snapshotCreator every { appBackupManager.snapshotCreator } returns snapshotCreator
@ -149,11 +144,11 @@ internal class CoordinatorIntegrationTest : TransportTest() {
fun `test key-value backup and restore with 2 records`() = runBlocking { fun `test key-value backup and restore with 2 records`() = runBlocking {
val value = CapturingSlot<ByteArray>() val value = CapturingSlot<ByteArray>()
val value2 = CapturingSlot<ByteArray>() val value2 = CapturingSlot<ByteArray>()
val inputStream = CapturingSlot<InputStream>()
val bOutputStream = ByteArrayOutputStream() val bOutputStream = ByteArrayOutputStream()
every { metadataManager.requiresInit } returns false every { metadataManager.requiresInit } returns false
every { settingsManager.getToken() } returns token every { backupReceiver.assertFinalized() } just Runs
every { metadataManager.salt } returns salt
// read one key/value record and write it to output stream // read one key/value record and write it to output stream
every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen true andThen false 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.copyInto(value2.captured) // write the app data into the passed ByteArray
appData2.size 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 // start K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
// upload DB // upload DB
coEvery { coEvery { backupReceiver.readFromStream(capture(inputStream)) } answers {
backend.save(LegacyAppBackupFile.Blob(token, realName)) inputStream.captured.copyTo(bOutputStream)
} returns 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 // finish K/V backup
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
@ -190,9 +185,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
restore.beforeStartRestore(restorableBackup) restore.beforeStartRestore(restorableBackup)
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo))) 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() val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName) assertEquals(packageInfo.packageName, restoreDescription.packageName)
assertEquals(RestoreDescription.TYPE_KEY_VALUE, restoreDescription.dataType) 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 // restore finds the backed up key and writes the decrypted value
val backupDataOutput = mockk<BackupDataOutput>() val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
coEvery { coEvery { loader.loadFiles(listOf(blobHandle1)) } returns rInputStream
backend.load(LegacyAppBackupFile.Blob(token, name))
} returns rInputStream
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137 every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137
every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size
@ -222,13 +212,13 @@ internal class CoordinatorIntegrationTest : TransportTest() {
@Test @Test
fun `test key-value backup with huge value`() = runBlocking { fun `test key-value backup with huge value`() = runBlocking {
val value = CapturingSlot<ByteArray>() val value = CapturingSlot<ByteArray>()
val inputStream = CapturingSlot<InputStream>()
val size = Random.nextInt(5) * MAX_SEGMENT_CLEARTEXT_LENGTH + Random.nextInt(0, 1337) val size = Random.nextInt(5) * MAX_SEGMENT_CLEARTEXT_LENGTH + Random.nextInt(0, 1337)
val appData = ByteArray(size).apply { Random.nextBytes(this) } val appData = ByteArray(size).apply { Random.nextBytes(this) }
val bOutputStream = ByteArrayOutputStream() val bOutputStream = ByteArrayOutputStream()
every { metadataManager.requiresInit } returns false every { metadataManager.requiresInit } returns false
every { settingsManager.getToken() } returns token every { backupReceiver.assertFinalized() } just Runs
every { metadataManager.salt } returns salt
// read one key/value record and write it to output stream // read one key/value record and write it to output stream
every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen false 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.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size 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 // start K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
// upload DB // upload DB
coEvery { coEvery { backupReceiver.readFromStream(capture(inputStream)) } answers {
backend.save(LegacyAppBackupFile.Blob(token, realName)) inputStream.captured.copyTo(bOutputStream)
} returns 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 // finish K/V backup
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
@ -265,9 +251,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
restore.beforeStartRestore(restorableBackup) restore.beforeStartRestore(restorableBackup)
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo))) 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() val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName) assertEquals(packageInfo.packageName, restoreDescription.packageName)
assertEquals(RestoreDescription.TYPE_KEY_VALUE, restoreDescription.dataType) 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 // restore finds the backed up key and writes the decrypted value
val backupDataOutput = mockk<BackupDataOutput>() val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
coEvery { coEvery { loader.loadFiles(listOf(blobHandle1)) } returns rInputStream
backend.load(LegacyAppBackupFile.Blob(token, name))
} returns rInputStream
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137 every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137
every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size 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 { fun `test full backup and restore with two chunks`() = runBlocking {
metadata.packageMetadataMap[packageName] = PackageMetadata( metadata.packageMetadataMap[packageName] = PackageMetadata(
backupType = BackupType.FULL, backupType = BackupType.FULL,
chunkIds = listOf(apkChunkId), chunkIds = listOf(chunkId1),
) )
// package is of type FULL // package is of type FULL
@ -342,7 +323,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
// reverse the backup streams into restore input // reverse the backup streams into restore input
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
val rOutputStream = ByteArrayOutputStream() val rOutputStream = ByteArrayOutputStream()
coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns rInputStream coEvery { loader.loadFiles(listOf(blobHandle1)) } returns rInputStream
every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream
// restore data // restore data

View file

@ -69,21 +69,7 @@ internal abstract class TransportTest {
protected val pmPackageInfo = PackageInfo().apply { protected val pmPackageInfo = PackageInfo().apply {
packageName = MAGIC_PACKAGE_MANAGER 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 name = getRandomString(12)
protected val name2 = getRandomString(23) protected val name2 = getRandomString(23)
protected val storageProviderPackageName = getRandomString(23) protected val storageProviderPackageName = getRandomString(23)
@ -92,26 +78,27 @@ internal abstract class TransportTest {
protected val repoId = Random.nextBytes(32).toHexString() protected val repoId = Random.nextBytes(32).toHexString()
protected val splitName = getRandomString() protected val splitName = getRandomString()
protected val splitBytes = byteArrayOf(0x07, 0x08, 0x09) protected val splitBytes = byteArrayOf(0x07, 0x08, 0x09)
protected val apkChunkId = Random.nextBytes(32).toHexString() protected val chunkId1 = Random.nextBytes(32).toHexString()
protected val splitChunkId = Random.nextBytes(32).toHexString() protected val chunkId2 = Random.nextBytes(32).toHexString()
protected val apkBlob = blob { protected val apkBlob = blob {
id = ByteString.copyFrom(Random.nextBytes(32)) id = ByteString.copyFrom(Random.nextBytes(32))
} }
protected val splitBlob = blob { protected val splitBlob = blob {
id = ByteString.copyFrom(Random.nextBytes(32)) id = ByteString.copyFrom(Random.nextBytes(32))
} }
protected val apkBlobHandle = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto()) protected val blobHandle1 = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto())
protected val apkBackupData = BackupData(listOf(apkChunkId), mapOf(apkChunkId to apkBlob)) protected val blobHandle2 = AppBackupFileType.Blob(repoId, splitBlob.id.hexFromProto())
protected val apkBackupData = BackupData(listOf(chunkId1), mapOf(chunkId1 to apkBlob))
protected val splitBackupData = 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 chunkMap = apkBackupData.chunkMap + splitBackupData.chunkMap
protected val baseSplit = split { protected val baseSplit = split {
name = BASE_SPLIT name = BASE_SPLIT
chunkIds.add(ByteString.fromHex(apkChunkId)) chunkIds.add(ByteString.fromHex(chunkId1))
} }
protected val apkSplit = split { protected val apkSplit = split {
name = splitName name = splitName
chunkIds.add(ByteString.fromHex(splitChunkId)) chunkIds.add(ByteString.fromHex(chunkId2))
} }
protected val apk = SnapshotKt.apk { protected val apk = SnapshotKt.apk {
versionCode = packageInfo.longVersionCode - 1 versionCode = packageInfo.longVersionCode - 1
@ -128,6 +115,23 @@ internal abstract class TransportTest {
apps[packageName] = app apps[packageName] = app
blobs.putAll(chunkMap) 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 { init {
mockkStatic(Log::class) mockkStatic(Log::class)

View file

@ -21,6 +21,7 @@ import com.stevesoltys.seedvault.metadata.BackupType
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED 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.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.worker.ApkBackup import com.stevesoltys.seedvault.worker.ApkBackup
import io.mockk.Runs import io.mockk.Runs
@ -81,7 +82,7 @@ internal class BackupCoordinatorTest : BackupTest() {
@Test @Test
fun `device initialization succeeds and delegates to plugin`() = runBlocking { fun `device initialization succeeds and delegates to plugin`() = runBlocking {
expectStartNewRestoreSet() expectStartNewRestoreSet()
every { kv.hasState() } returns false every { kv.hasState } returns false
every { full.hasState } returns false every { full.hasState } returns false
assertEquals(TRANSPORT_OK, backup.initializeDevice()) assertEquals(TRANSPORT_OK, backup.initializeDevice())
@ -108,7 +109,7 @@ internal class BackupCoordinatorTest : BackupTest() {
assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
// finish will only be called when TRANSPORT_OK is returned, so it should throw // 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 every { full.hasState } returns false
coAssertThrows(IllegalStateException::class.java) { coAssertThrows(IllegalStateException::class.java) {
backup.finishBackup() backup.finishBackup()
@ -127,7 +128,7 @@ internal class BackupCoordinatorTest : BackupTest() {
assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
// finish will only be called when TRANSPORT_OK is returned, so it should throw // 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 every { full.hasState } returns false
coAssertThrows(IllegalStateException::class.java) { coAssertThrows(IllegalStateException::class.java) {
backup.finishBackup() backup.finishBackup()
@ -163,51 +164,61 @@ internal class BackupCoordinatorTest : BackupTest() {
if (isFullBackup) { if (isFullBackup) {
every { full.quota } returns quota every { full.quota } returns quota
} else { } else {
every { kv.getQuota() } returns quota every { kv.quota } returns quota
} }
assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup)) assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup))
} }
@Test @Test
fun `clearing KV backup data throws`() = runBlocking { fun `clearing backup data does nothing`() = runBlocking {
every { settingsManager.getToken() } returns token assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo))
every { metadataManager.salt } returns salt
coEvery { kv.clearBackupData(packageInfo, token, salt) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo)) every { kv.hasState } returns false
every { full.hasState } returns false
assertEquals(TRANSPORT_OK, backup.finishBackup())
} }
@Test @Test
fun `finish backup delegates to KV plugin if it has state`() = runBlocking { 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 { full.hasState } returns false
every { kv.getCurrentPackage() } returns packageInfo every { kv.currentPackageInfo } returns packageInfo
coEvery { kv.finishBackup() } returns TRANSPORT_OK coEvery { kv.finishBackup() } returns apkBackupData
every { kv.getCurrentSize() } returns size every { appBackupManager.snapshotCreator } returns snapshotCreator
every { every {
metadataManager.onPackageBackedUp( snapshotCreator.onPackageBackedUp(packageInfo, BackupType.KV, apkBackupData)
packageInfo = packageInfo, } just Runs
type = BackupType.KV, every {
size = size, metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, apkBackupData.size)
)
} just Runs } just Runs
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
} }
@Test @Test
fun `finish backup does not upload @pm@ metadata, if it can't do backups`() = runBlocking { fun `finish KV backup throws exception`() = runBlocking {
every { kv.hasState() } returns true every { kv.hasState } returns true
every { full.hasState } returns false every { full.hasState } returns false
every { kv.getCurrentPackage() } returns pmPackageInfo every { kv.currentPackageInfo } returns packageInfo
every { kv.getCurrentSize() } returns 42L coEvery { kv.finishBackup() } throws IOException()
coEvery { kv.finishBackup() } returns TRANSPORT_OK every { settingsManager.getToken() } returns token
every { backendManager.canDoBackupNow() } returns false 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 @Test
@ -215,7 +226,7 @@ internal class BackupCoordinatorTest : BackupTest() {
val snapshotCreator: SnapshotCreator = mockk() val snapshotCreator: SnapshotCreator = mockk()
val size: Long = 2345 val size: Long = 2345
every { kv.hasState() } returns false every { kv.hasState } returns false
every { full.hasState } returns true every { full.hasState } returns true
every { full.currentPackageInfo } returns packageInfo every { full.currentPackageInfo } returns packageInfo
coEvery { full.finishBackup() } returns apkBackupData coEvery { full.finishBackup() } returns apkBackupData
@ -236,8 +247,6 @@ internal class BackupCoordinatorTest : BackupTest() {
@Test @Test
fun `metadata does not get updated when no APK was backed up`() = runBlocking { fun `metadata does not get updated when no APK was backed up`() = runBlocking {
every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt
coEvery { coEvery {
full.performFullBackup(packageInfo, fileDescriptor, 0) full.performFullBackup(packageInfo, fileDescriptor, 0)
} returns TRANSPORT_OK } returns TRANSPORT_OK
@ -248,8 +257,6 @@ internal class BackupCoordinatorTest : BackupTest() {
@Test @Test
fun `app exceeding quota gets cancelled and reason written to metadata`() = runBlocking { fun `app exceeding quota gets cancelled and reason written to metadata`() = runBlocking {
every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt
coEvery { coEvery {
full.performFullBackup(packageInfo, fileDescriptor, 0) full.performFullBackup(packageInfo, fileDescriptor, 0)
} returns TRANSPORT_OK } returns TRANSPORT_OK
@ -300,8 +307,6 @@ internal class BackupCoordinatorTest : BackupTest() {
@Test @Test
fun `app with no data gets cancelled and reason written to metadata`() = runBlocking { fun `app with no data gets cancelled and reason written to metadata`() = runBlocking {
every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt
coEvery { coEvery {
full.performFullBackup(packageInfo, fileDescriptor, 0) full.performFullBackup(packageInfo, fileDescriptor, 0)
} returns TRANSPORT_OK } returns TRANSPORT_OK

View file

@ -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_NON_INCREMENTAL_BACKUP_REQUIRED
import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE 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.CapturingSlot
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.Backend
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.IOException import java.io.IOException
import kotlin.random.Random import kotlin.random.Random
internal class KVBackupTest : BackupTest() { internal class KVBackupTest : BackupTest() {
private val backendManager = mockk<BackendManager>() private val backupReceiver = mockk<BackupReceiver>()
private val notificationManager = mockk<BackupNotificationManager>()
private val dataInput = mockk<BackupDataInput>() private val dataInput = mockk<BackupDataInput>()
private val dbManager = mockk<KvDbManager>() private val dbManager = mockk<KvDbManager>()
private val backup = KVBackup( private val backup = KVBackup(
backendManager = backendManager,
settingsManager = settingsManager, settingsManager = settingsManager,
nm = notificationManager, backupReceiver = backupReceiver,
inputFactory = inputFactory, inputFactory = inputFactory,
crypto = crypto, dbManager = dbManager,
dbManager = dbManager
) )
private val db = mockk<KVDb>() private val db = mockk<KVDb>()
private val backend = mockk<Backend>()
private val key = getRandomString(MAX_KEY_LENGTH_SIZE) private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
private val dataValue = Random.nextBytes(23) private val dataValue = Random.nextBytes(23)
private val dbBytes = Random.nextBytes(42) private val dbBytes = Random.nextBytes(42)
private val inputStream = ByteArrayInputStream(dbBytes) private val inputStream = ByteArrayInputStream(dbBytes)
init {
every { backendManager.backend } returns backend
}
@Test @Test
fun `has no initial state`() { fun `has no initial state`() {
assertFalse(backup.hasState()) assertFalse(backup.hasState)
} }
@Test @Test
fun `simple backup with one record`() = runBlocking { fun `simple backup with one record`() = runBlocking {
singleRecordBackup() singleRecordBackup()
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt)) every { data.close() } just Runs
assertTrue(backup.hasState())
assertEquals(packageInfo, backup.getCurrentPackage()) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertTrue(backup.hasState)
assertFalse(backup.hasState()) assertEquals(packageInfo, backup.currentPackageInfo)
assertEquals(apkBackupData, backup.finishBackup())
assertFalse(backup.hasState)
verify { data.close() }
} }
@Test @Test
fun `incremental backup with no data gets rejected`() = runBlocking { fun `incremental backup with no data gets rejected`() = runBlocking {
initPlugin(false) initPlugin(false)
every { data.close() } just Runs
every { db.close() } just Runs every { db.close() } just Runs
assertEquals( assertEquals(
TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, 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 @Test
fun `non-incremental backup with data clears old data first`() = runBlocking { 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 every { dbManager.deleteDb(packageName) } returns true
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 `ignoring exception when clearing data when non-incremental backup has data`() =
runBlocking {
singleRecordBackup(true) singleRecordBackup(true)
coEvery { backend.remove(handle) } throws IOException() every { data.close() } just Runs
assertEquals( assertEquals(
TRANSPORT_OK, TRANSPORT_OK,
backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL, token, salt) backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)
) )
assertTrue(backup.hasState()) assertTrue(backup.hasState)
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState()) assertEquals(apkBackupData, backup.finishBackup())
assertFalse(backup.hasState)
verify { data.close() }
} }
@Test @Test
fun `package with no new data comes back ok right away`() = runBlocking { fun `package with no new data comes back ok right away (if we have data)`() = runBlocking {
every { crypto.getNameForPackage(salt, packageName) } returns name every { backupReceiver.assertFinalized() } just Runs
every { dbManager.existsDb(packageName) } returns true
every { dbManager.getDb(packageName) } returns db every { dbManager.getDb(packageName) } returns db
every { data.close() } just Runs every { data.close() } just Runs
assertEquals( assertEquals(
TRANSPORT_OK, 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() } verify { data.close() }
every { db.close() } just Runs }
assertEquals(TRANSPORT_OK, backup.finishBackup()) @Test
assertFalse(backup.hasState()) 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 @Test
@ -147,9 +152,15 @@ internal class KVBackupTest : BackupTest() {
createBackupDataInput() createBackupDataInput()
every { dataInput.readNextHeader() } throws IOException() every { dataInput.readNextHeader() } throws IOException()
every { db.close() } just Runs every { db.close() } just Runs
every { data.close() } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0, token, salt)) assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState()) assertFalse(backup.hasState)
verify {
db.close()
data.close()
}
} }
@Test @Test
@ -161,23 +172,35 @@ internal class KVBackupTest : BackupTest() {
every { dataInput.dataSize } returns dataValue.size every { dataInput.dataSize } returns dataValue.size
every { dataInput.readEntityData(any(), 0, dataValue.size) } throws IOException() every { dataInput.readEntityData(any(), 0, dataValue.size) } throws IOException()
every { db.close() } just Runs every { db.close() } just Runs
every { data.close() } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0, token, salt)) assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState()) assertFalse(backup.hasState)
verify { data.close() }
} }
@Test @Test
fun `no data records`() = runBlocking { fun `no data records`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(false)) getDataInput(listOf(false))
every { data.close() } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
assertTrue(backup.hasState()) assertTrue(backup.hasState)
every { db.close() } just Runs every { db.close() } just Runs
assertEquals(TRANSPORT_OK, backup.finishBackup()) // if there's no data, the system wouldn't call us, so no special handling here
assertFalse(backup.hasState()) uploadData()
assertEquals(apkBackupData, backup.finishBackup())
assertFalse(backup.hasState)
verify {
db.close()
data.close()
}
} }
@Test @Test
@ -188,82 +211,69 @@ internal class KVBackupTest : BackupTest() {
every { dataInput.key } returns key every { dataInput.key } returns key
every { dataInput.dataSize } returns -1 // just documented by example code in LocalTransport every { dataInput.dataSize } returns -1 // just documented by example code in LocalTransport
every { db.delete(key) } just Runs every { db.delete(key) } just Runs
every { data.close() } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
assertTrue(backup.hasState()) assertTrue(backup.hasState)
uploadData() uploadData()
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(apkBackupData, backup.finishBackup())
assertFalse(backup.hasState()) assertFalse(backup.hasState)
verify { data.close() }
} }
@Test @Test
fun `exception while writing version`() = runBlocking { fun `exception while finalizing`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(true, false)) getDataInput(listOf(true, false))
every { db.put(key, dataValue) } just Runs every { db.put(key, dataValue) } just Runs
every { data.close() } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
assertTrue(backup.hasState()) assertTrue(backup.hasState)
every { db.vacuum() } just Runs every { db.vacuum() } just Runs
every { db.close() } just Runs every { db.close() } just Runs
coEvery { backend.save(handle) } returns outputStream every { dbManager.getDbInputStream(packageName) } returns inputStream
every { outputStream.write(ByteArray(1) { VERSION }) } throws IOException() coEvery { backupReceiver.readFromStream(inputStream) } just Runs
every { outputStream.close() } just Runs coEvery { backupReceiver.finalize() } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.finishBackup())
assertFalse(backup.hasState())
verify { outputStream.close() } assertThrows<IOException> { // we let exceptions bubble up to coordinators
backup.finishBackup()
} }
assertFalse(backup.hasState)
@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<ByteArray>()) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.finishBackup())
assertFalse(backup.hasState())
verify { verify {
encryptedOutputStream.close() db.close()
outputStream.close() data.close()
} }
} }
@Test @Test
fun `no upload when we back up @pm@ while we can't do backups`() = runBlocking { fun `exception while uploading data`() = runBlocking {
every { dbManager.existsDb(pmPackageInfo.packageName) } returns false initPlugin(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
getDataInput(listOf(true, 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)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
assertTrue(backup.hasState()) assertTrue(backup.hasState)
assertEquals(pmPackageInfo, backup.getCurrentPackage())
every { db.vacuum() } just Runs
every { db.close() } just Runs every { db.close() } just Runs
every { dbManager.getDbInputStream(packageName) } returns inputStream
coEvery { backupReceiver.readFromStream(inputStream) } throws IOException()
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertThrows<IOException> { // we let exceptions bubble up to coordinators
assertFalse(backup.hasState()) backup.finishBackup()
}
assertFalse(backup.hasState)
coVerify(exactly = 0) { verify {
backend.save(handle) db.close()
data.close()
} }
} }
@ -275,8 +285,8 @@ internal class KVBackupTest : BackupTest() {
} }
private fun initPlugin(hasDataForPackage: Boolean = false, pi: PackageInfo = packageInfo) { private fun initPlugin(hasDataForPackage: Boolean = false, pi: PackageInfo = packageInfo) {
every { backupReceiver.assertFinalized() } just Runs
every { dbManager.existsDb(pi.packageName) } returns hasDataForPackage every { dbManager.existsDb(pi.packageName) } returns hasDataForPackage
every { crypto.getNameForPackage(salt, pi.packageName) } returns name
every { dbManager.getDb(pi.packageName) } returns db every { dbManager.getDb(pi.packageName) } returns db
} }
@ -299,16 +309,9 @@ internal class KVBackupTest : BackupTest() {
private fun uploadData() { private fun uploadData() {
every { db.vacuum() } just Runs every { db.vacuum() } just Runs
every { db.close() } 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<ByteArray>()) } just Runs // gzip header
every { encryptedOutputStream.write(any(), any(), any()) } just Runs // stream copy
every { dbManager.getDbInputStream(packageName) } returns inputStream every { dbManager.getDbInputStream(packageName) } returns inputStream
every { encryptedOutputStream.close() } just Runs coEvery { backupReceiver.readFromStream(inputStream) } just Runs
every { outputStream.close() } just Runs coEvery { backupReceiver.finalize() } returns apkBackupData
} }
} }

View file

@ -52,7 +52,7 @@ internal class FullRestoreTest : RestoreTest() {
private val encrypted = getRandomByteArray() private val encrypted = getRandomByteArray()
private val outputStream = ByteArrayOutputStream() private val outputStream = ByteArrayOutputStream()
private val blobHandles = listOf(apkBlobHandle) private val blobHandles = listOf(blobHandle1)
init { init {
every { backendManager.backend } returns backend every { backendManager.backend } returns backend

View file

@ -8,15 +8,14 @@ package com.stevesoltys.seedvault.transport.restore
import android.app.backup.BackupDataOutput import android.app.backup.BackupDataOutput
import android.app.backup.BackupTransport.TRANSPORT_ERROR import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.seedvault.coAssertThrows import android.content.pm.PackageInfo
import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.ANCESTRAL_RECORD_KEY
import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.GLOBAL_METADATA_KEY
import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
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 com.stevesoltys.seedvault.backend.BackendManager 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.KVDb
import com.stevesoltys.seedvault.transport.backup.KvDbManager import com.stevesoltys.seedvault.transport.backup.KvDbManager
import io.mockk.Runs import io.mockk.Runs
@ -24,59 +23,39 @@ import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify import io.mockk.verify
import io.mockk.verifyAll import io.mockk.verifyAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.Backend
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.zip.GZIPOutputStream
import kotlin.random.Random import kotlin.random.Random
internal class KVRestoreTest : RestoreTest() { internal class KVRestoreTest : RestoreTest() {
private val backendManager: BackendManager = mockk() private val backendManager: BackendManager = mockk()
private val backend = mockk<Backend>() private val loader = mockk<Loader>()
@Suppress("DEPRECATION")
private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val dbManager = mockk<KvDbManager>() private val dbManager = mockk<KvDbManager>()
private val output = mockk<BackupDataOutput>() private val output = mockk<BackupDataOutput>()
private val restore = KVRestore( private val restore = KVRestore(
backendManager = backendManager, backendManager = backendManager,
legacyPlugin = legacyPlugin, loader = loader,
legacyPlugin = mockk(),
outputFactory = outputFactory, outputFactory = outputFactory,
headerReader = headerReader, headerReader = mockk(),
crypto = crypto, crypto = mockk(),
dbManager = dbManager, dbManager = dbManager,
) )
private val db = mockk<KVDb>() private val db = mockk<KVDb>()
private val ad = getADForKV(VERSION, packageInfo.packageName) private val blobHandles = listOf(blobHandle1)
private val key = "Restore Key" private val key = "Restore Key"
private val key64 = key.encodeBase64()
private val key2 = "Restore Key2" private val key2 = "Restore Key2"
private val key264 = key2.encodeBase64()
private val data2 = getRandomByteArray() 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 @Test
fun `getRestoreData() throws without initializing state`() { fun `getRestoreData() throws without initializing state`() {
coAssertThrows(IllegalStateException::class.java) { coAssertThrows(IllegalStateException::class.java) {
@ -85,45 +64,27 @@ internal class KVRestoreTest : RestoreTest() {
} }
@Test @Test
fun `unexpected version aborts with error`() = runBlocking { fun `loader#loadFiles() throws`() = runBlocking {
restore.initializeState(VERSION, token, name, packageInfo) restore.initializeState(VERSION, packageInfo, blobHandles)
coEvery { backend.load(handle) } returns inputStream coEvery { loader.loadFiles(blobHandles) } throws GeneralSecurityException()
every {
headerReader.readVersion(inputStream, VERSION)
} throws UnsupportedVersionException(Byte.MAX_VALUE)
every { dbManager.deleteDb(packageInfo.packageName, true) } returns true every { dbManager.deleteDb(packageInfo.packageName, true) } returns true
streamsGetClosed() streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) 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 { verifyAll {
fileDescriptor.close()
dbManager.deleteDb(packageInfo.packageName, true) dbManager.deleteDb(packageInfo.packageName, true)
} }
} }
@Test @Test
fun `writeEntityHeader throws`() = runBlocking { fun `writeEntityHeader throws`() = runBlocking {
restore.initializeState(VERSION, token, name, packageInfo) restore.initializeState(VERSION, packageInfo, blobHandles)
coEvery { backend.load(handle) } returns inputStream coEvery { loader.loadFiles(blobHandles) } returns inputStream
every { headerReader.readVersion(inputStream, VERSION) } returns VERSION every { inputStream.read(any()) } returns -1 // the DB we'll mock below
every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptInputStream
every { every {
dbManager.getDbOutputStream(packageInfo.packageName) dbManager.getDbOutputStream(packageInfo.packageName)
} returns ByteArrayOutputStream() } returns ByteArrayOutputStream()
@ -144,11 +105,10 @@ internal class KVRestoreTest : RestoreTest() {
@Test @Test
fun `two records get restored`() = runBlocking { fun `two records get restored`() = runBlocking {
restore.initializeState(VERSION, token, name, packageInfo) restore.initializeState(VERSION, packageInfo, blobHandles)
coEvery { backend.load(handle) } returns inputStream coEvery { loader.loadFiles(blobHandles) } returns inputStream
every { headerReader.readVersion(inputStream, VERSION) } returns VERSION every { inputStream.read(any()) } returns -1 // the DB we'll mock below
every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptInputStream
every { every {
dbManager.getDbOutputStream(packageInfo.packageName) dbManager.getDbOutputStream(packageInfo.packageName)
} returns ByteArrayOutputStream() } returns ByteArrayOutputStream()
@ -180,226 +140,43 @@ internal class KVRestoreTest : RestoreTest() {
} }
} }
//
// v0 legacy tests below
//
@Test @Test
@Suppress("Deprecation") fun `auto restore uses cached DB`() = runBlocking {
fun `v0 hasDataForPackage() delegates to plugin`() = runBlocking { val pmPackageInfo = PackageInfo().apply {
val result = Random.nextBoolean() packageName = MAGIC_PACKAGE_MANAGER
coEvery { legacyPlugin.hasDataForPackage(token, packageInfo) } returns result
assertEquals(result, restore.hasDataForPackage(token, packageInfo))
} }
restore.initializeState(2, pmPackageInfo, blobHandles, packageInfo)
@Test every { dbManager.existsDb(MAGIC_PACKAGE_MANAGER) } returns true
fun `v0 listing records throws`() = runBlocking { every { dbManager.getDb(MAGIC_PACKAGE_MANAGER) } returns db
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<InputStream>()
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<String> = listOf(key64)) {
coEvery { legacyPlugin.listRecords(token, packageInfo) } returns recordKeys
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output 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() { private fun streamsGetClosed() {
@ -408,7 +185,7 @@ internal class KVRestoreTest : RestoreTest() {
} }
private fun verifyStreamWasClosed() { private fun verifyStreamWasClosed() {
verifyAll { verify {
inputStream.close() inputStream.close()
fileDescriptor.close() fileDescriptor.close()
} }

View file

@ -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<Loader>()
private val backend = mockk<Backend>()
@Suppress("DEPRECATION")
private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val dbManager = mockk<KvDbManager>()
private val output = mockk<BackupDataOutput>()
private val restore = KVRestore(
backendManager = backendManager,
loader = loader,
legacyPlugin = legacyPlugin,
outputFactory = outputFactory,
headerReader = headerReader,
crypto = crypto,
dbManager = dbManager,
)
private val db = mockk<KVDb>()
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<InputStream>()
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<String> = 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()
}
}
}

View file

@ -15,15 +15,18 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.coAssertThrows import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.BackupType
import com.stevesoltys.seedvault.metadata.MetadataReader import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.proto.copy
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk 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.FileInfo
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import org.calyxos.seedvault.core.backends.saf.SafProperties 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.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import kotlin.random.Random import kotlin.random.Random
@ -82,7 +88,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
init { init {
metadata.packageMetadataMap[packageInfo2.packageName] = PackageMetadata( metadata.packageMetadataMap[packageInfo2.packageName] = PackageMetadata(
backupType = BackupType.FULL, backupType = BackupType.FULL,
chunkIds = listOf(apkChunkId), chunkIds = listOf(chunkId2),
) )
mockkStatic("com.stevesoltys.seedvault.backend.BackendExtKt") 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 @Test
fun `startRestore() with removed storage shows no notification`() = runBlocking { fun `startRestore() with removed storage shows no notification`() = runBlocking {
every { backendManager.backendProperties } returns safStorage every { backendManager.backendProperties } returns safStorage
@ -274,12 +336,12 @@ internal class RestoreCoordinatorTest : TransportTest() {
} }
@Test @Test
fun `nextRestorePackage() returns KV description`() = runBlocking { fun `nextRestorePackageV1() returns KV description`() = runBlocking {
restore.beforeStartRestore(restorableBackup) restore.beforeStartRestore(restorableBackup.copy(metadata.copy(version = 1)))
restore.startRestore(token, packageInfoArray) restore.startRestore(token, packageInfoArray)
every { crypto.getNameForPackage(metadata.salt, packageName) } returns name 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) val expected = RestoreDescription(packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage()) assertEquals(expected, restore.nextRestorePackage())
@ -293,7 +355,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
restore.startRestore(token, packageInfoArray) restore.startRestore(token, packageInfoArray)
coEvery { kv.hasDataForPackage(token, packageInfo) } returns true 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) val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage()) assertEquals(expected, restore.nextRestorePackage())
@ -321,7 +383,23 @@ internal class RestoreCoordinatorTest : TransportTest() {
restore.beforeStartRestore(restorableBackup) restore.beforeStartRestore(restorableBackup)
restore.startRestore(token, packageInfoArray2) 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) val expected = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
assertEquals(expected, restore.nextRestorePackage()) assertEquals(expected, restore.nextRestorePackage())
@ -334,13 +412,33 @@ internal class RestoreCoordinatorTest : TransportTest() {
restore.beforeStartRestore(restorableBackup) restore.beforeStartRestore(restorableBackup)
restore.startRestore(token, packageInfoArray2) restore.startRestore(token, packageInfoArray2)
every { crypto.getNameForPackage(metadata.salt, packageName) } returns name every { kv.initializeState(VERSION, packageInfo, listOf(blobHandle1)) } just Runs
every { kv.initializeState(VERSION, token, name, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage()) 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 = val expected2 =
RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM) RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
@ -357,7 +455,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
restore.startRestore(token, packageInfoArray2) restore.startRestore(token, packageInfoArray2)
coEvery { kv.hasDataForPackage(token, packageInfo) } returns true 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) val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage()) assertEquals(expected, restore.nextRestorePackage())

View file

@ -62,6 +62,7 @@ internal class RestoreV0IntegrationTest : TransportTest() {
private val backend = mockk<Backend>() private val backend = mockk<Backend>()
private val kvRestore = KVRestore( private val kvRestore = KVRestore(
backendManager = backendManager, backendManager = backendManager,
loader = loader,
legacyPlugin = legacyPlugin, legacyPlugin = legacyPlugin,
outputFactory = outputFactory, outputFactory = outputFactory,
headerReader = headerReader, headerReader = headerReader,