Fix device initialization and generation of new backup tokens

This commit is contained in:
Torsten Grote 2019-12-23 09:04:10 -03:00
parent 81c2031ce7
commit 569e3db385
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
18 changed files with 266 additions and 149 deletions

View file

@ -4,6 +4,8 @@ import android.os.Build
import com.stevesoltys.seedvault.header.VERSION
import java.io.InputStream
typealias PackageMetadataMap = HashMap<String, PackageMetadata>
data class BackupMetadata(
internal val version: Byte = VERSION,
internal val token: Long,
@ -11,7 +13,7 @@ data class BackupMetadata(
internal val androidVersion: Int = Build.VERSION.SDK_INT,
internal val androidIncremental: String = Build.VERSION.INCREMENTAL,
internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}",
internal val packageMetadata: HashMap<String, PackageMetadata> = HashMap()
internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap()
)
internal const val JSON_METADATA = "@meta@"
@ -26,16 +28,18 @@ data class PackageMetadata(
internal var time: Long,
internal val version: Long? = null,
internal val installer: String? = null,
internal val sha256: String? = null,
internal val signatures: List<String>? = null
) {
fun hasApk(): Boolean {
return version != null && signatures != null
return version != null && sha256 != null && signatures != null
}
}
internal const val JSON_PACKAGE_TIME = "time"
internal const val JSON_PACKAGE_VERSION = "version"
internal const val JSON_PACKAGE_INSTALLER = "installer"
internal const val JSON_PACKAGE_SHA256 = "sha256"
internal const val JSON_PACKAGE_SIGNATURES = "signatures"
internal class DecryptionFailedException(cause: Throwable) : Exception(cause)

View file

