K/V backup and restore using v2

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

View file

@ -37,11 +37,11 @@ class KoinInstrumentationTestApp : App() {
single { spyk(BackupNotificationManager(context)) }
single { spyk(FullBackup(get(), get(), get(), get())) }
single { spyk(KVBackup(get(), get(), get(), get(), get(), get())) }
single { spyk(KVBackup(get(), get(), get(), get())) }
single { spyk(InputFactory()) }
single { spyk(FullRestore(get(), get(), get(), get(), get(), get())) }
single { spyk(KVRestore(get(), get(), get(), get(), get(), get())) }
single { spyk(KVRestore(get(), get(), get(), get(), get(), get(), get())) }
single { spyk(OutputFactory()) }
viewModel {

View file

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

View file

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

View file

@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupDataInput
import android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.stevesoltys.seedvault.settings.SettingsManager
import io.mockk.CapturingSlot
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.random.Random
import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class)
@MediumTest
class KvBackupInstrumentationTest : KoinComponent {
private val settingsManager: SettingsManager by inject()
private val backupReceiver: BackupReceiver = mockk()
private val inputFactory: InputFactory = mockk()
private val dbManager: KvDbManager by inject()
private val backup = KVBackup(
settingsManager = settingsManager,
backupReceiver = backupReceiver,
inputFactory = inputFactory,
dbManager = dbManager,
)
private val data = mockk<ParcelFileDescriptor>()
private val dataInput = mockk<BackupDataInput>()
private val key = "foo.bar"
private val dataValue = Random.nextBytes(23)
@Test
fun `test non-incremental backup with existing DB`() {
val packageName = "com.example"
val backupData = BackupData(emptyList(), emptyMap())
// create existing db
dbManager.getDb(packageName).use { db ->
db.put("foo", "bar".toByteArray())
}
val packageInfo = PackageInfo().apply {
this.packageName = packageName
}
every { backupReceiver.assertFinalized() } just Runs
every { inputFactory.getBackupDataInput(data) } returns dataInput
every { dataInput.readNextHeader() } returnsMany listOf(true, false)
every { dataInput.key } returns key
every { dataInput.dataSize } returns dataValue.size
val slot = CapturingSlot<ByteArray>()
every { dataInput.readEntityData(capture(slot), 0, dataValue.size) } answers {
dataValue.copyInto(slot.captured)
dataValue.size
}
every { data.close() } just Runs
backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)
coEvery { backupReceiver.readFromStream(any()) } just Runs
coEvery { backupReceiver.finalize() } returns backupData
runBlocking {
assertEquals(backupData, backup.finishBackup())
}
dbManager.getDb(packageName).use { db ->
assertNull(db.get("foo")) // existing data foo is gone
assertArrayEquals(dataValue, db.get(key)) // new data got added
}
}
}

View file

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

View file

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

View file

