diff --git a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/Zip.android.kt b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/Zip.android.kt new file mode 100644 index 00000000..f705a4e9 --- /dev/null +++ b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/Zip.android.kt @@ -0,0 +1,37 @@ +package io.github.vinceglb.filekit + +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater + +internal actual class RawDeflater actual constructor() { + // nowrap: zip entries carry raw deflate, without the zlib header and trailer. + private val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true) + private val buffer = ByteArray(DEFLATE_BUFFER_BYTES) + + actual fun deflate(input: ByteArray, length: Int): ByteArray { + if (length <= 0) return ByteArray(0) + deflater.setInput(input, 0, length) + return drain { !deflater.needsInput() } + } + + actual fun finish(): ByteArray { + deflater.finish() + return drain { !deflater.finished() } + } + + actual fun close() { + deflater.end() + } + + private inline fun drain(hasMore: () -> Boolean): ByteArray { + val output = ByteArrayOutputStream() + while (hasMore()) { + val produced = deflater.deflate(buffer, 0, buffer.size) + if (produced <= 0) break + output.write(buffer, 0, produced) + } + return output.toByteArray() + } +} + +private const val DEFLATE_BUFFER_BYTES = 64 * 1024 diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/Zip.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/Zip.jvm.kt new file mode 100644 index 00000000..f705a4e9 --- /dev/null +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/Zip.jvm.kt @@ -0,0 +1,37 @@ +package io.github.vinceglb.filekit + +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater + +internal actual class RawDeflater actual constructor() { + // nowrap: zip entries carry raw deflate, without the zlib header and trailer. + private val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true) + private val buffer = ByteArray(DEFLATE_BUFFER_BYTES) + + actual fun deflate(input: ByteArray, length: Int): ByteArray { + if (length <= 0) return ByteArray(0) + deflater.setInput(input, 0, length) + return drain { !deflater.needsInput() } + } + + actual fun finish(): ByteArray { + deflater.finish() + return drain { !deflater.finished() } + } + + actual fun close() { + deflater.end() + } + + private inline fun drain(hasMore: () -> Boolean): ByteArray { + val output = ByteArrayOutputStream() + while (hasMore()) { + val produced = deflater.deflate(buffer, 0, buffer.size) + if (produced <= 0) break + output.write(buffer, 0, produced) + } + return output.toByteArray() + } +} + +private const val DEFLATE_BUFFER_BYTES = 64 * 1024 diff --git a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt index d719d23e..9a392770 100644 --- a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt +++ b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt @@ -7,17 +7,118 @@ import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import io.github.vinceglb.filekit.exceptions.FileKitException import io.github.vinceglb.filekit.mimeType.MimeType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.io.files.Path import java.io.File +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream import kotlin.coroutines.Continuation +import kotlin.io.path.createDirectory import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.io.path.writeText import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertTrue class PlatformFileJvmTest { + /** + * Nothing in the compression loop suspends on its own, so cancellation only works because the + * loop checks for it. Uses real time rather than runTest's virtual clock, which would skip the + * delay and cancel before any work had started. + */ + @Test + fun PlatformFile_zipTo_cancelledMidEntry_stopsAndLeavesNoArchive() = runBlocking { + val root = createTempDirectory("filekit-zip-cancel") + try { + // Big and barely compressible, so the deflater is still busy when the cancel lands. + val payload = ByteArray(64 * 1024 * 1024) + var seed = 1L + for (index in payload.indices) { + seed = seed * 6_364_136_223_846_793_005L + 1_442_695_040_888_963_407L + payload[index] = (seed ushr 33).toByte() + } + val source = root.resolve("big.bin") + source.writeBytes(payload) + val archive = PlatformFile(root.resolve("out.zip").toFile()) + + val job = launch(Dispatchers.Default) { PlatformFile(source.toFile()) zipTo archive } + delay(100) + job.cancelAndJoin() + + // If this fails because the job already finished, the case is no longer being + // exercised and the payload needs to grow. + assertTrue(job.isCancelled, "zip finished before it could be cancelled") + assertFalse(archive.file.exists(), "a cancelled zip should not leave a truncated archive") + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Reads the archive back with java.util.zip, so the format is judged by an implementation that + * knows nothing about the one that wrote it. + */ + @Test + fun PlatformFile_zipTo_directoryTree_roundTripsThroughJavaUtilZip() = runTest { + val root = createTempDirectory("filekit-zip-test") + try { + val tree = root.resolve("photos").createDirectory() + tree.resolve("a.txt").writeText("first") + tree.resolve("nested").createDirectory().resolve("b.txt").writeText("second") + val archive = PlatformFile(root.resolve("out.zip").toFile()) + + PlatformFile(tree.toFile()) zipTo archive + + val unpacked = mutableMapOf() + ZipInputStream(archive.file.inputStream()).use { input -> + while (true) { + val entry = input.nextEntry ?: break + unpacked[entry.name] = if (entry.isDirectory) "" else input.readBytes().decodeToString() + } + } + + assertEquals( + expected = setOf("photos/", "photos/a.txt", "photos/nested/", "photos/nested/b.txt"), + actual = unpacked.keys, + ) + assertEquals(expected = "first", actual = unpacked.getValue("photos/a.txt")) + assertEquals(expected = "second", actual = unpacked.getValue("photos/nested/b.txt")) + } finally { + root.toFile().deleteRecursively() + } + } + + /** ZipFile reads through the central directory and verifies each entry's CRC on close. */ + @Test + fun PlatformFile_zipTo_binaryContent_survivesByteForByteWithMatchingCrc() = runTest { + val root = createTempDirectory("filekit-zip-binary") + try { + val payload = ByteArray(200_000) { (it * 31 % 251).toByte() } + val source = root.resolve("payload.bin") + source.writeBytes(payload) + val archive = PlatformFile(root.resolve("out.zip").toFile()) + + PlatformFile(source.toFile()) zipTo archive + + ZipFile(archive.file).use { zip -> + val entry = zip.getEntry("payload.bin") + assertEquals(expected = payload.size.toLong(), actual = entry.size) + assertContentEquals(payload, zip.getInputStream(entry).use { it.readBytes() }) + } + } finally { + root.toFile().deleteRecursively() + } + } + private val resourceDirectory = PlatformFile(Path("src/nonWebTest/resources")) private val textFile = PlatformFile(resourceDirectory, "hello.txt") private val imageFile = PlatformFile(resourceDirectory, "compose-logo.png") diff --git a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/ZipMacosTest.kt b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/ZipMacosTest.kt new file mode 100644 index 00000000..9f0f79a3 --- /dev/null +++ b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/ZipMacosTest.kt @@ -0,0 +1,39 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") +@file:OptIn(ExperimentalForeignApi::class) + +package io.github.vinceglb.filekit + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.test.runTest +import platform.posix.system +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The zip container is shared code, already round-tripped through java.util.zip on the JVM. What + * only runs here is the zlib deflater behind [RawDeflater], so this hands the archive to the + * system's own `unzip`, which checks every entry's CRC against the compressed bytes. + */ +class ZipMacosTest { + @Test + fun PlatformFile_zipTo_writesArchiveThatSystemUnzipVerifies() = runTest { + val root = FileKit.projectDir / "build/zip-native-test" + val tree = root / "photos" + val nested = tree / "nested" + val archive = root / "out.zip" + try { + nested.createDirectories() + (tree / "a.txt").writeString("first") + // Repetitive enough that the deflater has to emit back-references rather than literals. + (nested / "b.txt").writeString("compress me ".repeat(2_000)) + + tree zipTo archive + + val status = system("unzip -t '${archive.absolutePath()}' > /dev/null 2>&1") + assertEquals(expected = 0, actual = status, "system unzip rejected the archive") + } finally { + listOf(nested / "b.txt", tree / "a.txt", nested, tree, archive, root) + .forEach { it.delete(mustExist = false) } + } + } +} diff --git a/filekit-core/src/nativeMain/kotlin/io/github/vinceglb/filekit/Zip.native.kt b/filekit-core/src/nativeMain/kotlin/io/github/vinceglb/filekit/Zip.native.kt new file mode 100644 index 00000000..ea475e0c --- /dev/null +++ b/filekit-core/src/nativeMain/kotlin/io/github/vinceglb/filekit/Zip.native.kt @@ -0,0 +1,109 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.exceptions.FileKitException +import kotlinx.cinterop.Arena +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.convert +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.sizeOf +import kotlinx.cinterop.usePinned +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import platform.zlib.ZLIB_VERSION +import platform.zlib.Z_DEFAULT_COMPRESSION +import platform.zlib.Z_DEFAULT_STRATEGY +import platform.zlib.Z_DEFLATED +import platform.zlib.Z_FINISH +import platform.zlib.Z_NO_FLUSH +import platform.zlib.Z_OK +import platform.zlib.Z_STREAM_ERROR +import platform.zlib.deflateEnd +import platform.zlib.deflateInit2_ +import platform.zlib.z_stream +import platform.zlib.deflate as zlibDeflate + +internal actual class RawDeflater actual constructor() { + private val arena = Arena() + private val stream = arena.alloc() + private val buffer = ByteArray(DEFLATE_BUFFER_BYTES) + private var closed = false + + init { + // A negative windowBits asks zlib for raw deflate, without the zlib header and trailer, + // which is what a zip entry holds. deflateInit2 itself is a macro, so the underlying + // deflateInit2_ is what cinterop exposes. + val result = deflateInit2_( + strm = stream.ptr, + level = Z_DEFAULT_COMPRESSION, + method = Z_DEFLATED, + windowBits = -MAX_WINDOW_BITS, + memLevel = DEFAULT_MEM_LEVEL, + strategy = Z_DEFAULT_STRATEGY, + version = ZLIB_VERSION, + stream_size = sizeOf().convert(), + ) + if (result != Z_OK) { + arena.clear() + throw FileKitException("Could not start compression: zlib returned $result") + } + } + + actual fun deflate(input: ByteArray, length: Int): ByteArray { + if (length <= 0) return ByteArray(0) + return input.usePinned { pinnedInput -> + stream.next_in = pinnedInput.addressOf(0).reinterpret() + stream.avail_in = length.convert() + pump(Z_NO_FLUSH) + } + } + + actual fun finish(): ByteArray { + stream.next_in = null + stream.avail_in = 0u + return pump(Z_FINISH) + } + + actual fun close() { + if (closed) return + closed = true + deflateEnd(stream.ptr) + arena.clear() + } + + /** + * Runs zlib until it stops filling the output buffer. A full buffer means there is more to + * come, so the loop only ends once zlib leaves room, which is its signal that it is done for + * the input it has. + */ + private fun pump(flush: Int): ByteArray { + val output = Buffer() + while (true) { + val produced = buffer.usePinned { pinnedOutput -> + stream.next_out = pinnedOutput.addressOf(0).reinterpret() + stream.avail_out = buffer.size.convert() + val result = zlibDeflate(stream.ptr, flush) + if (result == Z_STREAM_ERROR) { + throw FileKitException("Compression failed: zlib returned $result") + } + buffer.size - stream.avail_out.toInt() + } + if (produced > 0) { + output.write(buffer, 0, produced) + } + if (produced < buffer.size) break + } + return output.readByteArray() + } +} + +private const val DEFLATE_BUFFER_BYTES = 64 * 1024 +private const val DEFAULT_MEM_LEVEL = 8 + +// zlib's MAX_WBITS is a macro, so cinterop does not surface it. 15 is the largest window zlib +// supports and the value zip entries are written with. +private const val MAX_WINDOW_BITS = 15 diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/Zip.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/Zip.kt new file mode 100644 index 00000000..acf92250 --- /dev/null +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/Zip.kt @@ -0,0 +1,332 @@ +@file:OptIn(ExperimentalTime::class) + +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.exceptions.FileKitException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.io.Sink +import kotlinx.io.buffered +import kotlinx.io.readByteArray +import kotlinx.io.writeIntLe +import kotlinx.io.writeShortLe +import kotlin.coroutines.coroutineContext +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Writes this file, or this whole directory, into a zip archive at [destination]. + * + * A directory is stored with its own name as the top-level entry, so unzipping recreates the folder + * rather than spilling its contents into the current directory. + * + * @param destination The archive to create. An existing file is overwritten. + */ +public suspend infix fun PlatformFile.zipTo(destination: PlatformFile): Unit = + listOf(this).zipTo(destination) + +/** + * Writes all of these files and directories into a single zip archive at [destination]. + * + * Entries are named after each item, so zipping `report.pdf` and a `photos/` directory produces an + * archive holding `report.pdf` and `photos/...`. Two items sharing a name would collide, so that is + * rejected rather than silently written. + * + * Symbolic links are followed, as `zip -r` does, but a link that leads back into a directory + * already being written is skipped so a cycle cannot run forever. + * + * @param destination The archive to create. An existing file is overwritten. + */ +public suspend infix fun List.zipTo(destination: PlatformFile) { + if (isEmpty()) { + throw FileKitException("Nothing to zip.") + } + + val duplicate = map { it.name }.groupingBy { it }.eachCount().entries.firstOrNull { it.value > 1 } + if (duplicate != null) { + throw FileKitException("Cannot zip two entries both named \"${duplicate.key}\".") + } + + withContext(Dispatchers.IO) { + val missing = firstOrNull { !it.exists() } + if (missing != null) { + throw FileKitException("Cannot zip \"${missing.name}\": it does not exist.") + } + + try { + destination.sink().buffered().use { sink -> + val writer = ZipWriter(sink) + forEach { source -> writer.add(source, source.name, mutableSetOf()) } + writer.finish() + } + } catch (error: Throwable) { + // A truncated archive still opens like an archive, and cancellation can now land + // mid-entry, so half a zip is a real outcome rather than a theoretical one. + // + // NonCancellable is load-bearing: delete() suspends, and on the already-cancelled job + // that got us here it would throw before removing anything. + withContext(NonCancellable) { runCatching { destination.delete(mustExist = false) } } + throw error + } + } +} + +private class ZipWriter(private val sink: Sink) { + private var offset = 0L + private val entries = mutableListOf() + + suspend fun add(source: PlatformFile, entryName: String, visited: MutableSet) { + if (source.isDirectory()) { + // Absolute paths, not names: a link pointing back at an ancestor is the shape that + // would otherwise recurse forever. + if (!visited.add(source.absolutePath())) return + writeDirectoryEntry("$entryName/", source.lastModified()) + source.list().forEach { child -> add(child, "$entryName/${child.name}", visited) } + visited.remove(source.absolutePath()) + } else { + writeFileEntry(source, entryName) + } + } + + private fun writeDirectoryEntry(entryName: String, modifiedAt: Instant) { + val nameBytes = entryName.encodeToByteArray() + val localHeaderOffset = offset + val (time, date) = modifiedAt.toDosDateTime() + + writeLocalHeader(nameBytes, METHOD_STORED, time, date, crc = 0, size = 0) + entries += CentralDirectoryEntry(nameBytes, METHOD_STORED, time, date, 0, 0, 0, localHeaderOffset, isDirectory = true) + } + + private suspend fun writeFileEntry(source: PlatformFile, entryName: String) { + val nameBytes = entryName.encodeToByteArray() + val localHeaderOffset = offset + val (time, date) = source.lastModified().toDosDateTime() + + // Sizes and CRC are only known once the whole file has gone through the deflater, and the + // sink cannot seek back to patch the header. Bit 3 says so and moves them after the data. + writeLocalHeader(nameBytes, METHOD_DEFLATED, time, date, crc = 0, size = 0, useDataDescriptor = true) + + val crc = Crc32() + val deflater = RawDeflater() + var uncompressed = 0L + var compressed = 0L + try { + source.source().buffered().use { input -> + val chunk = ByteArray(COPY_CHUNK_BYTES) + while (true) { + // Nothing in this loop suspends on its own, so without this a cancelled job + // would keep compressing to the end of the file. + coroutineContext.ensureActive() + val read = input.readAtMostTo(chunk, 0, chunk.size) + if (read <= 0) break + crc.update(chunk, read) + uncompressed += read + compressed += writeRaw(deflater.deflate(chunk, read)) + } + compressed += writeRaw(deflater.finish()) + } + } finally { + deflater.close() + } + + writeRaw( + buildBytes { + writeIntLe(SIGNATURE_DATA_DESCRIPTOR) + writeIntLe(crc.value.toInt()) + writeIntLe(compressed.toInt()) + writeIntLe(uncompressed.toInt()) + }, + ) + + entries += CentralDirectoryEntry( + nameBytes = nameBytes, + method = METHOD_DEFLATED, + time = time, + date = date, + crc = crc.value, + compressedSize = compressed, + uncompressedSize = uncompressed, + localHeaderOffset = localHeaderOffset, + isDirectory = false, + ) + } + + private fun writeLocalHeader( + nameBytes: ByteArray, + method: Int, + time: Int, + date: Int, + crc: Long, + size: Long, + useDataDescriptor: Boolean = false, + ) { + writeRaw( + buildBytes { + writeIntLe(SIGNATURE_LOCAL_HEADER) + writeShortLe(VERSION_NEEDED.toShort()) + writeShortLe((if (useDataDescriptor) FLAG_UTF8 or FLAG_DATA_DESCRIPTOR else FLAG_UTF8).toShort()) + writeShortLe(method.toShort()) + writeShortLe(time.toShort()) + writeShortLe(date.toShort()) + writeIntLe(crc.toInt()) + writeIntLe(size.toInt()) + writeIntLe(size.toInt()) + writeShortLe(nameBytes.size.toShort()) + writeShortLe(0) + write(nameBytes) + }, + ) + } + + fun finish() { + val centralDirectoryOffset = offset + entries.forEach { entry -> + writeRaw( + buildBytes { + writeIntLe(SIGNATURE_CENTRAL_HEADER) + writeShortLe(VERSION_NEEDED.toShort()) + writeShortLe(VERSION_NEEDED.toShort()) + writeShortLe( + (if (entry.isDirectory) FLAG_UTF8 else FLAG_UTF8 or FLAG_DATA_DESCRIPTOR).toShort(), + ) + writeShortLe(entry.method.toShort()) + writeShortLe(entry.time.toShort()) + writeShortLe(entry.date.toShort()) + writeIntLe(entry.crc.toInt()) + writeIntLe(entry.compressedSize.toInt()) + writeIntLe(entry.uncompressedSize.toInt()) + writeShortLe(entry.nameBytes.size.toShort()) + writeShortLe(0) + writeShortLe(0) + writeShortLe(0) + writeShortLe(0) + writeIntLe(if (entry.isDirectory) EXTERNAL_ATTRIBUTES_DIRECTORY else 0) + writeIntLe(entry.localHeaderOffset.toInt()) + write(entry.nameBytes) + }, + ) + } + val centralDirectorySize = offset - centralDirectoryOffset + + writeRaw( + buildBytes { + writeIntLe(SIGNATURE_END_OF_CENTRAL_DIRECTORY) + writeShortLe(0) + writeShortLe(0) + writeShortLe(entries.size.toShort()) + writeShortLe(entries.size.toShort()) + writeIntLe(centralDirectorySize.toInt()) + writeIntLe(centralDirectoryOffset.toInt()) + writeShortLe(0) + }, + ) + } + + private fun writeRaw(bytes: ByteArray): Int { + if (bytes.isNotEmpty()) { + sink.write(bytes) + offset += bytes.size + } + return bytes.size + } +} + +private class CentralDirectoryEntry( + val nameBytes: ByteArray, + val method: Int, + val time: Int, + val date: Int, + val crc: Long, + val compressedSize: Long, + val uncompressedSize: Long, + val localHeaderOffset: Long, + val isDirectory: Boolean, +) + +private inline fun buildBytes(block: Sink.() -> Unit): ByteArray = + kotlinx.io.Buffer().apply(block).readByteArray() + +/** + * Zip stores the modification time as MS-DOS date and time words: a two second resolution, and no + * timezone, counted from 1980. Anything older is clamped to that floor, which is what the format + * can represent. + */ +private fun Instant.toDosDateTime(): Pair { + val epochSeconds = epochSeconds + val days = epochSeconds.floorDiv(SECONDS_PER_DAY) + val secondOfDay = epochSeconds.mod(SECONDS_PER_DAY).toInt() + + // civil-from-days: shifts the epoch to March 1st so leap days land at the end of the cycle. + val shifted = days + DAYS_FROM_0000_03_01_TO_EPOCH + val era = (if (shifted >= 0) shifted else shifted - 146_096L) / 146_097L + val dayOfEra = shifted - era * 146_097L + val yearOfEra = (dayOfEra - dayOfEra / 1_460L + dayOfEra / 36_524L - dayOfEra / 146_096L) / 365L + val dayOfYear = dayOfEra - (365L * yearOfEra + yearOfEra / 4L - yearOfEra / 100L) + val monthIndex = (5L * dayOfYear + 2L) / 153L + val day = (dayOfYear - (153L * monthIndex + 2L) / 5L + 1L).toInt() + val month = (if (monthIndex < 10L) monthIndex + 3L else monthIndex - 9L).toInt() + val year = (yearOfEra + era * 400L + if (month <= 2) 1L else 0L).toInt() + + if (year < DOS_EPOCH_YEAR) return 0 to (1 shl 5 or 1) + + val time = (secondOfDay / 3600 shl 11) or (secondOfDay % 3600 / 60 shl 5) or (secondOfDay % 60 / 2) + val date = (year - DOS_EPOCH_YEAR shl 9) or (month shl 5) or day + return time to date +} + +/** Feeds bytes through raw deflate, the compression zip entries use. */ +internal expect class RawDeflater() { + /** Compresses the first [length] bytes of [input], returning whatever output is ready. */ + fun deflate(input: ByteArray, length: Int): ByteArray + + /** Ends the stream and returns the remaining output. */ + fun finish(): ByteArray + + fun close() +} + +/** + * CRC-32 as zip defines it. Written here rather than taken from each platform because the algorithm + * is fixed and tiny, and one implementation means one thing to be wrong. + */ +internal class Crc32 { + private var crc = 0xFFFFFFFFuL.toLong() + + fun update(input: ByteArray, length: Int) { + for (index in 0 until length) { + val position = (crc xor (input[index].toLong() and 0xFF)).toInt() and 0xFF + crc = TABLE[position] xor (crc ushr 8) + } + } + + val value: Long + get() = crc xor 0xFFFFFFFFuL.toLong() + + private companion object { + val TABLE = LongArray(256) { index -> + var value = index.toLong() + repeat(8) { + value = if (value and 1L != 0L) 0xEDB88320L xor (value ushr 1) else value ushr 1 + } + value + } + } +} + +private const val SIGNATURE_LOCAL_HEADER = 0x04034b50 +private const val SIGNATURE_DATA_DESCRIPTOR = 0x08074b50 +private const val SIGNATURE_CENTRAL_HEADER = 0x02014b50 +private const val SIGNATURE_END_OF_CENTRAL_DIRECTORY = 0x06054b50 +private const val VERSION_NEEDED = 20 +private const val FLAG_DATA_DESCRIPTOR = 1 shl 3 +private const val FLAG_UTF8 = 1 shl 11 +private const val METHOD_STORED = 0 +private const val METHOD_DEFLATED = 8 +private const val EXTERNAL_ATTRIBUTES_DIRECTORY = 0x10 +private const val COPY_CHUNK_BYTES = 64 * 1024 +private const val SECONDS_PER_DAY = 86_400L +private const val DAYS_FROM_0000_03_01_TO_EPOCH = 719_468L +private const val DOS_EPOCH_YEAR = 1980 diff --git a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt index da9c2654..24d76b11 100644 --- a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt +++ b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt @@ -109,6 +109,65 @@ class PlatformFileNonWebTest { PlatformFile("").delete(mustExist = false) } + @Test + fun PlatformFile_zipTo_writesAnArchiveThatStartsAndEndsWithZipSignatures() = runTest { + val archive = resourceDirectory / "zip-signatures.zip" + try { + textFile zipTo archive + + val bytes = archive.readBytes() + assertTrue(bytes.size > 4, "archive should not be empty") + assertContentEquals(byteArrayOf(0x50, 0x4B, 0x03, 0x04), bytes.copyOfRange(0, 4)) + // End of central directory, with no trailing comment. + assertContentEquals(byteArrayOf(0x50, 0x4B, 0x05, 0x06), bytes.copyOfRange(bytes.size - 22, bytes.size - 18)) + } finally { + archive.delete(mustExist = false) + } + } + + @Test + fun PlatformFile_zipTo_compressesRatherThanStoring() = runTest { + val source = resourceDirectory / "zip-compressible.txt" + val archive = resourceDirectory / "zip-compressible.zip" + try { + source.writeString("compress me ".repeat(2_000)) + + source zipTo archive + + assertTrue( + archive.size() < source.size() / 2, + "expected deflate to shrink repetitive text well below half: ${archive.size()} vs ${source.size()}", + ) + } finally { + source.delete(mustExist = false) + archive.delete(mustExist = false) + } + } + + @Test + fun List_zipTo_emptyList_fails() = runTest { + assertFailsWith { + emptyList() zipTo (resourceDirectory / "never-written.zip") + } + } + + @Test + fun List_zipTo_duplicateEntryNames_fails() = runTest { + assertFailsWith { + listOf(textFile, textFile) zipTo (resourceDirectory / "never-written.zip") + } + } + + @Test + fun PlatformFile_zipTo_missingSource_fails() = runTest { + val archive = resourceDirectory / "zip-missing-source.zip" + try { + assertFailsWith { notExistingFile zipTo archive } + } finally { + archive.delete(mustExist = false) + } + } + @Test fun testPlatformFileReadBytes() = runTest { val textFileContent = textFile.readString()