Properly handle exception while writing zip chunk entries
diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt
index 3979f1b..606e879 100644
--- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt
+++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt
@@ -17,7 +17,6 @@
import java.nio.file.attribute.FileTime
import java.security.GeneralSecurityException
import java.util.zip.ZipEntry
-import java.util.zip.ZipOutputStream
import kotlin.math.min
internal data class ChunkWriterResult(
@@ -121,9 +120,17 @@
}
@Throws(IOException::class)
- fun writeNewZipEntry(zipOutputStream: ZipOutputStream, counter: Int, inputStream: InputStream) {
- val entry = createNewZipEntry(counter)
- zipOutputStream.putNextEntry(entry)
+ fun writeNewZipEntry(
+ zipOutputStream: NameZipOutputStream,
+ counter: Int,
+ inputStream: InputStream,
+ ) {
+ // If copying below throws an exception, we'd be adding a new entry with the same counter,
+ // so we check if we have added an entry for that counter already to prevent duplicates.
+ if ((zipOutputStream.lastName?.toIntOrNull() ?: 0) != counter) {
+ val entry = createNewZipEntry(counter)
+ zipOutputStream.putNextEntry(entry)
+ }
inputStream.copyTo(zipOutputStream)
zipOutputStream.closeEntry()
}
diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt
index f98f932..88efa1b 100644
--- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt
+++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt
@@ -11,7 +11,9 @@
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
+import java.io.OutputStream
import java.security.GeneralSecurityException
+import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.crypto.Mac
@@ -36,7 +38,7 @@
private val files = ArrayList<ContentFile>()
private val outputStream = ByteArrayOutputStream(chunkSizeMax)
- private var zipOutputStream = ZipOutputStream(outputStream)
+ private var zipOutputStream = NameZipOutputStream(outputStream)
// we start with 1, because 0 is the default value in protobuf 3
private var counter = 1
@@ -77,8 +79,21 @@
private fun reset() {
files.clear()
outputStream.reset()
- zipOutputStream = ZipOutputStream(outputStream)
+ zipOutputStream = NameZipOutputStream(outputStream)
counter = 1
}
}
+
+/**
+ * A wrapper for [ZipOutputStream] that remembers the name of the last [ZipEntry] that was added.
+ */
+internal class NameZipOutputStream(outputStream: OutputStream) : ZipOutputStream(outputStream) {
+ internal var lastName: String? = null
+ private set
+
+ override fun putNextEntry(e: ZipEntry) {
+ super.putNextEntry(e)
+ lastName = e.name
+ }
+}
diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt
new file mode 100644
index 0000000..4d8214e
--- /dev/null
+++ b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt
@@ -0,0 +1,115 @@
+/*
+ * SPDX-FileCopyrightText: 2021 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.calyxos.backup.storage.backup
+
+import android.content.ContentResolver
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import kotlinx.coroutines.runBlocking
+import org.calyxos.backup.storage.api.BackupObserver
+import org.calyxos.backup.storage.api.StoragePlugin
+import org.calyxos.backup.storage.crypto.Hkdf.KEY_SIZE_BYTES
+import org.calyxos.backup.storage.crypto.StreamCrypto
+import org.calyxos.backup.storage.db.CachedChunk
+import org.calyxos.backup.storage.db.ChunksCache
+import org.calyxos.backup.storage.db.FilesCache
+import org.calyxos.backup.storage.getRandomDocFile
+import org.calyxos.backup.storage.getRandomString
+import org.calyxos.backup.storage.mockLog
+import org.calyxos.backup.storage.toHexString
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.io.InputStream
+import javax.crypto.Mac
+import kotlin.random.Random
+
+internal class SmallFileBackupIntegrationTest {
+
+ private val contentResolver: ContentResolver = mockk()
+ private val filesCache: FilesCache = mockk()
+ private val mac: Mac = mockk()
+ private val chunksCache: ChunksCache = mockk()
+ private val storagePlugin: StoragePlugin = mockk()
+
+ private val chunkWriter = ChunkWriter(
+ streamCrypto = StreamCrypto,
+ streamKey = Random.nextBytes(KEY_SIZE_BYTES),
+ chunksCache = chunksCache,
+ storagePlugin = storagePlugin,
+ )
+ private val zipChunker = ZipChunker(
+ mac = mac,
+ chunkWriter = chunkWriter,
+ )
+
+ private val smallFileBackup = SmallFileBackup(contentResolver, filesCache, zipChunker, true)
+
+ init {
+ mockLog()
+ }
+
+ /**
+ * This tests that if writing out one ZIP entry throws an exception,
+ * the subsequent entries can still be written.
+ * Previously, we'd start a new ZipEntry with the same counter value
+ * which is not allowed, so all subsequent files would also not get backed up.
+ */
+ @Test
+ fun `first of two new files throws and gets ignored`(): Unit = runBlocking {
+ val file1 = getRandomDocFile()
+ val file2 = getRandomDocFile()
+ val files = listOf(file1, file2)
+ val availableChunkIds = hashSetOf(getRandomString(6))
+ val observer: BackupObserver = mockk()
+
+ val inputStream1: InputStream = mockk()
+ val inputStream2: InputStream = ByteArrayInputStream(Random.nextBytes(42))
+ val outputStream2 = ByteArrayOutputStream()
+
+ val chunkId = Random.nextBytes(KEY_SIZE_BYTES)
+ val cachedChunk = CachedChunk(chunkId.toHexString(), 0, 181, 0)
+ val cachedFile2 = file2.toCachedFile(listOf(chunkId.toHexString()), 1)
+ val backupFile = file2.toBackupFile(cachedFile2.chunks, cachedFile2.zipIndex)
+
+ every { filesCache.getByUri(file1.uri) } returns null
+ every { filesCache.getByUri(file2.uri) } returns null
+
+ every { contentResolver.openInputStream(file1.uri) } returns inputStream1
+ every { contentResolver.openInputStream(file2.uri) } returns inputStream2
+
+ every { inputStream1.read(any<ByteArray>()) } throws IOException()
+ coEvery { observer.onFileBackupError(file1, "S") } just Runs
+
+ every { mac.doFinal(any<ByteArray>()) } returns chunkId
+ every { chunksCache.get(any()) } returns null
+ every { storagePlugin.getChunkOutputStream(any()) } returns outputStream2
+ every { chunksCache.insert(cachedChunk) } just Runs
+ every {
+ filesCache.upsert(match {
+ it.copy(lastSeen = cachedFile2.lastSeen) == cachedFile2
+ })
+ } just Runs
+ coEvery { observer.onFileBackedUp(file2, true, 0, 181, "S") } just Runs
+
+ val result = smallFileBackup.backupFiles(files, availableChunkIds, observer)
+ assertEquals(setOf(chunkId.toHexString()), result.chunkIds)
+ assertEquals(1, result.backupDocumentFiles.size)
+ assertEquals(backupFile, result.backupDocumentFiles[0])
+ assertEquals(0, result.backupMediaFiles.size)
+
+ coVerify {
+ observer.onFileBackedUp(file2, true, 0, 181, "S")
+ }
+ }
+
+}