From f51b5acf944bcdf7d6abe054f62cdb196e2961da Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 7 Jul 2026 15:27:47 +0300 Subject: [PATCH 1/2] refactor: centralize UTF-16 code-point primitives in text/CodePoints.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surrogate/code-point helpers (toCodePoint, appendCodePoint, codePointsOf, charCount, codePointAt, isSurrogatePairAt) and their backing surrogate constants were copy-pasted near-verbatim across the four idna engine files (Idna, IdnaValidity, Normalizer, Punycode) and again in query/ and percent/. Kotlin's common stdlib has no code-point iteration (Character.toChars / codePointAt are JVM-only), so the shim is necessary — but duplicating the surrogate bit-math 4-5 times is a needless drift risk in logic that is easy to get subtly wrong. Extract a single internal text/CodePoints.kt and route every call site through it. IdnaValidity keeps its boxing-free IntArray scan (now calling the shared toCodePoint); Idna.NON_ASCII_MIN and Punycode.INITIAL_N stay separate (same value, different meaning). Pure refactor: output is byte-identical and the IDNA and NFC conformance suites pass unmodified. --- .../kotlin/org/dexpace/kuri/idna/Idna.kt | 68 +------------ .../org/dexpace/kuri/idna/IdnaValidity.kt | 25 +---- .../org/dexpace/kuri/idna/Normalizer.kt | 65 +------------ .../kotlin/org/dexpace/kuri/idna/Punycode.kt | 69 +------------ .../org/dexpace/kuri/percent/PercentCodec.kt | 10 +- .../kuri/query/QueryParametersBuilder.kt | 32 +----- .../org/dexpace/kuri/text/CodePoints.kt | 97 +++++++++++++++++++ 7 files changed, 112 insertions(+), 254 deletions(-) create mode 100644 kuri/src/commonMain/kotlin/org/dexpace/kuri/text/CodePoints.kt 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 b3b3bea..bbfbbac 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Idna.kt @@ -8,6 +8,9 @@ import org.dexpace.kuri.error.HostError import org.dexpace.kuri.error.ParseResult import org.dexpace.kuri.error.UriParseError import org.dexpace.kuri.error.map +import org.dexpace.kuri.text.MAX_CODE_POINT +import org.dexpace.kuri.text.appendCodePoint +import org.dexpace.kuri.text.codePointsOf /** ACE prefix marking a Punycode-encoded (`xn--`) label (RFC 5890 §2.3.2.1). */ private const val ACE_PREFIX: String = "xn--" @@ -24,27 +27,6 @@ private const val NON_ASCII_MIN: Int = 0x80 /** Distance from an uppercase ASCII letter to its lowercase counterpart (`'A'` -> `'a'`). */ private const val ASCII_CASE_OFFSET: Int = 0x20 -/** Largest Unicode scalar value (U+10FFFF). */ -private const val MAX_CODE_POINT: Int = 0x10FFFF - -/** Largest code point that fits in a single UTF-16 code unit. */ -private const val MAX_BMP_CODE_POINT: Int = 0xFFFF - -/** First code point of the supplementary planes (needs a surrogate pair). */ -private const val SUPPLEMENTARY_BASE: Int = 0x10000 - -/** Bit shift separating the high- and low-surrogate halves of a code point. */ -private const val SURROGATE_SHIFT: Int = 10 - -/** Mask isolating the low-surrogate payload bits of a supplementary code point. */ -private const val LOW_SURROGATE_MASK: Int = 0x3FF - -/** First UTF-16 high-surrogate code unit (`U+D800`). */ -private const val HIGH_SURROGATE_START: Int = 0xD800 - -/** First UTF-16 low-surrogate code unit (`U+DC00`). */ -private const val LOW_SURROGATE_START: Int = 0xDC00 - /** * UTS-46 ToASCII / ToUnicode for IDNA domains (SPEC §7.4, [HOST-26]) under the `Url`-profile * parameter set ([HOST-28]): `CheckHyphens = false`, `CheckBidi = true`, `CheckJoiners = true`, @@ -290,48 +272,4 @@ internal object Idna { buildString(s.length) { for (c in s) append(if (c in 'A'..'Z') c + ASCII_CASE_OFFSET else c) } - - /** Splits [input] into Unicode code points, combining well-formed surrogate pairs. */ - private fun codePointsOf(input: String): List { - val result = ArrayList(input.length) - var index = 0 - while (index < input.length) { - val high = input[index] - val low = if (index + 1 < input.length) input[index + 1] else null - if (high.isHighSurrogate() && low != null && low.isLowSurrogate()) { - result.add(toCodePoint(high, low)) - index += 2 - } else { - result.add(high.code) - index++ - } - } - return result - } - - /** Combines a UTF-16 surrogate pair into a single supplementary-plane code point. */ - private fun toCodePoint( - high: Char, - low: Char, - ): Int { - require(high.isHighSurrogate()) { "expected high surrogate: $high" } - require(low.isLowSurrogate()) { "expected low surrogate: $low" } - val highBits = (high.code - HIGH_SURROGATE_START) shl SURROGATE_SHIFT - return SUPPLEMENTARY_BASE + highBits + (low.code - LOW_SURROGATE_START) - } - - /** Appends [codePoint] to [out], emitting a surrogate pair for supplementary values. */ - private fun appendCodePoint( - out: StringBuilder, - codePoint: Int, - ) { - require(codePoint in 0..MAX_CODE_POINT) { "code point out of range: $codePoint" } - if (codePoint <= MAX_BMP_CODE_POINT) { - out.append(codePoint.toChar()) - } else { - val offset = codePoint - SUPPLEMENTARY_BASE - out.append((HIGH_SURROGATE_START + (offset ushr SURROGATE_SHIFT)).toChar()) - out.append((LOW_SURROGATE_START + (offset and LOW_SURROGATE_MASK)).toChar()) - } - } } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/IdnaValidity.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/IdnaValidity.kt index f3e8f9d..2677d6f 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/IdnaValidity.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/IdnaValidity.kt @@ -4,6 +4,8 @@ */ package org.dexpace.kuri.idna +import org.dexpace.kuri.text.toCodePoint + /** Record separator inside a decoded range blob (never appears inside a record). */ private const val RECORD_SEPARATOR: Char = '\n' @@ -57,18 +59,6 @@ private const val BIDI_ON: Char = 'O' /** Sentinel for a code point with no Bidi_Class the rule consults; it satisfies no condition. */ private const val BIDI_NONE: Char = '\u0000' -/** First UTF-16 high-surrogate code unit (`U+D800`). */ -private const val HIGH_SURROGATE_START: Int = 0xD800 - -/** First UTF-16 low-surrogate code unit (`U+DC00`). */ -private const val LOW_SURROGATE_START: Int = 0xDC00 - -/** Bit shift separating the high- and low-surrogate halves of a code point. */ -private const val SURROGATE_SHIFT: Int = 10 - -/** First code point of the supplementary planes (needs a surrogate pair). */ -private const val SUPPLEMENTARY_BASE: Int = 0x10000 - /** * UTS-46 label-validity rules beyond the mapping table (SPEC §7.4, [HOST-28] `CheckJoiners = true`, * `CheckBidi = true`): @@ -419,15 +409,4 @@ internal object IdnaValidity { val paired = high.isHighSurrogate() && low != null && low.isLowSurrogate() return if (paired) toCodePoint(high, requireNotNull(low)) else high.code } - - /** Combines a UTF-16 surrogate pair into a single supplementary-plane code point. */ - private fun toCodePoint( - high: Char, - low: Char, - ): Int { - require(high.isHighSurrogate()) { "expected high surrogate: $high" } - require(low.isLowSurrogate()) { "expected low surrogate: $low" } - val highBits = (high.code - HIGH_SURROGATE_START) shl SURROGATE_SHIFT - return SUPPLEMENTARY_BASE + highBits + (low.code - LOW_SURROGATE_START) - } } diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Normalizer.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Normalizer.kt index c279e59..f9b99e8 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Normalizer.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Normalizer.kt @@ -5,6 +5,9 @@ package org.dexpace.kuri.idna +import org.dexpace.kuri.text.appendCodePoint +import org.dexpace.kuri.text.codePointsOf + /** Combining class of a starter (and of all non-combining characters). */ private const val CCC_STARTER: Int = 0 @@ -29,27 +32,6 @@ private const val DECOMP_SEPARATOR: Char = ' ' /** Bit width reserved for a code point when packing a composition key (covers U+10FFFF). */ private const val CODE_POINT_BITS: Int = 21 -/** Inclusive upper bound of the Unicode code-point space (U+10FFFF). */ -private const val MAX_CODE_POINT: Int = 0x10FFFF - -/** Highest code point representable in a single UTF-16 unit. */ -private const val MAX_BMP_CODE_POINT: Int = 0xFFFF - -/** First supplementary-plane code point (the surrogate-pair base). */ -private const val SUPPLEMENTARY_BASE: Int = 0x10000 - -/** First UTF-16 high-surrogate unit. */ -private const val HIGH_SURROGATE_START: Int = 0xD800 - -/** First UTF-16 low-surrogate unit. */ -private const val LOW_SURROGATE_START: Int = 0xDC00 - -/** Bit shift separating the high- and low-surrogate halves of a supplementary code point. */ -private const val SURROGATE_SHIFT: Int = 10 - -/** Mask isolating the low-surrogate bits of a supplementary code point. */ -private const val LOW_SURROGATE_MASK: Int = 0x3FF - // Algorithmic Hangul syllable composition/decomposition parameters (UAX #15, §3.12 of the core spec). private const val S_BASE: Int = 0xAC00 private const val L_BASE: Int = 0x1100 @@ -278,32 +260,6 @@ private fun packPair( combining: Int, ): Long = (starter.toLong() shl CODE_POINT_BITS) or combining.toLong() -/** Splits [input] into Unicode scalar values, folding well-formed surrogate pairs. */ -private fun codePointsOf(input: String): List { - require(input.isNotEmpty()) { "codePointsOf requires non-empty input" } - val result = ArrayList(input.length) - var index = 0 - while (index < input.length) { - val high = input[index] - val low = if (index + 1 < input.length) input[index + 1] else null - val paired = high.isHighSurrogate() && low != null && low.isLowSurrogate() - result.add(if (paired) toCodePoint(high, low) else high.code) - index += if (paired) 2 else 1 - } - return result -} - -/** Combines a UTF-16 surrogate pair into a single supplementary-plane code point. */ -private fun toCodePoint( - high: Char, - low: Char, -): Int { - require(high.isHighSurrogate()) { "expected high surrogate: $high" } - require(low.isLowSurrogate()) { "expected low surrogate: $low" } - val highBits = (high.code - HIGH_SURROGATE_START) shl SURROGATE_SHIFT - return SUPPLEMENTARY_BASE + highBits + (low.code - LOW_SURROGATE_START) -} - /** Builds a string from a list of scalar [codePoints], emitting surrogate pairs as needed. */ private fun buildScalarString(codePoints: List): String { require(codePoints.isNotEmpty()) { "cannot build a string from no code points" } @@ -313,18 +269,3 @@ private fun buildScalarString(codePoints: List): String { } return builder.toString() } - -/** Appends [codePoint] to [out], emitting a surrogate pair for supplementary values. */ -private fun appendCodePoint( - out: StringBuilder, - codePoint: Int, -) { - require(codePoint in 0..MAX_CODE_POINT) { "code point out of range: $codePoint" } - if (codePoint <= MAX_BMP_CODE_POINT) { - out.append(codePoint.toChar()) - return - } - val offset = codePoint - SUPPLEMENTARY_BASE - out.append((HIGH_SURROGATE_START + (offset ushr SURROGATE_SHIFT)).toChar()) - out.append((LOW_SURROGATE_START + (offset and LOW_SURROGATE_MASK)).toChar()) -} diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Punycode.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Punycode.kt index 9751655..2323003 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Punycode.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/idna/Punycode.kt @@ -4,6 +4,10 @@ */ package org.dexpace.kuri.idna +import org.dexpace.kuri.text.HIGH_SURROGATE_START +import org.dexpace.kuri.text.MAX_CODE_POINT +import org.dexpace.kuri.text.appendCodePoint +import org.dexpace.kuri.text.codePointsOf import org.dexpace.kuri.text.isAsciiAlphanumeric /** Radix of the Bootstring generalized variable-length integers (RFC 3492 §5). */ @@ -33,27 +37,6 @@ private const val LETTER_DIGIT_COUNT: Int = 26 /** Delimiter separating the basic-code-point prefix from the encoded suffix. */ private const val DELIMITER: Char = '-' -/** Largest Unicode scalar value; decoding past it is malformed input. */ -private const val MAX_CODE_POINT: Int = 0x10FFFF - -/** Largest code point that fits in a single UTF-16 code unit. */ -private const val MAX_BMP_CODE_POINT: Int = 0xFFFF - -/** First code point of the supplementary planes (needs a surrogate pair). */ -private const val SUPPLEMENTARY_BASE: Int = 0x10000 - -/** Bit shift separating the high- and low-surrogate halves of a code point. */ -private const val SURROGATE_SHIFT: Int = 10 - -/** Mask isolating the low-surrogate payload bits of a supplementary code point. */ -private const val LOW_SURROGATE_MASK: Int = 0x3FF - -/** First UTF-16 high-surrogate code unit (`U+D800`). */ -private const val HIGH_SURROGATE_START: Int = 0xD800 - -/** First UTF-16 low-surrogate code unit (`U+DC00`). */ -private const val LOW_SURROGATE_START: Int = 0xDC00 - /** Last UTF-16 surrogate code unit (`U+DFFF`); the surrogate block is `U+D800..U+DFFF`. */ private const val LAST_SURROGATE: Int = 0xDFFF @@ -349,50 +332,6 @@ internal object Punycode { else -> -1 } - /** Splits [input] into Unicode code points, combining well-formed surrogate pairs. */ - private fun codePointsOf(input: String): List { - val result = ArrayList(input.length) - var index = 0 - while (index < input.length) { - val high = input[index] - val low = if (index + 1 < input.length) input[index + 1] else null - if (high.isHighSurrogate() && low != null && low.isLowSurrogate()) { - result.add(toCodePoint(high, low)) - index += 2 - } else { - result.add(high.code) - index++ - } - } - return result - } - - /** Combines a UTF-16 surrogate pair into a single supplementary-plane code point. */ - private fun toCodePoint( - high: Char, - low: Char, - ): Int { - require(high.isHighSurrogate()) { "expected high surrogate: $high" } - require(low.isLowSurrogate()) { "expected low surrogate: $low" } - val highBits = (high.code - HIGH_SURROGATE_START) shl SURROGATE_SHIFT - return SUPPLEMENTARY_BASE + highBits + (low.code - LOW_SURROGATE_START) - } - - /** Appends [codePoint] to [output], emitting a surrogate pair for supplementary values. */ - private fun appendCodePoint( - output: StringBuilder, - codePoint: Int, - ) { - require(codePoint in 0..MAX_CODE_POINT) { "code point out of range: $codePoint" } - if (codePoint <= MAX_BMP_CODE_POINT) { - output.append(codePoint.toChar()) - } else { - val offset = codePoint - SUPPLEMENTARY_BASE - output.append((HIGH_SURROGATE_START + (offset ushr SURROGATE_SHIFT)).toChar()) - output.append((LOW_SURROGATE_START + (offset and LOW_SURROGATE_MASK)).toChar()) - } - } - /** * Immutable carrier for the encoder's per-`n`-pass state (RFC 3492 §6.3). [basicCount] * is threaded through so the `first` flag of [adapt] (`handled == basicCount`) survives diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/percent/PercentCodec.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/percent/PercentCodec.kt index a2af5fd..cc773f1 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/percent/PercentCodec.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/percent/PercentCodec.kt @@ -6,6 +6,7 @@ package org.dexpace.kuri.percent import org.dexpace.kuri.text.hexDigitToInt import org.dexpace.kuri.text.isAsciiHexDigit +import org.dexpace.kuri.text.isSurrogatePairAt import org.dexpace.kuri.text.toPercentEncodedByte /** Mask isolating the low eight bits of an `Int`, turning a signed `Byte` into an octet `0..255`. */ @@ -206,15 +207,6 @@ internal object PercentCodec { return found } - /** True when a high surrogate at [i] is immediately followed by a low surrogate. */ - private fun isSurrogatePairAt( - input: String, - i: Int, - ): Boolean { - val next = i + 1 - return input[i].isHighSurrogate() && next < input.length && input[next].isLowSurrogate() - } - /** Appends the [step]-unit code point at [i] as `+` (form space) or as UTF-8 triplets. */ private fun appendEncoded( out: StringBuilder, diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/query/QueryParametersBuilder.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/query/QueryParametersBuilder.kt index 1abc712..b95c011 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/query/QueryParametersBuilder.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/query/QueryParametersBuilder.kt @@ -4,17 +4,8 @@ */ package org.dexpace.kuri.query -/** Smallest supplementary code point, `U+10000`; below it a code point fits one UTF-16 unit. */ -private const val SUPPLEMENTARY_MIN: Int = 0x10000 - -/** Base of the high-surrogate range, `U+D800`, subtracted when recomposing a surrogate pair. */ -private const val HIGH_SURROGATE_BASE: Int = 0xD800 - -/** Base of the low-surrogate range, `U+DC00`, subtracted when recomposing a surrogate pair. */ -private const val LOW_SURROGATE_BASE: Int = 0xDC00 - -/** Bit width contributed by the high surrogate when recomposing a supplementary code point. */ -private const val SURROGATE_SHIFT: Int = 10 +import org.dexpace.kuri.text.charCount +import org.dexpace.kuri.text.codePointAt /** * Mutable, ordered, duplicate-preserving accumulator that produces an immutable [QueryParameters] @@ -240,22 +231,3 @@ private fun compareByCodePoint( check(i <= left.length && j <= right.length) { "code-point scan overran a name" } return if (result != 0) result else (left.length - i) - (right.length - j) } - -/** The code point at [index], recomposing a high+low surrogate pair into one value. */ -private fun codePointAt( - text: String, - index: Int, -): Int { - val high = text[index] - val next = index + 1 - val paired = high.isHighSurrogate() && next < text.length && text[next].isLowSurrogate() - return if (paired) { - SUPPLEMENTARY_MIN + ((high.code - HIGH_SURROGATE_BASE) shl SURROGATE_SHIFT) + - (text[next].code - LOW_SURROGATE_BASE) - } else { - high.code - } -} - -/** UTF-16 unit width of [codePoint]: `2` for supplementary, `1` otherwise. */ -private fun charCount(codePoint: Int): Int = if (codePoint >= SUPPLEMENTARY_MIN) 2 else 1 diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/text/CodePoints.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/text/CodePoints.kt new file mode 100644 index 0000000..a3ecc6c --- /dev/null +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/text/CodePoints.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.text + +// UTF-16 surrogate-pair primitives the Kotlin common stdlib omits (Character.toChars / codePointAt / +// charCount are JVM-only). Every helper works on Unicode scalar values so supplementary code points +// round-trip through UTF-16. Shared by the idna, percent, and query engines. + +/** First code point of the supplementary planes (needs a surrogate pair). */ +private const val SUPPLEMENTARY_BASE: Int = 0x10000 + +/** Largest code point that fits in a single UTF-16 code unit. */ +private const val MAX_BMP_CODE_POINT: Int = 0xFFFF + +/** Largest Unicode scalar value (U+10FFFF). */ +internal const val MAX_CODE_POINT: Int = 0x10FFFF + +/** First UTF-16 high-surrogate code unit (`U+D800`). */ +internal const val HIGH_SURROGATE_START: Int = 0xD800 + +/** First UTF-16 low-surrogate code unit (`U+DC00`). */ +private const val LOW_SURROGATE_START: Int = 0xDC00 + +/** Bit shift separating the high- and low-surrogate halves of a code point. */ +private const val SURROGATE_SHIFT: Int = 10 + +/** Mask isolating the low-surrogate payload bits of a supplementary code point. */ +private const val LOW_SURROGATE_MASK: Int = 0x3FF + +/** Combines a UTF-16 surrogate pair into a single supplementary-plane code point. */ +internal fun toCodePoint( + high: Char, + low: Char, +): Int { + require(high.isHighSurrogate()) { "expected high surrogate: $high" } + require(low.isLowSurrogate()) { "expected low surrogate: $low" } + val highBits = (high.code - HIGH_SURROGATE_START) shl SURROGATE_SHIFT + return SUPPLEMENTARY_BASE + highBits + (low.code - LOW_SURROGATE_START) +} + +/** UTF-16 unit width of [codePoint]: `2` for supplementary, `1` otherwise. */ +internal fun charCount(codePoint: Int): Int = if (codePoint >= SUPPLEMENTARY_BASE) 2 else 1 + +/** True when a high surrogate at [index] is immediately followed by a low surrogate in [text]. */ +internal fun isSurrogatePairAt( + text: String, + index: Int, +): Boolean { + val next = index + 1 + return text[index].isHighSurrogate() && next < text.length && text[next].isLowSurrogate() +} + +/** The code point at [index], recomposing a high+low surrogate pair into one value. */ +internal fun codePointAt( + text: String, + index: Int, +): Int { + val high = text[index] + val next = index + 1 + val paired = high.isHighSurrogate() && next < text.length && text[next].isLowSurrogate() + return if (paired) toCodePoint(high, text[next]) else high.code +} + +/** Splits [text] into Unicode code points, combining well-formed surrogate pairs. */ +internal fun codePointsOf(text: String): List { + val result = ArrayList(text.length) + var index = 0 + while (index < text.length) { + val high = text[index] + val low = if (index + 1 < text.length) text[index + 1] else null + if (high.isHighSurrogate() && low != null && low.isLowSurrogate()) { + result.add(toCodePoint(high, low)) + index += 2 + } else { + result.add(high.code) + index++ + } + } + return result +} + +/** Appends [codePoint] to [out], emitting a surrogate pair for supplementary values. */ +internal fun appendCodePoint( + out: StringBuilder, + codePoint: Int, +) { + require(codePoint in 0..MAX_CODE_POINT) { "code point out of range: $codePoint" } + if (codePoint <= MAX_BMP_CODE_POINT) { + out.append(codePoint.toChar()) + } else { + val offset = codePoint - SUPPLEMENTARY_BASE + out.append((HIGH_SURROGATE_START + (offset ushr SURROGATE_SHIFT)).toChar()) + out.append((LOW_SURROGATE_START + (offset and LOW_SURROGATE_MASK)).toChar()) + } +} From 0f71fdd087eabb943348228686c7cef2a40128ae Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 12:47:11 +0300 Subject: [PATCH 2/2] fix: keep Uri.relativize total when the resolved candidate is invalid Uri.relativize verified its candidate by resolving it through the structured Resolver.resolve overload, which threw IllegalStateException internally when the recomposed target failed to parse. That broke relativize's documented contract that it never throws: a candidate suffix such as "/.//h:zz" is a valid rootless reference, but dot-segment removal collapses it to "//h:zz", which re-reads as an authority with a non-numeric port and fails the strict parser. Make the structured Resolver.resolve overload return ParseResult rather than raising an internal error, and have relativize fold a resolution failure (or a non-matching round-trip) to null. The candidate is still resolved from its already-parsed components, so no redundant string re-parse is added. --- .../commonMain/kotlin/org/dexpace/kuri/Uri.kt | 16 +++++++++++----- .../kotlin/org/dexpace/kuri/parser/Resolver.kt | 11 +++++------ .../kotlin/org/dexpace/kuri/UriDxTest.kt | 11 +++++++++++ .../org/dexpace/kuri/parser/ResolverTest.kt | 6 +++--- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt index f7ddebf..c60e404 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/Uri.kt @@ -298,9 +298,11 @@ public class Uri internal constructor( * The contract is enforced, not merely intended: the candidate is resolved back against this URI * and compared to [target], and returned only when it round-trips. The result is `null` when no * relative form resolves back to [target] — the two differ in [scheme] or [authority], either side - * has an opaque path (see [isOpaquePath]), [target] is not under this URI's directory, or the only + * has an opaque path (see [isOpaquePath]), [target] is not under this URI's directory, the only * candidate is a case RFC 3986 resolution would re-read (an empty reference re-inherits the base - * query; a `..`-bearing suffix), or this URI has no [scheme] (resolution requires an absolute base). + * query; a `..`-bearing suffix), the candidate's resolution does not produce a valid URI (dot-segment + * removal can yield a `//`-leading path that re-reads as an invalid authority), or this URI has no + * [scheme] (resolution requires an absolute base). * This mirrors [Url.relativize], which likewise returns `null` when * no relative form exists. This is total: it never throws. * @@ -312,9 +314,13 @@ public class Uri internal constructor( // relativize inverts resolve, which requires an absolute base; a scheme-less base has no relative // form and would trip the structured resolver's absolute-base precondition, so reject it up front. if (scheme == null) return null - // Round-trip the already-parsed candidate through the structured resolver instead of re-parsing - // its serialized string, keeping it only when the resolved value equals the target. - return relativeReference(target)?.takeIf { Uri(Resolver.resolve(components, it.components)) == target } + val candidate = relativeReference(target) + // Resolve the already-parsed candidate through the structured resolver; a candidate whose + // resolution does not yield a valid URI (dot-segment removal can produce a //-leading path that + // re-reads as an invalid authority) means no relative form exists, so fold that failure to null + // rather than letting the resolver's internal parse error surface — relativize stays total. + val resolved = candidate?.let { Resolver.resolve(components, it.components).getOrNull() } + return candidate?.takeIf { resolved != null && Uri(resolved) == target } } /** 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 20d7e15..012b898 100644 --- a/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt +++ b/kuri/src/commonMain/kotlin/org/dexpace/kuri/parser/Resolver.kt @@ -132,18 +132,17 @@ internal object Resolver { * * @param base the absolute base components; its scheme MUST be present. * @param reference the reference components to resolve. - * @return the resolved target components. + * @return [ParseResult.Ok] with the resolved target components, or [ParseResult.Err] when the + * recomposed target does not parse — e.g. dot-segment removal produced a `//`-leading, authority-less + * path that re-reads as an invalid authority. */ internal fun resolve( base: ParsedComponents, reference: ParsedComponents, - ): ParsedComponents { + ): ParseResult { require(base.scheme != null) { "structured resolution requires an absolute base scheme" } val target = transformReferences(partsOf(base), partsOf(reference)) - return when (val parsed = UriParser.parse(recompose(target), structuredOptions(base, reference))) { - is ParseResult.Ok -> parsed.value - is ParseResult.Err -> error("resolved reference is not a valid URI: ${parsed.error}") - } + return UriParser.parse(recompose(target), structuredOptions(base, reference)) } /** Derives the round-trip [ParseOptions] for a structured resolve: a zone id on either input opts in. */ diff --git a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriDxTest.kt b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriDxTest.kt index e981a25..859319e 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriDxTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/UriDxTest.kt @@ -237,6 +237,17 @@ class UriDxTest { // --- relativize --- + @Test + fun `relativize stays total when a dot-collapsed suffix reparses as an invalid authority`() { + // The candidate suffix "/.//h:zz" is a valid rootless-path reference, but resolving it removes the + // "/./" and yields "//h:zz", which reparses as an authority with a non-numeric port. That resolution + // failure must surface as "no relative form" (null), never as a thrown exception. + val base = parseOk("s:/a/b") + val target = parseOk("s:/a//.//h:zz") + + assertNull(base.relativize(target)) + } + @Test fun `relativize emits a relative path for a same-origin descendant`() { val base = parseOk("http://h/a/b/") 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 4613575..e025060 100644 --- a/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt +++ b/kuri/src/commonTest/kotlin/org/dexpace/kuri/parser/ResolverTest.kt @@ -99,7 +99,7 @@ internal class ResolverTest { val base = UriParser.parse("http://a/b/c/d").getOrThrow() val reference = UriParser.parse("mailto:x@y").getOrThrow() - val resolved = Resolver.resolve(base, reference) + val resolved = Resolver.resolve(base, reference).getOrThrow() assertEquals("mailto:x@y", Serializer.serialize(resolved, ParseProfile.URI)) } @@ -110,7 +110,7 @@ internal class ResolverTest { val base = UriParser.parse("http://h/a/b").getOrThrow() val reference = UriParser.parse("g/x").getOrThrow() - val resolved = Resolver.resolve(base, reference) + val resolved = Resolver.resolve(base, reference).getOrThrow() assertEquals("http://h/a/g/x", Serializer.serialize(resolved, ParseProfile.URI)) } @@ -121,7 +121,7 @@ internal class ResolverTest { val base = UriParser.parse("foo://[fe80::1%25eth0]/a/b", zoneOptions).getOrThrow() val reference = UriParser.parse("x", zoneOptions).getOrThrow() - val resolved = Resolver.resolve(base, reference) + val resolved = Resolver.resolve(base, reference).getOrThrow() assertEquals(Host.Ipv6(listOf(0xFE80, 0, 0, 0, 0, 0, 0, 1), zoneId = "eth0"), resolved.host) assertEquals("foo://[fe80::1%25eth0]/a/x", Serializer.serialize(resolved, ParseProfile.URI))