@ -28,10 +28,8 @@ class MetadataManager(
field = try {
getMetadataFromCache() ?: throw IOException()
} catch (e: IOException) {
// create new default metadata
// Attention: If this happens due to a read error, we will overwrite remote metadata
Log.w(TAG, "Creating new metadata...")
BackupMetadata(token = clock.time())
// If this happens, it is hard to recover from this. Let's hope it never does.
throw AssertionError("Error reading metadata from cache", e)
}
}
return field
@ -40,14 +38,13 @@ class MetadataManager(
/**
* Call this when initializing a new device.
*
* A new backup token will be generated.
* Existing [BackupMetadata] will be cleared
* Existing [BackupMetadata] will be cleared, use the given new token,
* and written encrypted to the given [OutputStream] as well as the internal cache.
*/
@Synchronized
@Throws(IOException::class)
fun onDeviceInitialization(metadataOutputStream: OutputStream) {
metadata = BackupMetadata(token = clock.time())
fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) {
metadata = BackupMetadata(token = token)
metadataWriter.write(metadata, metadataOutputStream)
writeMetadataToCache()
}
@ -59,7 +56,7 @@ class MetadataManager(
*/
@Synchronized
fun onApkBackedUp(packageName: String, packageMetadata: PackageMetadata) {
metadata.packageMetadata[packageName]?.let {
metadata.packageMetadataMap[packageName]?.let {
check(it.time <= packageMetadata.time) {
"APK backup set time of $packageName backwards"
}
@ -70,7 +67,7 @@ class MetadataManager(
"APK backup backed up the same or a smaller version: was ${it.version} is ${packageMetadata.version}"
}
}
metadata.packageMetadata[packageName] = packageMetadata
metadata.packageMetadataMap[packageName] = packageMetadata
}
/**
@ -85,22 +82,28 @@ class MetadataManager(
val oldMetadata = metadata.copy()
val now = clock.time()
metadata.time = now
if (metadata.packageMetadata.containsKey(packageName)) {
metadata.packageMetadata[packageName]?.time = now
if (metadata.packageMetadataMap.containsKey(packageName)) {
metadata.packageMetadataMap[packageName]?.time = now
} else {
metadata.packageMetadata[packageName] = PackageMetadata(time = now)
metadata.packageMetadataMap[packageName] = PackageMetadata(time = now)
}
try {
metadataWriter.write(metadata, metadataOutputStream)
} catch (e: IOException) {
Log.w(TAG, "Error writing metadata to storage", e)
// revert metadata and do not write it to cache
// TODO also revert changes made by last [onApkBackedUp]
metadata = oldMetadata
throw IOException(e)
}
writeMetadataToCache()
}
/**
* Returns the current backup token.
*
* If the token is 0L, it is not yet initialized and must not be used for anything.
*/
@Synchronized
fun getBackupToken(): Long = metadata.token
@ -114,7 +117,7 @@ class MetadataManager(
@Synchronized
fun getPackageMetadata(packageName: String): PackageMetadata? {
return metadata.packageMetadata[packageName]?.copy()
return metadata.packageMetadataMap[packageName]?.copy()
}
@Synchronized
@ -129,7 +132,7 @@ class MetadataManager(
return null
} catch (e: FileNotFoundException) {
Log.d(TAG, "Cached metadata not found, creating...")
return null
return uninitializedMetadata
}
}

View file

@ -55,12 +55,13 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.")
}
// get package metadata
val packageMetadata: HashMap<String, PackageMetadata> = HashMap()
val packageMetadataMap = PackageMetadataMap()
for (packageName in json.keys()) {
if (packageName == JSON_METADATA) continue
val p = json.getJSONObject(packageName)
val pVersion = p.optLong(JSON_PACKAGE_VERSION, 0L)
val pInstaller = p.optString(JSON_PACKAGE_INSTALLER, "")
val pSha256 = p.optString(JSON_PACKAGE_SHA256)
val pSignatures = p.optJSONArray(JSON_PACKAGE_SIGNATURES)
val signatures = if (pSignatures == null) null else
ArrayList<String>(pSignatures.length()).apply {
@ -68,10 +69,11 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
add(pSignatures.getString(i))
}
}
packageMetadata[packageName] = PackageMetadata(
packageMetadataMap[packageName] = PackageMetadata(
time = p.getLong(JSON_PACKAGE_TIME),
version = if (pVersion == 0L) null else pVersion,
installer = if (pInstaller == "") null else pInstaller,
sha256 = if (pSha256 == "") null else pSha256,
signatures = signatures
)
}
@ -82,7 +84,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
androidVersion = meta.getInt(JSON_METADATA_SDK_INT),
androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL),
deviceName = meta.getString(JSON_METADATA_NAME),
packageMetadata = packageMetadata
packageMetadataMap = packageMetadataMap
)
} catch (e: JSONException) {
throw SecurityException(e)

View file

@ -33,11 +33,12 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
put(JSON_METADATA_NAME, metadata.deviceName)
})
}
for ((packageName, packageMetadata) in metadata.packageMetadata) {
for ((packageName, packageMetadata) in metadata.packageMetadataMap) {
json.put(packageName, JSONObject().apply {
put(JSON_PACKAGE_TIME, packageMetadata.time)
packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) }
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }
packageMetadata.sha256?.let { put(JSON_PACKAGE_SHA256, it) }
packageMetadata.signatures?.let { put(JSON_PACKAGE_SIGNATURES, JSONArray(it)) }
})
}

View file

