K/V backup using single file

Tests are still broken until restore has also been implemented with single file approach
This commit is contained in:
Torsten Grote 2021-09-21 18:50:10 +02:00 committed by Chirayu Desai
parent 23bb385190
commit 0c915e5eb8
10 changed files with 486 additions and 290 deletions

View file

@ -268,7 +268,9 @@ internal class BackupCoordinator(
return TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED return TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
} }
} }
val result = kv.performBackup(packageInfo, data, flags) val token = settingsManager.getToken() ?: error("no token in performFullBackup")
val salt = metadataManager.salt
val result = kv.performBackup(packageInfo, data, flags, token, salt)
if (result == TRANSPORT_OK && packageName == MAGIC_PACKAGE_MANAGER) { if (result == TRANSPORT_OK && packageName == MAGIC_PACKAGE_MANAGER) {
// hook in here to back up APKs of apps that are otherwise not allowed for backup // hook in here to back up APKs of apps that are otherwise not allowed for backup
backUpApksOfNotBackedUpPackages() backUpApksOfNotBackedUpPackages()
@ -360,7 +362,7 @@ internal class BackupCoordinator(
val token = settingsManager.getToken() ?: error("no token in clearBackupData") val token = settingsManager.getToken() ?: error("no token in clearBackupData")
val salt = metadataManager.salt val salt = metadataManager.salt
try { try {
kv.clearBackupData(packageInfo) kv.clearBackupData(packageInfo, token, salt)
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, "Error clearing K/V backup data for $packageName", e) Log.w(TAG, "Error clearing K/V backup data for $packageName", e)
return TRANSPORT_ERROR return TRANSPORT_ERROR

View file

@ -18,13 +18,14 @@ val backupModule = module {
metadataManager = get() metadataManager = get()
) )
} }
single<KvDbManager> { KvDbManagerImpl(androidContext()) }
single { single {
KVBackup( KVBackup(
plugin = get<BackupPlugin>().kvBackupPlugin, plugin = get(),
settingsManager = get(), settingsManager = get(),
inputFactory = get(), inputFactory = get(),
crypto = get(), crypto = get(),
nm = get() dbManager = get()
) )
} }
single { single {

View file

@ -9,16 +9,21 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.header.getADForKV
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import java.io.IOException import java.io.IOException
import java.util.zip.GZIPOutputStream
class KVBackupState(internal val packageInfo: PackageInfo) 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() const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
@ -26,11 +31,11 @@ private val TAG = KVBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackup( internal class KVBackup(
private val plugin: KVBackupPlugin, private val plugin: BackupPlugin,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val inputFactory: InputFactory, private val inputFactory: InputFactory,
private val crypto: Crypto, private val crypto: Crypto,
private val nm: BackupNotificationManager private val dbManager: KvDbManager
) { ) {
private var state: KVBackupState? = null private var state: KVBackupState? = null
@ -39,14 +44,18 @@ internal class KVBackup(
fun getCurrentPackage() = state?.packageInfo fun getCurrentPackage() = state?.packageInfo
fun getQuota(): Long { fun getQuota(): Long = if (settingsManager.isQuotaUnlimited()) {
return if (settingsManager.isQuotaUnlimited()) Long.MAX_VALUE else plugin.getQuota() Long.MAX_VALUE
} else {
DEFAULT_QUOTA_KEY_VALUE_BACKUP
} }
suspend fun performBackup( suspend fun performBackup(
packageInfo: PackageInfo, packageInfo: PackageInfo,
data: ParcelFileDescriptor, data: ParcelFileDescriptor,
flags: Int flags: Int,
token: Long,
salt: String
): Int { ): Int {
val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0 val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0
val isIncremental = flags and FLAG_INCREMENTAL != 0 val isIncremental = flags and FLAG_INCREMENTAL != 0
@ -73,7 +82,9 @@ internal class KVBackup(
if (state != null) { if (state != null) {
throw AssertionError("Have state for ${state.packageInfo.packageName}") throw AssertionError("Have state for ${state.packageInfo.packageName}")
} }
this.state = KVBackupState(packageInfo) val name = crypto.getNameForPackage(salt, packageName)
val db = dbManager.getDb(packageName)
this.state = KVBackupState(packageInfo, token, name, db)
// no need for backup when no data has changed // no need for backup when no data has changed
if (dataNotChanged) { if (dataNotChanged) {
@ -82,12 +93,7 @@ internal class KVBackup(
} }
// check if we have existing data for the given package // check if we have existing data for the given package
val hasDataForPackage = try { val hasDataForPackage = dbManager.existsDb(packageName)
plugin.hasDataForPackage(packageInfo)
} catch (e: IOException) {
Log.e(TAG, "Error checking for existing data for ${packageInfo.packageName}.", e)
return backupError(TRANSPORT_ERROR)
}
if (isIncremental && !hasDataForPackage) { if (isIncremental && !hasDataForPackage) {
Log.w( Log.w(
TAG, "Requested incremental, but transport currently stores no data" + TAG, "Requested incremental, but transport currently stores no data" +
@ -101,80 +107,36 @@ internal class KVBackup(
if (isNonIncremental && hasDataForPackage) { if (isNonIncremental && hasDataForPackage) {
Log.w(TAG, "Requested non-incremental, deleting existing data.") Log.w(TAG, "Requested non-incremental, deleting existing data.")
try { try {
clearBackupData(packageInfo) clearBackupData(packageInfo, token, salt)
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e) Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e)
} }
} }
// parse and store the K/V updates // parse and store the K/V updates
return storeRecords(packageInfo, data) return storeRecords(data)
} }
private suspend fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int { private fun storeRecords(data: ParcelFileDescriptor): Int {
val backupSequence: Iterable<Result<KVOperation>> val state = this.state ?: error("No state in storeRecords")
val pmRecordNumber: Int?
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER) {
// Since the package manager has many small keys to store,
// and this can be slow, especially on cloud-based storage,
// we get the entire data set first, so we can show progress notifications.
val list = parseBackupStream(data).toList()
backupSequence = list
pmRecordNumber = list.size
} else {
backupSequence = parseBackupStream(data).asIterable()
pmRecordNumber = null
}
// apply the delta operations // apply the delta operations
var i = 1 for (result in parseBackupStream(data)) {
for (result in backupSequence) {
if (result is Result.Error) { if (result is Result.Error) {
Log.e(TAG, "Exception reading backup input", result.exception) Log.e(TAG, "Exception reading backup input", result.exception)
return backupError(TRANSPORT_ERROR) return backupError(TRANSPORT_ERROR)
} }
state.needsUpload = true
val op = (result as Result.Ok).result val op = (result as Result.Ok).result
try { if (op.value == null) {
storeRecord(packageInfo, op, i++, pmRecordNumber) Log.e(TAG, "Deleting record with key ${op.key}")
} catch (e: IOException) { state.db.delete(op.key)
Log.e(TAG, "Unable to update base64Key file for base64Key ${op.base64Key}", e) } else {
// Returning something more forgiving such as TRANSPORT_PACKAGE_REJECTED state.db.put(op.key, op.value)
// will still make the entire backup fail.
// TODO However, TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED might buy us a retry,
// we would just need to be careful not to create an infinite loop
// for permanent errors.
return backupError(TRANSPORT_ERROR)
} }
} }
return TRANSPORT_OK return TRANSPORT_OK
} }
@Throws(IOException::class)
private suspend fun storeRecord(
packageInfo: PackageInfo,
op: KVOperation,
currentNum: Int,
pmRecordNumber: Int?
) {
// update notification for package manager backup
if (pmRecordNumber != null) {
nm.onPmKvBackup(op.key, currentNum, pmRecordNumber)
}
// check if record should get deleted
if (op.value == null) {
Log.e(TAG, "Deleting record with base64Key ${op.base64Key}")
plugin.deleteRecord(packageInfo, op.base64Key)
} else {
plugin.getOutputStreamForRecord(packageInfo, op.base64Key).use { outputStream ->
outputStream.write(ByteArray(1) { VERSION })
val ad = getADForKV(VERSION, packageInfo.packageName)
crypto.newEncryptingStream(outputStream, ad).use { encryptedStream ->
encryptedStream.write(op.value)
encryptedStream.flush()
}
}
}
}
/** /**
* Parses a backup stream into individual key/value operations * Parses a backup stream into individual key/value operations
*/ */
@ -194,12 +156,11 @@ internal class KVBackup(
} }
// encode key // encode key
val key = changeSet.key val key = changeSet.key
val base64Key = key.encodeBase64()
val dataSize = changeSet.dataSize val dataSize = changeSet.dataSize
// read value // read value
val value = if (dataSize >= 0) { val value = if (dataSize >= 0) {
Log.v(TAG, " Delta operation key $key size $dataSize key64 $base64Key") Log.v(TAG, " Delta operation key $key size $dataSize")
val bytes = ByteArray(dataSize) val bytes = ByteArray(dataSize)
val bytesRead = try { val bytesRead = try {
changeSet.readEntityData(bytes, 0, dataSize) changeSet.readEntityData(bytes, 0, dataSize)
@ -213,19 +174,31 @@ internal class KVBackup(
bytes bytes
} else null } else null
// add change operation to the sequence // add change operation to the sequence
Result.Ok(KVOperation(key, base64Key, value)) Result.Ok(KVOperation(key, value))
} }
} }
@Throws(IOException::class) @Throws(IOException::class)
suspend fun clearBackupData(packageInfo: PackageInfo) { suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) {
plugin.removeDataOfPackage(packageInfo) Log.i(TAG, "Clearing K/V data of ${packageInfo.packageName}")
val name = state?.name ?: crypto.getNameForPackage(salt, packageInfo.packageName)
plugin.removeData(token, name)
if (!dbManager.deleteDb(packageInfo.packageName)) throw IOException()
} }
fun finishBackup(): Int { @Throws(IOException::class)
Log.i(TAG, "Finish K/V Backup of ${state!!.packageInfo.packageName}") suspend fun finishBackup(): Int {
plugin.packageFinished(state!!.packageInfo) val state = this.state ?: error("No state in finishBackup")
state = null val packageName = state.packageInfo.packageName
Log.i(TAG, "Finish K/V Backup of $packageName")
try {
if (state.needsUpload) uploadDb(state.token, state.name, packageName, state.db)
} catch (e: IOException) {
return TRANSPORT_ERROR
} finally {
this.state = null
}
return TRANSPORT_OK return TRANSPORT_OK
} }
@ -234,17 +207,43 @@ internal class KVBackup(
* because [finishBackup] is not called when we don't return [TRANSPORT_OK]. * because [finishBackup] is not called when we don't return [TRANSPORT_OK].
*/ */
private fun backupError(result: Int): Int { private fun backupError(result: Int): Int {
"Resetting state because of K/V Backup error of ${state!!.packageInfo.packageName}".let { val state = this.state ?: error("No state in backupError")
Log.i(TAG, it) val packageName = state.packageInfo.packageName
} Log.i(TAG, "Resetting state because of K/V Backup error of $packageName")
plugin.packageFinished(state!!.packageInfo)
state = null state.db.close()
this.state = null
return result return result
} }
@Throws(IOException::class)
private suspend fun uploadDb(
token: Long,
name: String,
packageName: String,
db: KVDb
) {
db.vacuum()
db.close()
plugin.getOutputStream(token, name).use { outputStream ->
outputStream.write(ByteArray(1) { VERSION })
val ad = getADForKV(VERSION, packageName)
crypto.newEncryptingStream(outputStream, ad).use { encryptedStream ->
GZIPOutputStream(encryptedStream).use { gZipStream ->
dbManager.getDbInputStream(packageName).use { inputStream ->
inputStream.copyTo(gZipStream)
}
// TODO remove log
Log.d(TAG, "=> Uploaded db file for $packageName")
}
}
}
}
private class KVOperation( private class KVOperation(
val key: String, val key: String,
val base64Key: String,
/** /**
* value is null when this is a deletion operation * value is null when this is a deletion operation
*/ */

View file

@ -0,0 +1,125 @@
package com.stevesoltys.seedvault.transport.backup
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE
import android.database.sqlite.SQLiteOpenHelper
import android.provider.BaseColumns
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
interface KvDbManager {
fun getDb(packageName: String): KVDb
fun getDbInputStream(packageName: String): InputStream
fun existsDb(packageName: String): Boolean
fun deleteDb(packageName: String): Boolean
}
class KvDbManagerImpl(private val context: Context) : KvDbManager {
override fun getDb(packageName: String): KVDb {
return KVDbImpl(context, getFileName(packageName))
}
private fun getFileName(packageName: String) = "kv_$packageName.db"
private fun getDbFile(packageName: String): File {
return context.getDatabasePath(getFileName(packageName))
}
override fun getDbInputStream(packageName: String): InputStream {
return FileInputStream(getDbFile(packageName))
}
override fun existsDb(packageName: String): Boolean {
return getDbFile(packageName).isFile
}
override fun deleteDb(packageName: String): Boolean {
return getDbFile(packageName).delete()
}
}
interface KVDb {
fun put(key: String, value: ByteArray)
fun get(key: String): ByteArray?
fun getAll(): List<Pair<String, ByteArray>>
fun delete(key: String)
fun vacuum()
fun close()
}
class KVDbImpl(context: Context, fileName: String) :
SQLiteOpenHelper(context, fileName, null, DATABASE_VERSION), KVDb {
companion object {
private const val DATABASE_VERSION = 1
private object KVEntry : BaseColumns {
const val TABLE_NAME = "kv_entry"
const val COLUMN_NAME_KEY = "key"
const val COLUMN_NAME_VALUE = "value"
}
private const val SQL_CREATE_ENTRIES =
"CREATE TABLE IF NOT EXISTS ${KVEntry.TABLE_NAME} (" +
"${KVEntry.COLUMN_NAME_KEY} TEXT PRIMARY KEY," +
"${KVEntry.COLUMN_NAME_VALUE} BLOB)"
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(SQL_CREATE_ENTRIES)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
}
override fun vacuum() = writableDatabase.execSQL("VACUUM")
override fun put(key: String, value: ByteArray) {
val values = ContentValues().apply {
put(KVEntry.COLUMN_NAME_KEY, key)
put(KVEntry.COLUMN_NAME_VALUE, value)
}
writableDatabase.insertWithOnConflict(KVEntry.TABLE_NAME, null, values, CONFLICT_REPLACE)
}
override fun get(key: String): ByteArray? = readableDatabase.query(
KVEntry.TABLE_NAME,
arrayOf(KVEntry.COLUMN_NAME_VALUE),
"${KVEntry.COLUMN_NAME_KEY} = ?",
arrayOf(key),
null,
null,
null
).use { cursor ->
if (!cursor.moveToNext()) null
else cursor.getBlob(0)
}
override fun getAll(): List<Pair<String, ByteArray>> = readableDatabase.query(
KVEntry.TABLE_NAME,
arrayOf(KVEntry.COLUMN_NAME_KEY, KVEntry.COLUMN_NAME_VALUE),
null,
null,
null,
null,
null
).use { cursor ->
val list = ArrayList<Pair<String, ByteArray>>(cursor.count)
while (cursor.moveToNext()) {
list.add(Pair(cursor.getString(0), cursor.getBlob(1)))
}
list
}
override fun delete(key: String) {
writableDatabase.delete(KVEntry.TABLE_NAME, "${KVEntry.COLUMN_NAME_KEY} = ?", arrayOf(key))
}
}

View file

@ -90,6 +90,7 @@ internal class BackupNotificationManager(private val context: Context) {
/** /**
* This is expected to get called before [onOptOutAppBackup] and [onBackupUpdate]. * This is expected to get called before [onOptOutAppBackup] and [onBackupUpdate].
*/ */
// TODO remove?
fun onPmKvBackup(packageName: String, transferred: Int, expected: Int) { fun onPmKvBackup(packageName: String, transferred: Int, expected: Int) {
val text = "@pm@ record for $packageName" val text = "@pm@ record for $packageName"
if (expectedApps == null) { if (expectedApps == null) {

View file

@ -21,13 +21,11 @@ import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.transport.backup.ApkBackup import com.stevesoltys.seedvault.transport.backup.ApkBackup
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.backup.BackupPlugin import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
import com.stevesoltys.seedvault.transport.backup.FullBackup import com.stevesoltys.seedvault.transport.backup.FullBackup
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.InputFactory import com.stevesoltys.seedvault.transport.backup.InputFactory
import com.stevesoltys.seedvault.transport.backup.KVBackup import com.stevesoltys.seedvault.transport.backup.KVBackup
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.backup.TestKvDbManager
import com.stevesoltys.seedvault.transport.restore.FullRestore import com.stevesoltys.seedvault.transport.restore.FullRestore
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestore import com.stevesoltys.seedvault.transport.restore.KVRestore
@ -61,23 +59,12 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val cryptoImpl = CryptoImpl(keyManager, cipherFactory, headerReader) private val cryptoImpl = CryptoImpl(keyManager, cipherFactory, headerReader)
private val metadataReader = MetadataReaderImpl(cryptoImpl) private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val notificationManager = mockk<BackupNotificationManager>() private val notificationManager = mockk<BackupNotificationManager>()
private val dbManager = TestKvDbManager()
private val backupPlugin = mockk<BackupPlugin>() private val backupPlugin = mockk<BackupPlugin>()
private val kvBackupPlugin = mockk<KVBackupPlugin>() private val kvBackup =
private val kvBackup = KVBackup( KVBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl, dbManager)
plugin = kvBackupPlugin, private val fullBackup = FullBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl)
settingsManager = settingsManager,
inputFactory = inputFactory,
crypto = cryptoImpl,
nm = notificationManager
)
private val fullBackupPlugin = mockk<FullBackupPlugin>()
private val fullBackup = FullBackup(
plugin = backupPlugin,
settingsManager = settingsManager,
inputFactory = inputFactory,
crypto = cryptoImpl
)
private val apkBackup = mockk<ApkBackup>() private val apkBackup = mockk<ApkBackup>()
private val packageService: PackageService = mockk() private val packageService: PackageService = mockk()
private val backup = BackupCoordinator( private val backup = BackupCoordinator(
@ -121,12 +108,8 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val key2 = "RestoreKey2" private val key2 = "RestoreKey2"
private val key264 = key2.encodeBase64() private val key264 = key2.encodeBase64()
init { // as we use real crypto, we need a real name for packageInfo
@Suppress("deprecation") private val realName = cryptoImpl.getNameForPackage(salt, packageInfo.packageName)
every { backupPlugin.kvBackupPlugin } returns kvBackupPlugin
@Suppress("deprecation")
every { backupPlugin.fullBackupPlugin } returns fullBackupPlugin
}
@Test @Test
fun `test key-value backup and restore with 2 records`() = runBlocking { fun `test key-value backup and restore with 2 records`() = runBlocking {
@ -135,8 +118,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
val bOutputStream = ByteArrayOutputStream() val bOutputStream = ByteArrayOutputStream()
val bOutputStream2 = ByteArrayOutputStream() val bOutputStream2 = ByteArrayOutputStream()
every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt
// read one key/value record and write it to output stream // read one key/value record and write it to output stream
coEvery { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen true andThen false every { backupDataInput.readNextHeader() } returns true andThen true andThen false
every { backupDataInput.key } returns key andThen key2 every { backupDataInput.key } returns key andThen key2
@ -145,31 +129,13 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData.copyInto(value.captured) // write the app data into the passed ByteArray appData.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size appData.size
} }
coEvery {
kvBackupPlugin.getOutputStreamForRecord(
packageInfo,
key64
)
} returns bOutputStream
every { backupDataInput.readEntityData(capture(value2), 0, appData2.size) } answers { every { backupDataInput.readEntityData(capture(value2), 0, appData2.size) } answers {
appData2.copyInto(value2.captured) // write the app data into the passed ByteArray appData2.copyInto(value2.captured) // write the app data into the passed ByteArray
appData2.size appData2.size
} }
coEvery { coEvery {
kvBackupPlugin.getOutputStreamForRecord( apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any())
packageInfo,
key264
)
} returns bOutputStream2
every { kvBackupPlugin.packageFinished(packageInfo) } just Runs
coEvery {
apkBackup.backupApkIfNecessary(
packageInfo,
UNKNOWN_ERROR,
any()
)
} returns packageMetadata } returns packageMetadata
every { settingsManager.getToken() } returns token
coEvery { coEvery {
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
} returns metadataOutputStream } returns metadataOutputStream
@ -180,8 +146,13 @@ internal class CoordinatorIntegrationTest : TransportTest() {
metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream) metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream)
} just Runs } just Runs
// start and finish K/V backup // start K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
// upload DB
coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream
// finish K/V backup
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
// start restore // start restore
@ -231,8 +202,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
val appData = ByteArray(size).apply { Random.nextBytes(this) } val appData = ByteArray(size).apply { Random.nextBytes(this) }
val bOutputStream = ByteArrayOutputStream() val bOutputStream = ByteArrayOutputStream()
every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt
// read one key/value record and write it to output stream // read one key/value record and write it to output stream
coEvery { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen false every { backupDataInput.readNextHeader() } returns true andThen false
every { backupDataInput.key } returns key every { backupDataInput.key } returns key
@ -241,13 +213,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData.copyInto(value.captured) // write the app data into the passed ByteArray appData.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size appData.size
} }
coEvery {
kvBackupPlugin.getOutputStreamForRecord(
packageInfo,
key64
)
} returns bOutputStream
every { kvBackupPlugin.packageFinished(packageInfo) } just Runs
coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { coEvery {
@ -257,8 +222,13 @@ internal class CoordinatorIntegrationTest : TransportTest() {
metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream) metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream)
} just Runs } just Runs
// start and finish K/V backup // start K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
// upload DB
coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream
// finish K/V backup
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
// start restore // start restore
@ -297,16 +267,13 @@ internal class CoordinatorIntegrationTest : TransportTest() {
val packageMetadata = metadata.packageMetadataMap[packageInfo.packageName]!! val packageMetadata = metadata.packageMetadataMap[packageInfo.packageName]!!
metadata.packageMetadataMap[packageInfo.packageName] = metadata.packageMetadataMap[packageInfo.packageName] =
packageMetadata.copy(backupType = BackupType.FULL) packageMetadata.copy(backupType = BackupType.FULL)
// as we use real crypto, we need a real name for packageInfo
val name = cryptoImpl.getNameForPackage(salt, packageInfo.packageName)
// return streams from plugin and app data // return streams from plugin and app data
val bOutputStream = ByteArrayOutputStream() val bOutputStream = ByteArrayOutputStream()
val bInputStream = ByteArrayInputStream(appData) val bInputStream = ByteArrayInputStream(appData)
coEvery { backupPlugin.getOutputStream(token, name) } returns bOutputStream coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { settingsManager.isQuotaUnlimited() } returns false every { settingsManager.isQuotaUnlimited() } returns false
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
coEvery { coEvery {
apkBackup.backupApkIfNecessary( apkBackup.backupApkIfNecessary(
packageInfo, packageInfo,

View file

@ -222,7 +222,7 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `clearing KV backup data throws`() = runBlocking { fun `clearing KV backup data throws`() = runBlocking {
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt every { metadataManager.salt } returns salt
coEvery { kv.clearBackupData(packageInfo) } throws IOException() coEvery { kv.clearBackupData(packageInfo, token, salt) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo)) assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
} }
@ -231,7 +231,7 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `clearing full backup data throws`() = runBlocking { fun `clearing full backup data throws`() = runBlocking {
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt every { metadataManager.salt } returns salt
coEvery { kv.clearBackupData(packageInfo) } just Runs coEvery { kv.clearBackupData(packageInfo, token, salt) } just Runs
coEvery { full.clearBackupData(packageInfo, token, salt) } throws IOException() coEvery { full.clearBackupData(packageInfo, token, salt) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo)) assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
@ -241,7 +241,7 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `clearing backup data succeeds`() = runBlocking { fun `clearing backup data succeeds`() = runBlocking {
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt every { metadataManager.salt } returns salt
coEvery { kv.clearBackupData(packageInfo) } just Runs coEvery { kv.clearBackupData(packageInfo, token, salt) } just Runs
coEvery { full.clearBackupData(packageInfo, token, salt) } just Runs coEvery { full.clearBackupData(packageInfo, token, salt) } just Runs
assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo)) assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo))
@ -264,7 +264,7 @@ internal class BackupCoordinatorTest : BackupTest() {
every { every {
metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream) metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream)
} just Runs } just Runs
every { kv.finishBackup() } returns result coEvery { kv.finishBackup() } returns result
every { metadataOutputStream.close() } just Runs every { metadataOutputStream.close() } just Runs
assertEquals(result, backup.finishBackup()) assertEquals(result, backup.finishBackup())
@ -416,8 +416,12 @@ internal class BackupCoordinatorTest : BackupTest() {
every { settingsManager.canDoBackupNow() } returns true every { settingsManager.canDoBackupNow() } returns true
every { metadataManager.isLegacyFormat } returns false every { metadataManager.isLegacyFormat } returns false
every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt
// do actual @pm@ backup // do actual @pm@ backup
coEvery { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK coEvery {
kv.performBackup(packageInfo, fileDescriptor, 0, token, salt)
} returns TRANSPORT_OK
// now check if we have opt-out apps that we need to back up APKs for // now check if we have opt-out apps that we need to back up APKs for
every { packageService.notBackedUpPackages } returns notAllowedPackages every { packageService.notBackedUpPackages } returns notAllowedPackages
// update notification // update notification

View file

@ -39,7 +39,10 @@ internal class FullBackupTest : BackupTest() {
fun `checkFullBackupSize exceeds quota`() { fun `checkFullBackupSize exceeds quota`() {
every { settingsManager.isQuotaUnlimited() } returns false every { settingsManager.isQuotaUnlimited() } returns false
assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1)) assertEquals(
TRANSPORT_QUOTA_EXCEEDED,
backup.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1)
)
} }
@Test @Test

View file

@ -8,12 +8,10 @@ import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.header.getADForKV
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.CapturingSlot import io.mockk.CapturingSlot
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
@ -21,34 +19,30 @@ import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.IOException import java.io.IOException
import java.util.Base64
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackupTest : BackupTest() { internal class KVBackupTest : BackupTest() {
private val plugin = mockk<KVBackupPlugin>() private val plugin = mockk<BackupPlugin>()
private val dataInput = mockk<BackupDataInput>() private val dataInput = mockk<BackupDataInput>()
private val notificationManager = mockk<BackupNotificationManager>() private val dbManager = mockk<KvDbManager>()
private val backup = KVBackup( private val backup = KVBackup(plugin, settingsManager, inputFactory, crypto, dbManager)
plugin = plugin,
settingsManager = settingsManager,
inputFactory = inputFactory,
crypto = crypto,
nm = notificationManager
)
private val db = mockk<KVDb>()
private val packageName = packageInfo.packageName
private val key = getRandomString(MAX_KEY_LENGTH_SIZE) private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
private val key64 = Base64.getEncoder().encodeToString(key.toByteArray(Utf8))
private val dataValue = Random.nextBytes(23) private val dataValue = Random.nextBytes(23)
private val dbBytes = Random.nextBytes(42)
private val inputStream = ByteArrayInputStream(dbBytes)
@Test @Test
fun `has no initial state`() { fun `has no initial state`() {
@ -59,82 +53,35 @@ internal class KVBackupTest : BackupTest() {
fun `simple backup with one record`() = runBlocking { fun `simple backup with one record`() = runBlocking {
singleRecordBackup() singleRecordBackup()
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt))
assertTrue(backup.hasState()) assertTrue(backup.hasState())
assertEquals(packageInfo, backup.getCurrentPackage())
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState()) assertFalse(backup.hasState())
} }
@Test
fun `@pm@ backup shows notification`() = runBlocking {
// init plugin and give back two keys
initPlugin(true, pmPackageInfo)
createBackupDataInput()
every { dataInput.readNextHeader() } returnsMany listOf(true, true, false)
every { dataInput.key } returnsMany listOf("key1", "key2")
// we don't care about values, so just use the same one always
every { dataInput.dataSize } returns dataValue.size
every { dataInput.readEntityData(any(), 0, dataValue.size) } returns dataValue.size
// store first record and show notification for it
every { notificationManager.onPmKvBackup("key1", 1, 2) } just Runs
coEvery { plugin.getOutputStreamForRecord(pmPackageInfo, "a2V5MQ") } returns outputStream
every { outputStream.write(ByteArray(1) { VERSION }) } just Runs
// store second record and show notification for it
every { notificationManager.onPmKvBackup("key2", 2, 2) } just Runs
coEvery { plugin.getOutputStreamForRecord(pmPackageInfo, "a2V5Mg") } returns outputStream
// encrypt to and close output stream
every { crypto.newEncryptingStream(outputStream, any()) } returns encryptedOutputStream
every { encryptedOutputStream.write(any<ByteArray>()) } just Runs
every { encryptedOutputStream.flush() } just Runs
every { encryptedOutputStream.close() } just Runs
every { outputStream.flush() } just Runs
every { outputStream.close() } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(pmPackageInfo, data, 0))
assertTrue(backup.hasState())
every { plugin.packageFinished(pmPackageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
// verify that notifications were shown
verifyOrder {
notificationManager.onPmKvBackup("key1", 1, 2)
notificationManager.onPmKvBackup("key2", 2, 2)
}
}
@Test @Test
fun `incremental backup with no data gets rejected`() = runBlocking { fun `incremental backup with no data gets rejected`() = runBlocking {
coEvery { plugin.hasDataForPackage(packageInfo) } returns false initPlugin(false)
every { plugin.packageFinished(packageInfo) } just Runs every { db.close() } just Runs
assertEquals( assertEquals(
TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED,
backup.performBackup(packageInfo, data, FLAG_INCREMENTAL) backup.performBackup(packageInfo, data, FLAG_INCREMENTAL, token, salt)
) )
assertFalse(backup.hasState()) assertFalse(backup.hasState())
} }
@Test
fun `check for existing data throws exception`() = runBlocking {
coEvery { plugin.hasDataForPackage(packageInfo) } throws IOException()
every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test @Test
fun `non-incremental backup with data clears old data first`() = runBlocking { fun `non-incremental backup with data clears old data first`() = runBlocking {
singleRecordBackup(true) singleRecordBackup(true)
coEvery { plugin.removeDataOfPackage(packageInfo) } just Runs coEvery { plugin.removeData(token, name) } just Runs
every { dbManager.deleteDb(packageName) } returns true
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)) assertEquals(
TRANSPORT_OK,
backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL, token, salt)
)
assertTrue(backup.hasState()) assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState()) assertFalse(backup.hasState())
@ -144,11 +91,11 @@ internal class KVBackupTest : BackupTest() {
fun `ignoring exception when clearing data when non-incremental backup has data`() = fun `ignoring exception when clearing data when non-incremental backup has data`() =
runBlocking { runBlocking {
singleRecordBackup(true) singleRecordBackup(true)
coEvery { plugin.removeDataOfPackage(packageInfo) } throws IOException() coEvery { plugin.removeData(token, name) } throws IOException()
assertEquals( assertEquals(
TRANSPORT_OK, TRANSPORT_OK,
backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL) backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL, token, salt)
) )
assertTrue(backup.hasState()) assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
@ -157,15 +104,18 @@ internal class KVBackupTest : BackupTest() {
@Test @Test
fun `package with no new data comes back ok right away`() = runBlocking { fun `package with no new data comes back ok right away`() = runBlocking {
every { crypto.getNameForPackage(salt, packageName) } returns name
every { dbManager.getDb(packageName) } returns db
every { data.close() } just Runs every { data.close() } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED)) assertEquals(
TRANSPORT_OK,
backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED, token, salt)
)
assertTrue(backup.hasState()) assertTrue(backup.hasState())
verify { data.close() } verify { data.close() }
every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState()) assertFalse(backup.hasState())
} }
@ -175,9 +125,9 @@ internal class KVBackupTest : BackupTest() {
initPlugin(false) initPlugin(false)
createBackupDataInput() createBackupDataInput()
every { dataInput.readNextHeader() } throws IOException() every { dataInput.readNextHeader() } throws IOException()
every { plugin.packageFinished(packageInfo) } just Runs every { db.close() } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0, token, salt))
assertFalse(backup.hasState()) assertFalse(backup.hasState())
} }
@ -189,9 +139,9 @@ internal class KVBackupTest : BackupTest() {
every { dataInput.key } returns key every { dataInput.key } returns key
every { dataInput.dataSize } returns dataValue.size every { dataInput.dataSize } returns dataValue.size
every { dataInput.readEntityData(any(), 0, dataValue.size) } throws IOException() every { dataInput.readEntityData(any(), 0, dataValue.size) } throws IOException()
every { plugin.packageFinished(packageInfo) } just Runs every { db.close() } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0, token, salt))
assertFalse(backup.hasState()) assertFalse(backup.hasState())
} }
@ -199,24 +149,46 @@ internal class KVBackupTest : BackupTest() {
fun `no data records`() = runBlocking { fun `no data records`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(false)) getDataInput(listOf(false))
every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt))
assertTrue(backup.hasState()) assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState()) assertFalse(backup.hasState())
} }
@Test
fun `null data deletes key`() = runBlocking {
initPlugin(true)
createBackupDataInput()
every { dataInput.readNextHeader() } returns true andThen false
every { dataInput.key } returns key
every { dataInput.dataSize } returns -1 // just documented by example code in LocalTransport
every { db.delete(key) } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt))
assertTrue(backup.hasState())
uploadData()
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test @Test
fun `exception while writing version`() = runBlocking { fun `exception while writing version`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(true)) getDataInput(listOf(true, false))
coEvery { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream 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 { plugin.getOutputStream(token, name) } returns outputStream
every { outputStream.write(ByteArray(1) { VERSION }) } throws IOException() every { outputStream.write(ByteArray(1) { VERSION }) } throws IOException()
every { outputStream.close() } just Runs every { outputStream.close() } just Runs
every { plugin.packageFinished(packageInfo) } just Runs assertEquals(TRANSPORT_ERROR, backup.finishBackup())
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState()) assertFalse(backup.hasState())
verify { outputStream.close() } verify { outputStream.close() }
@ -224,65 +196,41 @@ internal class KVBackupTest : BackupTest() {
@Test @Test
fun `exception while writing encrypted value to output stream`() = runBlocking { fun `exception while writing encrypted value to output stream`() = runBlocking {
initPlugin(false)
getDataInput(listOf(true))
writeVersionAndEncrypt()
every { encryptedOutputStream.write(dataValue) } throws IOException()
every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
verify { outputStream.close() }
}
@Test
fun `exception while flushing output stream`() = runBlocking {
initPlugin(false)
getDataInput(listOf(true))
writeVersionAndEncrypt()
every { encryptedOutputStream.write(dataValue) } just Runs
every { encryptedOutputStream.flush() } throws IOException()
every { encryptedOutputStream.close() } just Runs
every { outputStream.close() } just Runs
every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
verify { outputStream.close() }
}
@Test
fun `ignoring exception while closing output stream`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(true, false)) getDataInput(listOf(true, false))
writeVersionAndEncrypt() every { db.put(key, dataValue) } just Runs
every { encryptedOutputStream.write(dataValue) } just Runs
every { encryptedOutputStream.flush() } just Runs
every { encryptedOutputStream.close() } just Runs
every { outputStream.close() } just Runs
every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt))
assertTrue(backup.hasState()) assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
every { db.vacuum() } just Runs
every { db.close() } just Runs
coEvery { plugin.getOutputStream(token, name) } returns outputStream
every { outputStream.write(ByteArray(1) { VERSION }) } just Runs
val ad = getADForKV(VERSION, packageInfo.packageName)
every { crypto.newEncryptingStream(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()
}
} }
private fun singleRecordBackup(hasDataForPackage: Boolean = false) { private fun singleRecordBackup(hasDataForPackage: Boolean = false) {
initPlugin(hasDataForPackage) initPlugin(hasDataForPackage)
every { db.put(key, dataValue) } just Runs
getDataInput(listOf(true, false)) getDataInput(listOf(true, false))
writeVersionAndEncrypt() uploadData()
every { encryptedOutputStream.write(dataValue) } just Runs
every { encryptedOutputStream.flush() } just Runs
every { encryptedOutputStream.close() } just Runs
every { outputStream.close() } just Runs
every { plugin.packageFinished(packageInfo) } just Runs
} }
private fun initPlugin(hasDataForPackage: Boolean = false, pi: PackageInfo = packageInfo) { private fun initPlugin(hasDataForPackage: Boolean = false, pi: PackageInfo = packageInfo) {
coEvery { plugin.hasDataForPackage(pi) } returns hasDataForPackage every { dbManager.existsDb(pi.packageName) } returns hasDataForPackage
every { crypto.getNameForPackage(salt, pi.packageName) } returns name
every { dbManager.getDb(pi.packageName) } returns db
} }
private fun createBackupDataInput() { private fun createBackupDataInput() {
@ -301,11 +249,19 @@ internal class KVBackupTest : BackupTest() {
} }
} }
private fun writeVersionAndEncrypt() { private fun uploadData() {
coEvery { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream every { db.vacuum() } just Runs
every { db.close() } just Runs
coEvery { plugin.getOutputStream(token, name) } returns outputStream
every { outputStream.write(ByteArray(1) { VERSION }) } just Runs every { outputStream.write(ByteArray(1) { VERSION }) } just Runs
val ad = getADForKV(VERSION, packageInfo.packageName) val ad = getADForKV(VERSION, packageInfo.packageName)
every { crypto.newEncryptingStream(outputStream, ad) } returns encryptedOutputStream every { crypto.newEncryptingStream(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
} }
} }

View file

@ -0,0 +1,138 @@
package com.stevesoltys.seedvault.transport.backup
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.toByteArrayFromHex
import com.stevesoltys.seedvault.toHexString
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertFalse
import junit.framework.Assert.assertNull
import junit.framework.Assert.assertTrue
import org.json.JSONObject
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlin.random.Random
class TestKvDbManager : KvDbManager {
private var db: TestKVDb? = null
override fun getDb(packageName: String): KVDb {
return TestKVDb().apply { db = this }
}
override fun getDbInputStream(packageName: String): InputStream {
return ByteArrayInputStream(db!!.serialize().toByteArray())
}
override fun existsDb(packageName: String): Boolean {
return db != null
}
override fun deleteDb(packageName: String): Boolean {
clearDb()
return true
}
fun clearDb() {
this.db = null
}
fun readDbFromStream(inputStream: InputStream) {
this.db = TestKVDb.deserialize(String(inputStream.readBytes()))
}
}
class TestKVDb(private val json: JSONObject = JSONObject()) : KVDb {
override fun put(key: String, value: ByteArray) {
json.put(key, value.toHexString(spacer = ""))
}
override fun get(key: String): ByteArray? {
return json.getByteArray(key)
}
override fun getAll(): List<Pair<String, ByteArray>> {
val list = ArrayList<Pair<String, ByteArray>>(json.length())
json.keys().forEach { key ->
val bytes = json.getByteArray(key)
if (bytes != null) list.add(Pair(key, bytes))
}
return list
}
override fun delete(key: String) {
json.remove(key)
}
override fun vacuum() {
}
override fun close() {
}
fun serialize(): String {
return json.toString()
}
companion object {
fun deserialize(str: String): TestKVDb {
return TestKVDb(JSONObject(str))
}
}
private fun JSONObject.getByteArray(key: String): ByteArray? {
val str = optString(key, "")
if (str.isNullOrEmpty()) return null
return str.toByteArrayFromHex()
}
}
class TestKvDbManagerTest {
private val dbManager = TestKvDbManager()
private val key1 = getRandomString(12)
private val key2 = getRandomString(12)
private val bytes1 = Random.nextBytes(23)
private val bytes2 = Random.nextBytes(23)
@Test
fun test() {
assertFalse(dbManager.existsDb("foo"))
val db = dbManager.getDb("foo")
db.put(key1, bytes1)
db.put(key2, bytes2)
assertTrue(dbManager.existsDb("foo"))
assertArrayEquals(bytes1, db.get(key1))
assertArrayEquals(bytes2, db.get(key2))
val list = db.getAll()
assertEquals(2, list.size)
assertEquals(key1, list[0].first)
assertArrayEquals(bytes1, list[0].second)
assertEquals(key2, list[1].first)
assertArrayEquals(bytes2, list[1].second)
val dbBytes = dbManager.getDbInputStream("foo").readBytes()
assertTrue(dbManager.existsDb("foo"))
dbManager.clearDb()
assertFalse(dbManager.existsDb("foo"))
dbManager.readDbFromStream(ByteArrayInputStream(dbBytes))
assertTrue(dbManager.existsDb("foo"))
assertArrayEquals(bytes1, db.get(key1))
assertArrayEquals(bytes2, db.get(key2))
assertNull(db.get("bar"))
db.delete(key2)
assertNull(db.get(key2))
}
}