From 569e3db38572409472cfa2230e5325126748d0b7 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 23 Dec 2019 09:04:10 -0300 Subject: [PATCH] Fix device initialization and generation of new backup tokens --- .../seedvault/metadata/Metadata.kt | 8 +- .../seedvault/metadata/MetadataManager.kt | 33 ++--- .../seedvault/metadata/MetadataReader.kt | 8 +- .../seedvault/metadata/MetadataWriter.kt | 3 +- .../saf/DocumentsProviderBackupPlugin.kt | 10 +- .../seedvault/plugins/saf/DocumentsStorage.kt | 124 ++++++++++++------ .../seedvault/settings/SettingsManager.kt | 8 ++ .../seedvault/transport/backup/ApkBackup.kt | 75 +++++------ .../transport/backup/BackupCoordinator.kt | 13 +- .../transport/backup/BackupModule.kt | 2 +- .../transport/backup/BackupPlugin.kt | 5 +- .../seedvault/ui/storage/StorageViewModel.kt | 4 - .../seedvault/metadata/MetadataManagerTest.kt | 65 ++++++--- .../seedvault/metadata/MetadataReaderTest.kt | 8 +- .../metadata/MetadataWriterDecoderTest.kt | 5 +- .../transport/CoordinatorIntegrationTest.kt | 2 +- .../transport/backup/ApkBackupTest.kt | 18 ++- .../transport/backup/BackupCoordinatorTest.kt | 24 +++- 18 files changed, 266 insertions(+), 149 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt index 4d5c0b32..cd9ed1a1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt @@ -4,6 +4,8 @@ import android.os.Build import com.stevesoltys.seedvault.header.VERSION import java.io.InputStream +typealias PackageMetadataMap = HashMap + data class BackupMetadata( internal val version: Byte = VERSION, internal val token: Long, @@ -11,7 +13,7 @@ data class BackupMetadata( internal val androidVersion: Int = Build.VERSION.SDK_INT, internal val androidIncremental: String = Build.VERSION.INCREMENTAL, internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}", - internal val packageMetadata: HashMap = HashMap() + internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap() ) internal const val JSON_METADATA = "@meta@" @@ -26,16 +28,18 @@ data class PackageMetadata( internal var time: Long, internal val version: Long? = null, internal val installer: String? = null, + internal val sha256: String? = null, internal val signatures: List? = null ) { fun hasApk(): Boolean { - return version != null && signatures != null + return version != null && sha256 != null && signatures != null } } internal const val JSON_PACKAGE_TIME = "time" internal const val JSON_PACKAGE_VERSION = "version" internal const val JSON_PACKAGE_INSTALLER = "installer" +internal const val JSON_PACKAGE_SHA256 = "sha256" internal const val JSON_PACKAGE_SIGNATURES = "signatures" internal class DecryptionFailedException(cause: Throwable) : Exception(cause) diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt index 79c9b0eb..1a40d417 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt @@ -28,10 +28,8 @@ class MetadataManager( field = try { getMetadataFromCache() ?: throw IOException() } catch (e: IOException) { - // create new default metadata - // Attention: If this happens due to a read error, we will overwrite remote metadata - Log.w(TAG, "Creating new metadata...") - BackupMetadata(token = clock.time()) + // If this happens, it is hard to recover from this. Let's hope it never does. + throw AssertionError("Error reading metadata from cache", e) } } return field @@ -40,14 +38,13 @@ class MetadataManager( /** * Call this when initializing a new device. * - * A new backup token will be generated. - * Existing [BackupMetadata] will be cleared + * Existing [BackupMetadata] will be cleared, use the given new token, * and written encrypted to the given [OutputStream] as well as the internal cache. */ @Synchronized @Throws(IOException::class) - fun onDeviceInitialization(metadataOutputStream: OutputStream) { - metadata = BackupMetadata(token = clock.time()) + fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) { + metadata = BackupMetadata(token = token) metadataWriter.write(metadata, metadataOutputStream) writeMetadataToCache() } @@ -59,7 +56,7 @@ class MetadataManager( */ @Synchronized fun onApkBackedUp(packageName: String, packageMetadata: PackageMetadata) { - metadata.packageMetadata[packageName]?.let { + metadata.packageMetadataMap[packageName]?.let { check(it.time <= packageMetadata.time) { "APK backup set time of $packageName backwards" } @@ -70,7 +67,7 @@ class MetadataManager( "APK backup backed up the same or a smaller version: was ${it.version} is ${packageMetadata.version}" } } - metadata.packageMetadata[packageName] = packageMetadata + metadata.packageMetadataMap[packageName] = packageMetadata } /** @@ -85,22 +82,28 @@ class MetadataManager( val oldMetadata = metadata.copy() val now = clock.time() metadata.time = now - if (metadata.packageMetadata.containsKey(packageName)) { - metadata.packageMetadata[packageName]?.time = now + if (metadata.packageMetadataMap.containsKey(packageName)) { + metadata.packageMetadataMap[packageName]?.time = now } else { - metadata.packageMetadata[packageName] = PackageMetadata(time = now) + metadata.packageMetadataMap[packageName] = PackageMetadata(time = now) } try { metadataWriter.write(metadata, metadataOutputStream) } catch (e: IOException) { Log.w(TAG, "Error writing metadata to storage", e) // revert metadata and do not write it to cache + // TODO also revert changes made by last [onApkBackedUp] metadata = oldMetadata throw IOException(e) } writeMetadataToCache() } + /** + * Returns the current backup token. + * + * If the token is 0L, it is not yet initialized and must not be used for anything. + */ @Synchronized fun getBackupToken(): Long = metadata.token @@ -114,7 +117,7 @@ class MetadataManager( @Synchronized fun getPackageMetadata(packageName: String): PackageMetadata? { - return metadata.packageMetadata[packageName]?.copy() + return metadata.packageMetadataMap[packageName]?.copy() } @Synchronized @@ -129,7 +132,7 @@ class MetadataManager( return null } catch (e: FileNotFoundException) { Log.d(TAG, "Cached metadata not found, creating...") - return null + return uninitializedMetadata } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt index 29b6472a..a79901a1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt @@ -55,12 +55,13 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader { throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.") } // get package metadata - val packageMetadata: HashMap = HashMap() + val packageMetadataMap = PackageMetadataMap() for (packageName in json.keys()) { if (packageName == JSON_METADATA) continue val p = json.getJSONObject(packageName) val pVersion = p.optLong(JSON_PACKAGE_VERSION, 0L) val pInstaller = p.optString(JSON_PACKAGE_INSTALLER, "") + val pSha256 = p.optString(JSON_PACKAGE_SHA256) val pSignatures = p.optJSONArray(JSON_PACKAGE_SIGNATURES) val signatures = if (pSignatures == null) null else ArrayList(pSignatures.length()).apply { @@ -68,10 +69,11 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader { add(pSignatures.getString(i)) } } - packageMetadata[packageName] = PackageMetadata( + packageMetadataMap[packageName] = PackageMetadata( time = p.getLong(JSON_PACKAGE_TIME), version = if (pVersion == 0L) null else pVersion, installer = if (pInstaller == "") null else pInstaller, + sha256 = if (pSha256 == "") null else pSha256, signatures = signatures ) } @@ -82,7 +84,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader { androidVersion = meta.getInt(JSON_METADATA_SDK_INT), androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL), deviceName = meta.getString(JSON_METADATA_NAME), - packageMetadata = packageMetadata + packageMetadataMap = packageMetadataMap ) } catch (e: JSONException) { throw SecurityException(e) diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt index 0080efff..643b534a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt @@ -33,11 +33,12 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter { put(JSON_METADATA_NAME, metadata.deviceName) }) } - for ((packageName, packageMetadata) in metadata.packageMetadata) { + for ((packageName, packageMetadata) in metadata.packageMetadataMap) { json.put(packageName, JSONObject().apply { put(JSON_PACKAGE_TIME, packageMetadata.time) packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) } packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) } + packageMetadata.sha256?.let { put(JSON_PACKAGE_SHA256, it) } packageMetadata.signatures?.let { put(JSON_PACKAGE_SIGNATURES, JSONArray(it)) } }) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt index e26ce164..89e4c74e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt @@ -23,7 +23,13 @@ internal class DocumentsProviderBackupPlugin( } @Throws(IOException::class) - override fun initializeDevice() { + override fun initializeDevice(newToken: Long): Boolean { + // check if storage is already initialized + if (storage.isInitialized()) return false + + // reset current storage + storage.reset(newToken) + // get or create root backup dir storage.rootBackupDir ?: throw IOException() @@ -35,6 +41,8 @@ internal class DocumentsProviderBackupPlugin( storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete() kvDir?.deleteContents() fullDir?.deleteContents() + + return true } @Throws(IOException::class) diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt index 253a1ade..28c83435 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt @@ -30,53 +30,91 @@ private val TAG = DocumentsStorage::class.java.simpleName internal class DocumentsStorage( private val context: Context, private val metadataManager: MetadataManager, - settingsManager: SettingsManager) { + private val settingsManager: SettingsManager) { - private val storage: Storage? = settingsManager.getStorage() - - internal val rootBackupDir: DocumentFile? by lazy { - val parent = storage?.getDocumentFile(context) ?: return@lazy null - try { - val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT) - // create .nomedia file to prevent Android's MediaScanner from trying to index the backup - rootDir.createOrGetFile(FILE_NO_MEDIA) - rootDir - } catch (e: IOException) { - Log.e(TAG, "Error creating root backup dir.", e) - null + internal var storage: Storage? = null + get() { + if (field == null) field = settingsManager.getStorage() + return field } + + internal var rootBackupDir: DocumentFile? = null + get() { + if (field == null) { + val parent = storage?.getDocumentFile(context) ?: return null + field = try { + val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT) + // create .nomedia file to prevent Android's MediaScanner from trying to index the backup + rootDir.createOrGetFile(FILE_NO_MEDIA) + rootDir + } catch (e: IOException) { + Log.e(TAG, "Error creating root backup dir.", e) + null + } + } + return field + } + + private var currentToken: Long = 0L + get() { + if (field == 0L) field = metadataManager.getBackupToken() + return field + } + + private var currentSetDir: DocumentFile? = null + get() { + if (field == null) { + if (currentToken == 0L) return null + field = try { + rootBackupDir?.createOrGetDirectory(currentToken.toString()) + } catch (e: IOException) { + Log.e(TAG, "Error creating current restore set dir.", e) + null + } + } + return field + } + + var currentFullBackupDir: DocumentFile? = null + get() { + if (field == null) { + field = try { + currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP) + } catch (e: IOException) { + Log.e(TAG, "Error creating full backup dir.", e) + null + } + } + return field + } + + var currentKvBackupDir: DocumentFile? = null + get() { + if (field == null) { + field = try { + currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP) + } catch (e: IOException) { + Log.e(TAG, "Error creating K/V backup dir.", e) + null + } + } + return field + } + + fun isInitialized(): Boolean { + if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed + val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false + val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false + return kvEmpty && fullEmpty } - private val currentToken: Long by lazy { - metadataManager.getBackupToken() - } - - private val currentSetDir: DocumentFile? by lazy { - val currentSetName = currentToken.toString() - try { - rootBackupDir?.createOrGetDirectory(currentSetName) - } catch (e: IOException) { - Log.e(TAG, "Error creating current restore set dir.", e) - null - } - } - - val currentFullBackupDir: DocumentFile? by lazy { - try { - currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP) - } catch (e: IOException) { - Log.e(TAG, "Error creating full backup dir.", e) - null - } - } - - val currentKvBackupDir: DocumentFile? by lazy { - try { - currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP) - } catch (e: IOException) { - Log.e(TAG, "Error creating K/V backup dir.", e) - null - } + fun reset(newToken: Long) { + storage = null + currentToken = newToken + rootBackupDir = null + currentSetDir = null + currentKvBackupDir = null + currentFullBackupDir = null } fun getAuthority(): String? = storage?.uri?.authority diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt index 805cd915..fc43c987 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -5,6 +5,7 @@ import android.hardware.usb.UsbDevice import android.net.Uri import androidx.documentfile.provider.DocumentFile import androidx.preference.PreferenceManager +import java.util.concurrent.atomic.AtomicBoolean private const val PREF_KEY_STORAGE_URI = "storageUri" private const val PREF_KEY_STORAGE_NAME = "storageName" @@ -19,6 +20,8 @@ class SettingsManager(context: Context) { private val prefs = PreferenceManager.getDefaultSharedPreferences(context) + private var isStorageChanging: AtomicBoolean = AtomicBoolean(false) + // FIXME Storage is currently plugin specific and not generic fun setStorage(storage: Storage) { prefs.edit() @@ -26,6 +29,7 @@ class SettingsManager(context: Context) { .putString(PREF_KEY_STORAGE_NAME, storage.name) .putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb) .apply() + isStorageChanging.set(true) } fun getStorage(): Storage? { @@ -36,6 +40,10 @@ class SettingsManager(context: Context) { return Storage(uri, name, isUsb) } + fun getAndResetIsStorageChanging(): Boolean { + return isStorageChanging.getAndSet(false) + } + fun setFlashDrive(usb: FlashDrive?) { if (usb == null) { prefs.edit() diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt index 151e2c20..e7a8372c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt @@ -7,6 +7,7 @@ import android.content.pm.PackageManager import android.content.pm.Signature import android.content.pm.SigningInfo import android.util.Log +import android.util.PackageUtils.computeSha256DigestBytes import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.encodeBase64 @@ -18,7 +19,6 @@ import java.io.FileNotFoundException import java.io.IOException import java.io.OutputStream import java.security.MessageDigest -import java.security.NoSuchAlgorithmException private val TAG = ApkBackup::class.java.simpleName @@ -45,23 +45,23 @@ class ApkBackup( return false } - // get cached metadata about package - val packageMetadata = metadataManager.getPackageMetadata(packageName) - ?: PackageMetadata(time = clock.time()) - - // TODO remove when adding support in [signaturesChanged] + // TODO remove when adding support for packages with multiple signers if (packageInfo.signingInfo.hasMultipleSigners()) { Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.") return false } // get signatures - val signatures = getSignatures(packageInfo.signingInfo) + val signatures = packageInfo.signingInfo.getSignatures() if (signatures.isEmpty()) { Log.e(TAG, "Package $packageName has no signatures. Not backing it up.") return false } + // get cached metadata about package + val packageMetadata = metadataManager.getPackageMetadata(packageName) + ?: PackageMetadata(time = clock.time()) + // get version codes val version = packageInfo.longVersionCode val backedUpVersion = packageMetadata.version ?: 0L // no version will cause backup @@ -84,12 +84,20 @@ class ApkBackup( throw IOException(e) } - // copy the APK to the storage's output + // copy the APK to the storage's output and calculate SHA-256 hash while at it + val messageDigest = MessageDigest.getInstance("SHA-256") streamGetter.invoke().use { outputStream -> inputStream.use { inputStream -> - inputStream.copyTo(outputStream) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + outputStream.write(buffer, 0, bytes) + messageDigest.update(buffer, 0, bytes) + bytes = inputStream.read(buffer) + } } } + val sha256 = messageDigest.digest().encodeBase64() Log.d(TAG, "Backed up new APK of $packageName with version $version.") // update the metadata @@ -98,44 +106,37 @@ class ApkBackup( time = clock.time(), version = version, installer = installer, + sha256 = sha256, signatures = signatures ) metadataManager.onApkBackedUp(packageName, updatedMetadata) return true } - private fun getSignatures(signingInfo: SigningInfo): List { - val signatures = ArrayList() - if (signingInfo.hasMultipleSigners()) { - for (sig in signingInfo.apkContentsSigners) { - signatures.add(hashSignature(sig).encodeBase64()) - } - } else { - for (sig in signingInfo.signingCertificateHistory) { - signatures.add(hashSignature(sig).encodeBase64()) - } - } - return signatures - } - - private fun hashSignature(signature: Signature): ByteArray { - try { - val digest = MessageDigest.getInstance("SHA-256") - digest.update(signature.toByteArray()) - return digest.digest() - } catch (e: NoSuchAlgorithmException) { - Log.e(TAG, "No SHA-256 algorithm found!", e) - throw AssertionError(e) - } - } - private fun signaturesChanged(packageMetadata: PackageMetadata, signatures: List): Boolean { // no signatures in package metadata counts as them not having changed if (packageMetadata.signatures == null) return false - // TODO this is probably more complicated, need to verify - // 1. multiple signers: need to match all signatures in list - // 2. single signer (with or without history): the intersection of both lists must not be empty. + // TODO to support multiple signers check if lists differ return packageMetadata.signatures.intersect(signatures).isEmpty() } } + +/** + * Returns a list of Base64 encoded SHA-256 signature hashes. + */ +fun SigningInfo.getSignatures(): List { + return if (hasMultipleSigners()) { + apkContentsSigners.map { signature -> + hashSignature(signature).encodeBase64() + } + } else { + signingCertificateHistory.map { signature -> + hashSignature(signature).encodeBase64() + } + } +} + +private fun hashSignature(signature: Signature): ByteArray { + return computeSha256DigestBytes(signature.toByteArray()) ?: throw AssertionError() +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index d04664c8..7200ac77 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -6,6 +6,7 @@ import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor import android.util.Log import com.stevesoltys.seedvault.BackupNotificationManager +import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.settings.SettingsManager @@ -24,6 +25,7 @@ internal class BackupCoordinator( private val kv: KVBackup, private val full: FullBackup, private val apkBackup: ApkBackup, + private val clock: Clock, private val metadataManager: MetadataManager, private val settingsManager: SettingsManager, private val nm: BackupNotificationManager) { @@ -48,7 +50,7 @@ internal class BackupCoordinator( * for example, if there is no current live data-set at all, * or there is no authenticated account under which to store the data remotely - * the transport should return [TRANSPORT_OK] here - * and treat the initializeDevice() / finishBackup() pair as a graceful no-op. + * and treat the [initializeDevice] / [finishBackup] pair as a graceful no-op. * * @return One of [TRANSPORT_OK] (OK so far) or * [TRANSPORT_ERROR] (to retry following network error or other failure). @@ -56,8 +58,13 @@ internal class BackupCoordinator( fun initializeDevice(): Int { Log.i(TAG, "Initialize Device!") return try { - plugin.initializeDevice() - metadataManager.onDeviceInitialization(plugin.getMetadataOutputStream()) + val token = clock.time() + if (plugin.initializeDevice(token)) { + Log.d(TAG, "Resetting backup metadata...") + metadataManager.onDeviceInitialization(token, plugin.getMetadataOutputStream()) + } else { + Log.d(TAG, "Storage was already initialized, doing no-op") + } // [finishBackup] will only be called when we return [TRANSPORT_OK] here // so we remember that we initialized successfully calledInitialize = true diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index abda907d..8775e0fe 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -8,5 +8,5 @@ val backupModule = module { single { ApkBackup(androidContext().packageManager, get(), get(), get()) } single { KVBackup(get().kvBackupPlugin, get(), get(), get()) } single { FullBackup(get().fullBackupPlugin, get(), get(), get()) } - single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get()) } + single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt index 42d6c8ca..9b01d0b9 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt @@ -12,9 +12,12 @@ interface BackupPlugin { /** * Initialize the storage for this device, erasing all stored data. + * + * @return true if the device needs initialization or + * false if the device was initialized already and initialization should be a no-op. */ @Throws(IOException::class) - fun initializeDevice() + fun initializeDevice(newToken: Long): Boolean /** * Returns an [OutputStream] for writing backup metadata. diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt index 85706425..6a99ff62 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt @@ -18,7 +18,6 @@ import com.stevesoltys.seedvault.settings.BackupManagerSettings import com.stevesoltys.seedvault.settings.FlashDrive import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.Storage -import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent @@ -107,9 +106,6 @@ internal abstract class StorageViewModel( BackupManagerSettings.enableAutomaticBackups(app.contentResolver) } - // stop backup service to be sure the old location will get updated - app.stopService(Intent(app, ConfigurableBackupTransportService::class.java)) - Log.d(TAG, "New storage location saved: $uri") return storage.isUsb diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt index b13a9e4a..193448a9 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt @@ -30,8 +30,9 @@ class MetadataManagerTest { private val manager = MetadataManager(context, clock, metadataWriter, metadataReader) private val time = 42L + private val token = Random.nextLong() private val packageName = getRandomString() - private val initialMetadata = BackupMetadata(token = time) + private val initialMetadata = BackupMetadata(token = token) private val storageOutputStream = ByteArrayOutputStream() private val cacheOutputStream: FileOutputStream = mockk() private val cacheInputStream: FileInputStream = mockk() @@ -48,9 +49,9 @@ class MetadataManagerTest { every { metadataWriter.write(initialMetadata, storageOutputStream) } just Runs expectWriteToCache(initialMetadata) - manager.onDeviceInitialization(storageOutputStream) + manager.onDeviceInitialization(token, storageOutputStream) - assertEquals(time, manager.getBackupToken()) + assertEquals(token, manager.getBackupToken()) assertEquals(0L, manager.getLastBackupTime()) } @@ -63,8 +64,7 @@ class MetadataManagerTest { signatures = listOf("sig") ) - every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException() - every { clock.time() } returns time + expectReadFromCache() manager.onApkBackedUp(packageName, packageMetadata) @@ -73,14 +73,13 @@ class MetadataManagerTest { @Test fun `test onApkBackedUp() with existing package metadata`() { - val cachedMetadata = initialMetadata.copy() val packageMetadata = PackageMetadata( time = time, version = Random.nextLong(Long.MAX_VALUE), installer = getRandomString(), signatures = listOf("sig") ) - cachedMetadata.packageMetadata[packageName] = packageMetadata + initialMetadata.packageMetadataMap[packageName] = packageMetadata val updatedPackageMetadata = PackageMetadata( time = time + 1, version = packageMetadata.version!! + 1, @@ -88,7 +87,7 @@ class MetadataManagerTest { signatures = listOf("sig foo") ) - expectReadFromCache(cachedMetadata) + expectReadFromCache() manager.onApkBackedUp(packageName, updatedPackageMetadata) @@ -99,9 +98,9 @@ class MetadataManagerTest { fun `test onPackageBackedUp()`() { val updatedMetadata = initialMetadata.copy() updatedMetadata.time = time - updatedMetadata.packageMetadata[packageName] = PackageMetadata(time) + updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(time) - every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException() + expectReadFromCache() every { clock.time() } returns time every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs expectWriteToCache(updatedMetadata) @@ -116,10 +115,10 @@ class MetadataManagerTest { val updateTime = time + 1 val updatedMetadata = initialMetadata.copy() updatedMetadata.time = updateTime - updatedMetadata.packageMetadata[packageName] = PackageMetadata(updateTime) + updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(updateTime) - every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException() - every { clock.time() } returns time andThen updateTime + expectReadFromCache() + every { clock.time() } returns updateTime every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException() try { @@ -130,7 +129,7 @@ class MetadataManagerTest { } assertEquals(0L, manager.getLastBackupTime()) // time was reverted - assertEquals(initialMetadata.packageMetadata[packageName], manager.getPackageMetadata(packageName)) + assertEquals(initialMetadata.packageMetadataMap[packageName], manager.getPackageMetadata(packageName)) } @Test @@ -139,14 +138,14 @@ class MetadataManagerTest { val cacheTime = time - 1 val cachedMetadata = initialMetadata.copy(time = cacheTime) - cachedMetadata.packageMetadata[cachedPackageName] = PackageMetadata(cacheTime) - cachedMetadata.packageMetadata[packageName] = PackageMetadata(cacheTime) + cachedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(cacheTime) + cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(cacheTime) val updatedMetadata = cachedMetadata.copy(time = time) - cachedMetadata.packageMetadata[cachedPackageName] = PackageMetadata(time) - cachedMetadata.packageMetadata[packageName] = PackageMetadata(time) + cachedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(time) + cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(time) - expectReadFromCache(cachedMetadata) + expectReadFromCache() every { clock.time() } returns time every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs expectWriteToCache(updatedMetadata) @@ -158,18 +157,42 @@ class MetadataManagerTest { assertEquals(PackageMetadata(time), manager.getPackageMetadata(packageName)) } + @Test + fun `test getBackupToken() on first run`() { + every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException() + + assertEquals(0L, manager.getBackupToken()) + } + + @Test + fun `test getLastBackupTime() on first run`() { + every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException() + + assertEquals(0L, manager.getLastBackupTime()) + } + + @Test + fun `test getLastBackupTime() and getBackupToken() with cached metadata`() { + initialMetadata.time = Random.nextLong() + + expectReadFromCache() + + assertEquals(initialMetadata.time, manager.getLastBackupTime()) + assertEquals(initialMetadata.token, manager.getBackupToken()) + } + private fun expectWriteToCache(metadata: BackupMetadata) { every { metadataWriter.encode(metadata) } returns encodedMetadata every { context.openFileOutput(METADATA_CACHE_FILE, MODE_PRIVATE) } returns cacheOutputStream every { cacheOutputStream.write(encodedMetadata) } just Runs } - private fun expectReadFromCache(metadata: BackupMetadata) { + private fun expectReadFromCache() { val byteArray = ByteArray(DEFAULT_BUFFER_SIZE) every { context.openFileInput(METADATA_CACHE_FILE) } returns cacheInputStream every { cacheInputStream.available() } returns byteArray.size andThen 0 every { cacheInputStream.read(byteArray) } returns -1 - every { metadataReader.decode(ByteArray(0)) } returns metadata + every { metadataReader.decode(ByteArray(0)) } returns initialMetadata } } diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt index 8552c26e..5263440e 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt @@ -84,6 +84,7 @@ class MetadataReaderTest { time = Random.nextLong(), version = Random.nextLong(), installer = getRandomString(), + sha256 = getRandomString(), signatures = listOf(getRandomString(), getRandomString()) )) } @@ -98,6 +99,7 @@ class MetadataReaderTest { json.put("org.example", JSONObject().apply { put(JSON_PACKAGE_VERSION, Random.nextLong()) put(JSON_PACKAGE_INSTALLER, getRandomString()) + put(JSON_PACKAGE_SHA256, getRandomString()) put(JSON_PACKAGE_SIGNATURES, JSONArray(listOf(getRandomString(), getRandomString()))) }) val jsonBytes = json.toString().toByteArray(Utf8) @@ -115,8 +117,8 @@ class MetadataReaderTest { val jsonBytes = json.toString().toByteArray(Utf8) val result = decoder.decode(jsonBytes, metadata.version, metadata.token) - assertEquals(1, result.packageMetadata.size) - val packageMetadata = result.packageMetadata.getOrElse("org.example") { fail() } + assertEquals(1, result.packageMetadataMap.size) + val packageMetadata = result.packageMetadataMap.getOrElse("org.example") { fail() } assertNull(packageMetadata.version) assertNull(packageMetadata.installer) assertNull(packageMetadata.signatures) @@ -130,7 +132,7 @@ class MetadataReaderTest { androidVersion = Random.nextInt(), androidIncremental = getRandomString(), deviceName = getRandomString(), - packageMetadata = packageMetadata + packageMetadataMap = packageMetadata ) } diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt index 6d5b7e87..235a26c9 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt @@ -40,6 +40,7 @@ internal class MetadataWriterDecoderTest { time = Random.nextLong(), version = Random.nextLong(), installer = getRandomString(), + sha256 = getRandomString(), signatures = listOf(getRandomString(), getRandomString()))) } val metadata = getMetadata(packages) @@ -53,12 +54,14 @@ internal class MetadataWriterDecoderTest { time = Random.nextLong(), version = Random.nextLong(), installer = getRandomString(), + sha256 = getRandomString(), signatures = listOf(getRandomString()) )) put(getRandomString(), PackageMetadata( time = Random.nextLong(), version = Random.nextLong(), installer = getRandomString(), + sha256 = getRandomString(), signatures = listOf(getRandomString(), getRandomString()) )) } @@ -74,7 +77,7 @@ internal class MetadataWriterDecoderTest { androidVersion = Random.nextInt(), androidIncremental = getRandomString(), deviceName = getRandomString(), - packageMetadata = packageMetadata + packageMetadataMap = packageMetadata ) } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt index f457491f..e7e7c947 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -44,7 +44,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl) private val apkBackup = mockk() private val notificationManager = mockk() - private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, apkBackup, metadataManager, settingsManager, notificationManager) + private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, apkBackup, clock, metadataManager, settingsManager, notificationManager) private val restorePlugin = mockk() private val kvRestorePlugin = mockk() diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt index c83a839a..296a3128 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt @@ -5,14 +5,11 @@ import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.Signature +import android.util.PackageUtils import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER -import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.PackageMetadata -import io.mockk.Runs -import io.mockk.every -import io.mockk.just -import io.mockk.mockk +import io.mockk.* import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -32,13 +29,18 @@ internal class ApkBackupTest : BackupTest() { private val apkBackup = ApkBackup(pm, clock, settingsManager, metadataManager) private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03) + private val signatureHash = byteArrayOf(0x03, 0x02, 0x01) private val sigs = arrayOf(Signature(signatureBytes)) private val packageMetadata = PackageMetadata( time = Random.nextLong(), version = packageInfo.longVersionCode - 1, - signatures = listOf("A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E") + signatures = listOf("AwIB") ) + init { + mockkStatic(PackageUtils::class) + } + @Test fun `does not back up @pm@`() { val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } @@ -96,7 +98,7 @@ internal class ApkBackupTest : BackupTest() { @Test fun `test successful APK backup`(@TempDir tmpDir: Path) { - val apkBytes = getRandomByteArray() + val apkBytes = byteArrayOf(0x04, 0x05, 0x06) val tmpFile = File(tmpDir.toAbsolutePath().toString()) packageInfo.applicationInfo.sourceDir = File(tmpFile, "test.apk").apply { assertTrue(createNewFile()) @@ -107,6 +109,7 @@ internal class ApkBackupTest : BackupTest() { time = Random.nextLong(), version = packageInfo.longVersionCode, installer = getRandomString(), + sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI", signatures = packageMetadata.signatures ) @@ -123,6 +126,7 @@ internal class ApkBackupTest : BackupTest() { private fun expectChecks(packageMetadata: PackageMetadata = this.packageMetadata) { every { settingsManager.backupApks() } returns true every { metadataManager.getPackageMetadata(packageInfo.packageName) } returns packageMetadata + every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash every { sigInfo.hasMultipleSigners() } returns false every { sigInfo.signingCertificateHistory } returns sigs } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index c3a3c306..a5c0c8fe 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -26,15 +26,27 @@ internal class BackupCoordinatorTest: BackupTest() { private val apkBackup = mockk() private val notificationManager = mockk() - private val backup = BackupCoordinator(context, plugin, kv, full, apkBackup, metadataManager, settingsManager, notificationManager) + private val backup = BackupCoordinator(context, plugin, kv, full, apkBackup, clock, metadataManager, settingsManager, notificationManager) private val metadataOutputStream = mockk() @Test fun `device initialization succeeds and delegates to plugin`() { - every { plugin.initializeDevice() } just Runs + every { clock.time() } returns token + every { plugin.initializeDevice(token) } returns true // TODO test when false every { plugin.getMetadataOutputStream() } returns metadataOutputStream - every { metadataManager.onDeviceInitialization(metadataOutputStream) } just Runs + every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs + every { kv.hasState() } returns false + every { full.hasState() } returns false + + assertEquals(TRANSPORT_OK, backup.initializeDevice()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + } + + @Test + fun `device initialization does no-op when already initialized`() { + every { clock.time() } returns token + every { plugin.initializeDevice(token) } returns false every { kv.hasState() } returns false every { full.hasState() } returns false @@ -46,7 +58,8 @@ internal class BackupCoordinatorTest: BackupTest() { fun `error notification when device initialization fails`() { val storage = Storage(Uri.EMPTY, getRandomString(), false) - every { plugin.initializeDevice() } throws IOException() + every { clock.time() } returns token + every { plugin.initializeDevice(token) } throws IOException() every { settingsManager.getStorage() } returns storage every { notificationManager.onBackupError() } just Runs @@ -65,7 +78,8 @@ internal class BackupCoordinatorTest: BackupTest() { val storage = mockk() val documentFile = mockk() - every { plugin.initializeDevice() } throws IOException() + every { clock.time() } returns token + every { plugin.initializeDevice(token) } throws IOException() every { settingsManager.getStorage() } returns storage every { storage.isUsb } returns true every { storage.getDocumentFile(context) } returns documentFile