Read RestoreSets from encrypted backup metadata file
This commit is contained in:
parent
f9c8b657a0
commit
8b6656a350
10 changed files with 126 additions and 30 deletions
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
@ -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<RestoreSet>? {
|
||||
return plugin.getAvailableRestoreSets()
|
||||
.apply { Log.i(TAG, "Got available restore sets: $this") }
|
||||
val availableBackups = plugin.getAvailableBackups() ?: return null
|
||||
val restoreSets = ArrayList<RestoreSet>()
|
||||
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 {
|
||||
|
|
|
@ -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<RestoreSet>?
|
||||
fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>?
|
||||
|
||||
/**
|
||||
* Get the identifying token of the backup set currently being stored from this device.
|
||||
|
|
|
@ -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<RestoreSet>? {
|
||||
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
|
||||
val rootDir = storage.rootBackupDir ?: return null
|
||||
val restoreSets = ArrayList<RestoreSet>()
|
||||
val files = ArrayList<DocumentFile>()
|
||||
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 {
|
||||
|
|
|
@ -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<Crypto>()
|
||||
|
||||
private val encoder = MetadataWriterImpl(crypto)
|
||||
private val decoder = MetadataDecoderImpl()
|
||||
private val decoder = MetadataReaderImpl(crypto)
|
||||
|
||||
private val metadata = BackupMetadata(
|
||||
version = 1.toByte(),
|
|
@ -15,7 +15,7 @@ internal class MetadataWriterDecoderTest {
|
|||
private val crypto = mockk<Crypto>()
|
||||
|
||||
private val encoder = MetadataWriterImpl(crypto)
|
||||
private val decoder = MetadataDecoderImpl()
|
||||
private val decoder = MetadataReaderImpl(crypto)
|
||||
|
||||
private val metadata = BackupMetadata(
|
||||
version = Random.nextBytes(1)[0],
|
||||
|
|
|
@ -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<BackupPlugin>()
|
||||
private val kvBackupPlugin = mockk<KVBackupPlugin>()
|
||||
|
@ -48,7 +50,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
|
||||
private val fullRestorePlugin = mockk<FullRestorePlugin>()
|
||||
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<BackupDataInput>()
|
||||
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
|
||||
|
|
|
@ -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<RestorePlugin>()
|
||||
private val kv = mockk<KVRestore>()
|
||||
private val full = mockk<FullRestore>()
|
||||
private val metadataReader = mockk<MetadataReader>()
|
||||
|
||||
private val restore = RestoreCoordinator(plugin, kv, full)
|
||||
private val restore = RestoreCoordinator(plugin, kv, full, metadataReader)
|
||||
|
||||
private val token = Random.nextLong()
|
||||
private val inputStream = mockk<InputStream>()
|
||||
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
|
||||
|
|
Loading…
Reference in a new issue