Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>()
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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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) }
}
}
}
Original file line number Diff line number Diff line change
@@ -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<z_stream>()
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<z_stream>().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
Loading