Full backup and restore using v2

while maintaining support for v0 and v1
This commit is contained in:
Torsten Grote 2024-09-06 16:27:37 -03:00
parent 83708d9403
commit 7c7ea5fcd7
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
19 changed files with 695 additions and 610 deletions

View file

@ -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()) }

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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 {

View file

@ -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(),

View file

@ -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)

View file

@ -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,43 +141,40 @@ 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()
}
private fun clearState(): Int {
val state = this.state ?: throw AssertionError("Trying to clear empty state.")
return try {
state.outputStream?.flush()
closeLogging(state.outputStream)
closeLogging(state.inputStream)
closeLogging(state.inputFileDescriptor)
TRANSPORT_OK
} catch (e: IOException) {
Log.w(TAG, "Error when clearing state", e)
TRANSPORT_ERROR
/**
* 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 {
this.state = null
clearState()
}
return result
}
private fun clearState() {
val state = this.state ?: error("Trying to clear empty state.")
closeLogging(state.inputStream)
closeLogging(state.inputFileDescriptor)
this.state = null
}
private fun closeLogging(closable: Closeable?) = try {

View file

@ -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,19 +118,29 @@ internal class FullRestore(
if (state.inputStream == null) {
Log.i(TAG, "First Chunk, initializing package input stream.")
try {
if (state.version == 0.toByte()) {
val inputStream =
legacyPlugin.getInputStreamForPackage(state.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)
val inputStream = backend.load(handle)
val version = headerReader.readVersion(inputStream, state.version)
val ad = getADForFull(version, packageName)
state.inputStream = crypto.newDecryptingStreamV1(inputStream, ad)
when (state.version) {
0.toByte() -> {
val token = state.token ?: error("no token for v0 backup")
val inputStream =
legacyPlugin.getInputStreamForPackage(token, state.packageInfo)
val version = headerReader.readVersion(inputStream, state.version)
@Suppress("deprecation")
crypto.decryptHeader(inputStream, version, packageName)
state.inputStream = inputStream
}
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)

View file

@ -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
}

View file

@ -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(),

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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())
}
@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())
assertThrows<IOException> {
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() {

View file

@ -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()
every { fileDescriptor.close() } just Runs
assertEquals(TRANSPORT_ERROR, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `full chunk gets decrypted`() = runBlocking {
restore.initializeState(VERSION, 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.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
coEvery { loader.loadFiles(blobHandles) } throws GeneralSecurityException()
every { fileDescriptor.close() } just Runs
assertEquals(TRANSPORT_ERROR, restore.getNextFullRestoreDataChunk(fileDescriptor))
restore.abortFullRestore()
assertFalse(restore.hasState())
verify { fileDescriptor.close() }
}
@Test
fun `three full chunk get decrypted and then return no more data`() = runBlocking {
fun `full chunk gets decrypted`() = runBlocking {
restore.initializeState(VERSION, packageInfo, blobHandles)
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)
}
@Test
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
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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,