WebDAV files backup plugin

This commit is contained in:
Torsten Grote 2024-03-27 17:52:02 -03:00
parent 870d1617d2
commit e6e65d0dd1
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
22 changed files with 657 additions and 195 deletions

View file

@ -0,0 +1,179 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.webdav
import android.util.Log
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.ResourceType
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.ConnectionSpec
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.internal.closeQuietly
import okio.BufferedSink
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
const val DEBUG_LOG = true
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
@OptIn(DelicateCoroutinesApi::class)
internal abstract class WebDavStorage(
webDavConfig: WebDavConfig,
) {
companion object {
val TAG: String = WebDavStorage::class.java.simpleName
}
private val authHandler = BasicDigestAuthHandler(
domain = null, // Optional, to only authenticate against hosts with this domain.
username = webDavConfig.username,
password = webDavConfig.password,
)
protected val okHttpClient = OkHttpClient.Builder()
.followRedirects(false)
.authenticator(authHandler)
.addNetworkInterceptor(authHandler)
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.pingInterval(45, TimeUnit.SECONDS)
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS))
.retryOnConnectionFailure(true)
.build()
protected val url = "${webDavConfig.url}/$DIRECTORY_ROOT"
@Throws(IOException::class)
protected suspend fun getOutputStream(location: HttpUrl): OutputStream {
val davCollection = DavCollection(okHttpClient, location)
val pipedInputStream = PipedInputStream()
val pipedOutputStream = PipedCloseActionOutputStream(pipedInputStream)
val body = object : RequestBody() {
override fun contentType() = "application/octet-stream".toMediaType()
override fun writeTo(sink: BufferedSink) {
pipedInputStream.use { inputStream ->
sink.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
}
val deferred = GlobalScope.async(Dispatchers.IO) {
davCollection.put(body) { response ->
debugLog { "getOutputStream($location) = $response" }
}
}
pipedOutputStream.doOnClose {
runBlocking { // blocking i/o wait
deferred.await()
}
}
return pipedOutputStream
}
@Throws(IOException::class)
protected fun getInputStream(location: HttpUrl): InputStream {
val davCollection = DavCollection(okHttpClient, location)
val pipedInputStream = PipedExceptionInputStream()
val pipedOutputStream = PipedOutputStream(pipedInputStream)
GlobalScope.launch(Dispatchers.IO) {
try {
davCollection.get(accept = "", headers = null) { response ->
val inputStream = response.body?.byteStream()
?: throw IOException("No response body")
debugLog { "getInputStream($location) = $response" }
pipedOutputStream.use { outputStream ->
inputStream.copyTo(outputStream)
}
}
} catch (e: Exception) {
debugLog { "Exception while getting input stream: $e" }
// pass exception to stream, so it gets thrown when stream is closed
// if we'd just throw it here, it would be uncaught, on a different thread
pipedInputStream.throwable = e
pipedOutputStream.closeQuietly()
}
}
return pipedInputStream
}
protected suspend fun DavCollection.createFolder(xmlBody: String? = null): okhttp3.Response {
return try {
suspendCoroutine { cont ->
mkCol(xmlBody) { response ->
cont.resume(response)
}
}
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
protected inline fun debugLog(block: () -> String) {
if (DEBUG_LOG) Log.d(TAG, block())
}
protected fun Response.isFolder(): Boolean {
return this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true
}
private class PipedCloseActionOutputStream(
inputStream: PipedInputStream,
) : PipedOutputStream(inputStream) {
private var onClose: (() -> Unit)? = null
@Throws(IOException::class)
override fun close() {
super.close()
try {
onClose?.invoke()
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
fun doOnClose(function: () -> Unit) {
this.onClose = function
}
}
private class PipedExceptionInputStream : PipedInputStream() {
var throwable: Throwable? = null
override fun close() {
super.close()
throwable?.let { e ->
if (e is IOException) throw e
else throw IOException(e)
}
}
}
}

View file

@ -2,13 +2,11 @@ package com.stevesoltys.seedvault.plugins.webdav
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.DavCollection import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response.HrefRelation.SELF import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.NotFoundException import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.DisplayName import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.ResourceType import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.dav4jvm.property.ResourceType.Companion.COLLECTION
import com.stevesoltys.seedvault.plugins.EncryptedMetadata import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.chunkFolderRegex import com.stevesoltys.seedvault.plugins.chunkFolderRegex
@ -16,65 +14,25 @@ import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.plugins.saf.FILE_NO_MEDIA import com.stevesoltys.seedvault.plugins.saf.FILE_NO_MEDIA
import com.stevesoltys.seedvault.plugins.tokenRegex import com.stevesoltys.seedvault.plugins.tokenRegex
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okio.BufferedSink
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
private val TAG = WebDavStoragePlugin::class.java.simpleName
const val DEBUG_LOG = true
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
@OptIn(DelicateCoroutinesApi::class)
@Suppress("BlockingMethodInNonBlockingContext")
internal class WebDavStoragePlugin( internal class WebDavStoragePlugin(
context: Context, context: Context,
webDavConfig: WebDavConfig, webDavConfig: WebDavConfig,
) : StoragePlugin { ) : WebDavStorage(webDavConfig), StoragePlugin {
private val authHandler = BasicDigestAuthHandler(
domain = null, // Optional, to only authenticate against hosts with this domain.
username = webDavConfig.username,
password = webDavConfig.password,
)
private val okHttpClient = OkHttpClient.Builder()
.followRedirects(false)
.authenticator(authHandler)
.addNetworkInterceptor(authHandler)
.build()
private val url = "${webDavConfig.url}/$DIRECTORY_ROOT"
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun startNewRestoreSet(token: Long) { override suspend fun startNewRestoreSet(token: Long) {
try {
val location = "$url/$token".toHttpUrl() val location = "$url/$token".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location) val davCollection = DavCollection(okHttpClient, location)
val response = suspendCoroutine { cont -> val response = davCollection.createFolder()
davCollection.mkCol(null) { response ->
cont.resume(response)
}
}
debugLog { "startNewRestoreSet($token) = $response" } debugLog { "startNewRestoreSet($token) = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
} }
@Throws(IOException::class) @Throws(IOException::class)
@ -107,76 +65,26 @@ internal class WebDavStoragePlugin(
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getOutputStream(token: Long, name: String): OutputStream { override suspend fun getOutputStream(token: Long, name: String): OutputStream {
val location = "$url/$token/$name".toHttpUrl()
return try { return try {
doGetOutputStream(token, name) getOutputStream(location)
} catch (e: Exception) { } catch (e: Exception) {
if (e is IOException) throw e if (e is IOException) throw e
else throw IOException("Error getting OutputStream for $token and $name: ", e) else throw IOException("Error getting OutputStream for $token and $name: ", e)
} }
} }
@Throws(IOException::class)
private suspend fun doGetOutputStream(token: Long, name: String): OutputStream {
val location = "$url/$token/$name".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val pipedInputStream = PipedInputStream()
val pipedOutputStream = PipedCloseActionOutputStream(pipedInputStream)
val body = object : RequestBody() {
override fun contentType() = "application/octet-stream".toMediaType()
override fun writeTo(sink: BufferedSink) {
pipedInputStream.use { inputStream ->
sink.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
}
val deferred = GlobalScope.async(Dispatchers.IO) {
davCollection.put(body) { response ->
debugLog { "getOutputStream($token, $name) = $response" }
}
}
pipedOutputStream.doOnClose {
runBlocking { // blocking i/o wait
deferred.await()
}
}
return pipedOutputStream
}
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getInputStream(token: Long, name: String): InputStream { override suspend fun getInputStream(token: Long, name: String): InputStream {
val location = "$url/$token/$name".toHttpUrl()
return try { return try {
doGetInputStream(token, name) getInputStream(location)
} catch (e: Exception) { } catch (e: Exception) {
if (e is IOException) throw e if (e is IOException) throw e
else throw IOException("Error getting InputStream for $token and $name: ", e) else throw IOException("Error getting InputStream for $token and $name: ", e)
} }
} }
@Throws(IOException::class)
private fun doGetInputStream(token: Long, name: String): InputStream {
val location = "$url/$token/$name".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val pipedInputStream = PipedInputStream()
val pipedOutputStream = PipedOutputStream(pipedInputStream)
GlobalScope.launch(Dispatchers.IO) {
davCollection.get(accept = "", headers = null) { response ->
val inputStream = response.body?.byteStream()
?: throw IOException("No response body")
debugLog { "getInputStream($token, $name) = $response" }
pipedOutputStream.use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
return pipedInputStream
}
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun removeData(token: Long, name: String) { override suspend fun removeData(token: Long, name: String) {
val location = "$url/$token/$name".toHttpUrl() val location = "$url/$token/$name".toHttpUrl()
@ -262,36 +170,6 @@ internal class WebDavStoragePlugin(
!name.endsWith(".SeedSnap") !name.endsWith(".SeedSnap")
} }
private fun Response.isFolder(): Boolean {
return this[ResourceType::class.java]?.types?.contains(COLLECTION) == true
}
override val providerPackageName: String = context.packageName // 100% built-in plugin override val providerPackageName: String = context.packageName // 100% built-in plugin
private class PipedCloseActionOutputStream(
inputStream: PipedInputStream,
) : PipedOutputStream(inputStream) {
private var onClose: (() -> Unit)? = null
@Throws(IOException::class)
override fun close() {
super.close()
try {
onClose?.invoke()
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
fun doOnClose(function: () -> Unit) {
this.onClose = function
}
}
private fun debugLog(block: () -> String) {
if (DEBUG_LOG) Log.d(TAG, block())
}
} }

View file

@ -8,7 +8,7 @@ import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
import javax.crypto.SecretKey import javax.crypto.SecretKey
internal class SeedvaultStoragePlugin( internal class SeedvaultSafStoragePlugin(
private val appContext: Context, private val appContext: Context,
private val storage: DocumentsStorage, private val storage: DocumentsStorage,
private val keyManager: KeyManager, private val keyManager: KeyManager,

View file

@ -5,6 +5,6 @@ import org.calyxos.backup.storage.api.StoragePlugin
import org.koin.dsl.module import org.koin.dsl.module
val storageModule = module { val storageModule = module {
single<StoragePlugin> { SeedvaultStoragePlugin(get(), get(), get()) } single<StoragePlugin> { SeedvaultSafStoragePlugin(get(), get(), get()) }
single { StorageBackup(get(), get()) } single { StorageBackup(get(), get()) }
} }

View file

@ -0,0 +1,275 @@
package com.stevesoltys.seedvault.storage
import android.util.Log
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.ResourceType
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
import com.stevesoltys.seedvault.plugins.webdav.WebDavStorage
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.calyxos.backup.storage.api.StoragePlugin
import org.calyxos.backup.storage.api.StoredSnapshot
import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex
import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex
import org.koin.core.time.measureDuration
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import javax.crypto.SecretKey
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
internal class WebDavStoragePlugin(
private val keyManager: KeyManager,
/**
* The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
*/
androidId: String,
webDavConfig: WebDavConfig,
) : WebDavStorage(webDavConfig), StoragePlugin {
/**
* The folder name is our user ID plus .sv extension (for SeedVault).
* The user or `androidId` is unique to each combination of app-signing key, user, and device
* so we don't leak anything by not hashing this and can use it as is.
*/
private val folder: String = "$androidId.sv"
@Throws(IOException::class)
override suspend fun getAvailableChunkIds(): List<String> {
val location = "$url/$folder".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getAvailableChunkIds($location)" }
val expectedChunkFolders = (0x00..0xff).map {
Integer.toHexString(it).padStart(2, '0')
}.toHashSet()
val chunkIds = ArrayList<String>()
try {
val duration = measureDuration {
davCollection.propfind(
depth = 2,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getAvailableChunkIds() = $response" }
// This callback will be called for every file in the folder
if (relation != SELF && response.isFolder()) {
val name = response.hrefName()
if (chunkFolderRegex.matches(name)) {
expectedChunkFolders.remove(name)
}
} else if (relation != SELF && response.href.pathSize >= 2) {
val folderName =
response.href.pathSegments[response.href.pathSegments.size - 2]
if (folderName != folder && chunkFolderRegex.matches(folderName)) {
val name = response.hrefName()
if (chunkRegex.matches(name)) chunkIds.add(name)
}
}
}
}
Log.i(TAG, "Retrieving chunks took $duration")
} catch (e: NotFoundException) {
debugLog { "Folder not found: $location" }
davCollection.createFolder()
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error populating chunk folders: ", e)
}
Log.i(TAG, "Got ${chunkIds.size} available chunks")
createMissingChunkFolders(expectedChunkFolders)
return chunkIds
}
@Throws(IOException::class)
private suspend fun createMissingChunkFolders(
missingChunkFolders: Set<String>,
) {
val s = missingChunkFolders.size
for ((i, chunkFolderName) in missingChunkFolders.withIndex()) {
val location = "$url/$folder/$chunkFolderName".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val response = davCollection.createFolder()
debugLog { "Created missing folder $chunkFolderName (${i + 1}/$s) $response" }
}
}
override fun getMasterKey(): SecretKey = keyManager.getMainKey()
override fun hasMasterKey(): Boolean = keyManager.hasMainKey()
@Throws(IOException::class)
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
val chunkFolderName = chunkId.substring(0, 2)
val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl()
debugLog { "getChunkOutputStream($location) for $chunkId" }
return try {
getOutputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting OutputStream for $chunkId: ", e)
}
}
@Throws(IOException::class)
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
val location = "$url/$folder/$timestamp.SeedSnap".toHttpUrl()
debugLog { "getBackupSnapshotOutputStream($location)" }
return try {
getOutputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting OutputStream for $timestamp.SeedSnap: ", e)
}
}
/************************* Restore *******************************/
@Throws(IOException::class)
override suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot> {
val location = url.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getBackupSnapshotsForRestore($location)" }
val snapshots = ArrayList<StoredSnapshot>()
try {
davCollection.propfind(
depth = 2,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getBackupSnapshotsForRestore() = $response" }
// This callback will be called for every file in the folder
if (relation != SELF && !response.isFolder()) {
val name = response.hrefName()
val match = snapshotRegex.matchEntire(name)
if (match != null) {
val timestamp = match.groupValues[1].toLong()
val folderName =
response.href.pathSegments[response.href.pathSegments.size - 2]
val storedSnapshot = StoredSnapshot(folderName, timestamp)
snapshots.add(storedSnapshot)
}
}
}
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting snapshots for restore: ", e)
}
return snapshots
}
@Throws(IOException::class)
override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream {
val timestamp = storedSnapshot.timestamp
val location = "$url/${storedSnapshot.userId}/$timestamp.SeedSnap".toHttpUrl()
debugLog { "getBackupSnapshotInputStream($location)" }
return try {
getInputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting InputStream for $storedSnapshot: ", e)
}
}
@Throws(IOException::class)
override suspend fun getChunkInputStream(
snapshot: StoredSnapshot,
chunkId: String,
): InputStream {
val chunkFolderName = chunkId.substring(0, 2)
val location = "$url/${snapshot.userId}/$chunkFolderName/$chunkId".toHttpUrl()
debugLog { "getChunkInputStream($location) for $chunkId" }
return try {
getInputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting InputStream for $chunkFolderName/$chunkId: ", e)
}
}
/************************* Pruning *******************************/
@Throws(IOException::class)
override suspend fun getCurrentBackupSnapshots(): List<StoredSnapshot> {
val location = "$url/$folder".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getCurrentBackupSnapshots($location)" }
val snapshots = ArrayList<StoredSnapshot>()
try {
val duration = measureDuration {
davCollection.propfind(
depth = 1,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getCurrentBackupSnapshots() = $response" }
// This callback will be called for every file in the folder
if (relation != SELF && !response.isFolder()) {
val match = snapshotRegex.matchEntire(response.hrefName())
if (match != null) {
val timestamp = match.groupValues[1].toLong()
val folderName =
response.href.pathSegments[response.href.pathSegments.size - 2]
val storedSnapshot = StoredSnapshot(folderName, timestamp)
snapshots.add(storedSnapshot)
}
}
}
}
Log.i(TAG, "getCurrentBackupSnapshots took $duration")
} catch (e: NotFoundException) {
debugLog { "Folder not found: $location" }
davCollection.createFolder()
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting current snapshots: ", e)
}
Log.i(TAG, "Got ${snapshots.size} snapshots.")
return snapshots
}
@Throws(IOException::class)
override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) {
val timestamp = storedSnapshot.timestamp
Log.d(TAG, "Deleting snapshot $timestamp")
val location = "$url/${storedSnapshot.userId}/$timestamp.SeedSnap".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
val response = suspendCoroutine { cont ->
davCollection.delete { response ->
cont.resume(response)
}
}
debugLog { "deleteBackupSnapshot() = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
@Throws(IOException::class)
override suspend fun deleteChunks(chunkIds: List<String>) {
chunkIds.forEach { chunkId ->
val chunkFolderName = chunkId.substring(0, 2)
val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
val response = suspendCoroutine { cont ->
davCollection.delete { response ->
cont.resume(response)
}
}
debugLog { "deleteChunks($chunkId) = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
}
}

View file

@ -14,7 +14,9 @@ import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.backupModule import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.transport.restore.restoreModule import com.stevesoltys.seedvault.transport.restore.restoreModule
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module import org.koin.dsl.module
class TestApp : App() { class TestApp : App() {
@ -31,7 +33,9 @@ class TestApp : App() {
single { SettingsManager(this@TestApp) } single { SettingsManager(this@TestApp) }
} }
override fun startKoin() = startKoin { override fun startKoin(): KoinApplication {
stopKoin()
return startKoin {
androidContext(this@TestApp) androidContext(this@TestApp)
modules( modules(
listOf( listOf(
@ -46,4 +50,5 @@ class TestApp : App() {
) )
) )
} }
}
} }

View file

@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.storage
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig
import com.stevesoltys.seedvault.transport.backup.BackupTest
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.calyxos.backup.storage.api.StoredSnapshot
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
internal class WebDavStoragePluginTest : BackupTest() {
private val keyManager: KeyManager = mockk()
private val plugin = WebDavStoragePlugin(keyManager, "foo", WebDavTestConfig.getConfig())
private val snapshot = StoredSnapshot("foo.sv", System.currentTimeMillis())
@Test
fun `test chunks`() = runBlocking {
val chunkId1 = getRandomByteArray(32).toHexString()
val chunkBytes1 = getRandomByteArray()
// first we don't have any chunks
assertEquals(emptyList<String>(), plugin.getAvailableChunkIds())
// we write out chunk1
plugin.getChunkOutputStream(chunkId1).use {
it.write(chunkBytes1)
}
try {
// now we have the ID of chunk1
assertEquals(listOf(chunkId1), plugin.getAvailableChunkIds())
// reading chunk1 matches what we wrote
assertArrayEquals(
chunkBytes1,
plugin.getChunkInputStream(snapshot, chunkId1).readAllBytes(),
)
} finally {
// delete chunk again
plugin.deleteChunks(listOf(chunkId1))
}
}
@Test
fun `test snapshots`() = runBlocking {
val snapshotBytes = getRandomByteArray()
// first we don't have any snapshots
assertEquals(emptyList<StoredSnapshot>(), plugin.getCurrentBackupSnapshots())
assertEquals(emptyList<StoredSnapshot>(), plugin.getBackupSnapshotsForRestore())
// now write one snapshot
plugin.getBackupSnapshotOutputStream(snapshot.timestamp).use {
it.write(snapshotBytes)
}
try {
// now we have that one snapshot
assertEquals(listOf(snapshot), plugin.getCurrentBackupSnapshots())
assertEquals(listOf(snapshot), plugin.getBackupSnapshotsForRestore())
// read back written snapshot
assertArrayEquals(
snapshotBytes,
plugin.getBackupSnapshotInputStream(snapshot).readAllBytes(),
)
// other device writes another snapshot
val otherPlugin = WebDavStoragePlugin(keyManager, "bar", WebDavTestConfig.getConfig())
val otherSnapshot = StoredSnapshot("bar.sv", System.currentTimeMillis())
val otherSnapshotBytes = getRandomByteArray()
assertEquals(emptyList<String>(), otherPlugin.getAvailableChunkIds())
otherPlugin.getBackupSnapshotOutputStream(otherSnapshot.timestamp).use {
it.write(otherSnapshotBytes)
}
try {
// now that initial one snapshot is still the only current, but restore has both
assertEquals(listOf(snapshot), plugin.getCurrentBackupSnapshots())
assertEquals(
setOf(snapshot, otherSnapshot),
plugin.getBackupSnapshotsForRestore().toSet(), // set to avoid sorting issues
)
} finally {
plugin.deleteBackupSnapshot(otherSnapshot)
}
} finally {
plugin.deleteBackupSnapshot(snapshot)
}
}
}
private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }

View file

@ -42,12 +42,12 @@ class TestSafStoragePlugin(
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getChunkOutputStream(chunkId: String): OutputStream { override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
if (getLocationUri() == null) return nullStream if (getLocationUri() == null) return nullStream
return super.getChunkOutputStream(chunkId) return super.getChunkOutputStream(chunkId)
} }
override fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream { override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
if (root == null) return nullStream if (root == null) return nullStream
return super.getBackupSnapshotOutputStream(timestamp) return super.getBackupSnapshotOutputStream(timestamp)
} }

View file

@ -22,6 +22,7 @@ public sealed class SnapshotResult {
public data class StoredSnapshot( public data class StoredSnapshot(
/** /**
* The unique ID of the current device/user combination chosen by the [StoragePlugin]. * The unique ID of the current device/user combination chosen by the [StoragePlugin].
* It may include an '.sv' extension.
*/ */
public val userId: String, public val userId: String,
/** /**

View file

@ -32,10 +32,10 @@ public interface StoragePlugin {
public fun hasMasterKey(): Boolean public fun hasMasterKey(): Boolean
@Throws(IOException::class) @Throws(IOException::class)
public fun getChunkOutputStream(chunkId: String): OutputStream public suspend fun getChunkOutputStream(chunkId: String): OutputStream
@Throws(IOException::class) @Throws(IOException::class)
public fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream public suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream
/* Restore */ /* Restore */

View file

@ -37,7 +37,7 @@ internal class ChunkWriter(
private val buffer = ByteArray(bufferSize) private val buffer = ByteArray(bufferSize)
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
fun writeChunk( suspend fun writeChunk(
inputStream: InputStream, inputStream: InputStream,
chunks: List<Chunk>, chunks: List<Chunk>,
missingChunkIds: List<String>, missingChunkIds: List<String>,
@ -67,7 +67,7 @@ internal class ChunkWriter(
} }
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
private fun writeChunkData(chunkId: String, writer: (OutputStream) -> Unit) { private suspend fun writeChunkData(chunkId: String, writer: (OutputStream) -> Unit) {
storagePlugin.getChunkOutputStream(chunkId).use { chunkStream -> storagePlugin.getChunkOutputStream(chunkId).use { chunkStream ->
chunkStream.write(VERSION.toInt()) chunkStream.write(VERSION.toInt())
val ad = streamCrypto.getAssociatedDataForChunk(chunkId) val ad = streamCrypto.getAssociatedDataForChunk(chunkId)
@ -102,7 +102,7 @@ internal class ChunkWriter(
* @return true if the chunk was written or false, if it was present already. * @return true if the chunk was written or false, if it was present already.
*/ */
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
fun writeZipChunk( suspend fun writeZipChunk(
chunk: ZipChunk, chunk: ZipChunk,
zip: ByteArrayOutputStream, zip: ByteArrayOutputStream,
missingChunkIds: List<String>, missingChunkIds: List<String>,

View file

@ -74,7 +74,7 @@ internal class FileBackup(
) )
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
private fun backupFile( private suspend fun backupFile(
file: ContentFile, file: ContentFile,
availableChunkIds: HashSet<String>, availableChunkIds: HashSet<String>,
): FileBackupResult { ): FileBackupResult {

View file

@ -108,7 +108,7 @@ internal class SmallFileBackup(
* Returns null, if there's space in the zip chunk and the next file can be added. * Returns null, if there's space in the zip chunk and the next file can be added.
*/ */
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
private fun makeZipChunk( private suspend fun makeZipChunk(
window: List<ContentFile>, window: List<ContentFile>,
missingChunkIds: List<String>, missingChunkIds: List<String>,
): SmallFileBackupResult? { ): SmallFileBackupResult? {
@ -127,7 +127,7 @@ internal class SmallFileBackup(
} }
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
private fun finalizeAndReset( private suspend fun finalizeAndReset(
zipChunker: ZipChunker, zipChunker: ZipChunker,
missingChunkIds: List<String>, missingChunkIds: List<String>,
): SmallFileBackupResult { ): SmallFileBackupResult {

View file

@ -61,7 +61,7 @@ internal class ZipChunker(
* This object gets reset for the next operation. * This object gets reset for the next operation.
*/ */
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
fun finalizeAndReset(missingChunkIds: List<String>): ZipChunk = try { suspend fun finalizeAndReset(missingChunkIds: List<String>): ZipChunk = try {
zipOutputStream.finish() zipOutputStream.finish()
zipOutputStream.close() zipOutputStream.close()

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.backup.storage.plugin
public object PluginConstants {
public val folderRegex: Regex = Regex("^[a-f0-9]{16}\\.sv$")
public val chunkFolderRegex: Regex = Regex("[a-f0-9]{2}")
public val chunkRegex: Regex = Regex("[a-f0-9]{64}")
public val snapshotRegex: Regex = Regex("([0-9]{13})\\.SeedSnap") // good until the year 2286
public const val MIME_TYPE: String = "application/octet-stream"
public const val CHUNK_FOLDER_COUNT: Int = 256
}

View file

@ -7,6 +7,7 @@ package org.calyxos.backup.storage.plugin.saf
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.api.StoredSnapshot
import org.calyxos.backup.storage.plugin.PluginConstants.CHUNK_FOLDER_COUNT
/** /**
* Accessing files and attributes via SAF is costly. * Accessing files and attributes via SAF is costly.

View file

@ -14,6 +14,12 @@ import androidx.documentfile.provider.DocumentFile
import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoragePlugin
import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.api.StoredSnapshot
import org.calyxos.backup.storage.measure import org.calyxos.backup.storage.measure
import org.calyxos.backup.storage.plugin.PluginConstants.CHUNK_FOLDER_COUNT
import org.calyxos.backup.storage.plugin.PluginConstants.MIME_TYPE
import org.calyxos.backup.storage.plugin.PluginConstants.chunkFolderRegex
import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex
import org.calyxos.backup.storage.plugin.PluginConstants.folderRegex
import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex
import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.createDirectoryOrThrow import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.createDirectoryOrThrow
import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.createFileOrThrow import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.createFileOrThrow
import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.findFileBlocking import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.findFileBlocking
@ -25,13 +31,6 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
private val folderRegex = Regex("^[a-f0-9]{16}\\.sv$")
private val chunkFolderRegex = Regex("[a-f0-9]{2}")
private val chunkRegex = Regex("[a-f0-9]{64}")
private val snapshotRegex = Regex("([0-9]{13})\\.SeedSnap") // good until the year 2286
private const val MIME_TYPE: String = "application/octet-stream"
internal const val CHUNK_FOLDER_COUNT = 256
private const val TAG = "SafStoragePlugin" private const val TAG = "SafStoragePlugin"
/** /**
@ -154,7 +153,7 @@ public abstract class SafStoragePlugin(
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getChunkOutputStream(chunkId: String): OutputStream { override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
val chunkFolderName = chunkId.substring(0, 2) val chunkFolderName = chunkId.substring(0, 2)
val chunkFolder = val chunkFolder =
cache.backupChunkFolders[chunkFolderName] ?: error("No folder for chunk $chunkId") cache.backupChunkFolders[chunkFolderName] ?: error("No folder for chunk $chunkId")
@ -164,7 +163,7 @@ public abstract class SafStoragePlugin(
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream { override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
val folder = folder ?: throw IOException() val folder = folder ?: throw IOException()
val name = timestampToSnapshot(timestamp) val name = timestampToSnapshot(timestamp)
// TODO should we check if it exists first? // TODO should we check if it exists first?

View file

@ -19,7 +19,6 @@ import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.slot import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.calyxos.backup.storage.api.SnapshotResult import org.calyxos.backup.storage.api.SnapshotResult
@ -150,14 +149,14 @@ internal class BackupRestoreTest {
} returns ByteArrayInputStream(fileDBytes) andThen ByteArrayInputStream(fileDBytes) } returns ByteArrayInputStream(fileDBytes) andThen ByteArrayInputStream(fileDBytes)
// output streams and caching // output streams and caching
every { plugin.getChunkOutputStream(any()) } returnsMany listOf( coEvery { plugin.getChunkOutputStream(any()) } returnsMany listOf(
zipChunkOutputStream, mOutputStream, dOutputStream zipChunkOutputStream, mOutputStream, dOutputStream
) )
every { chunksCache.insert(any<CachedChunk>()) } just Runs every { chunksCache.insert(any<CachedChunk>()) } just Runs
every { filesCache.upsert(capture(cachedFiles)) } just Runs every { filesCache.upsert(capture(cachedFiles)) } just Runs
// snapshot writing // snapshot writing
every { coEvery {
plugin.getBackupSnapshotOutputStream(capture(snapshotTimestamp)) plugin.getBackupSnapshotOutputStream(capture(snapshotTimestamp))
} returns snapshotOutputStream } returns snapshotOutputStream
every { db.applyInParts<String>(any(), any()) } just Runs every { db.applyInParts<String>(any(), any()) } just Runs
@ -296,25 +295,25 @@ internal class BackupRestoreTest {
// output streams for deterministic chunks // output streams for deterministic chunks
val id040f32 = ByteArrayOutputStream() val id040f32 = ByteArrayOutputStream()
every { coEvery {
plugin.getChunkOutputStream( plugin.getChunkOutputStream(
"040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3" "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3"
) )
} returns id040f32 } returns id040f32
val id901fbc = ByteArrayOutputStream() val id901fbc = ByteArrayOutputStream()
every { coEvery {
plugin.getChunkOutputStream( plugin.getChunkOutputStream(
"901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29" "901fbcf9a94271fc0455d0052522cab994f9392d0bb85187860282b4beadfb29"
) )
} returns id901fbc } returns id901fbc
val id5adea3 = ByteArrayOutputStream() val id5adea3 = ByteArrayOutputStream()
every { coEvery {
plugin.getChunkOutputStream( plugin.getChunkOutputStream(
"5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d" "5adea3149fe6cf9c6e3270a52ee2c31bc9dfcef5f2080b583a4dd3b779c9182d"
) )
} returns id5adea3 } returns id5adea3
val id40d00c = ByteArrayOutputStream() val id40d00c = ByteArrayOutputStream()
every { coEvery {
plugin.getChunkOutputStream( plugin.getChunkOutputStream(
"40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67" "40d00c1be4b0f89e8b12d47f3658aa42f568a8d02b978260da6d0050e7007e67"
) )
@ -324,7 +323,7 @@ internal class BackupRestoreTest {
every { filesCache.upsert(capture(cachedFiles)) } just Runs every { filesCache.upsert(capture(cachedFiles)) } just Runs
// snapshot writing // snapshot writing
every { coEvery {
plugin.getBackupSnapshotOutputStream(capture(snapshotTimestamp)) plugin.getBackupSnapshotOutputStream(capture(snapshotTimestamp))
} returns snapshotOutputStream } returns snapshotOutputStream
every { db.applyInParts<String>(any(), any()) } just Runs every { db.applyInParts<String>(any(), any()) } just Runs
@ -332,7 +331,7 @@ internal class BackupRestoreTest {
backup.runBackup(null) backup.runBackup(null)
// chunks were only written to storage once // chunks were only written to storage once
verify(exactly = 1) { coVerify(exactly = 1) {
plugin.getChunkOutputStream( plugin.getChunkOutputStream(
"040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3") "040f3204869543c4015d92c04bf875b25ebde55f9645380f4172aa439b2825d3")
plugin.getChunkOutputStream( plugin.getChunkOutputStream(

View file

@ -7,9 +7,11 @@ package org.calyxos.backup.storage.backup
import io.mockk.MockKMatcherScope import io.mockk.MockKMatcherScope
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoragePlugin
import org.calyxos.backup.storage.backup.Backup.Companion.VERSION import org.calyxos.backup.storage.backup.Backup.Companion.VERSION
import org.calyxos.backup.storage.crypto.Hkdf.KEY_SIZE_BYTES import org.calyxos.backup.storage.crypto.Hkdf.KEY_SIZE_BYTES
@ -45,7 +47,7 @@ internal class ChunkWriterTest {
} }
@Test @Test
fun testTwoByteChunksNotCached() { fun testTwoByteChunksNotCached() = runBlocking {
val inputBytes = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04, 0x05) val inputBytes = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04, 0x05)
val inputStream = ByteArrayInputStream(inputBytes) val inputStream = ByteArrayInputStream(inputBytes)
val chunks = listOf( val chunks = listOf(
@ -64,9 +66,9 @@ internal class ChunkWriterTest {
every { chunksCache.get(chunkId3) } returns null every { chunksCache.get(chunkId3) } returns null
// get the output streams for the chunks // get the output streams for the chunks
every { storagePlugin.getChunkOutputStream(chunkId1) } returns chunk1Output coEvery { storagePlugin.getChunkOutputStream(chunkId1) } returns chunk1Output
every { storagePlugin.getChunkOutputStream(chunkId2) } returns chunk2Output coEvery { storagePlugin.getChunkOutputStream(chunkId2) } returns chunk2Output
every { storagePlugin.getChunkOutputStream(chunkId3) } returns chunk3Output coEvery { storagePlugin.getChunkOutputStream(chunkId3) } returns chunk3Output
// get AD // get AD
every { streamCrypto.getAssociatedDataForChunk(chunkId1) } returns ad1 every { streamCrypto.getAssociatedDataForChunk(chunkId1) } returns ad1
@ -103,7 +105,7 @@ internal class ChunkWriterTest {
} }
@Test @Test
fun testCachedChunksSkippedIfNotMissing() { fun testCachedChunksSkippedIfNotMissing() = runBlocking {
val inputBytes = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04, 0x05) val inputBytes = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04, 0x05)
val inputStream = ByteArrayInputStream(inputBytes) val inputStream = ByteArrayInputStream(inputBytes)
val chunks = listOf( val chunks = listOf(
@ -120,7 +122,7 @@ internal class ChunkWriterTest {
every { chunksCache.get(chunkId3) } returns null every { chunksCache.get(chunkId3) } returns null
// get and wrap the output stream for chunk that is missing // get and wrap the output stream for chunk that is missing
every { storagePlugin.getChunkOutputStream(chunkId1) } returns chunk1Output coEvery { storagePlugin.getChunkOutputStream(chunkId1) } returns chunk1Output
every { streamCrypto.getAssociatedDataForChunk(chunkId1) } returns ad1 every { streamCrypto.getAssociatedDataForChunk(chunkId1) } returns ad1
every { every {
streamCrypto.newEncryptingStream(streamKey, chunk1Output, bytes(34)) streamCrypto.newEncryptingStream(streamKey, chunk1Output, bytes(34))
@ -130,7 +132,7 @@ internal class ChunkWriterTest {
every { chunksCache.insert(chunks[0].toCachedChunk()) } just Runs every { chunksCache.insert(chunks[0].toCachedChunk()) } just Runs
// get and wrap the output stream for chunk that isn't cached // get and wrap the output stream for chunk that isn't cached
every { storagePlugin.getChunkOutputStream(chunkId3) } returns chunk3Output coEvery { storagePlugin.getChunkOutputStream(chunkId3) } returns chunk3Output
every { streamCrypto.getAssociatedDataForChunk(chunkId3) } returns ad3 every { streamCrypto.getAssociatedDataForChunk(chunkId3) } returns ad3
every { every {
streamCrypto.newEncryptingStream(streamKey, chunk3Output, bytes(34)) streamCrypto.newEncryptingStream(streamKey, chunk3Output, bytes(34))
@ -149,7 +151,7 @@ internal class ChunkWriterTest {
} }
@Test @Test
fun testLargerRandomChunks() { fun testLargerRandomChunks() = runBlocking {
val chunk1Bytes = Random.nextBytes(Random.nextInt(1, 1024 * 1024)) val chunk1Bytes = Random.nextBytes(Random.nextInt(1, 1024 * 1024))
val chunk2Bytes = Random.nextBytes(Random.nextInt(1, 1024 * 1024)) val chunk2Bytes = Random.nextBytes(Random.nextInt(1, 1024 * 1024))
val chunk3Bytes = Random.nextBytes(Random.nextInt(1, 1024 * 1024)) val chunk3Bytes = Random.nextBytes(Random.nextInt(1, 1024 * 1024))
@ -173,8 +175,8 @@ internal class ChunkWriterTest {
every { chunksCache.get(chunkId3) } returns null every { chunksCache.get(chunkId3) } returns null
// get the output streams for the chunks // get the output streams for the chunks
every { storagePlugin.getChunkOutputStream(chunkId1) } returns chunk1Output coEvery { storagePlugin.getChunkOutputStream(chunkId1) } returns chunk1Output
every { storagePlugin.getChunkOutputStream(chunkId3) } returns chunk3Output coEvery { storagePlugin.getChunkOutputStream(chunkId3) } returns chunk3Output
// get AD // get AD
every { streamCrypto.getAssociatedDataForChunk(chunkId1) } returns ad1 every { streamCrypto.getAssociatedDataForChunk(chunkId1) } returns ad1

View file

@ -91,7 +91,7 @@ internal class SmallFileBackupIntegrationTest {
every { mac.doFinal(any<ByteArray>()) } returns chunkId every { mac.doFinal(any<ByteArray>()) } returns chunkId
every { chunksCache.get(any()) } returns null every { chunksCache.get(any()) } returns null
every { storagePlugin.getChunkOutputStream(any()) } returns outputStream2 coEvery { storagePlugin.getChunkOutputStream(any()) } returns outputStream2
every { every {
chunksCache.insert(match<CachedChunk> { cachedChunk -> chunksCache.insert(match<CachedChunk> { cachedChunk ->
cachedChunk.id == chunkId.toHexString() && cachedChunk.id == chunkId.toHexString() &&

View file

@ -7,6 +7,7 @@ package org.calyxos.backup.storage.backup
import android.content.ContentResolver import android.content.ContentResolver
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
@ -99,7 +100,7 @@ internal class SmallFileBackupTest {
if (cachedFile == null) emptyList() else cachedFile.chunks - availableChunkIds if (cachedFile == null) emptyList() else cachedFile.chunks - availableChunkIds
addFile(file, cachedFile) addFile(file, cachedFile)
every { zipChunker.finalizeAndReset(missingChunks) } returns zipChunk coEvery { zipChunker.finalizeAndReset(missingChunks) } returns zipChunk
every { filesCache.upsert(sameCachedFile(newCachedFile)) } just Runs every { filesCache.upsert(sameCachedFile(newCachedFile)) } just Runs
val result = smallFileBackup.backupFiles(files, availableChunkIds, null) val result = smallFileBackup.backupFiles(files, availableChunkIds, null)
@ -123,7 +124,7 @@ internal class SmallFileBackupTest {
every { filesCache.getByUri(file1.uri) } returns null every { filesCache.getByUri(file1.uri) } returns null
every { contentResolver.openInputStream(file1.uri) } throws IOException() every { contentResolver.openInputStream(file1.uri) } throws IOException()
addFile(file2) addFile(file2)
every { zipChunker.finalizeAndReset(emptyList()) } returns zipChunk coEvery { zipChunker.finalizeAndReset(emptyList()) } returns zipChunk
every { filesCache.upsert(sameCachedFile(cachedFile2)) } just Runs every { filesCache.upsert(sameCachedFile(cachedFile2)) } just Runs
val result = smallFileBackup.backupFiles(files, availableChunkIds, null) val result = smallFileBackup.backupFiles(files, availableChunkIds, null)
@ -148,7 +149,7 @@ internal class SmallFileBackupTest {
addFile(file1) addFile(file1)
every { zipChunker.fitsFile(file2) } returns false every { zipChunker.fitsFile(file2) } returns false
every { zipChunker.finalizeAndReset(emptyList()) } returns zipChunk1 andThen zipChunk2 coEvery { zipChunker.finalizeAndReset(emptyList()) } returns zipChunk1 andThen zipChunk2
every { filesCache.upsert(sameCachedFile(cachedFile1)) } just Runs every { filesCache.upsert(sameCachedFile(cachedFile1)) } just Runs
addFile(file2) addFile(file2)
// zipChunker.finalizeAndReset defined above for both files // zipChunker.finalizeAndReset defined above for both files
@ -178,7 +179,7 @@ internal class SmallFileBackupTest {
addFile(file1) addFile(file1)
every { zipChunker.fitsFile(file2) } returns true every { zipChunker.fitsFile(file2) } returns true
addFile(file2) addFile(file2)
every { zipChunker.finalizeAndReset(emptyList()) } returns zipChunk coEvery { zipChunker.finalizeAndReset(emptyList()) } returns zipChunk
every { filesCache.upsert(sameCachedFile(cachedFile1)) } just Runs every { filesCache.upsert(sameCachedFile(cachedFile1)) } just Runs
every { filesCache.upsert(sameCachedFile(cachedFile2)) } just Runs every { filesCache.upsert(sameCachedFile(cachedFile2)) } just Runs
@ -200,7 +201,7 @@ internal class SmallFileBackupTest {
addFile(file1) addFile(file1)
every { zipChunker.fitsFile(file2) } returns true every { zipChunker.fitsFile(file2) } returns true
addFile(file2) addFile(file2)
every { zipChunker.finalizeAndReset(emptyList()) } throws IOException() coEvery { zipChunker.finalizeAndReset(emptyList()) } throws IOException()
val result = smallFileBackup.backupFiles(files, hashSetOf(), null) val result = smallFileBackup.backupFiles(files, hashSetOf(), null)
assertEquals(emptySet<String>(), result.chunkIds) assertEquals(emptySet<String>(), result.chunkIds)

View file

@ -6,9 +6,11 @@
package org.calyxos.backup.storage.backup package org.calyxos.backup.storage.backup
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.calyxos.backup.storage.getRandomDocFile import org.calyxos.backup.storage.getRandomDocFile
import org.calyxos.backup.storage.getRandomString import org.calyxos.backup.storage.getRandomString
import org.calyxos.backup.storage.toHexString import org.calyxos.backup.storage.toHexString
@ -76,7 +78,7 @@ internal class ZipChunkerTest {
} }
@Test @Test
fun `two files finalizeAndReset()`() { fun `two files finalizeAndReset()`() = runBlocking {
val file1 = getRandomDocFile(4) val file1 = getRandomDocFile(4)
val file2 = getRandomDocFile(8) val file2 = getRandomDocFile(8)
val chunkIdBytes = Random.nextBytes(64) val chunkIdBytes = Random.nextBytes(64)
@ -90,7 +92,7 @@ internal class ZipChunkerTest {
zipChunker.addFile(file2, fileInputStream) zipChunker.addFile(file2, fileInputStream)
every { mac.doFinal(any()) } returns chunkIdBytes every { mac.doFinal(any()) } returns chunkIdBytes
every { chunkWriter.writeZipChunk(zipChunk, any(), missingChunks) } returns wasWritten coEvery { chunkWriter.writeZipChunk(zipChunk, any(), missingChunks) } returns wasWritten
assertEquals( assertEquals(
zipChunk.copy(wasUploaded = wasWritten), zipChunk.copy(wasUploaded = wasWritten),
@ -104,7 +106,7 @@ internal class ZipChunkerTest {
} }
@Test @Test
fun `throwing in finalizeAndReset() resets counter`() { fun `throwing in finalizeAndReset() resets counter`() = runBlocking {
val file1 = getRandomDocFile(4) val file1 = getRandomDocFile(4)
val file2 = getRandomDocFile(8) val file2 = getRandomDocFile(8)
val chunkIdBytes = Random.nextBytes(64) val chunkIdBytes = Random.nextBytes(64)
@ -117,7 +119,7 @@ internal class ZipChunkerTest {
zipChunker.addFile(file2, fileInputStream) zipChunker.addFile(file2, fileInputStream)
every { mac.doFinal(any()) } returns chunkIdBytes every { mac.doFinal(any()) } returns chunkIdBytes
every { chunkWriter.writeZipChunk(zipChunk, any(), missingChunks) } throws IOException() coEvery { chunkWriter.writeZipChunk(zipChunk, any(), missingChunks) } throws IOException()
assertFailsWith(IOException::class) { assertFailsWith(IOException::class) {
zipChunker.finalizeAndReset(missingChunks) zipChunker.finalizeAndReset(missingChunks)