diff --git a/.travis.yml b/.travis.yml index c4e4e394..dd23f68d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,9 @@ +dist: trusty language: android android: components: - - build-tools-28.0.3 - - android-28 + - build-tools-28.0.3 + - android-28 licenses: - android-sdk-license-.+ diff --git a/app/build.gradle b/app/build.gradle index 0545a964..cd662b64 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -27,6 +27,14 @@ android { targetCompatibility 1.8 sourceCompatibility 1.8 } + testOptions { + unitTests.all { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } + } + } // optional signingConfigs def keystorePropertiesFile = rootProject.file("keystore.properties") @@ -43,6 +51,7 @@ android { } } buildTypes.release.signingConfig = signingConfigs.release + buildTypes.debug.signingConfig = signingConfigs.release } } @@ -70,15 +79,17 @@ preBuild.doLast { } } +// To produce these binaries, in latest AOSP source tree, run +// $ make +def aospDeps = fileTree(include: [ + // out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar + 'android.jar', + // out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar + 'libcore.jar' +], dir: 'libs') + dependencies { - // To produce these binaries, in latest AOSP source tree, run - // $ make - compileOnly fileTree(include: [ - // out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar - 'android.jar', - // out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar - 'libcore.jar' - ], dir: 'libs') + compileOnly aospDeps implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" @@ -90,4 +101,11 @@ dependencies { implementation 'com.google.android.material:material:1.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + + lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha' + + testImplementation aospDeps + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.0' + testImplementation 'io.mockk:mockk:1.9.3' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.0' } diff --git a/app/libs/commons-io-2.6.jar b/app/libs/commons-io-2.6.jar deleted file mode 100644 index 00556b11..00000000 Binary files a/app/libs/commons-io-2.6.jar and /dev/null differ diff --git a/app/src/main/java/com/stevesoltys/backup/Backup.kt b/app/src/main/java/com/stevesoltys/backup/Backup.kt index 707bd642..8d03e025 100644 --- a/app/src/main/java/com/stevesoltys/backup/Backup.kt +++ b/app/src/main/java/com/stevesoltys/backup/Backup.kt @@ -4,6 +4,8 @@ import android.app.Application import android.app.backup.IBackupManager import android.content.Context.BACKUP_SERVICE import android.os.ServiceManager.getService +import com.stevesoltys.backup.crypto.KeyManager +import com.stevesoltys.backup.crypto.KeyManagerImpl const val JOB_ID_BACKGROUND_BACKUP = 1 @@ -17,6 +19,9 @@ class Backup : Application() { val backupManager: IBackupManager by lazy { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } + val keyManager: KeyManager by lazy { + KeyManagerImpl() + } } } diff --git a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt index d414cb5d..b4d01bb8 100644 --- a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt @@ -3,7 +3,6 @@ package com.stevesoltys.backup import android.app.NotificationChannel import android.app.NotificationManager import android.app.NotificationManager.IMPORTANCE_MIN -import android.app.backup.BackupManager import android.app.backup.BackupProgress import android.app.backup.IBackupObserver import android.content.Context @@ -13,12 +12,11 @@ import android.util.Log.isLoggable import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT import androidx.core.app.NotificationCompat.PRIORITY_LOW -import com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport private const val CHANNEL_ID = "NotificationBackupObserver" private const val NOTIFICATION_ID = 1 -private val TAG = NotificationBackupObserver::class.java.name +private val TAG = NotificationBackupObserver::class.java.simpleName class NotificationBackupObserver( private val context: Context, @@ -94,11 +92,11 @@ class NotificationBackupObserver( if (isLoggable(TAG, INFO)) { Log.i(TAG, "Backup finished. Status: $status") } - if (status == BackupManager.SUCCESS) getBackupTransport(context).backupFinished() nm.cancel(NOTIFICATION_ID) } private fun getAppName(packageId: String): CharSequence { + if (packageId == "@pm@") return packageId val appInfo = pm.getApplicationInfo(packageId, 0) return pm.getApplicationLabel(appInfo) } diff --git a/app/src/main/java/com/stevesoltys/backup/activity/restore/RestoreBackupActivityController.java b/app/src/main/java/com/stevesoltys/backup/activity/restore/RestoreBackupActivityController.java index 9daa8799..43374240 100644 --- a/app/src/main/java/com/stevesoltys/backup/activity/restore/RestoreBackupActivityController.java +++ b/app/src/main/java/com/stevesoltys/backup/activity/restore/RestoreBackupActivityController.java @@ -29,7 +29,8 @@ import java.util.zip.ZipInputStream; import libcore.io.IoUtils; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY; +import static com.stevesoltys.backup.transport.backup.plugins.DocumentsStorageKt.DIRECTORY_FULL_BACKUP; + /** * @author Steve Soltys @@ -84,7 +85,7 @@ class RestoreBackupActivityController { while ((zipEntry = inputStream.getNextEntry()) != null) { String zipEntryPath = zipEntry.getName(); - if (zipEntryPath.startsWith(DEFAULT_FULL_BACKUP_DIRECTORY)) { + if (zipEntryPath.startsWith(DIRECTORY_FULL_BACKUP)) { String fileName = new File(zipEntryPath).getName(); results.add(fileName); } diff --git a/app/src/main/java/com/stevesoltys/backup/crypto/CipherFactory.kt b/app/src/main/java/com/stevesoltys/backup/crypto/CipherFactory.kt new file mode 100644 index 00000000..22bf719d --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/crypto/CipherFactory.kt @@ -0,0 +1,31 @@ +package com.stevesoltys.backup.crypto + +import javax.crypto.Cipher +import javax.crypto.Cipher.DECRYPT_MODE +import javax.crypto.Cipher.ENCRYPT_MODE +import javax.crypto.spec.GCMParameterSpec + +private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding" +private const val GCM_AUTHENTICATION_TAG_LENGTH = 128 + +interface CipherFactory { + fun createEncryptionCipher(): Cipher + fun createDecryptionCipher(iv: ByteArray): Cipher +} + +class CipherFactoryImpl(private val keyManager: KeyManager) : CipherFactory { + + override fun createEncryptionCipher(): Cipher { + return Cipher.getInstance(CIPHER_TRANSFORMATION).apply { + init(ENCRYPT_MODE, keyManager.getBackupKey()) + } + } + + override fun createDecryptionCipher(iv: ByteArray): Cipher { + return Cipher.getInstance(CIPHER_TRANSFORMATION).apply { + val spec = GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH, iv) + init(DECRYPT_MODE, keyManager.getBackupKey(), spec) + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/crypto/Crypto.kt b/app/src/main/java/com/stevesoltys/backup/crypto/Crypto.kt new file mode 100644 index 00000000..7d615b2a --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/crypto/Crypto.kt @@ -0,0 +1,138 @@ +package com.stevesoltys.backup.crypto + +import com.stevesoltys.backup.header.* +import java.io.EOFException +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * A backup stream starts with a version byte followed by an encrypted [VersionHeader]. + * + * The header will be encrypted with AES/GCM to provide authentication. + * It can be written using [encryptHeader] and read using [decryptHeader]. + * The latter throws a [SecurityException], + * if the expected version and package name do not match the encrypted header. + * + * After the header, follows one or more data segments. + * Each segment begins with a clear-text [SegmentHeader] + * that contains the length of the segment + * and a nonce acting as the initialization vector for the encryption. + * The segment can be written using [encryptSegment] and read using [decryptSegment]. + * The latter throws a [SecurityException], + * if the length of the segment is specified larger than allowed. + */ +interface Crypto { + + /** + * Encrypts a backup stream header ([VersionHeader]) and writes it to the given [OutputStream]. + * + * The header using a small segment containing only + * the version number, the package name and (optionally) the key of a key/value stream. + */ + @Throws(IOException::class) + fun encryptHeader(outputStream: OutputStream, versionHeader: VersionHeader) + + /** + * Encrypts a new backup segment from the given cleartext payload + * and writes it to the given [OutputStream]. + * + * A segment starts with a [SegmentHeader] which includes the length of the segment + * and a nonce which is used as initialization vector for the encryption. + * + * After the header follows the encrypted payload. + * Larger backup streams such as from a full backup are encrypted in multiple segments + * to avoid having to load the entire stream into memory when doing authenticated encryption. + * + * The given cleartext can later be decrypted + * by calling [decryptSegment] on the same byte stream. + */ + @Throws(IOException::class) + fun encryptSegment(outputStream: OutputStream, cleartext: ByteArray) + + /** + * Reads and decrypts a [VersionHeader] from the given [InputStream] + * and ensures that the expected version, package name and key match + * what is found in the header. + * If a mismatch is found, a [SecurityException] is thrown. + * + * @return The read [VersionHeader] present in the beginning of the given [InputStream]. + */ + @Throws(IOException::class, SecurityException::class) + fun decryptHeader(inputStream: InputStream, expectedVersion: Byte, expectedPackageName: String, + expectedKey: String? = null): VersionHeader + + /** + * Reads and decrypts a segment from the given [InputStream]. + * + * @return The decrypted segment payload as passed into [encryptSegment] + */ + @Throws(EOFException::class, IOException::class, SecurityException::class) + fun decryptSegment(inputStream: InputStream): ByteArray +} + +class CryptoImpl( + private val cipherFactory: CipherFactory, + private val headerWriter: HeaderWriter, + private val headerReader: HeaderReader) : Crypto { + + @Throws(IOException::class) + override fun encryptHeader(outputStream: OutputStream, versionHeader: VersionHeader) { + val bytes = headerWriter.getEncodedVersionHeader(versionHeader) + + encryptSegment(outputStream, bytes) + } + + @Throws(IOException::class) + override fun encryptSegment(outputStream: OutputStream, cleartext: ByteArray) { + val cipher = cipherFactory.createEncryptionCipher() + + check(cipher.getOutputSize(cleartext.size) <= MAX_SEGMENT_LENGTH) + + val encrypted = cipher.doFinal(cleartext) + val segmentHeader = SegmentHeader(encrypted.size.toShort(), cipher.iv) + headerWriter.writeSegmentHeader(outputStream, segmentHeader) + outputStream.write(encrypted) + } + + @Throws(IOException::class, SecurityException::class) + override fun decryptHeader(inputStream: InputStream, expectedVersion: Byte, + expectedPackageName: String, expectedKey: String?): VersionHeader { + val decrypted = decryptSegment(inputStream, MAX_VERSION_HEADER_SIZE) + val header = headerReader.getVersionHeader(decrypted) + + if (header.version != expectedVersion) { + throw SecurityException("Invalid version '${header.version.toInt()}' in header, expected '${expectedVersion.toInt()}'.") + } + if (header.packageName != expectedPackageName) { + throw SecurityException("Invalid package name '${header.packageName}' in header, expected '$expectedPackageName'.") + } + if (header.key != expectedKey) { + throw SecurityException("Invalid key '${header.key}' in header, expected '$expectedKey'.") + } + + return header + } + + @Throws(EOFException::class, IOException::class, SecurityException::class) + override fun decryptSegment(inputStream: InputStream): ByteArray { + return decryptSegment(inputStream, MAX_SEGMENT_LENGTH) + } + + @Throws(EOFException::class, IOException::class, SecurityException::class) + fun decryptSegment(inputStream: InputStream, maxSegmentLength: Int): ByteArray { + val segmentHeader = headerReader.readSegmentHeader(inputStream) + if (segmentHeader.segmentLength > maxSegmentLength) { + throw SecurityException("Segment length too long: ${segmentHeader.segmentLength} > $maxSegmentLength") + } + + val buffer = ByteArray(segmentHeader.segmentLength.toInt()) + val bytesRead = inputStream.read(buffer) + if (bytesRead == -1) throw EOFException() + if (bytesRead != buffer.size) throw IOException() + val cipher = cipherFactory.createDecryptionCipher(segmentHeader.nonce) + + return cipher.doFinal(buffer) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt b/app/src/main/java/com/stevesoltys/backup/crypto/KeyManager.kt similarity index 52% rename from app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt rename to app/src/main/java/com/stevesoltys/backup/crypto/KeyManager.kt index 319e219c..793e34d2 100644 --- a/app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt +++ b/app/src/main/java/com/stevesoltys/backup/crypto/KeyManager.kt @@ -1,4 +1,4 @@ -package com.stevesoltys.backup.security +package com.stevesoltys.backup.crypto import android.os.Build.VERSION.SDK_INT import android.security.keystore.KeyProperties.* @@ -8,11 +8,34 @@ import java.security.KeyStore.SecretKeyEntry import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec -private const val KEY_SIZE = 256 +internal const val KEY_SIZE = 256 +private const val KEY_SIZE_BYTES = KEY_SIZE / 8 private const val KEY_ALIAS = "com.stevesoltys.backup" private const val ANDROID_KEY_STORE = "AndroidKeyStore" -object KeyManager { +interface KeyManager { + /** + * Store a new backup key derived from the given [seed]. + * + * The seed needs to be larger or equal to [KEY_SIZE_BYTES]. + */ + fun storeBackupKey(seed: ByteArray) + + /** + * @return true if a backup key already exists in the [KeyStore]. + */ + fun hasBackupKey(): Boolean + + /** + * Returns the backup key, so it can be used for encryption or decryption. + * + * Note that any attempt to export the key will return null or an empty [ByteArray], + * because the key can not leave the [KeyStore]'s hardware security module. + */ + fun getBackupKey(): SecretKey +} + +class KeyManagerImpl : KeyManager { private val keyStore by lazy { KeyStore.getInstance(ANDROID_KEY_STORE).apply { @@ -20,18 +43,18 @@ object KeyManager { } } - fun storeBackupKey(seed: ByteArray) { - if (seed.size < KEY_SIZE / 8) throw IllegalArgumentException() + override fun storeBackupKey(seed: ByteArray) { + if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException() // TODO check if using first 256 of 512 bytes produced by PBKDF2WithHmacSHA512 is safe! - val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE / 8, "AES") + val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES") val ksEntry = SecretKeyEntry(secretKeySpec) keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection()) } - fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) && + override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) && keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java) - fun getBackupKey(): SecretKey { + override fun getBackupKey(): SecretKey { val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry return ksEntry.secretKey } @@ -41,6 +64,7 @@ object KeyManager { .setBlockModes(BLOCK_MODE_GCM) .setEncryptionPaddings(ENCRYPTION_PADDING_NONE) .setRandomizedEncryptionRequired(true) + // unlocking is required only for decryption, so when restoring from backup if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true) return builder.build() } diff --git a/app/src/main/java/com/stevesoltys/backup/header/Header.kt b/app/src/main/java/com/stevesoltys/backup/header/Header.kt new file mode 100644 index 00000000..806b6afa --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/header/Header.kt @@ -0,0 +1,43 @@ +package com.stevesoltys.backup.header + +import java.nio.charset.Charset + +internal const val VERSION: Byte = 0 +internal const val MAX_PACKAGE_LENGTH_SIZE = 255 +internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE +internal const val MAX_VERSION_HEADER_SIZE = 1 + Short.SIZE_BYTES * 2 + MAX_PACKAGE_LENGTH_SIZE + MAX_KEY_LENGTH_SIZE + +/** + * After the first version byte of each backup stream + * must follow followed this header encrypted with authentication. + */ +data class VersionHeader( + internal val version: Byte = VERSION, // 1 byte + internal val packageName: String, // ?? bytes (max 255) + internal val key: String? = null // ?? bytes +) { + init { + check(packageName.length <= MAX_PACKAGE_LENGTH_SIZE) + key?.let { check(key.length <= MAX_KEY_LENGTH_SIZE) } + } +} + + +internal const val SEGMENT_LENGTH_SIZE: Int = Short.SIZE_BYTES +internal const val MAX_SEGMENT_LENGTH: Int = Short.MAX_VALUE.toInt() +internal const val IV_SIZE: Int = 12 +internal const val SEGMENT_HEADER_SIZE = SEGMENT_LENGTH_SIZE + IV_SIZE + +/** + * Each data segment must start with this header + */ +class SegmentHeader( + internal val segmentLength: Short, // 2 bytes + internal val nonce: ByteArray // 12 bytes +) { + init { + check(nonce.size == IV_SIZE) + } +} + +val Utf8: Charset = Charset.forName("UTF-8") diff --git a/app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt b/app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt new file mode 100644 index 00000000..2cf0b4a6 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt @@ -0,0 +1,72 @@ +package com.stevesoltys.backup.header + +import java.io.EOFException +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer + +interface HeaderReader { + @Throws(IOException::class, UnsupportedVersionException::class) + fun readVersion(inputStream: InputStream): Byte + + @Throws(SecurityException::class) + fun getVersionHeader(byteArray: ByteArray): VersionHeader + + @Throws(EOFException::class, IOException::class) + fun readSegmentHeader(inputStream: InputStream): SegmentHeader +} + +internal class HeaderReaderImpl : HeaderReader { + + @Throws(IOException::class, UnsupportedVersionException::class) + override fun readVersion(inputStream: InputStream): Byte { + val version = inputStream.read().toByte() + if (version < 0) throw IOException() + if (version > VERSION) throw UnsupportedVersionException(version) + return version + } + + override fun getVersionHeader(byteArray: ByteArray): VersionHeader { + val buffer = ByteBuffer.wrap(byteArray) + val version = buffer.get() + + val packageLength = buffer.short.toInt() + if (packageLength <= 0) throw SecurityException("Invalid package length: $packageLength") + if (packageLength > MAX_PACKAGE_LENGTH_SIZE) throw SecurityException("Too large package length: $packageLength") + if (packageLength > buffer.remaining()) throw SecurityException("Not enough bytes for package name") + val packageName = ByteArray(packageLength) + .apply { buffer.get(this) } + .toString(Utf8) + + val keyLength = buffer.short.toInt() + if (keyLength < 0) throw SecurityException("Invalid key length: $keyLength") + if (keyLength > MAX_KEY_LENGTH_SIZE) throw SecurityException("Too large key length: $keyLength") + if (keyLength > buffer.remaining()) throw SecurityException("Not enough bytes for key") + val key = if (keyLength == 0) null else ByteArray(keyLength) + .apply { buffer.get(this) } + .toString(Utf8) + + if (buffer.remaining() != 0) throw SecurityException("Found extra bytes in header") + + return VersionHeader(version, packageName, key) + } + + @Throws(EOFException::class, IOException::class) + override fun readSegmentHeader(inputStream: InputStream): SegmentHeader { + val buffer = ByteArray(SEGMENT_HEADER_SIZE) + val bytesRead = inputStream.read(buffer) + if (bytesRead == -1) throw EOFException() + if (bytesRead != SEGMENT_HEADER_SIZE) { + throw IOException("Read $bytesRead bytes, but expected $SEGMENT_HEADER_SIZE") + } + + val segmentLength = ByteBuffer.wrap(buffer, 0, SEGMENT_LENGTH_SIZE).short + if (segmentLength <= 0) throw IOException() + val nonce = buffer.copyOfRange(SEGMENT_LENGTH_SIZE, buffer.size) + + return SegmentHeader(segmentLength, nonce) + } + +} + +class UnsupportedVersionException(val version: Byte) : IOException() diff --git a/app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt b/app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt new file mode 100644 index 00000000..36f93b53 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt @@ -0,0 +1,50 @@ +package com.stevesoltys.backup.header + +import java.io.IOException +import java.io.OutputStream +import java.nio.ByteBuffer + +interface HeaderWriter { + @Throws(IOException::class) + fun writeVersion(outputStream: OutputStream, header: VersionHeader) + + fun getEncodedVersionHeader(header: VersionHeader): ByteArray + + @Throws(IOException::class) + fun writeSegmentHeader(outputStream: OutputStream, header: SegmentHeader) +} + +internal class HeaderWriterImpl : HeaderWriter { + + @Throws(IOException::class) + override fun writeVersion(outputStream: OutputStream, header: VersionHeader) { + val headerBytes = ByteArray(1) + headerBytes[0] = header.version + outputStream.write(headerBytes) + } + + override fun getEncodedVersionHeader(header: VersionHeader): ByteArray { + val packageBytes = header.packageName.toByteArray(Utf8) + val keyBytes = header.key?.toByteArray(Utf8) + val size = 1 + 2 + packageBytes.size + 2 + (keyBytes?.size ?: 0) + return ByteBuffer.allocate(size).apply { + put(header.version) + putShort(packageBytes.size.toShort()) + put(packageBytes) + if (keyBytes == null) { + putShort(0.toShort()) + } else { + putShort(keyBytes.size.toShort()) + put(keyBytes) + } + }.array() + } + + override fun writeSegmentHeader(outputStream: OutputStream, header: SegmentHeader) { + val buffer = ByteBuffer.allocate(SEGMENT_HEADER_SIZE) + .putShort(header.segmentLength) + .put(header.nonce) + outputStream.write(buffer.array()) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java b/app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java deleted file mode 100644 index 868930f2..00000000 --- a/app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.stevesoltys.backup.security; - -import javax.crypto.*; -import javax.crypto.spec.IvParameterSpec; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; - -/** - * A utility class for encrypting and decrypting data using a {@link Cipher}. - * - * @author Steve Soltys - */ -public class CipherUtil { - - /** - * The cipher algorithm. - */ - public static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; - - /** - * . - * Encrypts the given payload using the provided secret key. - * - * @param payload The payload. - * @param secretKey The secret key. - * @param iv The initialization vector. - */ - public static byte[] encrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException, - NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException, - InvalidAlgorithmParameterException, InvalidKeyException { - - return startEncrypt(secretKey, iv).doFinal(payload); - } - - /** - * Initializes a cipher in {@link Cipher#ENCRYPT_MODE}. - * - * @param secretKey The secret key. - * @param iv The initialization vector. - * @return The initialized cipher. - */ - public static Cipher startEncrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException, - NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException { - - Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); - cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv)); - return cipher; - } - - /** - * Decrypts the given payload using the provided secret key. - * - * @param payload The payload. - * @param secretKey The secret key. - * @param iv The initialization vector. - */ - public static byte[] decrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException, - NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException, - InvalidAlgorithmParameterException, InvalidKeyException { - - return startDecrypt(secretKey, iv).doFinal(payload); - } - - /** - * Initializes a cipher in {@link Cipher#DECRYPT_MODE}. - * - * @param secretKey The secret key. - * @param iv The initialization vector. - * @return The initialized cipher. - */ - public static Cipher startDecrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException, - NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException { - - Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); - cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv)); - return cipher; - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java b/app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java deleted file mode 100644 index 9323e7b3..00000000 --- a/app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.stevesoltys.backup.security; - -import javax.crypto.SecretKey; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; -import javax.crypto.spec.SecretKeySpec; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.KeySpec; - -/** - * A utility class which can be used for generating an AES secret key using PBKDF2. - * - * @author Steve Soltys - */ -public class KeyGenerator { - - /** - * The number of iterations for key generation. - */ - private static final int ITERATIONS = 32767; - - /** - * The generated key length. - */ - private static final int KEY_LENGTH = 256; - - /** - * Generates an AES secret key using PBKDF2. - * - * @param password The password. - * @param salt The salt. - * @return The generated key. - */ - public static SecretKey generate(String password, byte[] salt) - throws NoSuchAlgorithmException, InvalidKeySpecException { - - SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); - KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH); - - SecretKey secretKey = secretKeyFactory.generateSecret(keySpec); - return new SecretKeySpec(secretKey.getEncoded(), "AES"); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/service/PackageService.java b/app/src/main/java/com/stevesoltys/backup/service/PackageService.java deleted file mode 100644 index 0b7560a6..00000000 --- a/app/src/main/java/com/stevesoltys/backup/service/PackageService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.stevesoltys.backup.service; - -import android.app.backup.IBackupManager; -import android.content.pm.IPackageManager; -import android.content.pm.PackageInfo; -import android.os.RemoteException; -import android.os.ServiceManager; -import android.os.UserHandle; - -import java.util.List; -import java.util.Set; - -import static com.google.android.collect.Sets.newArraySet; - -/** - * @author Steve Soltys - */ -public class PackageService { - - private final IBackupManager backupManager; - - private final IPackageManager packageManager; - - private static final Set<String> IGNORED_PACKAGES = newArraySet( - "com.android.externalstorage", - "com.android.providers.downloads.ui", - "com.android.providers.downloads", - "com.android.providers.media", - "com.android.providers.calendar", - "com.android.providers.contacts", - "com.stevesoltys.backup" - ); - - public PackageService() { - backupManager = IBackupManager.Stub.asInterface(ServiceManager.getService("backup")); - packageManager = IPackageManager.Stub.asInterface(ServiceManager.getService("package")); - } - - public String[] getEligiblePackages() throws RemoteException { - List<PackageInfo> packages = packageManager.getInstalledPackages(0, UserHandle.USER_SYSTEM).getList(); - String[] packageArray = packages.stream() - .map(packageInfo -> packageInfo.packageName) - .filter(packageName -> !IGNORED_PACKAGES.contains(packageName)) - .toArray(String[]::new); - - return backupManager.filterAppsEligibleForBackup(packageArray); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt b/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt new file mode 100644 index 00000000..91dea0cf --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt @@ -0,0 +1,57 @@ +package com.stevesoltys.backup.service + +import android.content.pm.IPackageManager +import android.content.pm.PackageInfo +import android.os.RemoteException +import android.os.ServiceManager.getService +import android.os.UserHandle +import android.util.Log +import com.google.android.collect.Sets.newArraySet +import com.stevesoltys.backup.Backup +import java.util.* + +private val TAG = PackageService::class.java.simpleName + +private val IGNORED_PACKAGES = newArraySet( + "com.android.externalstorage", + "com.android.providers.downloads.ui", + "com.android.providers.downloads", + "com.android.providers.media", + "com.android.providers.calendar", + "com.android.providers.contacts", + "com.stevesoltys.backup" +) + +/** + * @author Steve Soltys + * @author Torsten Grote + */ +class PackageService { + + private val backupManager = Backup.backupManager + private val packageManager: IPackageManager = IPackageManager.Stub.asInterface(getService("package")) + + val eligiblePackages: Array<String> + @Throws(RemoteException::class) + get() { + val packages: List<PackageInfo> = packageManager.getInstalledPackages(0, UserHandle.USER_SYSTEM).list as List<PackageInfo> + val packageList = packages + .map { packageInfo -> packageInfo.packageName } + .filter { packageName -> !IGNORED_PACKAGES.contains(packageName) } + .sorted() + + Log.d(TAG, "Got ${packageList.size} packages: $packageList") + + // TODO why is this filtering out so much? + val eligibleApps = backupManager.filterAppsEligibleForBackup(packageList.toTypedArray()) + + Log.d(TAG, "Filtering left ${eligibleApps.size} eligible packages: ${Arrays.toString(eligibleApps)}") + + // add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data + val packageArray = eligibleApps.toMutableList() + packageArray.add("@pm@") + + return packageArray.toTypedArray() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java index a319b377..8bcbfefa 100644 --- a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java +++ b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java @@ -12,8 +12,6 @@ import com.stevesoltys.backup.session.backup.BackupResult; import com.stevesoltys.backup.session.backup.BackupSession; import com.stevesoltys.backup.session.backup.BackupSessionObserver; -import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport; - /** * @author Steve Soltys */ @@ -61,9 +59,6 @@ class BackupObserver implements BackupSessionObserver { @Override public void backupSessionCompleted(BackupSession backupSession, BackupResult backupResult) { - - if (backupResult == BackupResult.SUCCESS) getBackupTransport(context).backupFinished(); - context.runOnUiThread(() -> { if (backupResult == BackupResult.SUCCESS) { Toast.makeText(context, R.string.backup_success, Toast.LENGTH_LONG).show(); diff --git a/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java b/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java index 6441397a..8e966b8f 100644 --- a/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java +++ b/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java @@ -12,12 +12,9 @@ import com.stevesoltys.backup.activity.PopupWindowUtil; import com.stevesoltys.backup.activity.restore.RestorePopupWindowListener; import com.stevesoltys.backup.service.TransportService; import com.stevesoltys.backup.session.restore.RestoreSession; -import com.stevesoltys.backup.transport.ConfigurableBackupTransport; import java.util.Set; -import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport; - /** * @author Steve Soltys */ @@ -28,8 +25,6 @@ public class RestoreService { private final TransportService transportService = new TransportService(); public void restorePackages(Set<String> selectedPackages, Uri contentUri, Activity parent, String password) { - ConfigurableBackupTransport backupTransport = getBackupTransport(parent.getApplication()); - backupTransport.prepareRestore(password, contentUri); try { PopupWindow popupWindow = PopupWindowUtil.showLoadingPopupWindow(parent); RestoreObserver restoreObserver = new RestoreObserver(parent, popupWindow, selectedPackages.size()); diff --git a/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt index 140ea439..eab22e01 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt @@ -5,6 +5,7 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.content.Intent.* import android.os.Bundle +import android.provider.DocumentsContract.EXTRA_PROMPT import android.widget.Toast import android.widget.Toast.LENGTH_LONG import androidx.lifecycle.ViewModelProviders @@ -38,6 +39,7 @@ class BackupLocationFragment : PreferenceFragmentCompat() { private fun showChooseFolderActivity() { val openTreeIntent = Intent(ACTION_OPEN_DOCUMENT_TREE) + openTreeIntent.putExtra(EXTRA_PROMPT, getString(R.string.settings_backup_location_picker)) openTreeIntent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION) try { diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt index a64f4ec8..59643546 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt @@ -3,9 +3,9 @@ package com.stevesoltys.backup.settings import android.app.Application import android.util.ByteStringUtils.toHexString import androidx.lifecycle.AndroidViewModel +import com.stevesoltys.backup.Backup import com.stevesoltys.backup.LiveEvent import com.stevesoltys.backup.MutableLiveEvent -import com.stevesoltys.backup.security.KeyManager import io.github.novacrypto.bip39.* import io.github.novacrypto.bip39.Validation.InvalidChecksumException import io.github.novacrypto.bip39.Validation.InvalidWordCountException @@ -48,7 +48,7 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica } val mnemonic = input.joinToString(" ") val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "") - KeyManager.storeBackupKey(seed) + Backup.keyManager.storeBackupKey(seed) // TODO remove once encryption/decryption uses key from KeyStore setBackupPassword(getApplication(), toHexString(seed)) diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt index ccc0bb35..e36627d7 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt @@ -10,6 +10,7 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT import androidx.lifecycle.ViewModelProviders import androidx.preference.Preference.OnPreferenceChangeListener import androidx.preference.PreferenceFragmentCompat @@ -99,7 +100,7 @@ class SettingsFragment : PreferenceFragmentCompat() { true } item.itemId == R.id.action_restore -> { - Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), "Not yet implemented", LENGTH_SHORT).show() true } else -> super.onOptionsItemSelected(item) diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt index dd20a14a..4c243eba 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt @@ -4,13 +4,15 @@ import android.app.Application import android.content.Intent import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION +import android.util.Log import androidx.lifecycle.AndroidViewModel +import com.stevesoltys.backup.Backup import com.stevesoltys.backup.LiveEvent import com.stevesoltys.backup.MutableLiveEvent -import com.stevesoltys.backup.security.KeyManager import com.stevesoltys.backup.service.backup.requestFullBackup +import com.stevesoltys.backup.transport.ConfigurableBackupTransportService -private val TAG = SettingsViewModel::class.java.name +private val TAG = SettingsViewModel::class.java.simpleName class SettingsViewModel(application: Application) : AndroidViewModel(application) { @@ -27,7 +29,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application internal val chooseBackupLocation: LiveEvent<Boolean> = mChooseBackupLocation internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true) - fun recoveryCodeIsSet() = KeyManager.hasBackupKey() + fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey() fun locationIsSet() = getBackupFolderUri(getApplication()) != null fun handleChooseFolderResult(result: Intent?) { @@ -45,6 +47,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application // notify the UI that the location has been set locationWasSet.setEvent(wasEmptyBefore) + + // 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 chosen: $folderUri") } fun backupNow() = Thread { requestFullBackup(app) }.start() diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java deleted file mode 100644 index 522a6a14..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.stevesoltys.backup.transport; - -import android.app.backup.BackupTransport; -import android.app.backup.RestoreDescription; -import android.app.backup.RestoreSet; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Log; - -import com.stevesoltys.backup.settings.SettingsActivity; -import com.stevesoltys.backup.transport.component.BackupComponent; -import com.stevesoltys.backup.transport.component.RestoreComponent; -import com.stevesoltys.backup.transport.component.provider.ContentProviderBackupComponent; -import com.stevesoltys.backup.transport.component.provider.ContentProviderRestoreComponent; - -import static android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED; -import static android.os.Build.VERSION.SDK_INT; - -/** - * @author Steve Soltys - */ -public class ConfigurableBackupTransport extends BackupTransport { - - private static final String TRANSPORT_DIRECTORY_NAME = - "com.stevesoltys.backup.transport.ConfigurableBackupTransport"; - - private static final String TAG = TRANSPORT_DIRECTORY_NAME; - - private final Context context; - - private final BackupComponent backupComponent; - - private final RestoreComponent restoreComponent; - - ConfigurableBackupTransport(Context context) { - this.context = context; - backupComponent = new ContentProviderBackupComponent(context); - restoreComponent = new ContentProviderRestoreComponent(context); - } - - public void prepareRestore(String password, Uri fileUri) { - restoreComponent.prepareRestore(password, fileUri); - } - - @Override - public String transportDirName() { - return TRANSPORT_DIRECTORY_NAME; - } - - @Override - public String name() { - // TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName. - return this.getClass().getName(); - } - - @Override - public int getTransportFlags() { - if (SDK_INT >= 28) return FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED; - return 0; - } - - @Override - public Intent dataManagementIntent() { - return new Intent(context, SettingsActivity.class); - } - - @Override - public boolean isAppEligibleForBackup(PackageInfo targetPackage, boolean isFullBackup) { - return true; - } - - @Override - public long requestBackupTime() { - return backupComponent.requestBackupTime(); - } - - @Override - public String dataManagementLabel() { - return backupComponent.dataManagementLabel(); - } - - @Override - public int initializeDevice() { - return backupComponent.initializeDevice(); - } - - @Override - public String currentDestinationString() { - return backupComponent.currentDestinationString(); - } - - /* Methods related to Backup */ - - @Override - public int performBackup(PackageInfo packageInfo, ParcelFileDescriptor inFd, int flags) { - return backupComponent.performIncrementalBackup(packageInfo, inFd, flags); - } - - @Override - public int performBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) { - Log.w(TAG, "Warning: Legacy performBackup() method called."); - return performBackup(targetPackage, fileDescriptor, 0); - } - - @Override - public int checkFullBackupSize(long size) { - return backupComponent.checkFullBackupSize(size); - } - - @Override - public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor socket, int flags) { - // TODO handle flags - return performFullBackup(targetPackage, socket); - } - - @Override - public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) { - return backupComponent.performFullBackup(targetPackage, fileDescriptor); - } - - @Override - public int sendBackupData(int numBytes) { - return backupComponent.sendBackupData(numBytes); - } - - @Override - public void cancelFullBackup() { - backupComponent.cancelFullBackup(); - } - - @Override - public int finishBackup() { - return backupComponent.finishBackup(); - } - - @Override - public long requestFullBackupTime() { - return backupComponent.requestFullBackupTime(); - } - - @Override - public long getBackupQuota(String packageName, boolean isFullBackup) { - return backupComponent.getBackupQuota(packageName, isFullBackup); - } - - @Override - public int clearBackupData(PackageInfo packageInfo) { - return backupComponent.clearBackupData(packageInfo); - } - - public void backupFinished() { - backupComponent.backupFinished(); - } - - /* Methods related to Restore */ - - @Override - public long getCurrentRestoreSet() { - return restoreComponent.getCurrentRestoreSet(); - } - - @Override - public int startRestore(long token, PackageInfo[] packages) { - return restoreComponent.startRestore(token, packages); - } - - @Override - public int getNextFullRestoreDataChunk(ParcelFileDescriptor socket) { - return restoreComponent.getNextFullRestoreDataChunk(socket); - } - - @Override - public RestoreSet[] getAvailableRestoreSets() { - return restoreComponent.getAvailableRestoreSets(); - } - - @Override - public RestoreDescription nextRestorePackage() { - return restoreComponent.nextRestorePackage(); - } - - @Override - public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) { - return restoreComponent.getRestoreData(outputFileDescriptor); - } - - @Override - public int abortFullRestore() { - return restoreComponent.abortFullRestore(); - } - - @Override - public void finishRestore() { - restoreComponent.finishRestore(); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt new file mode 100644 index 00000000..53e47cf0 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt @@ -0,0 +1,160 @@ +package com.stevesoltys.backup.transport + +import android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED +import android.app.backup.BackupTransport +import android.app.backup.RestoreDescription +import android.app.backup.RestoreSet +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.os.Build.VERSION.SDK_INT +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.settings.SettingsActivity + +const val DEFAULT_RESTORE_SET_TOKEN: Long = 1 + +private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.backup.transport.ConfigurableBackupTransport" +private val TAG = ConfigurableBackupTransport::class.java.simpleName + +/** + * @author Steve Soltys + * @author Torsten Grote + */ +class ConfigurableBackupTransport internal constructor(private val context: Context) : BackupTransport() { + + private val pluginManager = PluginManager(context) + private val backupCoordinator = pluginManager.backupCoordinator + private val restoreCoordinator = pluginManager.restoreCoordinator + + override fun transportDirName(): String { + return TRANSPORT_DIRECTORY_NAME + } + + override fun name(): String { + // TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName. + return this.javaClass.name + } + + override fun getTransportFlags(): Int { + return if (SDK_INT >= 28) FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED else 0 + } + + override fun dataManagementIntent(): Intent { + return Intent(context, SettingsActivity::class.java) + } + + override fun dataManagementLabel(): String { + return "Please file a bug if you see this! 1" + } + + override fun currentDestinationString(): String { + return "Please file a bug if you see this! 2" + } + + // ------------------------------------------------------------------------------------ + // General backup methods + // + + override fun initializeDevice(): Int { + return backupCoordinator.initializeDevice() + } + + override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean { + return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup) + } + + override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { + return backupCoordinator.getBackupQuota(packageName, isFullBackup) + } + + override fun clearBackupData(packageInfo: PackageInfo): Int { + return backupCoordinator.clearBackupData(packageInfo) + } + + override fun finishBackup(): Int { + return backupCoordinator.finishBackup() + } + + // ------------------------------------------------------------------------------------ + // Key/value incremental backup support + // + + override fun requestBackupTime(): Long { + return backupCoordinator.requestBackupTime() + } + + override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int { + return backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags) + } + + override fun performBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int { + Log.w(TAG, "Warning: Legacy performBackup() method called.") + return performBackup(targetPackage, fileDescriptor, 0) + } + + // ------------------------------------------------------------------------------------ + // Full backup + // + + override fun requestFullBackupTime(): Long { + return backupCoordinator.requestFullBackupTime() + } + + override fun checkFullBackupSize(size: Long): Int { + return backupCoordinator.checkFullBackupSize(size) + } + + override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int { + return backupCoordinator.performFullBackup(targetPackage, socket, flags) + } + + override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int { + return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0) + } + + override fun sendBackupData(numBytes: Int): Int { + return backupCoordinator.sendBackupData(numBytes) + } + + override fun cancelFullBackup() { + backupCoordinator.cancelFullBackup() + } + + // ------------------------------------------------------------------------------------ + // Restore + // + + override fun getAvailableRestoreSets(): Array<RestoreSet>? { + return restoreCoordinator.getAvailableRestoreSets() + } + + override fun getCurrentRestoreSet(): Long { + return restoreCoordinator.getCurrentRestoreSet() + } + + override fun startRestore(token: Long, packages: Array<PackageInfo>): Int { + return restoreCoordinator.startRestore(token, packages) + } + + override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int { + return restoreCoordinator.getNextFullRestoreDataChunk(socket) + } + + override fun nextRestorePackage(): RestoreDescription? { + return restoreCoordinator.nextRestorePackage() + } + + override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int { + return restoreCoordinator.getRestoreData(outputFileDescriptor) + } + + override fun abortFullRestore(): Int { + return restoreCoordinator.abortFullRestore() + } + + override fun finishRestore() { + restoreCoordinator.finishRestore() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java deleted file mode 100644 index da896889..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.stevesoltys.backup.transport; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.IBinder; -import android.util.Log; - -/** - * @author Steve Soltys - */ -public class ConfigurableBackupTransportService extends Service { - - private static final String TAG = ConfigurableBackupTransportService.class.getName(); - - private static ConfigurableBackupTransport backupTransport = null; - - public static ConfigurableBackupTransport getBackupTransport(Context context) { - - if (backupTransport == null) { - backupTransport = new ConfigurableBackupTransport(context); - } - - return backupTransport; - } - - @Override - public void onCreate() { - super.onCreate(); - Log.d(TAG, "Service created."); - } - - @Override - public IBinder onBind(Intent intent) { - return getBackupTransport(getApplicationContext()).getBinder(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - Log.d(TAG, "Service destroyed."); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt new file mode 100644 index 00000000..f178245d --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt @@ -0,0 +1,37 @@ +package com.stevesoltys.backup.transport + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.util.Log + +private val TAG = ConfigurableBackupTransportService::class.java.simpleName + +/** + * @author Steve Soltys + * @author Torsten Grote + */ +class ConfigurableBackupTransportService : Service() { + + private var transport: ConfigurableBackupTransport? = null + + override fun onCreate() { + super.onCreate() + transport = ConfigurableBackupTransport(applicationContext) + Log.d(TAG, "Service created.") + } + + override fun onBind(intent: Intent): IBinder { + val transport = this.transport ?: throw IllegalStateException() + return transport.binder.apply { + Log.d(TAG, "Transport bound.") + } + } + + override fun onDestroy() { + super.onDestroy() + transport = null + Log.d(TAG, "Service destroyed.") + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt new file mode 100644 index 00000000..0c0c6471 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt @@ -0,0 +1,56 @@ +package com.stevesoltys.backup.transport + +import android.content.Context +import android.os.Build +import com.stevesoltys.backup.Backup +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.settings.getBackupFolderUri +import com.stevesoltys.backup.transport.backup.BackupCoordinator +import com.stevesoltys.backup.transport.backup.FullBackup +import com.stevesoltys.backup.transport.backup.InputFactory +import com.stevesoltys.backup.transport.backup.KVBackup +import com.stevesoltys.backup.transport.backup.plugins.DocumentsProviderBackupPlugin +import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.restore.FullRestore +import com.stevesoltys.backup.transport.restore.KVRestore +import com.stevesoltys.backup.transport.restore.OutputFactory +import com.stevesoltys.backup.transport.restore.RestoreCoordinator +import com.stevesoltys.backup.transport.restore.plugins.DocumentsProviderRestorePlugin + +class PluginManager(context: Context) { + + // We can think about using an injection framework such as Dagger to simplify this. + + private val storage = DocumentsStorage(context, getBackupFolderUri(context), getDeviceName()) + + private val headerWriter = HeaderWriterImpl() + private val headerReader = HeaderReaderImpl() + private val cipherFactory = CipherFactoryImpl(Backup.keyManager) + private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) + + + private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager) + private val inputFactory = InputFactory() + private val kvBackup = KVBackup(backupPlugin.kvBackupPlugin, inputFactory, headerWriter, crypto) + private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto) + + internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup) + + + private val restorePlugin = DocumentsProviderRestorePlugin(storage) + private val outputFactory = OutputFactory() + 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) + + + private fun getDeviceName(): String { + // TODO add device specific unique ID to the end + return "${Build.MANUFACTURER} ${Build.MODEL}" + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt new file mode 100644 index 00000000..65ad91b0 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt @@ -0,0 +1,144 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import java.io.IOException + +private val TAG = BackupCoordinator::class.java.simpleName + +/** + * @author Steve Soltys + * @author Torsten Grote + */ +class BackupCoordinator( + private val plugin: BackupPlugin, + private val kv: KVBackup, + private val full: FullBackup) { + + private var calledInitialize = false + private var calledClearBackupData = false + + // ------------------------------------------------------------------------------------ + // Transport initialization and quota + // + + /** + * Initialize the storage for this device, erasing all stored data. + * The transport may send the request immediately, or may buffer it. + * After this is called, + * [finishBackup] will be called to ensure the request is sent and received successfully. + * + * If the transport returns anything other than [TRANSPORT_OK] from this method, + * the OS will halt the current initialize operation and schedule a retry in the near future. + * Even if the transport is in a state + * such that attempting to "initialize" the backend storage is meaningless - + * 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. + * + * @return One of [TRANSPORT_OK] (OK so far) or + * [TRANSPORT_ERROR] (to retry following network error or other failure). + */ + fun initializeDevice(): Int { + Log.i(TAG, "Initialize Device!") + return try { + plugin.initializeDevice() + // [finishBackup] will only be called when we return [TRANSPORT_OK] here + // so we remember that we initialized successfully + calledInitialize = true + TRANSPORT_OK + } catch (e: IOException) { + Log.e(TAG, "Error initializing device", e) + TRANSPORT_ERROR + } + } + + fun isAppEligibleForBackup(targetPackage: PackageInfo, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean): Boolean { + // We need to exclude the DocumentsProvider used to store backup data. + // Otherwise, it gets killed when we back it up, terminating our backup. + return targetPackage.packageName != plugin.providerPackageName + } + + fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { + Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.") + val quota = if (isFullBackup) full.getQuota() else kv.getQuota() + Log.i(TAG, "Reported quota of $quota bytes.") + return quota + } + + // ------------------------------------------------------------------------------------ + // Key/value incremental backup support + // + + fun requestBackupTime() = kv.requestBackupTime() + + fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int) = + kv.performBackup(packageInfo, data, flags) + + // ------------------------------------------------------------------------------------ + // Full backup + // + + fun requestFullBackupTime() = full.requestFullBackupTime() + + fun checkFullBackupSize(size: Long) = full.checkFullBackupSize(size) + + fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int) = + full.performFullBackup(targetPackage, fileDescriptor, flags) + + fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes) + + fun cancelFullBackup() = full.cancelFullBackup() + + // Clear and Finish + + /** + * Erase the given application's data from the backup destination. + * This clears out the given package's data from the current backup set, + * making it as though the app had never yet been backed up. + * After this is called, [finishBackup] must be called + * to ensure that the operation is recorded successfully. + * + * @return the same error codes as [performFullBackup]. + */ + fun clearBackupData(packageInfo: PackageInfo): Int { + val packageName = packageInfo.packageName + Log.i(TAG, "Clear Backup Data of $packageName.") + try { + kv.clearBackupData(packageInfo) + } catch (e: IOException) { + Log.w(TAG, "Error clearing K/V backup data for $packageName", e) + return TRANSPORT_ERROR + } + try { + full.clearBackupData(packageInfo) + } catch (e: IOException) { + Log.w(TAG, "Error clearing full backup data for $packageName", e) + return TRANSPORT_ERROR + } + calledClearBackupData = true + return TRANSPORT_OK + } + + fun finishBackup(): Int = when { + kv.hasState() -> { + if (full.hasState()) throw IllegalStateException() + kv.finishBackup() + } + full.hasState() -> { + if (kv.hasState()) throw IllegalStateException() + full.finishBackup() + } + calledInitialize || calledClearBackupData -> { + calledInitialize = false + calledClearBackupData = false + TRANSPORT_OK + } + else -> throw IllegalStateException() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt new file mode 100644 index 00000000..b3d6f5dc --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt @@ -0,0 +1,27 @@ +package com.stevesoltys.backup.transport.backup + +import java.io.IOException + +interface BackupPlugin { + + val kvBackupPlugin: KVBackupPlugin + + val fullBackupPlugin: FullBackupPlugin + + /** + * Initialize the storage for this device, erasing all stored data. + */ + @Throws(IOException::class) + fun initializeDevice() + + /** + * Returns the package name of the app that provides the backend storage + * which is used for the current backup location. + * + * Plugins are advised to cache this as it will be requested frequently. + * + * @return null if no package name could be found + */ + val providerPackageName: String? + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt new file mode 100644 index 00000000..0a56c59b --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt @@ -0,0 +1,191 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.* +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.header.HeaderWriter +import com.stevesoltys.backup.header.VersionHeader +import libcore.io.IoUtils.closeQuietly +import org.apache.commons.io.IOUtils +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +private class FullBackupState( + internal val packageInfo: PackageInfo, + internal val inputFileDescriptor: ParcelFileDescriptor, + internal val inputStream: InputStream, + internal val outputStream: OutputStream) { + internal val packageName: String = packageInfo.packageName + internal var size: Long = 0 +} + +const val DEFAULT_QUOTA_FULL_BACKUP = (2 * (25 * 1024 * 1024)).toLong() + +private val TAG = FullBackup::class.java.simpleName + +class FullBackup( + private val plugin: FullBackupPlugin, + private val inputFactory: InputFactory, + private val headerWriter: HeaderWriter, + private val crypto: Crypto) { + + private var state: FullBackupState? = null + + fun hasState() = state != null + + fun requestFullBackupTime(): Long { + Log.i(TAG, "Request full backup time") + return 0 + } + + fun getQuota(): Long = plugin.getQuota() + + fun checkFullBackupSize(size: Long): Int { + Log.i(TAG, "Check full backup size of $size bytes.") + return when { + size <= 0 -> TRANSPORT_PACKAGE_REJECTED + size > plugin.getQuota() -> TRANSPORT_QUOTA_EXCEEDED + else -> TRANSPORT_OK + } + } + + /** + * Begin the process of sending a packages' full-data archive to the backend. + * The description of the package whose data will be delivered is provided, + * as well as the socket file descriptor on which the transport will receive the data itself. + * + * If the package is not eligible for backup, + * the transport should return [TRANSPORT_PACKAGE_REJECTED]. + * In this case the system will simply proceed with the next candidate if any, + * or finish the full backup operation if all apps have been processed. + * + * After the transport returns [TRANSPORT_OK] from this method, + * the OS will proceed to call [sendBackupData] one or more times + * to deliver the packages' data as a streamed tarball. + * The transport should not read() from the socket except as instructed to + * via the [sendBackupData] method. + * + * After all data has been delivered to the transport, the system will call [finishBackup]. + * At this point the transport should commit the data to its datastore, if appropriate, + * and close the socket that had been provided in [performFullBackup]. + * + * If the transport returns [TRANSPORT_OK] from this method, + * then the OS will always provide a matching call to [finishBackup] + * even if sending data via [sendBackupData] failed at some point. + * + * @param targetPackage The package whose data is to follow. + * @param socket The socket file descriptor through which the data will be provided. + * If the transport returns [TRANSPORT_PACKAGE_REJECTED] here, + * it must still close this file descriptor now; + * otherwise it should be cached for use during succeeding calls to [sendBackupData], + * and closed in response to [finishBackup]. + * @param flags [FLAG_USER_INITIATED] or 0. + * @return [TRANSPORT_PACKAGE_REJECTED] to indicate that the package is not to be backed up; + * [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data; + * [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time. + */ + fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, @Suppress("UNUSED_PARAMETER") flags: Int = 0): Int { + if (state != null) throw AssertionError() + Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.") + + // get OutputStream to write backup data into + val outputStream = try { + plugin.getOutputStream(targetPackage) + } catch (e: IOException) { + Log.e(TAG, "Error getting OutputStream for full backup of ${targetPackage.packageName}", e) + return backupError(TRANSPORT_ERROR) + } + + // create new state + val inputStream = inputFactory.getInputStream(socket) + state = FullBackupState(targetPackage, socket, inputStream, outputStream) + + // store version header + val state = this.state ?: throw AssertionError() + val header = VersionHeader(packageName = state.packageName) + try { + headerWriter.writeVersion(state.outputStream, header) + crypto.encryptHeader(state.outputStream, header) + } catch (e: IOException) { + Log.e(TAG, "Error writing backup header", e) + return backupError(TRANSPORT_ERROR) + } + return TRANSPORT_OK + } + + /** + * Method to reset state, + * because [finishBackup] is not called + * when we don't return [TRANSPORT_OK] from [performFullBackup]. + */ + private fun backupError(result: Int): Int { + Log.i(TAG, "Resetting state because of full backup error.") + state = null + return result + } + + fun sendBackupData(numBytes: Int): Int { + val state = this.state + ?: throw AssertionError("Attempted sendBackupData before performFullBackup") + + // check if size fits quota + state.size += numBytes + val quota = plugin.getQuota() + if (state.size > quota) { + Log.w(TAG, "Full backup of additional $numBytes exceeds quota of $quota with ${state.size}.") + return TRANSPORT_QUOTA_EXCEEDED + } + + Log.i(TAG, "Send full backup data of $numBytes bytes.") + + return try { + val payload = IOUtils.readFully(state.inputStream, numBytes) + crypto.encryptSegment(state.outputStream, payload) + TRANSPORT_OK + } catch (e: IOException) { + Log.e(TAG, "Error handling backup data for ${state.packageName}: ", e) + TRANSPORT_ERROR + } + } + + fun clearBackupData(packageInfo: PackageInfo) { + // TODO + } + + fun cancelFullBackup() { + Log.i(TAG, "Cancel full backup") + val state = this.state ?: throw AssertionError("No state when canceling") + clearState() + try { + plugin.cancelFullBackup(state.packageInfo) + } catch (e: IOException) { + Log.w(TAG, "Error cancelling full backup for ${state.packageName}", e) + } + // TODO roll back to the previous known-good archive + } + + fun finishBackup(): Int { + Log.i(TAG, "Finish full backup of ${state!!.packageName}.") + return clearState() + } + + private fun clearState(): Int { + val state = this.state ?: throw AssertionError("Trying to clear empty state.") + return try { + state.outputStream.flush() + closeQuietly(state.outputStream) + closeQuietly(state.inputStream) + closeQuietly(state.inputFileDescriptor) + TRANSPORT_OK + } catch (e: IOException) { + Log.w(TAG, "Error when clearing state", e) + TRANSPORT_ERROR + } finally { + this.state = null + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt new file mode 100644 index 00000000..e1c882d6 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt @@ -0,0 +1,17 @@ +package com.stevesoltys.backup.transport.backup + +import android.content.pm.PackageInfo +import java.io.IOException +import java.io.OutputStream + +interface FullBackupPlugin { + + fun getQuota(): Long + + @Throws(IOException::class) + fun getOutputStream(targetPackage: PackageInfo): OutputStream + + @Throws(IOException::class) + fun cancelFullBackup(targetPackage: PackageInfo) + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt new file mode 100644 index 00000000..78db8dbb --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt @@ -0,0 +1,21 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupDataInput +import android.os.ParcelFileDescriptor +import java.io.FileInputStream +import java.io.InputStream + +/** + * This class exists for easier testing, so we can mock it and return custom data inputs. + */ +class InputFactory { + + fun getBackupDataInput(inputFileDescriptor: ParcelFileDescriptor): BackupDataInput { + return BackupDataInput(inputFileDescriptor.fileDescriptor) + } + + fun getInputStream(inputFileDescriptor: ParcelFileDescriptor): InputStream { + return FileInputStream(inputFileDescriptor.fileDescriptor) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt new file mode 100644 index 00000000..5a14f43a --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt @@ -0,0 +1,200 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.* +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.header.HeaderWriter +import com.stevesoltys.backup.header.Utf8 +import com.stevesoltys.backup.header.VersionHeader +import libcore.io.IoUtils.closeQuietly +import java.io.IOException +import java.util.Base64.getUrlEncoder + +class KVBackupState(internal val packageName: String) + +const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong() + +private val TAG = KVBackup::class.java.simpleName + +class KVBackup( + private val plugin: KVBackupPlugin, + private val inputFactory: InputFactory, + private val headerWriter: HeaderWriter, + private val crypto: Crypto) { + + private var state: KVBackupState? = null + + fun hasState() = state != null + + fun requestBackupTime(): Long { + Log.i(TAG, "Request K/V backup time") + return 0 + } + + fun getQuota(): Long = plugin.getQuota() + + fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int { + val isIncremental = flags and FLAG_INCREMENTAL != 0 + val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0 + val packageName = packageInfo.packageName + + when { + isIncremental -> { + Log.i(TAG, "Performing incremental K/V backup for $packageName") + } + isNonIncremental -> { + Log.i(TAG, "Performing non-incremental K/V backup for $packageName") + } + else -> { + Log.i(TAG, "Performing K/V backup for $packageName") + } + } + + // initialize state + if (this.state != null) throw AssertionError() + this.state = KVBackupState(packageInfo.packageName) + + // check if we have existing data for the given package + val hasDataForPackage = try { + plugin.hasDataForPackage(packageInfo) + } catch (e: IOException) { + Log.e(TAG, "Error checking for existing data for ${packageInfo.packageName}.", e) + return backupError(TRANSPORT_ERROR) + } + if (isIncremental && !hasDataForPackage) { + Log.w(TAG, "Requested incremental, but transport currently stores no data $packageName, requesting non-incremental retry.") + return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED) + } + + // TODO check if package is over-quota + + if (isNonIncremental && hasDataForPackage) { + Log.w(TAG, "Requested non-incremental, deleting existing data.") + try { + clearBackupData(packageInfo) + } catch (e: IOException) { + Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e) + } + } + + // ensure there's a place to store K/V for the given package + try { + plugin.ensureRecordStorageForPackage(packageInfo) + } catch (e: IOException) { + Log.e(TAG, "Error ensuring storage for ${packageInfo.packageName}.", e) + return backupError(TRANSPORT_ERROR) + } + + // parse and store the K/V updates + return storeRecords(packageInfo, data) + } + + private fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int { + // apply the delta operations + for (result in parseBackupStream(data)) { + if (result is Result.Error) { + Log.e(TAG, "Exception reading backup input", result.exception) + return backupError(TRANSPORT_ERROR) + } + val op = (result as Result.Ok).result + try { + if (op.value == null) { + Log.e(TAG, "Deleting record with base64Key ${op.base64Key}") + plugin.deleteRecord(packageInfo, op.base64Key) + } else { + val outputStream = plugin.getOutputStreamForRecord(packageInfo, op.base64Key) + val header = VersionHeader(packageName = packageInfo.packageName, key = op.key) + headerWriter.writeVersion(outputStream, header) + crypto.encryptHeader(outputStream, header) + crypto.encryptSegment(outputStream, op.value) + outputStream.flush() + closeQuietly(outputStream) + } + } catch (e: IOException) { + Log.e(TAG, "Unable to update base64Key file for base64Key ${op.base64Key}", e) + return backupError(TRANSPORT_ERROR) + } + } + return TRANSPORT_OK + } + + /** + * Parses a backup stream into individual key/value operations + */ + private fun parseBackupStream(data: ParcelFileDescriptor): Sequence<Result<KVOperation>> { + val changeSet = inputFactory.getBackupDataInput(data) + + // Each K/V pair in the restore set is kept in its own file, named by the record key. + // Wind through the data file, extracting individual record operations + // and building a sequence of all the updates to apply in this update. + return generateSequence { + // read the next header or end the sequence in case of error or no more headers + try { + if (!changeSet.readNextHeader()) return@generateSequence null // end the sequence + } catch (e: IOException) { + Log.e(TAG, "Error reading next header", e) + return@generateSequence Result.Error(e) + } + // encode key + val key = changeSet.key + val base64Key = getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) + val dataSize = changeSet.dataSize + + // read and encrypt value + val value = if (dataSize >= 0) { + Log.v(TAG, " Delta operation key $key size $dataSize key64 $base64Key") + val bytes = ByteArray(dataSize) + val bytesRead = try { + changeSet.readEntityData(bytes, 0, dataSize) + } catch (e: IOException) { + Log.e(TAG, "Error reading entity data for key $key", e) + return@generateSequence Result.Error(e) + } + if (bytesRead != dataSize) { + Log.w(TAG, "Expecting $dataSize bytes, but only read $bytesRead.") + } + bytes + } else null + // add change operation to the sequence + Result.Ok(KVOperation(key, base64Key, value)) + } + } + + @Throws(IOException::class) + fun clearBackupData(packageInfo: PackageInfo) { + plugin.removeDataOfPackage(packageInfo) + } + + fun finishBackup(): Int { + Log.i(TAG, "Finish K/V Backup of ${state!!.packageName}") + state = null + return TRANSPORT_OK + } + + /** + * Method to reset state, + * because [finishBackup] is not called when we don't return [TRANSPORT_OK]. + */ + private fun backupError(result: Int): Int { + Log.i(TAG, "Resetting state because of K/V Backup error of ${state!!.packageName}") + state = null + return result + } + + private class KVOperation( + internal val key: String, + internal val base64Key: String, + /** + * value is null when this is a deletion operation + */ + internal val value: ByteArray? + ) + + private sealed class Result<out T> { + class Ok<out T>(val result: T) : Result<T>() + class Error(val exception: Exception) : Result<Nothing>() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt new file mode 100644 index 00000000..4e1146ee --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt @@ -0,0 +1,48 @@ +package com.stevesoltys.backup.transport.backup + +import android.content.pm.PackageInfo +import java.io.IOException +import java.io.OutputStream + +interface KVBackupPlugin { + + /** + * Get quota for key/value backups. + */ + fun getQuota(): Long + + /** + * Return true if there are records stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(packageInfo: PackageInfo): Boolean + + /** + * This marks the beginning of a backup operation. + * + * Make sure that there is a place to store K/V pairs for the given package. + * E.g. file-based plugins should a create a directory for the package, if none exists. + */ + @Throws(IOException::class) + fun ensureRecordStorageForPackage(packageInfo: PackageInfo) + + /** + * Return an [OutputStream] for the given package and key + * which will receive the record's encrypted value. + */ + @Throws(IOException::class) + fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream + + /** + * Delete the record for the given package identified by the given key. + */ + @Throws(IOException::class) + fun deleteRecord(packageInfo: PackageInfo, key: String) + + /** + * Remove all data associated with the given package. + */ + @Throws(IOException::class) + fun removeDataOfPackage(packageInfo: PackageInfo) + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt new file mode 100644 index 00000000..2d80d581 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt @@ -0,0 +1,46 @@ +package com.stevesoltys.backup.transport.backup.plugins + +import android.content.pm.PackageManager +import com.stevesoltys.backup.transport.backup.BackupPlugin +import com.stevesoltys.backup.transport.backup.FullBackupPlugin +import com.stevesoltys.backup.transport.backup.KVBackupPlugin +import java.io.IOException + +private const val NO_MEDIA = ".nomedia" + +class DocumentsProviderBackupPlugin( + private val storage: DocumentsStorage, + packageManager: PackageManager) : BackupPlugin { + + override val kvBackupPlugin: KVBackupPlugin by lazy { + DocumentsProviderKVBackup(storage) + } + + override val fullBackupPlugin: FullBackupPlugin by lazy { + DocumentsProviderFullBackup(storage) + } + + @Throws(IOException::class) + override fun initializeDevice() { + // get or create root backup dir + val rootDir = storage.rootBackupDir ?: throw IOException() + + // create .nomedia file to prevent Android's MediaScanner from trying to index the backup + rootDir.createOrGetFile(NO_MEDIA) + + // create backup folders + val kvDir = storage.defaultKvBackupDir + val fullDir = storage.defaultFullBackupDir + + // wipe existing data + kvDir?.deleteContents() + fullDir?.deleteContents() + } + + override val providerPackageName: String? by lazy { + val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null + val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null + providerInfo.packageName + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt new file mode 100644 index 00000000..2116b926 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt @@ -0,0 +1,33 @@ +package com.stevesoltys.backup.transport.backup.plugins + +import android.content.pm.PackageInfo +import android.util.Log +import com.stevesoltys.backup.transport.backup.DEFAULT_QUOTA_FULL_BACKUP +import com.stevesoltys.backup.transport.backup.FullBackupPlugin +import java.io.IOException +import java.io.OutputStream + +private val TAG = DocumentsProviderFullBackup::class.java.simpleName + +class DocumentsProviderFullBackup( + private val storage: DocumentsStorage) : FullBackupPlugin { + + override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP + + @Throws(IOException::class) + override fun getOutputStream(targetPackage: PackageInfo): OutputStream { + // TODO test file-size after overwriting bigger file + val file = storage.defaultFullBackupDir?.createOrGetFile(targetPackage.packageName) + ?: throw IOException() + return storage.getOutputStream(file) + } + + @Throws(IOException::class) + override fun cancelFullBackup(targetPackage: PackageInfo) { + val packageName = targetPackage.packageName + Log.i(TAG, "Deleting $packageName due to canceled backup...") + val file = storage.defaultFullBackupDir?.findFile(packageName) ?: return + if (!file.delete()) throw IOException("Failed to delete $packageName") + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt new file mode 100644 index 00000000..dd6a08ca --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt @@ -0,0 +1,54 @@ +package com.stevesoltys.backup.transport.backup.plugins + +import android.content.pm.PackageInfo +import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.backup.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP +import com.stevesoltys.backup.transport.backup.KVBackupPlugin +import java.io.IOException +import java.io.OutputStream + +class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBackupPlugin { + + private var packageFile: DocumentFile? = null + + override fun getQuota(): Long = DEFAULT_QUOTA_KEY_VALUE_BACKUP + + @Throws(IOException::class) + override fun hasDataForPackage(packageInfo: PackageInfo): Boolean { + val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName) + ?: return false + return packageFile.listFiles().isNotEmpty() + } + + @Throws(IOException::class) + override fun ensureRecordStorageForPackage(packageInfo: PackageInfo) { + // remember package file for subsequent operations + packageFile = storage.getOrCreateKVBackupDir().createOrGetDirectory(packageInfo.packageName) + } + + @Throws(IOException::class) + override fun removeDataOfPackage(packageInfo: PackageInfo) { + // we cannot use the cached this.packageFile here, + // because this can be called before [ensureRecordStorageForPackage] + val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName) ?: return + packageFile.delete() + } + + @Throws(IOException::class) + override fun deleteRecord(packageInfo: PackageInfo, key: String) { + val packageFile = this.packageFile ?: throw AssertionError() + packageFile.assertRightFile(packageInfo) + val keyFile = packageFile.findFile(key) ?: return + keyFile.delete() + } + + @Throws(IOException::class) + override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream { + val packageFile = this.packageFile ?: throw AssertionError() + packageFile.assertRightFile(packageInfo) + // TODO check what happens if we overwrite a bigger file + val keyFile = packageFile.createOrGetFile(key) + return storage.getOutputStream(keyFile) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt new file mode 100644 index 00000000..20bf10df --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt @@ -0,0 +1,123 @@ +package com.stevesoltys.backup.transport.backup.plugins + +import android.content.Context +import android.content.pm.PackageInfo +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +const val DIRECTORY_FULL_BACKUP = "full" +const val DIRECTORY_KEY_VALUE_BACKUP = "kv" +private const val ROOT_DIR_NAME = ".AndroidBackup" +private const val MIME_TYPE = "application/octet-stream" + +private val TAG = DocumentsStorage::class.java.simpleName + +class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String) { + + private val contentResolver = context.contentResolver + + internal val rootBackupDir: DocumentFile? by lazy { + val folderUri = parentFolder ?: return@lazy null + val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError() + try { + parent.createOrGetDirectory(ROOT_DIR_NAME) + } catch (e: IOException) { + Log.e(TAG, "Error creating root backup dir.", e) + null + } + } + + private val deviceDir: DocumentFile? by lazy { + try { + rootBackupDir?.createOrGetDirectory(deviceName) + } catch (e: IOException) { + Log.e(TAG, "Error creating current restore set dir.", e) + null + } + } + + private val defaultSetDir: DocumentFile? by lazy { + val currentSetName = DEFAULT_RESTORE_SET_TOKEN.toString() + try { + deviceDir?.createOrGetDirectory(currentSetName) + } catch (e: IOException) { + Log.e(TAG, "Error creating current restore set dir.", e) + null + } + } + + val defaultFullBackupDir: DocumentFile? by lazy { + try { + defaultSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP) + } catch (e: IOException) { + Log.e(TAG, "Error creating full backup dir.", e) + null + } + } + + val defaultKvBackupDir: DocumentFile? by lazy { + try { + defaultSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP) + } catch (e: IOException) { + Log.e(TAG, "Error creating K/V backup dir.", e) + null + } + } + + private fun getSetDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? { + if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultSetDir + return deviceDir?.findFile(token.toString()) + } + + fun getKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? { + if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException() + return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP) + } + + @Throws(IOException::class) + fun getOrCreateKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile { + if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException() + val setDir = getSetDir(token) ?: throw IOException() + return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP) + } + + fun getFullBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? { + if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultFullBackupDir ?: throw IOException() + return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP) + } + + @Throws(IOException::class) + fun getInputStream(file: DocumentFile): InputStream { + return contentResolver.openInputStream(file.uri) ?: throw IOException() + } + + @Throws(IOException::class) + fun getOutputStream(file: DocumentFile): OutputStream { + return contentResolver.openOutputStream(file.uri) ?: throw IOException() + } + +} + +@Throws(IOException::class) +fun DocumentFile.createOrGetFile(name: String, mimeType: String = MIME_TYPE): DocumentFile { + return findFile(name) ?: createFile(mimeType, name) ?: throw IOException() +} + +@Throws(IOException::class) +fun DocumentFile.createOrGetDirectory(name: String): DocumentFile { + return findFile(name) ?: createDirectory(name) ?: throw IOException() +} + +@Throws(IOException::class) +fun DocumentFile.deleteContents() { + for (file in listFiles()) file.delete() +} + +fun DocumentFile.assertRightFile(packageInfo: PackageInfo) { + if (name != packageInfo.packageName) throw AssertionError() +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java deleted file mode 100644 index 92715d61..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.stevesoltys.backup.transport.component; - -import android.content.pm.PackageInfo; -import android.os.ParcelFileDescriptor; - -/** - * @author Steve Soltys - */ -public interface BackupComponent { - - String currentDestinationString(); - - String dataManagementLabel(); - - int initializeDevice(); - - int clearBackupData(PackageInfo packageInfo); - - int finishBackup(); - - int performIncrementalBackup(PackageInfo targetPackage, ParcelFileDescriptor data, int flags); - - int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor); - - int checkFullBackupSize(long size); - - int sendBackupData(int numBytes); - - void cancelFullBackup(); - - long getBackupQuota(String packageName, boolean fullBackup); - - long requestBackupTime(); - - long requestFullBackupTime(); - - void backupFinished(); -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java deleted file mode 100644 index 08f866a0..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.stevesoltys.backup.transport.component; - -import android.app.backup.RestoreDescription; -import android.app.backup.RestoreSet; -import android.content.pm.PackageInfo; -import android.net.Uri; -import android.os.ParcelFileDescriptor; - -/** - * @author Steve Soltys - */ -public interface RestoreComponent { - - void prepareRestore(String password, Uri fileUri); - - int startRestore(long token, PackageInfo[] packages); - - RestoreDescription nextRestorePackage(); - - int getRestoreData(ParcelFileDescriptor outputFileDescriptor); - - int getNextFullRestoreDataChunk(ParcelFileDescriptor socket); - - int abortFullRestore(); - - long getCurrentRestoreSet(); - - void finishRestore(); - - RestoreSet[] getAvailableRestoreSets(); -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java deleted file mode 100644 index a951d50a..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java +++ /dev/null @@ -1,367 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -import android.app.backup.BackupDataInput; -import android.content.Context; -import android.content.pm.PackageInfo; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Base64; -import android.util.Log; - -import com.stevesoltys.backup.security.CipherUtil; -import com.stevesoltys.backup.security.KeyGenerator; -import com.stevesoltys.backup.transport.component.BackupComponent; - -import org.apache.commons.io.IOUtils; - -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.text.SimpleDateFormat; -import java.util.Arrays; -import java.util.Date; -import java.util.Locale; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; - -import libcore.io.IoUtils; - -import static android.app.backup.BackupTransport.FLAG_INCREMENTAL; -import static android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL; -import static android.app.backup.BackupTransport.TRANSPORT_ERROR; -import static android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED; -import static android.app.backup.BackupTransport.TRANSPORT_OK; -import static android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED; -import static android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED; -import static android.provider.DocumentsContract.buildDocumentUriUsingTree; -import static android.provider.DocumentsContract.createDocument; -import static android.provider.DocumentsContract.getTreeDocumentId; -import static com.stevesoltys.backup.activity.MainActivityController.DOCUMENT_MIME_TYPE; -import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupFolderUri; -import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_BACKUP_QUOTA; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY; -import static java.util.Objects.requireNonNull; - -/** - * @author Steve Soltys - */ -public class ContentProviderBackupComponent implements BackupComponent { - - private static final String TAG = ContentProviderBackupComponent.class.getSimpleName(); - - private static final String DOCUMENT_SUFFIX = "yyyy-MM-dd_HH_mm_ss"; - - private static final String DESTINATION_DESCRIPTION = "Backing up to zip file"; - - private static final String TRANSPORT_DATA_MANAGEMENT_LABEL = ""; - - private static final int INITIAL_BUFFER_SIZE = 512; - - private final Context context; - - private ContentProviderBackupState backupState; - - public ContentProviderBackupComponent(Context context) { - this.context = context; - } - - @Override - public void cancelFullBackup() { - clearBackupState(false); - } - - @Override - public int checkFullBackupSize(long size) { - int result = TRANSPORT_OK; - - if (size <= 0) { - result = TRANSPORT_PACKAGE_REJECTED; - - } else if (size > DEFAULT_BACKUP_QUOTA) { - result = TRANSPORT_QUOTA_EXCEEDED; - } - - return result; - } - - @Override - public int clearBackupData(PackageInfo packageInfo) { - return TRANSPORT_OK; - } - - @Override - public String currentDestinationString() { - return DESTINATION_DESCRIPTION; - } - - @Override - public String dataManagementLabel() { - return TRANSPORT_DATA_MANAGEMENT_LABEL; - } - - @Override - public int finishBackup() { - return clearBackupState(false); - } - - @Override - public long getBackupQuota(String packageName, boolean fullBackup) { - return DEFAULT_BACKUP_QUOTA; - } - - @Override - public int initializeDevice() { - return TRANSPORT_OK; - } - - @Override - public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) { - - if (backupState != null && backupState.getInputFileDescriptor() != null) { - Log.e(TAG, "Attempt to initiate full backup while one is in progress"); - return TRANSPORT_ERROR; - } - - try { - initializeBackupState(); - backupState.setPackageName(targetPackage.packageName); - - backupState.setInputFileDescriptor(fileDescriptor); - backupState.setInputStream(new FileInputStream(fileDescriptor.getFileDescriptor())); - backupState.setBytesTransferred(0); - - Cipher cipher = CipherUtil.startEncrypt(backupState.getSecretKey(), backupState.getSalt()); - backupState.setCipher(cipher); - - ZipEntry zipEntry = new ZipEntry(DEFAULT_FULL_BACKUP_DIRECTORY + backupState.getPackageName()); - backupState.getOutputStream().putNextEntry(zipEntry); - - } catch (Exception ex) { - Log.e(TAG, "Error creating backup file for " + targetPackage.packageName + ": ", ex); - clearBackupState(true); - return TRANSPORT_ERROR; - } - - return TRANSPORT_OK; - } - - @Override - public int performIncrementalBackup(PackageInfo packageInfo, ParcelFileDescriptor data, int flags) { - boolean isIncremental = (flags & FLAG_INCREMENTAL) != 0; - if (isIncremental) { - Log.w(TAG, "Can not handle incremental backup. Requesting non-incremental for " + packageInfo.packageName); - return TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED; - } - - boolean isNonIncremental = (flags & FLAG_NON_INCREMENTAL) != 0; - if (isNonIncremental) { - Log.i(TAG, "Performing non-incremental backup for " + packageInfo.packageName); - } else { - Log.i(TAG, "Performing backup for " + packageInfo.packageName); - } - - BackupDataInput backupDataInput = new BackupDataInput(data.getFileDescriptor()); - - try { - initializeBackupState(); - backupState.setPackageName(packageInfo.packageName); - - return transferIncrementalBackupData(backupDataInput); - - } catch (Exception ex) { - Log.e(TAG, "Error reading backup input: ", ex); - return TRANSPORT_ERROR; - } - } - - @Override - public long requestBackupTime() { - return 0; - } - - @Override - public long requestFullBackupTime() { - return 0; - } - - @Override - public int sendBackupData(int numBytes) { - - if (backupState == null) { - Log.e(TAG, "Attempted sendBackupData() before performFullBackup()"); - return TRANSPORT_ERROR; - } - - long bytesTransferred = backupState.getBytesTransferred() + numBytes; - - if (bytesTransferred > DEFAULT_BACKUP_QUOTA) { - return TRANSPORT_QUOTA_EXCEEDED; - } - - InputStream inputStream = backupState.getInputStream(); - ZipOutputStream outputStream = backupState.getOutputStream(); - - try { - byte[] payload = IOUtils.readFully(inputStream, numBytes); - - if (backupState.getCipher() != null) { - payload = backupState.getCipher().update(payload); - } - - outputStream.write(payload, 0, numBytes); - backupState.setBytesTransferred(bytesTransferred); - - } catch (Exception ex) { - Log.e(TAG, "Error handling backup data for " + backupState.getPackageName() + ": ", ex); - return TRANSPORT_ERROR; - } - return TRANSPORT_OK; - } - - private int transferIncrementalBackupData(BackupDataInput backupDataInput) throws IOException { - ZipOutputStream outputStream = backupState.getOutputStream(); - - int bufferSize = INITIAL_BUFFER_SIZE; - byte[] buffer = new byte[bufferSize]; - - while (backupDataInput.readNextHeader()) { - String chunkFileName = Base64.encodeToString(backupDataInput.getKey().getBytes(), Base64.DEFAULT); - int dataSize = backupDataInput.getDataSize(); - - if (dataSize >= 0) { - ZipEntry zipEntry = new ZipEntry(DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + - backupState.getPackageName() + "/" + chunkFileName); - outputStream.putNextEntry(zipEntry); - - if (dataSize > bufferSize) { - bufferSize = dataSize; - buffer = new byte[bufferSize]; - } - - backupDataInput.readEntityData(buffer, 0, dataSize); - - try { - if (backupState.getSecretKey() != null) { - byte[] payload = Arrays.copyOfRange(buffer, 0, dataSize); - SecretKey secretKey = backupState.getSecretKey(); - byte[] salt = backupState.getSalt(); - - outputStream.write(CipherUtil.encrypt(payload, secretKey, salt)); - - } else { - outputStream.write(buffer, 0, dataSize); - } - - } catch (Exception ex) { - Log.e(TAG, "Error performing incremental backup for " + backupState.getPackageName() + ": ", ex); - clearBackupState(true); - return TRANSPORT_ERROR; - } - } - } - - return TRANSPORT_OK; - } - - @Override - public void backupFinished() { - clearBackupState(true); - } - - private void initializeBackupState() throws Exception { - if (backupState == null) { - backupState = new ContentProviderBackupState(); - } - - if (backupState.getOutputStream() == null) { - initializeOutputStream(); - - ZipEntry saltZipEntry = new ZipEntry(ContentProviderBackupConstants.SALT_FILE_PATH); - backupState.getOutputStream().putNextEntry(saltZipEntry); - backupState.getOutputStream().write(backupState.getSalt()); - backupState.getOutputStream().closeEntry(); - - String password = requireNonNull(getBackupPassword(context)); - backupState.setSecretKey(KeyGenerator.generate(password, backupState.getSalt())); - } - } - - private void initializeOutputStream() throws IOException { - Uri folderUri = getBackupFolderUri(context); - // TODO notify about failure with notification - Uri fileUri = createBackupFile(folderUri); - - ParcelFileDescriptor outputFileDescriptor = context.getContentResolver().openFileDescriptor(fileUri, "w"); - if (outputFileDescriptor == null) throw new IOException(); - backupState.setOutputFileDescriptor(outputFileDescriptor); - - FileOutputStream fileOutputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor()); - ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream); - backupState.setOutputStream(zipOutputStream); - } - - private Uri createBackupFile(Uri folderUri) throws IOException { - Uri documentUri = buildDocumentUriUsingTree(folderUri, getTreeDocumentId(folderUri)); - try { - Uri fileUri = createDocument(context.getContentResolver(), documentUri, DOCUMENT_MIME_TYPE, getBackupFileName()); - if (fileUri == null) throw new IOException(); - return fileUri; - - } catch (SecurityException e) { - // happens when folder was deleted and thus Uri permission don't exist anymore - throw new IOException(e); - } - } - - private String getBackupFileName() { - SimpleDateFormat dateFormat = new SimpleDateFormat(DOCUMENT_SUFFIX, Locale.US); - String date = dateFormat.format(new Date()); - return "backup-" + date; - } - - private int clearBackupState(boolean closeFile) { - - if (backupState == null) { - return TRANSPORT_OK; - } - - try { - IoUtils.closeQuietly(backupState.getInputFileDescriptor()); - backupState.setInputFileDescriptor(null); - - ZipOutputStream outputStream = backupState.getOutputStream(); - - if (outputStream != null) { - - if (backupState.getCipher() != null) { - outputStream.write(backupState.getCipher().doFinal()); - backupState.setCipher(null); - } - - outputStream.closeEntry(); - } - if (closeFile) { - Log.d(TAG, "Closing backup file..."); - if (outputStream != null) { - outputStream.finish(); - outputStream.close(); - } - - IoUtils.closeQuietly(backupState.getOutputFileDescriptor()); - backupState = null; - } - - } catch (Exception ex) { - Log.e(TAG, "Error cancelling full backup: ", ex); - return TRANSPORT_ERROR; - } - - return TRANSPORT_OK; - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java deleted file mode 100644 index 4020f9d9..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -/** - * @author Steve Soltys - */ -public interface ContentProviderBackupConstants { - - String SALT_FILE_PATH = "salt"; - - String DEFAULT_FULL_BACKUP_DIRECTORY = "full/"; - - String DEFAULT_INCREMENTAL_BACKUP_DIRECTORY = "incr/"; - - long DEFAULT_BACKUP_QUOTA = Long.MAX_VALUE; - -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java deleted file mode 100644 index 0adc8d17..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -import android.os.ParcelFileDescriptor; - -import java.io.InputStream; -import java.security.SecureRandom; -import java.util.zip.ZipOutputStream; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; - -/** - * @author Steve Soltys - */ -class ContentProviderBackupState { - - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - - private ParcelFileDescriptor inputFileDescriptor; - - private ParcelFileDescriptor outputFileDescriptor; - - private InputStream inputStream; - - private ZipOutputStream outputStream; - - private Cipher cipher; - - private long bytesTransferred; - - private String packageName; - - private byte[] salt; - - private SecretKey secretKey; - - ContentProviderBackupState() { - salt = new byte[16]; - SECURE_RANDOM.nextBytes(salt); - } - - long getBytesTransferred() { - return bytesTransferred; - } - - void setBytesTransferred(long bytesTransferred) { - this.bytesTransferred = bytesTransferred; - } - - Cipher getCipher() { - return cipher; - } - - void setCipher(Cipher cipher) { - this.cipher = cipher; - } - - ParcelFileDescriptor getInputFileDescriptor() { - return inputFileDescriptor; - } - - void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) { - this.inputFileDescriptor = inputFileDescriptor; - } - - InputStream getInputStream() { - return inputStream; - } - - void setInputStream(InputStream inputStream) { - this.inputStream = inputStream; - } - - ParcelFileDescriptor getOutputFileDescriptor() { - return outputFileDescriptor; - } - - void setOutputFileDescriptor(ParcelFileDescriptor outputFileDescriptor) { - this.outputFileDescriptor = outputFileDescriptor; - } - - ZipOutputStream getOutputStream() { - return outputStream; - } - - void setOutputStream(ZipOutputStream outputStream) { - this.outputStream = outputStream; - } - - String getPackageName() { - return packageName; - } - - void setPackageName(String packageName) { - this.packageName = packageName; - } - - byte[] getSalt() { - return salt; - } - - SecretKey getSecretKey() { - return secretKey; - } - - void setSecretKey(SecretKey secretKey) { - this.secretKey = secretKey; - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java deleted file mode 100644 index f983bed9..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java +++ /dev/null @@ -1,360 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -import android.annotation.Nullable; -import android.app.backup.BackupDataOutput; -import android.app.backup.RestoreDescription; -import android.app.backup.RestoreSet; -import android.content.ContentResolver; -import android.content.Context; -import android.content.pm.PackageInfo; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Base64; -import android.util.Log; - -import com.android.internal.util.Preconditions; -import com.stevesoltys.backup.security.CipherUtil; -import com.stevesoltys.backup.security.KeyGenerator; -import com.stevesoltys.backup.transport.component.RestoreComponent; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.Optional; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -import javax.crypto.SecretKey; - -import libcore.io.IoUtils; -import libcore.io.Streams; - -import static android.app.backup.BackupTransport.NO_MORE_DATA; -import static android.app.backup.BackupTransport.TRANSPORT_ERROR; -import static android.app.backup.BackupTransport.TRANSPORT_OK; -import static android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED; -import static android.app.backup.RestoreDescription.TYPE_FULL_STREAM; -import static android.app.backup.RestoreDescription.TYPE_KEY_VALUE; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY; -import static java.util.Objects.requireNonNull; - -/** - * @author Steve Soltys - */ -public class ContentProviderRestoreComponent implements RestoreComponent { - - private static final String TAG = ContentProviderRestoreComponent.class.getName(); - - private static final int DEFAULT_RESTORE_SET = 1; - - private static final int DEFAULT_BUFFER_SIZE = 2048; - - @Nullable - private String password; - @Nullable - private Uri fileUri; - - private ContentProviderRestoreState restoreState; - - private final Context context; - - public ContentProviderRestoreComponent(Context context) { - this.context = context; - } - - @Override - public void prepareRestore(String password, Uri fileUri) { - this.password = password; - this.fileUri = fileUri; - } - - @Override - public int startRestore(long token, PackageInfo[] packages) { - restoreState = new ContentProviderRestoreState(); - restoreState.setPackages(packages); - restoreState.setPackageIndex(-1); - - String password = requireNonNull(this.password); - - if (!password.isEmpty()) { - try { - ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor(); - ZipInputStream inputStream = buildInputStream(inputFileDescriptor); - seekToEntry(inputStream, ContentProviderBackupConstants.SALT_FILE_PATH); - - restoreState.setSalt(Streams.readFullyNoClose(inputStream)); - restoreState.setSecretKey(KeyGenerator.generate(password, restoreState.getSalt())); - - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(inputStream); - - } catch (Exception ex) { - Log.e(TAG, "Salt not found", ex); - } - } - - try { - List<ZipEntry> zipEntries = new LinkedList<>(); - - ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor(); - ZipInputStream inputStream = buildInputStream(inputFileDescriptor); - - ZipEntry zipEntry; - while ((zipEntry = inputStream.getNextEntry()) != null) { - zipEntries.add(zipEntry); - inputStream.closeEntry(); - } - - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(inputStream); - - restoreState.setZipEntries(zipEntries); - - } catch (Exception ex) { - Log.e(TAG, "Error while caching zip entries", ex); - } - - return TRANSPORT_OK; - } - - @Override - public RestoreDescription nextRestorePackage() { - Preconditions.checkNotNull(restoreState, "startRestore() not called"); - - int packageIndex = restoreState.getPackageIndex(); - PackageInfo[] packages = restoreState.getPackages(); - - while (++packageIndex < packages.length) { - restoreState.setPackageIndex(packageIndex); - String name = packages[packageIndex].packageName; - - if (containsPackageFile(DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + name)) { - restoreState.setRestoreType(TYPE_KEY_VALUE); - return new RestoreDescription(name, restoreState.getRestoreType()); - - } else if (containsPackageFile(DEFAULT_FULL_BACKUP_DIRECTORY + name)) { - restoreState.setRestoreType(TYPE_FULL_STREAM); - return new RestoreDescription(name, restoreState.getRestoreType()); - } - } - return RestoreDescription.NO_MORE_PACKAGES; - } - - private boolean containsPackageFile(String fileName) { - return restoreState.getZipEntries().stream() - .anyMatch(zipEntry -> zipEntry.getName().startsWith(fileName)); - } - - @Override - public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) { - Preconditions.checkState(restoreState != null, "startRestore() not called"); - Preconditions.checkState(restoreState.getPackageIndex() >= 0, "nextRestorePackage() not called"); - Preconditions.checkState(restoreState.getRestoreType() == TYPE_KEY_VALUE, - "getRestoreData() for non-key/value dataset"); - - PackageInfo packageInfo = restoreState.getPackages()[restoreState.getPackageIndex()]; - - try { - return transferIncrementalRestoreData(packageInfo.packageName, outputFileDescriptor); - - } catch (Exception ex) { - Log.e(TAG, "Unable to read backup records: ", ex); - return TRANSPORT_ERROR; - } - } - - private int transferIncrementalRestoreData(String packageName, ParcelFileDescriptor outputFileDescriptor) - throws Exception { - - ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor(); - ZipInputStream inputStream = buildInputStream(inputFileDescriptor); - BackupDataOutput backupDataOutput = new BackupDataOutput(outputFileDescriptor.getFileDescriptor()); - - Optional<ZipEntry> zipEntryOptional = seekToEntry(inputStream, - DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName); - - while (zipEntryOptional.isPresent()) { - String fileName = new File(zipEntryOptional.get().getName()).getName(); - String blobKey = new String(Base64.decode(fileName, Base64.DEFAULT)); - - byte[] backupData = readBackupData(inputStream); - backupDataOutput.writeEntityHeader(blobKey, backupData.length); - backupDataOutput.writeEntityData(backupData, backupData.length); - inputStream.closeEntry(); - - zipEntryOptional = seekToEntry(inputStream, DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName); - } - - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(outputFileDescriptor); - return TRANSPORT_OK; - } - - private byte[] readBackupData(ZipInputStream inputStream) throws Exception { - byte[] backupData = Streams.readFullyNoClose(inputStream); - SecretKey secretKey = restoreState.getSecretKey(); - byte[] initializationVector = restoreState.getSalt(); - - if (secretKey != null) { - backupData = CipherUtil.decrypt(backupData, secretKey, initializationVector); - } - - return backupData; - } - - @Override - public int getNextFullRestoreDataChunk(ParcelFileDescriptor outputFileDescriptor) { - Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM, - "Asked for full restore data for non-stream package"); - - ParcelFileDescriptor inputFileDescriptor = restoreState.getInputFileDescriptor(); - - if (inputFileDescriptor == null) { - String name = restoreState.getPackages()[restoreState.getPackageIndex()].packageName; - - try { - inputFileDescriptor = buildInputFileDescriptor(); - restoreState.setInputFileDescriptor(inputFileDescriptor); - - ZipInputStream inputStream = buildInputStream(inputFileDescriptor); - restoreState.setInputStream(inputStream); - - if (!seekToEntry(inputStream, DEFAULT_FULL_BACKUP_DIRECTORY + name).isPresent()) { - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(outputFileDescriptor); - return TRANSPORT_PACKAGE_REJECTED; - } - - } catch (IOException ex) { - Log.e(TAG, "Unable to read archive for " + name, ex); - - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(outputFileDescriptor); - return TRANSPORT_PACKAGE_REJECTED; - } - } - - return transferFullRestoreData(outputFileDescriptor); - } - - private int transferFullRestoreData(ParcelFileDescriptor outputFileDescriptor) { - ZipInputStream inputStream = restoreState.getInputStream(); - OutputStream outputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor()); - - byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; - int bytesRead = NO_MORE_DATA; - - try { - bytesRead = inputStream.read(buffer); - - if (bytesRead <= 0) { - bytesRead = NO_MORE_DATA; - - if (restoreState.getCipher() != null) { - buffer = restoreState.getCipher().doFinal(); - bytesRead = buffer.length; - - outputStream.write(buffer, 0, bytesRead); - restoreState.setCipher(null); - } - - } else { - if (restoreState.getSecretKey() != null) { - SecretKey secretKey = restoreState.getSecretKey(); - byte[] salt = restoreState.getSalt(); - - if (restoreState.getCipher() == null) { - restoreState.setCipher(CipherUtil.startDecrypt(secretKey, salt)); - } - - buffer = restoreState.getCipher().update(Arrays.copyOfRange(buffer, 0, bytesRead)); - bytesRead = buffer.length; - } - - outputStream.write(buffer, 0, bytesRead); - } - - } catch (Exception e) { - Log.e(TAG, "Exception while streaming restore data: ", e); - return TRANSPORT_ERROR; - - } finally { - if (bytesRead == NO_MORE_DATA) { - - if (restoreState.getInputFileDescriptor() != null) { - IoUtils.closeQuietly(restoreState.getInputFileDescriptor()); - } - - restoreState.setInputFileDescriptor(null); - restoreState.setInputStream(null); - } - - IoUtils.closeQuietly(outputFileDescriptor); - } - - return bytesRead; - } - - @Override - public int abortFullRestore() { - resetFullRestoreState(); - return TRANSPORT_OK; - } - - @Override - public long getCurrentRestoreSet() { - return DEFAULT_RESTORE_SET; - } - - @Override - public void finishRestore() { - if (restoreState.getRestoreType() == TYPE_FULL_STREAM) { - resetFullRestoreState(); - } - - restoreState = null; - } - - @Override - public RestoreSet[] getAvailableRestoreSets() { - return new RestoreSet[]{new RestoreSet("Local disk image", "flash", DEFAULT_RESTORE_SET)}; - } - - private void resetFullRestoreState() { - Preconditions.checkNotNull(restoreState); - Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM); - - IoUtils.closeQuietly(restoreState.getInputFileDescriptor()); - restoreState = null; - } - - private ParcelFileDescriptor buildInputFileDescriptor() throws FileNotFoundException { - ContentResolver contentResolver = context.getContentResolver(); - return contentResolver.openFileDescriptor(requireNonNull(fileUri), "r"); - } - - private ZipInputStream buildInputStream(ParcelFileDescriptor inputFileDescriptor) { - FileInputStream fileInputStream = new FileInputStream(inputFileDescriptor.getFileDescriptor()); - return new ZipInputStream(fileInputStream); - } - - private Optional<ZipEntry> seekToEntry(ZipInputStream inputStream, String entryPath) throws IOException { - ZipEntry zipEntry; - while ((zipEntry = inputStream.getNextEntry()) != null) { - - if (zipEntry.getName().startsWith(entryPath)) { - return Optional.of(zipEntry); - } - inputStream.closeEntry(); - } - - return Optional.empty(); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java deleted file mode 100644 index a7b6e3c6..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -import android.content.pm.PackageInfo; -import android.os.ParcelFileDescriptor; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; -import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -/** - * @author Steve Soltys - */ -class ContentProviderRestoreState { - - private ParcelFileDescriptor inputFileDescriptor; - - private PackageInfo[] packages; - - private int packageIndex; - - private int restoreType; - - private ZipInputStream inputStream; - - private Cipher cipher; - - private byte[] salt; - - private SecretKey secretKey; - - private List<ZipEntry> zipEntries; - - Cipher getCipher() { - return cipher; - } - - ParcelFileDescriptor getInputFileDescriptor() { - return inputFileDescriptor; - } - - void setCipher(Cipher cipher) { - this.cipher = cipher; - } - - void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) { - this.inputFileDescriptor = inputFileDescriptor; - } - - ZipInputStream getInputStream() { - return inputStream; - } - - void setInputStream(ZipInputStream inputStream) { - this.inputStream = inputStream; - } - - int getPackageIndex() { - return packageIndex; - } - - void setPackageIndex(int packageIndex) { - this.packageIndex = packageIndex; - } - - PackageInfo[] getPackages() { - return packages; - } - - void setPackages(PackageInfo[] packages) { - this.packages = packages; - } - - int getRestoreType() { - return restoreType; - } - - void setRestoreType(int restoreType) { - this.restoreType = restoreType; - } - - byte[] getSalt() { - return salt; - } - - void setSalt(byte[] salt) { - this.salt = salt; - } - - public SecretKey getSecretKey() { - return secretKey; - } - - public void setSecretKey(SecretKey secretKey) { - this.secretKey = secretKey; - } - - public List<ZipEntry> getZipEntries() { - return zipEntries; - } - - public void setZipEntries(List<ZipEntry> zipEntries) { - this.zipEntries = zipEntries; - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt new file mode 100644 index 00000000..66588567 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt @@ -0,0 +1,172 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupTransport.* +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.header.HeaderReader +import com.stevesoltys.backup.header.UnsupportedVersionException +import libcore.io.IoUtils.closeQuietly +import java.io.EOFException +import java.io.IOException +import java.io.InputStream + +private class FullRestoreState( + internal val token: Long, + internal val packageInfo: PackageInfo) { + internal var inputStream: InputStream? = null +} + +private val TAG = FullRestore::class.java.simpleName + +internal class FullRestore( + private val plugin: FullRestorePlugin, + private val outputFactory: OutputFactory, + private val headerReader: HeaderReader, + private val crypto: Crypto) { + + private var state: FullRestoreState? = null + + fun hasState() = state != null + + /** + * Return true if there is data stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { + return plugin.hasDataForPackage(token, packageInfo) + } + + /** + * This prepares to restore the given package from the given restore token. + * + * It is possible that the system decides to not restore the package. + * Then a new state will be initialized right away without calling other methods. + */ + fun initializeState(token: Long, packageInfo: PackageInfo) { + state = FullRestoreState(token, packageInfo) + } + + /** + * Ask the transport to provide data for the "current" package being restored. + * + * The transport writes some data to the socket supplied to this call, + * and returns the number of bytes written. + * The system will then read that many bytes + * and stream them to the application's agent for restore, + * then will call this method again to receive the next chunk of the archive. + * This sequence will be repeated until the transport returns zero + * indicating that all of the package's data has been delivered + * (or returns a negative value indicating a hard error condition at the transport level). + * + * The transport should always close this socket when returning from this method. + * Do not cache this socket across multiple calls or you may leak file descriptors. + * + * @param socket The file descriptor for delivering the streamed archive. + * The transport must close this socket in all cases when returning from this method. + * @return [NO_MORE_DATA] when no more data for the current package is available. + * A positive value indicates the presence of that many bytes to be delivered to the app. + * A value of zero indicates that no data was deliverable at this time, + * but the restore is still running and the caller should retry. + * [TRANSPORT_PACKAGE_REJECTED] means that the package's restore operation should be aborted, + * but that the transport itself is still in a good state + * and so a multiple-package restore sequence can still be continued. + * Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition + * that aborts all further restore operations on the current dataset. + */ + fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int { + Log.i(TAG, "Get next full restore data chunk.") + val state = this.state ?: throw IllegalStateException() + val packageName = state.packageInfo.packageName + + if (state.inputStream == null) { + Log.i(TAG, "First Chunk, initializing package input stream.") + try { + val inputStream = plugin.getInputStreamForPackage(state.token, state.packageInfo) + val version = headerReader.readVersion(inputStream) + crypto.decryptHeader(inputStream, version, packageName) + state.inputStream = inputStream + } catch (e: IOException) { + Log.w(TAG, "Error getting input stream for $packageName", e) + return TRANSPORT_PACKAGE_REJECTED + } catch (e: SecurityException) { + Log.e(TAG, "Security Exception while getting input stream for $packageName", e) + return TRANSPORT_ERROR + } catch (e: UnsupportedVersionException) { + Log.e(TAG, "Backup data for $packageName uses unsupported version ${e.version}.", e) + return TRANSPORT_PACKAGE_REJECTED + } + } + + return readInputStream(socket) + } + + private fun readInputStream(socket: ParcelFileDescriptor): Int = socket.use { fileDescriptor -> + val state = this.state ?: throw IllegalStateException() + val packageName = state.packageInfo.packageName + val inputStream = state.inputStream ?: throw IllegalStateException() + val outputStream = outputFactory.getOutputStream(fileDescriptor) + + try { + // read segment from input stream and decrypt it + val decrypted = try { + crypto.decryptSegment(inputStream) + } catch (e: EOFException) { + Log.i(TAG, " EOF") + // close input stream here as we won't need it anymore + closeQuietly(inputStream) + return NO_MORE_DATA + } + + // write decrypted segment to output stream (without header) + outputStream.write(decrypted) + // return number of written bytes + return decrypted.size + } catch (e: IOException) { + Log.w(TAG, "Error processing stream for package $packageName.", e) + closeQuietly(inputStream) + return TRANSPORT_PACKAGE_REJECTED + } finally { + closeQuietly(outputStream) + } + } + + /** + * If the OS encounters an error while processing full data for restore, + * it will invoke this method + * to tell the transport that it should abandon the data download for the current package. + * + * @return [TRANSPORT_OK] if the transport shut down the current stream cleanly, + * or [TRANSPORT_ERROR] to indicate a serious transport-level failure. + * If the transport reports an error here, + * the entire restore operation will immediately be finished + * with no further attempts to restore app data. + */ + fun abortFullRestore(): Int { + val state = this.state ?: throw IllegalStateException() + Log.i(TAG, "Abort full restore of ${state.packageInfo.packageName}!") + + resetState() + return TRANSPORT_OK + } + + /** + * End a restore session (aborting any in-process data transfer as necessary), + * freeing any resources and connections used during the restore process. + */ + fun finishRestore() { + val state = this.state ?: throw IllegalStateException() + Log.i(TAG, "Finish restore of ${state.packageInfo.packageName}!") + + resetState() + } + + private fun resetState() { + Log.i(TAG, "Resetting state.") + + closeQuietly(state?.inputStream) + state = null + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt new file mode 100644 index 00000000..9281d76c --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt @@ -0,0 +1,18 @@ +package com.stevesoltys.backup.transport.restore + +import android.content.pm.PackageInfo +import java.io.IOException +import java.io.InputStream + +interface FullRestorePlugin { + + /** + * Return true if there is data stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean + + @Throws(IOException::class) + fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt new file mode 100644 index 00000000..67f6b1a5 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt @@ -0,0 +1,140 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupDataOutput +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.header.HeaderReader +import com.stevesoltys.backup.header.UnsupportedVersionException +import libcore.io.IoUtils.closeQuietly +import java.io.IOException +import java.util.* +import java.util.Base64.getUrlDecoder + +private class KVRestoreState( + internal val token: Long, + internal val packageInfo: PackageInfo) + +private val TAG = KVRestore::class.java.simpleName + +internal class KVRestore( + private val plugin: KVRestorePlugin, + private val outputFactory: OutputFactory, + private val headerReader: HeaderReader, + private val crypto: Crypto) { + + private var state: KVRestoreState? = null + + /** + * Return true if there are records stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { + return plugin.hasDataForPackage(token, packageInfo) + } + + /** + * This prepares to restore the given package from the given restore token. + * + * It is possible that the system decides to not restore the package. + * Then a new state will be initialized right away without calling other methods. + */ + fun initializeState(token: Long, packageInfo: PackageInfo) { + state = KVRestoreState(token, packageInfo) + } + + /** + * Get the data for the current package. + * + * @param data An open, writable file into which the key/value backup data should be stored. + * @return One of [TRANSPORT_OK] + * or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled). + */ + fun getRestoreData(data: ParcelFileDescriptor): Int { + val state = this.state ?: throw IllegalStateException() + + // The restore set is the concatenation of the individual record blobs, + // each of which is a file in the package's directory. + // We return the data in lexical order sorted by key, + // so that apps which use synthetic keys like BLOB_1, BLOB_2, etc + // will see the date in the most obvious order. + val sortedKeys = getSortedKeys(state.token, state.packageInfo) + if (sortedKeys == null) { + // nextRestorePackage() ensures the dir exists, so this is an error + Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}") + return TRANSPORT_ERROR + } + + // We expect at least some data if the directory exists in the first place + Log.v(TAG, " getRestoreData() found ${sortedKeys.size} key files") + + return try { + val dataOutput = outputFactory.getBackupDataOutput(data) + for (keyEntry in sortedKeys) { + readAndWriteValue(state, keyEntry, dataOutput) + } + TRANSPORT_OK + } catch (e: IOException) { + Log.e(TAG, "Unable to read backup records", e) + TRANSPORT_ERROR + } catch (e: SecurityException) { + Log.e(TAG, "Security exception while reading backup records", e) + TRANSPORT_ERROR + } catch (e: UnsupportedVersionException) { + Log.e(TAG, "Unsupported version in backup: ${e.version}", e) + TRANSPORT_ERROR + } finally { + this.state = null + closeQuietly(data) + } + } + + /** + * Return a list of the records (represented by key files) in the given directory, + * sorted lexically by the Base64-decoded key file name, not by the on-disk filename. + */ + private fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? { + val records: List<String> = try { + plugin.listRecords(token, packageInfo) + } catch (e: IOException) { + return null + } + if (records.isEmpty()) return null + + // Decode the key filenames into keys then sort lexically by key + val contents = ArrayList<DecodedKey>() + for (recordKey in records) contents.add(DecodedKey(recordKey)) + contents.sort() + return contents + } + + /** + * Read the encrypted value for the given key and write it to the given [BackupDataOutput]. + */ + @Throws(IOException::class, UnsupportedVersionException::class, SecurityException::class) + private fun readAndWriteValue(state: KVRestoreState, dKey: DecodedKey, out: BackupDataOutput) { + val inputStream = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key) + try { + val version = headerReader.readVersion(inputStream) + crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key) + val value = crypto.decryptSegment(inputStream) + val size = value.size + Log.v(TAG, " ... key=${dKey.key} size=$size") + + out.writeEntityHeader(dKey.key, size) + out.writeEntityData(value, size) + } finally { + closeQuietly(inputStream) + } + } + + private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> { + internal val key = String(getUrlDecoder().decode(base64Key)) + + override fun compareTo(other: DecodedKey) = key.compareTo(other.key) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt new file mode 100644 index 00000000..e425752c --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt @@ -0,0 +1,30 @@ +package com.stevesoltys.backup.transport.restore + +import android.content.pm.PackageInfo +import java.io.IOException +import java.io.InputStream + +interface KVRestorePlugin { + + /** + * Return true if there is data stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean + + /** + * Return all record keys for the given token and package. + * + * For file-based plugins, this is usually a list of file names in the package directory. + */ + @Throws(IOException::class) + fun listRecords(token: Long, packageInfo: PackageInfo): List<String> + + /** + * Return an [InputStream] for the given token, package and key + * which will provide the record's encrypted value. + */ + @Throws(IOException::class) + fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt new file mode 100644 index 00000000..16162bb6 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt @@ -0,0 +1,21 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupDataOutput +import android.os.ParcelFileDescriptor +import java.io.FileOutputStream +import java.io.OutputStream + +/** + * This class exists for easier testing, so we can mock it and return custom data outputs. + */ +class OutputFactory { + + fun getBackupDataOutput(outputFileDescriptor: ParcelFileDescriptor): BackupDataOutput { + return BackupDataOutput(outputFileDescriptor.fileDescriptor) + } + + fun getOutputStream(outputFileDescriptor: ParcelFileDescriptor): OutputStream { + return FileOutputStream(outputFileDescriptor.fileDescriptor) + } + +} 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 new file mode 100644 index 00000000..d6dc397a --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt @@ -0,0 +1,155 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupTransport.TRANSPORT_ERROR +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 android.util.Log +import java.io.IOException + +private class RestoreCoordinatorState( + internal val token: Long, + internal val packages: Iterator<PackageInfo>) + +private val TAG = RestoreCoordinator::class.java.simpleName + +internal class RestoreCoordinator( + private val plugin: RestorePlugin, + private val kv: KVRestore, + private val full: FullRestore) { + + private var state: RestoreCoordinatorState? = null + + fun getAvailableRestoreSets(): Array<RestoreSet>? { + return plugin.getAvailableRestoreSets() + .apply { Log.i(TAG, "Got available restore sets: $this") } + } + + fun getCurrentRestoreSet(): Long { + return plugin.getCurrentRestoreSet() + .apply { Log.i(TAG, "Got current restore set token: $this") } + } + + /** + * Start restoring application data from backup. + * After calling this function, + * there will be alternate calls to [nextRestorePackage] and [getRestoreData] + * to walk through the actual application data. + * + * @param token A backup token as returned by [getAvailableRestoreSets] or [getCurrentRestoreSet]. + * @param packages List of applications to restore (if data is available). + * Application data will be restored in the order given. + * @return One of [TRANSPORT_OK] (OK so far, call [nextRestorePackage]) + * or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled). + */ + fun startRestore(token: Long, packages: Array<out PackageInfo>): Int { + if (state != null) throw IllegalStateException() + Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}") + state = RestoreCoordinatorState(token, packages.iterator()) + return TRANSPORT_OK + } + + /** + * Get the package name of the next package with data in the backup store, + * plus a description of the structure of the restored archive: + * either [TYPE_KEY_VALUE] for an original-API key/value dataset, + * or [TYPE_FULL_STREAM] for a tarball-type archive stream. + * + * If the package name in the returned [RestoreDescription] object is [NO_MORE_PACKAGES], + * it indicates that no further data is available in the current restore session, + * i.e. all packages described in [startRestore] have been processed. + * + * If this method returns null, it means that a transport-level error has + * occurred and the entire restore operation should be abandoned. + * + * The OS may call [nextRestorePackage] multiple times + * before calling either [getRestoreData] or [getNextFullRestoreDataChunk]. + * It does this when it has determined + * that it needs to skip restore of one or more packages. + * The transport should not actually transfer any restore data + * for the given package in response to [nextRestorePackage], + * but rather wait for an explicit request before doing so. + * + * @return A [RestoreDescription] object containing the name of one of the packages + * supplied to [startRestore] plus an indicator of the data type of that restore data; + * or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session; + * or null to indicate a transport-level error. + */ + fun nextRestorePackage(): RestoreDescription? { + Log.i(TAG, "Next restore package!") + val state = this.state ?: throw IllegalStateException() + + if (!state.packages.hasNext()) return NO_MORE_PACKAGES + val packageInfo = state.packages.next() + val packageName = packageInfo.packageName + + val type = try { + when { + // check key/value data first and if available, don't even check for full data + kv.hasDataForPackage(state.token, packageInfo) -> { + Log.i(TAG, "Found K/V data for $packageName.") + kv.initializeState(state.token, packageInfo) + TYPE_KEY_VALUE + } + full.hasDataForPackage(state.token, packageInfo) -> { + Log.i(TAG, "Found full backup data for $packageName.") + full.initializeState(state.token, packageInfo) + TYPE_FULL_STREAM + } + else -> { + Log.i(TAG, "No data found for $packageName. Skipping.") + return nextRestorePackage() + } + } + } catch (e: IOException) { + Log.e(TAG, "Error finding restore data for $packageName.", e) + return null + } + return RestoreDescription(packageName, type) + } + + /** + * Get the data for the application returned by [nextRestorePackage], + * if that method reported [TYPE_KEY_VALUE] as its delivery type. + * If the package has only TYPE_FULL_STREAM data, then this method will return an error. + * + * @param data An open, writable file into which the key/value backup data should be stored. + * @return the same error codes as [startRestore]. + */ + fun getRestoreData(data: ParcelFileDescriptor): Int { + return kv.getRestoreData(data) + } + + /** + * Ask the transport to provide data for the "current" package being restored. + * + * After this method returns zero, the system will then call [nextRestorePackage] + * to begin the restore process for the next application, and the sequence begins again. + */ + fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int { + return full.getNextFullRestoreDataChunk(outputFileDescriptor) + } + + /** + * If the OS encounters an error while processing full data for restore, it will abort. + * + * The OS will then either call [nextRestorePackage] again to move on + * to restoring the next package in the set being iterated over, + * or will call [finishRestore] to shut down the restore operation. + */ + fun abortFullRestore(): Int { + return full.abortFullRestore() + } + + /** + * End a restore session (aborting any in-process data transfer as necessary), + * freeing any resources and connections used during the restore process. + */ + fun finishRestore() { + if (full.hasState()) full.finishRestore() + } + +} 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 new file mode 100644 index 00000000..17f4f0ad --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt @@ -0,0 +1,28 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.RestoreSet + +interface RestorePlugin { + + val kvRestorePlugin: KVRestorePlugin + + val fullRestorePlugin: FullRestorePlugin + + /** + * Get the set of all backups currently available for restore. + * + * @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>? + + /** + * Get the identifying token of the backup set currently being stored from this device. + * This is used in the case of applications wishing to restore their last-known-good data. + * + * @return A token that can be used for restore, + * or 0 if there is no backup set available corresponding to the current device state. + */ + fun getCurrentRestoreSet(): Long + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt new file mode 100644 index 00000000..83825a06 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt @@ -0,0 +1,25 @@ +package com.stevesoltys.backup.transport.restore.plugins + +import android.content.pm.PackageInfo +import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.restore.FullRestorePlugin +import java.io.IOException +import java.io.InputStream + +class DocumentsProviderFullRestorePlugin( + private val documentsStorage: DocumentsStorage) : FullRestorePlugin { + + @Throws(IOException::class) + override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { + val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException() + return backupDir.findFile(packageInfo.packageName) != null + } + + @Throws(IOException::class) + override fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream { + val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException() + val packageFile = backupDir.findFile(packageInfo.packageName) ?: throw IOException() + return documentsStorage.getInputStream(packageFile) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt new file mode 100644 index 00000000..d24b1bd1 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt @@ -0,0 +1,42 @@ +package com.stevesoltys.backup.transport.restore.plugins + +import android.content.pm.PackageInfo +import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.backup.plugins.assertRightFile +import com.stevesoltys.backup.transport.restore.KVRestorePlugin +import java.io.IOException +import java.io.InputStream + +class DocumentsProviderKVRestorePlugin(private val storage: DocumentsStorage) : KVRestorePlugin { + + private var packageDir: DocumentFile? = null + + override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { + return try { + val backupDir = storage.getKVBackupDir(token) ?: return false + // remember package file for subsequent operations + packageDir = backupDir.findFile(packageInfo.packageName) + packageDir != null + } catch (e: IOException) { + false + } + } + + override fun listRecords(token: Long, packageInfo: PackageInfo): List<String> { + val packageDir = this.packageDir ?: throw AssertionError() + packageDir.assertRightFile(packageInfo) + return packageDir.listFiles() + .filter { file -> file.name != null } + .map { file -> file.name!! } + } + + @Throws(IOException::class) + override fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream { + val packageDir = this.packageDir ?: throw AssertionError() + packageDir.assertRightFile(packageInfo) + val keyFile = packageDir.findFile(key) ?: throw IOException() + return storage.getInputStream(keyFile) + } + +} 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 new file mode 100644 index 00000000..deb9e327 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt @@ -0,0 +1,29 @@ +package com.stevesoltys.backup.transport.restore.plugins + +import android.app.backup.RestoreSet +import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN +import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.restore.FullRestorePlugin +import com.stevesoltys.backup.transport.restore.KVRestorePlugin +import com.stevesoltys.backup.transport.restore.RestorePlugin + +class DocumentsProviderRestorePlugin( + private val documentsStorage: DocumentsStorage) : RestorePlugin { + + override val kvRestorePlugin: KVRestorePlugin by lazy { + DocumentsProviderKVRestorePlugin(documentsStorage) + } + + override val fullRestorePlugin: FullRestorePlugin by lazy { + DocumentsProviderFullRestorePlugin(documentsStorage) + } + + override fun getAvailableRestoreSets(): Array<RestoreSet>? { + return arrayOf(RestoreSet("default", "device", DEFAULT_RESTORE_SET_TOKEN)) + } + + override fun getCurrentRestoreSet(): Long { + return DEFAULT_RESTORE_SET_TOKEN + } + +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c8e82e3..e06e4f21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ <!-- Settings --> <string name="settings_backup">Backup my data</string> <string name="settings_backup_location">Backup location</string> + <string name="settings_backup_location_picker">Choose backup location</string> <string name="settings_backup_location_title">Backup Location</string> <string name="settings_backup_location_info">Choose where to store your backups. More options might get added in the future.</string> <string name="settings_backup_external_storage">External Storage</string> diff --git a/app/src/test/java/com/stevesoltys/backup/TestUtils.kt b/app/src/test/java/com/stevesoltys/backup/TestUtils.kt new file mode 100644 index 00000000..6644f3b1 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/TestUtils.kt @@ -0,0 +1,38 @@ +package com.stevesoltys.backup + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.fail +import kotlin.random.Random + +fun assertContains(stack: String?, needle: String) { + assertTrue(stack?.contains(needle) ?: fail()) +} + +fun getRandomByteArray(size: Int = Random.nextInt(1337)) = ByteArray(size).apply { + Random.nextBytes(this) +} + +private val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.' + +fun getRandomString(size: Int = Random.nextInt(1, 255)): String { + return (1..size) + .map { Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString("") +} + +fun ByteArray.toHexString(): String { + var str = "" + for (b in this) { + str += String.format("%02X ", b) + } + return str +} + +fun ByteArray.toIntString(): String { + var str = "" + for (b in this) { + str += String.format("%02d ", b) + } + return str +} diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt new file mode 100644 index 00000000..e606bb67 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt @@ -0,0 +1,53 @@ +package com.stevesoltys.backup.crypto + +import com.stevesoltys.backup.header.HeaderReaderImpl +import com.stevesoltys.backup.header.HeaderWriterImpl +import com.stevesoltys.backup.header.IV_SIZE +import com.stevesoltys.backup.header.MAX_SEGMENT_LENGTH +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.crypto.Cipher +import kotlin.random.Random + +@TestInstance(PER_METHOD) +class CryptoImplTest { + + private val cipherFactory = mockk<CipherFactory>() + private val headerWriter = HeaderWriterImpl() + private val headerReader = HeaderReaderImpl() + + private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) + + private val cipher = mockk<Cipher>() + + private val iv = ByteArray(IV_SIZE).apply { Random.nextBytes(this) } + private val cleartext = ByteArray(Random.nextInt(Short.MAX_VALUE.toInt())) + .apply { Random.nextBytes(this) } + private val ciphertext = ByteArray(Random.nextInt(Short.MAX_VALUE.toInt())) + .apply { Random.nextBytes(this) } + private val outputStream = ByteArrayOutputStream() + + @Test + fun `encrypted cleartext gets decrypted as expected`() { + every { cipherFactory.createEncryptionCipher() } returns cipher + every { cipher.getOutputSize(cleartext.size) } returns MAX_SEGMENT_LENGTH + every { cipher.doFinal(cleartext) } returns ciphertext + every { cipher.iv } returns iv + + crypto.encryptSegment(outputStream, cleartext) + + val inputStream = ByteArrayInputStream(outputStream.toByteArray()) + + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(ciphertext) } returns cleartext + + assertArrayEquals(cleartext, crypto.decryptSegment(inputStream)) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt new file mode 100644 index 00000000..a48d31e8 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt @@ -0,0 +1,44 @@ +package com.stevesoltys.backup.crypto + +import com.stevesoltys.backup.header.HeaderReaderImpl +import com.stevesoltys.backup.header.HeaderWriterImpl +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +@TestInstance(PER_METHOD) +class CryptoIntegrationTest { + + private val keyManager = KeyManagerTestImpl() + private val cipherFactory = CipherFactoryImpl(keyManager) + private val headerWriter = HeaderWriterImpl() + private val headerReader = HeaderReaderImpl() + + private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) + + private val cleartext = byteArrayOf(0x01, 0x02, 0x03) + + private val outputStream = ByteArrayOutputStream() + + @Test + fun `the plain crypto works`() { + val eCipher = cipherFactory.createEncryptionCipher() + val encrypted = eCipher.doFinal(cleartext) + + val dCipher = cipherFactory.createDecryptionCipher(eCipher.iv) + val decrypted = dCipher.doFinal(encrypted) + + assertArrayEquals(cleartext, decrypted) + } + + @Test + fun `encrypted cleartext gets decrypted as expected`() { + crypto.encryptSegment(outputStream, cleartext) + val inputStream = ByteArrayInputStream(outputStream.toByteArray()) + assertArrayEquals(cleartext, crypto.decryptSegment(inputStream)) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt new file mode 100644 index 00000000..e1d129dd --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt @@ -0,0 +1,192 @@ +package com.stevesoltys.backup.crypto + +import com.stevesoltys.backup.assertContains +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.getRandomString +import com.stevesoltys.backup.header.* +import io.mockk.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD +import java.io.* +import javax.crypto.Cipher +import kotlin.random.Random + +@TestInstance(PER_METHOD) +class CryptoTest { + + private val cipherFactory = mockk<CipherFactory>() + private val headerWriter = mockk<HeaderWriter>() + private val headerReader = mockk<HeaderReader>() + + private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) + + private val cipher = mockk<Cipher>() + + private val iv = getRandomByteArray(IV_SIZE) + private val cleartext = getRandomByteArray(Random.nextInt(MAX_SEGMENT_LENGTH)) + private val ciphertext = getRandomByteArray(Random.nextInt(MAX_SEGMENT_LENGTH)) + private val versionHeader = VersionHeader(VERSION, getRandomString(MAX_PACKAGE_LENGTH_SIZE), getRandomString(MAX_KEY_LENGTH_SIZE)) + private val versionCiphertext = getRandomByteArray(MAX_VERSION_HEADER_SIZE) + private val versionSegmentHeader = SegmentHeader(versionCiphertext.size.toShort(), iv) + private val outputStream = ByteArrayOutputStream() + private val segmentHeader = SegmentHeader(ciphertext.size.toShort(), iv) + // the headerReader will not actually read the header, so only insert cipher text + private val inputStream = ByteArrayInputStream(ciphertext) + private val versionInputStream = ByteArrayInputStream(versionCiphertext) + + // encrypting + + @Test + fun `encrypt header works as expected`() { + val segmentHeader = CapturingSlot<SegmentHeader>() + every { headerWriter.getEncodedVersionHeader(versionHeader) } returns ciphertext + encryptSegmentHeader(ciphertext, segmentHeader) + + crypto.encryptHeader(outputStream, versionHeader) + assertArrayEquals(iv, segmentHeader.captured.nonce) + assertEquals(ciphertext.size, segmentHeader.captured.segmentLength.toInt()) + } + + @Test + fun `encrypting segment works as expected`() { + val segmentHeader = CapturingSlot<SegmentHeader>() + encryptSegmentHeader(cleartext, segmentHeader) + + crypto.encryptSegment(outputStream, cleartext) + + assertArrayEquals(ciphertext, outputStream.toByteArray()) + assertArrayEquals(iv, segmentHeader.captured.nonce) + assertEquals(ciphertext.size, segmentHeader.captured.segmentLength.toInt()) + } + + private fun encryptSegmentHeader(toEncrypt: ByteArray, segmentHeader: CapturingSlot<SegmentHeader>) { + every { cipherFactory.createEncryptionCipher() } returns cipher + every { cipher.getOutputSize(toEncrypt.size) } returns toEncrypt.size + every { cipher.iv } returns iv + every { headerWriter.writeSegmentHeader(outputStream, capture(segmentHeader)) } just Runs + every { cipher.doFinal(toEncrypt) } returns ciphertext + } + + // decrypting + + @Test + fun `decrypting header works as expected`() { + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(versionCiphertext) } returns cleartext + every { headerReader.getVersionHeader(cleartext) } returns versionHeader + + assertEquals( + versionHeader, + crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, versionHeader.key) + ) + } + + @Test + fun `decrypting header throws if too large`() { + val size = MAX_VERSION_HEADER_SIZE + 1 + val versionCiphertext = getRandomByteArray(size) + val versionInputStream = ByteArrayInputStream(versionCiphertext) + val versionSegmentHeader = SegmentHeader(size.toShort(), iv) + + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + + val e = assertThrows(SecurityException::class.java) { + crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, versionHeader.key) + } + assertContains(e.message, size.toString()) + } + + @Test + fun `decrypting header throws because of different version`() { + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(versionCiphertext) } returns cleartext + every { headerReader.getVersionHeader(cleartext) } returns versionHeader + + val version = (VERSION + 1).toByte() + val e = assertThrows(SecurityException::class.java) { + crypto.decryptHeader(versionInputStream, version, versionHeader.packageName, versionHeader.key) + } + assertContains(e.message, version.toString()) + } + + @Test + fun `decrypting header throws because of different package name`() { + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(versionCiphertext) } returns cleartext + every { headerReader.getVersionHeader(cleartext) } returns versionHeader + + val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE) + val e = assertThrows(SecurityException::class.java) { + crypto.decryptHeader(versionInputStream, versionHeader.version, packageName, versionHeader.key) + } + assertContains(e.message, packageName) + } + + @Test + fun `decrypting header throws because of different key`() { + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(versionCiphertext) } returns cleartext + every { headerReader.getVersionHeader(cleartext) } returns versionHeader + + val e = assertThrows(SecurityException::class.java) { + crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, null) + } + assertContains(e.message, "null") + assertContains(e.message, versionHeader.key ?: fail()) + } + + @Test + fun `decrypting data segment header works as expected`() { + every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(ciphertext) } returns cleartext + + assertArrayEquals(cleartext, crypto.decryptSegment(inputStream)) + } + + @Test + fun `decrypting data segment throws if reading 0 bytes`() { + val inputStream = mockk<InputStream>() + val buffer = ByteArray(segmentHeader.segmentLength.toInt()) + + every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader + every { inputStream.read(buffer) } returns 0 + + assertThrows(IOException::class.java) { + crypto.decryptSegment(inputStream) + } + } + + @Test + fun `decrypting data segment throws if reaching end of stream`() { + val inputStream = mockk<InputStream>() + val buffer = ByteArray(segmentHeader.segmentLength.toInt()) + + every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader + every { inputStream.read(buffer) } returns -1 + + assertThrows(EOFException::class.java) { + crypto.decryptSegment(inputStream) + } + } + + @Test + fun `decrypting data segment throws if reading less than expected`() { + val inputStream = mockk<InputStream>() + val buffer = ByteArray(segmentHeader.segmentLength.toInt()) + + every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader + every { inputStream.read(buffer) } returns buffer.size - 1 + + assertThrows(IOException::class.java) { + crypto.decryptSegment(inputStream) + } + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt b/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt new file mode 100644 index 00000000..e9473c89 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt @@ -0,0 +1,26 @@ +package com.stevesoltys.backup.crypto + +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +class KeyManagerTestImpl : KeyManager { + + private val key by lazy { + val keyGenerator = KeyGenerator.getInstance("AES") + keyGenerator.init(KEY_SIZE) + keyGenerator.generateKey() + } + + override fun storeBackupKey(seed: ByteArray) { + TODO("not implemented") + } + + override fun hasBackupKey(): Boolean { + return true + } + + override fun getBackupKey(): SecretKey { + return key + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt b/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt new file mode 100644 index 00000000..94b85b6f --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt @@ -0,0 +1,274 @@ +package com.stevesoltys.backup.header + +import com.stevesoltys.backup.assertContains +import com.stevesoltys.backup.getRandomString +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.ByteBuffer +import kotlin.random.Random + +@TestInstance(PER_CLASS) +internal class HeaderReaderTest { + + private val reader = HeaderReaderImpl() + + // Version Tests + + @Test + fun `valid version is read`() { + val input = byteArrayOf(VERSION) + val inputStream = ByteArrayInputStream(input) + + assertEquals(VERSION, reader.readVersion(inputStream)) + } + + @Test + fun `too short version stream throws exception`() { + val input = ByteArray(0) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readVersion(inputStream) + } + } + + @Test + fun `unsupported version throws exception`() { + val input = byteArrayOf((VERSION + 1).toByte()) + val inputStream = ByteArrayInputStream(input) + assertThrows(UnsupportedVersionException::class.javaObjectType) { + reader.readVersion(inputStream) + } + } + + @Test + fun `negative version throws exception`() { + val input = byteArrayOf((-1).toByte()) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readVersion(inputStream) + } + } + + @Test + fun `max version byte throws exception`() { + val input = byteArrayOf(Byte.MAX_VALUE) + val inputStream = ByteArrayInputStream(input) + assertThrows(UnsupportedVersionException::class.javaObjectType) { + reader.readVersion(inputStream) + } + } + + // VersionHeader Tests + + @Test + fun `valid VersionHeader is read`() { + val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x01, 0x62) + + val versionHeader = VersionHeader(VERSION, "a", "b") + assertEquals(versionHeader, reader.getVersionHeader(input)) + } + + @Test + fun `zero package length in VersionHeader throws`() { + val input = byteArrayOf(VERSION, 0x00, 0x00, 0x00, 0x01, 0x62) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `negative package length in VersionHeader throws`() { + val input = byteArrayOf(0x00, 0xFF, 0xFF, 0x00, 0x01, 0x62) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `too large package length in VersionHeader throws`() { + val size = MAX_PACKAGE_LENGTH_SIZE + 1 + val input = ByteBuffer.allocate(3 + size) + .put(VERSION) + .putShort(size.toShort()) + .put(ByteArray(size)) + .array() + val e = assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + assertContains(e.message, size.toString()) + } + + @Test + fun `insufficient bytes for package in VersionHeader throws`() { + val input = byteArrayOf(VERSION, 0x00, 0x50) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `zero key length in VersionHeader gets accepted`() { + val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x00) + + val versionHeader = VersionHeader(VERSION, "a", null) + assertEquals(versionHeader, reader.getVersionHeader(input)) + } + + @Test + fun `negative key length in VersionHeader throws`() { + val input = byteArrayOf(0x00, 0x00, 0x01, 0x61, 0xFF, 0xFF) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `too large key length in VersionHeader throws`() { + val size = MAX_KEY_LENGTH_SIZE + 1 + val input = ByteBuffer.allocate(4 + size) + .put(VERSION) + .putShort(1.toShort()) + .put("a".toByteArray(Utf8)) + .putShort(size.toShort()) + .array() + val e = assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + assertContains(e.message, size.toString()) + } + + @Test + fun `insufficient bytes for key in VersionHeader throws`() { + val input = byteArrayOf(0x00, 0x00, 0x01, 0x61, 0x00, 0x50) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `extra bytes in VersionHeader throws`() { + val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x01, 0x62, 0x00) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `max sized VersionHeader gets accepted`() { + val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE) + val key = getRandomString(MAX_KEY_LENGTH_SIZE) + val input = ByteBuffer.allocate(MAX_VERSION_HEADER_SIZE) + .put(VERSION) + .putShort(MAX_PACKAGE_LENGTH_SIZE.toShort()) + .put(packageName.toByteArray(Utf8)) + .putShort(MAX_KEY_LENGTH_SIZE.toShort()) + .put(key.toByteArray(Utf8)) + .array() + assertEquals(MAX_VERSION_HEADER_SIZE, input.size) + val h = reader.getVersionHeader(input) + assertEquals(VERSION, h.version) + assertEquals(packageName, h.packageName) + assertEquals(key, h.key) + } + + // SegmentHeader Tests + + @Test + fun `too short SegmentHeader throws exception`() { + val input = byteArrayOf(0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readSegmentHeader(inputStream) + } + } + + @Test + fun `segment length of zero is rejected`() { + val input = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readSegmentHeader(inputStream) + } + } + + @Test + fun `negative segment length is rejected`() { + val input = byteArrayOf(0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readSegmentHeader(inputStream) + } + } + + @Test + fun `minimum negative segment length is rejected`() { + val input = byteArrayOf(0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readSegmentHeader(inputStream) + } + } + + @Test + fun `max segment length is accepted`() { + val input = byteArrayOf(0x7F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertEquals(MAX_SEGMENT_LENGTH, reader.readSegmentHeader(inputStream).segmentLength.toInt()) + } + + @Test + fun `min segment length of 1 is accepted`() { + val input = byteArrayOf(0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertEquals(1, reader.readSegmentHeader(inputStream).segmentLength.toInt()) + } + + @Test + fun `segment length is always read correctly`() { + val segmentLength = getRandomValidSegmentLength() + val input = ByteBuffer.allocate(SEGMENT_HEADER_SIZE) + .putShort(segmentLength) + .put(ByteArray(IV_SIZE)) + .array() + val inputStream = ByteArrayInputStream(input) + assertEquals(segmentLength, reader.readSegmentHeader(inputStream).segmentLength) + } + + @Test + fun `nonce is read in big endian`() { + val nonce = byteArrayOf(0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01) + val input = byteArrayOf(0x00, 0x01, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01) + val inputStream = ByteArrayInputStream(input) + assertArrayEquals(nonce, reader.readSegmentHeader(inputStream).nonce) + } + + @Test + fun `nonce is always read correctly`() { + val nonce = ByteArray(IV_SIZE).apply { Random.nextBytes(this) } + val input = ByteBuffer.allocate(SEGMENT_HEADER_SIZE) + .putShort(1) + .put(nonce) + .array() + val inputStream = ByteArrayInputStream(input) + assertArrayEquals(nonce, reader.readSegmentHeader(inputStream).nonce) + } + + private fun byteArrayOf(vararg elements: Int): ByteArray { + return elements.map { it.toByte() }.toByteArray() + } + +} + +internal fun getRandomValidSegmentLength(): Short { + return Random.nextInt(1, Short.MAX_VALUE.toInt()).toShort() +} diff --git a/app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt b/app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt new file mode 100644 index 00000000..a1df050b --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt @@ -0,0 +1,102 @@ +package com.stevesoltys.backup.header + +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.getRandomString +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlin.random.Random + +@TestInstance(PER_CLASS) +internal class HeaderWriterReaderTest { + + private val writer = HeaderWriterImpl() + private val reader = HeaderReaderImpl() + + private val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE) + private val key = getRandomString(MAX_KEY_LENGTH_SIZE) + private val versionHeader = VersionHeader(VERSION, packageName, key) + private val unsupportedVersionHeader = VersionHeader((VERSION + 1).toByte(), packageName) + + private val segmentLength = getRandomValidSegmentLength() + private val nonce = getRandomByteArray(IV_SIZE) + private val segmentHeader = SegmentHeader(segmentLength, nonce) + + @Test + fun `written version matches read input`() { + assertEquals(versionHeader.version, readWriteVersion(versionHeader)) + } + + @Test + fun `reading unsupported version throws exception`() { + assertThrows(UnsupportedVersionException::class.javaObjectType) { + readWriteVersion(unsupportedVersionHeader) + } + } + + @Test + fun `VersionHeader output matches read input`() { + assertEquals(versionHeader, readWrite(versionHeader)) + } + + @Test + fun `VersionHeader with no key output matches read input`() { + val versionHeader = VersionHeader(VERSION, packageName, null) + assertEquals(versionHeader, readWrite(versionHeader)) + } + + @Test + fun `VersionHeader with empty package name throws`() { + val versionHeader = VersionHeader(VERSION, "") + assertThrows(SecurityException::class.java) { + readWrite(versionHeader) + } + } + + @Test + fun `SegmentHeader constructor needs right IV size`() { + val nonceTooBig = ByteArray(IV_SIZE + 1).apply { Random.nextBytes(this) } + assertThrows(IllegalStateException::class.javaObjectType) { + SegmentHeader(segmentLength, nonceTooBig) + } + val nonceTooSmall = ByteArray(IV_SIZE - 1).apply { Random.nextBytes(this) } + assertThrows(IllegalStateException::class.javaObjectType) { + SegmentHeader(segmentLength, nonceTooSmall) + } + } + + @Test + fun `SegmentHeader output matches read input`() { + assertEquals(segmentHeader, readWriteVersion(segmentHeader)) + } + + private fun readWriteVersion(header: VersionHeader): Byte { + val outputStream = ByteArrayOutputStream() + writer.writeVersion(outputStream, header) + val written = outputStream.toByteArray() + val inputStream = ByteArrayInputStream(written) + return reader.readVersion(inputStream) + } + + private fun readWrite(header: VersionHeader): VersionHeader { + val written = writer.getEncodedVersionHeader(header) + return reader.getVersionHeader(written) + } + + private fun readWriteVersion(header: SegmentHeader): SegmentHeader { + val outputStream = ByteArrayOutputStream() + writer.writeSegmentHeader(outputStream, header) + val written = outputStream.toByteArray() + val inputStream = ByteArrayInputStream(written) + return reader.readSegmentHeader(inputStream) + } + + private fun assertEquals(expected: SegmentHeader, actual: SegmentHeader) { + assertEquals(expected.segmentLength, actual.segmentLength) + assertArrayEquals(expected.nonce, actual.nonce) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt new file mode 100644 index 00000000..9622568c --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt @@ -0,0 +1,162 @@ +package com.stevesoltys.backup.transport + +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataOutput +import android.app.backup.BackupTransport.NO_MORE_DATA +import android.app.backup.BackupTransport.TRANSPORT_OK +import android.app.backup.RestoreDescription +import android.app.backup.RestoreDescription.TYPE_FULL_STREAM +import android.os.ParcelFileDescriptor +import com.stevesoltys.backup.crypto.CipherFactoryImpl +import com.stevesoltys.backup.crypto.CryptoImpl +import com.stevesoltys.backup.crypto.KeyManagerTestImpl +import com.stevesoltys.backup.transport.backup.* +import com.stevesoltys.backup.header.HeaderReaderImpl +import com.stevesoltys.backup.header.HeaderWriterImpl +import com.stevesoltys.backup.header.Utf8 +import com.stevesoltys.backup.transport.restore.* +import io.mockk.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.* +import kotlin.random.Random + +internal class CoordinatorIntegrationTest : TransportTest() { + + private val inputFactory = mockk<InputFactory>() + private val outputFactory = mockk<OutputFactory>() + private val keyManager = KeyManagerTestImpl() + private val cipherFactory = CipherFactoryImpl(keyManager) + private val headerWriter = HeaderWriterImpl() + private val headerReader = HeaderReaderImpl() + private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader) + + private val backupPlugin = mockk<BackupPlugin>() + private val kvBackupPlugin = mockk<KVBackupPlugin>() + private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl) + private val fullBackupPlugin = mockk<FullBackupPlugin>() + private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl) + private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup) + + private val restorePlugin = mockk<RestorePlugin>() + private val kvRestorePlugin = mockk<KVRestorePlugin>() + 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 backupDataInput = mockk<BackupDataInput>() + private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true) + private val token = DEFAULT_RESTORE_SET_TOKEN + private val appData = ByteArray(42).apply { Random.nextBytes(this) } + private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) } + private val key = "RestoreKey" + private val key64 = Base64.getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) + private val key2 = "RestoreKey2" + private val key264 = Base64.getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8)) + + init { + every { backupPlugin.kvBackupPlugin } returns kvBackupPlugin + every { backupPlugin.fullBackupPlugin } returns fullBackupPlugin + } + + @Test + fun `test key-value backup and restore with 2 records`() { + val value = CapturingSlot<ByteArray>() + val value2 = CapturingSlot<ByteArray>() + val bOutputStream = ByteArrayOutputStream() + val bOutputStream2 = ByteArrayOutputStream() + + // read one key/value record and write it to output stream + every { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false + every { kvBackupPlugin.ensureRecordStorageForPackage(packageInfo) } just Runs + every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput + every { backupDataInput.readNextHeader() } returns true andThen true andThen false + every { backupDataInput.key } returns key andThen key2 + every { backupDataInput.dataSize } returns appData.size andThen appData2.size + every { backupDataInput.readEntityData(capture(value), 0, appData.size) } answers { + appData.copyInto(value.captured) // write the app data into the passed ByteArray + appData.size + } + every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream + every { backupDataInput.readEntityData(capture(value2), 0, appData2.size) } answers { + appData2.copyInto(value2.captured) // write the app data into the passed ByteArray + appData2.size + } + every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2 + + // start and finish K/V backup + assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + + // start restore + assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo))) + + // find data for K/V backup + every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns true + + val restoreDescription = restore.nextRestorePackage() ?: fail() + assertEquals(packageInfo.packageName, restoreDescription.packageName) + assertEquals(RestoreDescription.TYPE_KEY_VALUE, restoreDescription.dataType) + + // restore finds the backed up key and writes the decrypted value + val backupDataOutput = mockk<BackupDataOutput>() + val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) + val rInputStream2 = ByteArrayInputStream(bOutputStream2.toByteArray()) + every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264) + every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput + every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key64) } returns rInputStream + every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137 + every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size + every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key264) } returns rInputStream2 + every { backupDataOutput.writeEntityHeader(key2, appData2.size) } returns 1137 + every { backupDataOutput.writeEntityData(appData2, appData2.size) } returns appData2.size + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + } + + @Test + fun `test full backup and restore with two chunks`() { + // return streams from plugin and app data + val bOutputStream = ByteArrayOutputStream() + val bInputStream = ByteArrayInputStream(appData) + every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream + every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream + every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP + + // perform backup to output stream + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0)) + assertEquals(TRANSPORT_OK, backup.sendBackupData(appData.size / 2)) + assertEquals(TRANSPORT_OK, backup.sendBackupData(appData.size / 2)) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + + // start restore + assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo))) + + // find data only for full backup + every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns false + every { fullRestorePlugin.hasDataForPackage(token, packageInfo) } returns true + + val restoreDescription = restore.nextRestorePackage() ?: fail() + assertEquals(packageInfo.packageName, restoreDescription.packageName) + assertEquals(TYPE_FULL_STREAM, restoreDescription.dataType) + + // reverse the backup streams into restore input + val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) + val rOutputStream = ByteArrayOutputStream() + every { fullRestorePlugin.getInputStreamForPackage(token, packageInfo) } returns rInputStream + every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream + + // restore data + assertEquals(appData.size / 2, restore.getNextFullRestoreDataChunk(fileDescriptor)) + assertEquals(appData.size / 2, restore.getNextFullRestoreDataChunk(fileDescriptor)) + assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor)) + restore.finishRestore() + + // assert that restored data matches original app data + assertArrayEquals(appData, rOutputStream.toByteArray()) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt new file mode 100644 index 00000000..6c425339 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt @@ -0,0 +1,30 @@ +package com.stevesoltys.backup.transport + +import android.content.pm.PackageInfo +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD + +@TestInstance(PER_METHOD) +abstract class TransportTest { + + protected val crypto = mockk<Crypto>() + + protected val packageInfo = PackageInfo().apply { packageName = "org.example" } + + init { + mockkStatic(Log::class) + every { Log.v(any(), any()) } returns 0 + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + 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 + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt new file mode 100644 index 00000000..e5833277 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt @@ -0,0 +1,110 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.IOException +import kotlin.random.Random + +internal class BackupCoordinatorTest: BackupTest() { + + private val plugin = mockk<BackupPlugin>() + private val kv = mockk<KVBackup>() + private val full = mockk<FullBackup>() + + private val backup = BackupCoordinator(plugin, kv, full) + + @Test + fun `device initialization succeeds and delegates to plugin`() { + every { plugin.initializeDevice() } 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 fails`() { + every { plugin.initializeDevice() } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) + + // finish will only be called when TRANSPORT_OK is returned, so it should throw + every { kv.hasState() } returns false + every { full.hasState() } returns false + assertThrows(IllegalStateException::class.java) { + backup.finishBackup() + } + } + + @Test + fun `getBackupQuota() delegates to right plugin`() { + val isFullBackup = Random.nextBoolean() + val quota = Random.nextLong() + + if (isFullBackup) { + every { full.getQuota() } returns quota + } else { + every { kv.getQuota() } returns quota + } + assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup)) + } + + @Test + fun `clearing KV backup data throws`() { + every { kv.clearBackupData(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo)) + } + + @Test + fun `clearing full backup data throws`() { + every { kv.clearBackupData(packageInfo) } just Runs + every { full.clearBackupData(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo)) + } + + @Test + fun `clearing backup data succeeds`() { + every { kv.clearBackupData(packageInfo) } just Runs + every { full.clearBackupData(packageInfo) } just Runs + + assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo)) + + every { kv.hasState() } returns false + every { full.hasState() } returns false + + assertEquals(TRANSPORT_OK, backup.finishBackup()) + } + + @Test + fun `finish backup delegates to KV plugin if it has state`() { + val result = Random.nextInt() + + every { kv.hasState() } returns true + every { full.hasState() } returns false + every { kv.finishBackup() } returns result + + assertEquals(result, backup.finishBackup()) + } + + @Test + fun `finish backup delegates to full plugin if it has state`() { + val result = Random.nextInt() + + every { kv.hasState() } returns false + every { full.hasState() } returns true + every { full.finishBackup() } returns result + + assertEquals(result, backup.finishBackup()) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt new file mode 100644 index 00000000..df96b38e --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt @@ -0,0 +1,20 @@ +package com.stevesoltys.backup.transport.backup + +import android.os.ParcelFileDescriptor +import com.stevesoltys.backup.transport.TransportTest +import com.stevesoltys.backup.header.HeaderWriter +import com.stevesoltys.backup.header.VersionHeader +import io.mockk.mockk +import java.io.OutputStream + +internal abstract class BackupTest : TransportTest() { + + protected val inputFactory = mockk<InputFactory>() + protected val headerWriter = mockk<HeaderWriter>() + protected val data = mockk<ParcelFileDescriptor>() + protected val outputStream = mockk<OutputStream>() + + protected val header = VersionHeader(packageName = packageInfo.packageName) + protected val quota = 42L + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt new file mode 100644 index 00000000..b6f1972e --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt @@ -0,0 +1,276 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.* +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.FileInputStream +import java.io.IOException +import kotlin.random.Random + +internal class FullBackupTest : BackupTest() { + + private val plugin = mockk<FullBackupPlugin>() + private val backup = FullBackup(plugin, inputFactory, headerWriter, crypto) + + private val bytes = ByteArray(23).apply { Random.nextBytes(this) } + private val closeBytes = ByteArray(42).apply { Random.nextBytes(this) } + private val inputStream = mockk<FileInputStream>() + + @Test + fun `now is a good time for a backup`() { + assertEquals(0, backup.requestFullBackupTime()) + } + + @Test + fun `has no initial state`() { + assertFalse(backup.hasState()) + } + + @Test + fun `checkFullBackupSize exceeds quota`() { + every { plugin.getQuota() } returns quota + + assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.checkFullBackupSize(quota + 1)) + } + + @Test + fun `checkFullBackupSize for no data`() { + assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(0)) + } + + @Test + fun `checkFullBackupSize for negative data`() { + assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(-1)) + } + + @Test + fun `checkFullBackupSize accepts min data`() { + every { plugin.getQuota() } returns quota + + assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(1)) + } + + @Test + fun `checkFullBackupSize accepts max data`() { + every { plugin.getQuota() } returns quota + + assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(quota)) + } + + @Test + fun `performFullBackup throws exception when getting outputStream`() { + every { plugin.getOutputStream(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performFullBackup(packageInfo, data)) + assertFalse(backup.hasState()) + } + + @Test + fun `performFullBackup throws exception when writing header`() { + every { plugin.getOutputStream(packageInfo) } returns outputStream + every { inputFactory.getInputStream(data) } returns inputStream + every { headerWriter.writeVersion(outputStream, header) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performFullBackup(packageInfo, data)) + assertFalse(backup.hasState()) + } + + @Test + fun `performFullBackup runs ok`() { + expectPerformFullBackup() + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData first call over quota`() { + expectPerformFullBackup() + val numBytes = (quota + 1).toInt() + expectSendData(numBytes) + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData second call over quota`() { + expectPerformFullBackup() + val numBytes1 = quota.toInt() + expectSendData(numBytes1) + val numBytes2 = 1 + expectSendData(numBytes2) + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes2)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData throws exception when reading from InputStream`() { + expectPerformFullBackup() + every { plugin.getQuota() } returns quota + every { inputStream.read(any(), any(), bytes.size) } throws IOException() + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData throws exception when writing encrypted data to OutputStream`() { + expectPerformFullBackup() + every { plugin.getQuota() } returns quota + every { inputStream.read(any(), any(), bytes.size) } returns bytes.size + every { crypto.encryptSegment(outputStream, any()) } throws IOException() + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData runs ok`() { + expectPerformFullBackup() + val numBytes1 = (quota / 2).toInt() + expectSendData(numBytes1) + val numBytes2 = (quota / 2).toInt() + expectSendData(numBytes2) + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes2)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `cancel full backup runs ok`() { + expectPerformFullBackup() + expectClearState() + every { plugin.cancelFullBackup(packageInfo) } just Runs + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + backup.cancelFullBackup() + assertFalse(backup.hasState()) + } + + @Test + fun `cancel full backup ignores exception when calling plugin`() { + expectPerformFullBackup() + expectClearState() + every { plugin.cancelFullBackup(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + backup.cancelFullBackup() + assertFalse(backup.hasState()) + } + + @Test + fun `clearState throws exception when flushing OutputStream`() { + expectPerformFullBackup() + every { outputStream.write(closeBytes) } just Runs + every { outputStream.flush() } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_ERROR, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `clearState ignores exception when closing OutputStream`() { + expectPerformFullBackup() + every { outputStream.flush() } just Runs + every { outputStream.close() } throws IOException() + every { inputStream.close() } just Runs + every { data.close() } just Runs + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `clearState ignores exception when closing InputStream`() { + expectPerformFullBackup() + every { outputStream.flush() } just Runs + every { outputStream.close() } just Runs + every { inputStream.close() } throws IOException() + every { data.close() } just Runs + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `clearState ignores exception when closing ParcelFileDescriptor`() { + expectPerformFullBackup() + every { outputStream.flush() } just Runs + every { outputStream.close() } just Runs + every { inputStream.close() } just Runs + every { data.close() } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + private fun expectPerformFullBackup() { + every { plugin.getOutputStream(packageInfo) } returns outputStream + every { inputFactory.getInputStream(data) } returns inputStream + every { headerWriter.writeVersion(outputStream, header) } just Runs + every { crypto.encryptHeader(outputStream, header) } just Runs + } + + private fun expectSendData(numBytes: Int, readBytes: Int = numBytes) { + every { plugin.getQuota() } returns quota + every { inputStream.read(any(), any(), numBytes) } returns readBytes + every { crypto.encryptSegment(outputStream, any()) } just Runs + } + + private fun expectClearState() { + every { outputStream.write(closeBytes) } just Runs + every { outputStream.flush() } just Runs + every { outputStream.close() } just Runs + every { inputStream.close() } just Runs + every { data.close() } just Runs + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt new file mode 100644 index 00000000..5362415b --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt @@ -0,0 +1,216 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupDataInput +import android.app.backup.BackupTransport.* +import com.stevesoltys.backup.getRandomString +import com.stevesoltys.backup.header.MAX_KEY_LENGTH_SIZE +import com.stevesoltys.backup.header.Utf8 +import com.stevesoltys.backup.header.VersionHeader +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.IOException +import java.util.* +import kotlin.random.Random + +internal class KVBackupTest : BackupTest() { + + private val plugin = mockk<KVBackupPlugin>() + private val dataInput = mockk<BackupDataInput>() + + private val backup = KVBackup(plugin, inputFactory, headerWriter, crypto) + + private val key = getRandomString(MAX_KEY_LENGTH_SIZE) + private val key64 = Base64.getEncoder().encodeToString(key.toByteArray(Utf8)) + private val value = ByteArray(23).apply { Random.nextBytes(this) } + private val versionHeader = VersionHeader(packageName = packageInfo.packageName, key = key) + + @Test + fun `now is a good time for a backup`() { + assertEquals(0, backup.requestBackupTime()) + } + + @Test + fun `has no initial state`() { + assertFalse(backup.hasState()) + } + + @Test + fun `simple backup with one record`() { + singleRecordBackup() + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `incremental backup with no data gets rejected`() { + every { plugin.hasDataForPackage(packageInfo) } returns false + + assertEquals(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, backup.performBackup(packageInfo, data, FLAG_INCREMENTAL)) + assertFalse(backup.hasState()) + } + + @Test + fun `check for existing data throws exception`() { + every { plugin.hasDataForPackage(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `non-incremental backup with data clears old data first`() { + singleRecordBackup(true) + every { plugin.removeDataOfPackage(packageInfo) } just Runs + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `ignoring exception when clearing data when non-incremental backup has data`() { + singleRecordBackup(true) + every { plugin.removeDataOfPackage(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `ensuring storage throws exception`() { + every { plugin.hasDataForPackage(packageInfo) } returns false + every { plugin.ensureRecordStorageForPackage(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while reading next header`() { + initPlugin(false) + createBackupDataInput() + every { dataInput.readNextHeader() } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while reading value`() { + initPlugin(false) + createBackupDataInput() + every { dataInput.readNextHeader() } returns true + every { dataInput.key } returns key + every { dataInput.dataSize } returns value.size + every { dataInput.readEntityData(any(), 0, value.size) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `no data records`() { + initPlugin(false) + getDataInput(listOf(false)) + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while writing version header`() { + initPlugin(false) + getDataInput(listOf(true)) + every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream + every { headerWriter.writeVersion(outputStream, versionHeader) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while writing encrypted value to output stream`() { + initPlugin(false) + getDataInput(listOf(true)) + writeHeaderAndEncrypt() + every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream + every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs + every { crypto.encryptSegment(outputStream, any()) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while flushing output stream`() { + initPlugin(false) + getDataInput(listOf(true)) + writeHeaderAndEncrypt() + every { outputStream.write(value) } just Runs + every { outputStream.flush() } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `ignoring exception while closing output stream`() { + initPlugin(false) + getDataInput(listOf(true, false)) + writeHeaderAndEncrypt() + every { outputStream.write(value) } just Runs + every { outputStream.flush() } just Runs + every { outputStream.close() } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + private fun singleRecordBackup(hasDataForPackage: Boolean = false) { + initPlugin(hasDataForPackage) + getDataInput(listOf(true, false)) + writeHeaderAndEncrypt() + every { outputStream.write(value) } just Runs + every { outputStream.flush() } just Runs + every { outputStream.close() } just Runs + } + + private fun initPlugin(hasDataForPackage: Boolean = false) { + every { plugin.hasDataForPackage(packageInfo) } returns hasDataForPackage + every { plugin.ensureRecordStorageForPackage(packageInfo) } just Runs + } + + private fun createBackupDataInput() { + every { inputFactory.getBackupDataInput(data) } returns dataInput + } + + private fun getDataInput(returnValues: List<Boolean>) { + createBackupDataInput() + every { dataInput.readNextHeader() } returnsMany returnValues + every { dataInput.key } returns key + every { dataInput.dataSize } returns value.size + every { dataInput.readEntityData(any(), 0, value.size) } returns value.size + } + + private fun writeHeaderAndEncrypt() { + every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream + every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs + every { crypto.encryptHeader(outputStream, versionHeader) } just Runs + every { crypto.encryptSegment(outputStream, any()) } just Runs + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt new file mode 100644 index 00000000..fd6ee47d --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt @@ -0,0 +1,173 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupTransport.* +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.header.UnsupportedVersionException +import com.stevesoltys.backup.header.VERSION +import com.stevesoltys.backup.header.VersionHeader +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.EOFException +import java.io.IOException +import kotlin.random.Random + +internal class FullRestoreTest : RestoreTest() { + + private val plugin = mockk<FullRestorePlugin>() + private val restore = FullRestore(plugin, outputFactory, headerReader, crypto) + + private val encrypted = getRandomByteArray() + private val outputStream = ByteArrayOutputStream() + private val versionHeader = VersionHeader(VERSION, packageInfo.packageName) + + @Test + fun `has no initial state`() { + assertFalse(restore.hasState()) + } + + @Test + fun `hasDataForPackage() delegates to plugin`() { + val result = Random.nextBoolean() + every { plugin.hasDataForPackage(token, packageInfo) } returns result + assertEquals(result, restore.hasDataForPackage(token, packageInfo)) + } + + @Test + fun `initializing state leaves a state`() { + assertFalse(restore.hasState()) + restore.initializeState(token, packageInfo) + assertTrue(restore.hasState()) + } + + @Test + fun `getting chunks without initializing state throws`() { + assertFalse(restore.hasState()) + assertThrows(IllegalStateException::class.java) { + restore.getNextFullRestoreDataChunk(fileDescriptor) + } + } + + @Test + fun `getting InputStream for package when getting first chunk throws`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } throws IOException() + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `reading version header when getting first chunk throws`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } throws IOException() + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `reading unsupported version when getting first chunk`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion) + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `decrypting version header when getting first chunk throws`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws IOException() + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `decrypting version header when getting first chunk throws security exception`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws SecurityException() + + assertEquals(TRANSPORT_ERROR, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `decrypting segment throws IOException`() { + restore.initializeState(token, packageInfo) + + initInputStream() + every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream + every { crypto.decryptSegment(inputStream) } throws IOException() + every { inputStream.close() } just Runs + every { fileDescriptor.close() } just Runs + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `decrypting segment throws EOFException`() { + restore.initializeState(token, packageInfo) + + initInputStream() + every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream + every { crypto.decryptSegment(inputStream) } throws EOFException() + every { inputStream.close() } just Runs + every { fileDescriptor.close() } just Runs + + assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `full chunk gets encrypted`() { + restore.initializeState(token, packageInfo) + + initInputStream() + readAndEncryptInputStream(encrypted) + every { inputStream.close() } just Runs + + assertEquals(encrypted.size, restore.getNextFullRestoreDataChunk(fileDescriptor)) + assertArrayEquals(encrypted, outputStream.toByteArray()) + restore.finishRestore() + assertFalse(restore.hasState()) + } + + @Test + fun `aborting full restore closes stream, resets state`() { + restore.initializeState(token, packageInfo) + + initInputStream() + readAndEncryptInputStream(encrypted) + + restore.getNextFullRestoreDataChunk(fileDescriptor) + + every { inputStream.close() } just Runs + + assertEquals(TRANSPORT_OK, restore.abortFullRestore()) + assertFalse(restore.hasState()) + } + + private fun initInputStream() { + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } returns versionHeader + } + + private fun readAndEncryptInputStream(encryptedBytes: ByteArray) { + every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream + every { crypto.decryptSegment(inputStream) } returns encryptedBytes + every { fileDescriptor.close() } just Runs + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt new file mode 100644 index 00000000..f0237ce8 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt @@ -0,0 +1,221 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupDataOutput +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.header.UnsupportedVersionException +import com.stevesoltys.backup.header.Utf8 +import com.stevesoltys.backup.header.VERSION +import com.stevesoltys.backup.header.VersionHeader +import io.mockk.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.IOException +import java.io.InputStream +import java.util.Base64.getUrlEncoder +import kotlin.random.Random + +internal class KVRestoreTest : RestoreTest() { + + private val plugin = mockk<KVRestorePlugin>() + private val output = mockk<BackupDataOutput>() + private val restore = KVRestore(plugin, outputFactory, headerReader, crypto) + + private val key = "Restore Key" + private val key64 = getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) + private val versionHeader = VersionHeader(VERSION, packageInfo.packageName, key) + private val key2 = "Restore Key2" + private val key264 = getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8)) + private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2) + + @Test + fun `hasDataForPackage() delegates to plugin`() { + val result = Random.nextBoolean() + + every { plugin.hasDataForPackage(token, packageInfo) } returns result + + assertEquals(result, restore.hasDataForPackage(token, packageInfo)) + } + + @Test + fun `getRestoreData() throws without initializing state`() { + assertThrows(IllegalStateException::class.java) { + restore.getRestoreData(fileDescriptor) + } + } + + @Test + fun `listing records throws`() { + restore.initializeState(token, packageInfo) + + every { plugin.listRecords(token, packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + } + + @Test + fun `reading VersionHeader with unsupported version throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion) + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `error reading VersionHeader throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `decrypting segment throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `decrypting header throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `decrypting header throws security exception`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws SecurityException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `writing header throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `writing value throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `writing value succeeds`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } returns data.size + streamsGetClosed() + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `writing two values succeeds`() { + val data2 = getRandomByteArray() + val inputStream2 = mockk<InputStream>() + restore.initializeState(token, packageInfo) + + getRecordsAndOutput(listOf(key64, key264)) + // first key/value + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } returns data.size + // second key/value + every { plugin.getInputStreamForRecord(token, packageInfo, key264) } returns inputStream2 + every { headerReader.readVersion(inputStream2) } returns VERSION + every { crypto.decryptHeader(inputStream2, VERSION, packageInfo.packageName, key2) } returns versionHeader2 + every { crypto.decryptSegment(inputStream2) } returns data2 + every { output.writeEntityHeader(key2, data2.size) } returns 42 + every { output.writeEntityData(data2, data2.size) } returns data2.size + every { inputStream2.close() } just Runs + streamsGetClosed() + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + } + + private fun getRecordsAndOutput(recordKeys: List<String> = listOf(key64)) { + every { plugin.listRecords(token, packageInfo) } returns recordKeys + every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output + } + + private fun streamsGetClosed() { + every { inputStream.close() } just Runs + every { fileDescriptor.close() } just Runs + } + + private fun verifyStreamWasClosed() { + verifyAll { + inputStream.close() + fileDescriptor.close() + } + } + +} 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 new file mode 100644 index 00000000..10aa6cb0 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt @@ -0,0 +1,189 @@ +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.transport.TransportTest +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.IOException +import kotlin.random.Random + +internal class RestoreCoordinatorTest : TransportTest() { + + private val plugin = mockk<RestorePlugin>() + private val kv = mockk<KVRestore>() + private val full = mockk<FullRestore>() + + private val restore = RestoreCoordinator(plugin, kv, full) + + private val token = Random.nextLong() + 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() } + + every { plugin.getAvailableRestoreSets() } returns restoreSets + + assertEquals(restoreSets, restore.getAvailableRestoreSets()) + } + + @Test + fun `getCurrentRestoreSet() delegates to plugin`() { + val currentRestoreSet = Random.nextLong() + + every { plugin.getCurrentRestoreSet() } returns currentRestoreSet + + assertEquals(currentRestoreSet, restore.getCurrentRestoreSet()) + } + + @Test + fun `startRestore() returns OK`() { + assertEquals(TRANSPORT_OK, restore.startRestore(token, packageInfoArray)) + } + + @Test + fun `startRestore() can not be called twice`() { + assertEquals(TRANSPORT_OK, restore.startRestore(token, packageInfoArray)) + assertThrows(IllegalStateException::class.javaObjectType) { + restore.startRestore(token, packageInfoArray) + } + } + + @Test + fun `nextRestorePackage() throws without startRestore()`() { + assertThrows(IllegalStateException::class.javaObjectType) { + restore.nextRestorePackage() + } + } + + @Test + fun `nextRestorePackage() returns KV description and takes precedence`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } returns true + every { kv.initializeState(token, packageInfo) } just Runs + + val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) + assertEquals(expected, restore.nextRestorePackage()) + } + + @Test + fun `nextRestorePackage() returns full description if no KV data found`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } returns false + every { full.hasDataForPackage(token, packageInfo) } returns true + every { full.initializeState(token, packageInfo) } just Runs + + val expected = RestoreDescription(packageInfo.packageName, TYPE_FULL_STREAM) + assertEquals(expected, restore.nextRestorePackage()) + } + + @Test + fun `nextRestorePackage() returns NO_MORE_PACKAGES if data found`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } returns false + every { full.hasDataForPackage(token, packageInfo) } returns false + + assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) + } + + @Test + fun `nextRestorePackage() returns all packages from startRestore()`() { + restore.startRestore(token, packageInfoArray2) + + every { kv.hasDataForPackage(token, packageInfo) } returns true + every { kv.initializeState(token, packageInfo) } just Runs + + val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) + assertEquals(expected, restore.nextRestorePackage()) + + every { kv.hasDataForPackage(token, packageInfo2) } returns false + every { full.hasDataForPackage(token, packageInfo2) } returns true + every { full.initializeState(token, packageInfo2) } just Runs + + val expected2 = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM) + assertEquals(expected2, restore.nextRestorePackage()) + + assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) + } + + @Test + fun `when kv#hasDataForPackage() throws return null`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } throws IOException() + + assertNull(restore.nextRestorePackage()) + } + + @Test + fun `when full#hasDataForPackage() throws return null`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } returns false + every { full.hasDataForPackage(token, packageInfo) } throws IOException() + + assertNull(restore.nextRestorePackage()) + } + + @Test + fun `getRestoreData() delegates to KV`() { + val data = mockk<ParcelFileDescriptor>() + val result = Random.nextInt() + + every { kv.getRestoreData(data) } returns result + + assertEquals(result, restore.getRestoreData(data)) + } + + @Test + fun `getNextFullRestoreDataChunk() delegates to Full`() { + val data = mockk<ParcelFileDescriptor>() + val result = Random.nextInt() + + every { full.getNextFullRestoreDataChunk(data) } returns result + + assertEquals(result, restore.getNextFullRestoreDataChunk(data)) + } + + @Test + fun `abortFullRestore() delegates to Full`() { + val result = Random.nextInt() + + every { full.abortFullRestore() } returns result + + assertEquals(result, restore.abortFullRestore()) + } + + @Test + fun `finishRestore() delegates to Full if it has state`() { + val hasState = Random.nextBoolean() + + every { full.hasState() } returns hasState + if (hasState) { + every { full.finishRestore() } just Runs + } + + restore.finishRestore() + } + + private fun assertEquals(expected: RestoreDescription, actual: RestoreDescription?) { + assertNotNull(actual) + assertEquals(expected.packageName, actual?.packageName) + assertEquals(expected.dataType, actual?.dataType) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt new file mode 100644 index 00000000..577f3a6c --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt @@ -0,0 +1,24 @@ +package com.stevesoltys.backup.transport.restore + +import android.os.ParcelFileDescriptor +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.transport.TransportTest +import com.stevesoltys.backup.header.HeaderReader +import com.stevesoltys.backup.header.VERSION +import io.mockk.mockk +import java.io.InputStream +import kotlin.random.Random + +internal abstract class RestoreTest : TransportTest() { + + protected val outputFactory = mockk<OutputFactory>() + protected val headerReader = mockk<HeaderReader>() + protected val fileDescriptor = mockk<ParcelFileDescriptor>() + + protected val token = Random.nextLong() + protected val data = getRandomByteArray() + protected val inputStream = mockk<InputStream>() + + protected val unsupportedVersion = (VERSION + 1).toByte() + +} diff --git a/build.gradle b/build.gradle index aee7f914..26dd7758 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ allprojects { mavenCentral() jcenter() google() + maven { url 'https://jitpack.io' } } }