Full backup and restore using v2
while maintaining support for v0 and v1
This commit is contained in:
parent
83708d9403
commit
7c7ea5fcd7
19 changed files with 695 additions and 610 deletions
|
@ -36,11 +36,11 @@ class KoinInstrumentationTestApp : App() {
|
|||
single { spyk(SettingsManager(context)) }
|
||||
|
||||
single { spyk(BackupNotificationManager(context)) }
|
||||
single { spyk(FullBackup(get(), get(), get(), get(), get())) }
|
||||
single { spyk(FullBackup(get(), get(), get(), get())) }
|
||||
single { spyk(KVBackup(get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(InputFactory()) }
|
||||
|
||||
single { spyk(FullRestore(get(), get(), get(), get(), get())) }
|
||||
single { spyk(FullRestore(get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(KVRestore(get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(OutputFactory()) }
|
||||
|
||||
|
|
|
@ -157,7 +157,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
var dataIntercept = ByteArrayOutputStream()
|
||||
|
||||
coEvery {
|
||||
spyFullBackup.performFullBackup(any(), any(), any(), any(), any())
|
||||
spyFullBackup.performFullBackup(any(), any(), any())
|
||||
} answers {
|
||||
packageName = firstArg<PackageInfo>().packageName
|
||||
callOriginal()
|
||||
|
@ -172,7 +172,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
)
|
||||
}
|
||||
|
||||
every {
|
||||
coEvery {
|
||||
spyFullBackup.finishBackup()
|
||||
} answers {
|
||||
val result = callOriginal()
|
||||
|
|
|
@ -189,7 +189,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
clearMocks(spyFullRestore)
|
||||
|
||||
coEvery {
|
||||
spyFullRestore.initializeState(any(), any(), any(), any())
|
||||
spyFullRestore.initializeState(any(), any(), any())
|
||||
} answers {
|
||||
packageName?.let {
|
||||
restoreResult.full[it] = dataIntercept.toByteArray().sha256()
|
||||
|
|
|
@ -145,10 +145,9 @@ internal class MetadataManager(
|
|||
packageInfo: PackageInfo,
|
||||
type: BackupType,
|
||||
size: Long?,
|
||||
metadataOutputStream: OutputStream,
|
||||
) {
|
||||
val packageName = packageInfo.packageName
|
||||
modifyMetadata(metadataOutputStream) {
|
||||
modifyCachedMetadata {
|
||||
val now = clock.time()
|
||||
metadata.time = now
|
||||
metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
|
||||
|
|
|
@ -23,15 +23,15 @@ 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
|
||||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
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.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.getMetadataOutputStream
|
||||
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import java.io.IOException
|
||||
|
@ -63,6 +63,7 @@ private class CoordinatorState(
|
|||
internal class BackupCoordinator(
|
||||
private val context: Context,
|
||||
private val backendManager: BackendManager,
|
||||
private val appBackupManager: AppBackupManager,
|
||||
private val kv: KVBackup,
|
||||
private val full: FullBackup,
|
||||
private val clock: Clock,
|
||||
|
@ -73,6 +74,8 @@ internal class BackupCoordinator(
|
|||
) {
|
||||
|
||||
private val backend get() = backendManager.backend
|
||||
private val snapshotCreator
|
||||
get() = appBackupManager.snapshotCreator ?: error("No SnapshotCreator")
|
||||
private val state = CoordinatorState(
|
||||
calledInitialize = false,
|
||||
calledClearBackupData = false,
|
||||
|
@ -154,7 +157,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.getQuota() else kv.getQuota()
|
||||
val quota = if (isFullBackup) full.quota else kv.getQuota()
|
||||
Log.i(TAG, "Reported quota of $quota bytes.")
|
||||
return quota
|
||||
}
|
||||
|
@ -262,15 +265,13 @@ internal class BackupCoordinator(
|
|||
return result
|
||||
}
|
||||
|
||||
suspend fun performFullBackup(
|
||||
fun performFullBackup(
|
||||
targetPackage: PackageInfo,
|
||||
fileDescriptor: ParcelFileDescriptor,
|
||||
flags: Int,
|
||||
): Int {
|
||||
state.cancelReason = UNKNOWN_ERROR
|
||||
val token = settingsManager.getToken() ?: error("no token in performFullBackup")
|
||||
val salt = metadataManager.salt
|
||||
return full.performFullBackup(targetPackage, fileDescriptor, flags, token, salt)
|
||||
return full.performFullBackup(targetPackage, fileDescriptor, flags)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -299,8 +300,8 @@ internal class BackupCoordinator(
|
|||
* It needs to tear down any ongoing backup state here.
|
||||
*/
|
||||
suspend fun cancelFullBackup() {
|
||||
val packageInfo = full.getCurrentPackage()
|
||||
?: throw AssertionError("Cancelling full backup, but no current package")
|
||||
val packageInfo = full.currentPackageInfo
|
||||
?: error("Cancelling full backup, but no current package")
|
||||
Log.i(
|
||||
TAG, "Cancel full backup of ${packageInfo.packageName}" +
|
||||
" because of ${state.cancelReason}"
|
||||
|
@ -308,9 +309,7 @@ internal class BackupCoordinator(
|
|||
// don't bother with system apps that have no data
|
||||
val ignoreApp = state.cancelReason == NO_DATA && packageInfo.isSystemApp()
|
||||
if (!ignoreApp) onPackageBackupError(packageInfo, BackupType.FULL)
|
||||
val token = settingsManager.getToken() ?: error("no token in cancelFullBackup")
|
||||
val salt = metadataManager.salt
|
||||
full.cancelFullBackup(token, salt, ignoreApp)
|
||||
full.cancelFullBackup()
|
||||
}
|
||||
|
||||
// Clear and Finish
|
||||
|
@ -335,12 +334,7 @@ internal class BackupCoordinator(
|
|||
Log.w(TAG, "Error clearing K/V backup data for $packageName", e)
|
||||
return TRANSPORT_ERROR
|
||||
}
|
||||
try {
|
||||
full.clearBackupData(packageInfo, token, salt)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error clearing full backup data for $packageName", e)
|
||||
return TRANSPORT_ERROR
|
||||
}
|
||||
// we don't clear backup data anymore, we have snapshots and those old ones stay valid
|
||||
state.calledClearBackupData = true
|
||||
return TRANSPORT_OK
|
||||
}
|
||||
|
@ -355,7 +349,7 @@ internal class BackupCoordinator(
|
|||
*/
|
||||
suspend fun finishBackup(): Int = when {
|
||||
kv.hasState() -> {
|
||||
check(!full.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
|
||||
|
@ -369,7 +363,7 @@ internal class BackupCoordinator(
|
|||
// call onPackageBackedUp for @pm@ only if we can do backups right now
|
||||
if (isNormalBackup || backendManager.canDoBackupNow()) {
|
||||
try {
|
||||
onPackageBackedUp(packageInfo, BackupType.KV, size)
|
||||
metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, size)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
|
||||
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
||||
|
@ -379,24 +373,25 @@ internal class BackupCoordinator(
|
|||
}
|
||||
result
|
||||
}
|
||||
full.hasState() -> {
|
||||
full.hasState -> {
|
||||
check(!kv.hasState()) {
|
||||
"Full backup has state, but K/V backup has dangling state as well"
|
||||
}
|
||||
// getCurrentPackage() not-null because we have state
|
||||
val packageInfo = full.getCurrentPackage()!!
|
||||
val packageInfo = full.currentPackageInfo!!
|
||||
val packageName = packageInfo.packageName
|
||||
val size = full.getCurrentSize()
|
||||
// tell full backup to finish
|
||||
var result = full.finishBackup()
|
||||
try {
|
||||
onPackageBackedUp(packageInfo, BackupType.FULL, size)
|
||||
val backupData = full.finishBackup()
|
||||
snapshotCreator.onPackageBackedUp(packageInfo, BackupType.FULL, backupData)
|
||||
// TODO unify both calls
|
||||
metadataManager.onPackageBackedUp(packageInfo, BackupType.FULL, backupData.size)
|
||||
TRANSPORT_OK
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
|
||||
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
||||
result = TRANSPORT_PACKAGE_REJECTED
|
||||
TRANSPORT_PACKAGE_REJECTED
|
||||
}
|
||||
result
|
||||
}
|
||||
state.expectFinish -> {
|
||||
state.onFinish()
|
||||
|
@ -405,13 +400,6 @@ internal class BackupCoordinator(
|
|||
else -> throw IllegalStateException("Unexpected state in finishBackup()")
|
||||
}
|
||||
|
||||
private suspend fun onPackageBackedUp(packageInfo: PackageInfo, type: BackupType, size: Long?) {
|
||||
val token = settingsManager.getToken() ?: error("no token")
|
||||
backend.getMetadataOutputStream(token).use {
|
||||
metadataManager.onPackageBackedUp(packageInfo, type, size, it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onPackageBackupError(packageInfo: PackageInfo, type: BackupType) {
|
||||
val packageName = packageInfo.packageName
|
||||
try {
|
||||
|
|
|
@ -38,17 +38,17 @@ val backupModule = module {
|
|||
}
|
||||
single {
|
||||
FullBackup(
|
||||
backendManager = get(),
|
||||
settingsManager = get(),
|
||||
nm = get(),
|
||||
backupReceiver = get(),
|
||||
inputFactory = get(),
|
||||
crypto = get(),
|
||||
)
|
||||
}
|
||||
single {
|
||||
BackupCoordinator(
|
||||
context = androidContext(),
|
||||
backendManager = get(),
|
||||
appBackupManager = get(),
|
||||
kv = get(),
|
||||
full = get(),
|
||||
clock = get(),
|
||||
|
|
|
@ -16,7 +16,9 @@ import java.io.InputStream
|
|||
data class BackupData(
|
||||
val chunks: List<String>,
|
||||
val chunkMap: Map<String, Blob>,
|
||||
)
|
||||
) {
|
||||
val size get() = chunkMap.values.sumOf { it.uncompressedLength }.toLong()
|
||||
}
|
||||
|
||||
internal class BackupReceiver(
|
||||
private val blobsCache: BlobsCache,
|
||||
|
@ -40,8 +42,10 @@ internal class BackupReceiver(
|
|||
}
|
||||
private val chunks = mutableListOf<String>()
|
||||
private val chunkMap = mutableMapOf<String, Blob>()
|
||||
private var addedBytes = false
|
||||
|
||||
suspend fun addBytes(bytes: ByteArray) {
|
||||
addedBytes = true
|
||||
chunker.addBytes(bytes).forEach { chunk ->
|
||||
onNewChunk(chunk)
|
||||
}
|
||||
|
@ -73,9 +77,15 @@ internal class BackupReceiver(
|
|||
val backupData = BackupData(chunks.toList(), chunkMap.toMap())
|
||||
chunks.clear()
|
||||
chunkMap.clear()
|
||||
addedBytes = false
|
||||
return backupData
|
||||
}
|
||||
|
||||
fun assertFinalized() {
|
||||
// TODO maybe even use a userTag and throw also above if that doesn't match
|
||||
check(!addedBytes) { "Re-used non-finalized BackupReceiver" }
|
||||
}
|
||||
|
||||
private suspend fun onNewChunk(chunk: Chunk) {
|
||||
chunks.add(chunk.hash)
|
||||
|
||||
|
|
|
@ -13,30 +13,19 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
|||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import java.io.Closeable
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
private class FullBackupState(
|
||||
val packageInfo: PackageInfo,
|
||||
val inputFileDescriptor: ParcelFileDescriptor,
|
||||
val inputStream: InputStream,
|
||||
var outputStreamInit: (suspend () -> OutputStream)?,
|
||||
) {
|
||||
/**
|
||||
* This is an encrypted stream that can be written to directly.
|
||||
*/
|
||||
var outputStream: OutputStream? = null
|
||||
val packageName: String = packageInfo.packageName
|
||||
var size: Long = 0
|
||||
}
|
||||
|
@ -47,31 +36,28 @@ private val TAG = FullBackup::class.java.simpleName
|
|||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class FullBackup(
|
||||
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 backend get() = backendManager.backend
|
||||
private var state: FullBackupState? = null
|
||||
|
||||
fun hasState() = state != null
|
||||
|
||||
fun getCurrentPackage() = state?.packageInfo
|
||||
|
||||
fun getCurrentSize() = state?.size
|
||||
|
||||
fun getQuota(): Long {
|
||||
return if (settingsManager.isQuotaUnlimited()) Long.MAX_VALUE else DEFAULT_QUOTA_FULL_BACKUP
|
||||
val hasState: Boolean get() = state != null
|
||||
val currentPackageInfo get() = state?.packageInfo
|
||||
val quota
|
||||
get() = if (settingsManager.isQuotaUnlimited()) {
|
||||
Long.MAX_VALUE
|
||||
} else {
|
||||
DEFAULT_QUOTA_FULL_BACKUP
|
||||
}
|
||||
|
||||
fun checkFullBackupSize(size: Long): Int {
|
||||
Log.i(TAG, "Check full backup size of $size bytes.")
|
||||
return when {
|
||||
size <= 0 -> TRANSPORT_PACKAGE_REJECTED
|
||||
size > getQuota() -> TRANSPORT_QUOTA_EXCEEDED
|
||||
size > quota -> TRANSPORT_QUOTA_EXCEEDED
|
||||
else -> TRANSPORT_OK
|
||||
}
|
||||
}
|
||||
|
@ -111,71 +97,42 @@ internal class FullBackup(
|
|||
* [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data;
|
||||
* [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time.
|
||||
*/
|
||||
suspend fun performFullBackup(
|
||||
fun performFullBackup(
|
||||
targetPackage: PackageInfo,
|
||||
socket: ParcelFileDescriptor,
|
||||
@Suppress("UNUSED_PARAMETER") flags: Int = 0,
|
||||
token: Long,
|
||||
salt: String,
|
||||
): Int {
|
||||
if (state != null) throw AssertionError()
|
||||
if (state != null) error("state wasn't initialized for $targetPackage")
|
||||
val packageName = targetPackage.packageName
|
||||
Log.i(TAG, "Perform full backup for $packageName.")
|
||||
|
||||
// create new state
|
||||
val inputStream = inputFactory.getInputStream(socket)
|
||||
state = FullBackupState(targetPackage, socket, inputStream) {
|
||||
Log.d(TAG, "Initializing OutputStream for $packageName.")
|
||||
val name = crypto.getNameForPackage(salt, packageName)
|
||||
// get OutputStream to write backup data into
|
||||
val outputStream = try {
|
||||
backend.save(LegacyAppBackupFile.Blob(token, name))
|
||||
} catch (e: IOException) {
|
||||
"Error getting OutputStream for full backup of $packageName".let {
|
||||
Log.e(TAG, it, e)
|
||||
}
|
||||
throw(e)
|
||||
}
|
||||
// store version header
|
||||
val state = this.state ?: throw AssertionError()
|
||||
outputStream.write(ByteArray(1) { VERSION })
|
||||
crypto.newEncryptingStreamV1(outputStream, getADForFull(VERSION, state.packageName))
|
||||
} // this lambda is only called before we actually write backup data the first time
|
||||
state = FullBackupState(targetPackage, socket, inputStream)
|
||||
backupReceiver.assertFinalized()
|
||||
return TRANSPORT_OK
|
||||
}
|
||||
|
||||
suspend fun sendBackupData(numBytes: Int): Int {
|
||||
val state = this.state
|
||||
?: throw AssertionError("Attempted sendBackupData before performFullBackup")
|
||||
val state = this.state ?: error("Attempted sendBackupData before performFullBackup")
|
||||
|
||||
// check if size fits quota
|
||||
state.size += numBytes
|
||||
val quota = getQuota()
|
||||
if (state.size > quota) {
|
||||
val newSize = state.size + numBytes
|
||||
if (newSize > quota) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Full backup of additional $numBytes exceeds quota of $quota with ${state.size}."
|
||||
"Full backup of additional $numBytes exceeds quota of $quota with $newSize."
|
||||
)
|
||||
return TRANSPORT_QUOTA_EXCEEDED
|
||||
}
|
||||
|
||||
return try {
|
||||
// get output stream or initialize it, if it does not yet exist
|
||||
check((state.outputStream != null) xor (state.outputStreamInit != null)) {
|
||||
"No OutputStream xor no StreamGetter"
|
||||
}
|
||||
val outputStream = state.outputStream ?: suspend {
|
||||
val stream = state.outputStreamInit!!() // not-null due to check above
|
||||
state.outputStream = stream
|
||||
stream
|
||||
}()
|
||||
state.outputStreamInit = null // the stream init lambda is not needed beyond that point
|
||||
|
||||
// read backup data and write it to encrypted output stream
|
||||
val payload = ByteArray(numBytes)
|
||||
val read = state.inputStream.read(payload, 0, numBytes)
|
||||
if (read != numBytes) throw EOFException("Read $read bytes instead of $numBytes.")
|
||||
outputStream.write(payload)
|
||||
backupReceiver.addBytes(payload)
|
||||
state.size += numBytes
|
||||
TRANSPORT_OK
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error handling backup data for ${state.packageName}: ", e)
|
||||
|
@ -184,44 +141,41 @@ internal class FullBackup(
|
|||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) {
|
||||
val name = crypto.getNameForPackage(salt, packageInfo.packageName)
|
||||
backend.remove(LegacyAppBackupFile.Blob(token, name))
|
||||
}
|
||||
|
||||
suspend fun cancelFullBackup(token: Long, salt: String, ignoreApp: Boolean) {
|
||||
Log.i(TAG, "Cancel full backup")
|
||||
val state = this.state ?: throw AssertionError("No state when canceling")
|
||||
suspend fun cancelFullBackup() {
|
||||
val state = this.state ?: error("No state when canceling")
|
||||
Log.i(TAG, "Cancel full backup for ${state.packageName}")
|
||||
// TODO check if worth keeping the blobs. they've been uploaded already and may be re-usable
|
||||
// so we could add them to the snapshot's blobMap or just let prune remove them at the end
|
||||
try {
|
||||
if (!ignoreApp) clearBackupData(state.packageInfo, token, salt)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error cancelling full backup for ${state.packageName}", e)
|
||||
backupReceiver.finalize()
|
||||
} catch (e: Exception) {
|
||||
// as the backup was cancelled anyway, we don't care if finalizing had an error
|
||||
Log.e(TAG, "Error finalizing backup in cancelFullBackup().", e)
|
||||
}
|
||||
clearState()
|
||||
// TODO roll back to the previous known-good archive
|
||||
}
|
||||
|
||||
fun finishBackup(): Int {
|
||||
Log.i(TAG, "Finish full backup of ${state!!.packageName}. Wrote ${state!!.size} bytes")
|
||||
return clearState()
|
||||
/**
|
||||
* Returns a pair of the [BackupData] after finalizing last chunks and the total backup size.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
suspend fun finishBackup(): BackupData {
|
||||
val state = this.state ?: error("No state when finishing")
|
||||
Log.i(TAG, "Finish full backup of ${state.packageName}. Wrote ${state.size} bytes")
|
||||
val result = try {
|
||||
backupReceiver.finalize()
|
||||
} finally {
|
||||
clearState()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun clearState(): Int {
|
||||
val state = this.state ?: throw AssertionError("Trying to clear empty state.")
|
||||
return try {
|
||||
state.outputStream?.flush()
|
||||
closeLogging(state.outputStream)
|
||||
private fun clearState() {
|
||||
val state = this.state ?: error("Trying to clear empty state.")
|
||||
closeLogging(state.inputStream)
|
||||
closeLogging(state.inputFileDescriptor)
|
||||
TRANSPORT_OK
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error when clearing state", e)
|
||||
TRANSPORT_ERROR
|
||||
} finally {
|
||||
this.state = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeLogging(closable: Closeable?) = try {
|
||||
closable?.close()
|
||||
|
|
|
@ -12,14 +12,15 @@ import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
|||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.HeaderReader
|
||||
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import libcore.io.IoUtils.closeQuietly
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob
|
||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
|
@ -29,9 +30,10 @@ import java.security.GeneralSecurityException
|
|||
|
||||
private class FullRestoreState(
|
||||
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,
|
||||
) {
|
||||
var inputStream: InputStream? = null
|
||||
}
|
||||
|
@ -40,6 +42,7 @@ private val TAG = FullRestore::class.java.simpleName
|
|||
|
||||
internal class FullRestore(
|
||||
private val backendManager: BackendManager,
|
||||
private val loader: Loader,
|
||||
@Suppress("Deprecation")
|
||||
private val legacyPlugin: LegacyStoragePlugin,
|
||||
private val outputFactory: OutputFactory,
|
||||
|
@ -50,7 +53,7 @@ internal class FullRestore(
|
|||
private val backend get() = backendManager.backend
|
||||
private var state: FullRestoreState? = null
|
||||
|
||||
fun hasState() = state != null
|
||||
val hasState get() = state != null
|
||||
|
||||
/**
|
||||
* Return true if there is data stored for the given package.
|
||||
|
@ -69,8 +72,16 @@ internal class FullRestore(
|
|||
* It is possible that the system decides to not restore the package.
|
||||
* Then a new state will be initialized right away without calling other methods.
|
||||
*/
|
||||
fun initializeState(version: Byte, token: Long, name: String, packageInfo: PackageInfo) {
|
||||
state = FullRestoreState(version, token, name, packageInfo)
|
||||
fun initializeState(version: Byte, packageInfo: PackageInfo, blobHandles: List<Blob>) {
|
||||
state = FullRestoreState(version, packageInfo, blobHandles)
|
||||
}
|
||||
|
||||
fun initializeStateV1(token: Long, name: String, packageInfo: PackageInfo) {
|
||||
state = FullRestoreState(1, packageInfo, null, token, name)
|
||||
}
|
||||
|
||||
fun initializeStateV0(token: Long, packageInfo: PackageInfo) {
|
||||
state = FullRestoreState(0x00, packageInfo, null, token)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -107,20 +118,30 @@ internal class FullRestore(
|
|||
if (state.inputStream == null) {
|
||||
Log.i(TAG, "First Chunk, initializing package input stream.")
|
||||
try {
|
||||
if (state.version == 0.toByte()) {
|
||||
when (state.version) {
|
||||
0.toByte() -> {
|
||||
val token = state.token ?: error("no token for v0 backup")
|
||||
val inputStream =
|
||||
legacyPlugin.getInputStreamForPackage(state.token, state.packageInfo)
|
||||
legacyPlugin.getInputStreamForPackage(token, state.packageInfo)
|
||||
val version = headerReader.readVersion(inputStream, state.version)
|
||||
@Suppress("deprecation")
|
||||
crypto.decryptHeader(inputStream, version, packageName)
|
||||
state.inputStream = inputStream
|
||||
} else {
|
||||
val handle = LegacyAppBackupFile.Blob(state.token, state.name)
|
||||
}
|
||||
1.toByte() -> {
|
||||
val token = state.token ?: error("no token for v1 backup")
|
||||
val name = state.name ?: error("no name for v1 backup")
|
||||
val handle = LegacyAppBackupFile.Blob(token, name)
|
||||
val inputStream = backend.load(handle)
|
||||
val version = headerReader.readVersion(inputStream, state.version)
|
||||
val ad = getADForFull(version, packageName)
|
||||
state.inputStream = crypto.newDecryptingStreamV1(inputStream, ad)
|
||||
}
|
||||
else -> {
|
||||
val handles = state.blobHandles ?: error("no blob handles for v2")
|
||||
state.inputStream = loader.loadFiles(handles)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error getting input stream for $packageName", e)
|
||||
return TRANSPORT_PACKAGE_REJECTED
|
||||
|
|
|
@ -30,6 +30,7 @@ import com.stevesoltys.seedvault.proto.Snapshot
|
|||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS
|
||||
import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS
|
||||
import com.stevesoltys.seedvault.transport.backup.getBlobHandles
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
|
@ -261,8 +262,11 @@ internal class RestoreCoordinator(
|
|||
val packageInfo = state.packages.next()
|
||||
val version = state.backup.version
|
||||
if (version == 0.toByte()) return nextRestorePackageV0(state, packageInfo)
|
||||
if (version == 1.toByte()) return nextRestorePackageV1(state, packageInfo)
|
||||
|
||||
val packageName = packageInfo.packageName
|
||||
val repoId = state.backup.repoId ?: error("No repoId in v2 backup")
|
||||
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)
|
||||
|
@ -278,8 +282,57 @@ internal class RestoreCoordinator(
|
|||
}
|
||||
|
||||
BackupType.FULL -> {
|
||||
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)
|
||||
failedPackages.add(packageName)
|
||||
// abort here as this is close to an assertion error
|
||||
return null
|
||||
}
|
||||
full.initializeState(version, packageInfo, blobHandles)
|
||||
state.currentPackage = packageName
|
||||
TYPE_FULL_STREAM
|
||||
}
|
||||
|
||||
null -> {
|
||||
Log.i(TAG, "No backup type found for $packageName. Skipping...")
|
||||
state.backup.packageMetadataMap[packageName]?.backupType?.let { s ->
|
||||
Log.w(TAG, "State was ${s.name}")
|
||||
}
|
||||
failedPackages.add(packageName)
|
||||
// don't return null and cause abort here, but try next package
|
||||
return nextRestorePackage()
|
||||
}
|
||||
}
|
||||
return RestoreDescription(packageName, type)
|
||||
}
|
||||
|
||||
@Suppress("deprecation")
|
||||
private suspend fun nextRestorePackageV1(
|
||||
state: RestoreCoordinatorState,
|
||||
packageInfo: PackageInfo,
|
||||
): RestoreDescription? {
|
||||
val packageName = packageInfo.packageName
|
||||
val type = when (state.backup.packageMetadataMap[packageName]?.backupType) {
|
||||
BackupType.KV -> {
|
||||
val name = crypto.getNameForPackage(state.backup.salt, packageName)
|
||||
full.initializeState(version, state.token, name, packageInfo)
|
||||
kv.initializeState(
|
||||
version = 1,
|
||||
token = state.token,
|
||||
name = name,
|
||||
packageInfo = packageInfo,
|
||||
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
|
||||
}
|
||||
|
@ -315,7 +368,7 @@ internal class RestoreCoordinator(
|
|||
|
||||
full.hasDataForPackage(state.token, packageInfo) -> {
|
||||
Log.i(TAG, "Found full backup data for $packageName.")
|
||||
full.initializeState(0x00, state.token, "", packageInfo)
|
||||
full.initializeStateV0(state.token, packageInfo)
|
||||
state.currentPackage = packageName
|
||||
TYPE_FULL_STREAM
|
||||
}
|
||||
|
@ -380,7 +433,7 @@ internal class RestoreCoordinator(
|
|||
*/
|
||||
fun finishRestore() {
|
||||
Log.d(TAG, "finishRestore")
|
||||
if (full.hasState()) full.finishRestore()
|
||||
if (full.hasState) full.finishRestore()
|
||||
state = null
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ val restoreModule = module {
|
|||
single { OutputFactory() }
|
||||
single { Loader(get(), get()) }
|
||||
single { KVRestore(get(), get(), get(), get(), get(), get()) }
|
||||
single { FullRestore(get(), get(), get(), get(), get()) }
|
||||
single { FullRestore(get(), get(), get(), get(), get(), get()) }
|
||||
single {
|
||||
RestoreCoordinator(
|
||||
context = androidContext(),
|
||||
|
|
|
@ -40,7 +40,6 @@ import org.junit.Assert.assertEquals
|
|||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
@ -358,7 +357,7 @@ class MetadataManagerTest {
|
|||
every { clock.time() } returns time
|
||||
expectModifyMetadata(initialMetadata)
|
||||
|
||||
manager.onPackageBackedUp(packageInfo, BackupType.FULL, size, storageOutputStream)
|
||||
manager.onPackageBackedUp(packageInfo, BackupType.FULL, size)
|
||||
|
||||
assertEquals(
|
||||
packageMetadata.copy(
|
||||
|
@ -388,7 +387,7 @@ class MetadataManagerTest {
|
|||
every { settingsManager.d2dBackupsEnabled() } returns true
|
||||
every { context.packageManager } returns packageManager
|
||||
|
||||
manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L, storageOutputStream)
|
||||
manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L)
|
||||
assertTrue(initialMetadata.d2dBackup)
|
||||
|
||||
verify {
|
||||
|
@ -397,35 +396,6 @@ class MetadataManagerTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test onPackageBackedUp() fails to write to storage`() {
|
||||
val updateTime = time + 1
|
||||
val size = Random.nextLong()
|
||||
val updatedMetadata = initialMetadata.copy(
|
||||
time = updateTime,
|
||||
packageMetadataMap = PackageMetadataMap() // otherwise this isn't copied, but referenced
|
||||
)
|
||||
updatedMetadata.packageMetadataMap[packageName] =
|
||||
PackageMetadata(updateTime, APK_AND_DATA, BackupType.KV, size)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
expectReadFromCache()
|
||||
every { clock.time() } returns updateTime
|
||||
every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException()
|
||||
|
||||
try {
|
||||
manager.onPackageBackedUp(packageInfo, BackupType.KV, size, storageOutputStream)
|
||||
fail()
|
||||
} catch (e: IOException) {
|
||||
// expected
|
||||
}
|
||||
|
||||
assertEquals(0L, manager.getLastBackupTime()) // time was reverted
|
||||
assertNull(manager.getPackageMetadata(packageName)) // no package metadata got added
|
||||
|
||||
verify { cacheInputStream.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test onPackageBackedUp() with filled cache`() {
|
||||
val cachedPackageName = getRandomString()
|
||||
|
@ -445,7 +415,7 @@ class MetadataManagerTest {
|
|||
every { clock.time() } returns time
|
||||
expectModifyMetadata(updatedMetadata)
|
||||
|
||||
manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L, storageOutputStream)
|
||||
manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L)
|
||||
|
||||
assertEquals(time, manager.getLastBackupTime())
|
||||
assertEquals(PackageMetadata(time), manager.getPackageMetadata(cachedPackageName))
|
||||
|
|
|
@ -22,11 +22,14 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
|
|||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupReceiver
|
||||
import com.stevesoltys.seedvault.transport.backup.FullBackup
|
||||
import com.stevesoltys.seedvault.transport.backup.InputFactory
|
||||
import com.stevesoltys.seedvault.transport.backup.KVBackup
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.transport.backup.SnapshotCreator
|
||||
import com.stevesoltys.seedvault.transport.backup.TestKvDbManager
|
||||
import com.stevesoltys.seedvault.transport.restore.FullRestore
|
||||
import com.stevesoltys.seedvault.transport.restore.KVRestore
|
||||
|
@ -35,13 +38,13 @@ import com.stevesoltys.seedvault.transport.restore.OutputFactory
|
|||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||
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 io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
|
@ -66,11 +69,14 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
private val dbManager = TestKvDbManager()
|
||||
private val backendManager: BackendManager = mockk()
|
||||
private val appBackupManager: AppBackupManager = mockk()
|
||||
private val snapshotCreator: SnapshotCreator = mockk()
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
||||
private val backend = mockk<Backend>()
|
||||
private val loader = mockk<Loader>()
|
||||
private val backupReceiver = mockk<BackupReceiver>()
|
||||
private val kvBackup = KVBackup(
|
||||
backendManager = backendManager,
|
||||
settingsManager = settingsManager,
|
||||
|
@ -80,17 +86,16 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
dbManager = dbManager,
|
||||
)
|
||||
private val fullBackup = FullBackup(
|
||||
backendManager = backendManager,
|
||||
settingsManager = settingsManager,
|
||||
nm = notificationManager,
|
||||
backupReceiver = backupReceiver,
|
||||
inputFactory = inputFactory,
|
||||
crypto = cryptoImpl,
|
||||
)
|
||||
private val apkBackup = mockk<ApkBackup>()
|
||||
private val packageService: PackageService = mockk()
|
||||
private val backup = BackupCoordinator(
|
||||
context,
|
||||
backendManager,
|
||||
appBackupManager,
|
||||
kvBackup,
|
||||
fullBackup,
|
||||
clock,
|
||||
|
@ -109,7 +114,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
dbManager
|
||||
)
|
||||
private val fullRestore =
|
||||
FullRestore(backendManager, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||
FullRestore(backendManager, loader, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||
private val restore = RestoreCoordinator(
|
||||
context,
|
||||
crypto,
|
||||
|
@ -123,21 +128,21 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
metadataReader
|
||||
)
|
||||
|
||||
private val restorableBackup = RestorableBackup(metadata)
|
||||
private val restorableBackup = RestorableBackup(metadata, repoId, snapshot)
|
||||
private val backupDataInput = mockk<BackupDataInput>()
|
||||
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 packageMetadata = PackageMetadata(time = 0L)
|
||||
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, packageInfo.packageName)
|
||||
private val realName = cryptoImpl.getNameForPackage(salt, packageName)
|
||||
|
||||
init {
|
||||
every { backendManager.backend } returns backend
|
||||
every { appBackupManager.snapshotCreator } returns snapshotCreator
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -162,19 +167,11 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
appData2.copyInto(value2.captured) // write the app data into the passed ByteArray
|
||||
appData2.size
|
||||
}
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
|
||||
coEvery {
|
||||
backend.save(LegacyAppBackupFile.Metadata(token))
|
||||
} returns metadataOutputStream
|
||||
every {
|
||||
metadataManager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
} just Runs
|
||||
every {
|
||||
metadataManager.onPackageBackedUp(
|
||||
packageInfo = packageInfo,
|
||||
type = BackupType.KV,
|
||||
size = more((appData.size + appData2.size).toLong()), // more because DB overhead
|
||||
metadataOutputStream = metadataOutputStream,
|
||||
)
|
||||
} just Runs
|
||||
|
||||
|
@ -241,7 +238,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
appData.copyInto(value.captured) // write the app data into the passed ByteArray
|
||||
appData.size
|
||||
}
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery {
|
||||
backend.save(LegacyAppBackupFile.Metadata(token))
|
||||
|
@ -251,7 +247,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
packageInfo = packageInfo,
|
||||
type = BackupType.KV,
|
||||
size = more(size.toLong()), // more than $size, because DB overhead
|
||||
metadataOutputStream = metadataOutputStream,
|
||||
)
|
||||
} just Runs
|
||||
|
||||
|
@ -297,34 +292,38 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
|
||||
@Test
|
||||
fun `test full backup and restore with two chunks`() = runBlocking {
|
||||
metadata.packageMetadataMap[packageName] = PackageMetadata(
|
||||
backupType = BackupType.FULL,
|
||||
chunkIds = listOf(apkChunkId),
|
||||
)
|
||||
|
||||
// package is of type FULL
|
||||
val packageMetadata = metadata.packageMetadataMap[packageInfo.packageName]!!
|
||||
metadata.packageMetadataMap[packageInfo.packageName] =
|
||||
packageMetadata.copy(backupType = BackupType.FULL)
|
||||
|
||||
// return streams from plugin and app data
|
||||
val byteSlot = slot<ByteArray>()
|
||||
val bOutputStream = ByteArrayOutputStream()
|
||||
val bInputStream = ByteArrayInputStream(appData)
|
||||
coEvery {
|
||||
backend.save(LegacyAppBackupFile.Blob(token, realName))
|
||||
} returns bOutputStream
|
||||
|
||||
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
|
||||
every { settingsManager.getToken() } returns token
|
||||
every { metadataManager.salt } returns salt
|
||||
coEvery {
|
||||
backend.save(LegacyAppBackupFile.Metadata(token))
|
||||
} returns metadataOutputStream
|
||||
every { metadataManager.onApkBackedUp(packageInfo, packageMetadata) } just Runs
|
||||
coEvery { backupReceiver.addBytes(capture(byteSlot)) } answers {
|
||||
bOutputStream.writeBytes(byteSlot.captured)
|
||||
}
|
||||
every {
|
||||
snapshotCreator.onPackageBackedUp(packageInfo, BackupType.FULL, apkBackupData)
|
||||
} just Runs
|
||||
every {
|
||||
metadataManager.onPackageBackedUp(
|
||||
packageInfo = packageInfo,
|
||||
type = BackupType.FULL,
|
||||
size = appData.size.toLong(),
|
||||
metadataOutputStream = metadataOutputStream,
|
||||
size = apkBackupData.size,
|
||||
)
|
||||
} just Runs
|
||||
coEvery { backupReceiver.finalize() } returns apkBackupData // just some backupData
|
||||
|
||||
// perform backup to output stream
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
|
||||
|
@ -336,9 +335,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
restore.beforeStartRestore(restorableBackup)
|
||||
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
|
||||
|
||||
// finds data for full backup
|
||||
every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name
|
||||
|
||||
val restoreDescription = restore.nextRestorePackage() ?: fail()
|
||||
assertEquals(packageInfo.packageName, restoreDescription.packageName)
|
||||
assertEquals(TYPE_FULL_STREAM, restoreDescription.dataType)
|
||||
|
@ -346,9 +342,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
// reverse the backup streams into restore input
|
||||
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
|
||||
val rOutputStream = ByteArrayOutputStream()
|
||||
coEvery {
|
||||
backend.load(LegacyAppBackupFile.Blob(token, name))
|
||||
} returns rInputStream
|
||||
coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns rInputStream
|
||||
every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream
|
||||
|
||||
// restore data
|
||||
|
|
|
@ -42,6 +42,7 @@ import kotlin.random.Random
|
|||
internal class BackupCoordinatorTest : BackupTest() {
|
||||
|
||||
private val backendManager = mockk<BackendManager>()
|
||||
private val appBackupManager = mockk<AppBackupManager>()
|
||||
private val kv = mockk<KVBackup>()
|
||||
private val full = mockk<FullBackup>()
|
||||
private val apkBackup = mockk<ApkBackup>()
|
||||
|
@ -51,6 +52,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
private val backup = BackupCoordinator(
|
||||
context = context,
|
||||
backendManager = backendManager,
|
||||
appBackupManager = appBackupManager,
|
||||
kv = kv,
|
||||
full = full,
|
||||
clock = clock,
|
||||
|
@ -80,7 +82,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
fun `device initialization succeeds and delegates to plugin`() = runBlocking {
|
||||
expectStartNewRestoreSet()
|
||||
every { kv.hasState() } returns false
|
||||
every { full.hasState() } returns false
|
||||
every { full.hasState } returns false
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.initializeDevice())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
|
@ -107,7 +109,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
|
||||
// finish will only be called when TRANSPORT_OK is returned, so it should throw
|
||||
every { kv.hasState() } returns false
|
||||
every { full.hasState() } returns false
|
||||
every { full.hasState } returns false
|
||||
coAssertThrows(IllegalStateException::class.java) {
|
||||
backup.finishBackup()
|
||||
}
|
||||
|
@ -126,7 +128,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
|
||||
// finish will only be called when TRANSPORT_OK is returned, so it should throw
|
||||
every { kv.hasState() } returns false
|
||||
every { full.hasState() } returns false
|
||||
every { full.hasState } returns false
|
||||
coAssertThrows(IllegalStateException::class.java) {
|
||||
backup.finishBackup()
|
||||
}
|
||||
|
@ -159,7 +161,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
val quota = Random.nextLong()
|
||||
|
||||
if (isFullBackup) {
|
||||
every { full.getQuota() } returns quota
|
||||
every { full.quota } returns quota
|
||||
} else {
|
||||
every { kv.getQuota() } returns quota
|
||||
}
|
||||
|
@ -175,61 +177,30 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing full backup data throws`() = runBlocking {
|
||||
every { settingsManager.getToken() } returns token
|
||||
every { metadataManager.salt } returns salt
|
||||
coEvery { kv.clearBackupData(packageInfo, token, salt) } just Runs
|
||||
coEvery { full.clearBackupData(packageInfo, token, salt) } throws IOException()
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing backup data succeeds`() = runBlocking {
|
||||
every { settingsManager.getToken() } returns token
|
||||
every { metadataManager.salt } returns salt
|
||||
coEvery { kv.clearBackupData(packageInfo, token, salt) } just Runs
|
||||
coEvery { full.clearBackupData(packageInfo, token, salt) } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, 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
|
||||
|
||||
every { kv.hasState() } returns true
|
||||
every { full.hasState() } returns false
|
||||
every { full.hasState } returns false
|
||||
every { kv.getCurrentPackage() } returns packageInfo
|
||||
coEvery { kv.finishBackup() } returns TRANSPORT_OK
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream
|
||||
every { kv.getCurrentSize() } returns size
|
||||
every {
|
||||
metadataManager.onPackageBackedUp(
|
||||
packageInfo = packageInfo,
|
||||
type = BackupType.KV,
|
||||
size = size,
|
||||
metadataOutputStream = metadataOutputStream,
|
||||
)
|
||||
} just Runs
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
|
||||
verify { metadataOutputStream.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finish backup does not upload @pm@ metadata, if it can't do backups`() = runBlocking {
|
||||
every { kv.hasState() } returns true
|
||||
every { full.hasState() } returns false
|
||||
every { full.hasState } returns false
|
||||
every { kv.getCurrentPackage() } returns pmPackageInfo
|
||||
every { kv.getCurrentSize() } returns 42L
|
||||
|
||||
|
@ -241,29 +212,26 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
|
||||
@Test
|
||||
fun `finish backup delegates to full plugin if it has state`() = runBlocking {
|
||||
val result = Random.nextInt()
|
||||
val size: Long? = null
|
||||
val snapshotCreator: SnapshotCreator = mockk()
|
||||
val size: Long = 2345
|
||||
|
||||
every { kv.hasState() } returns false
|
||||
every { full.hasState() } returns true
|
||||
every { full.getCurrentPackage() } returns packageInfo
|
||||
every { full.finishBackup() } returns result
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream
|
||||
every { full.getCurrentSize() } returns size
|
||||
every { full.hasState } returns true
|
||||
every { full.currentPackageInfo } returns packageInfo
|
||||
coEvery { full.finishBackup() } returns apkBackupData
|
||||
every { appBackupManager.snapshotCreator } returns snapshotCreator
|
||||
every {
|
||||
snapshotCreator.onPackageBackedUp(packageInfo, BackupType.FULL, apkBackupData)
|
||||
} just Runs
|
||||
every {
|
||||
metadataManager.onPackageBackedUp(
|
||||
packageInfo = packageInfo,
|
||||
type = BackupType.FULL,
|
||||
size = size,
|
||||
metadataOutputStream = metadataOutputStream,
|
||||
size = apkBackupData.size,
|
||||
)
|
||||
} just Runs
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
||||
assertEquals(result, backup.finishBackup())
|
||||
|
||||
verify { metadataOutputStream.close() }
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -271,7 +239,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
every { settingsManager.getToken() } returns token
|
||||
every { metadataManager.salt } returns salt
|
||||
coEvery {
|
||||
full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt)
|
||||
full.performFullBackup(packageInfo, fileDescriptor, 0)
|
||||
} returns TRANSPORT_OK
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
|
||||
|
||||
|
@ -283,14 +251,14 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
every { settingsManager.getToken() } returns token
|
||||
every { metadataManager.salt } returns salt
|
||||
coEvery {
|
||||
full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt)
|
||||
full.performFullBackup(packageInfo, fileDescriptor, 0)
|
||||
} returns TRANSPORT_OK
|
||||
expectApkBackupAndMetadataWrite()
|
||||
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
|
||||
every { full.quota } returns DEFAULT_QUOTA_FULL_BACKUP
|
||||
every {
|
||||
full.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1)
|
||||
} returns TRANSPORT_QUOTA_EXCEEDED
|
||||
every { full.getCurrentPackage() } returns packageInfo
|
||||
every { full.currentPackageInfo } returns packageInfo
|
||||
every {
|
||||
metadataManager.onPackageBackupError(
|
||||
packageInfo,
|
||||
|
@ -299,7 +267,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
BackupType.FULL
|
||||
)
|
||||
} just Runs
|
||||
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
|
||||
coEvery { full.cancelFullBackup() } just Runs
|
||||
every { backendManager.backendProperties } returns safProperties
|
||||
every { settingsManager.useMeteredNetwork } returns false
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
@ -335,12 +303,12 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
every { settingsManager.getToken() } returns token
|
||||
every { metadataManager.salt } returns salt
|
||||
coEvery {
|
||||
full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt)
|
||||
full.performFullBackup(packageInfo, fileDescriptor, 0)
|
||||
} returns TRANSPORT_OK
|
||||
expectApkBackupAndMetadataWrite()
|
||||
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
|
||||
every { full.quota } returns DEFAULT_QUOTA_FULL_BACKUP
|
||||
every { full.checkFullBackupSize(0) } returns TRANSPORT_PACKAGE_REJECTED
|
||||
every { full.getCurrentPackage() } returns packageInfo
|
||||
every { full.currentPackageInfo } returns packageInfo
|
||||
every {
|
||||
metadataManager.onPackageBackupError(
|
||||
packageInfo,
|
||||
|
@ -349,7 +317,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
BackupType.FULL
|
||||
)
|
||||
} just Runs
|
||||
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
|
||||
coEvery { full.cancelFullBackup() } just Runs
|
||||
every { backendManager.backendProperties } returns safProperties
|
||||
every { settingsManager.useMeteredNetwork } returns false
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
|
|
@ -9,50 +9,41 @@ import android.app.backup.BackupTransport.TRANSPORT_ERROR
|
|||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
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.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
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.FileInputStream
|
||||
import java.io.IOException
|
||||
import kotlin.random.Random
|
||||
|
||||
internal class FullBackupTest : BackupTest() {
|
||||
|
||||
private val backendManager: BackendManager = mockk()
|
||||
private val backend = mockk<Backend>()
|
||||
private val backupReceiver = mockk<BackupReceiver>()
|
||||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
private val backup = FullBackup(
|
||||
backendManager = backendManager,
|
||||
settingsManager = settingsManager,
|
||||
nm = notificationManager,
|
||||
backupReceiver = backupReceiver,
|
||||
inputFactory = inputFactory,
|
||||
crypto = crypto,
|
||||
)
|
||||
|
||||
private val bytes = ByteArray(23).apply { Random.nextBytes(this) }
|
||||
private val inputStream = mockk<FileInputStream>()
|
||||
private val ad = getADForFull(VERSION, packageInfo.packageName)
|
||||
|
||||
init {
|
||||
every { backendManager.backend } returns backend
|
||||
}
|
||||
private val backupData = apkBackupData
|
||||
|
||||
@Test
|
||||
fun `has no initial state`() {
|
||||
assertFalse(backup.hasState())
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -99,254 +90,229 @@ internal class FullBackupTest : BackupTest() {
|
|||
@Test
|
||||
fun `performFullBackup runs ok`() = runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } returns backupData
|
||||
expectClearState()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
assertEquals(backupData, backup.finishBackup())
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendBackupData first call over quota`() = runBlocking {
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
val numBytes = (quota + 1).toInt()
|
||||
expectSendData(numBytes)
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } returns backupData
|
||||
expectClearState()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
assertEquals(backupData, backup.finishBackup())
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendBackupData second call over quota`() = runBlocking {
|
||||
fun `sendBackupData subsequent calls over quota`() = runBlocking {
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
val numBytes1 = quota.toInt()
|
||||
expectSendData(numBytes1)
|
||||
val numBytes2 = 1
|
||||
expectSendData(numBytes2)
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
// split up sending data in smaller chunks, so we don't run out of heap space
|
||||
var sendResult: Int = TRANSPORT_ERROR
|
||||
val numBytes = (quota / 1024).toInt()
|
||||
for (i in 0..1024) {
|
||||
expectSendData(numBytes)
|
||||
sendResult = backup.sendBackupData(numBytes)
|
||||
assertTrue(backup.hasState)
|
||||
if (sendResult == TRANSPORT_QUOTA_EXCEEDED) break
|
||||
}
|
||||
assertEquals(TRANSPORT_QUOTA_EXCEEDED, sendResult)
|
||||
|
||||
coEvery { backupReceiver.finalize() } returns backupData
|
||||
expectClearState()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes2))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
// in reality, this may not call finishBackup(), but cancelBackup()
|
||||
assertEquals(backupData, backup.finishBackup())
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendBackupData throws exception when reading from InputStream`() = runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
every { crypto.newEncryptingStreamV1(outputStream, ad) } returns encryptedOutputStream
|
||||
every { inputStream.read(any(), any(), bytes.size) } throws IOException()
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } returns backupData
|
||||
expectClearState()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
assertEquals(backupData, backup.finishBackup())
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendBackupData throws exception when getting outputStream`() = runBlocking {
|
||||
fun `sendBackupData throws exception when sending data`() = runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name
|
||||
coEvery { backend.save(handle) } throws IOException()
|
||||
every { inputStream.read(any(), 0, bytes.size) } returns bytes.size
|
||||
coEvery { backupReceiver.addBytes(any()) } throws IOException()
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } returns backupData
|
||||
expectClearState()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
assertEquals(backupData, backup.finishBackup())
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendBackupData throws exception when writing header`() = runBlocking {
|
||||
fun `sendBackupData throws exception when finalizing`() = runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name
|
||||
coEvery { backend.save(handle) } returns outputStream
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
every { outputStream.write(ByteArray(1) { VERSION }) } throws IOException()
|
||||
expectSendData(bytes.size)
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.sendBackupData(bytes.size))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } throws IOException()
|
||||
expectClearState()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
assertThrows<IOException> {
|
||||
backup.finishBackup()
|
||||
}
|
||||
assertFalse(backup.hasState)
|
||||
|
||||
@Test
|
||||
fun `sendBackupData throws exception when writing encrypted data to OutputStream`() =
|
||||
runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
every { crypto.newEncryptingStreamV1(outputStream, ad) } returns encryptedOutputStream
|
||||
every { inputStream.read(any(), any(), bytes.size) } returns bytes.size
|
||||
every { encryptedOutputStream.write(any<ByteArray>()) } throws IOException()
|
||||
expectClearState()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
verify { data.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendBackupData runs ok`() = runBlocking {
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
val numBytes1 = (quota / 2).toInt()
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
val numBytes1 = 2342
|
||||
expectSendData(numBytes1)
|
||||
val numBytes2 = (quota / 2).toInt()
|
||||
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
val numBytes2 = 4223
|
||||
expectSendData(numBytes2)
|
||||
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes2))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } returns backupData
|
||||
expectClearState()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes2))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearBackupData delegates to plugin`() = runBlocking {
|
||||
every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name
|
||||
coEvery { backend.remove(handle) } just Runs
|
||||
|
||||
backup.clearBackupData(packageInfo, token, salt)
|
||||
assertEquals(backupData, backup.finishBackup())
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel full backup runs ok`() = runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } returns backupData
|
||||
expectClearState()
|
||||
every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name
|
||||
coEvery { backend.remove(handle) } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
backup.cancelFullBackup(token, salt, false)
|
||||
assertFalse(backup.hasState())
|
||||
backup.cancelFullBackup()
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel full backup ignores exception when calling plugin`() = runBlocking {
|
||||
fun `cancel full backup throws exception when finalizing`() = runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } throws IOException()
|
||||
expectClearState()
|
||||
every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name
|
||||
coEvery { backend.remove(handle) } throws IOException()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
backup.cancelFullBackup(token, salt, false)
|
||||
assertFalse(backup.hasState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearState throws exception when flushing OutputStream`() = runBlocking {
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
val numBytes = 42
|
||||
expectSendData(numBytes)
|
||||
every { encryptedOutputStream.flush() } throws IOException()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes))
|
||||
assertEquals(TRANSPORT_ERROR, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearState ignores exception when closing OutputStream`() = runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
every { outputStream.flush() } just Runs
|
||||
every { outputStream.close() } throws IOException()
|
||||
every { inputStream.close() } just Runs
|
||||
every { data.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
backup.cancelFullBackup()
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearState ignores exception when closing InputStream`() = runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } returns backupData
|
||||
every { outputStream.flush() } just Runs
|
||||
every { outputStream.close() } just Runs
|
||||
every { inputStream.close() } throws IOException()
|
||||
every { data.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
assertEquals(backupData, backup.finishBackup())
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearState ignores exception when closing ParcelFileDescriptor`() = runBlocking {
|
||||
every { inputFactory.getInputStream(data) } returns inputStream
|
||||
expectInitializeOutputStream()
|
||||
every { backupReceiver.assertFinalized() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0))
|
||||
assertTrue(backup.hasState)
|
||||
|
||||
coEvery { backupReceiver.finalize() } returns backupData
|
||||
every { outputStream.flush() } just Runs
|
||||
every { outputStream.close() } just Runs
|
||||
every { inputStream.close() } just Runs
|
||||
every { data.close() } throws IOException()
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data, 0, token, salt))
|
||||
assertTrue(backup.hasState())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
assertFalse(backup.hasState())
|
||||
}
|
||||
|
||||
private fun expectInitializeOutputStream() {
|
||||
every { crypto.getNameForPackage(salt, packageInfo.packageName) } returns name
|
||||
coEvery {
|
||||
backend.save(LegacyAppBackupFile.Blob(token, name))
|
||||
} returns outputStream
|
||||
every { outputStream.write(ByteArray(1) { VERSION }) } just Runs
|
||||
assertEquals(backupData, backup.finishBackup())
|
||||
assertFalse(backup.hasState)
|
||||
}
|
||||
|
||||
private fun expectSendData(numBytes: Int, readBytes: Int = numBytes) {
|
||||
every { inputStream.read(any(), any(), numBytes) } returns readBytes
|
||||
every { crypto.newEncryptingStreamV1(outputStream, ad) } returns encryptedOutputStream
|
||||
every { encryptedOutputStream.write(any<ByteArray>()) } just Runs
|
||||
coEvery { backupReceiver.addBytes(any()) } just Runs
|
||||
}
|
||||
|
||||
private fun expectClearState() {
|
||||
|
|
|
@ -14,16 +14,14 @@ import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
|||
import com.stevesoltys.seedvault.coAssertThrows
|
||||
import com.stevesoltys.seedvault.getRandomByteArray
|
||||
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.VersionHeader
|
||||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
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 io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||
|
@ -41,9 +39,11 @@ internal class FullRestoreTest : RestoreTest() {
|
|||
|
||||
private val backendManager: BackendManager = mockk()
|
||||
private val backend = mockk<Backend>()
|
||||
private val loader = mockk<Loader>()
|
||||
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
||||
private val restore = FullRestore(
|
||||
backendManager = backendManager,
|
||||
loader = loader,
|
||||
legacyPlugin = legacyPlugin,
|
||||
outputFactory = outputFactory,
|
||||
headerReader = headerReader,
|
||||
|
@ -52,7 +52,7 @@ internal class FullRestoreTest : RestoreTest() {
|
|||
|
||||
private val encrypted = getRandomByteArray()
|
||||
private val outputStream = ByteArrayOutputStream()
|
||||
private val ad = getADForFull(VERSION, packageInfo.packageName)
|
||||
private val blobHandles = listOf(apkBlobHandle)
|
||||
|
||||
init {
|
||||
every { backendManager.backend } returns backend
|
||||
|
@ -60,7 +60,7 @@ internal class FullRestoreTest : RestoreTest() {
|
|||
|
||||
@Test
|
||||
fun `has no initial state`() {
|
||||
assertFalse(restore.hasState())
|
||||
assertFalse(restore.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -73,14 +73,14 @@ internal class FullRestoreTest : RestoreTest() {
|
|||
|
||||
@Test
|
||||
fun `initializing state leaves a state`() {
|
||||
assertFalse(restore.hasState())
|
||||
restore.initializeState(VERSION, token, name, packageInfo)
|
||||
assertTrue(restore.hasState())
|
||||
assertFalse(restore.hasState)
|
||||
restore.initializeState(VERSION, packageInfo, blobHandles)
|
||||
assertTrue(restore.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting chunks without initializing state throws`() {
|
||||
assertFalse(restore.hasState())
|
||||
assertFalse(restore.hasState)
|
||||
coAssertThrows(IllegalStateException::class.java) {
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
}
|
||||
|
@ -88,138 +88,52 @@ internal class FullRestoreTest : RestoreTest() {
|
|||
|
||||
@Test
|
||||
fun `getting InputStream for package when getting first chunk throws`() = runBlocking {
|
||||
restore.initializeState(VERSION, token, name, packageInfo)
|
||||
restore.initializeState(VERSION, packageInfo, blobHandles)
|
||||
|
||||
coEvery { backend.load(handle) } throws IOException()
|
||||
coEvery { loader.loadFiles(blobHandles) } throws IOException()
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
TRANSPORT_PACKAGE_REJECTED,
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
)
|
||||
|
||||
verify { fileDescriptor.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading version header when getting first chunk throws`() = runBlocking {
|
||||
restore.initializeState(VERSION, token, name, packageInfo)
|
||||
fun `reading from stream throws general security exception`() = runBlocking {
|
||||
restore.initializeState(VERSION, packageInfo, blobHandles)
|
||||
|
||||
coEvery { backend.load(handle) } returns inputStream
|
||||
every { headerReader.readVersion(inputStream, VERSION) } throws IOException()
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
TRANSPORT_PACKAGE_REJECTED,
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading unsupported version when getting first chunk`() = runBlocking {
|
||||
restore.initializeState(VERSION, token, name, packageInfo)
|
||||
|
||||
coEvery { backend.load(handle) } returns inputStream
|
||||
every {
|
||||
headerReader.readVersion(inputStream, VERSION)
|
||||
} throws UnsupportedVersionException(unsupportedVersion)
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
TRANSPORT_PACKAGE_REJECTED,
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting decrypted stream when getting first chunk 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 IOException()
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
TRANSPORT_PACKAGE_REJECTED,
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting decrypted stream when getting first chunk throws general security exception`() =
|
||||
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()
|
||||
coEvery { loader.loadFiles(blobHandles) } throws GeneralSecurityException()
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
|
||||
verify { fileDescriptor.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full chunk gets decrypted`() = runBlocking {
|
||||
restore.initializeState(VERSION, token, name, packageInfo)
|
||||
restore.initializeState(VERSION, packageInfo, blobHandles)
|
||||
|
||||
initInputStream()
|
||||
readAndEncryptInputStream(encrypted)
|
||||
coEvery { loader.loadFiles(blobHandles) } returns inputStream
|
||||
readInputStream(encrypted)
|
||||
every { inputStream.close() } just Runs
|
||||
|
||||
assertEquals(encrypted.size, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertArrayEquals(encrypted, outputStream.toByteArray())
|
||||
restore.finishRestore()
|
||||
assertFalse(restore.hasState())
|
||||
assertFalse(restore.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("deprecation")
|
||||
fun `full chunk gets decrypted from version 0`() = runBlocking {
|
||||
restore.initializeState(0.toByte(), token, name, packageInfo)
|
||||
|
||||
coEvery { legacyPlugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
|
||||
every { headerReader.readVersion(inputStream, 0.toByte()) } returns 0.toByte()
|
||||
every {
|
||||
crypto.decryptHeader(inputStream, 0.toByte(), packageInfo.packageName)
|
||||
} returns VersionHeader(0.toByte(), packageInfo.packageName)
|
||||
every { crypto.decryptSegment(inputStream) } returns encrypted
|
||||
|
||||
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
|
||||
every { fileDescriptor.close() } just Runs
|
||||
every { inputStream.close() } just Runs
|
||||
|
||||
assertEquals(encrypted.size, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertArrayEquals(encrypted, outputStream.toByteArray())
|
||||
restore.finishRestore()
|
||||
assertFalse(restore.hasState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unexpected version aborts with error`() = runBlocking {
|
||||
restore.initializeState(Byte.MAX_VALUE, token, name, packageInfo)
|
||||
|
||||
coEvery { backend.load(handle) } returns inputStream
|
||||
every {
|
||||
headerReader.readVersion(inputStream, Byte.MAX_VALUE)
|
||||
} throws GeneralSecurityException()
|
||||
every { inputStream.close() } just Runs
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
restore.abortFullRestore()
|
||||
assertFalse(restore.hasState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three full chunk get decrypted and then return no more data`() = runBlocking {
|
||||
fun `larger data gets decrypted and then return no more data`() = runBlocking {
|
||||
val encryptedBytes = Random.nextBytes(MAX_SEGMENT_LENGTH * 2 + 1)
|
||||
val decryptedInputStream = ByteArrayInputStream(encryptedBytes)
|
||||
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 decryptedInputStream
|
||||
coEvery { loader.loadFiles(blobHandles) } returns decryptedInputStream
|
||||
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
|
||||
every { fileDescriptor.close() } just Runs
|
||||
every { inputStream.close() } just Runs
|
||||
|
@ -231,38 +145,32 @@ internal class FullRestoreTest : RestoreTest() {
|
|||
assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertArrayEquals(encryptedBytes, outputStream.toByteArray())
|
||||
restore.finishRestore()
|
||||
assertFalse(restore.hasState())
|
||||
assertFalse(restore.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aborting full restore closes stream, resets state`() = runBlocking {
|
||||
restore.initializeState(VERSION, token, name, packageInfo)
|
||||
restore.initializeState(VERSION, packageInfo, blobHandles)
|
||||
|
||||
initInputStream()
|
||||
readAndEncryptInputStream(encrypted)
|
||||
coEvery { loader.loadFiles(blobHandles) } returns inputStream
|
||||
readInputStream(encrypted)
|
||||
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
|
||||
every { inputStream.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, restore.abortFullRestore())
|
||||
assertFalse(restore.hasState())
|
||||
assertFalse(restore.hasState)
|
||||
}
|
||||
|
||||
private fun initInputStream() {
|
||||
coEvery { backend.load(handle) } returns inputStream
|
||||
every { headerReader.readVersion(inputStream, VERSION) } returns VERSION
|
||||
every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptedInputStream
|
||||
}
|
||||
|
||||
private fun readAndEncryptInputStream(encryptedBytes: ByteArray) {
|
||||
private fun readInputStream(encryptedBytes: ByteArray) {
|
||||
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
|
||||
val slot = CapturingSlot<ByteArray>()
|
||||
every { decryptedInputStream.read(capture(slot)) } answers {
|
||||
every { inputStream.read(capture(slot)) } answers {
|
||||
encryptedBytes.copyInto(slot.captured)
|
||||
encryptedBytes.size
|
||||
}
|
||||
every { decryptedInputStream.close() } just Runs
|
||||
every { inputStream.close() } just Runs
|
||||
every { fileDescriptor.close() } just Runs
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,254 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.restore
|
||||
|
||||
import android.app.backup.BackupTransport.NO_MORE_DATA
|
||||
import android.app.backup.BackupTransport.TRANSPORT_ERROR
|
||||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.coAssertThrows
|
||||
import com.stevesoltys.seedvault.getRandomByteArray
|
||||
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.header.VersionHeader
|
||||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
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.calyxos.seedvault.core.backends.Backend
|
||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||
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 java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.security.GeneralSecurityException
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal class FullRestoreV1Test : RestoreTest() {
|
||||
|
||||
private val backendManager: BackendManager = mockk()
|
||||
private val backend = mockk<Backend>()
|
||||
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
||||
private val restore = FullRestore(
|
||||
backendManager = backendManager,
|
||||
loader = mockk(),
|
||||
legacyPlugin = legacyPlugin,
|
||||
outputFactory = outputFactory,
|
||||
headerReader = headerReader,
|
||||
crypto = crypto,
|
||||
)
|
||||
|
||||
private val encrypted = getRandomByteArray()
|
||||
private val outputStream = ByteArrayOutputStream()
|
||||
private val ad = getADForFull(1, packageInfo.packageName)
|
||||
|
||||
init {
|
||||
every { backendManager.backend } returns backend
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `has no initial state`() {
|
||||
assertFalse(restore.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("deprecation")
|
||||
fun `v0 hasDataForPackage() delegates to plugin`() = runBlocking {
|
||||
val result = Random.nextBoolean()
|
||||
coEvery { legacyPlugin.hasDataForFullPackage(token, packageInfo) } returns result
|
||||
assertEquals(result, restore.hasDataForPackage(token, packageInfo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initializing state leaves a state`() {
|
||||
assertFalse(restore.hasState)
|
||||
restore.initializeStateV1(token, name, packageInfo)
|
||||
assertTrue(restore.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting chunks without initializing state throws`() {
|
||||
assertFalse(restore.hasState)
|
||||
coAssertThrows(IllegalStateException::class.java) {
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting InputStream for package when getting first chunk throws`() = runBlocking {
|
||||
restore.initializeStateV1(token, name, packageInfo)
|
||||
|
||||
coEvery { backend.load(handle) } throws IOException()
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
TRANSPORT_PACKAGE_REJECTED,
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading version header when getting first chunk throws`() = runBlocking {
|
||||
restore.initializeStateV1(token, name, packageInfo)
|
||||
|
||||
coEvery { backend.load(handle) } returns inputStream
|
||||
every { headerReader.readVersion(inputStream, 1) } throws IOException()
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
TRANSPORT_PACKAGE_REJECTED,
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading unsupported version when getting first chunk`() = runBlocking {
|
||||
restore.initializeStateV1(token, name, packageInfo)
|
||||
|
||||
coEvery { backend.load(handle) } returns inputStream
|
||||
every {
|
||||
headerReader.readVersion(inputStream, 1)
|
||||
} throws UnsupportedVersionException(unsupportedVersion)
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
TRANSPORT_PACKAGE_REJECTED,
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting decrypted stream when getting first chunk 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 IOException()
|
||||
every { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
TRANSPORT_PACKAGE_REJECTED,
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting decrypted stream when getting first chunk throws general security exception`() =
|
||||
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 { fileDescriptor.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full chunk gets decrypted`() = runBlocking {
|
||||
restore.initializeStateV1(token, name, packageInfo)
|
||||
|
||||
initInputStream()
|
||||
readAndEncryptInputStream(encrypted)
|
||||
every { inputStream.close() } just Runs
|
||||
|
||||
assertEquals(encrypted.size, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertArrayEquals(encrypted, outputStream.toByteArray())
|
||||
restore.finishRestore()
|
||||
assertFalse(restore.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("deprecation")
|
||||
fun `full chunk gets decrypted from version 0`() = runBlocking {
|
||||
restore.initializeStateV0(token, packageInfo)
|
||||
|
||||
coEvery { legacyPlugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
|
||||
every { headerReader.readVersion(inputStream, 0.toByte()) } returns 0.toByte()
|
||||
every {
|
||||
crypto.decryptHeader(inputStream, 0.toByte(), packageInfo.packageName)
|
||||
} returns VersionHeader(0.toByte(), packageInfo.packageName)
|
||||
every { crypto.decryptSegment(inputStream) } returns encrypted
|
||||
|
||||
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
|
||||
every { fileDescriptor.close() } just Runs
|
||||
every { inputStream.close() } just Runs
|
||||
|
||||
assertEquals(encrypted.size, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertArrayEquals(encrypted, outputStream.toByteArray())
|
||||
restore.finishRestore()
|
||||
assertFalse(restore.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three full chunk get decrypted and then return no more data`() = runBlocking {
|
||||
val encryptedBytes = Random.nextBytes(MAX_SEGMENT_LENGTH * 2 + 1)
|
||||
val decryptedInputStream = ByteArrayInputStream(encryptedBytes)
|
||||
restore.initializeStateV1(token, name, packageInfo)
|
||||
|
||||
coEvery { backend.load(handle) } returns inputStream
|
||||
every { headerReader.readVersion(inputStream, 1) } returns 1
|
||||
every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptedInputStream
|
||||
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
|
||||
every { fileDescriptor.close() } just Runs
|
||||
every { inputStream.close() } just Runs
|
||||
|
||||
assertEquals(MAX_SEGMENT_LENGTH, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertEquals(MAX_SEGMENT_LENGTH, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertEquals(1, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor))
|
||||
assertArrayEquals(encryptedBytes, outputStream.toByteArray())
|
||||
restore.finishRestore()
|
||||
assertFalse(restore.hasState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aborting full restore closes stream, resets state`() = runBlocking {
|
||||
restore.initializeStateV1(token, name, packageInfo)
|
||||
|
||||
initInputStream()
|
||||
readAndEncryptInputStream(encrypted)
|
||||
|
||||
restore.getNextFullRestoreDataChunk(fileDescriptor)
|
||||
|
||||
every { inputStream.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, restore.abortFullRestore())
|
||||
assertFalse(restore.hasState)
|
||||
}
|
||||
|
||||
private fun initInputStream() {
|
||||
coEvery { backend.load(handle) } returns inputStream
|
||||
every { headerReader.readVersion(inputStream, 1) } returns 1
|
||||
every { crypto.newDecryptingStreamV1(inputStream, ad) } returns decryptedInputStream
|
||||
}
|
||||
|
||||
private fun readAndEncryptInputStream(encryptedBytes: ByteArray) {
|
||||
every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
|
||||
val slot = CapturingSlot<ByteArray>()
|
||||
every { decryptedInputStream.read(capture(slot)) } answers {
|
||||
encryptedBytes.copyInto(slot.captured)
|
||||
encryptedBytes.size
|
||||
}
|
||||
every { decryptedInputStream.close() } just Runs
|
||||
every { fileDescriptor.close() } just Runs
|
||||
}
|
||||
|
||||
}
|
|
@ -67,7 +67,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
metadataReader = metadataReader,
|
||||
)
|
||||
|
||||
private val restorableBackup = RestorableBackup(metadata)
|
||||
private val restorableBackup = RestorableBackup(metadata, repoId, snapshot)
|
||||
private val inputStream = mockk<InputStream>()
|
||||
private val safStorage: SafProperties = mockk()
|
||||
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
|
||||
|
@ -80,8 +80,10 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
private val storageName = getRandomString()
|
||||
|
||||
init {
|
||||
metadata.packageMetadataMap[packageInfo2.packageName] =
|
||||
PackageMetadata(backupType = BackupType.FULL)
|
||||
metadata.packageMetadataMap[packageInfo2.packageName] = PackageMetadata(
|
||||
backupType = BackupType.FULL,
|
||||
chunkIds = listOf(apkChunkId),
|
||||
)
|
||||
|
||||
mockkStatic("com.stevesoltys.seedvault.backend.BackendExtKt")
|
||||
every { backendManager.backend } returns backend
|
||||
|
@ -200,7 +202,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
restore.beforeStartRestore(restorableBackup)
|
||||
assertEquals(TRANSPORT_OK, restore.startRestore(token, packageInfoArray))
|
||||
|
||||
every { full.hasState() } returns false
|
||||
every { full.hasState } returns false
|
||||
restore.finishRestore()
|
||||
|
||||
restore.beforeStartRestore(restorableBackup)
|
||||
|
@ -306,7 +308,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
|
||||
coEvery { kv.hasDataForPackage(token, packageInfo) } returns false
|
||||
coEvery { full.hasDataForPackage(token, packageInfo) } returns true
|
||||
every { full.initializeState(0x00, token, "", packageInfo) } just Runs
|
||||
every { full.initializeStateV0(token, packageInfo) } just Runs
|
||||
|
||||
val expected = RestoreDescription(packageInfo.packageName, TYPE_FULL_STREAM)
|
||||
assertEquals(expected, restore.nextRestorePackage())
|
||||
|
@ -319,8 +321,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
restore.beforeStartRestore(restorableBackup)
|
||||
restore.startRestore(token, packageInfoArray2)
|
||||
|
||||
every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2
|
||||
every { full.initializeState(VERSION, token, name2, packageInfo2) } just Runs
|
||||
every { full.initializeState(VERSION, packageInfo2, listOf(apkBlobHandle)) } just Runs
|
||||
|
||||
val expected = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
|
||||
assertEquals(expected, restore.nextRestorePackage())
|
||||
|
@ -339,8 +340,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
|
||||
assertEquals(expected, restore.nextRestorePackage())
|
||||
|
||||
every { crypto.getNameForPackage(metadata.salt, packageInfo2.packageName) } returns name2
|
||||
every { full.initializeState(VERSION, token, name2, packageInfo2) } just Runs
|
||||
every { full.initializeState(VERSION, packageInfo2, listOf(apkBlobHandle)) } just Runs
|
||||
|
||||
val expected2 =
|
||||
RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
|
||||
|
@ -364,7 +364,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
|
||||
coEvery { kv.hasDataForPackage(token, packageInfo2) } returns false
|
||||
coEvery { full.hasDataForPackage(token, packageInfo2) } returns true
|
||||
every { full.initializeState(0.toByte(), token, "", packageInfo2) } just Runs
|
||||
every { full.initializeStateV0(token, packageInfo2) } just Runs
|
||||
|
||||
val expected2 = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
|
||||
assertEquals(expected2, restore.nextRestorePackage())
|
||||
|
@ -430,7 +430,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
fun `finishRestore() delegates to Full if it has state`() {
|
||||
val hasState = Random.nextBoolean()
|
||||
|
||||
every { full.hasState() } returns hasState
|
||||
every { full.hasState } returns hasState
|
||||
if (hasState) {
|
||||
every { full.finishRestore() } just Runs
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ internal class RestoreV0IntegrationTest : TransportTest() {
|
|||
dbManager = dbManager,
|
||||
)
|
||||
private val fullRestore =
|
||||
FullRestore(backendManager, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||
FullRestore(backendManager, loader, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||
private val restore = RestoreCoordinator(
|
||||
context = context,
|
||||
crypto = crypto,
|
||||
|
|
Loading…
Reference in a new issue