From 7067e6fb10c21cb39eb69061b451ce796b0b2a8f Mon Sep 17 00:00:00 2001 From: "anggrayudi.hardiannico" Date: Sun, 16 Aug 2026 04:28:54 +0700 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20zipTo=20for=20files=20and?= =?UTF-8?q?=20folders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file zipTo archive` writes one file or a whole directory tree, and `listOf(a, b) zipTo archive` writes several into one archive. A directory keeps its own name as the top-level entry, so unzipping recreates the folder instead of spilling its contents. The zip container is written in shared Kotlin — local headers, data descriptors, central directory, EOCD, CRC-32 and the DOS timestamp — so every platform emits the same bytes and the format lives in one place. Only the deflater is per-platform: java.util.zip.Deflater on JVM and Android, zlib's deflateInit2_ with a negative window on native, which covers Apple, Linux and Windows from a single nativeMain implementation. Entries stream through a 64 KB chunk rather than being buffered whole, so archiving a large file does not need it in memory. Sizes and the CRC are therefore unknown when the local header is written, which is what the data descriptor flag is for. Symlinks are followed as `zip -r` does, but a link leading back into a directory already being written is skipped, so a cycle cannot recurse forever. Web is out of scope, matching delete() and createDirectories(). Closes #298 Co-Authored-By: Claude Opus 5 (1M context) --- .../io/github/vinceglb/filekit/Zip.android.kt | 37 ++ .../io/github/vinceglb/filekit/Zip.jvm.kt | 37 ++ .../vinceglb/filekit/PlatformFileJvmTest.kt | 62 ++++ .../github/vinceglb/filekit/ZipMacosTest.kt | 39 +++ .../io/github/vinceglb/filekit/Zip.native.kt | 109 ++++++ .../kotlin/io/github/vinceglb/filekit/Zip.kt | 316 ++++++++++++++++++ .../filekit/PlatformFileNonWebTest.kt | 59 ++++ 7 files changed, 659 insertions(+) create mode 100644 filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/Zip.android.kt create mode 100644 filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/Zip.jvm.kt create mode 100644 filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/ZipMacosTest.kt create mode 100644 filekit-core/src/nativeMain/kotlin/io/github/vinceglb/filekit/Zip.native.kt create mode 100644 filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/Zip.kt 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..eec4aaf2 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 @@ -10,14 +10,76 @@ import io.github.vinceglb.filekit.mimeType.MimeType 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 class PlatformFileJvmTest { + /** + * 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..b5dcb4bb --- /dev/null +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/Zip.kt @@ -0,0 +1,316 @@ +@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.withContext +import kotlinx.io.Sink +import kotlinx.io.buffered +import kotlinx.io.readByteArray +import kotlinx.io.writeIntLe +import kotlinx.io.writeShortLe +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) { + destination.sink().buffered().use { sink -> + val writer = ZipWriter(sink) + forEach { source -> + if (!source.exists()) { + throw FileKitException("Cannot zip \"${source.name}\": it does not exist.") + } + writer.add(source, source.name, mutableSetOf()) + } + writer.finish() + } + } +} + +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 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) { + 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() From 78b527a2ea93ef4dd8684ce0636e464996e3badf Mon Sep 17 00:00:00 2001 From: "anggrayudi.hardiannico" Date: Sun, 16 Aug 2026 15:14:45 +0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20Make=20zipTo=20cancellable?= =?UTF-8?q?=20and=20clean=20up=20a=20partial=20archive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compression loop had no suspension point of its own, so a cancelled job kept deflating to the end of the file. It now checks the job on every chunk. Cancelling mid-entry makes a truncated archive a real outcome, and one still opens like an archive, so a failed or cancelled run removes the destination. That delete runs under NonCancellable: it suspends, and on the already-cancelled job that got us here it would otherwise throw before removing anything. The regression test cancels a zip of a 64 MB barely-compressible file and asserts both that the job actually cancelled and that nothing was left behind. Removing either the ensureActive check or NonCancellable makes it fail. Co-Authored-By: Claude Opus 5 (1M context) --- .../vinceglb/filekit/PlatformFileJvmTest.kt | 39 +++++++++++++++++++ .../kotlin/io/github/vinceglb/filekit/Zip.kt | 34 +++++++++++----- 2 files changed, 64 insertions(+), 9 deletions(-) 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 eec4aaf2..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,6 +7,11 @@ 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 @@ -22,8 +27,42 @@ 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. 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 index b5dcb4bb..acf92250 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/Zip.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/Zip.kt @@ -5,12 +5,15 @@ 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 @@ -48,15 +51,25 @@ public suspend infix fun List.zipTo(destination: PlatformFile) { } withContext(Dispatchers.IO) { - destination.sink().buffered().use { sink -> - val writer = ZipWriter(sink) - forEach { source -> - if (!source.exists()) { - throw FileKitException("Cannot zip \"${source.name}\": it does not exist.") - } - writer.add(source, source.name, mutableSetOf()) + 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() } - 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 } } } @@ -87,7 +100,7 @@ private class ZipWriter(private val sink: Sink) { entries += CentralDirectoryEntry(nameBytes, METHOD_STORED, time, date, 0, 0, 0, localHeaderOffset, isDirectory = true) } - private fun writeFileEntry(source: PlatformFile, entryName: String) { + private suspend fun writeFileEntry(source: PlatformFile, entryName: String) { val nameBytes = entryName.encodeToByteArray() val localHeaderOffset = offset val (time, date) = source.lastModified().toDosDateTime() @@ -104,6 +117,9 @@ private class ZipWriter(private val sink: Sink) { 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)