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")
+        }
+    }
+
+}