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/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/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/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()) + } +} 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))