@ -23,7 +23,13 @@ internal class DocumentsProviderBackupPlugin(
}
@Throws(IOException::class)
override fun initializeDevice() {
override fun initializeDevice(newToken: Long): Boolean {
// check if storage is already initialized
if (storage.isInitialized()) return false
// reset current storage
storage.reset(newToken)
// get or create root backup dir
storage.rootBackupDir ?: throw IOException()
@ -35,6 +41,8 @@ internal class DocumentsProviderBackupPlugin(
storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete()
kvDir?.deleteContents()
fullDir?.deleteContents()
return true
}
@Throws(IOException::class)

View file

@ -30,53 +30,91 @@ private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage(
private val context: Context,
private val metadataManager: MetadataManager,
settingsManager: SettingsManager) {
private val settingsManager: SettingsManager) {
private val storage: Storage? = settingsManager.getStorage()
internal val rootBackupDir: DocumentFile? by lazy {
val parent = storage?.getDocumentFile(context) ?: return@lazy null
try {
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(FILE_NO_MEDIA)
rootDir
} catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e)
null
internal var storage: Storage? = null
get() {
if (field == null) field = settingsManager.getStorage()
return field
}
internal var rootBackupDir: DocumentFile? = null
get() {
if (field == null) {
val parent = storage?.getDocumentFile(context) ?: return null
field = try {
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(FILE_NO_MEDIA)
rootDir
} catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e)
null
}
}
return field
}
private var currentToken: Long = 0L
get() {
if (field == 0L) field = metadataManager.getBackupToken()
return field
}
private var currentSetDir: DocumentFile? = null
get() {
if (field == null) {
if (currentToken == 0L) return null
field = try {
rootBackupDir?.createOrGetDirectory(currentToken.toString())
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
return field
}
var currentFullBackupDir: DocumentFile? = null
get() {
if (field == null) {
field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e)
null
}
}
return field
}
var currentKvBackupDir: DocumentFile? = null
get() {
if (field == null) {
field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e)
null
}
}
return field
}
fun isInitialized(): Boolean {
if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed
val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false
val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false
return kvEmpty && fullEmpty
}
private val currentToken: Long by lazy {
metadataManager.getBackupToken()
}
private val currentSetDir: DocumentFile? by lazy {
val currentSetName = currentToken.toString()
try {
rootBackupDir?.createOrGetDirectory(currentSetName)
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
val currentFullBackupDir: DocumentFile? by lazy {
try {
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e)
null
}
}
val currentKvBackupDir: DocumentFile? by lazy {
try {
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e)
null
}
fun reset(newToken: Long) {
storage = null
currentToken = newToken
rootBackupDir = null
currentSetDir = null
currentKvBackupDir = null
currentFullBackupDir = null
}
fun getAuthority(): String? = storage?.uri?.authority

View file

@ -5,6 +5,7 @@ import android.hardware.usb.UsbDevice
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import java.util.concurrent.atomic.AtomicBoolean
private const val PREF_KEY_STORAGE_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName"
@ -19,6 +20,8 @@ class SettingsManager(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private var isStorageChanging: AtomicBoolean = AtomicBoolean(false)
// FIXME Storage is currently plugin specific and not generic
fun setStorage(storage: Storage) {
prefs.edit()
@ -26,6 +29,7 @@ class SettingsManager(context: Context) {
.putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
.apply()
isStorageChanging.set(true)
}
fun getStorage(): Storage? {
@ -36,6 +40,10 @@ class SettingsManager(context: Context) {
return Storage(uri, name, isUsb)
}
fun getAndResetIsStorageChanging(): Boolean {
return isStorageChanging.getAndSet(false)
}
fun setFlashDrive(usb: FlashDrive?) {
if (usb == null) {
prefs.edit()

View file

@ -7,6 +7,7 @@ import android.content.pm.PackageManager
import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.util.Log
import android.util.PackageUtils.computeSha256DigestBytes
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.encodeBase64
@ -18,7 +19,6 @@ import java.io.FileNotFoundException
import java.io.IOException
import java.io.OutputStream
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
private val TAG = ApkBackup::class.java.simpleName
@ -45,23 +45,23 @@ class ApkBackup(
return false
}
// get cached metadata about package
val packageMetadata = metadataManager.getPackageMetadata(packageName)
?: PackageMetadata(time = clock.time())
// TODO remove when adding support in [signaturesChanged]
// TODO remove when adding support for packages with multiple signers
if (packageInfo.signingInfo.hasMultipleSigners()) {
Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.")
return false
}
// get signatures
val signatures = getSignatures(packageInfo.signingInfo)
val signatures = packageInfo.signingInfo.getSignatures()
if (signatures.isEmpty()) {
Log.e(TAG, "Package $packageName has no signatures. Not backing it up.")
return false
}
// get cached metadata about package
val packageMetadata = metadataManager.getPackageMetadata(packageName)
?: PackageMetadata(time = clock.time())
// get version codes
val version = packageInfo.longVersionCode
val backedUpVersion = packageMetadata.version ?: 0L // no version will cause backup
@ -84,12 +84,20 @@ class ApkBackup(
throw IOException(e)
}
// copy the APK to the storage's output
// copy the APK to the storage's output and calculate SHA-256 hash while at it
val messageDigest = MessageDigest.getInstance("SHA-256")
streamGetter.invoke().use { outputStream ->
inputStream.use { inputStream ->
inputStream.copyTo(outputStream)
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
outputStream.write(buffer, 0, bytes)
messageDigest.update(buffer, 0, bytes)
bytes = inputStream.read(buffer)
}
}
}
val sha256 = messageDigest.digest().encodeBase64()
Log.d(TAG, "Backed up new APK of $packageName with version $version.")
// update the metadata
@ -98,44 +106,37 @@ class ApkBackup(
time = clock.time(),
version = version,
installer = installer,
sha256 = sha256,
signatures = signatures
)
metadataManager.onApkBackedUp(packageName, updatedMetadata)
return true
}
private fun getSignatures(signingInfo: SigningInfo): List<String> {
val signatures = ArrayList<String>()
if (signingInfo.hasMultipleSigners()) {
for (sig in signingInfo.apkContentsSigners) {
signatures.add(hashSignature(sig).encodeBase64())
}
} else {
for (sig in signingInfo.signingCertificateHistory) {
signatures.add(hashSignature(sig).encodeBase64())
}
}
return signatures
}
private fun hashSignature(signature: Signature): ByteArray {
try {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(signature.toByteArray())
return digest.digest()
} catch (e: NoSuchAlgorithmException) {
Log.e(TAG, "No SHA-256 algorithm found!", e)
throw AssertionError(e)
}
}
private fun signaturesChanged(packageMetadata: PackageMetadata, signatures: List<String>): Boolean {
// no signatures in package metadata counts as them not having changed
if (packageMetadata.signatures == null) return false
// TODO this is probably more complicated, need to verify
// 1. multiple signers: need to match all signatures in list
// 2. single signer (with or without history): the intersection of both lists must not be empty.
// TODO to support multiple signers check if lists differ
return packageMetadata.signatures.intersect(signatures).isEmpty()
}
}
/**
* Returns a list of Base64 encoded SHA-256 signature hashes.
*/
fun SigningInfo.getSignatures(): List<String> {
return if (hasMultipleSigners()) {
apkContentsSigners.map { signature ->
hashSignature(signature).encodeBase64()
}
} else {
signingCertificateHistory.map { signature ->
hashSignature(signature).encodeBase64()
}
}
}
private fun hashSignature(signature: Signature): ByteArray {
return computeSha256DigestBytes(signature.toByteArray()) ?: throw AssertionError()
}

View file

@ -6,6 +6,7 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
@ -24,6 +25,7 @@ internal class BackupCoordinator(
private val kv: KVBackup,
private val full: FullBackup,
private val apkBackup: ApkBackup,
private val clock: Clock,
private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager) {
@ -48,7 +50,7 @@ internal class BackupCoordinator(
* for example, if there is no current live data-set at all,
* or there is no authenticated account under which to store the data remotely -
* the transport should return [TRANSPORT_OK] here
* and treat the initializeDevice() / finishBackup() pair as a graceful no-op.
* and treat the [initializeDevice] / [finishBackup] pair as a graceful no-op.
*
* @return One of [TRANSPORT_OK] (OK so far) or
* [TRANSPORT_ERROR] (to retry following network error or other failure).
@ -56,8 +58,13 @@ internal class BackupCoordinator(
fun initializeDevice(): Int {
Log.i(TAG, "Initialize Device!")
return try {
plugin.initializeDevice()
metadataManager.onDeviceInitialization(plugin.getMetadataOutputStream())
val token = clock.time()
if (plugin.initializeDevice(token)) {
Log.d(TAG, "Resetting backup metadata...")
metadataManager.onDeviceInitialization(token, plugin.getMetadataOutputStream())
} else {
Log.d(TAG, "Storage was already initialized, doing no-op")
}
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true

View file

@ -8,5 +8,5 @@ val backupModule = module {
single { ApkBackup(androidContext().packageManager, get(), get(), get()) }
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get()) }
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get()) }
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) }
}

View file

@ -12,9 +12,12 @@ interface BackupPlugin {
/**
* Initialize the storage for this device, erasing all stored data.
*
* @return true if the device needs initialization or
* false if the device was initialized already and initialization should be a no-op.
*/
@Throws(IOException::class)
fun initializeDevice()
fun initializeDevice(newToken: Long): Boolean
/**
* Returns an [OutputStream] for writing backup metadata.

View file

@ -18,7 +18,6 @@ import com.stevesoltys.seedvault.settings.BackupManagerSettings
import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
@ -107,9 +106,6 @@ internal abstract class StorageViewModel(
BackupManagerSettings.enableAutomaticBackups(app.contentResolver)
}
// stop backup service to be sure the old location will get updated
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
Log.d(TAG, "New storage location saved: $uri")
return storage.isUsb

View file

@ -30,8 +30,9 @@ class MetadataManagerTest {
private val manager = MetadataManager(context, clock, metadataWriter, metadataReader)
private val time = 42L
private val token = Random.nextLong()
private val packageName = getRandomString()
private val initialMetadata = BackupMetadata(token = time)
private val initialMetadata = BackupMetadata(token = token)
private val storageOutputStream = ByteArrayOutputStream()
private val cacheOutputStream: FileOutputStream = mockk()
private val cacheInputStream: FileInputStream = mockk()
@ -48,9 +49,9 @@ class MetadataManagerTest {
every { metadataWriter.write(initialMetadata, storageOutputStream) } just Runs
expectWriteToCache(initialMetadata)
manager.onDeviceInitialization(storageOutputStream)
manager.onDeviceInitialization(token, storageOutputStream)
assertEquals(time, manager.getBackupToken())
assertEquals(token, manager.getBackupToken())
assertEquals(0L, manager.getLastBackupTime())
}
@ -63,8 +64,7 @@ class MetadataManagerTest {
signatures = listOf("sig")
)
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
every { clock.time() } returns time
expectReadFromCache()
manager.onApkBackedUp(packageName, packageMetadata)
@ -73,14 +73,13 @@ class MetadataManagerTest {
@Test
fun `test onApkBackedUp() with existing package metadata`() {
val cachedMetadata = initialMetadata.copy()
val packageMetadata = PackageMetadata(
time = time,
version = Random.nextLong(Long.MAX_VALUE),
installer = getRandomString(),
signatures = listOf("sig")
)
cachedMetadata.packageMetadata[packageName] = packageMetadata
initialMetadata.packageMetadataMap[packageName] = packageMetadata
val updatedPackageMetadata = PackageMetadata(
time = time + 1,
version = packageMetadata.version!! + 1,
@ -88,7 +87,7 @@ class MetadataManagerTest {
signatures = listOf("sig foo")
)
expectReadFromCache(cachedMetadata)
expectReadFromCache()
manager.onApkBackedUp(packageName, updatedPackageMetadata)
@ -99,9 +98,9 @@ class MetadataManagerTest {
fun `test onPackageBackedUp()`() {
val updatedMetadata = initialMetadata.copy()
updatedMetadata.time = time
updatedMetadata.packageMetadata[packageName] = PackageMetadata(time)
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(time)
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
expectReadFromCache()
every { clock.time() } returns time
every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs
expectWriteToCache(updatedMetadata)
@ -116,10 +115,10 @@ class MetadataManagerTest {
val updateTime = time + 1
val updatedMetadata = initialMetadata.copy()
updatedMetadata.time = updateTime
updatedMetadata.packageMetadata[packageName] = PackageMetadata(updateTime)
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(updateTime)
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
every { clock.time() } returns time andThen updateTime
expectReadFromCache()
every { clock.time() } returns updateTime
every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException()
try {
@ -130,7 +129,7 @@ class MetadataManagerTest {
}
assertEquals(0L, manager.getLastBackupTime()) // time was reverted
assertEquals(initialMetadata.packageMetadata[packageName], manager.getPackageMetadata(packageName))
assertEquals(initialMetadata.packageMetadataMap[packageName], manager.getPackageMetadata(packageName))
}
@Test
@ -139,14 +138,14 @@ class MetadataManagerTest {
val cacheTime = time - 1
val cachedMetadata = initialMetadata.copy(time = cacheTime)
cachedMetadata.packageMetadata[cachedPackageName] = PackageMetadata(cacheTime)
cachedMetadata.packageMetadata[packageName] = PackageMetadata(cacheTime)
cachedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(cacheTime)
cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(cacheTime)
val updatedMetadata = cachedMetadata.copy(time = time)
cachedMetadata.packageMetadata[cachedPackageName] = PackageMetadata(time)
cachedMetadata.packageMetadata[packageName] = PackageMetadata(time)
cachedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(time)
cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(time)
expectReadFromCache(cachedMetadata)
expectReadFromCache()
every { clock.time() } returns time
every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs
expectWriteToCache(updatedMetadata)
@ -158,18 +157,42 @@ class MetadataManagerTest {
assertEquals(PackageMetadata(time), manager.getPackageMetadata(packageName))
}
@Test
fun `test getBackupToken() on first run`() {
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
assertEquals(0L, manager.getBackupToken())
}
@Test
fun `test getLastBackupTime() on first run`() {
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
assertEquals(0L, manager.getLastBackupTime())
}
@Test
fun `test getLastBackupTime() and getBackupToken() with cached metadata`() {
initialMetadata.time = Random.nextLong()
expectReadFromCache()
assertEquals(initialMetadata.time, manager.getLastBackupTime())
assertEquals(initialMetadata.token, manager.getBackupToken())
}
private fun expectWriteToCache(metadata: BackupMetadata) {
every { metadataWriter.encode(metadata) } returns encodedMetadata
every { context.openFileOutput(METADATA_CACHE_FILE, MODE_PRIVATE) } returns cacheOutputStream
every { cacheOutputStream.write(encodedMetadata) } just Runs
}
private fun expectReadFromCache(metadata: BackupMetadata) {
private fun expectReadFromCache() {
val byteArray = ByteArray(DEFAULT_BUFFER_SIZE)
every { context.openFileInput(METADATA_CACHE_FILE) } returns cacheInputStream
every { cacheInputStream.available() } returns byteArray.size andThen 0
every { cacheInputStream.read(byteArray) } returns -1
every { metadataReader.decode(ByteArray(0)) } returns metadata
every { metadataReader.decode(ByteArray(0)) } returns initialMetadata
}
}

View file

@ -84,6 +84,7 @@ class MetadataReaderTest {
time = Random.nextLong(),
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
signatures = listOf(getRandomString(), getRandomString())
))
}
@ -98,6 +99,7 @@ class MetadataReaderTest {
json.put("org.example", JSONObject().apply {
put(JSON_PACKAGE_VERSION, Random.nextLong())
put(JSON_PACKAGE_INSTALLER, getRandomString())
put(JSON_PACKAGE_SHA256, getRandomString())
put(JSON_PACKAGE_SIGNATURES, JSONArray(listOf(getRandomString(), getRandomString())))
})
val jsonBytes = json.toString().toByteArray(Utf8)
@ -115,8 +117,8 @@ class MetadataReaderTest {
val jsonBytes = json.toString().toByteArray(Utf8)
val result = decoder.decode(jsonBytes, metadata.version, metadata.token)
assertEquals(1, result.packageMetadata.size)
val packageMetadata = result.packageMetadata.getOrElse("org.example") { fail() }
assertEquals(1, result.packageMetadataMap.size)
val packageMetadata = result.packageMetadataMap.getOrElse("org.example") { fail() }
assertNull(packageMetadata.version)
assertNull(packageMetadata.installer)
assertNull(packageMetadata.signatures)
@ -130,7 +132,7 @@ class MetadataReaderTest {
androidVersion = Random.nextInt(),
androidIncremental = getRandomString(),
deviceName = getRandomString(),
packageMetadata = packageMetadata
packageMetadataMap = packageMetadata
)
}

View file

@ -40,6 +40,7 @@ internal class MetadataWriterDecoderTest {
time = Random.nextLong(),
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
signatures = listOf(getRandomString(), getRandomString())))
}
val metadata = getMetadata(packages)
@ -53,12 +54,14 @@ internal class MetadataWriterDecoderTest {
time = Random.nextLong(),
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
signatures = listOf(getRandomString())
))
put(getRandomString(), PackageMetadata(
time = Random.nextLong(),
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
signatures = listOf(getRandomString(), getRandomString())
))
}
@ -74,7 +77,7 @@ internal class MetadataWriterDecoderTest {
androidVersion = Random.nextInt(),
androidIncremental = getRandomString(),
deviceName = getRandomString(),
packageMetadata = packageMetadata
packageMetadataMap = packageMetadata
)
}

View file

@ -44,7 +44,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val apkBackup = mockk<ApkBackup>()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, apkBackup, metadataManager, settingsManager, notificationManager)
private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, apkBackup, clock, metadataManager, settingsManager, notificationManager)
private val restorePlugin = mockk<RestorePlugin>()
private val kvRestorePlugin = mockk<KVRestorePlugin>()

View file

@ -5,14 +5,11 @@ import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.Signature
import android.util.PackageUtils
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageMetadata
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
@ -32,13 +29,18 @@ internal class ApkBackupTest : BackupTest() {
private val apkBackup = ApkBackup(pm, clock, settingsManager, metadataManager)
private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03)
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
private val sigs = arrayOf(Signature(signatureBytes))
private val packageMetadata = PackageMetadata(
time = Random.nextLong(),
version = packageInfo.longVersionCode - 1,
signatures = listOf("A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E")
signatures = listOf("AwIB")
)
init {
mockkStatic(PackageUtils::class)
}
@Test
fun `does not back up @pm@`() {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
@ -96,7 +98,7 @@ internal class ApkBackupTest : BackupTest() {
@Test
fun `test successful APK backup`(@TempDir tmpDir: Path) {
val apkBytes = getRandomByteArray()
val apkBytes = byteArrayOf(0x04, 0x05, 0x06)
val tmpFile = File(tmpDir.toAbsolutePath().toString())
packageInfo.applicationInfo.sourceDir = File(tmpFile, "test.apk").apply {
assertTrue(createNewFile())
@ -107,6 +109,7 @@ internal class ApkBackupTest : BackupTest() {
time = Random.nextLong(),
version = packageInfo.longVersionCode,
installer = getRandomString(),
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
signatures = packageMetadata.signatures
)
@ -123,6 +126,7 @@ internal class ApkBackupTest : BackupTest() {
private fun expectChecks(packageMetadata: PackageMetadata = this.packageMetadata) {
every { settingsManager.backupApks() } returns true
every { metadataManager.getPackageMetadata(packageInfo.packageName) } returns packageMetadata
every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash
every { sigInfo.hasMultipleSigners() } returns false
every { sigInfo.signingCertificateHistory } returns sigs
}

View file

@ -26,15 +26,27 @@ internal class BackupCoordinatorTest: BackupTest() {
private val apkBackup = mockk<ApkBackup>()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(context, plugin, kv, full, apkBackup, metadataManager, settingsManager, notificationManager)
private val backup = BackupCoordinator(context, plugin, kv, full, apkBackup, clock, metadataManager, settingsManager, notificationManager)
private val metadataOutputStream = mockk<OutputStream>()
@Test
fun `device initialization succeeds and delegates to plugin`() {
every { plugin.initializeDevice() } just Runs
every { clock.time() } returns token
every { plugin.initializeDevice(token) } returns true // TODO test when false
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onDeviceInitialization(metadataOutputStream) } just Runs
every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs
every { kv.hasState() } returns false
every { full.hasState() } returns false
assertEquals(TRANSPORT_OK, backup.initializeDevice())
assertEquals(TRANSPORT_OK, backup.finishBackup())
}
@Test
fun `device initialization does no-op when already initialized`() {
every { clock.time() } returns token
every { plugin.initializeDevice(token) } returns false
every { kv.hasState() } returns false
every { full.hasState() } returns false
@ -46,7 +58,8 @@ internal class BackupCoordinatorTest: BackupTest() {
fun `error notification when device initialization fails`() {
val storage = Storage(Uri.EMPTY, getRandomString(), false)
every { plugin.initializeDevice() } throws IOException()
every { clock.time() } returns token
every { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getStorage() } returns storage
every { notificationManager.onBackupError() } just Runs
@ -65,7 +78,8 @@ internal class BackupCoordinatorTest: BackupTest() {
val storage = mockk<Storage>()
val documentFile = mockk<DocumentFile>()
every { plugin.initializeDevice() } throws IOException()
every { clock.time() } returns token
every { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile