diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/GZipTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/GZipTest.kt new file mode 100644 index 000000000..d3e259996 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/GZipTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertTrue + +class GZipTest { + @Test + fun roundTripSimpleString() { + val original = "Hello, Nostr!" + val decompressed = GZip.decompress(GZip.compress(original)) + assertEquals(original, decompressed) + } + + @Test + fun roundTripEmptyString() { + val original = "" + val decompressed = GZip.decompress(GZip.compress(original)) + assertEquals(original, decompressed) + } + + @Test + fun roundTripLongRepetitiveString() { + val original = "abcdefghij".repeat(1000) + val compressed = GZip.compress(original) + val decompressed = GZip.decompress(compressed) + assertEquals(original, decompressed) + } + + @Test + fun compressedSizeIsSmallerForRepetitiveInput() { + val original = "abcdefghij".repeat(1000) + val compressed = GZip.compress(original) + assertTrue( + compressed.size < original.encodeToByteArray().size, + "Expected compressed size (${compressed.size}) to be smaller than original (${original.encodeToByteArray().size})", + ) + } + + @Test + fun roundTripUnicodeString() { + val original = "こんにちは世界 🌍 مرحبا Привет" + val decompressed = GZip.decompress(GZip.compress(original)) + assertEquals(original, decompressed) + } + + @Test + fun roundTripNostrJsonEvent() { + val original = + """{"id":"abc123","pubkey":"deadbeef","created_at":1700000000,"kind":1,""" + + """"tags":[],"content":"Hello world","sig":"cafebabe"}""" + val decompressed = GZip.decompress(GZip.compress(original)) + assertEquals(original, decompressed) + } + + @Test + fun compressedBytesStartWithGzipMagicNumber() { + // gzip streams always begin with 0x1F 0x8B + val compressed = GZip.compress("test") + assertTrue(compressed.size >= 2, "Compressed output too short to contain gzip header") + assertEquals(0x1F.toByte(), compressed[0], "Expected gzip magic byte 0 (0x1F)") + assertEquals(0x8B.toByte(), compressed[1], "Expected gzip magic byte 1 (0x8B)") + } + + @Test + fun compressedOutputDiffersFromInput() { + val original = "Hello, Nostr!" + val compressed = GZip.compress(original) + assertTrue( + !compressed.contentEquals(original.encodeToByteArray()), + "Compressed output should differ from the raw input bytes", + ) + } + + @Test + fun decompressInvalidDataThrows() { + val garbage = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04) + assertFails { GZip.decompress(garbage) } + } + + @Test + fun roundTripSingleCharacter() { + val original = "A" + assertEquals(original, GZip.decompress(GZip.compress(original))) + } + + @Test + fun roundTripSpecialCharacters() { + val original = "\t\n\r\u0000\u001F\u007F" + assertEquals(original, GZip.decompress(GZip.compress(original))) + } +} diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt index 6db97fc26..cd7419a8c 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt @@ -20,12 +20,118 @@ */ package com.vitorpamplona.quartz.utils +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +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_END +import platform.zlib.deflate +import platform.zlib.deflateBound +import platform.zlib.deflateEnd +import platform.zlib.deflateInit2 +import platform.zlib.inflate +import platform.zlib.inflateEnd +import platform.zlib.inflateInit2 +import platform.zlib.z_stream + +@OptIn(ExperimentalForeignApi::class) actual object GZip { + /** + * Compresses [content] into a gzip byte array using zlib's deflateInit2 with + * windowBits=31 (MAX_WBITS + 16), which requests the gzip wrapper format. + * deflateBound gives an upper-bound on output size so a single deflate call + * with Z_FINISH is always sufficient. + */ actual fun compress(content: String): ByteArray { - TODO("Not yet implemented") + val input = content.encodeToByteArray() + + return memScoped { + val stream = alloc() + + // windowBits = 15 + 16 = 31 → gzip format + deflateInit2( + stream.ptr, + Z_DEFAULT_COMPRESSION, + Z_DEFLATED, + 31, + 8, + Z_DEFAULT_STRATEGY, + ).let { check(it == Z_OK) { "deflateInit2 failed: $it" } } + + val maxSize = deflateBound(stream.ptr, input.size.toULong()).toInt() + val output = ByteArray(maxSize) + + val written = + input.usePinned { pinIn -> + output.usePinned { pinOut -> + stream.next_in = pinIn.addressOf(0).reinterpret() + stream.avail_in = input.size.toUInt() + stream.next_out = pinOut.addressOf(0).reinterpret() + stream.avail_out = maxSize.toUInt() + + deflate(stream.ptr, Z_FINISH) + .let { check(it == Z_STREAM_END) { "deflate failed: $it" } } + + maxSize - stream.avail_out.toInt() + } + } + + deflateEnd(stream.ptr) + output.copyOf(written) + } } + /** + * Decompresses a gzip [content] byte array back to a UTF-8 string using + * zlib's inflateInit2 with windowBits=47 (MAX_WBITS + 32), which enables + * automatic detection of both gzip and zlib formats. + * Output is collected in fixed-size chunks to handle arbitrary output size. + */ actual fun decompress(content: ByteArray): String { - TODO("Not yet implemented") + val chunks = ArrayList() + val chunkSize = maxOf(content.size * 4, 4096) + + memScoped { + val stream = alloc() + + // windowBits = 15 + 32 = 47 → auto-detect gzip or zlib + inflateInit2(stream.ptr, 47) + .let { check(it == Z_OK) { "inflateInit2 failed: $it" } } + + content.usePinned { pinIn -> + stream.next_in = pinIn.addressOf(0).reinterpret() + stream.avail_in = content.size.toUInt() + + var status: Int + do { + val chunk = ByteArray(chunkSize) + chunk.usePinned { pinOut -> + stream.next_out = pinOut.addressOf(0).reinterpret() + stream.avail_out = chunkSize.toUInt() + status = inflate(stream.ptr, Z_NO_FLUSH) + val produced = chunkSize - stream.avail_out.toInt() + if (produced > 0) chunks.add(chunk.copyOf(produced)) + } + } while (status == Z_OK) + + check(status == Z_STREAM_END) { "inflate failed: $status" } + } + + inflateEnd(stream.ptr) + } + + val totalSize = chunks.sumOf { it.size } + val result = ByteArray(totalSize) + var pos = 0 + chunks.forEach { chunk -> chunk.copyInto(result, pos); pos += chunk.size } + return result.decodeToString() } }