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