Coverage Summary for Class: ZipStoredReader (com.georgeci.moneysurfer.data.backup.zip)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| ZipStoredReader |
100%
(6/6)
|
73.3%
(22/30)
|
92.6%
(50/54)
|
90%
(233/259)
|
| ZipStoredReader$LocalEntry |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(17/17)
|
| Total |
100%
(7/7)
|
73.3%
(22/30)
|
92.7%
(51/55)
|
90.6%
(250/276)
|
package com.georgeci.moneysurfer.data.backup.zip
import com.georgeci.moneysurfer.domain.backup.BackupError
import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource
/**
* Streaming reader for ZIPs produced by [ZipStoredWriter].
*
* Reads local-file-headers in order and ignores the trailing central directory.
* Only the STORED method is supported — a non-zero compression method is a
* hard error.
*/
internal class ZipStoredReader(private val source: BufferedSource) {
/** Reads the next entry's name. Returns `null` once the central directory is reached. */
fun nextEntryName(): String? {
if (source.exhausted()) return null
val signature = source.readIntLe()
if (signature != LOCAL_FILE_HEADER_SIGNATURE) {
// Treat anything else (central directory, EOCD) as end of stream.
return null
}
currentHeader = readLocalFileHeader()
return currentHeader?.name
}
/** Declared size of the most-recent entry, or `null` outside an entry. */
val currentEntrySize: Long?
get() = currentHeader?.size?.toLong()
/**
* Streams the body of the most-recent entry into [out] and verifies the CRC32.
* Throws [BackupError.CrcMismatch] on mismatch.
*/
fun readCurrentEntryTo(out: BufferedSink) {
val header = currentHeader ?: error("nextEntryName() returned null or was not called")
val crc = Crc32()
var remaining = header.size
val readBuffer = ByteArray(COPY_BUFFER_SIZE)
while (remaining > 0) {
val toRead = minOf(remaining.toLong(), readBuffer.size.toLong()).toInt()
val n = source.read(readBuffer, 0, toRead)
if (n == -1) {
throw BackupError.InvalidArchive("Truncated entry: ${header.name}")
}
crc.update(readBuffer, length = n)
out.write(readBuffer, 0, n)
remaining -= n
}
if (crc.getValue() != header.crc) {
throw BackupError.CrcMismatch(header.name)
}
currentHeader = null
}
/** Reads the current entry into a fresh [Buffer]. */
fun readCurrentEntryToBuffer(): Buffer {
val buffer = Buffer()
readCurrentEntryTo(buffer)
return buffer
}
private var currentHeader: LocalEntry? = null
private fun readLocalFileHeader(): LocalEntry {
val versionNeeded = source.readShortLe().toInt() and MASK_U16
val flags = source.readShortLe().toInt() and MASK_U16
val method = source.readShortLe().toInt() and MASK_U16
source.readShortLe()
source.readShortLe()
val crc = source.readIntLe().toLong() and MASK_U32
val compressedSize = source.readIntLe().toLong() and MASK_U32
val uncompressedSize = source.readIntLe().toLong() and MASK_U32
val nameLength = source.readShortLe().toInt() and MASK_U16
val extraLength = source.readShortLe().toInt() and MASK_U16
val problem = when {
method != COMPRESSION_METHOD_STORED ->
"Unsupported compression method: $method"
flags and GENERAL_PURPOSE_DATA_DESCRIPTOR_FLAG != 0 ->
"Data-descriptor entries are not supported"
versionNeeded > VERSION_NEEDED_TO_EXTRACT ->
"ZIP requires version $versionNeeded"
compressedSize != uncompressedSize ->
"STORED entry has mismatched sizes"
// The codec keeps each entry buffered as a single okio Segment chain,
// so anything above Int.MAX_VALUE bytes can't be addressed in the
// ByteArray copy loop and would silently overflow on the cast below.
// We don't ship multi-GB DBs, so reject up front.
uncompressedSize > Int.MAX_VALUE.toLong() ->
"Entry too large: $uncompressedSize bytes"
else -> null
}
if (problem != null) {
throw BackupError.InvalidArchive(problem)
}
val nameBytes = source.readByteArray(nameLength.toLong())
val name = nameBytes.decodeToString()
if (extraLength > 0) source.skip(extraLength.toLong())
return LocalEntry(name = name, size = uncompressedSize.toInt(), crc = crc)
}
private data class LocalEntry(val name: String, val size: Int, val crc: Long)
}
private const val GENERAL_PURPOSE_DATA_DESCRIPTOR_FLAG: Int = 1 shl 3
private const val COPY_BUFFER_SIZE: Int = 64 * 1024
private const val MASK_U16: Int = 0xFFFF
private const val MASK_U32: Long = 0xFFFFFFFFL