Merge branch 'main' into claude/implement-nip-85-hBlbp

This commit is contained in:
Vitor Pamplona
2026-03-28 11:35:19 -04:00
committed by GitHub
66 changed files with 2503 additions and 20 deletions
+57 -11
View File
@@ -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)
}
}
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -101,6 +102,7 @@ class LnZapRequestEvent(
arrayOf("e", zappedEvent.id),
arrayOf("p", toUserPubHex ?: zappedEvent.pubKey),
arrayOf("relays") + relays.map { it.url },
KindTag.assemble(zappedEvent.kind),
AltTag.assemble(ALT),
)
if (zappedEvent is AddressableEvent) {
@@ -83,6 +83,7 @@ class PublicMessageEvent(
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
initializer()
remove("e") // NIP-A4: e tags must not be used
}
fun build(
@@ -94,6 +95,7 @@ class PublicMessageEvent(
alt(ALT_DESCRIPTION)
toUser(to)
initializer()
remove("e") // NIP-A4: e tags must not be used
}
fun build(
@@ -105,6 +107,7 @@ class PublicMessageEvent(
alt(ALT_DESCRIPTION)
toGroup(to)
initializer()
remove("e") // NIP-A4: e tags must not be used
}
}
}
@@ -0,0 +1,119 @@
/*
* 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.nip57Zaps
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class LnZapRequestKindTagTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val nostrSigner = NostrSignerInternal(signer.key)
private val receiverPubKey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
@Test
fun `zap request for kind 24 event includes k tag`() =
runTest {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test message")
val publicMsg = signer.sign<PublicMessageEvent>(template)
val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/"))
val zapRequest =
LnZapRequestEvent.create(
zappedEvent = publicMsg,
relays = relays,
signer = nostrSigner,
pollOption = null,
message = "",
zapType = LnZapEvent.ZapType.PUBLIC,
toUserPubHex = null,
)
val kTag = zapRequest.tags.firstOrNull { it[0] == "k" }
assertTrue(kTag != null, "Zap request must include k tag per NIP-A4")
assertEquals("24", kTag[1], "k tag must contain the zapped event's kind")
}
@Test
fun `zap request for kind 1 event includes k tag`() =
runTest {
val kind1Event =
Event(
id = "a".repeat(64),
pubKey = receiverPubKey,
createdAt = 1000L,
kind = 1,
tags = emptyArray(),
content = "Hello world",
sig = "b".repeat(128),
)
val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/"))
val zapRequest =
LnZapRequestEvent.create(
zappedEvent = kind1Event,
relays = relays,
signer = nostrSigner,
pollOption = null,
message = "",
zapType = LnZapEvent.ZapType.PUBLIC,
toUserPubHex = null,
)
val kTag = zapRequest.tags.firstOrNull { it[0] == "k" }
assertTrue(kTag != null, "Zap request should include k tag")
assertEquals("1", kTag[1], "k tag should contain the zapped event's kind")
}
@Test
fun `zap request for user only has no k tag`() =
runTest {
val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/"))
val zapRequest =
LnZapRequestEvent.create(
userHex = receiverPubKey,
relays = relays,
signer = nostrSigner,
message = "",
zapType = LnZapEvent.ZapType.PUBLIC,
)
val kTag = zapRequest.tags.firstOrNull { it[0] == "k" }
assertEquals(null, kTag, "User-only zap should not have k tag")
}
}
@@ -0,0 +1,184 @@
/*
* 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.nipA4PublicMessages
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PublicMessageEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val receiverPubKey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val receiverRelay = "wss://relay.example.com/"
@Test
fun `verify event kind is 24`() {
assertEquals(24, PublicMessageEvent.KIND)
}
@Test
fun `verify alt description`() {
assertEquals("Public Message", PublicMessageEvent.ALT_DESCRIPTION)
}
@Test
fun `build with single receiver`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Hello!")
val event = signer.sign<PublicMessageEvent>(template)
assertEquals(24, event.kind)
assertEquals("Hello!", event.content)
assertTrue(event.tags.any { it[0] == "p" && it[1] == receiverPubKey })
assertTrue(event.tags.any { it[0] == "alt" && it[1] == "Public Message" })
}
@Test
fun `build with multiple receivers`() {
val receiver1 = ReceiverTag(receiverPubKey, null)
val receiver2PubKey = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
val receiver2 = ReceiverTag(receiver2PubKey, null)
val template = PublicMessageEvent.build(listOf(receiver1, receiver2), "Group message")
val event = signer.sign<PublicMessageEvent>(template)
assertEquals("Group message", event.content)
val pTags = event.tags.filter { it[0] == "p" }
assertEquals(2, pTags.size)
assertTrue(pTags.any { it[1] == receiverPubKey })
assertTrue(pTags.any { it[1] == receiver2PubKey })
}
@Test
fun `build with relay hint`() {
val receiver = ReceiverTag.parse(arrayOf("p", receiverPubKey, "wss://relay.example.com/"))!!
val template = PublicMessageEvent.build(receiver, "With relay hint")
val event = signer.sign<PublicMessageEvent>(template)
val pTag = event.tags.first { it[0] == "p" && it[1] == receiverPubKey }
assertEquals("wss://relay.example.com/", pTag[2])
}
@Test
fun `e tags are stripped from build output`() {
val receiver = ReceiverTag(receiverPubKey, null)
val fakeEventId = "a".repeat(64)
val template =
PublicMessageEvent.build(receiver, "Test message") {
eTag(ETag(fakeEventId, null))
}
val event = signer.sign<PublicMessageEvent>(template)
assertFalse(
event.tags.any { it[0] == "e" },
"NIP-A4 prohibits e tags in kind 24 events",
)
}
@Test
fun `e tags are stripped from empty build`() {
val fakeEventId = "b".repeat(64)
val template =
PublicMessageEvent.build {
eTag(ETag(fakeEventId, null))
}
val event = signer.sign<PublicMessageEvent>(template)
assertFalse(
event.tags.any { it[0] == "e" },
"NIP-A4 prohibits e tags in kind 24 events",
)
}
@Test
fun `e tags are stripped from group build`() {
val receiver = ReceiverTag(receiverPubKey, null)
val fakeEventId = "c".repeat(64)
val template =
PublicMessageEvent.build(listOf(receiver), "Group test") {
eTag(ETag(fakeEventId, null))
}
val event = signer.sign<PublicMessageEvent>(template)
assertFalse(
event.tags.any { it[0] == "e" },
"NIP-A4 prohibits e tags in kind 24 events",
)
}
@Test
fun `isIncluded checks receivers and author`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test")
val event = signer.sign<PublicMessageEvent>(template)
assertTrue(event.isIncluded(receiverPubKey))
assertTrue(event.isIncluded(event.pubKey))
assertFalse(event.isIncluded("0".repeat(64)))
}
@Test
fun `groupKeys includes author and receivers`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test")
val event = signer.sign<PublicMessageEvent>(template)
val keys = event.groupKeys()
assertTrue(keys.contains(event.pubKey))
assertTrue(keys.contains(receiverPubKey))
}
@Test
fun `groupKeySet returns unique keys`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test")
val event = signer.sign<PublicMessageEvent>(template)
val keySet = event.groupKeySet()
assertEquals(2, keySet.size)
assertTrue(keySet.contains(event.pubKey))
assertTrue(keySet.contains(receiverPubKey))
}
@Test
fun `chatroomKey excludes given user`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test")
val event = signer.sign<PublicMessageEvent>(template)
val chatroomKey = event.chatroomKey(event.pubKey)
assertFalse(chatroomKey.contains(event.pubKey))
assertTrue(chatroomKey.contains(receiverPubKey))
}
}
@@ -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
}
@@ -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 actual constructor(
strVal: String,
) : Number() {
private val value: String = 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) || (other is BigDecimal && value == other.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()
}
@@ -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<z_stream>()
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<ByteArray>()
val chunkSize = maxOf(content.size * 4, 4096)
memScoped {
val stream = alloc<z_stream>()
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()
}
}
@@ -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")
}
}
@@ -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<timespec>()
clock_gettime(CLOCK_REALTIME, ts.ptr)
return ts.tv_sec
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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<String> {
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<String, String> 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<String, String> = fragments
}
@@ -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)
}
@@ -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<K, V> : ICacheOperations<K, V> {
private val mapRef = AtomicReference(LinkedHashMap<K, V>())
private inline fun <R> withMap(block: (LinkedHashMap<K, V>) -> R): R = block(mapRef.value)
private inline fun mutate(block: (LinkedHashMap<K, V>) -> Unit) {
val copy = LinkedHashMap(mapRef.value)
block(copy)
mapRef.value = copy
}
actual fun keys(): Set<K> = withMap { LinkedHashSet(it.keys) }
actual fun values(): Iterable<V> = 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<K, V>) {
withMap { map -> map.forEach { consumer.accept(it.key, it.value) } }
}
actual override fun filter(consumer: CacheCollectors.BiFilter<K, V>): List<V> = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toList() }
actual override fun filterIntoSet(consumer: CacheCollectors.BiFilter<K, V>): Set<V> = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toSet() }
actual override fun <R> map(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): List<R> = withMap { map -> map.map { consumer.map(it.key, it.value) } }
actual override fun <R> mapNotNull(consumer: CacheCollectors.BiMapper<K, V, R?>): List<R> = withMap { map -> map.mapNotNull { consumer.map(it.key, it.value) } }
actual override fun <R> mapNotNullIntoSet(consumer: CacheCollectors.BiMapper<K, V, R?>): Set<R> = mapNotNull(consumer).toSet()
actual override fun <R> mapFlatten(consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>): List<R> = withMap { map -> map.flatMap { entry -> consumer.map(entry.key, entry.value) ?: emptyList() } }
actual override fun <R> mapFlattenIntoSet(consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>): Set<R> = mapFlatten(consumer).toSet()
actual override fun maxOrNullOf(
filter: CacheCollectors.BiFilter<K, V>,
comparator: Comparator<V>,
): 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<K, V>): Int =
withMap { map ->
var sum = 0
map.forEach { sum += consumer.map(it.key, it.value) }
sum
}
actual override fun sumOfLong(consumer: CacheCollectors.BiSumOfLong<K, V>): Long =
withMap { map ->
var sum = 0L
map.forEach { sum += consumer.map(it.key, it.value) }
sum
}
actual override fun <R> groupBy(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): Map<R, List<V>> =
withMap { map ->
val results = HashMap<R, ArrayList<V>>()
map.forEach {
val group = consumer.map(it.key, it.value)
results.getOrPut(group) { ArrayList() }.add(it.value)
}
results
}
actual override fun <R> countByGroup(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): Map<R, Int> =
withMap { map ->
val results = HashMap<R, Int>()
map.forEach {
val group = consumer.map(it.key, it.value)
results[group] = (results[group] ?: 0) + 1
}
results
}
actual override fun <R> sumByGroup(
groupMap: CacheCollectors.BiNotNullMapper<K, V, R>,
sumOf: CacheCollectors.BiNotNullMapper<K, V, Long>,
): Map<R, Long> =
withMap { map ->
val results = HashMap<R, Long>()
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<K, V>): Int = withMap { map -> map.count { consumer.filter(it.key, it.value) } }
actual override fun <T, U> associate(transform: (K, V) -> Pair<T, U>): Map<T, U> =
withMap { map ->
val results = LinkedHashMap<T, U>(map.size)
map.forEach {
val pair = transform(it.key, it.value)
results[pair.first] = pair.second
}
results
}
actual override fun <U> associateWith(transform: (K, V) -> U?): Map<K, U?> =
withMap { map ->
val results = LinkedHashMap<K, U?>(map.size)
map.forEach {
results[it.key] = transform(it.key, it.value)
}
results
}
actual override fun filter(
from: K,
to: K,
consumer: CacheCollectors.BiFilter<K, V>,
): List<V> = filter(consumer)
actual override fun filterIntoSet(
from: K,
to: K,
consumer: CacheCollectors.BiFilter<K, V>,
): Set<V> = filterIntoSet(consumer)
actual override fun <R> map(
from: K,
to: K,
consumer: CacheCollectors.BiNotNullMapper<K, V, R>,
): List<R> = map(consumer)
actual override fun <R> mapNotNull(
from: K,
to: K,
consumer: CacheCollectors.BiMapper<K, V, R?>,
): List<R> = mapNotNull(consumer)
actual override fun <R> mapNotNullIntoSet(
from: K,
to: K,
consumer: CacheCollectors.BiMapper<K, V, R?>,
): Set<R> = mapNotNullIntoSet(consumer)
actual override fun <R> mapFlatten(
from: K,
to: K,
consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>,
): List<R> = mapFlatten(consumer)
actual override fun <R> mapFlattenIntoSet(
from: K,
to: K,
consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>,
): Set<R> = mapFlattenIntoSet(consumer)
actual override fun maxOrNullOf(
from: K,
to: K,
filter: CacheCollectors.BiFilter<K, V>,
comparator: Comparator<V>,
): V? = maxOrNullOf(filter, comparator)
actual override fun sumOf(
from: K,
to: K,
consumer: CacheCollectors.BiSumOf<K, V>,
): Int = sumOf(consumer)
actual override fun sumOfLong(
from: K,
to: K,
consumer: CacheCollectors.BiSumOfLong<K, V>,
): Long = sumOfLong(consumer)
actual override fun <R> groupBy(
from: K,
to: K,
consumer: CacheCollectors.BiNotNullMapper<K, V, R>,
): Map<R, List<V>> = groupBy(consumer)
actual override fun <R> countByGroup(
from: K,
to: K,
consumer: CacheCollectors.BiNotNullMapper<K, V, R>,
): Map<R, Int> = countByGroup(consumer)
actual override fun <R> sumByGroup(
from: K,
to: K,
groupMap: CacheCollectors.BiNotNullMapper<K, V, R>,
sumOf: CacheCollectors.BiNotNullMapper<K, V, Long>,
): Map<R, Long> = sumByGroup(groupMap, sumOf)
actual override fun count(
from: K,
to: K,
consumer: CacheCollectors.BiFilter<K, V>,
): Int = count(consumer)
actual override fun <T, U> associate(
from: K,
to: K,
transform: (K, V) -> Pair<T, U>,
): Map<T, U> = associate(transform)
actual override fun <U> associateWith(
from: K,
to: K,
transform: (K, V) -> U?,
): Map<K, U?> = 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()
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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.")
}
}
@@ -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.")
}
}
@@ -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)
}
@@ -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()
@@ -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<Array<String>>,
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<Array<String>>,
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)
}