diff --git a/app/src/main/java/com/stevesoltys/backup/metadata/Metadata.kt b/app/src/main/java/com/stevesoltys/backup/metadata/Metadata.kt index 47a2fb23..c4327984 100644 --- a/app/src/main/java/com/stevesoltys/backup/metadata/Metadata.kt +++ b/app/src/main/java/com/stevesoltys/backup/metadata/Metadata.kt @@ -4,6 +4,7 @@ import android.os.Build import android.os.Build.VERSION.SDK_INT import com.stevesoltys.backup.header.VERSION import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN +import java.io.InputStream data class BackupMetadata( internal val version: Byte = VERSION, @@ -18,3 +19,8 @@ internal const val JSON_ANDROID_VERSION = "androidVersion" internal const val JSON_DEVICE_NAME = "deviceName" class FormatException(cause: Throwable) : Exception(cause) + +class EncryptedBackupMetadata private constructor(val token: Long, val inputStream: InputStream?, val error: Boolean) { + constructor(token: Long, inputStream: InputStream) : this(token, inputStream, false) + constructor(token: Long, error: Boolean) : this(token, null, false) +} diff --git a/app/src/main/java/com/stevesoltys/backup/metadata/MetadataDecoder.kt b/app/src/main/java/com/stevesoltys/backup/metadata/MetadataReader.kt similarity index 52% rename from app/src/main/java/com/stevesoltys/backup/metadata/MetadataDecoder.kt rename to app/src/main/java/com/stevesoltys/backup/metadata/MetadataReader.kt index ae54c1c9..1db5d627 100644 --- a/app/src/main/java/com/stevesoltys/backup/metadata/MetadataDecoder.kt +++ b/app/src/main/java/com/stevesoltys/backup/metadata/MetadataReader.kt @@ -1,20 +1,36 @@ package com.stevesoltys.backup.metadata +import androidx.annotation.VisibleForTesting import com.stevesoltys.backup.Utf8 +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.header.UnsupportedVersionException +import com.stevesoltys.backup.header.VERSION import org.json.JSONException import org.json.JSONObject +import java.io.IOException +import java.io.InputStream -interface MetadataDecoder { +interface MetadataReader { - @Throws(FormatException::class, SecurityException::class) - fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata + @Throws(FormatException::class, SecurityException::class, UnsupportedVersionException::class, IOException::class) + fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata } -class MetadataDecoderImpl : MetadataDecoder { +class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader { + @Throws(FormatException::class, SecurityException::class, UnsupportedVersionException::class, IOException::class) + override fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata { + val version = inputStream.read().toByte() + if (version < 0) throw IOException() + if (version > VERSION) throw UnsupportedVersionException(version) + val metadataBytes = crypto.decryptSegment(inputStream) + return decode(metadataBytes, version, expectedToken) + } + + @VisibleForTesting @Throws(FormatException::class, SecurityException::class) - override fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata { + internal fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata { // NOTE: We don't do extensive validation of the parsed input here, // because it was encrypted with authentication, so we should be able to trust it. // @@ -28,7 +44,7 @@ class MetadataDecoderImpl : MetadataDecoder { } val token = json.getLong(JSON_TOKEN) if (token != expectedToken) { - throw SecurityException("Invalid token '$expectedVersion' in metadata, expected '$expectedToken'.") + throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.") } return BackupMetadata( version = version, diff --git a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt index 44b3df0e..314b7ed0 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt @@ -6,6 +6,7 @@ import com.stevesoltys.backup.crypto.CipherFactoryImpl import com.stevesoltys.backup.crypto.CryptoImpl import com.stevesoltys.backup.header.HeaderReaderImpl import com.stevesoltys.backup.header.HeaderWriterImpl +import com.stevesoltys.backup.metadata.MetadataReaderImpl import com.stevesoltys.backup.metadata.MetadataWriterImpl import com.stevesoltys.backup.settings.getBackupFolderUri import com.stevesoltys.backup.settings.getDeviceName @@ -32,6 +33,7 @@ class PluginManager(context: Context) { private val cipherFactory = CipherFactoryImpl(Backup.keyManager) private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) private val metadataWriter = MetadataWriterImpl(crypto) + private val metadataReader = MetadataReaderImpl(crypto) private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager) @@ -48,6 +50,6 @@ class PluginManager(context: Context) { private val kvRestore = KVRestore(restorePlugin.kvRestorePlugin, outputFactory, headerReader, crypto) private val fullRestore = FullRestore(restorePlugin.fullRestorePlugin, outputFactory, headerReader, crypto) - internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore) + internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore, metadataReader) } diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt index 7d118803..46e8ee0b 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt @@ -8,6 +8,10 @@ import android.app.backup.RestoreSet import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor import android.util.Log +import com.stevesoltys.backup.header.UnsupportedVersionException +import com.stevesoltys.backup.metadata.FormatException +import com.stevesoltys.backup.metadata.MetadataReader +import libcore.io.IoUtils.closeQuietly import java.io.IOException private class RestoreCoordinatorState( @@ -19,13 +23,45 @@ private val TAG = RestoreCoordinator::class.java.simpleName internal class RestoreCoordinator( private val plugin: RestorePlugin, private val kv: KVRestore, - private val full: FullRestore) { + private val full: FullRestore, + private val metadataReader: MetadataReader) { private var state: RestoreCoordinatorState? = null + /** + * Get the set of all backups currently available over this transport. + * + * @return Descriptions of the set of restore images available for this device, + * or null if an error occurred (the attempt should be rescheduled). + **/ fun getAvailableRestoreSets(): Array? { - return plugin.getAvailableRestoreSets() - .apply { Log.i(TAG, "Got available restore sets: $this") } + val availableBackups = plugin.getAvailableBackups() ?: return null + val restoreSets = ArrayList() + for (encryptedMetadata in availableBackups) { + if (encryptedMetadata.error) continue + check(encryptedMetadata.inputStream != null) // if there's no error, there must be a stream + try { + val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token) + val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token) + restoreSets.add(set) + } catch (e: IOException) { + Log.e(TAG, "Error while getting restore sets", e) + return null + } catch (e: FormatException) { + Log.e(TAG, "Error while getting restore sets", e) + return null + } catch (e: SecurityException) { + Log.e(TAG, "Error while getting restore sets", e) + return null + } catch (e: UnsupportedVersionException) { + Log.w(TAG, "Backup with unsupported version read", e) + continue + } finally { + closeQuietly(encryptedMetadata.inputStream) + } + } + Log.i(TAG, "Got available restore sets: $restoreSets") + return restoreSets.toTypedArray() } fun getCurrentRestoreSet(): Long { diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt index 17f4f0ad..125050ae 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt @@ -1,6 +1,6 @@ package com.stevesoltys.backup.transport.restore -import android.app.backup.RestoreSet +import com.stevesoltys.backup.metadata.EncryptedBackupMetadata interface RestorePlugin { @@ -11,10 +11,10 @@ interface RestorePlugin { /** * Get the set of all backups currently available for restore. * - * @return Descriptions of the set of restore images available for this device, + * @return metadata for the set of restore images available, * or null if an error occurred (the attempt should be rescheduled). **/ - fun getAvailableRestoreSets(): Array? + fun getAvailableBackups(): Sequence? /** * Get the identifying token of the backup set currently being stored from this device. diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt index 5fc63579..ef7ae0fb 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt @@ -1,11 +1,17 @@ package com.stevesoltys.backup.transport.restore.plugins -import android.app.backup.RestoreSet +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.backup.metadata.EncryptedBackupMetadata import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.backup.plugins.FILE_BACKUP_METADATA import com.stevesoltys.backup.transport.restore.FullRestorePlugin import com.stevesoltys.backup.transport.restore.KVRestorePlugin import com.stevesoltys.backup.transport.restore.RestorePlugin +import java.io.IOException + +private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : RestorePlugin { @@ -17,16 +23,28 @@ class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : Re DocumentsProviderFullRestorePlugin(storage) } - override fun getAvailableRestoreSets(): Array? { + override fun getAvailableBackups(): Sequence? { val rootDir = storage.rootBackupDir ?: return null - val restoreSets = ArrayList() + val files = ArrayList() for (file in rootDir.listFiles()) { - if (file.isDirectory && file.findFile(DEFAULT_RESTORE_SET_TOKEN.toString()) != null) { - // TODO include time of last backup - file.name?.let { restoreSets.add(RestoreSet(it, it, DEFAULT_RESTORE_SET_TOKEN)) } + file.isDirectory || continue + val set = file.findFile(DEFAULT_RESTORE_SET_TOKEN.toString()) ?: continue + val metadata = set.findFile(FILE_BACKUP_METADATA) ?: continue + files.add(metadata) + } + val iterator = files.iterator() + return generateSequence { + if (!iterator.hasNext()) return@generateSequence null // end sequence + val metadata = iterator.next() + val token = metadata.parentFile!!.name!!.toLong() + try { + val stream = storage.getInputStream(metadata) + EncryptedBackupMetadata(token, stream) + } catch (e: IOException) { + Log.e(TAG, "Error getting InputStream for backup metadata.", e) + EncryptedBackupMetadata(token, true) } } - return restoreSets.toTypedArray() } override fun getCurrentRestoreSet(): Long { diff --git a/app/src/test/java/com/stevesoltys/backup/metadata/MetadataDecoderTest.kt b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataReaderTest.kt similarity index 96% rename from app/src/test/java/com/stevesoltys/backup/metadata/MetadataDecoderTest.kt rename to app/src/test/java/com/stevesoltys/backup/metadata/MetadataReaderTest.kt index c25d29c3..44e05149 100644 --- a/app/src/test/java/com/stevesoltys/backup/metadata/MetadataDecoderTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataReaderTest.kt @@ -12,12 +12,12 @@ import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS import kotlin.random.Random @TestInstance(PER_CLASS) -class MetadataDecoderTest { +class MetadataReaderTest { private val crypto = mockk() private val encoder = MetadataWriterImpl(crypto) - private val decoder = MetadataDecoderImpl() + private val decoder = MetadataReaderImpl(crypto) private val metadata = BackupMetadata( version = 1.toByte(), diff --git a/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt index 1e139f32..e2f315bb 100644 --- a/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt @@ -15,7 +15,7 @@ internal class MetadataWriterDecoderTest { private val crypto = mockk() private val encoder = MetadataWriterImpl(crypto) - private val decoder = MetadataDecoderImpl() + private val decoder = MetadataReaderImpl(crypto) private val metadata = BackupMetadata( version = Random.nextBytes(1)[0], diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt index 8dd97c3e..bdbf3e1b 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt @@ -14,6 +14,7 @@ import com.stevesoltys.backup.crypto.KeyManagerTestImpl import com.stevesoltys.backup.encodeBase64 import com.stevesoltys.backup.header.HeaderReaderImpl import com.stevesoltys.backup.header.HeaderWriterImpl +import com.stevesoltys.backup.metadata.MetadataReaderImpl import com.stevesoltys.backup.metadata.MetadataWriterImpl import com.stevesoltys.backup.transport.backup.* import com.stevesoltys.backup.transport.restore.* @@ -34,6 +35,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val headerReader = HeaderReaderImpl() private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader) private val metadataWriter = MetadataWriterImpl(cryptoImpl) + private val metadataReader = MetadataReaderImpl(cryptoImpl) private val backupPlugin = mockk() private val kvBackupPlugin = mockk() @@ -48,7 +50,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl) private val fullRestorePlugin = mockk() private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl) - private val restore = RestoreCoordinator(restorePlugin, kvRestore, fullRestore) + private val restore = RestoreCoordinator(restorePlugin, kvRestore, fullRestore, metadataReader) private val backupDataInput = mockk() private val fileDescriptor = mockk(relaxed = true) diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt index 10aa6cb0..fb8fede5 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt @@ -3,9 +3,12 @@ package com.stevesoltys.backup.transport.restore import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.RestoreDescription import android.app.backup.RestoreDescription.* -import android.app.backup.RestoreSet import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor +import com.stevesoltys.backup.getRandomString +import com.stevesoltys.backup.metadata.BackupMetadata +import com.stevesoltys.backup.metadata.EncryptedBackupMetadata +import com.stevesoltys.backup.metadata.MetadataReader import com.stevesoltys.backup.transport.TransportTest import io.mockk.Runs import io.mockk.every @@ -14,6 +17,7 @@ import io.mockk.mockk import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import java.io.IOException +import java.io.InputStream import kotlin.random.Random internal class RestoreCoordinatorTest : TransportTest() { @@ -21,21 +25,33 @@ internal class RestoreCoordinatorTest : TransportTest() { private val plugin = mockk() private val kv = mockk() private val full = mockk() + private val metadataReader = mockk() - private val restore = RestoreCoordinator(plugin, kv, full) + private val restore = RestoreCoordinator(plugin, kv, full, metadataReader) private val token = Random.nextLong() + private val inputStream = mockk() private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" } private val packageInfoArray = arrayOf(packageInfo) private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2) @Test - fun `getAvailableRestoreSets() delegates to plugin`() { - val restoreSets = Array(1) { RestoreSet() } + fun `getAvailableRestoreSets() builds set from plugin response`() { + val encryptedMetadata = EncryptedBackupMetadata(token, inputStream) + val metadata = BackupMetadata( + token = token, + androidVersion = Random.nextInt(), + deviceName = getRandomString()) - every { plugin.getAvailableRestoreSets() } returns restoreSets + every { plugin.getAvailableBackups() } returns sequenceOf(encryptedMetadata, encryptedMetadata) + every { metadataReader.readMetadata(inputStream, token) } returns metadata + every { inputStream.close() } just Runs - assertEquals(restoreSets, restore.getAvailableRestoreSets()) + val sets = restore.getAvailableRestoreSets() ?: fail() + assertEquals(2, sets.size) + assertEquals(metadata.deviceName, sets[0].device) + assertEquals(metadata.deviceName, sets[0].name) + assertEquals(metadata.token, sets[0].token) } @Test