diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index b10c683a8..adb968e98 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -75,6 +75,8 @@ kotlin { } } + linuxX64() + // This makes sure that the resource file directory is visible for iOS tests. val rootDir = "${rootProject.rootDir.path}/quartz/src/commonTest/resources" @@ -222,15 +224,37 @@ kotlin { } } - iosMain { - dependsOn(commonMain.get()) - dependencies { - implementation(libs.charlietap.cachemap) - implementation(libs.net.thauvin.erik.urlencoder.lib) - implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) - implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4") - implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4") + // Must be defined before appleMain, linuxMain, etc. + val nativeMain = + create("nativeMain") { + dependsOn(commonMain.get()) } + + val nativeTest = + create("nativeTest") { + dependsOn(commonTest.get()) + } + + // Must be defined before iosMain and macosMain + val appleMain = + create("appleMain") { + dependsOn(nativeMain) + dependencies { + implementation(libs.charlietap.cachemap) + implementation(libs.net.thauvin.erik.urlencoder.lib) + implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) + implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4") + implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4") + } + } + + val appleTest = + create("appleTest") { + dependsOn(nativeTest) + } + + iosMain { + dependsOn(appleMain) } val iosArm64Main by getting { @@ -242,9 +266,7 @@ kotlin { } iosTest { - dependsOn(commonTest.get()) - dependencies { - } + dependsOn(appleTest) } val iosArm64Test by getting { @@ -254,6 +276,30 @@ kotlin { val iosSimulatorArm64Test by getting { dependsOn(iosTest.get()) } + + val linuxMain = + create("linuxMain") { + dependsOn(nativeMain) + dependencies { + implementation(libs.net.thauvin.erik.urlencoder.lib) + implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) + implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4") + implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4") + } + } + + val linuxTest = + create("linuxTest") { + dependsOn(nativeTest) + } + + val linuxX64Main by getting { + dependsOn(linuxMain) + } + + val linuxX64Test by getting { + dependsOn(linuxTest) + } } } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/GZip.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/GZip.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Log.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/Log.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Log.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/Log.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.ios.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.ios.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.apple.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.linux.kt new file mode 100644 index 000000000..dbb7fa2a7 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.linux.kt @@ -0,0 +1,52 @@ +/* + * 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.nip96FileStorage.info + +actual fun makeAbsoluteIfRelativeUrl( + baseUrl: String, + potentiallyRelativeUrl: String, +): String = + try { + if (potentiallyRelativeUrl.contains("://")) { + potentiallyRelativeUrl + } else { + val base = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" + if (potentiallyRelativeUrl.startsWith("/")) { + // Absolute path - combine with scheme + host from base + val schemeEnd = base.indexOf("://") + if (schemeEnd >= 0) { + val hostEnd = base.indexOf('/', schemeEnd + 3) + if (hostEnd >= 0) { + base.substring(0, hostEnd) + potentiallyRelativeUrl + } else { + base + potentiallyRelativeUrl.removePrefix("/") + } + } else { + potentiallyRelativeUrl + } + } else { + // Relative path + base + potentiallyRelativeUrl + } + } + } catch (e: Exception) { + potentiallyRelativeUrl + } diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.linux.kt new file mode 100644 index 000000000..572b40d33 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimal.linux.kt @@ -0,0 +1,77 @@ +/* + * 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 + +actual class BigDecimal internal constructor( + private val value: String, +) : Number() { + actual constructor(strVal: String) : this(strVal.trim()) + + actual constructor(doubleVal: Double) : this(doubleVal.toBigDecimalString()) + + actual constructor(intVal: Int) : this(intVal.toString()) + + actual constructor(longVal: Long) : this(longVal.toString()) + + actual fun add(augend: BigDecimal): BigDecimal = BigDecimal((toDouble() + augend.toDouble()).toBigDecimalString()) + + actual fun subtract(subtrahend: BigDecimal): BigDecimal = BigDecimal((toDouble() - subtrahend.toDouble()).toBigDecimalString()) + + actual fun multiply(multiplicand: BigDecimal): BigDecimal = BigDecimal((toDouble() * multiplicand.toDouble()).toBigDecimalString()) + + actual fun divide(divisor: BigDecimal): BigDecimal = BigDecimal((toDouble() / divisor.toDouble()).toBigDecimalString()) + + actual fun negate(): BigDecimal = BigDecimal((-toDouble()).toBigDecimalString()) + + actual fun signum(): Int { + val d = toDouble() + return when { + d < 0.0 -> -1 + d > 0.0 -> 1 + else -> 0 + } + } + + override fun toInt(): Int = toDouble().toInt() + + override fun toLong(): Long = toDouble().toLong() + + override fun toShort(): Short = toDouble().toInt().toShort() + + override fun toByte(): Byte = toDouble().toInt().toByte() + + override fun toDouble(): Double = value.toDouble() + + override fun toFloat(): Float = value.toFloat() + + override fun equals(other: Any?): Boolean = (this === other) || value == (other as? BigDecimal)?.value + + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = value +} + +private fun Double.toBigDecimalString(): String { + if (this == this.toLong().toDouble()) { + return this.toLong().toString() + } + return this.toString() +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/GZip.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/GZip.linux.kt new file mode 100644 index 000000000..926929068 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/GZip.linux.kt @@ -0,0 +1,136 @@ +/* + * 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 kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +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 { + actual fun compress(content: String): ByteArray { + val input = content.encodeToByteArray() + + return memScoped { + val stream = alloc() + + 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 -> + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } + stream.avail_in = input.size.toUInt() + + if (output.isNotEmpty()) { + 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) + } + } + + actual fun decompress(content: ByteArray): String { + if (content.isEmpty()) return "" + + val chunks = ArrayList() + val chunkSize = maxOf(content.size * 4, 4096) + + memScoped { + val stream = alloc() + + inflateInit2(stream.ptr, 47) + .let { check(it == Z_OK) { "inflateInit2 failed: $it" } } + + content.usePinned { pinIn -> + if (content.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } + stream.avail_in = content.size.toUInt() + + var status: Int = Z_OK + 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() + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Log.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Log.linux.kt new file mode 100644 index 000000000..6bc080295 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Log.linux.kt @@ -0,0 +1,61 @@ +/* + * 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 + +actual object Log { + actual fun w( + tag: String, + message: String, + throwable: Throwable?, + ) { + if (throwable != null) { + println("WARN: [$tag] $message. Throwable: $throwable CAUSE ${throwable.cause}") + } else { + println("WARN: [$tag] $message") + } + } + + actual fun e( + tag: String, + message: String, + throwable: Throwable?, + ) { + if (throwable != null) { + println("ERROR: [$tag] $message. Throwable: $throwable CAUSE ${throwable.cause}") + } else { + println("ERROR: [$tag] $message") + } + } + + actual fun d( + tag: String, + message: String, + ) { + println("DEBUG: [$tag] $message") + } + + actual fun i( + tag: String, + message: String, + ) { + println("INFO: [$tag] $message") + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt new file mode 100644 index 000000000..7e4b5fc50 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt @@ -0,0 +1,40 @@ +/* + * 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 kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.posix.CLOCK_REALTIME +import platform.posix.clock_gettime +import platform.posix.timespec + +actual fun platform() = "Linux" + +@OptIn(ExperimentalForeignApi::class) +actual fun currentTimeSeconds(): Long { + memScoped { + val ts = alloc() + clock_gettime(CLOCK_REALTIME, ts.ptr) + return ts.tv_sec + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.linux.kt new file mode 100644 index 000000000..323593c44 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.linux.kt @@ -0,0 +1,125 @@ +/* + * 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 kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.usePinned +import platform.posix.O_RDONLY +import platform.posix.close +import platform.posix.open +import platform.posix.read + +actual class SecureRandom { + actual fun nextInt(): Int { + val bytes = ByteArray(4) + nextBytes(bytes) + return ((bytes[0].toInt() and 0xFF) shl 24) or + ((bytes[1].toInt() and 0xFF) shl 16) or + ((bytes[2].toInt() and 0xFF) shl 8) or + (bytes[3].toInt() and 0xFF) + } + + actual fun nextInt(bound: Int): Int { + require(bound > 0) { throw IllegalArgumentException("Bad Bound $bound") } + + var intValue = nextPositiveInt() + + val m = bound - 1 + if ((bound and m) == 0) { + intValue = ((bound * intValue.toLong()) shr 31).toInt() + } else { + var u: Int = intValue + intValue = u % bound + while (u - intValue + m < 0) { + u = nextPositiveInt() + intValue = u % bound + } + } + + return intValue + } + + actual fun nextLong(): Long { + val bytes = ByteArray(8) + nextBytes(bytes) + return ((bytes[0].toLong() and 0xFFL) shl 56) or + ((bytes[1].toLong() and 0xFFL) shl 48) or + ((bytes[2].toLong() and 0xFFL) shl 40) or + ((bytes[3].toLong() and 0xFFL) shl 32) or + ((bytes[4].toLong() and 0xFFL) shl 24) or + ((bytes[5].toLong() and 0xFFL) shl 16) or + ((bytes[6].toLong() and 0xFFL) shl 8) or + (bytes[7].toLong() and 0xFFL) + } + + actual fun nextLong(bound: Long): Long { + require(bound > 0) { throw IllegalArgumentException("Bad Bound $bound") } + + if (bound < Int.MAX_VALUE) { + return nextInt(bound.toInt()).toLong() + } + + return nextPositiveLong() % bound + } + + fun nextPositiveInt(): Int { + val value = nextInt() + return if (value > 0) { + value + } else { + -value + } + } + + fun nextPositiveLong(): Long { + val value = nextLong() + return if (value > 0) { + value + } else { + -value + } + } + + @OptIn(ExperimentalForeignApi::class) + actual fun nextBytes(output: ByteArray) { + if (output.isEmpty()) return + val fd = open("/dev/urandom", O_RDONLY) + if (fd == -1) { + throw IllegalStateException("Failed to open /dev/urandom") + } + try { + output.usePinned { pinned -> + var bytesRead = 0 + while (bytesRead < output.size) { + val result = read(fd, pinned.addressOf(bytesRead), (output.size - bytesRead).convert()) + if (result <= 0) { + throw IllegalStateException("Failed to read from /dev/urandom") + } + bytesRead += result.toInt() + } + } + } finally { + close(fd) + } + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.linux.kt new file mode 100644 index 000000000..e23796184 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.linux.kt @@ -0,0 +1,30 @@ +/* + * 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 + +actual class UnicodeNormalizer { + actual fun normalizeNFKC(input: String): String { + // NFKC normalization on Linux native without ICU + // For most Nostr use cases (NIP-05, etc.) ASCII-safe content works without normalization. + // Full NFKC support would require linking against ICU or a pure-Kotlin normalizer. + return input + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.linux.kt new file mode 100644 index 000000000..dbbc792cc --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.linux.kt @@ -0,0 +1,161 @@ +/* + * 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 + +actual class UriParser actual constructor( + uri: String, +) { + private val parsedScheme: String? + private val parsedHost: String? + private val parsedPort: Int? + private val parsedPath: String? + private val parsedQuery: String? + private val parsedFragment: String? + + init { + var remaining = uri + + // Parse scheme + val schemeEnd = remaining.indexOf("://") + if (schemeEnd >= 0) { + parsedScheme = remaining.substring(0, schemeEnd) + remaining = remaining.substring(schemeEnd + 3) + } else { + parsedScheme = null + } + + // Parse fragment + val fragmentStart = remaining.indexOf('#') + if (fragmentStart >= 0) { + parsedFragment = remaining.substring(fragmentStart + 1) + remaining = remaining.substring(0, fragmentStart) + } else { + parsedFragment = null + } + + // Parse query + val queryStart = remaining.indexOf('?') + if (queryStart >= 0) { + parsedQuery = remaining.substring(queryStart + 1) + remaining = remaining.substring(0, queryStart) + } else { + parsedQuery = null + } + + // Parse host and port + val pathStart = remaining.indexOf('/') + val authority = + if (pathStart >= 0) { + val auth = remaining.substring(0, pathStart) + remaining = remaining.substring(pathStart) + auth + } else { + val auth = remaining + remaining = "" + auth + } + + // Remove userinfo + val atIndex = authority.lastIndexOf('@') + val hostPort = if (atIndex >= 0) authority.substring(atIndex + 1) else authority + + // Handle IPv6 + if (hostPort.startsWith("[")) { + val bracketEnd = hostPort.indexOf(']') + if (bracketEnd >= 0) { + parsedHost = hostPort.substring(1, bracketEnd) + val afterBracket = hostPort.substring(bracketEnd + 1) + parsedPort = + if (afterBracket.startsWith(":")) { + afterBracket.substring(1).toIntOrNull() + } else { + null + } + } else { + parsedHost = hostPort + parsedPort = null + } + } else { + val colonIndex = hostPort.lastIndexOf(':') + if (colonIndex >= 0) { + val potentialPort = hostPort.substring(colonIndex + 1).toIntOrNull() + if (potentialPort != null) { + parsedHost = hostPort.substring(0, colonIndex) + parsedPort = potentialPort + } else { + parsedHost = hostPort + parsedPort = null + } + } else { + parsedHost = hostPort.ifEmpty { null } + parsedPort = null + } + } + + parsedPath = remaining.ifEmpty { null } + } + + actual fun scheme(): String? = parsedScheme + + actual fun host(): String? = parsedHost + + actual fun port(): Int? = parsedPort + + actual fun path(): String? = parsedPath + + actual fun queryParameterNames(): Set { + val query = parsedQuery ?: return emptySet() + return query + .split('&') + .mapNotNull { param -> + val eqIndex = param.indexOf('=') + if (eqIndex >= 0) param.substring(0, eqIndex) else param + }.toSet() + } + + actual fun getQueryParameter(param: String): String? { + val query = parsedQuery ?: return null + return query + .split('&') + .firstOrNull { part -> + val eqIndex = part.indexOf('=') + if (eqIndex >= 0) part.substring(0, eqIndex) == param else part == param + }?.let { part -> + val eqIndex = part.indexOf('=') + if (eqIndex >= 0) part.substring(eqIndex + 1) else "" + } + } + + val fragments: Map by lazy { + parsedFragment?.ifBlank { null }?.let { keyValuePair -> + keyValuePair.split('&').associate { paramValue -> + val parts = paramValue.split("=", limit = 2) + if (parts.size == 2) { + parts[0] to parts[1] + } else { + parts[0] to "" + } + } + } ?: emptyMap() + } + + actual fun fragments(): Map = fragments +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.linux.kt new file mode 100644 index 000000000..3b8e98f02 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UrlEncoder.linux.kt @@ -0,0 +1,29 @@ +/* + * 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 net.thauvin.erik.urlencoder.UrlEncoderUtil + +actual object UrlEncoder { + actual fun encode(value: String): String = UrlEncoderUtil.encode(value) + + actual fun decode(value: String): String = UrlEncoderUtil.decode(value) +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.linux.kt new file mode 100644 index 000000000..272602309 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.linux.kt @@ -0,0 +1,319 @@ +/* + * 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.cache + +import kotlin.concurrent.AtomicReference + +actual class LargeCache : ICacheOperations { + private val mapRef = AtomicReference(LinkedHashMap()) + + private inline fun withMap(block: (LinkedHashMap) -> R): R = block(mapRef.value) + + private inline fun mutate(block: (LinkedHashMap) -> Unit) { + val copy = LinkedHashMap(mapRef.value) + block(copy) + mapRef.value = copy + } + + actual fun keys(): Set = withMap { LinkedHashSet(it.keys) } + + actual fun values(): Iterable = withMap { ArrayList(it.values) } + + actual fun get(key: K): V? = withMap { it[key] } + + actual fun remove(key: K): V? { + val current = mapRef.value + val value = current[key] + if (value != null) { + mutate { it.remove(key) } + } + return value + } + + actual fun isEmpty(): Boolean = withMap { it.isEmpty() } + + actual fun clear() { + mapRef.value = LinkedHashMap() + } + + actual fun containsKey(key: K): Boolean = withMap { it.containsKey(key) } + + actual fun put( + key: K, + value: V, + ) { + mutate { it[key] = value } + } + + actual fun getOrCreate( + key: K, + builder: (K) -> V, + ): V { + val existing = get(key) + if (existing != null) return existing + val newObject = builder(key) + mutate { it[key] = newObject } + return get(key) ?: newObject + } + + actual fun createIfAbsent( + key: K, + builder: (K) -> V, + ): Boolean { + val existing = get(key) + if (existing != null) return false + val newObject = builder(key) + mutate { it[key] = newObject } + return get(key) != null + } + + actual override fun size(): Int = withMap { it.size } + + actual override fun forEach(consumer: ICacheBiConsumer) { + withMap { map -> map.forEach { consumer.accept(it.key, it.value) } } + } + + actual override fun filter(consumer: CacheCollectors.BiFilter): List = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toList() } + + actual override fun filterIntoSet(consumer: CacheCollectors.BiFilter): Set = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toSet() } + + actual override fun map(consumer: CacheCollectors.BiNotNullMapper): List = withMap { map -> map.map { consumer.map(it.key, it.value) } } + + actual override fun mapNotNull(consumer: CacheCollectors.BiMapper): List = withMap { map -> map.mapNotNull { consumer.map(it.key, it.value) } } + + actual override fun mapNotNullIntoSet(consumer: CacheCollectors.BiMapper): Set = mapNotNull(consumer).toSet() + + actual override fun mapFlatten(consumer: CacheCollectors.BiMapper?>): List = withMap { map -> map.flatMap { entry -> consumer.map(entry.key, entry.value) ?: emptyList() } } + + actual override fun mapFlattenIntoSet(consumer: CacheCollectors.BiMapper?>): Set = mapFlatten(consumer).toSet() + + actual override fun maxOrNullOf( + filter: CacheCollectors.BiFilter, + comparator: Comparator, + ): V? = + withMap { map -> + var maxV: V? = null + map.forEach { + if (filter.filter(it.key, it.value)) { + if (maxV == null || comparator.compare(it.value, maxV) > 0) { + maxV = it.value + } + } + } + maxV + } + + actual override fun sumOf(consumer: CacheCollectors.BiSumOf): Int = + withMap { map -> + var sum = 0 + map.forEach { sum += consumer.map(it.key, it.value) } + sum + } + + actual override fun sumOfLong(consumer: CacheCollectors.BiSumOfLong): Long = + withMap { map -> + var sum = 0L + map.forEach { sum += consumer.map(it.key, it.value) } + sum + } + + actual override fun groupBy(consumer: CacheCollectors.BiNotNullMapper): Map> = + withMap { map -> + val results = HashMap>() + map.forEach { + val group = consumer.map(it.key, it.value) + results.getOrPut(group) { ArrayList() }.add(it.value) + } + results + } + + actual override fun countByGroup(consumer: CacheCollectors.BiNotNullMapper): Map = + withMap { map -> + val results = HashMap() + map.forEach { + val group = consumer.map(it.key, it.value) + results[group] = (results[group] ?: 0) + 1 + } + results + } + + actual override fun sumByGroup( + groupMap: CacheCollectors.BiNotNullMapper, + sumOf: CacheCollectors.BiNotNullMapper, + ): Map = + withMap { map -> + val results = HashMap() + map.forEach { + val group = groupMap.map(it.key, it.value) + results[group] = (results[group] ?: 0L) + sumOf.map(it.key, it.value) + } + results + } + + actual override fun count(consumer: CacheCollectors.BiFilter): Int = withMap { map -> map.count { consumer.filter(it.key, it.value) } } + + actual override fun associate(transform: (K, V) -> Pair): Map = + withMap { map -> + val results = LinkedHashMap(map.size) + map.forEach { + val pair = transform(it.key, it.value) + results[pair.first] = pair.second + } + results + } + + actual override fun associateWith(transform: (K, V) -> U?): Map = + withMap { map -> + val results = LinkedHashMap(map.size) + map.forEach { + results[it.key] = transform(it.key, it.value) + } + results + } + + actual override fun filter( + from: K, + to: K, + consumer: CacheCollectors.BiFilter, + ): List = filter(consumer) + + actual override fun filterIntoSet( + from: K, + to: K, + consumer: CacheCollectors.BiFilter, + ): Set = filterIntoSet(consumer) + + actual override fun map( + from: K, + to: K, + consumer: CacheCollectors.BiNotNullMapper, + ): List = map(consumer) + + actual override fun mapNotNull( + from: K, + to: K, + consumer: CacheCollectors.BiMapper, + ): List = mapNotNull(consumer) + + actual override fun mapNotNullIntoSet( + from: K, + to: K, + consumer: CacheCollectors.BiMapper, + ): Set = mapNotNullIntoSet(consumer) + + actual override fun mapFlatten( + from: K, + to: K, + consumer: CacheCollectors.BiMapper?>, + ): List = mapFlatten(consumer) + + actual override fun mapFlattenIntoSet( + from: K, + to: K, + consumer: CacheCollectors.BiMapper?>, + ): Set = mapFlattenIntoSet(consumer) + + actual override fun maxOrNullOf( + from: K, + to: K, + filter: CacheCollectors.BiFilter, + comparator: Comparator, + ): V? = maxOrNullOf(filter, comparator) + + actual override fun sumOf( + from: K, + to: K, + consumer: CacheCollectors.BiSumOf, + ): Int = sumOf(consumer) + + actual override fun sumOfLong( + from: K, + to: K, + consumer: CacheCollectors.BiSumOfLong, + ): Long = sumOfLong(consumer) + + actual override fun groupBy( + from: K, + to: K, + consumer: CacheCollectors.BiNotNullMapper, + ): Map> = groupBy(consumer) + + actual override fun countByGroup( + from: K, + to: K, + consumer: CacheCollectors.BiNotNullMapper, + ): Map = countByGroup(consumer) + + actual override fun sumByGroup( + from: K, + to: K, + groupMap: CacheCollectors.BiNotNullMapper, + sumOf: CacheCollectors.BiNotNullMapper, + ): Map = sumByGroup(groupMap, sumOf) + + actual override fun count( + from: K, + to: K, + consumer: CacheCollectors.BiFilter, + ): Int = count(consumer) + + actual override fun associate( + from: K, + to: K, + transform: (K, V) -> Pair, + ): Map = associate(transform) + + actual override fun associateWith( + from: K, + to: K, + transform: (K, V) -> U?, + ): Map = associateWith(transform) + + actual override fun joinToString( + separator: CharSequence, + prefix: CharSequence, + postfix: CharSequence, + limit: Int, + truncated: CharSequence, + transform: ((K, V) -> CharSequence)?, + ): String { + val buffer = StringBuilder() + buffer.append(prefix) + var count = 0 + forEach { key, value -> + val str = if (transform != null) transform(key, value) else "" + if (str.isNotEmpty()) { + if (++count > 1) buffer.append(separator) + if (limit < 0 || count <= limit) { + when { + transform != null -> buffer.append(str) + else -> buffer.append("$key $value") + } + } else { + return@forEach + } + } + } + if (limit >= 0 && count > limit) buffer.append(truncated) + buffer.append(postfix) + return buffer.toString() + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.linux.kt new file mode 100644 index 000000000..9f0cc6fb3 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESCBC.linux.kt @@ -0,0 +1,66 @@ +/* + * 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.ciphers + +import com.vitorpamplona.quartz.utils.Log +import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi +import dev.whyoleg.cryptography.algorithms.AES + +actual class AESCBC actual constructor( + actual val keyBytes: ByteArray, + actual val iv: ByteArray, +) : NostrCipher { + private val cryptoProvider = CryptographyProvider.Default + private val aesCbc = cryptoProvider.get(AES.CBC) + + private val keyDecoder = + aesCbc + .keyDecoder() + .decodeFromByteArrayBlocking(format = AES.Key.Format.RAW, keyBytes) + + private fun cipher() = keyDecoder.cipher() + + actual override fun name(): String = "aes-cbc" + + @OptIn(DelicateCryptographyApi::class) + actual override fun encrypt(bytesToEncrypt: ByteArray): ByteArray = + with(cipher()) { + encryptWithIvBlocking(iv, bytesToEncrypt) + } + + @OptIn(DelicateCryptographyApi::class) + actual override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = + with(cipher()) { + decryptWithIvBlocking(iv, bytesToDecrypt) + } + + @OptIn(DelicateCryptographyApi::class) + actual override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + with(cipher()) { + decryptWithIvBlocking(iv, bytesToDecrypt) + } + } catch (e: Exception) { + Log.w("AESCBC", "Failed to decrypt", e) + null + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.linux.kt new file mode 100644 index 000000000..eb065059a --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/ciphers/AESGCM.linux.kt @@ -0,0 +1,66 @@ +/* + * 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.ciphers + +import com.vitorpamplona.quartz.utils.Log +import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi +import dev.whyoleg.cryptography.algorithms.AES + +actual class AESGCM actual constructor( + actual val keyBytes: ByteArray, + actual val nonce: ByteArray, +) : NostrCipher { + private val provider = CryptographyProvider.Default + private val aesGcm = provider.get(AES.GCM) + + private val keyDecoder = + aesGcm + .keyDecoder() + .decodeFromByteArrayBlocking(AES.Key.Format.RAW, keyBytes) + + private fun cipher() = keyDecoder.cipher() + + actual override fun name(): String = "aes-gcm" + + @OptIn(DelicateCryptographyApi::class) + actual override fun encrypt(bytesToEncrypt: ByteArray): ByteArray = + with(cipher()) { + encryptWithIvBlocking(nonce, bytesToEncrypt) + } + + @OptIn(DelicateCryptographyApi::class) + actual override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = + with(cipher()) { + decryptWithIvBlocking(nonce, bytesToDecrypt) + } + + @OptIn(DelicateCryptographyApi::class) + actual override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + with(cipher()) { + decryptWithIvBlocking(nonce, bytesToDecrypt) + } + } catch (e: Exception) { + Log.w("AESGCM", "Failed to decrypt", e) + null + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.linux.kt new file mode 100644 index 000000000..924d22293 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/diggest/DigestInstance.linux.kt @@ -0,0 +1,58 @@ +/* + * 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.diggest + +import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi +import dev.whyoleg.cryptography.algorithms.RIPEMD160 +import dev.whyoleg.cryptography.algorithms.SHA1 +import dev.whyoleg.cryptography.algorithms.SHA256 + +actual class DigestInstance actual constructor( + algorithm: String, +) { + private val cryptoProvider = CryptographyProvider.Default + + private val hasher = cryptoProvider.get(digestForAlgorithm(algorithm)).hasher() + private val hashFunction = hasher.createHashFunction() + + actual fun update(array: ByteArray) { + hashFunction.update(array) + } + + actual fun update(byte: Byte) { + hashFunction.update(byteArrayOf(byte)) + } + + actual fun digest(): ByteArray = hashFunction.hashToByteArray() + + actual fun digest(input: ByteArray): ByteArray = hasher.hashBlocking(input) + + @OptIn(DelicateCryptographyApi::class) + private fun digestForAlgorithm(algorithmName: String) = + when (algorithmName) { + "SHA-256" -> SHA256 + "SHA-1" -> SHA1 + "ripemd160" -> RIPEMD160 + "keccak256" -> error("KECCAK-256 is not yet supported.") + else -> error("This message digest is not supported.") + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.linux.kt new file mode 100644 index 000000000..284b5d3cb --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/mac/MacInstance.linux.kt @@ -0,0 +1,65 @@ +/* + * 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.mac + +import io.github.andreypfau.kotlinx.crypto.HMac +import io.github.andreypfau.kotlinx.crypto.Sha256 +import io.github.andreypfau.kotlinx.crypto.Sha512 + +actual class MacInstance actual constructor( + algorithm: String, + key: ByteArray, +) { + private var nativeHmac = HMac(digestForAlgorithm(algorithm), key) + + actual fun init( + key: ByteArray, + algorithm: String, + ) { + nativeHmac = HMac(digestForAlgorithm(algorithm), key) + } + + actual fun getMacLength(): Int = nativeHmac.macSize + + actual fun update(array: ByteArray) { + nativeHmac.update(array) + } + + actual fun update(byte: Byte) { + nativeHmac.update(byte) + } + + actual fun doFinal(): ByteArray = nativeHmac.digest() + + actual fun doFinal( + output: ByteArray, + offset: Int, + ) { + nativeHmac.digest(output, offset) + } + + private fun digestForAlgorithm(algorithm: String) = + when (algorithm) { + "HmacSHA256" -> Sha256() + "HmacSHA512" -> Sha512() + else -> error("Algorithm is not yet supported.") + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.linux.kt new file mode 100644 index 000000000..91fb3205f --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.linux.kt @@ -0,0 +1,30 @@ +/* + * 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.sha256 + +import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.algorithms.SHA256 + +actual fun sha256(data: ByteArray): ByteArray { + val provider = CryptographyProvider.Default + val hasher = provider.get(SHA256).hasher() + return hasher.hashBlocking(data) +} diff --git a/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt new file mode 100644 index 000000000..75f7e4951 --- /dev/null +++ b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt @@ -0,0 +1,28 @@ +/* + * 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 platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 + +actual fun platform() = "macOS" + +actual fun currentTimeSeconds(): Long = (NSDate().timeIntervalSince1970).toLong() diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.ios.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.ios.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.native.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.native.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.ios.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.native.kt similarity index 88% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.ios.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.native.kt index ab7092849..ca61e3c97 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.ios.kt +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.native.kt @@ -45,7 +45,6 @@ actual object EventHasherSerializer { add(kind) addJsonArray { tags.fastForEach { tag -> - // add(JsonArray(content = tag.map { JsonPrimitive(it) })) addJsonArray { tag.fastForEach { add(it) } } } } @@ -68,10 +67,7 @@ actual object EventHasherSerializer { kind: Int, tags: Array>, content: String, - ): ByteArray { - // Also use makeJsonObjectForId(...) above. May change this if needed(for performance). - return makeJsonObjectForId(pubKey, createdAt, kind, tags, content).toString().encodeToByteArray() - } + ): ByteArray = makeJsonObjectForId(pubKey, createdAt, kind, tags, content).toString().encodeToByteArray() actual fun makeJsonForIdHashAndCheck( id: HexKey, @@ -81,7 +77,6 @@ actual object EventHasherSerializer { tags: Array>, content: String, ): Boolean { - // Also use makeJsonObjectForId(...) above. May change byteArray encode method if needed. val eventIdHash = sha256(makeJsonObjectForId(pubKey, createdAt, kind, tags, content).toString().encodeToByteArray()) return Hex.isEqual(id, eventIdHash) } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.ios.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.native.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.ios.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.native.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/BitSet.ios.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/BitSet.native.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/BitSet.ios.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/BitSet.native.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.ios.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.ios.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/StringExt.ios.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/StringExt.native.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/StringExt.ios.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/StringExt.native.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/io/Buffer.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/io/Buffer.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/io/Buffer.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/io/Buffer.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/io/ByteBuffers.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/io/ByteBuffers.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/io/ByteBuffers.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/io/ByteBuffers.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/io/CustomBitSet.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/io/CustomBitSet.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/io/CustomBitSet.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/io/CustomBitSet.kt diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/io/Extensions.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/io/Extensions.kt similarity index 100% rename from quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/io/Extensions.kt rename to quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/io/Extensions.kt