From 75a8e461ad5486a83a0cde94e630b6411b4eae57 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 26 Mar 2026 19:04:18 -0400 Subject: [PATCH] 20x Faster Rfc3986Normalizer and way less objects being created. --- ...rk.kt => Rfc3986UrlNormalizerBenchmark.kt} | 22 +- gradle/libs.versions.toml | 2 - quartz/build.gradle.kts | 7 - .../com/vitorpamplona/quartz/utils/Rfc3986.kt | 45 +--- .../quartz/utils/urldetector/Url.kt | 237 ++++++++++++++++-- .../urldetector/URIReferenceNormalizerTest.kt | 93 +++++++ 6 files changed, 322 insertions(+), 84 deletions(-) rename benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/{RelayUrlNormalizerBenchmark.kt => Rfc3986UrlNormalizerBenchmark.kt} (77%) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/URIReferenceNormalizerTest.kt diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RelayUrlNormalizerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Rfc3986UrlNormalizerBenchmark.kt similarity index 77% rename from benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RelayUrlNormalizerBenchmark.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Rfc3986UrlNormalizerBenchmark.kt index 087ee70ad..b501e8399 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RelayUrlNormalizerBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Rfc3986UrlNormalizerBenchmark.kt @@ -23,13 +23,11 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.utils.Rfc3986 import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector -import io.kotlingeekdev.urireference.URIReference import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import kotlin.toString /** * Benchmark, which will execute on an Android device. @@ -38,28 +36,14 @@ import kotlin.toString * result. Modify your code to see how it affects performance. */ @RunWith(AndroidJUnit4::class) -class RelayUrlNormalizerBenchmark { +class Rfc3986UrlNormalizerBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() @Test fun normalize() { benchmarkRule.measureRepeated { - "wss://relay.damus.io".normalizeRelayUrl() - } - } - - @Test - fun normalizeDirect() { - benchmarkRule.measureRepeated { - URIReference.parse("wss://nostr.mom/").normalize().toString() - } - } - - @Test - fun parseUriReference() { - benchmarkRule.measureRepeated { - URIReference.parse("wss://nostr.mom/").toString() + Rfc3986.normalize("wss://relay.damus.io") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b82ca191e..e7a0ca589 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,6 @@ torAndroid = "0.4.9.5.1" translate = "17.0.3" jetbrainsCompose = "1.10.3" unifiedpush = "3.0.10" -uriReferenceKmp = "1.0" vico-charts-compose = "3.0.3" zelory = "3.0.1" zoomable = "2.11.1" @@ -124,7 +123,6 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } -uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" } vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 5b49c924f..79badafc8 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -118,9 +118,6 @@ kotlin { // SQLite KMP driver for event store api(libs.androidx.sqlite) implementation(libs.androidx.sqlite.bundled) - - // RFC3986 library(normalizes URLs) - api(libs.uri.reference.kmp) } } @@ -170,7 +167,6 @@ kotlin { dependencies { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) - } } @@ -192,7 +188,6 @@ kotlin { // Bitcoin secp256k1 bindings to Android api(libs.secp256k1.kmp.jni.android) - } } @@ -204,7 +199,6 @@ kotlin { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) - // SQLite bundled driver for Host tests implementation(libs.androidx.sqlite.bundled.jvm) } @@ -222,7 +216,6 @@ kotlin { // Bitcoin secp256k1 bindings to Android api(libs.secp256k1.kmp.jni.android) - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt index a0bc76d7f..237674bb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt @@ -20,47 +20,16 @@ */ package com.vitorpamplona.quartz.utils -import io.kotlingeekdev.urireference.URIReference +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector object Rfc3986 { - fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString() + fun parse(url: String) = UrlDetector(url).detect()[0] - fun isValidUrl(url: String): Boolean = - runCatching { - URIReference.parse(url) - }.isSuccess + fun normalize(uri: String): String = parse(uri).fullUrl - fun normalizeAndRemoveFragment(url: String): String = - URIReference - .parse(url) - .normalize()!! - .toStringNoFragment() - .internIfPossible() + fun isValidUrl(url: String): Boolean = runCatching { parse(url) }.isSuccess - fun host(url: String): String = - URIReference - .parse(url) - .host - ?.value - .toString() -} - -fun URIReference.toStringSchemeHost(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - - return sb.toString() -} - -fun URIReference.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (host != null) sb.append("//").append(host.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() + fun normalizeAndRemoveFragment(url: String): String = parse(url).fullUrlWithoutFragment.internIfPossible() + + fun host(url: String): String = parse(url).host } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt index 46927f771..8782c16e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt @@ -98,9 +98,9 @@ class Url( if (index != -1) { _scheme = _scheme!!.substring(0, index) } + _scheme = _scheme!!.lowercase() } else if (!originalUrl.startsWith("//")) { - _scheme = - DEFAULT_SCHEME + _scheme = DEFAULT_SCHEME } } return _scheme ?: "" @@ -125,7 +125,10 @@ class Url( val host: String get() { if (this.rawHost == null) { - this.rawHost = getPart(UrlPart.HOST) + this.rawHost = + getPart(UrlPart.HOST)?.let { + lowercaseLiteralChars(normalizeComponent(it)) + } if (exists(UrlPart.PORT)) { this.rawHost = rawHost?.let { @@ -142,8 +145,7 @@ class Url( val port: Int get() { if (_port == 0) { - val portString = - getPart(UrlPart.PORT) + val portString = getPart(UrlPart.PORT) if (!portString.isNullOrEmpty()) { _port = portString.toIntOrNull() ?: -1 } else { @@ -156,14 +158,9 @@ class Url( val path: String? get() { if (this.rawPath == null) { - this.rawPath = - if (exists(UrlPart.PATH)) { - getPart( - UrlPart.PATH, - ) - } else { - "/" - } + this.rawPath = getPart(UrlPart.PATH)?.let { + normalizeComponent(removeDotSegments(it)) + } ?: "/" } return this.rawPath } @@ -171,7 +168,10 @@ class Url( val query: String get() { if (_query == null) { - _query = getPart(UrlPart.QUERY) + _query = + getPart(UrlPart.QUERY)?.let { + normalizeComponent(it) + } ?: "" } return _query ?: "" } @@ -179,7 +179,9 @@ class Url( val fragment: String get() { if (_fragment == null) { - _fragment = getPart(UrlPart.FRAGMENT) + _fragment = getPart(UrlPart.FRAGMENT)?.let { + normalizeComponent(it) + } ?: "" } return _fragment ?: "" } @@ -190,10 +192,10 @@ class Url( val usernamePasswordParts: List = usernamePassword.substring(0, usernamePassword.length - 1).split(":") if (usernamePasswordParts.size == 1) { - _username = usernamePasswordParts[0] + _username = normalizeComponent(usernamePasswordParts[0]) } else if (usernamePasswordParts.size == 2) { - _username = usernamePasswordParts[0] - _password = usernamePasswordParts[1] + _username = normalizeComponent(usernamePasswordParts[0]) + _password = normalizeComponent(usernamePasswordParts[1]) } } } @@ -242,7 +244,206 @@ class Url( } } + /** + * Removes dot segments from the given path as stated in + * ["RFC 3986, 5.2.4. Remove Dot Segments"](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4). + * + * @param path + * The path from which dot segments are to be removed. + * + * @return + * The path from which dot segments are removed. + */ + fun removeDotSegments(path: String): String { + // Initialize the input with the no-appended path components and the output + // with the empty string. + var input = path + var output = "" + + // While the input is not empty, loop the following steps. + while (input.isNotEmpty()) { + // If the input begins with a prefix of "../" or "./", then + // remove that prefix from the input; + if (DOT_DOT_SLASH.find(input) != null) { + input = DOT_DOT_SLASH.replaceFirst(input, "") + continue + } + + // If the input begins with a prefix of "/./" or "/.", where + // "." is a complete path segment, then replace that prefix + // with "/" in the input. + if (SLASH_DOT_SLASH.find(input) != null) { + input = SLASH_DOT_SLASH.replaceFirst(input, "/") + continue + } + + // If the input begins with a prefix of "/../" or "/..", + // where ".." is a complete path segment, then replace that + // prefix with "/" in the input and remove the last segment + // and its preceding "/" (if any) from the output. + if (SLASH_DOT_DOT_SLASH.find(input) != null) { + input = SLASH_DOT_DOT_SLASH.replaceFirst(input, "/") + output = dropLastSegment(output, true) + continue + } + + // If the input consists only of "." or "..", then remove + // that from the input. + if (DOT_OR_DOT_DOT.find(input) != null) { + input = DOT_OR_DOT_DOT.replaceFirst(input, "") + continue + } + + // Move the first path segment in the input buffer to the + // end of the output, including the initial "/" character + // (if any) and any subsequent characters up to, but not + // including, the next "/" character or the end of the input. + val matchResult = MOVE_REGEX.find(input) + if (matchResult != null) { + input = matchResult.groups["remaining"]!!.value + output += matchResult.groups["firstsegment"]!!.value + continue + } + } + + return output + } + + /** + * Drops the last segment (= characters after the last slash) of a path and + * optionally the last slash. If the path doesn't contain slash, an empty string + * is returned. + * + * @param path + * The path. + * + * @param dropLastSlash + * Whether or not to drop the last slash if present. + * + * @return The path from which the last segment is removed. + */ + fun dropLastSegment( + path: String, + dropLastSlash: Boolean, + ): String { + // The regular expression for the target. + val m = if (dropLastSlash) DROP_LAST_SLASH_REGEX else DROP_LAST_SEGMENT_REGEX + + // Find the target. (Any inputs matches the pattern.) + m.find(path) + + // Drop the target. + return m.replaceFirst(path, "") + } + + /** + * Unreserved characters per RFC 3986 ยง2.3: + * ALPHA / DIGIT / "-" / "." / "_" / "~" + */ + private fun isUnreserved(c: Char): Boolean = c.isLetter() || c.isDigit() || c == '-' || c == '.' || c == '_' || c == '~' + + /** + * Normalize percent-encoded triplets in a single URI component string. + * + * For each %XX triplet: + * - If the decoded byte is an unreserved ASCII character โ†’ decode it + * - Otherwise โ†’ keep encoded but uppercase the hex digits + * + * Non-ASCII bytes (e.g. UTF-8 multi-byte sequences) are left encoded + * since they cannot be unreserved characters. + */ + fun normalizeComponent(input: String): String { + val sb = StringBuilder(input.length) + var i = 0 + + while (i < input.length) { + val c = input[i] + + if (c == '%' && i + 2 < input.length) { + val hex = input.substring(i + 1, i + 3) + + // Validate that both characters are valid hex digits + if (hex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) { + val byteValue = hex.toInt(16) + + // Only consider single-byte ASCII values for potential decoding + if (byteValue < 0x80) { + val decoded = byteValue.toChar() + if (isUnreserved(decoded)) { + // Decode: replace %XX with the literal character + sb.append(decoded) + i += 3 + continue + } + } + + // Keep encoded, but uppercase the hex digits + sb.append('%') + sb.append(hex.uppercase()) + i += 3 + continue + } + } + + // Regular character โ€” pass through as-is + sb.append(c) + i++ + } + + return sb.toString() + } + + /** + * Lowercase only the literal (non-percent-encoded) characters in a string. + * Percent-encoded triplets are left untouched so their uppercased hex digits + * are not inadvertently lowercased. + */ + private fun lowercaseLiteralChars(input: String): String { + val sb = StringBuilder(input.length) + var i = 0 + while (i < input.length) { + if (input[i] == '%' && i + 2 < input.length) { + sb.append(input[i]) + sb.append(input[i + 1]) + sb.append(input[i + 2]) + i += 3 + } else { + sb.append(input[i].lowercaseChar()) + i++ + } + } + return sb.toString() + } + companion object { + val DROP_LAST_SLASH_REGEX = Regex("\\/?[^/]*$") + val DROP_LAST_SEGMENT_REGEX = Regex("[^/]*$") + + // If the input begins with a prefix of "../" or "./", then + // remove that prefix from the input; + val DOT_DOT_SLASH = Regex("^\\.?\\./") + + // If the input begins with a prefix of "/./" or "/.", where + // "." is a complete path segment, then replace that prefix + // with "/" in the input. + val SLASH_DOT_SLASH = Regex("^\\/\\.(\\/|$)") + + // If the input begins with a prefix of "/../" or "/..", + // where ".." is a complete path segment, then replace that + // prefix with "/" in the input and remove the last segment + // and its preceding "/" (if any) from the output. + val SLASH_DOT_DOT_SLASH = Regex("^\\/\\.\\.(\\/|$)") + + // If the input consists only of "." or "..", then remove + // that from the input. + val DOT_OR_DOT_DOT = Regex("^\\.?\\.$") + + // Move the first path segment in the input buffer to the + // end of the output, including the initial "/" character + // (if any) and any subsequent characters up to, but not + // including, the next "/" character or the end of the input. + val MOVE_REGEX = Regex("^(?\\/?[^/]*)(?.*)$") + private const val DEFAULT_SCHEME = "https" private val SCHEME_PORT_MAP: Map = mapOf( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/URIReferenceNormalizerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/URIReferenceNormalizerTest.kt new file mode 100644 index 000000000..ca95fd1c3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/URIReferenceNormalizerTest.kt @@ -0,0 +1,93 @@ +/* + * 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.urldetector + +import com.vitorpamplona.quartz.utils.Rfc3986 +import kotlin.test.Test +import kotlin.test.assertEquals + +class URIReferenceNormalizerTest { + @Test + fun test_normalize() { + val uriRef1 = Rfc3986.normalize("hTTp://example.com/") + assertEquals("http://example.com/", uriRef1) + + val uriRef2 = Rfc3986.normalize("http://example.com/") + assertEquals("http://example.com/", uriRef2) + + val uriRef3 = Rfc3986.normalize("http://%75ser@example.com/") + assertEquals("http://user@example.com/", uriRef3) + + val uriRef4 = Rfc3986.normalize("http://%e3%83%a6%e3%83%bc%e3%82%b6%e3%83%bc@example.com/") + assertEquals("http://%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC@example.com/", uriRef4) + + val uriRef5 = Rfc3986.normalize("http://%65%78%61%6D%70%6C%65.com/") + assertEquals("http://example.com/", uriRef5) + + val uriRef6 = Rfc3986.normalize("http://%e4%be%8b.com/") + assertEquals("http://%E4%BE%8B.com/", uriRef6) + + val uriRef7 = Rfc3986.normalize("http://LOCALhost/") + assertEquals("http://localhost/", uriRef7) + + val uriRef8 = Rfc3986.normalize("http://example.com") + assertEquals("http://example.com/", uriRef8) + + val uriRef9 = Rfc3986.normalize("http://example.com/%61/%62/%63/") + assertEquals("http://example.com/a/b/c/", uriRef9) + + val uriRef10 = Rfc3986.normalize("http://example.com/%e3%83%91%e3%82%b9/") + assertEquals("http://example.com/%E3%83%91%E3%82%B9/", uriRef10) + + val uriRef11 = Rfc3986.normalize("http://example.com/a/b/c/../d/") + assertEquals("http://example.com/a/b/d/", uriRef11) + + val uriRef12 = Rfc3986.normalize("http://example.com:80/") + assertEquals("http://example.com/", uriRef12) + } + + @Test + fun moreTests() { + // From our conversation + assertEquals("http://%E4%BE%8B.com/", Rfc3986.normalize("http://%e4%be%8b.com/")) + + // Unreserved chars that should be decoded + assertEquals("http://example.com/~user/ABC", Rfc3986.normalize("http://example.com/%7euser/%41BC")) + + // Reserved chars that must NOT be decoded + assertEquals("http://example.com/path%2Fsegment?key%3Dvalue", Rfc3986.normalize("http://example.com/path%2Fsegment?key%3Dvalue")) + + // Mixed: some decodable, some not, lowercase hex + assertEquals("http://example.com/~%2Fa%3F", Rfc3986.normalize("http://EXAMPLE.COM/%7e%2f%61%3F")) + + // Userinfo + assertEquals("http://user%40name@example.com/", Rfc3986.normalize("http://user%40name@example.com/")) + + // With port and query and fragment + assertEquals("https://example.com:8080/foo~bar?a%3D1&b%2Bc#frag%23ment", Rfc3986.normalize("https://Example.COM:8080/foo%7ebar?a%3D1&b%2Bc#frag%23ment")) + + // IPv6 + assertEquals("http://[::1]:8080/path", Rfc3986.normalize("http://[::1]:8080/path")) + + // Already normalized + assertEquals("http://example.com/~user", Rfc3986.normalize("http://example.com/~user")) + } +}