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