@ -14,122 +14,87 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.isOutOfSpace
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForKV
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import java.io.IOException
import java.util.zip.GZIPOutputStream
class KVBackupState(
internal val packageInfo: PackageInfo,
val token: Long,
val name: String,
val db: KVDb,
) {
var needsUpload: Boolean = false
}
)
const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
private val TAG = KVBackup::class.java.simpleName
internal class KVBackup(
private val backendManager: BackendManager,
private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager,
private val backupReceiver: BackupReceiver,
private val inputFactory: InputFactory,
private val crypto: Crypto,
private val dbManager: KvDbManager,
) {
private val backend get() = backendManager.backend
private var state: KVBackupState? = null
fun hasState() = state != null
fun getCurrentPackage() = state?.packageInfo
fun getCurrentSize() = getCurrentPackage()?.let {
dbManager.getDbSize(it.packageName)
}
fun getQuota(): Long = if (settingsManager.isQuotaUnlimited()) {
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
}
suspend fun performBackup(
fun performBackup(
packageInfo: PackageInfo,
data: ParcelFileDescriptor,
flags: Int,
token: Long,
salt: String,
): Int {
val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0
val isIncremental = flags and FLAG_INCREMENTAL != 0
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
val packageName = packageInfo.packageName
when {
dataNotChanged -> {
Log.i(TAG, "No K/V backup data has changed for $packageName")
}
isIncremental -> {
Log.i(TAG, "Performing incremental K/V backup for $packageName")
}
isNonIncremental -> {
Log.i(TAG, "Performing non-incremental K/V backup for $packageName")
}
else -> {
Log.i(TAG, "Performing K/V backup for $packageName")
}
dataNotChanged -> Log.i(TAG, "No K/V backup data has changed for $packageName")
isIncremental -> Log.i(TAG, "Performing incremental K/V backup for $packageName")
isNonIncremental -> Log.i(TAG, "Performing non-incremental K/V backup for $packageName")
else -> Log.i(TAG, "Performing K/V backup for $packageName")
}
check(state == null) { "Have unexpected state for ${state?.packageInfo?.packageName}" }
backupReceiver.assertFinalized()
// initialize state
val state = this.state
if (state != null) {
throw AssertionError("Have state for ${state.packageInfo.packageName}")
}
val name = crypto.getNameForPackage(salt, packageName)
val db = dbManager.getDb(packageName)
this.state = KVBackupState(packageInfo, token, name, db)
state = KVBackupState(packageInfo = packageInfo, db = dbManager.getDb(packageName))
// no need for backup when no data has changed
// handle case where data hasn't changed since last backup
val hasDataForPackage = dbManager.existsDb(packageName)
if (dataNotChanged) {
data.close()
return TRANSPORT_OK
return if (hasDataForPackage) {
TRANSPORT_OK
} else {
Log.w(TAG, "No previous data for $packageName, requesting non-incremental backup!")
backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
}
}
// check if we have existing data for the given package
val hasDataForPackage = dbManager.existsDb(packageName)
if (isIncremental && !hasDataForPackage) {
Log.w(
TAG, "Requested incremental, but transport currently stores no data" +
" for $packageName, requesting non-incremental retry."
)
data.close()
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
}
// TODO check if package is over-quota and respect unlimited setting
// check if we have existing data, but the system wants clean slate
if (isNonIncremental && hasDataForPackage) {
Log.w(TAG, "Requested non-incremental, deleting existing data.")
try {
clearBackupData(packageInfo, token, salt)
} catch (e: IOException) {
Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e)
Log.w(TAG, "Requested non-incremental, deleting existing data...")
dbManager.deleteDb(packageInfo.packageName)
// KvBackupInstrumentationTest tells us that the DB gets re-created automatically
}
}
// parse and store the K/V updates
return storeRecords(data)
return data.use {
storeRecords(it)
}
}
private fun storeRecords(data: ParcelFileDescriptor): Int {
@ -140,18 +105,6 @@ internal class KVBackup(
Log.e(TAG, "Exception reading backup input", result.exception)
return backupError(TRANSPORT_ERROR)
}
state.needsUpload = if (state.packageInfo.packageName == MAGIC_PACKAGE_MANAGER) {
// Don't upload, if we currently can't do backups.
// If we tried, we would fail @pm@ backup which causes the system to do a re-init.
// See: https://github.com/seedvault-app/seedvault/issues/102
// K/V backups (typically starting with package manager metadata - @pm@)
// are scheduled with JobInfo.Builder#setOverrideDeadline()
// and thus do not respect backoff.
backendManager.canDoBackupNow()
} else {
// all other packages always need upload
true
}
val op = (result as Result.Ok).result
if (op.value == null) {
Log.e(TAG, "Deleting record with key ${op.key}")
@ -205,27 +158,21 @@ internal class KVBackup(
}
@Throws(IOException::class)
suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) {
Log.i(TAG, "Clearing K/V data of ${packageInfo.packageName}")
val name = state?.name ?: crypto.getNameForPackage(salt, packageInfo.packageName)
backend.remove(LegacyAppBackupFile.Blob(token, name))
if (!dbManager.deleteDb(packageInfo.packageName)) throw IOException()
}
suspend fun finishBackup(): Int {
suspend fun finishBackup(): BackupData {
val state = this.state ?: error("No state in finishBackup")
val packageName = state.packageInfo.packageName
Log.i(TAG, "Finish K/V Backup of $packageName - needs upload: ${state.needsUpload}")
Log.i(TAG, "Finish K/V Backup of $packageName")
return try {
if (state.needsUpload) uploadDb(state.token, state.name, packageName, state.db)
else state.db.close()
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error uploading DB", e)
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
TRANSPORT_ERROR
} finally {
try {
state.db.vacuum()
state.db.close()
dbManager.getDbInputStream(packageName).use { inputStream ->
backupReceiver.readFromStream(inputStream)
}
val backupData = backupReceiver.finalize()
Log.d(TAG, "Uploaded db file for $packageName.")
return backupData
} finally { // exceptions bubble up
this.state = null
}
}
@ -240,36 +187,10 @@ internal class KVBackup(
Log.i(TAG, "Resetting state because of K/V Backup error of $packageName")
state.db.close()
this.state = null
return result
}
@Throws(IOException::class)
private suspend fun uploadDb(
token: Long,
name: String,
packageName: String,
db: KVDb,
) {
db.vacuum()
db.close()
val handle = LegacyAppBackupFile.Blob(token, name)
backend.save(handle).use { outputStream ->
outputStream.write(ByteArray(1) { VERSION })
val ad = getADForKV(VERSION, packageName)
crypto.newEncryptingStreamV1(outputStream, ad).use { encryptedStream ->
GZIPOutputStream(encryptedStream).use { gZipStream ->
dbManager.getDbInputStream(packageName).use { inputStream ->
inputStream.copyTo(gZipStream)
}
}
}
}
Log.d(TAG, "Uploaded db file for $packageName.")
}
private class KVOperation(
val key: String,
/**

View file

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

View file

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

View file

@ -11,7 +11,7 @@ import org.koin.dsl.module
val restoreModule = module {
single { OutputFactory() }
single { Loader(get(), get()) }
single { KVRestore(get(), get(), get(), get(), get(), get()) }
single { KVRestore(get(), get(), get(), get(), get(), get(), get()) }
single { FullRestore(get(), get(), get(), get(), get(), get()) }
single {
RestoreCoordinator(

View file

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

View file

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

View file

@ -69,21 +69,7 @@ internal abstract class TransportTest {
protected val pmPackageInfo = PackageInfo().apply {
packageName = MAGIC_PACKAGE_MANAGER
}
protected val metadata = BackupMetadata(
token = token,
salt = getRandomBase64(METADATA_SALT_SIZE),
androidVersion = Random.nextInt(),
androidIncremental = getRandomString(),
deviceName = getRandomString(),
packageMetadataMap = PackageMetadataMap().apply {
put(packageInfo.packageName, PackageMetadata(backupType = BackupType.KV))
}
)
protected val d2dMetadata = metadata.copy(
d2dBackup = true
)
protected val salt = metadata.salt
protected val name = getRandomString(12)
protected val name2 = getRandomString(23)
protected val storageProviderPackageName = getRandomString(23)
@ -92,26 +78,27 @@ internal abstract class TransportTest {
protected val repoId = Random.nextBytes(32).toHexString()
protected val splitName = getRandomString()
protected val splitBytes = byteArrayOf(0x07, 0x08, 0x09)
protected val apkChunkId = Random.nextBytes(32).toHexString()
protected val splitChunkId = Random.nextBytes(32).toHexString()
protected val chunkId1 = Random.nextBytes(32).toHexString()
protected val chunkId2 = Random.nextBytes(32).toHexString()
protected val apkBlob = blob {
id = ByteString.copyFrom(Random.nextBytes(32))
}
protected val splitBlob = blob {
id = ByteString.copyFrom(Random.nextBytes(32))
}
protected val apkBlobHandle = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto())
protected val apkBackupData = BackupData(listOf(apkChunkId), mapOf(apkChunkId to apkBlob))
protected val blobHandle1 = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto())
protected val blobHandle2 = AppBackupFileType.Blob(repoId, splitBlob.id.hexFromProto())
protected val apkBackupData = BackupData(listOf(chunkId1), mapOf(chunkId1 to apkBlob))
protected val splitBackupData =
BackupData(listOf(splitChunkId), mapOf(splitChunkId to splitBlob))
BackupData(listOf(chunkId2), mapOf(chunkId2 to splitBlob))
protected val chunkMap = apkBackupData.chunkMap + splitBackupData.chunkMap
protected val baseSplit = split {
name = BASE_SPLIT
chunkIds.add(ByteString.fromHex(apkChunkId))
chunkIds.add(ByteString.fromHex(chunkId1))
}
protected val apkSplit = split {
name = splitName
chunkIds.add(ByteString.fromHex(splitChunkId))
chunkIds.add(ByteString.fromHex(chunkId2))
}
protected val apk = SnapshotKt.apk {
versionCode = packageInfo.longVersionCode - 1
@ -128,6 +115,23 @@ internal abstract class TransportTest {
apps[packageName] = app
blobs.putAll(chunkMap)
}
protected val metadata = BackupMetadata(
token = token,
salt = getRandomBase64(METADATA_SALT_SIZE),
androidVersion = Random.nextInt(),
androidIncremental = getRandomString(),
deviceName = getRandomString(),
packageMetadataMap = PackageMetadataMap().apply {
put(
packageInfo.packageName,
PackageMetadata(backupType = BackupType.KV, chunkIds = listOf(chunkId1)),
)
}
)
protected val d2dMetadata = metadata.copy(
d2dBackup = true
)
protected val salt = metadata.salt
init {
mockkStatic(Log::class)

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,418 @@
/*
* SPDX-FileCopyrightText: 2020 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.transport.restore
import android.app.backup.BackupDataOutput
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VersionHeader
import com.stevesoltys.seedvault.header.getADForKV
import com.stevesoltys.seedvault.transport.backup.KVDb
import com.stevesoltys.seedvault.transport.backup.KvDbManager
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.Backend
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.security.GeneralSecurityException
import java.util.zip.GZIPOutputStream
import kotlin.random.Random
internal class KVRestoreV1Test : RestoreTest() {
private val backendManager: BackendManager = mockk()
private val loader = mockk<Loader>()
private val backend = mockk<Backend>()
@Suppress("DEPRECATION")
private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val dbManager = mockk<KvDbManager>()
private val output = mockk<BackupDataOutput>()
private val restore = KVRestore(
backendManager = backendManager,
loader = loader,
legacyPlugin = legacyPlugin,
outputFactory = outputFactory,
headerReader = headerReader,
crypto = crypto,
dbManager = dbManager,
)
private val db = mockk<KVDb>()
private val ad = getADForKV(1, packageInfo.packageName)
private val key = "Restore Key"
private val key64 = key.encodeBase64()
private val key2 = "Restore Key2"
private val key264 = key2.encodeBase64()
private val data2 = getRandomByteArray()
private val outputStream = ByteArrayOutputStream().apply {
GZIPOutputStream(this).close()
}
private val decryptInputStream = ByteArrayInputStream(outputStream.toByteArray())
init {
// for InputStream#readBytes()
mockkStatic("kotlin.io.ByteStreamsKt")
every { backendManager.backend } returns backend
}
@Test
fun `getRestoreData() throws without initializing state`() {
coAssertThrows(IllegalStateException::class.java) {
restore.getRestoreData(fileDescriptor)
}
}
@Test
fun `unexpected version aborts with error`() = runBlocking {
restore.initializeStateV1(token, name, packageInfo)
coEvery { backend.load(handle) } returns inputStream
every {
headerReader.readVersion(inputStream, 1)
} throws UnsupportedVersionException(Byte.MAX_VALUE)
every { dbManager.deleteDb(packageInfo.packageName, true) } returns true
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `newDecryptingStream throws`() = runBlocking {
restore.initializeStateV1(token, name, packageInfo)
coEvery { backend.load(handle) } returns inputStream
every { headerReader.readVersion(inputStream, 1) } returns 1
every { crypto.newDecryptingStreamV1(inputStream, ad) } throws GeneralSecurityException()
every { dbManager.deleteDb(packageInfo.packageName, true) } returns true
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
verifyAll {
dbManager.deleteDb(packageInfo.packageName, true)
}
}
@Test
fun `writeEntityHeader throws`() = runBlocking {
restore.initializeStateV1(token, name, packageInfo)
coEvery { backend.load(handle) } returns inputStream
every { headerReader.readVersion(inputStream, 1) } returns 1
every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptInputStream
every {
dbManager.getDbOutputStream(packageInfo.packageName)
} returns ByteArrayOutputStream()
every { dbManager.getDb(packageInfo.packageName, true) } returns db
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output
every { db.getAll() } returns listOf(Pair(key, data))
every { output.writeEntityHeader(key, data.size) } throws IOException()
every { dbManager.deleteDb(packageInfo.packageName, true) } returns true
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
verify {
dbManager.deleteDb(packageInfo.packageName, true)
}
}
@Test
fun `two records get restored`() = runBlocking {
restore.initializeStateV1(token, name, packageInfo)
coEvery { backend.load(handle) } returns inputStream
every { headerReader.readVersion(inputStream, 1) } returns 1
every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptInputStream
every {
dbManager.getDbOutputStream(packageInfo.packageName)
} returns ByteArrayOutputStream()
every { dbManager.getDb(packageInfo.packageName, true) } returns db
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output
every { db.getAll() } returns listOf(
Pair(key, data),
Pair(key2, data2)
)
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } returns data.size
every { output.writeEntityHeader(key2, data2.size) } returns 42
every { output.writeEntityData(data2, data2.size) } returns data2.size
every { db.close() } just Runs
every { dbManager.deleteDb(packageInfo.packageName, true) } returns true
streamsGetClosed()
assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
verify {
output.writeEntityHeader(key, data.size)
output.writeEntityData(data, data.size)
output.writeEntityHeader(key2, data2.size)
output.writeEntityData(data2, data2.size)
db.close()
dbManager.deleteDb(packageInfo.packageName, true)
}
}
//
// v0 legacy tests below
//
@Test
@Suppress("Deprecation")
fun `v0 hasDataForPackage() delegates to plugin`() = runBlocking {
val result = Random.nextBoolean()
coEvery { legacyPlugin.hasDataForPackage(token, packageInfo) } returns result
assertEquals(result, restore.hasDataForPackage(token, packageInfo))
}
@Test
fun `v0 listing records throws`() = runBlocking {
restore.initializeStateV0(token, packageInfo)
coEvery { legacyPlugin.listRecords(token, packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
}
@Test
fun `v0 reading VersionHeader with unsupported version throws`() = runBlocking {
restore.initializeStateV0(token, packageInfo)
getRecordsAndOutput()
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key64)
} returns inputStream
every {
headerReader.readVersion(inputStream, 0x00)
} throws UnsupportedVersionException(unsupportedVersion)
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
fun `v0 error reading VersionHeader throws`() = runBlocking {
restore.initializeStateV0(token, packageInfo)
getRecordsAndOutput()
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key64)
} returns inputStream
every { headerReader.readVersion(inputStream, 0x00) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
@Suppress("deprecation")
fun `v0 decrypting stream throws`() = runBlocking {
restore.initializeStateV0(token, packageInfo)
getRecordsAndOutput()
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key64)
} returns inputStream
every { headerReader.readVersion(inputStream, 0x00) } returns 0x00
every {
crypto.decryptHeader(inputStream, 0x00, packageInfo.packageName, key)
} throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
@Suppress("deprecation")
fun `v0 decrypting stream throws security exception`() = runBlocking {
restore.initializeStateV0(token, packageInfo)
getRecordsAndOutput()
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key64)
} returns inputStream
every { headerReader.readVersion(inputStream, 0x00) } returns 0x00
every {
crypto.decryptHeader(inputStream, 0x00, packageInfo.packageName, key)
} returns VersionHeader(0x00, packageInfo.packageName, key)
every { crypto.decryptMultipleSegments(inputStream) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
@Suppress("Deprecation")
fun `v0 writing header throws`() = runBlocking {
restore.initializeStateV0(token, packageInfo)
getRecordsAndOutput()
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key64)
} returns inputStream
every { headerReader.readVersion(inputStream, 0) } returns 0
every {
crypto.decryptHeader(inputStream, 0x00, packageInfo.packageName, key)
} returns VersionHeader(0x00, packageInfo.packageName, key)
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
@Suppress("deprecation")
fun `v0 writing value throws`() = runBlocking {
restore.initializeStateV0(token, packageInfo)
getRecordsAndOutput()
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key64)
} returns inputStream
every { headerReader.readVersion(inputStream, 0) } returns 0
every {
crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key)
} returns VersionHeader(0, packageInfo.packageName, key)
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
@Suppress("deprecation")
fun `v0 writing value succeeds`() = runBlocking {
restore.initializeStateV0(token, packageInfo)
getRecordsAndOutput()
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key64)
} returns inputStream
every { headerReader.readVersion(inputStream, 0) } returns 0
every {
crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key)
} returns VersionHeader(0, packageInfo.packageName, key)
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } returns data.size
streamsGetClosed()
assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
@Suppress("deprecation")
fun `v0 writing value uses old v0 code`() = runBlocking {
restore.initializeStateV0(token, packageInfo)
getRecordsAndOutput()
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key64)
} returns inputStream
every { headerReader.readVersion(inputStream, 0) } returns 0
every {
crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key)
} returns VersionHeader(1, packageInfo.packageName, key)
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } returns data.size
streamsGetClosed()
assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
verifyStreamWasClosed()
}
@Test
@Suppress("Deprecation")
fun `v0 writing two values succeeds`() = runBlocking {
val data2 = getRandomByteArray()
val inputStream2 = mockk<InputStream>()
restore.initializeStateV0(token, packageInfo)
getRecordsAndOutput(listOf(key64, key264))
// first key/value
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key64)
} returns inputStream
every { headerReader.readVersion(inputStream, 0) } returns 0
every {
crypto.decryptHeader(inputStream, 0, packageInfo.packageName, key)
} returns VersionHeader(0, packageInfo.packageName, key)
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } returns data.size
// second key/value
coEvery {
legacyPlugin.getInputStreamForRecord(token, packageInfo, key264)
} returns inputStream2
every { headerReader.readVersion(inputStream2, 0) } returns 0
every {
crypto.decryptHeader(inputStream2, 0, packageInfo.packageName, key2)
} returns VersionHeader(0, packageInfo.packageName, key2)
every { crypto.decryptMultipleSegments(inputStream2) } returns data2
every { output.writeEntityHeader(key2, data2.size) } returns 42
every { output.writeEntityData(data2, data2.size) } returns data2.size
every { inputStream2.close() } just Runs
streamsGetClosed()
assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
}
private fun getRecordsAndOutput(recordKeys: List<String> = listOf(key64)) {
coEvery { legacyPlugin.listRecords(token, packageInfo) } returns recordKeys
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output
}
private fun streamsGetClosed() {
every { inputStream.close() } just Runs
every { fileDescriptor.close() } just Runs
}
private fun verifyStreamWasClosed() {
verifyAll {
inputStream.close()
fileDescriptor.close()
}
}
}

View file

@ -15,15 +15,18 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.BackupType
import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.proto.copy
import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -35,11 +38,14 @@ import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.FileInfo
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import org.calyxos.seedvault.core.backends.saf.SafProperties
import org.calyxos.seedvault.core.toHexString
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import kotlin.random.Random
@ -82,7 +88,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
init {
metadata.packageMetadataMap[packageInfo2.packageName] = PackageMetadata(
backupType = BackupType.FULL,
chunkIds = listOf(apkChunkId),
chunkIds = listOf(chunkId2),
)
mockkStatic("com.stevesoltys.seedvault.backend.BackendExtKt")
@ -250,6 +256,62 @@ internal class RestoreCoordinatorTest : TransportTest() {
}
}
@Test
fun `startRestore() loads snapshots for auto-restore`() = runBlocking {
val handle = AppBackupFileType.Snapshot(repoId, getRandomByteArray(32).toHexString())
val info = FileInfo(handle, 1)
val snapshotBytes = ByteArrayOutputStream().apply {
snapshot.writeTo(this)
}.toByteArray()
every { backendManager.backendProperties } returns safStorage
every { safStorage.isUnavailableUsb(context) } returns false
coEvery {
backend.list(
topLevelFolder = null,
AppBackupFileType.Snapshot::class, LegacyAppBackupFile.Metadata::class,
callback = captureLambda<(FileInfo) -> Unit>()
)
} answers {
val callback = lambda<(FileInfo) -> Unit>().captured
callback(info)
}
coEvery { loader.loadFile(handle) } returns ByteArrayInputStream(snapshotBytes)
assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray))
}
@Test
fun `startRestore() errors when it can't find snapshots`() = runBlocking {
val handle = AppBackupFileType.Snapshot(repoId, getRandomByteArray(32).toHexString())
val info = FileInfo(handle, 1)
val snapshotBytes = ByteArrayOutputStream().apply { // snapshot has different token
snapshot.copy { token = this@RestoreCoordinatorTest.token - 1 }.writeTo(this)
}.toByteArray()
every { backendManager.backendProperties } returns safStorage
every { safStorage.isUnavailableUsb(context) } returns false
coEvery {
backend.list(
topLevelFolder = null,
AppBackupFileType.Snapshot::class, LegacyAppBackupFile.Metadata::class,
callback = captureLambda<(FileInfo) -> Unit>()
)
} answers {
val callback = lambda<(FileInfo) -> Unit>().captured
callback(info)
}
coEvery { loader.loadFile(handle) } returns ByteArrayInputStream(snapshotBytes)
assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray))
coVerify {
loader.loadFile(handle) // really loaded snapshot
}
}
@Test
fun `startRestore() with removed storage shows no notification`() = runBlocking {
every { backendManager.backendProperties } returns safStorage
@ -274,12 +336,12 @@ internal class RestoreCoordinatorTest : TransportTest() {
}
@Test
fun `nextRestorePackage() returns KV description`() = runBlocking {
restore.beforeStartRestore(restorableBackup)
fun `nextRestorePackageV1() returns KV description`() = runBlocking {
restore.beforeStartRestore(restorableBackup.copy(metadata.copy(version = 1)))
restore.startRestore(token, packageInfoArray)
every { crypto.getNameForPackage(metadata.salt, packageName) } returns name
every { kv.initializeState(VERSION, token, name, packageInfo) } just Runs
every { kv.initializeStateV1(token, name, packageInfo) } just Runs
val expected = RestoreDescription(packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage())
@ -293,7 +355,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
restore.startRestore(token, packageInfoArray)
coEvery { kv.hasDataForPackage(token, packageInfo) } returns true
every { kv.initializeState(0x00, token, "", packageInfo) } just Runs
every { kv.initializeStateV0(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage())
@ -321,7 +383,23 @@ internal class RestoreCoordinatorTest : TransportTest() {
restore.beforeStartRestore(restorableBackup)
restore.startRestore(token, packageInfoArray2)
every { full.initializeState(VERSION, packageInfo2, listOf(apkBlobHandle)) } just Runs
every { full.initializeState(VERSION, packageInfo2, listOf(blobHandle2)) } just Runs
val expected = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
assertEquals(expected, restore.nextRestorePackage())
assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage())
}
@Test
fun `nextRestorePackageV1() tries next package if one has no backup type()`() = runBlocking {
metadata.packageMetadataMap[packageName] =
metadata.packageMetadataMap[packageName]!!.copy(backupType = null)
restore.beforeStartRestore(restorableBackup.copy(metadata.copy(version = 1)))
restore.startRestore(token, packageInfoArray2)
every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2
every { full.initializeStateV1(token, name2, packageInfo2) } just Runs
val expected = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
assertEquals(expected, restore.nextRestorePackage())
@ -334,13 +412,33 @@ internal class RestoreCoordinatorTest : TransportTest() {
restore.beforeStartRestore(restorableBackup)
restore.startRestore(token, packageInfoArray2)
every { crypto.getNameForPackage(metadata.salt, packageName) } returns name
every { kv.initializeState(VERSION, token, name, packageInfo) } just Runs
every { kv.initializeState(VERSION, packageInfo, listOf(blobHandle1)) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage())
every { full.initializeState(VERSION, packageInfo2, listOf(apkBlobHandle)) } just Runs
every { full.initializeState(VERSION, packageInfo2, listOf(blobHandle2)) } just Runs
val expected2 =
RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
assertEquals(expected2, restore.nextRestorePackage())
assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage())
}
@Test
fun `nextRestorePackageV1() returns all packages from startRestore()`() = runBlocking {
restore.beforeStartRestore(restorableBackup.copy(metadata.copy(version = 1)))
restore.startRestore(token, packageInfoArray2)
every { crypto.getNameForPackage(metadata.salt, packageName) } returns name
every { kv.initializeStateV1(token, name, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage())
every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2
every { full.initializeStateV1(token, name2, packageInfo2) } just Runs
val expected2 =
RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
@ -357,7 +455,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
restore.startRestore(token, packageInfoArray2)
coEvery { kv.hasDataForPackage(token, packageInfo) } returns true
every { kv.initializeState(0.toByte(), token, "", packageInfo) } just Runs
every { kv.initializeStateV0(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage())

View file

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