Ensure root folder exists when using storage

We use the same root folder for app and files backup. App backup usually creates the root folder, but if only storage backup is used, it will be missing and needs to be created.
This commit is contained in:
Torsten Grote 2024-04-04 17:48:09 -03:00
parent e6e65d0dd1
commit 4f2ead66a5
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
12 changed files with 141 additions and 22 deletions

View file

@ -38,6 +38,7 @@ const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
@OptIn(DelicateCoroutinesApi::class)
internal abstract class WebDavStorage(
webDavConfig: WebDavConfig,
root: String = DIRECTORY_ROOT,
) {
companion object {
@ -61,7 +62,7 @@ internal abstract class WebDavStorage(
.retryOnConnectionFailure(true)
.build()
protected val url = "${webDavConfig.url}/$DIRECTORY_ROOT"
protected val url = "${webDavConfig.url}/$root"
@Throws(IOException::class)
protected suspend fun getOutputStream(location: HttpUrl): OutputStream {

View file

@ -24,7 +24,8 @@ import kotlin.coroutines.suspendCoroutine
internal class WebDavStoragePlugin(
context: Context,
webDavConfig: WebDavConfig,
) : WebDavStorage(webDavConfig), StoragePlugin {
root: String = DIRECTORY_ROOT,
) : WebDavStorage(webDavConfig, root), StoragePlugin {
@Throws(IOException::class)
override suspend fun startNewRestoreSet(token: Long) {
@ -39,6 +40,20 @@ internal class WebDavStoragePlugin(
override suspend fun initializeDevice() {
// TODO does it make sense to delete anything
// when [startNewRestoreSet] is always called first? Maybe unify both calls?
val location = url.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
davCollection.head { response ->
debugLog { "Root exists: $response" }
}
} catch (e: NotFoundException) {
val response = davCollection.createFolder()
debugLog { "initializeDevice() = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
@Throws(IOException::class)

View file

@ -8,6 +8,7 @@ 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.DIRECTORY_ROOT
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
import com.stevesoltys.seedvault.plugins.webdav.WebDavStorage
import okhttp3.HttpUrl.Companion.toHttpUrl
@ -30,7 +31,8 @@ internal class WebDavStoragePlugin(
*/
androidId: String,
webDavConfig: WebDavConfig,
) : WebDavStorage(webDavConfig), StoragePlugin {
root: String = DIRECTORY_ROOT,
) : WebDavStorage(webDavConfig, root), StoragePlugin {
/**
* The folder name is our user ID plus .sv extension (for SeedVault).
@ -39,6 +41,24 @@ internal class WebDavStoragePlugin(
*/
private val folder: String = "$androidId.sv"
@Throws(IOException::class)
override suspend fun init() {
val location = url.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
davCollection.head { response ->
debugLog { "Root exists: $response" }
}
} catch (e: NotFoundException) {
val response = davCollection.createFolder()
debugLog { "init() = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
@Throws(IOException::class)
override suspend fun getAvailableChunkIds(): List<String> {
val location = "$url/$folder".toHttpUrl()
@ -211,18 +231,13 @@ internal class WebDavStoragePlugin(
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)
val storedSnapshot = StoredSnapshot(folder, 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)

View file

@ -103,8 +103,7 @@ internal class RecoveryCodeViewModel(
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
GlobalScope.launch(Dispatchers.IO) {
// remove old storage snapshots and clear cache
storageBackup.deleteAllSnapshots()
storageBackup.clearCache()
storageBackup.init()
try {
// initialize the new location
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {

View file

@ -45,8 +45,7 @@ internal class BackupStorageViewModel(
// remove old storage snapshots and clear cache
// TODO is this needed? It also does create all 255 chunk folders which takes time
// pass a flag to getCurrentBackupSnapshots() to not create missing folders?
storageBackup.deleteAllSnapshots()
storageBackup.clearCache()
storageBackup.init()
try {
// initialize the new location (if backups are enabled)
if (backupManager.isBackupEnabled) {

View file

@ -12,6 +12,7 @@ import org.junit.Test
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.assertThrows
@ -34,15 +35,17 @@ internal class WebDavStoragePluginTest : TransportTest() {
val token = System.currentTimeMillis()
val metadata = getRandomByteArray()
// need to initialize, to have root .SeedVaultAndroidBackup folder
plugin.initializeDevice()
plugin.startNewRestoreSet(token)
// initially, we don't have any backups
assertEquals(emptySet<EncryptedMetadata>(), plugin.getAvailableBackups()?.toSet())
// and no data
assertFalse(plugin.hasData(token, FILE_BACKUP_METADATA))
// start a new restore set, initialize it and write out the metadata file
plugin.startNewRestoreSet(token)
plugin.initializeDevice()
// write out the metadata file
plugin.getOutputStream(token, FILE_BACKUP_METADATA).use {
it.write(metadata)
}
@ -85,4 +88,18 @@ internal class WebDavStoragePluginTest : TransportTest() {
Unit
}
@Test
fun `test missing root dir`() = runBlocking {
val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig(), getRandomString())
assertNull(plugin.getAvailableBackups())
assertFalse(plugin.hasData(42L, "foo"))
assertThrows<IOException> {
plugin.removeData(42L, "foo")
}
Unit
}
}

View file

@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.storage
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig
import com.stevesoltys.seedvault.transport.backup.BackupTest
import io.mockk.mockk
@ -15,6 +16,8 @@ import org.calyxos.backup.storage.api.StoredSnapshot
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.jupiter.api.assertThrows
import java.io.IOException
internal class WebDavStoragePluginTest : BackupTest() {
@ -28,6 +31,9 @@ internal class WebDavStoragePluginTest : BackupTest() {
val chunkId1 = getRandomByteArray(32).toHexString()
val chunkBytes1 = getRandomByteArray()
// init to create root folder
plugin.init()
// first we don't have any chunks
assertEquals(emptyList<String>(), plugin.getAvailableChunkIds())
@ -55,6 +61,9 @@ internal class WebDavStoragePluginTest : BackupTest() {
fun `test snapshots`() = runBlocking {
val snapshotBytes = getRandomByteArray()
// init to create root folder
plugin.init()
// first we don't have any snapshots
assertEquals(emptyList<StoredSnapshot>(), plugin.getCurrentBackupSnapshots())
assertEquals(emptyList<StoredSnapshot>(), plugin.getBackupSnapshotsForRestore())
@ -98,6 +107,45 @@ internal class WebDavStoragePluginTest : BackupTest() {
}
}
@Test
fun `test missing root dir`() = runBlocking {
val plugin = WebDavStoragePlugin(
keyManager = keyManager,
androidId = "foo",
webDavConfig = WebDavTestConfig.getConfig(),
root = getRandomString(),
)
assertThrows<IOException> {
plugin.getCurrentBackupSnapshots()
}
assertThrows<IOException> {
plugin.getBackupSnapshotsForRestore()
}
assertThrows<IOException> {
plugin.getAvailableChunkIds()
}
assertThrows<IOException> {
plugin.deleteChunks(listOf("foo"))
}
assertThrows<IOException> {
plugin.deleteBackupSnapshot(snapshot)
}
assertThrows<IOException> {
plugin.getBackupSnapshotOutputStream(snapshot.timestamp).close()
}
assertThrows<IOException> {
plugin.getBackupSnapshotInputStream(snapshot).use { it.readAllBytes() }
}
assertThrows<IOException> {
plugin.getChunkOutputStream("foo").close()
}
assertThrows<IOException> {
plugin.getChunkInputStream(snapshot, "foo").use { it.readAllBytes() }
}
Unit
}
}
private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }

View file

@ -73,6 +73,7 @@ internal abstract class TransportTest {
mockkStatic(Log::class)
val logTagSlot = slot<String>()
val logMsgSlot = slot<String>()
val logExSlot = slot<Throwable>()
every { Log.v(any(), any()) } returns 0
every { Log.d(capture(logTagSlot), capture(logMsgSlot)) } answers {
println("${logTagSlot.captured} - ${logMsgSlot.captured}")
@ -83,7 +84,11 @@ internal abstract class TransportTest {
every { Log.w(any(), ofType(String::class)) } returns 0
every { Log.w(any(), ofType(String::class), any()) } returns 0
every { Log.e(any(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
every { Log.e(capture(logTagSlot), capture(logMsgSlot), capture(logExSlot)) } answers {
println("${logTagSlot.captured} - ${logMsgSlot.captured} ${logExSlot.captured}")
logExSlot.captured.printStackTrace()
0
}
}
}

View file

@ -32,11 +32,11 @@ import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
private val logEmptyState = """
Press the button below to simulate a backup. Your files won't be changed and not uploaded anywhere. This is just to test code for a future real backup.
Please come back to this app from time to time and run a backup again to see if it correctly identifies files that were added/changed.
Note that after updating this app, it might need to re-backup all files again.
Thanks for testing!
""".trimIndent()
private const val TAG = "MainViewModel"
@ -98,8 +98,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
fun setBackupLocation(uri: Uri?) {
if (uri != null) {
viewModelScope.launch(Dispatchers.IO) {
storageBackup.deleteAllSnapshots()
storageBackup.clearCache()
storageBackup.init()
}
}
settingsManager.setBackupLocation(uri)

View file

@ -104,6 +104,16 @@ public class StorageBackup(
list.joinToString(", ", limit = 5)
}
/**
* Ensures the storage is set-up to receive backups and deletes all snapshots
* (see [deleteAllSnapshots]) as well as clears local cache (see [clearCache]).
*/
public suspend fun init() {
plugin.init()
deleteAllSnapshots()
clearCache()
}
/**
* Run this on a new storage location to ensure that there are no old snapshots
* (potentially encrypted with an old key) laying around.

View file

@ -13,6 +13,13 @@ import javax.crypto.SecretKey
public interface StoragePlugin {
/**
* Prepares the storage location for storing backups.
* Call this before using the [StoragePlugin] for the first time.
*/
@Throws(IOException::class)
public suspend fun init()
/**
* Called before starting a backup run to ensure that all cached chunks are still available.
* Plugins should use this opportunity

View file

@ -75,6 +75,10 @@ public abstract class SafStoragePlugin(
return "$timestamp.SeedSnap"
}
override suspend fun init() {
// no-op as we are getting [root] created from super class
}
@Throws(IOException::class)
override suspend fun getAvailableChunkIds(): List<String> {
val folder = folder ?: return emptyList()