From 3c8684101862eb66c8ae281efb02c0aa04b78812 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 16:21:27 +0300 Subject: [PATCH 01/10] fix: decode unreserved host octets before lowercasing in Uri normalization --- .../kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt | 2 +- .../org/dexpace/kuri/serialize/UriNormalizerTest.kt | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt index 0d15f65..b0c3a3c 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt @@ -73,7 +73,7 @@ internal object UriNormalizer { } /** Lowercases reg-name letters ([NORM-5]) then applies the shared triplet rules ([NORM-7]). */ - private fun normalizeRegName(value: String): String = normalizeText(lowercaseRegNameLetters(value)) + private fun normalizeRegName(value: String): String = lowercaseRegNameLetters(normalizeText(value)) /** Lowercases ASCII letters outside percent triplets; a triplet's hex digits are left to [NORM-6]. */ private fun lowercaseRegNameLetters(text: String): String { diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriNormalizerTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriNormalizerTest.kt index cc72fe8..6f9596c 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriNormalizerTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/serialize/UriNormalizerTest.kt @@ -44,6 +44,16 @@ internal class UriNormalizerTest { assertEquals("http://h/b", normalizedUri("http://h/a/%2e%2e/b")) } + @Test + fun `normalize decodes a percent-encoded unreserved host octet before lowercasing`() { + // NORM-5/NORM-8 order: an unreserved percent-triplet in the host must decode BEFORE the + // reg-name letters are lowercased, else a decoded uppercase letter (here "%41" -> "A") + // never gets folded. Also pins idempotency ([NORM-26]): normalizing an already-normalized + // result must not change it again. + assertEquals("http://a.com/", normalizedUri("http://%41.com/")) + assertEquals("http://a.com/", normalizedUri("http://a.com/")) + } + @Test fun `normalize elides a port equal to the scheme default`() { assertEquals("http://h/", normalizedUri("http://h:80/")) From 1ffd434373045dc4a656489394c629b3f7843138 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 16:25:19 +0300 Subject: [PATCH 02/10] docs: correct stale KDoc on normalizeRegName's execution order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KDoc on line 75 incorrectly described the old, buggy order: "lowercase then triplet rules". The fix (commit 3c86841) reordered the implementation to triplet/decode rules first, then lowercase. Update the comment to accurately reflect the new correct order: [NORM-6] uppercase triplets and [NORM-8] decode unreserved octets, followed by [NORM-5] lowercase letters. Also fix the incorrect NORM tag reference ([NORM-7] → [NORM-8]). --- .../kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt index b0c3a3c..d3e5066 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/serialize/UriNormalizer.kt @@ -72,7 +72,7 @@ internal object UriNormalizer { Host.Empty -> host } - /** Lowercases reg-name letters ([NORM-5]) then applies the shared triplet rules ([NORM-7]). */ + /** Applies the shared triplet rules ([NORM-6], [NORM-8]) then lowercases reg-name letters ([NORM-5]). */ private fun normalizeRegName(value: String): String = lowercaseRegNameLetters(normalizeText(value)) /** Lowercases ASCII letters outside percent triplets; a triplet's hex digits are left to [NORM-6]. */ From 04485c62360602eefc7b134637b50dbbb0e36f71 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 16:28:10 +0300 Subject: [PATCH 03/10] fix: reject an IPv6 double-colon that leaves no piece slot to fill RFC 3986's IPv6address ABNF has no production for eight explicit groups followed by ::. The fix adds a guard clause to finish() that fails when a :: is present with all eight piece slots already written, preventing malformed literals like [1:2:3:4:5:6:7::8] from being incorrectly accepted. --- kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt | 8 +++++++- .../commonTest/kotlin/org/dexpace/kuri/host/Ipv6Test.kt | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt index bb966bd..d7628ea 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/host/Ipv6.kt @@ -453,10 +453,16 @@ internal object Ipv6 { return seen } - /** Expands compression or enforces the exact-eight rule once scanning ends ([HOST-14]). */ + /** + * Expands compression or enforces the exact-eight rule once scanning ends ([HOST-14]). A + * `::` with no piece slot left to fill (all eight already written) is itself malformed — + * RFC 3986's `IPv6address` ABNF has no production for eight explicit groups plus `::` — so + * that case fails before [relocate] would otherwise silently no-op it. + */ private fun finish() { when { failure != null -> Unit + compress != UNSET_COMPRESS && pieceIndex == PIECE_COUNT -> fail() compress != UNSET_COMPRESS -> relocate() pieceIndex != PIECE_COUNT -> fail() else -> Unit diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/Ipv6Test.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/Ipv6Test.kt index df6f45e..7fd2202 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/Ipv6Test.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/host/Ipv6Test.kt @@ -118,6 +118,7 @@ class Ipv6Test { "1:2:3:4:5:6:7:8:9", "1::2::3", "1:2:3:4:5:6:7", + "1:2:3:4:5:6:7::8", "::1:256.255.255.255", "::1:255.255.255", "::1:01.2.3.4", From 9c37e65c4d5e4f6f139be9c660154883e85d6ef2 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 16:34:23 +0300 Subject: [PATCH 04/10] fix: record the WHATWG invalid-credentials validation error for userinfo Co-Authored-By: Claude Sonnet 5 --- .../kotlin/org/dexpace/kuri/error/ValidationError.kt | 6 ++++++ .../kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt | 1 + .../kotlin/org/dexpace/kuri/parser/UrlParserTest.kt | 7 +++++++ 3 files changed, 14 insertions(+) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/error/ValidationError.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/error/ValidationError.kt index 1e96766..4cd7168 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/error/ValidationError.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/error/ValidationError.kt @@ -36,4 +36,10 @@ public enum class ValidationError { * appeared in a component (the WHATWG *invalid-URL-unit* anomaly). */ INVALID_URL_UNIT, + + /** + * A userinfo subcomponent (`username[:password]@`) was present in the authority (the WHATWG + * *invalid-credentials* anomaly, fired once per `@` consumed while splitting userinfo). + */ + INVALID_CREDENTIALS, } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt index 28fe6d6..7497fcd 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/UrlParserAuthority.kt @@ -121,6 +121,7 @@ internal object UrlParserAuthority { state.password = if (colon >= 0) PercentCodec.encode(userinfo.substring(colon + 1), PercentEncodeSets.USERINFO) else "" state.atSignSeen = true + state.errors.add(ValidationError.INVALID_CREDENTIALS) } // --- host (§8.3 [PARSE-29]–[PARSE-31]) ------------------------------------------- diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlParserTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlParserTest.kt index c54f9cc..626f64c 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlParserTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/UrlParserTest.kt @@ -88,6 +88,13 @@ class UrlParserTest { assertEquals(Host.RegName("h"), url.host) } + @Test + fun `records an invalid-credentials validation error for userinfo`() { + val url = parsed("http://u:p@h/") + + assertTrue(ValidationError.INVALID_CREDENTIALS in url.validationErrors) + } + @Test fun `encodes a non-final at-sign in userinfo`() { val url = parsed("http://a@b@h/") From b13594e1d7acb49be79294f1dabd8bb5b07342d4 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 16:37:19 +0300 Subject: [PATCH 05/10] fix: add total toJavaUriOrNull bridges alongside the throwing toJavaUri Co-Authored-By: Claude Sonnet 5 --- .../kotlin/org/dexpace/kuri/JavaNetInterop.kt | 30 +++++++++++++++++++ .../org/dexpace/kuri/JavaNetInteropTest.kt | 15 ++++++++++ 2 files changed, 45 insertions(+) diff --git a/kuri/src/jvmMain/kotlin/org/dexpace/kuri/JavaNetInterop.kt b/kuri/src/jvmMain/kotlin/org/dexpace/kuri/JavaNetInterop.kt index e6dbb87..fa99455 100644 --- a/kuri/src/jvmMain/kotlin/org/dexpace/kuri/JavaNetInterop.kt +++ b/kuri/src/jvmMain/kotlin/org/dexpace/kuri/JavaNetInterop.kt @@ -21,6 +21,21 @@ import java.net.URL */ public fun Url.toJavaUri(): URI = URI.create(href) +/** + * Converts this [Url] to a [java.net.URI], punning the JDK's stricter-grammar rejection to `null`. + * + * The `null`-returning counterpart of [toJavaUri], for a call site that prefers a nullable value to + * an unchecked [IllegalArgumentException] from [URI.create]. + * + * @return the [URI] whose string form equals this URL's [href], or `null` when [URI.create] rejects it. + */ +public fun Url.toJavaUriOrNull(): URI? = + try { + URI.create(href) + } catch (_: IllegalArgumentException) { + null + } + /** * Converts this [Url] to a [java.net.URL] by first bridging to a [java.net.URI] and calling [URI.toURL]. * @@ -38,6 +53,21 @@ public fun Url.toJavaUrl(): URL = toJavaUri().toURL() */ public fun Uri.toJavaUri(): URI = URI.create(uriString) +/** + * Converts this [Uri] to a [java.net.URI], punning the JDK's stricter-grammar rejection to `null`. + * + * The `null`-returning counterpart of [toJavaUri], for a call site that prefers a nullable value to + * an unchecked [IllegalArgumentException] from [URI.create]. + * + * @return the [URI] whose string form equals this URI's [uriString], or `null` when [URI.create] rejects it. + */ +public fun Uri.toJavaUriOrNull(): URI? = + try { + URI.create(uriString) + } catch (_: IllegalArgumentException) { + null + } + /** * Re-parses this [java.net.URI]'s string form into a kuri [Uri] under the `Uri` profile. * diff --git a/kuri/src/jvmTest/kotlin/org/dexpace/kuri/JavaNetInteropTest.kt b/kuri/src/jvmTest/kotlin/org/dexpace/kuri/JavaNetInteropTest.kt index e20fd95..155857f 100644 --- a/kuri/src/jvmTest/kotlin/org/dexpace/kuri/JavaNetInteropTest.kt +++ b/kuri/src/jvmTest/kotlin/org/dexpace/kuri/JavaNetInteropTest.kt @@ -74,4 +74,19 @@ class JavaNetInteropTest { assertEquals("https", url.scheme) assertEquals("host", url.hostName ?: fail("expected a host name")) } + + @Test + fun `Url toJavaUriOrNull returns null instead of throwing for a WHATWG-only path character`() { + // "|" is valid verbatim in a WHATWG path but not in java.net.URI's stricter RFC 2396 grammar. + val url: Url = Url.parse("https://h/a|b").getOrThrow() + + assertEquals(null, url.toJavaUriOrNull()) + } + + @Test + fun `Uri toJavaUriOrNull returns null instead of throwing for a WHATWG-only path character`() { + val uri: Uri = Uri.parse("https://h/a|b").getOrThrow() + + assertEquals(null, uri.toJavaUriOrNull()) + } } From dcb4eb4466f5b5ff951e2ea6cf2e717065a29aa9 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 16:45:44 +0300 Subject: [PATCH 06/10] fix: make the IDNA ASCII web-compat leniency apply per label, not per domain --- .../kotlin/org/dexpace/kuri/idna/Idna.kt | 47 +++++++++++++------ .../kotlin/org/dexpace/kuri/idna/IdnTest.kt | 10 ++++ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt index e793884..92965d1 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt @@ -72,29 +72,48 @@ internal object Idna { * WHATWG "domain to ASCII" for the `Url` profile (`beStrict = false`): the URL-layer wrapper over * the pure UTS-46 [domainToAscii] (SPEC §7.4, [HOST-26]). * - * An all-ASCII [domain] is returned **lowercased verbatim**: per the standard, an ASCII domain's - * Unicode ToASCII failures are only validation errors, never fatal, for web compatibility — so an - * invalid `xn--` label such as `xn--pokxncvks` is kept as-is rather than rejected (and an - * `IgnoreInvalidPunycode` flag alone would not suffice, since Punycode can decode yet still fail a - * later validity check). A non-ASCII [domain] runs the full UTS-46 pipeline and propagates its - * failure. Either way, a result that collapses to the empty string (e.g. a lone soft hyphen, which - * maps to nothing) is a failure ("if result is the empty string, return failure"). The residual + * Decided **per label**, not per whole domain: an all-ASCII label is kept **lowercased + * verbatim**, since per the standard an ASCII label's Unicode ToASCII failure is only a + * validation error, never fatal, for web compatibility — so an invalid `xn--` label such as + * `xn--pokxncvks` is kept as-is rather than rejected, regardless of whether a sibling label + * elsewhere in the same domain happens to carry non-ASCII text. A label carrying any non-ASCII + * code point runs the full UTS-46 pipeline via [domainToAscii] and its failure is fatal. Either + * way, a result that collapses to the empty string (e.g. a lone soft hyphen, which maps to + * nothing) is a failure ("if result is the empty string, return failure"). The residual * forbidden-code-point check is applied by the host classifier, not here. * * @param domain the percent-decoded, assumed-NFC domain text. * @return [ParseResult.Ok] with the ASCII domain, or [ParseResult.Err] on a domain-to-ASCII failure. */ internal fun domainToAsciiForUrl(domain: String): ParseResult { - val result = - if (domain.isAllAscii()) { - asciiLowercase(domain) + val result = asciiLenientDomainToAscii(domain) ?: return idnaError(domain) + return if (result.isEmpty()) idnaError(domain) else ParseResult.Ok(result) + } + + /** + * Splits [domain] on [LABEL_SEPARATOR] and resolves each label independently: an all-ASCII label + * is lowercased and kept unconditionally (never validated, matching the existing intentional + * leniency — see [domainToAsciiForUrl]); a label carrying any non-ASCII code point runs the full + * [domainToAscii] pipeline, and that label's failure fails the whole domain. Splitting first and + * running NFC per label (inside [domainToAscii]) rather than over the whole domain is equivalent + * here: `U+002E` never participates in a cross-label canonical composition. + * + * @return the joined ASCII domain, or `null` on the first non-ASCII label's UTS-46 failure. + */ + private fun asciiLenientDomainToAscii(domain: String): String? { + val labels = splitLabels(domain) + val out = ArrayList(labels.size) + for (label in labels) { + if (label.isAllAscii()) { + out.add(asciiLowercase(label)) } else { - when (val ascii = domainToAscii(domain)) { - is ParseResult.Err -> return ascii - is ParseResult.Ok -> ascii.value + when (val result = domainToAscii(label)) { + is ParseResult.Ok -> out.add(result.value) + is ParseResult.Err -> return null } } - return if (result.isEmpty()) idnaError(domain) else ParseResult.Ok(result) + } + return out.joinToString(LABEL_SEPARATOR) } /** diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/idna/IdnTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/idna/IdnTest.kt index 63b7283..0894067 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/idna/IdnTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/idna/IdnTest.kt @@ -35,6 +35,16 @@ class IdnTest { assertEquals("xn--bcher-kva.example", Idn.toAscii("xn--bcher-kva.example").getOrNull()) } + @Test + fun `toAscii keeps an invalid xn-- label as-is even beside a genuine unicode label`() { + // "xn--a" is not valid Punycode (it decodes to a disallowed control code point); the + // web-compat leniency for a malformed xn-- label must hold per label, not per whole + // domain, so a non-ASCII sibling ("bücher") must not turn this label's failure fatal. + val input = "xn--a.b" + uUmlaut + "cher" + + assertEquals("xn--a.xn--bcher-kva", Idn.toAscii(input).getOrNull()) + } + @Test fun `toAscii fails on a disallowed code point`() { // U+0080 is a plain disallowed code point, so ToASCII must reject it fatally. From b69d9422a615bb613970d46cfcfd76f25e47e3b7 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 16:55:19 +0300 Subject: [PATCH 07/10] fix: guard resolved //-paths and stop throwing on an oversized path merge --- .../org/dexpace/kuri/parser/Resolver.kt | 81 +++++++++++++------ .../org/dexpace/kuri/parser/ResolverTest.kt | 25 ++++++ 2 files changed, 81 insertions(+), 25 deletions(-) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt index eaaf716..e421948 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt @@ -9,9 +9,11 @@ import org.dexpace.kuri.ZONE_ID_ENABLED import org.dexpace.kuri.carriesZoneId import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError +import org.dexpace.kuri.error.map import org.dexpace.kuri.host.serializeHost import org.dexpace.kuri.scheme.Scheme import org.dexpace.kuri.scheme.schemeColonIndex +import org.dexpace.kuri.serialize.guardRecomposedUriPath /** A generous upper bound on a path/URI length used only for defensive assertions (mirrors the parser cap). */ private const val MAX_PATH_LENGTH: Int = 8192 @@ -136,8 +138,10 @@ internal object Resolver { reference: ParsedComponents, ): ParseResult { require(base.scheme != null) { "structured resolution requires an absolute base scheme" } - val target = transformReferences(partsOf(base), partsOf(reference)) - return UriParser.parse(recompose(target), structuredOptions(base, reference)) + return when (val target = transformReferences(partsOf(base), partsOf(reference))) { + is ParseResult.Err -> target + is ParseResult.Ok -> UriParser.parse(recompose(target.value), structuredOptions(base, reference)) + } } /** Derives the round-trip [ParseOptions] for a structured resolve: a zone id on either input opts in. */ @@ -153,25 +157,29 @@ internal object Resolver { private fun transformReferences( base: UriParts, ref: UriParts, - ): UriParts { + ): ParseResult { require(base.scheme != null) { "reference resolution requires an absolute base scheme" } val resolved = when { - ref.scheme != null -> UriParts(ref.scheme, ref.authority, removeDotSegments(ref.path), ref.query, null) + ref.scheme != null -> + ParseResult.Ok(UriParts(ref.scheme, ref.authority, removeDotSegments(ref.path), ref.query, null)) else -> resolveRelative(base, ref) } - val withFragment = resolved.copy(fragment = ref.fragment) - check(withFragment.scheme != null) { "a resolved target must carry the base scheme" } - return withFragment + return resolved.map { part -> + val withFragment = part.copy(fragment = ref.fragment) + check(withFragment.scheme != null) { "a resolved target must carry the base scheme" } + withFragment + } } /** §5.2.2 with no reference scheme: a defined reference authority replaces the base authority. */ private fun resolveRelative( base: UriParts, ref: UriParts, - ): UriParts = + ): ParseResult = when { - ref.authority != null -> UriParts(base.scheme, ref.authority, removeDotSegments(ref.path), ref.query, null) + ref.authority != null -> + ParseResult.Ok(UriParts(base.scheme, ref.authority, removeDotSegments(ref.path), ref.query, null)) else -> resolveNoAuthority(base, ref) } @@ -179,20 +187,18 @@ internal object Resolver { private fun resolveNoAuthority( base: UriParts, ref: UriParts, - ): UriParts { - val (path, query) = resolvePathAndQuery(base, ref) - return UriParts(base.scheme, base.authority, path, query, null) - } + ): ParseResult = + resolvePathAndQuery(base, ref).map { (path, query) -> UriParts(base.scheme, base.authority, path, query, null) } /** §5.2.2 path/query selection: empty reference path keeps the base path (and base query if absent). */ private fun resolvePathAndQuery( base: UriParts, ref: UriParts, - ): Pair = + ): ParseResult> = when { - ref.path.isEmpty() -> Pair(base.path, ref.query ?: base.query) - ref.path.startsWith(SLASH) -> Pair(removeDotSegments(ref.path), ref.query) - else -> Pair(removeDotSegments(merge(base, ref.path)), ref.query) + ref.path.isEmpty() -> ParseResult.Ok(Pair(base.path, ref.query ?: base.query)) + ref.path.startsWith(SLASH) -> ParseResult.Ok(Pair(removeDotSegments(ref.path), ref.query)) + else -> mergedPath(base, ref.path).map { Pair(removeDotSegments(it), ref.query) } } // --- §5.2.3 Merge Paths ----------------------------------------------------------------------- @@ -220,6 +226,23 @@ internal object Resolver { return prefix + refPath } + /** + * Guards [merge]'s concatenation: the only point two independently length-bounded paths (the + * base's directory prefix and the reference path) combine into something that can exceed + * [MAX_PATH_LENGTH], since every already-parsed single path is bounded by the parser's own cap. + */ + private fun mergedPath( + base: UriParts, + refPath: String, + ): ParseResult { + val merged = merge(base, refPath) + return if (merged.length <= MAX_PATH_LENGTH) { + ParseResult.Ok(merged) + } else { + ParseResult.Err(UriParseError.InputTooLong(merged.length, MAX_PATH_LENGTH)) + } + } + // --- §5.2.4 Remove Dot Segments --------------------------------------------------------------- /** One §5.2.4 loop iteration: matches cases A–E and returns the remaining input buffer. */ @@ -272,13 +295,20 @@ internal object Resolver { // --- §5.3 Component Recomposition ------------------------------------------------------------- - /** §5.3: assembles the five resolved components back into a URI-reference string. */ + /** + * §5.3: assembles the five resolved components back into a URI-reference string, applying the + * §3.3 `/.`-guard so an authority-less path produced by dot-segment removal never re-parses as + * fabricating an authority (RFC 3986 §3.3/§5.2.2). The length invariant is a postcondition, not a + * precondition: by the time [transformReferences] returns [ParseResult.Ok], its path is already + * known to fit (the one path that can exceed [MAX_PATH_LENGTH] — [merge]'s concatenation — is + * caught earlier, in [mergedPath]), so a violation here means this class's own invariant broke. + */ private fun recompose(parts: UriParts): String { - require(parts.path.length <= MAX_PATH_LENGTH) { "resolved path exceeds the supported length bound" } + check(parts.path.length <= MAX_PATH_LENGTH) { "resolved path exceeds the supported length bound" } val sb = StringBuilder() if (parts.scheme != null) sb.append(parts.scheme).append(':') if (parts.authority != null) sb.append(DOUBLE_SLASH).append(parts.authority) - sb.append(parts.path) + sb.append(guardRecomposedUriPath(parts.scheme, parts.authority != null, parts.path)) if (parts.query != null) sb.append('?').append(parts.query) if (parts.fragment != null) sb.append('#').append(parts.fragment) check(sb.length >= parts.path.length) { "recomposition dropped path characters" } @@ -298,19 +328,20 @@ internal object Resolver { base is ParseResult.Err -> base ref is ParseResult.Err -> ref base is ParseResult.Ok && base.value.scheme == null -> ParseResult.Err(UriParseError.MissingScheme) - else -> ParseResult.Ok(resolveStrings(baseUri, reference)) + else -> resolveStrings(baseUri, reference) } /** Splits both strings into raw 5-tuples, transforms, and recomposes the target URI string. */ private fun resolveStrings( baseUri: String, reference: String, - ): String { + ): ParseResult { val baseParts = splitUri(baseUri) require(baseParts.scheme != null) { "an absolute base must carry a scheme" } - val target = transformReferences(baseParts, splitUri(reference)) - check(target.scheme != null) { "the resolved target must be absolute" } - return recompose(target) + return transformReferences(baseParts, splitUri(reference)).map { target -> + check(target.scheme != null) { "the resolved target must be absolute" } + recompose(target) + } } /** Appendix B split into the raw five parts, peeling fragment then query then the hierarchical part. */ diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt index 21449e4..0d0a1da 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt @@ -131,6 +131,31 @@ internal class ResolverTest { assertEquals(UriParseError.MissingScheme, err.error) } + @Test + fun `resolve guards a dot-segment-collapsed authority-less two-slash path`() { + // RFC 3986 §3.3: an authority-less path cannot begin with "//" (it would re-read as an + // authority). "/.//g" resolved against a no-authority base collapses via §5.2.4 case B to + // "//g", which recompose must guard with the leading "/." so the target re-parses with no + // authority and path "//g", rather than fabricating host "g". + val resolved = Resolver.resolve("a:/b/c", "/.//g").getOrThrow() + + assertEquals("a:/.//g", resolved) + } + + @Test + fun `resolve returns an error instead of throwing when the merge exceeds the length bound`() { + // Each individual input parses fine, but base's directory prefix concatenated with the + // rootless reference exceeds the resolver's defensive length bound (8192); this must + // surface as a ParseResult.Err, never an escaping exception ("total, never throws"). + val base = "a:/" + "x".repeat(5000) + "/c" + val longRef = "y".repeat(5000) + + val result = Resolver.resolve(base, longRef) + + val err = assertIs(result) + assertIs(err.error) + } + @Test fun `resolve merges a relative reference onto an empty-authority base path`() { // base authority present with an empty base path: §5.2.3 prepends a single "/" to the ref. From 2ac8889dc7a88d19c5bf36f6b650c0813bb4161d Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 17:04:25 +0300 Subject: [PATCH 08/10] chore: regenerate api dump for invalid-credentials and toJavaUriOrNull --- kuri/api/jvm/kuri.api | 3 +++ kuri/api/kuri.klib.api | 1 + 2 files changed, 4 insertions(+) diff --git a/kuri/api/jvm/kuri.api b/kuri/api/jvm/kuri.api index 99f6c5c..2f55010 100644 --- a/kuri/api/jvm/kuri.api +++ b/kuri/api/jvm/kuri.api @@ -7,6 +7,8 @@ public final class org/dexpace/kuri/Iri { public final class org/dexpace/kuri/JavaNetInteropKt { public static final fun toJavaUri (Lorg/dexpace/kuri/Uri;)Ljava/net/URI; public static final fun toJavaUri (Lorg/dexpace/kuri/Url;)Ljava/net/URI; + public static final fun toJavaUriOrNull (Lorg/dexpace/kuri/Uri;)Ljava/net/URI; + public static final fun toJavaUriOrNull (Lorg/dexpace/kuri/Url;)Ljava/net/URI; public static final fun toJavaUrl (Lorg/dexpace/kuri/Url;)Ljava/net/URL; public static final fun toKuriUri (Ljava/net/URI;)Lorg/dexpace/kuri/error/ParseResult; public static final fun toKuriUrl (Ljava/net/URI;)Lorg/dexpace/kuri/error/ParseResult; @@ -385,6 +387,7 @@ public final class org/dexpace/kuri/error/UriSyntaxException : java/lang/Illegal public final class org/dexpace/kuri/error/ValidationError : java/lang/Enum { public static final field BACKSLASH_AS_SOLIDUS Lorg/dexpace/kuri/error/ValidationError; + public static final field INVALID_CREDENTIALS Lorg/dexpace/kuri/error/ValidationError; public static final field INVALID_URL_UNIT Lorg/dexpace/kuri/error/ValidationError; public static final field LEADING_OR_TRAILING_C0_CONTROL_OR_SPACE Lorg/dexpace/kuri/error/ValidationError; public static final field MISSING_AUTHORITY_SLASHES Lorg/dexpace/kuri/error/ValidationError; diff --git a/kuri/api/kuri.klib.api b/kuri/api/kuri.klib.api index 792d805..d264fa1 100644 --- a/kuri/api/kuri.klib.api +++ b/kuri/api/kuri.klib.api @@ -25,6 +25,7 @@ final enum class org.dexpace.kuri.error/HostError : kotlin/Enum { // org.dexpace.kuri.error/ValidationError|null[0] enum entry BACKSLASH_AS_SOLIDUS // org.dexpace.kuri.error/ValidationError.BACKSLASH_AS_SOLIDUS|null[0] + enum entry INVALID_CREDENTIALS // org.dexpace.kuri.error/ValidationError.INVALID_CREDENTIALS|null[0] enum entry INVALID_URL_UNIT // org.dexpace.kuri.error/ValidationError.INVALID_URL_UNIT|null[0] enum entry LEADING_OR_TRAILING_C0_CONTROL_OR_SPACE // org.dexpace.kuri.error/ValidationError.LEADING_OR_TRAILING_C0_CONTROL_OR_SPACE|null[0] enum entry MISSING_AUTHORITY_SLASHES // org.dexpace.kuri.error/ValidationError.MISSING_AUTHORITY_SLASHES|null[0] From 304945c1ce50aff59c33a1d742e13fb71c7df93e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 18:22:14 +0300 Subject: [PATCH 09/10] docs: clarify INVALID_CREDENTIALS fires once per authority, not once per @ The KDoc read as if the validation error were recorded once for every `@` consumed while splitting userinfo, but the parser only ever adds one entry per authority parse, regardless of how many `@` characters the userinfo span contains (a non-final `@` is folded into the username instead of producing its own entry). --- .../kotlin/org/dexpace/kuri/error/ValidationError.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/error/ValidationError.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/error/ValidationError.kt index 4cd7168..433e84c 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/error/ValidationError.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/error/ValidationError.kt @@ -39,7 +39,9 @@ public enum class ValidationError { /** * A userinfo subcomponent (`username[:password]@`) was present in the authority (the WHATWG - * *invalid-credentials* anomaly, fired once per `@` consumed while splitting userinfo). + * *invalid-credentials* anomaly). Recorded once per authority parse when userinfo is present, + * even when splitting it consumes more than one `@` (a non-final `@` is folded into the + * username rather than producing its own entry). */ INVALID_CREDENTIALS, } From bb6b9f32fb46ba7599befec12891f1e24f9eff14 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 13 Jul 2026 18:22:23 +0300 Subject: [PATCH 10/10] test: cover the structured resolve merge-length-overflow error path The string-form Resolver.resolve(String, String) already had a test for the oversized-merge case returning ParseResult.Err instead of throwing, but the structured Resolver.resolve(ParsedComponents, ParsedComponents) overload shares the same transformReferences/mergedPath machinery and had no equivalent coverage. --- .../org/dexpace/kuri/parser/ResolverTest.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt index 0d0a1da..efffc06 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt @@ -275,6 +275,21 @@ internal class ResolverTest { assertEquals("http://:pass@h/a/x", UriSerializer.serialize(resolved)) } + @Test + fun `structured resolve returns an error instead of throwing when the merge exceeds the length bound`() { + // Mirrors "resolve returns an error instead of throwing when the merge exceeds the length + // bound" on the structured overload: both inputs parse fine individually, but base's + // directory prefix concatenated with the rootless reference exceeds the resolver's + // defensive length bound (8192), which the structured resolve shares via transformReferences. + val base = UriParser.parse("a:/" + "x".repeat(5000) + "/c").getOrThrow() + val reference = UriParser.parse("y".repeat(5000)).getOrThrow() + + val result = Resolver.resolve(base, reference) + + val err = assertIs(result) + assertIs(err.error) + } + private fun assertResolves(cases: List>) { for ((reference, expected) in cases) { val resolved = Resolver.resolve(BASE, reference).getOrThrow()